From 7f8cd6acb85df8b3e4864694b5344900f9a859c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Wed, 3 Jun 2026 22:23:28 +0200
Subject: [PATCH 01/75] =?UTF-8?q?docs(soft-delete):=20spec=20wykonalno?=
=?UTF-8?q?=C5=9Bci=20soft-delete=20dla=205=20typ=C3=B3w=20publikacji=20(O?=
=?UTF-8?q?D=C5=81O=C5=BBONE)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Analiza wprowadzenia soft-delete dla Wydawnictwo_Ciagle/Zwarte,
Praca_Doktorska, Praca_Habilitacyjna, Patent. Status: świadomie
odłożone — to spec/rozpoznanie, nie zlecenie implementacji.
Kluczowe ustalenia:
- choke-point w triggerze bpp_refresh_cache(): "deleted_at IS NOT NULL"
traktowany jak DELETE → wszystko czytające przez Rekord/Cache_* czyści
się jednym ruchem,
- django-soft-delete już w repo (pyproject.toml), precedens w
zglos_publikacje; domyślny manager ukrywa usunięte → kat. A czysta
za darmo, kat. B (import/dedup/PBN) musi przejść na global_objects,
- kaskada/auto-undelete pakietu zweryfikowana w kodzie: strict=True
wymaga by dzieci były SoftDeleteModel → rekomendacja Projekt A
(override delete(), dzieci nietknięte, cache/trigger robi resztę),
- slug unique → warunkowy UniqueConstraint(deleted_at__isnull=True),
- szacunek ~2-3 tygodnie; otwarte decyzje spisane.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2026-06-03-soft-delete-publikacje.md | 313 ++++++++++++++++++
1 file changed, 313 insertions(+)
create mode 100644 docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md
diff --git a/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md
new file mode 100644
index 000000000..5586b6dc5
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md
@@ -0,0 +1,313 @@
+# Spec: Soft-delete dla rekordów publikacji (Wydawnictwo_Ciagle/Zwarte, Doktorat, Habilitacja, Patent)
+
+> ⛔ **STATUS: ODŁOŻONE — na razie (2026-06-03).**
+> Ten dokument to spec/analiza wykonalności, a **NIE** zlecenie do
+> implementacji. Decyzja: świadomie wstrzymujemy realizację. Spec
+> spisany, żeby nie tracić rozpoznania; gdy wrócimy do tematu, startujemy
+> stąd. Patrz sekcja [„Dlaczego odkładamy"](#dlaczego-odkładamy).
+
+**Cel:** Umożliwić „miękkie" kasowanie 5 typów rekordów publikacji —
+zamiast fizycznego `DELETE` ustawiamy znacznik `deleted_at`, dzięki czemu
+rekord znika z
+widoku publicznego/ewaluacji/API, ale dane (w tym powiązania autor↔rekord)
+zostają i można je przywrócić.
+
+**Architektura (jednozdaniowo):** Wykorzystujemy istniejący w repo pakiet
+`django-soft-delete` (`SoftDeleteModel`) na 5 modelach źródłowych, a
+spójność z resztą systemu osiągamy w JEDNYM punkcie — w triggerze
+PostgreSQL zasilającym materializowany cache `bpp_rekord_mat`, który uczymy
+traktować „skasowany" jak zdarzenie DELETE.
+
+**Stack:** Django, PostgreSQL (triggery `plpython3u`), `django-soft-delete`
+(już w `pyproject.toml`), `django-denorm-iplweb`.
+
+---
+
+## 1. Motywacja
+
+Dziś `Wydawnictwo_Ciagle/Zwarte`, `Praca_Doktorska`, `Praca_Habilitacyjna`,
+`Patent` kasuje się fizycznie (`DELETE`). To pociąga kaskadowo:
+- usunięcie wierszy przez-modeli `*_Autor` (powiązania autorów),
+- usunięcie wpisów w `bpp_rekord_mat` / `bpp_autorzy_mat` (przez trigger),
+- utratę danych bez możliwości cofnięcia.
+
+Soft-delete daje: odzyskiwalność, ślad audytowy, oraz — co podkreślił
+użytkownik — **zachowanie powiązań `*_Autor`** nawet gdy rekord nadrzędny
+„znika" z widoków.
+
+## 2. Modele w zakresie
+
+| Model | Plik | Menedżer własny? | Through-model autorów |
+|---|---|---|---|
+| `Wydawnictwo_Ciagle` | `src/bpp/models/wydawnictwo_ciagle.py` | TAK (`Wydawnictwo_Ciagle_Manager`) | `Wydawnictwo_Ciagle_Autor` |
+| `Wydawnictwo_Zwarte` | `src/bpp/models/wydawnictwo_zwarte.py` | TAK (`Wydawnictwo_Zwarte_Manager`) | `Wydawnictwo_Zwarte_Autor` |
+| `Praca_Doktorska` | `src/bpp/models/praca_doktorska.py` | NIE | (brak; `autor` FK) |
+| `Praca_Habilitacyjna` | `src/bpp/models/praca_habilitacyjna.py` | NIE | (brak; `autor` O2O) |
+| `Patent` | `src/bpp/models/patent.py` | NIE | `Patent_Autor` |
+
+Żaden z 5 modeli **nie ma** dziś własnego `delete()` ani sygnałów
+`pre/post_delete` po stronie Pythona — cała logika kasowania siedzi w
+triggerach DB. To upraszcza warstwę ORM.
+
+## 3. Kluczowa decyzja architektoniczna — „choke-point" w triggerze
+
+`Rekord` to UNION-view (`bpp_rekord`) nad materializowaną tabelą
+`bpp_rekord_mat`, zasilaną triggerem `bpp_refresh_cache()`
+(`src/bpp/migrations/107_cache_functions.sql`). Z tej tabeli czyta
+**większość systemu**: publiczny frontend (`browse.py`), `multiseek`,
+global search (`search_index`), ewaluacja (`Cache_Punktacja_*`),
+raporty.
+
+**Fakt z kodu** (`107_cache_functions.sql:81-86`): na `DELETE` trigger
+usuwa wiersze z `bpp_rekord_mat`/`bpp_autorzy_mat`; na `UPDATE/INSERT`
+re-insertuje. Ponieważ soft-delete to technicznie `UPDATE`, **bez zmiany
+triggera skasowany rekord wróciłby do mat-view** i był widoczny wszędzie.
+
+**Rozwiązanie:** trigger uczymy reguły (kolumna w DB to `deleted_at`,
+nie boolean):
+> jeśli `NEW.deleted_at IS NOT NULL` → zachowaj się jak `DELETE`
+> (usuń wiersze z `bpp_rekord_mat` + `bpp_autorzy_mat`, **nie** re-insertuj).
+> Przywrócenie (`deleted_at: →NULL`) to zwykły UPDATE → normalny
+> re-insert.
+
+Skutek: **wszystko, co czyta przez `Rekord`/`Autorzy`/`Cache_*`, czyści
+się jednym ruchem, bez dotykania kodu konsumentów.**
+
+## 4. Odwrócenie zakresu dzięki `django-soft-delete`
+
+`SoftDeleteModel` (zweryfikowane w
+`.venv/.../django_softdelete/models.py`) udostępnia:
+- pola DB: `deleted_at`, `restored_at`, `transaction_id` (DateTime/UUID;
+ **nie ma** boolowskiego pola — `is_deleted` to *property* nad
+ `deleted_at`, filtr w ORM to `deleted_at__isnull`),
+- `objects` (`SoftDeleteManager`) — **domyślnie wyklucza** skasowane,
+- `global_objects` (`GlobalManager`) — wszystkie (z usuniętymi),
+- `deleted_objects` (`DeletedManager`) — tylko usunięte,
+- `.delete()` → soft, `.hard_delete()` → fizyczne, `.restore()` →
+ przywrócenie.
+
+Konsekwencja dla naszej wcześniejszej analizy „223 miejsc / 47 przecieków":
+
+- **Kategoria A („leak" — wyświetlanie/eksport/liczenie):** ich kod używa
+ `Model.objects` → po wpięciu `SoftDeleteModel` **stają się czyste
+ automatycznie**. Zero zmian w tych plikach. Dotyczy m.in.:
+ `api_v1` (viewsety/serializery), `admin_dashboard` (statystyki,
+ time-series), `bpp/views/autocomplete/*`, `bpp/views/browse.py`
+ (strona Źródła), `bpp/admin_site.py` (liczniki), `komparator_pbn`,
+ `ewaluacja_optymalizacja/utils.py`, `verification.py`,
+ `ranking_autorow/forms.py`.
+- **Kategoria B („wants-deleted" — MUSI widzieć usunięte):** te miejsca
+ trzeba **świadomie przełączyć** z `objects` na `global_objects`,
+ inaczej powstaną duplikaty / niespójny sync. To jest realny zakres
+ pracy. Dotyczy:
+ - `import_common/core/publikacja.py`, `importer_publikacji` — matching
+ przy imporcie (inaczej re-import odtworzy skasowaną publikację),
+ - `crossref_bpp/core.py:178,182` — dedup,
+ - `deduplikator_publikacji/tasks.py` — dedup,
+ - `pbn_integrator/utils/synchronization.py:42,69`,
+ `pbn_integrator/importer/chapters.py:64`, `pbn_api/management/*` —
+ sync z PBN (decyzja: skasowanie powinno raczej polecieć do PBN jako
+ wycofanie; matching musi widzieć usunięte po `pbn_uid`).
+
+> **Pułapka nadrzędna:** to właśnie kat. B jest groźna. Gdyby domyślny
+> menedżer ukrywał usunięte, a importer go użył — soft-delete zamienia się
+> w generator duplikatów. Audyt kat. B jest obowiązkowy.
+
+## 5. Punkty wymagające osobnej uwagi
+
+### 5.1 Własne menedżery `Wydawnictwo_*_Manager`
+Dziedziczą po `ManagerModeliZOplataZaPublikacjeMixin`
+(`src/bpp/models/abstract/fees.py`). Po wpięciu `SoftDeleteModel` muszą
+**złączyć** zachowanie soft-delete (filtr `deleted_at__isnull=True`) z
+istniejącą metodą `.rekordy_z_oplata()` / `.wydawnictwa_nadrzedne_dla_innych()`.
+Nie wolno ich nadpisać — trzeba przepleść (MRO / wspólny `QuerySet`).
+
+### 5.2 Unikalny `slug`
+Wszystkie 5 modeli ma denormalizowany `slug` z `unique=True`. Skasowany
+rekord trzyma slug zajęty → konflikt przy ponownym utworzeniu.
+Rozwiązanie: warunkowy constraint
+`UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True))`
+zamiast `unique=True`. Wymaga migracji (nie modyfikować istniejących!).
+
+### 5.3 Through-modele `*_Autor` i dane pochodne (Cache_Punktacja_*)
+Kluczowa obserwacja: `bpp_autorzy_mat`, `Cache_Punktacja_Autora`,
+`Cache_Punktacja_Dyscypliny` są **pochodne** — zasila/czyści je trigger.
+Gdy rodzic znika z `bpp_rekord_mat`, znikają i one (trigger + FK cascade
+`bpp_autorzy_mat`→`bpp_rekord_mat`, `0001_cache_init.sql:19`). Przy
+`restore` trigger re-projektuje je ze źródła. **To dzieje się automatycznie
+niezależnie od tego, czy `*_Autor` są soft-delete czy nie** — bo cache
+jest pochodny.
+
+Pozostaje pytanie o same wiersze **źródłowe** `*_Autor`: zostawić nietknięte
+(Projekt A) czy też je soft-deletować kaskadą (Projekt B). Pełna analiza i
+rekomendacja — sekcja [5.5](#55-kaskada-delete--auto-undelete--co-naprawdę-robi-pakiet).
+(Uwaga: trzeba zweryfikować, czy `Cache_Punktacja_*` faktycznie czyści ten
+sam trigger, czy osobny mechanizm przeliczania — jeśli osobny, restore może
+wymagać re-przeliczenia.)
+
+### 5.4 GenericForeignKey — sieroty
+`Publikacja_Habilitacyjna` i `Nagroda` wskazują na te modele przez
+`content_type`+`object_id`. GFK nie kaskaduje. Przy soft-delete obiekt
+fizycznie istnieje, więc GFK dalej rozwiązuje się poprawnie — to akurat
+**plus** soft-delete (mniej sierot niż przy hard-delete). Trzeba tylko
+zdecydować, czy `nagrody`/`publikacje_habilitacyjne` skasowanego rekordu
+mają być nadal pokazywane.
+
+### 5.5 Kaskada delete / auto-undelete — co NAPRAWDĘ robi pakiet
+**Zweryfikowane w kodzie** (`django_softdelete/models.py`, `delete()` +
+`restore()`):
+
+- `SoftDeleteModel.delete()` **domyślnie kaskaduje refleksją** po
+ odwrotnych relacjach (`one_to_one`, `one_to_many` = reverse FK),
+ pomijając `GenericRelation`. Każdemu skasowanemu obiektowi nadaje wspólny
+ `transaction_id`.
+- `restore()` używa `transaction_id`, żeby **automatycznie odtworzyć
+ dokładnie tę samą grupę** → „un-delete z automatu" działa.
+- **ALE** w trybie `strict=True` (domyślny) jeśli powiązany model **nie
+ jest** `SoftDeleteModel` → `SoftDeleteException`. A `strict=False` →
+ dzieci z `on_delete=CASCADE` zostają **fizycznie skasowane** (czyli
+ `*_Autor` przepadają, restore ich nie wskrzesi).
+
+**Problem dla nas:** 5 modeli ma liczne nie-soft dzieci: `*_Autor`,
+`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, `Publikacja_Habilitacyjna`,
+`Opi_2012_Tytul_Cache`. Goła kaskada pakietu **albo rzuci wyjątek
+(strict), albo twardo skasuje dzieci (non-strict)** — oba złe.
+
+**Dwa spójne projekty** (rozstrzygnąć w [otwartych decyzjach](#7-otwarte-decyzje),
+pkt 1):
+
+- **Projekt A — „cache sam to robi" (rekomendowany).**
+ Override `delete()` na 5 modelach tak, by **NIE** robił refleksyjnej
+ kaskady — tylko ustawia `deleted_at` i zapisuje. Dzieci `*_Autor`
+ zostają **nietknięte** w tabeli źródłowej. Trigger usuwa rodzica z
+ `bpp_rekord_mat` → `bpp_autorzy_mat` i `Cache_Punktacja_*` znikają
+ automatycznie (są pochodne). `restore()` = `deleted_at→NULL` → trigger
+ **re-projektuje** wszystko ze źródła (bo `*_Autor` nigdy nie zniknęły).
+ - Plusy: minimalny blast radius, brak wirusowego soft-delete, restore
+ automatyczny przez warstwę cache.
+ - Minus: bezpośrednie zapytania `Wydawnictwo_*_Autor.objects` (z
+ pominięciem rodzica) nadal widzą autorstwa skasowanych rekordów →
+ trzeba dodać `.filter(rekord__deleted_at__isnull=True)` w kilku
+ miejscach (ewaluacja `verification.py`, `komparator_pbn`).
+
+- **Projekt B — pełna kaskada soft-delete.**
+ `*_Autor` (i pozostałe dzieci, które chcemy móc przywrócić) stają się
+ `SoftDeleteModel`. Kaskada + `transaction_id` + auto-restore działają
+ „z pudełka", a bezpośrednie `*_Autor.objects` czyszczą się same.
+ - Plusy: spójne z grain pakietu, brak ręcznych filtrów na through-modelach.
+ - Minusy: efekt **wirusowy** — `*_Streszczenie`, `*_Zewnetrzna_Baza`,
+ `Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache` też muszą stać się
+ soft-delete (albo zaakceptować ich twardy CASCADE). Dużo więcej
+ migracji i pól; trzeba zweryfikować, że kaskada nie koliduje z
+ triggerem (podwójne odświeżanie).
+
+> **Rekomendacja:** Projekt A. Warstwa cache (`Rekord`/trigger) już
+> realizuje „pochodne dane znikają i wracają", więc kaskada pakietu jest
+> redundantna i tylko mnoży blast radius. Override `delete()` + kilka
+> filtrów na through-modelach.
+
+### 5.6 Self-referencja `Wydawnictwo_Zwarte → Wydawnictwo_Zwarte`
+Rozdziały wskazują na książkę-matkę (`wydawnictwo_nadrzedne`, CASCADE).
+Niezależnie od projektu z 5.5 trzeba zdecydować, czy soft-delete
+książki-matki pociąga soft-delete rozdziałów (patrz
+[otwarte pytania](#7-otwarte-decyzje), pkt 1).
+
+## 6. Szkic zakresu prac (gdy wrócimy)
+
+Kolejność (nie pełny TDD — to spec; szczegółowy plan TDD powstanie przy
+realizacji):
+
+1. **Trigger** `bpp_refresh_cache()` — nowa migracja SQL: obsługa
+ `NEW.deleted_at IS NOT NULL` jako DELETE. + testy spójności mat-view (soft-delete →
+ znika z `Rekord`; restore → wraca). **Najwrażliwszy, najpierw.**
+2. **Modele** — wpięcie `SoftDeleteModel` w 5 modeli; migracja dodająca
+ pola pakietu (`deleted_at`, `restored_at`, `transaction_id`) + indeks na
+ `deleted_at`. `is_deleted` to property — **nie** tworzyć osobnego pola.
+ Nie modyfikować istniejących migracji.
+ - Przy **Projekcie A** (rekomendacja, sekcja 5.5): override `delete()`
+ tak, by **nie** robił refleksyjnej kaskady pakietu (ustaw `deleted_at`
+ i `save()`), inaczej `strict=True` rzuci wyjątkiem na nie-soft
+ dzieciach. Zweryfikować też `restore()` (analogicznie bez kaskady).
+3. **Menedżery** — przeplecenie soft-delete z `Wydawnictwo_*_Manager`.
+4. **`slug`** — warunkowy `UniqueConstraint` (migracja).
+5. **Audyt kat. B** — przełączenie import/dedup/PBN na `global_objects`.
+6. **Admin** — akcja „przenieś do kosza" (zamiast hard-delete), filtr
+ „pokaż skasowane", akcja „przywróć". Admin świadomie używa
+ `global_objects`/`deleted_objects`.
+7. **Testy regresji** — PBN sync (duplikaty!), dashboard, import,
+ ewaluacja, API. Pełna suita (do ~10 min).
+
+## 7. Otwarte decyzje
+
+Do rozstrzygnięcia **zanim** ruszymy implementację:
+
+1. **Projekt kaskady (A vs B) — patrz [5.5](#55-kaskada-delete--auto-undelete--co-naprawdę-robi-pakiet).**
+ Rekomendacja: **Projekt A** (override `delete()`, dzieci nietknięte,
+ cache/trigger robi resztę). Do potwierdzenia. Powiązane: czy soft-delete
+ książki-matki `Wydawnictwo_Zwarte` pociąga rozdziały
+ (`wydawnictwo_nadrzedne`)? (Propozycja: NIE automatycznie; ostrzeżenie
+ w adminie.)
+2. **PBN przy skasowaniu:** czy skasowana publikacja leci do PBN jako
+ wycofanie/oświadczenie usuwające, czy tylko przestaje się
+ synchronizować?
+3. **Kto może kasować/przywracać** i czy potrzebny osobny perm
+ (`can_soft_delete` / `can_restore`).
+4. **Retencja / hard-delete:** czy po N dniach „kosz" czyści się fizycznie
+ (zadanie celery), czy zostaje na zawsze.
+5. **Widoczność `nagrody`/`publikacje_habilitacyjne`** skasowanego rekordu.
+6. Czy soft-delete dotyczy też przez-modeli `*_Autor` osobno (np. usunięcie
+ pojedynczego współautorstwa), czy tylko rekordów nadrzędnych.
+
+## 8. Szacunek nakładu
+
+Przy tej architekturze (trigger jako choke-point + istniejący
+`django-soft-delete`):
+
+| Obszar | Nakład |
+|---|---|
+| Trigger + 5 widoków + testy spójności cache | 2–3 dni |
+| Modele + menedżery + migracje (`deleted_at`, `slug` constraint) | 1–2 dni |
+| Audyt kat. B (`global_objects`) | 2–3 dni |
+| Admin (kosz/filtr/przywracanie) | 2–3 dni |
+| Testy regresji (PBN, dashboard, import, ewaluacja) | 3–5 dni |
+
+**Razem realnie ~2–3 tygodnie.** Najwięcej ryzyka: (1) trigger/cache,
+(2) duplikaty z importu/PBN przy źle zrobionym kat. B.
+
+## 9. Ryzyka
+
+- **Cache rozjedzie się**, jeśli trigger nie obsłuży `deleted_at IS NOT
+ NULL` we wszystkich 5 tabelach + ścieżce UPDATE. Najgroźniejszy,
+ wydajnościowo wrażliwy fragment.
+- **Duplikaty** z importu/PBN/dedup, jeśli kat. B nie przejdzie na
+ `global_objects`.
+- **Denorm** (`django-denorm-iplweb`) działa na `pre_save` — soft-delete
+ go bezpośrednio nie psuje, ale warto zweryfikować `cached_punkty_dyscyplin`
+ po przywróceniu rekordu.
+- Migracje dotykają 5 dużych tabel produkcyjnych — `deleted_at` domyślnie
+ `NULL` (brak backfillu), indeks na `deleted_at` zakładać `CONCURRENTLY`
+ jeśli rozmiar tego wymaga.
+
+## 10. Precedens w repo
+
+- `django-soft-delete>=1.0.23` — `pyproject.toml:122`.
+- `src/zglos_publikacje/models.py:10,61` — `Zgłoszenie_Publikacji` już
+ dziedziczy po `SoftDeleteModel`. Wzorzec do naśladowania (menedżery,
+ migracja, admin).
+
+---
+
+## Dlaczego odkładamy
+
+Świadoma decyzja z **2026-06-03**: temat jest dobrze rozpoznany i
+wykonalny (~2–3 tyg.), ale **nie wchodzi teraz w realizację**. Powody:
+inne priorytety (m.in. integracja DSpace, prace nad powiązaniami autorów).
+Spec spisany, żeby rozpoznanie nie wyparowało. Gdy wrócimy:
+
+1. rozstrzygnąć [otwarte decyzje](#7-otwarte-decyzje),
+2. zacząć od triggera (sekcja 6, krok 1) jako najwrażliwszego,
+3. dopiero potem reszta.
+
+> Niniejszy dokument NIE jest planem TDD do wykonania. Przy starcie
+> realizacji należy wygenerować szczegółowy plan implementacyjny
+> (skill `superpowers:writing-plans`) na bazie tego speca.
From 1aa3917e0a7c53d5551b6f5318c879544f9b15ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 4 Jun 2026 09:53:21 +0200
Subject: [PATCH 02/75] =?UTF-8?q?docs(soft-delete):=20projekt=20wdro=C5=BC?=
=?UTF-8?q?eniowy=20soft-delete=20publikacji=20+=20autor=C3=B3w?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Zatwierdzony design (brainstorming 2026-06-04) rozszerzający feasibility-spec
o: soft-delete autora (PROTECT z pracami / soft-delete husku bez prac),
wycofanie oświadczeń z PBN przez rozszerzenie pbn_export_queue (operacja
WYCOFANIE, async+retry), dedykowany SoftDeleteLog zasilany sygnałami pakietu,
oraz admin superuser-only (kosz/filtr/przywróć/usuń-trwale).
Kluczowe decyzje:
- asymetria: pełny SoftDeleteModel dla 5 publikacji (Projekt A, trigger jako
choke-point), ale autor soft-delete TYLKO bez prac → through-modele/doktorat
/habilitacja NIE stają się soft-delete (mały blast radius),
- flip FK autor CASCADE→PROTECT + guard w soft delete() (PROTECT nie łapie
UPDATE-owego soft-delete),
- synergia z deduplikator_autorow: husk po merge staje się odwracalny,
- PBN: wycofanie oświadczeń instytucji (delete_all_publication_statements),
obiektu publikacji nie kasujemy; restore → re-WYSYLKA,
- retencja: brak auto-czyszczenia, tylko ręczny hard-delete.
Stary 2026-06-03-soft-delete-publikacje.md oznaczony jako ZASTĄPIONY.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2026-06-03-soft-delete-publikacje.md | 12 +-
...soft-delete-publikacje-i-autorzy-design.md | 356 ++++++++++++++++++
2 files changed, 363 insertions(+), 5 deletions(-)
create mode 100644 docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
diff --git a/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md
index 5586b6dc5..0646e70c3 100644
--- a/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md
+++ b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md
@@ -1,10 +1,12 @@
# Spec: Soft-delete dla rekordów publikacji (Wydawnictwo_Ciagle/Zwarte, Doktorat, Habilitacja, Patent)
-> ⛔ **STATUS: ODŁOŻONE — na razie (2026-06-03).**
-> Ten dokument to spec/analiza wykonalności, a **NIE** zlecenie do
-> implementacji. Decyzja: świadomie wstrzymujemy realizację. Spec
-> spisany, żeby nie tracić rozpoznania; gdy wrócimy do tematu, startujemy
-> stąd. Patrz sekcja [„Dlaczego odkładamy"](#dlaczego-odkładamy).
+> 🔁 **STATUS: ZASTĄPIONY (2026-06-04).**
+> Ten dokument to wczesna analiza wykonalności (publikacje-only, „ODŁOŻONE").
+> Aktualnym, zatwierdzonym do realizacji projektem wdrożeniowym — obejmującym
+> publikacje ORAZ soft-delete autora, wycofanie z PBN przez kolejkę, tabelę-log
+> i admin — jest:
+> [`2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](2026-06-04-soft-delete-publikacje-i-autorzy-design.md).
+> Pozostawiony jako kontekst historyczny rozpoznania.
**Cel:** Umożliwić „miękkie" kasowanie 5 typów rekordów publikacji —
zamiast fizycznego `DELETE` ustawiamy znacznik `deleted_at`, dzięki czemu
diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
new file mode 100644
index 000000000..55dd9db7a
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
@@ -0,0 +1,356 @@
+# Spec: Soft-delete publikacji + autorów (jedno opracowanie wdrożeniowe)
+
+> ✅ **STATUS: DO REALIZACJI (2026-06-04).**
+> Ten dokument jest projektem wdrożeniowym (design), zatwierdzonym przez
+> użytkownika. Zastępuje feasibility-spec
+> [`2026-06-03-soft-delete-publikacje.md`](2026-06-03-soft-delete-publikacje.md)
+> (publikacje-only, „ODŁOŻONE") i rozszerza go o: soft-delete autora,
+> wycofanie z PBN przez kolejkę, tabelę-log audytu oraz wsparcie w adminie.
+> Szczegółowy plan TDD powstaje na bazie tego speca (skill
+> `superpowers:writing-plans`).
+
+**Cel.** Wprowadzić odwracalne („miękkie") kasowanie tam, gdzie ma to realny
+sens, przy minimalnym blast-radiusie:
+
+1. **Publikacje** (5 modeli) — pełny soft-delete: `DELETE` → znacznik
+ `deleted_at`; rekord znika z widoku publicznego / ewaluacji / API / PBN,
+ dane (w tym powiązania `*_Autor`) zostają i da się je przywrócić.
+2. **Autor** — soft-delete **wyłącznie dla autora bez prac** (odwracalny
+ „kosz" dla pustych/błędnych rekordów). Autor **z** pracami → `PROTECT`
+ (zero kasowania, soft ani hard).
+3. **PBN** — soft-delete publikacji wycofuje oświadczenia dyscyplin z profilu
+ instytucji, asynchronicznie przez kolejkę (`pbn_export_queue`).
+4. **Audyt** — dedykowana tabela `SoftDeleteLog` (kto / kiedy / dlaczego /
+ status PBN).
+5. **Admin** (superuser-only) — „kosz" zamiast hard-delete, filtr „pokaż
+ skasowane", akcja „przywróć", osobna jawna akcja „usuń trwale".
+
+**Stack.** Django, PostgreSQL (triggery `plpython3u`), `django-soft-delete`
+(`SoftDeleteModel`, już w `pyproject.toml`), `django-denorm-iplweb`,
+Celery + `pbn_export_queue`.
+
+---
+
+## 1. Decyzja architektoniczna nadrzędna — asymetria publikacja vs autor
+
+Połączenie obu ficzerów (soft-delete publikacji ORAZ autora) prowadzi do
+celowej **asymetrii**, która drastycznie ogranicza ryzyko:
+
+| | **Publikacje** (5 modeli) | **Autor** |
+|---|---|---|
+| Mechanizm | Pełny `SoftDeleteModel` | Soft-delete **tylko gdy brak prac** |
+| Autor/rekord z pracami | — | **PROTECT** (zero kasowania) |
+| Autor/rekord bez prac | — | Soft-delete = odwracalny husk |
+| Through-modele `*_Autor` | **Nietknięte** (Projekt A) | Nie stają się soft-delete |
+| Doktorat / habilitacja | Soft-delete (są publikacjami) | FK do autora → `PROTECT` |
+
+**Konsekwencja kluczowa:** `Wydawnictwo_*_Autor`, `Praca_Doktorska`,
+`Praca_Habilitacyjna` **NIE** stają się `SoftDeleteModel` na potrzeby
+soft-delete autora. Soft-delete autora to operacja-liść na pustych rekordach,
+więc nie dotyka materializowanych widoków, ewaluacji ani PBN. Cała ryzykowna
+robota zostaje skupiona na publikacjach.
+
+**Dlaczego nie kaskada autor→prace ani „guard z 50 publikacjami":**
+realny przypadek użycia kasowania autora jest wąski — to wyłącznie puste /
+błędne / duplikowane rekordy (literówki, dane testowe, husk po scaleniu).
+Nikt nie kasuje autora z 50 pracami („Kowalski zniknął, usuńmy go" się nie
+zdarza). Kaskada soft-delete autor+publikacje byłaby ogromną, rzadką operacją
+i kasowałaby publikacje współautorów; „guard" wymuszający ręczną edycję 50
+publikacji przed usunięciem czyni kasowanie bezużytecznym. Wąska semantyka
+„bez prac = soft-delete, z pracami = PROTECT" pokrywa 100% realnej potrzeby.
+
+---
+
+## 2. Publikacje — fundament (Projekt A)
+
+5 modeli: `Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`,
+`Praca_Habilitacyjna`, `Patent` ← `SoftDeleteModel`.
+
+### 2.1 Trigger jako choke-point (najwrażliwszy, robiony PIERWSZY)
+
+`Rekord` to UNION-view nad materializowaną tabelą `bpp_rekord_mat`, zasilaną
+triggerem `bpp_refresh_cache()`
+(**baseline: `src/bpp/migrations/0001_cache_functions.sql`** — uwaga: stary
+spec referował przed-squashowy `107_cache_functions.sql`). Z `bpp_rekord_mat`
+/ `bpp_autorzy_mat` czyta większość systemu (publiczny frontend, multiseek,
+global search, ewaluacja `Cache_Punktacja_*`, raporty).
+
+**Fakt z kodu** (`0001_cache_functions.sql:78-87`): na `DELETE` trigger usuwa
+wiersze z tabel `_mat`; na `UPDATE/INSERT` re-insertuje. Soft-delete to
+technicznie `UPDATE` → **bez zmiany triggera skasowany rekord wróciłby do
+mat-view**.
+
+**Zmiana:** ścieżka `UPDATE/INSERT` triggera uczona reguły:
+> jeśli `NEW.deleted_at IS NOT NULL` → zachowaj się jak `DELETE` (usuń z
+> `bpp_rekord_mat` + `bpp_autorzy_mat`, **nie** re-insertuj).
+> `deleted_at: →NULL` (restore) → normalny re-insert.
+
+Trzeba to obsłużyć dla **wszystkich 5 tabel źródłowych** oraz przemyśleć
+ścieżkę przez tabele autorskie (`bpp_wydawnictwo_*_autor`, `bpp_patent_autor`)
+— tam `deleted_at` siedzi na rekordzie nadrzędnym, nie na wierszu autorskim,
+więc warunek czytamy z rekordu rodzica (lub polegamy na tym, że trigger
+rodzica już wyczyścił `bpp_autorzy_mat`). Do rozstrzygnięcia w planie TDD;
+testy spójności mat-view są obowiązkowe (soft-delete → znika z `Rekord`;
+restore → wraca; brak rozjazdu `Cache_Punktacja_*`).
+
+### 2.2 Override `delete()` — bez refleksyjnej kaskady pakietu
+
+`SoftDeleteModel.delete()` domyślnie kaskaduje refleksyjnie po odwrotnych
+relacjach. W trybie `strict=True` (domyślny) rzuci `SoftDeleteException` na
+nie-soft dzieciach (`*_Autor`, `*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`,
+`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`), a `strict=False` twardo
+skasuje je przez CASCADE. **Oba złe.** Dlatego na 5 modelach nadpisujemy
+`delete()` tak, by jedynie ustawił `deleted_at` i zapisał (bez kaskady) —
+dzieci `*_Autor` zostają nietknięte, a trigger usuwa je z `bpp_autorzy_mat`
+jako pochodne. `restore()` (analogicznie bez kaskady) → trigger re-projektuje
+wszystko ze źródła. Zweryfikować, że nadpisany `delete()`/`restore()` nadal
+emituje sygnały `post_soft_delete`/`post_restore` (patrz §5).
+
+### 2.3 `slug` — warunkowy unique
+
+Wszystkie 5 modeli ma denormalizowany `slug unique=True`. Skasowany rekord
+trzyma slug → konflikt przy ponownym utworzeniu. Zamiana na
+`UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True))`.
+Migracja (NIE modyfikować istniejących migracji).
+
+### 2.4 Menedżery `Wydawnictwo_*_Manager`
+
+Dziedziczą po `ManagerModeliZOplataZaPublikacjeMixin`
+(`src/bpp/models/abstract/fees.py`). Po wpięciu `SoftDeleteModel` trzeba
+**przepleść** filtr soft-delete (`deleted_at__isnull=True`) z istniejącymi
+metodami (`rekordy_z_oplata()`, `wydawnictwa_nadrzedne_dla_innych()`) — przez
+wspólny `QuerySet`/MRO, nie przez nadpisanie.
+
+### 2.5 Audyt kategorii B — miejsca, które MUSZĄ widzieć usunięte
+
+Domyślny menedżer `objects` ukrywa usunięte → kategoria A (wyświetlanie /
+eksport / liczenie) staje się czysta automatycznie (zero zmian). Ale
+**kategoria B** musi świadomie przejść na `global_objects`, inaczej powstaną
+**duplikaty**:
+
+- `import_common/core/publikacja.py`, `importer_publikacji` — matching importu,
+- `crossref_bpp/core.py` — dedup,
+- `deduplikator_publikacji/tasks.py` — dedup,
+- `pbn_integrator/utils/synchronization.py`, `pbn_integrator/importer/chapters.py`,
+ `pbn_api/management/*` — matching po `pbn_uid`.
+
+> **Pułapka nadrzędna:** jeśli importer użyje domyślnego (ukrywającego)
+> menedżera, soft-delete staje się generatorem duplikatów. Audyt kat. B jest
+> obowiązkowy.
+
+---
+
+## 3. Autor — dwie warstwy ochrony + soft-delete husków
+
+Obecne `on_delete` (potwierdzone w kodzie):
+
+| Powiązanie | Plik | Dziś | Docelowo |
+|---|---|---|---|
+| `Wydawnictwo_*_Autor.autor` | `src/bpp/models/abstract/authors.py:22` (`CASCADE`) | hard-kasuje autorstwa | **PROTECT** |
+| `Praca_Doktorska.autor` | `src/bpp/models/praca_doktorska.py:136` (`CASCADE`) | hard-kasuje doktorat | **PROTECT** |
+| `Praca_Habilitacyjna.autor` | `src/bpp/models/praca_habilitacyjna.py:42` (`PROTECT`) | już blokuje | bez zmian |
+
+### 3.1 Warstwa 1 — flip FK `CASCADE→PROTECT`
+
+Migracja state-only (Django implementuje `on_delete` w ORM, nie jako
+constraint DB → brak zmiany schematu). Broni przed przypadkowym hard-delete
+i gołą kaskadą. Tabele atrybutów autora (jednostki, dyscypliny, funkcje,
+`Cache_Punktacja_Autora`, profil) **zostają `CASCADE`** — to nie „prace",
+mają znikać z autorem.
+
+### 3.2 Warstwa 2 — guard w soft `Autor.delete()`
+
+**Krytyczne:** `PROTECT` na FK łapie tylko hard-delete + kolektor kaskady
+Django. Soft-delete to `UPDATE deleted_at=now()` — `on_delete` **nigdy się
+nie odpala**. Dlatego `Autor.delete()` (soft) musi jawnie sprawdzić: jeśli
+autor ma JAKIEKOLWIEK autorstwo (`Wydawnictwo_Ciagle_Autor`,
+`Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) / doktorat / habilitację →
+odmowa (`ProtectedError`/`ValidationError` z czytelnym komunikatem).
+
+**Definicja „bez prac":** liczą się WSZYSTKIE wiersze, także wskazujące na
+*soft-deletowane* publikacje (najprościej i najbezpieczniej — autor jest
+„husk" dopiero gdy naprawdę nic nie wskazuje). Autor `SoftDeleteModel`; jego
+wiersze atrybutów zostają nietknięte (restore odtwarza całość).
+
+### 3.3 Synergia z `deduplikator_autorow` (merge)
+
+Merge najpierw przenosi wszystkie prace na autora głównego, potem woła
+`autor.delete()` na pustym duplikacie (`src/deduplikator_autorow/views/merge.py:155`;
+transfer through-rows w `src/deduplikator_autorow/utils/merge.py:191,284,354`).
+Skutki:
+- `PROTECT` **nie psuje** merge'a — duplikat jest już pusty w chwili `delete()`.
+- Soft-delete sprawia, że husk po scaleniu staje się **odwracalny** (dziś
+ znika bezpowrotnie) — błędne scalenie da się cofnąć. Darmowy bonus.
+- **Do zweryfikowania w planie TDD:** czy merge przenosi WSZYSTKIE typy prac
+ (ciągłe / zwarte / patent / doktorat / habilitacja) przed `delete()` —
+ inaczej guard/PROTECT zablokuje usunięcie husku.
+
+---
+
+## 4. PBN — wycofanie oświadczeń przez kolejkę
+
+### 4.1 Co i kiedy
+
+Soft-delete publikacji **z `pbn_uid`** → wycofanie **oświadczeń dyscyplin z
+profilu instytucji** (publikacja przestaje liczyć się do ewaluacji). Obiektu
+publikacji w PBN **nie ruszamy** (jest współdzielony — pełny `DELETE` mógłby
+się wywalić; wycofanie oświadczeń jest zawsze bezpieczne). Gate: jeśli rekord
+nigdy nie poszedł do PBN (`pbn_uid is None`) — nic nie robimy.
+
+Prymityw PBN istnieje:
+`src/pbn_api/client/mixins/institutions.py:87` →
+`delete_all_publication_statements(publicationId)` (+ selektywne
+`delete_publication_statement` w `:135`, retry w
+`pbn_api/client/publication_sync.py`).
+
+### 4.2 Mechanizm — rozszerzenie istniejącej `pbn_export_queue`
+
+Nie wprowadzamy nowego mechanizmu. Kolejka eksportu PBN żyje jako dedykowana
+aplikacja **`src/pbn_export_queue/`** (model `PBN_Export_Queue`: GFK
+content_type+object_id, `zamowil`, `ilosc_prob`, `zakonczono_pomyslnie`,
+`rodzaj_bledu`, klasyfikacja błędów, locking, „ponowna wysyłka", admin,
+`send_to_pbn()`).
+
+Rozszerzenie:
+- dodać pole `operacja: TextChoices(WYSYLKA, WYCOFANIE)` (default `WYSYLKA`
+ dla kompatybilności wstecznej), migracja,
+- gałąź w logice wysyłki: `WYCOFANIE` → `delete_all_publication_statements`,
+- status zapisywany jak dla wysyłki (`zakonczono_pomyslnie`, `komunikat`,
+ `ilosc_prob`) + odzwierciedlenie w `SentData` i `SoftDeleteLog`.
+
+`SentData` (`src/pbn_api/models/sentdata.py`, GFK + `pbn_uid` +
+`submitted_successfully` + `mark_as_successful`/`mark_as_failed`) trzyma stan
+PBN per-rekord — po wycofaniu oznaczamy odpowiednio.
+
+### 4.3 Restore → symetria
+
+Restore publikacji → wpis `WYSYLKA` w `pbn_export_queue` (ponowna wysyłka
+oświadczeń, dyscypliny wracają do profilu). Symetria delete↔restore.
+
+---
+
+## 5. SoftDeleteLog — dedykowany audyt (NASZ model)
+
+`django-soft-delete` **nie ma** żadnej tabeli-logu — daje tylko pola
+`deleted_at`/`restored_at`/`transaction_id` oraz **trzy sygnały**:
+`post_soft_delete`, `post_hard_delete`, `post_restore`
+(`django_softdelete/signals.py`). Audyt budujemy sami.
+
+**Model `SoftDeleteLog`:** `content_type`, `object_id` (GFK), `akcja`
+(`DELETE`/`RESTORE`/`HARD_DELETE`), `user` (kto), `timestamp`, `powod`
+(tekst), FK/link do wpisu `pbn_export_queue` + jego status. Centralny dla
+wszystkich soft-deletowalnych typów; zasila widok „Kosz"; jedno miejsce
+prawdy „co / kto / dlaczego zniknęło i czy PBN przyjął".
+
+**Zasilanie przez receivery sygnałów** (jeden punkt podpięcia dla wszystkich
+modeli — odporne na pominięcie):
+- `post_soft_delete` → `SoftDeleteLog(DELETE)` + (jeśli `pbn_uid`) wpis
+ `WYCOFANIE` w `pbn_export_queue`,
+- `post_restore` → `SoftDeleteLog(RESTORE)` + wpis `WYSYLKA`,
+- `post_hard_delete` → `SoftDeleteLog(HARD_DELETE)`.
+
+**Niuans „kto":** sygnał nie niesie użytkownika (`delete()` pakietu nie zna
+requestu). `user` wstrzykujemy jawnie z warstwy admina (akcja superusera ma
+`request.user` pod ręką — przekazujemy go do `delete(user=...)` / przez
+kontekst). Operacje systemowe (np. merge, celery) logują `user=None` lub
+konto techniczne.
+
+---
+
+## 6. Admin (superuser-only)
+
+Dla 5 modeli publikacji + `Autor`:
+- „Usuń" = **soft-delete** (kosz); „Usuń trwale" = osobna, jawnie oznaczona
+ akcja superusera (`hard_delete`),
+- filtr „Pokaż skasowane" (`deleted_objects`/`global_objects`) + akcja
+ „Przywróć",
+- pole „powód" przy kasowaniu (trafia do `SoftDeleteLog`),
+- admin świadomie używa `global_objects`/`deleted_objects` (nie domyślnego
+ ukrywającego menedżera),
+- dla `Autor`: próba soft-delete autora z pracami → czytelny komunikat
+ z guarda (§3.2).
+
+Precedens: `src/zglos_publikacje/models.py` (`Zgłoszenie_Publikacji` już jest
+`SoftDeleteModel` — wzorzec menedżerów/migracji/admina).
+
+---
+
+## 7. Retencja
+
+Brak automatycznego czyszczenia kosza. Soft-deletowane rekordy trwają do
+ręcznego „Usuń trwale" superusera. (Auto-hard-delete po N dniach — świadomie
+odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
+`src/django_bpp/settings/base.py:670`, jeśli zajdzie potrzeba.)
+
+---
+
+## 8. Kolejność prac (fazy; szczegółowy TDD → writing-plans)
+
+1. **Trigger** `bpp_refresh_cache()` — nowa migracja SQL: `deleted_at IS NOT
+ NULL` jako DELETE dla 5 tabel + ścieżka tabel autorskich; testy spójności
+ cache. **Najwrażliwsze, pierwsze.**
+2. **Publikacje** — `SoftDeleteModel` na 5 modelach, override
+ `delete()`/`restore()` (bez kaskady), migracje (`deleted_at`+indeks,
+ ew. `CONCURRENTLY`), `slug` `UniqueConstraint`, przeplecenie menedżerów.
+3. **Audyt kat. B** — przełączenie import/dedup/PBN-matching na
+ `global_objects`. Testy: re-import nie tworzy duplikatów.
+4. **Autor** — flip FK `CASCADE→PROTECT` (`*_Autor`, doktorat), guard w soft
+ `delete()`, soft-delete husku; weryfikacja merge.
+5. **PBN** — `operacja WYCOFANIE` w `pbn_export_queue` + restore→`WYSYLKA`;
+ integracja `SentData`.
+6. **SoftDeleteLog** + receivery sygnałów (`post_soft_delete`/`post_restore`/
+ `post_hard_delete`), wstrzykiwanie `user`.
+7. **Admin** — kosz / filtr / przywróć / usuń-trwale / powód (5 modeli +
+ `Autor`).
+8. **Testy regresji** — pełna suita: PBN (duplikaty + wycofanie), dashboard,
+ import, ewaluacja, merge autorów, API. Do ~10 min.
+
+---
+
+## 9. Ryzyka
+
+- **Cache/trigger** — rozjazd, jeśli `deleted_at` nie obsłużone we wszystkich
+ 5 tabelach + ścieżce UPDATE + tabelach autorskich. Najgroźniejsze,
+ wydajnościowo wrażliwe. Mitygacja: testy spójności jako pierwsze.
+- **Duplikaty** z importu/PBN/dedup, jeśli kat. B nie przejdzie na
+ `global_objects`.
+- **Merge autorów** — jeśli nie przenosi wszystkich typów prac przed
+ `delete()`, PROTECT/guard zablokuje. Zweryfikować.
+- **`user` w sygnałach** — łatwo zalogować `None`; zadbać o wstrzyknięcie
+ z admina.
+- **Denorm** (`django-denorm-iplweb`, `pre_save`) — soft-delete go wprost nie
+ psuje, ale zweryfikować `cached_punkty_dyscyplin` po restore.
+- **Migracje na dużych tabelach produkcyjnych** — `deleted_at` domyślnie
+ `NULL` (bez backfillu), indeks `CONCURRENTLY` jeśli rozmiar wymaga.
+
+---
+
+## 10. Decyzje rozstrzygnięte (z brainstormingu 2026-06-04)
+
+1. **Autor:** z pracami → `PROTECT`; bez prac → soft-delete (husk). Through-
+ modele/doktorat/habilitacja **nie** stają się `SoftDeleteModel`.
+2. **Publikacje:** Projekt A (override `delete()`, trigger jako choke-point;
+ dzieci nietknięte).
+3. **PBN przy soft-delete:** wycofanie oświadczeń instytucji
+ (`delete_all_publication_statements`), gate na `pbn_uid`; obiektu
+ publikacji nie kasujemy.
+4. **PBN — mechanizm:** rozszerzenie `pbn_export_queue` o operację
+ `WYCOFANIE` (async, retry, admin — istniejąca infra).
+5. **Restore → PBN:** auto-zakolejkowanie `WYSYLKA`.
+6. **Log:** dedykowany `SoftDeleteLog` zasilany sygnałami pakietu.
+7. **Admin:** superuser-only; soft-delete zastępuje „usuń"; hard-delete jako
+ osobna jawna akcja.
+8. **Retencja:** brak auto-czyszczenia; tylko ręczny hard-delete.
+
+---
+
+## 11. Precedensy w repo
+
+- `django-soft-delete>=1.0.23` — `pyproject.toml`.
+- `src/zglos_publikacje/models.py` — `Zgłoszenie_Publikacji` już
+ `SoftDeleteModel` (wzorzec).
+- `src/pbn_export_queue/` — dojrzała kolejka PBN (model + Celery + admin +
+ retry/lock), wzorzec dla operacji `WYCOFANIE`.
+- `src/pbn_api/models/sentdata.py` — `SentData` (stan PBN per-rekord).
+- `src/bpp/models/oplaty_log.py`, log w `deduplikator_autorow` — precedensy
+ tabel-logów.
From 38c0bf32d3c7fb744a9fdfb4dc6b9e50193b0d68 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 4 Jun 2026 10:26:34 +0200
Subject: [PATCH 03/75] =?UTF-8?q?docs(soft-delete):=20w=C4=85ska=20kaskada?=
=?UTF-8?q?=20soft-delete=20na=20*=5FAutor=20+=20uproszczony=20trigger?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Aktualizacja designu po decyzji użytkownika:
- 3 through-modele (Wydawnictwo_Ciagle/Zwarte_Autor, Patent_Autor) stają się
SoftDeleteModel jako cel WĄSKIEJ kaskady z soft-delete publikacji (wspólny
transaction_id), nie pełnego refleksyjnego Projektu B — pozostałe dzieci
(*_Streszczenie itd.) nietknięte, kaskada niewirusowa,
- powód: 90 bezpośrednich zapytań *_Autor.objects (głównie ewaluacja_
optymalizacja) staje się poprawnych z domyślnego menedżera — eliminuje
ryzyko silent-leak skasowanej pracy do ewaluacji,
- trigger UPROSZCZONY: wszystkie 8 tabel pod triggerem mają własne deleted_at
→ reguła "deleted_at IS NOT NULL → DELETE" jednolita, BEZ JOIN do rodzica;
widoki źródłowe filtrują po własnej kolumnie,
- guard autora MUSI liczyć przez global_objects (kaskadowo-skasowane
autorstwa są ukryte przed default objects) — inaczej autor z pracami
tylko-w-koszu przeszedłby przez guard.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
...soft-delete-publikacje-i-autorzy-design.md | 167 +++++++++++++-----
1 file changed, 127 insertions(+), 40 deletions(-)
diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
index 55dd9db7a..3820a915c 100644
--- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
+++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
@@ -41,14 +41,24 @@ celowej **asymetrii**, która drastycznie ogranicza ryzyko:
| Mechanizm | Pełny `SoftDeleteModel` | Soft-delete **tylko gdy brak prac** |
| Autor/rekord z pracami | — | **PROTECT** (zero kasowania) |
| Autor/rekord bez prac | — | Soft-delete = odwracalny husk |
-| Through-modele `*_Autor` | **Nietknięte** (Projekt A) | Nie stają się soft-delete |
+| Through-modele `*_Autor` | `SoftDeleteModel`, **wąska kaskada** z rodzica | nie kaskadują od autora |
| Doktorat / habilitacja | Soft-delete (są publikacjami) | FK do autora → `PROTECT` |
-**Konsekwencja kluczowa:** `Wydawnictwo_*_Autor`, `Praca_Doktorska`,
-`Praca_Habilitacyjna` **NIE** stają się `SoftDeleteModel` na potrzeby
-soft-delete autora. Soft-delete autora to operacja-liść na pustych rekordach,
-więc nie dotyka materializowanych widoków, ewaluacji ani PBN. Cała ryzykowna
-robota zostaje skupiona na publikacjach.
+**Konsekwencja kluczowa (autor):** soft-delete **autora** to operacja-liść na
+pustych rekordach — autor z jakimkolwiek autorstwem/doktoratem/habilitacją jest
+`PROTECT` (§3), więc usunięcie autora nigdy nie dotyka materializowanych
+widoków, ewaluacji ani PBN. Soft-delete autora **nie kaskaduje** do `*_Autor`.
+
+**Konsekwencja kluczowa (publikacja):** through-modele `Wydawnictwo_*_Autor`
+i `Patent_Autor` **stają się `SoftDeleteModel`** — ale wyłącznie jako cel
+**wąskiej kaskady** z soft-delete publikacji (§2.2), NIE pełnego refleksyjnego
+Projektu B. Pozostałe dzieci (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`,
+`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`) zostają nie-soft —
+kaskada zatrzymuje się na `*_Autor` i **nie jest wirusowa**. Powód: 90
+bezpośrednich zapytań `*_Autor.objects` w kodzie (większość w
+`ewaluacja_optymalizacja` — najwrażliwszy korekcyjnie podsystem) — domyślny
+menedżer `objects` po wpięciu `SoftDeleteModel` czyni je poprawnymi
+automatycznie, eliminując 90-punktowe ryzyko „silent leak" do ewaluacji.
**Dlaczego nie kaskada autor→prace ani „guard z 50 publikacjami":**
realny przypadek użycia kasowania autora jest wąski — to wyłącznie puste /
@@ -61,10 +71,12 @@ publikacji przed usunięciem czyni kasowanie bezużytecznym. Wąska semantyka
---
-## 2. Publikacje — fundament (Projekt A)
+## 2. Publikacje — fundament (Projekt A + wąska kaskada na `*_Autor`)
5 modeli: `Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`,
-`Praca_Habilitacyjna`, `Patent` ← `SoftDeleteModel`.
+`Praca_Habilitacyjna`, `Patent` ← `SoftDeleteModel`. Dodatkowo 3 through-modele
+`Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` ←
+`SoftDeleteModel` (cel wąskiej kaskady z rodzica, §2.2).
### 2.1 Trigger jako choke-point (najwrażliwszy, robiony PIERWSZY)
@@ -81,30 +93,66 @@ technicznie `UPDATE` → **bez zmiany triggera skasowany rekord wróciłby do
mat-view**.
**Zmiana:** ścieżka `UPDATE/INSERT` triggera uczona reguły:
-> jeśli `NEW.deleted_at IS NOT NULL` → zachowaj się jak `DELETE` (usuń z
-> `bpp_rekord_mat` + `bpp_autorzy_mat`, **nie** re-insertuj).
+> jeśli `TD['new']['deleted_at'] IS NOT NULL` → zachowaj się jak `DELETE`
+> (usuń z `_mat`, **nie** re-insertuj).
> `deleted_at: →NULL` (restore) → normalny re-insert.
-Trzeba to obsłużyć dla **wszystkich 5 tabel źródłowych** oraz przemyśleć
-ścieżkę przez tabele autorskie (`bpp_wydawnictwo_*_autor`, `bpp_patent_autor`)
-— tam `deleted_at` siedzi na rekordzie nadrzędnym, nie na wierszu autorskim,
-więc warunek czytamy z rekordu rodzica (lub polegamy na tym, że trigger
-rodzica już wyczyścił `bpp_autorzy_mat`). Do rozstrzygnięcia w planie TDD;
-testy spójności mat-view są obowiązkowe (soft-delete → znika z `Rekord`;
-restore → wraca; brak rozjazdu `Cache_Punktacja_*`).
+**Jednolitość dzięki wąskiej kaskadzie na `*_Autor` (§2.2).** Ponieważ
+through-modele też stają się `SoftDeleteModel`, **każda z 8 tabel pod
+triggerem ma własną kolumnę `deleted_at`** (5 publikacji + 3 `*_autor`).
+Trigger czyta `deleted_at` z **własnego** wiersza (`TD['new']`) — reguła
+działa identycznie niezależnie od tego, czy zadziałała tabela publikacji czy
+tabela autorska. **Nie ma potrzeby JOIN-a/lookupu do rekordu nadrzędnego.**
-### 2.2 Override `delete()` — bez refleksyjnej kaskady pakietu
-
-`SoftDeleteModel.delete()` domyślnie kaskaduje refleksyjnie po odwrotnych
-relacjach. W trybie `strict=True` (domyślny) rzuci `SoftDeleteException` na
-nie-soft dzieciach (`*_Autor`, `*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`,
+Skutki:
+- **Funkcja triggera:** jedna zmiana w gałęzi `UPDATE/INSERT` —
+ `if TD['new'].get('deleted_at') is not None: `.
+- **Widoki źródłowe:** filtr po **własnej** kolumnie tabeli, bez JOIN —
+ `bpp_*_autorzy` (selektują `FROM bpp_*_autor`,
+ `src/bpp/migrations/0001_widoki_autorzy.sql`) dostają `WHERE deleted_at IS
+ NULL` na kolumnie tabeli autorskiej; `bpp_rekord` analogicznie na kolumnie
+ tabeli publikacji.
+- **Przypadek brzegowy znika strukturalnie:** edycja wiersza autorstwa
+ skasowanej publikacji nie wskrzesi go w `bpp_autorzy_mat`, bo ten wiersz
+ ma własne `deleted_at` (ustawione kaskadą) → trigger traktuje go jak DELETE.
+- **Koszt:** soft-delete publikacji z N autorami odpala N dodatkowych (no-op)
+ triggerów through — usuwają z `bpp_autorzy_mat` to, co trigger rodzica już
+ usunął. Pomijalne.
+
+Testy spójności mat-view obowiązkowe (soft-delete → znika z `Rekord` i
+`Autorzy`; restore → wraca; brak rozjazdu `Cache_Punktacja_*`).
+
+### 2.2 Override `delete()` — wąska, kontrolowana kaskada na `*_Autor`
+
+`SoftDeleteModel.delete()` domyślnie kaskaduje **refleksyjnie** po wszystkich
+odwrotnych relacjach. W `strict=True` (domyślny) rzuci `SoftDeleteException`
+na nie-soft dzieciach (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`,
`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`), a `strict=False` twardo
skasuje je przez CASCADE. **Oba złe.** Dlatego na 5 modelach nadpisujemy
-`delete()` tak, by jedynie ustawił `deleted_at` i zapisał (bez kaskady) —
-dzieci `*_Autor` zostają nietknięte, a trigger usuwa je z `bpp_autorzy_mat`
-jako pochodne. `restore()` (analogicznie bez kaskady) → trigger re-projektuje
-wszystko ze źródła. Zweryfikować, że nadpisany `delete()`/`restore()` nadal
-emituje sygnały `post_soft_delete`/`post_restore` (patrz §5).
+`delete()` tak, by **NIE** używał refleksyjnej kaskady pakietu, lecz:
+
+1. ustawił własne `deleted_at` i zapisał,
+2. **jawnie soft-deletował własne wiersze `*_Autor`** (`Wydawnictwo_Ciagle_Autor`
+ / `Wydawnictwo_Zwarte_Autor` / `Patent_Autor`) pod **wspólnym
+ `transaction_id`** — kaskada wąska, kontrolowana, zatrzymana na `*_Autor`.
+
+Pozostałe dzieci (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`,
+`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`) **nie są ruszane** (nie są
+`SoftDeleteModel`, czyta się je przez rodzica). `restore()` analogicznie:
+przywraca rodzica i jego `*_Autor` po `transaction_id`. Trigger (§2.1) usuwa
+wszystko z `_mat` na podstawie własnych `deleted_at`; przy restore
+re-projektuje ze źródła.
+
+Po co jawna kaskada na `*_Autor`, skoro trigger i tak czyści `bpp_autorzy_mat`?
+Bo **90 miejsc w kodzie czyta `*_Autor.objects` bezpośrednio** (z pominięciem
+cache), głównie w `ewaluacja_optymalizacja`. Domyślny menedżer `objects`
+`SoftDeleteModel` ukrywa skasowane → te 90 miejsc staje się poprawne
+automatycznie, bez ręcznych filtrów `wydawnictwo_ciagle__deleted_at__isnull`
+(których pominięcie = po cichu zliczona skasowana praca w ewaluacji).
+
+Zweryfikować, że nadpisany `delete()`/`restore()` nadal emituje sygnały
+`post_soft_delete`/`post_restore` (patrz §5), oraz że ścieżka queryset
+(`.delete()` na QS) również kaskaduje na `*_Autor`.
### 2.3 `slug` — warunkowy unique
@@ -138,6 +186,17 @@ eksport / liczenie) staje się czysta automatycznie (zero zmian). Ale
> menedżera, soft-delete staje się generatorem duplikatów. Audyt kat. B jest
> obowiązkowy.
+**Through-modele `*_Autor` (90 miejsc).** Po wpięciu `SoftDeleteModel`
+90 bezpośrednich zapytań `*_Autor.objects` (głównie `ewaluacja_optymalizacja`:
+`reset_pins`, `reset_disciplines`, `unpin_all_sensible`, `optimization`,
+`author_works`, `evaluation_browser`, `verification`; oraz `api_v1`,
+`przemapuj_prace_autora`, `ewaluacja_dwudyscyplinowcy`) **staje się poprawne
+domyślnie** (pomijają skasowane). Audyt sprawdza wyjątki kat. B: czy
+któreś z nich *musi* widzieć skasowane autorstwa (mało prawdopodobne w
+ewaluacji — tam „pomiń skasowane" jest poprawnym defaultem) → wtedy
+`global_objects`. Domyślny default „pomijaj" jest tu znacznie bezpieczniejszy
+niż przeciwny.
+
---
## 3. Autor — dwie warstwy ochrony + soft-delete husków
@@ -172,6 +231,17 @@ odmowa (`ProtectedError`/`ValidationError` z czytelnym komunikatem).
„husk" dopiero gdy naprawdę nic nie wskazuje). Autor `SoftDeleteModel`; jego
wiersze atrybutów zostają nietknięte (restore odtwarza całość).
+> **Interakcja z kaskadą §2.2 (krytyczne!):** `*_Autor` są teraz
+> `SoftDeleteModel`, a soft-delete publikacji kaskadowo soft-deletuje ich
+> wiersze. Domyślny `*_Autor.objects` **ukrywa** te skasowane autorstwa.
+> Gdyby guard użył `objects`, autor, którego wszystkie prace są w koszu,
+> wyglądałby na „pustego" i przeszedłby przez guard — łamiąc decyzję „licz
+> wszystko, też kosz". **Guard musi liczyć przez `*_Autor.global_objects`**
+> (i analogicznie doktorat/habilitację przez `global_objects`), żeby widzieć
+> również kaskadowo-skasowane autorstwa. To samo dotyczy FK `PROTECT`:
+> chroni przed hard-delete niezależnie od `deleted_at` (constraint DB widzi
+> wiersz fizyczny).
+
### 3.3 Synergia z `deduplikator_autorow` (merge)
Merge najpierw przenosi wszystkie prace na autora głównego, potem woła
@@ -286,16 +356,24 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
## 8. Kolejność prac (fazy; szczegółowy TDD → writing-plans)
-1. **Trigger** `bpp_refresh_cache()` — nowa migracja SQL: `deleted_at IS NOT
- NULL` jako DELETE dla 5 tabel + ścieżka tabel autorskich; testy spójności
- cache. **Najwrażliwsze, pierwsze.**
+1. **`*_Autor` + trigger + widoki** — `SoftDeleteModel` na 3 through-modelach
+ (`deleted_at`+indeks); funkcja `bpp_refresh_cache()` ucząca się reguły
+ `deleted_at IS NOT NULL → DELETE` (jednolicie dla wszystkich 8 tabel,
+ bez JOIN do rodzica); filtr `deleted_at IS NULL` w widokach źródłowych
+ `bpp_rekord`/`bpp_*_autorzy` (po własnej kolumnie). Testy spójności
+ mat-view. **Najwrażliwsze, pierwsze.**
2. **Publikacje** — `SoftDeleteModel` na 5 modelach, override
- `delete()`/`restore()` (bez kaskady), migracje (`deleted_at`+indeks,
- ew. `CONCURRENTLY`), `slug` `UniqueConstraint`, przeplecenie menedżerów.
+ `delete()`/`restore()` z **wąską kaskadą na `*_Autor`** (wspólny
+ `transaction_id`, bez refleksyjnej kaskady pakietu), migracje
+ (`deleted_at`+indeks, ew. `CONCURRENTLY`), `slug` `UniqueConstraint`,
+ przeplecenie menedżerów.
3. **Audyt kat. B** — przełączenie import/dedup/PBN-matching na
- `global_objects`. Testy: re-import nie tworzy duplikatów.
+ `global_objects`; audyt 90 miejsc `*_Autor.objects` (default „pomijaj"
+ poprawny, wyjątki → `global_objects`). Testy: re-import nie tworzy
+ duplikatów; ewaluacja pomija prace w koszu.
4. **Autor** — flip FK `CASCADE→PROTECT` (`*_Autor`, doktorat), guard w soft
- `delete()`, soft-delete husku; weryfikacja merge.
+ `delete()` **liczący przez `global_objects`** (widzi kaskadowo-skasowane
+ autorstwa), soft-delete husku; weryfikacja merge.
5. **PBN** — `operacja WYCOFANIE` w `pbn_export_queue` + restore→`WYSYLKA`;
integracja `SentData`.
6. **SoftDeleteLog** + receivery sygnałów (`post_soft_delete`/`post_restore`/
@@ -310,8 +388,12 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
## 9. Ryzyka
- **Cache/trigger** — rozjazd, jeśli `deleted_at` nie obsłużone we wszystkich
- 5 tabelach + ścieżce UPDATE + tabelach autorskich. Najgroźniejsze,
- wydajnościowo wrażliwe. Mitygacja: testy spójności jako pierwsze.
+ 8 tabelach (5 publikacji + 3 `*_autor`) + ścieżce UPDATE + widokach
+ źródłowych. Najgroźniejsze, wydajnościowo wrażliwe. Mitygacja: testy
+ spójności jako pierwsze.
+- **Guard autora przez `objects` zamiast `global_objects`** — autor z pracami
+ tylko-w-koszu przeszedłby przez guard (autorstwa kaskadowo skasowane są
+ ukryte). MUSI być `global_objects` (§3.2).
- **Duplikaty** z importu/PBN/dedup, jeśli kat. B nie przejdzie na
`global_objects`.
- **Merge autorów** — jeśli nie przenosi wszystkich typów prac przed
@@ -327,10 +409,15 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
## 10. Decyzje rozstrzygnięte (z brainstormingu 2026-06-04)
-1. **Autor:** z pracami → `PROTECT`; bez prac → soft-delete (husk). Through-
- modele/doktorat/habilitacja **nie** stają się `SoftDeleteModel`.
-2. **Publikacje:** Projekt A (override `delete()`, trigger jako choke-point;
- dzieci nietknięte).
+1. **Autor:** z pracami → `PROTECT`; bez prac → soft-delete (husk). Guard
+ liczy przez `global_objects`. Soft-delete autora **nie** kaskaduje do
+ `*_Autor`. Doktorat/habilitacja: FK do autora → `PROTECT`.
+2. **Publikacje:** Projekt A z **wąską kaskadą na `*_Autor`** — 5 modeli +
+ 3 through-modele `*_Autor` stają się `SoftDeleteModel`; override `delete()`
+ soft-deletuje rodzica i jego `*_Autor` (wspólny `transaction_id`), bez
+ refleksyjnej kaskady na pozostałe dzieci. Trigger jako choke-point,
+ jednolity dzięki własnym `deleted_at` na wszystkich 8 tabelach (bez JOIN
+ do rodzica). Powód kaskady: 90 miejsc `*_Autor.objects` w ewaluacji.
3. **PBN przy soft-delete:** wycofanie oświadczeń instytucji
(`delete_all_publication_statements`), gate na `pbn_uid`; obiektu
publikacji nie kasujemy.
From 3234b26a4d8d5e03a8d0eb1c22229201224893a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 4 Jun 2026 10:31:53 +0200
Subject: [PATCH 04/75] docs(soft-delete): poprawki z self-review specu
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Self-review wykrył 3 luki materialne + drobiazgi:
- A2: filtr deleted_at w widokach źródłowych ustawiony jako mechanizm #1
(pokrywa odczyt z bpp_rekord, verify_cache, re-insert); trigger-skip
zdegradowany do opcjonalnej optymalizacji — sam nie wystarcza,
- A3: doprecyzowana semantyka SentData przy WYCOFANIE (submitted_successfully
=False + znacznik, bez kasowania wiersza),
- A1/§2.6: dodana pominięta self-referencja Wydawnictwo_Zwarte (rozdziały →
książka-matka) z proponowanym defaultem (brak kaskady + ostrzeżenie) do
potwierdzenia; + nota o GenericForeignKey (soft-delete bezpieczniejszy),
- B1: doprecyzowany Cel (powiązania *_Autor soft-deletowane, nie "aktywne"),
- B2: kolejność migracji w fazie 1 (deleted_at column przed trigger/widok),
- §10: dopisane decyzje #9/#10 + sekcja "oczekuje potwierdzenia".
Co-Authored-By: Claude Opus 4.8 (1M context)
---
...soft-delete-publikacje-i-autorzy-design.md | 88 ++++++++++++++-----
1 file changed, 67 insertions(+), 21 deletions(-)
diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
index 3820a915c..4d8b69d84 100644
--- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
+++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
@@ -13,8 +13,9 @@
sens, przy minimalnym blast-radiusie:
1. **Publikacje** (5 modeli) — pełny soft-delete: `DELETE` → znacznik
- `deleted_at`; rekord znika z widoku publicznego / ewaluacji / API / PBN,
- dane (w tym powiązania `*_Autor`) zostają i da się je przywrócić.
+ `deleted_at`; rekord znika z widoku publicznego / ewaluacji / API / PBN.
+ Powiązania `*_Autor` są soft-deletowane razem z rekordem (wąska kaskada,
+ §2.2) — zachowane i odwracalne, nie tracone jak przy hard-delete.
2. **Autor** — soft-delete **wyłącznie dla autora bez prac** (odwracalny
„kosz" dla pustych/błędnych rekordów). Autor **z** pracami → `PROTECT`
(zero kasowania, soft ani hard).
@@ -105,22 +106,30 @@ działa identycznie niezależnie od tego, czy zadziałała tabela publikacji czy
tabela autorska. **Nie ma potrzeby JOIN-a/lookupu do rekordu nadrzędnego.**
Skutki:
-- **Funkcja triggera:** jedna zmiana w gałęzi `UPDATE/INSERT` —
- `if TD['new'].get('deleted_at') is not None: `.
-- **Widoki źródłowe:** filtr po **własnej** kolumnie tabeli, bez JOIN —
- `bpp_*_autorzy` (selektują `FROM bpp_*_autor`,
- `src/bpp/migrations/0001_widoki_autorzy.sql`) dostają `WHERE deleted_at IS
- NULL` na kolumnie tabeli autorskiej; `bpp_rekord` analogicznie na kolumnie
- tabeli publikacji.
+- **Mechanizm #1 — filtr `deleted_at IS NULL` w widokach źródłowych** (po
+ **własnej** kolumnie tabeli, bez JOIN): `bpp_*_autorzy` (selektują
+ `FROM bpp_*_autor`, `src/bpp/migrations/0001_widoki_autorzy.sql`) i każda
+ gałąź UNION-u `bpp_rekord`. **To jest nadrzędny mechanizm**, bo pokrywa
+ WSZYSTKIE ścieżki: re-insert triggera, bezpośredni odczyt z widoku `bpp_rekord`
+ (`Rekord` czyta `bpp_rekord`, `src/bpp/models/cache/rekord.py:357`), oraz
+ pełną re-projekcję/weryfikację cache.
+- **Mechanizm #2 (optymalizacja) — trigger-skip:** w gałęzi `UPDATE/INSERT`
+ `if TD['new'].get('deleted_at') is not None: `. Oszczędza
+ no-op SELECT/INSERT, ale **sam nie wystarcza** (nie pokrywa odczytu z widoku
+ ani `verify_cache`). Filtr widoku (#1) jest obowiązkowy; trigger-skip
+ opcjonalny.
+- **`verify_cache` (`src/bpp/management/commands/verify_cache.py`)** porównuje
+ `bpp_rekord_mat` ze źródłem — MUSI respektować `deleted_at` (przez filtr #1),
+ inaczej zgłosi fałszywy rozjazd dla skasowanych i spróbuje je wskrzesić.
- **Przypadek brzegowy znika strukturalnie:** edycja wiersza autorstwa
- skasowanej publikacji nie wskrzesi go w `bpp_autorzy_mat`, bo ten wiersz
- ma własne `deleted_at` (ustawione kaskadą) → trigger traktuje go jak DELETE.
+ skasowanej publikacji nie wskrzesi go w `bpp_autorzy_mat`, bo widok
+ źródłowy go odfiltruje (ma własne `deleted_at` ustawione kaskadą).
- **Koszt:** soft-delete publikacji z N autorami odpala N dodatkowych (no-op)
- triggerów through — usuwają z `bpp_autorzy_mat` to, co trigger rodzica już
- usunął. Pomijalne.
+ triggerów through. Pomijalne.
Testy spójności mat-view obowiązkowe (soft-delete → znika z `Rekord` i
-`Autorzy`; restore → wraca; brak rozjazdu `Cache_Punktacja_*`).
+`Autorzy`; restore → wraca; `verify_cache` czysty po soft-delete; brak
+rozjazdu `Cache_Punktacja_*`).
### 2.2 Override `delete()` — wąska, kontrolowana kaskada na `*_Autor`
@@ -197,6 +206,29 @@ ewaluacji — tam „pomiń skasowane" jest poprawnym defaultem) → wtedy
`global_objects`. Domyślny default „pomijaj" jest tu znacznie bezpieczniejszy
niż przeciwny.
+### 2.6 Self-referencja `Wydawnictwo_Zwarte` + GenericForeignKey
+
+**Self-FK `wydawnictwo_nadrzedne`** (`src/bpp/models/wydawnictwo_zwarte.py:202`,
+rozdziały → książka-matka; denorm `@depend_on_related("self",
+"wydawnictwo_nadrzedne")`). Wąska kaskada (§2.2) zatrzymuje się na `*_Autor` —
+**nie kaskaduje na rozdziały**. Soft-delete książki-matki zostawia rozdziały
+widoczne, wskazujące na skasowaną książkę.
+
+> **DECYZJA (proponowana, do potwierdzenia):** soft-delete książki-matki
+> **NIE** kaskaduje automatycznie na rozdziały (są niezależnymi publikacjami,
+> często z osobnym dorobkiem autorów); admin pokazuje **ostrzeżenie** „ta
+> książka ma N rozdziałów — zostaną widoczne". Alternatywa: kaskada na
+> rozdziały (jak na `*_Autor`). Powiązane: zweryfikować, że denorm
+> `depend_on_related("self", ...)` nie wywala się przy ustawianiu `deleted_at`
+> rodzica.
+
+**GenericForeignKey** (`Nagroda`, `Publikacja_Habilitacyjna` → rekord przez
+`content_type`+`object_id`): przy soft-delete obiekt **fizycznie istnieje**,
+więc GFK rozwiązuje się poprawnie — soft-delete jest tu *bezpieczniejszy* niż
+hard-delete (mniej sierot). Do rozważenia tylko, czy `nagrody` skasowanego
+rekordu mają być nadal pokazywane (domyślnie: skoro rekord w koszu, jego
+podstrona i tak znika — kwestia bez realnego skutku).
+
---
## 3. Autor — dwie warstwy ochrony + soft-delete husków
@@ -290,7 +322,11 @@ Rozszerzenie:
`SentData` (`src/pbn_api/models/sentdata.py`, GFK + `pbn_uid` +
`submitted_successfully` + `mark_as_successful`/`mark_as_failed`) trzyma stan
-PBN per-rekord — po wycofaniu oznaczamy odpowiednio.
+PBN per-rekord. **Po udanym wycofaniu:** ustawiamy `submitted_successfully =
+False` (rekord nie jest już „wystawiony" w PBN) i dodajemy znacznik wycofania
+(np. `withdrawn_at` — nowe pole, lub `api_response_status`); **wiersza
+`SentData` NIE kasujemy** — zostaje dla audytu i re-matchingu przy restore.
+Restore (`WYSYLKA`) → ponowne `mark_as_successful` po udanej wysyłce.
### 4.3 Restore → symetria
@@ -356,12 +392,13 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
## 8. Kolejność prac (fazy; szczegółowy TDD → writing-plans)
-1. **`*_Autor` + trigger + widoki** — `SoftDeleteModel` na 3 through-modelach
- (`deleted_at`+indeks); funkcja `bpp_refresh_cache()` ucząca się reguły
- `deleted_at IS NOT NULL → DELETE` (jednolicie dla wszystkich 8 tabel,
- bez JOIN do rodzica); filtr `deleted_at IS NULL` w widokach źródłowych
- `bpp_rekord`/`bpp_*_autorzy` (po własnej kolumnie). Testy spójności
- mat-view. **Najwrażliwsze, pierwsze.**
+1. **`*_Autor` + trigger + widoki** — kolejność wewnątrz fazy: (a) migracja
+ `SoftDeleteModel` na 3 through-modelach (`deleted_at`+indeks) — **musi być
+ PRZED** (b), bo trigger/widok czytają tę kolumnę; (b) filtr `deleted_at IS
+ NULL` w widokach źródłowych `bpp_rekord`/`bpp_*_autorzy` (mechanizm #1, po
+ własnej kolumnie); (c) funkcja `bpp_refresh_cache()` z regułą
+ `deleted_at IS NOT NULL → pomiń re-insert` (opcjonalna optymalizacja).
+ Testy spójności mat-view + `verify_cache`. **Najwrażliwsze, pierwsze.**
2. **Publikacje** — `SoftDeleteModel` na 5 modelach, override
`delete()`/`restore()` z **wąską kaskadą na `*_Autor`** (wspólny
`transaction_id`, bez refleksyjnej kaskady pakietu), migracje
@@ -428,6 +465,15 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
7. **Admin:** superuser-only; soft-delete zastępuje „usuń"; hard-delete jako
osobna jawna akcja.
8. **Retencja:** brak auto-czyszczenia; tylko ręczny hard-delete.
+9. **Cache — mechanizm nadrzędny:** filtr `deleted_at IS NULL` w widokach
+ źródłowych (pokrywa trigger, odczyt z `bpp_rekord`, `verify_cache`);
+ trigger-skip to opcjonalna optymalizacja.
+10. **SentData przy wycofaniu:** `submitted_successfully=False` + znacznik
+ wycofania, wiersza nie kasujemy.
+
+**Oczekuje potwierdzenia:**
+- **Self-FK `Wydawnictwo_Zwarte` (rozdziały):** propozycja — soft-delete
+ książki-matki NIE kaskaduje na rozdziały, admin ostrzega (§2.6).
---
From 60e23f1931c932eac5fe63a1c222450afab180a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 4 Jun 2026 10:52:05 +0200
Subject: [PATCH 05/75] =?UTF-8?q?docs(soft-delete):=20rozdzia=C5=82y=20Wyd?=
=?UTF-8?q?awnictwo=5FZwarte=20=E2=86=92=20PROTECT=20(jak=20guard=20autora?=
=?UTF-8?q?)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Decyzja A1: soft-delete książki-matki zablokowany, jeśli ma rozdziały.
Dwuwarstwowy wzorzec identyczny z guardem autora: flip FK wydawnictwo_nadrzedne
CASCADE→PROTECT + guard w soft delete() liczący rozdziały przez global_objects.
Eliminuje problem "rozdziały wskazujące na skasowaną książkę" u źródła.
Zaktualizowane §2.6, §8 (faza 4 = guardy PROTECT), §10 (decyzja #11).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
...soft-delete-publikacje-i-autorzy-design.md | 44 +++++++++++--------
1 file changed, 26 insertions(+), 18 deletions(-)
diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
index 4d8b69d84..c8f8131f6 100644
--- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
+++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
@@ -210,17 +210,21 @@ niż przeciwny.
**Self-FK `wydawnictwo_nadrzedne`** (`src/bpp/models/wydawnictwo_zwarte.py:202`,
rozdziały → książka-matka; denorm `@depend_on_related("self",
-"wydawnictwo_nadrzedne")`). Wąska kaskada (§2.2) zatrzymuje się na `*_Autor` —
-**nie kaskaduje na rozdziały**. Soft-delete książki-matki zostawia rozdziały
-widoczne, wskazujące na skasowaną książkę.
-
-> **DECYZJA (proponowana, do potwierdzenia):** soft-delete książki-matki
-> **NIE** kaskaduje automatycznie na rozdziały (są niezależnymi publikacjami,
-> często z osobnym dorobkiem autorów); admin pokazuje **ostrzeżenie** „ta
-> książka ma N rozdziałów — zostaną widoczne". Alternatywa: kaskada na
-> rozdziały (jak na `*_Autor`). Powiązane: zweryfikować, że denorm
-> `depend_on_related("self", ...)` nie wywala się przy ustawianiu `deleted_at`
-> rodzica.
+"wydawnictwo_nadrzedne")`).
+
+> **DECYZJA: PROTECT — soft-delete książki-matki jest ZABLOKOWANY, jeśli ma
+> rozdziały.** Ten sam dwuwarstwowy wzorzec co guard autora (§3):
+> - **warstwa 1:** flip FK `wydawnictwo_nadrzedne` `CASCADE→PROTECT`
+> (obrona przed hard-delete; migracja state-only),
+> - **warstwa 2:** guard w soft-`delete()` `Wydawnictwo_Zwarte` — jeśli rekord
+> ma rozdziały (dzieci `wydawnictwo_nadrzedne`), odmów z czytelnym
+> komunikatem; operator najpierw usuwa/przenosi rozdziały.
+>
+> Liczenie rozdziałów: przez `global_objects` (także soft-deletowane
+> rozdziały blokują — spójnie z guardem autora §3.2). Dzięki PROTECT problem
+> „rozdziały wskazujące na skasowaną książkę" w ogóle nie powstaje, a denorm
+> `depend_on_related("self", ...)` nie jest wyzwalany kaskadą (rodzic nie
+> może być skasowany, póki ma dzieci).
**GenericForeignKey** (`Nagroda`, `Publikacja_Habilitacyjna` → rekord przez
`content_type`+`object_id`): przy soft-delete obiekt **fizycznie istnieje**,
@@ -408,9 +412,14 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
`global_objects`; audyt 90 miejsc `*_Autor.objects` (default „pomijaj"
poprawny, wyjątki → `global_objects`). Testy: re-import nie tworzy
duplikatów; ewaluacja pomija prace w koszu.
-4. **Autor** — flip FK `CASCADE→PROTECT` (`*_Autor`, doktorat), guard w soft
- `delete()` **liczący przez `global_objects`** (widzi kaskadowo-skasowane
- autorstwa), soft-delete husku; weryfikacja merge.
+4. **Guardy PROTECT** (ten sam wzorzec: flip FK + guard liczący przez
+ `global_objects`):
+ - **Autor** — flip FK `CASCADE→PROTECT` (`*_Autor`, doktorat), guard w soft
+ `delete()` (widzi kaskadowo-skasowane autorstwa), soft-delete husku;
+ weryfikacja merge.
+ - **`Wydawnictwo_Zwarte` (rozdziały)** — flip FK `wydawnictwo_nadrzedne`
+ `CASCADE→PROTECT`, guard w soft `delete()` blokujący gdy ma rozdziały
+ (§2.6).
5. **PBN** — `operacja WYCOFANIE` w `pbn_export_queue` + restore→`WYSYLKA`;
integracja `SentData`.
6. **SoftDeleteLog** + receivery sygnałów (`post_soft_delete`/`post_restore`/
@@ -470,10 +479,9 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
trigger-skip to opcjonalna optymalizacja.
10. **SentData przy wycofaniu:** `submitted_successfully=False` + znacznik
wycofania, wiersza nie kasujemy.
-
-**Oczekuje potwierdzenia:**
-- **Self-FK `Wydawnictwo_Zwarte` (rozdziały):** propozycja — soft-delete
- książki-matki NIE kaskaduje na rozdziały, admin ostrzega (§2.6).
+11. **Self-FK `Wydawnictwo_Zwarte` (rozdziały):** **PROTECT** — soft-delete
+ książki-matki zablokowany, jeśli ma rozdziały (flip FK `CASCADE→PROTECT`
+ + guard liczący przez `global_objects`, §2.6). Wzorzec jak guard autora.
---
From a6733446f0eb6fdb9473d466f7c7ed240d8310b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 4 Jun 2026 11:21:06 +0200
Subject: [PATCH 06/75] docs(soft-delete): plany implementacyjne TDD (indeks +
8 faz)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plan-indeks (00) z wpiętymi wspólnymi kontraktami + 8 planów fazowych w
formacie superpowers:writing-plans (bite-sized TDD, bez placeholderów):
01 *_Autor SoftDeleteModel + widoki/trigger + spójność cache
02 publikacje SoftDeleteModel + wąska kaskada na *_Autor + slug + menedżery
03 audyt kategorii B (global_objects, hard_delete w pbn_import)
04 guardy PROTECT (autor + książka-matka) + flip FK
05 PBN wycofanie przez pbn_export_queue (operacja WYCOFANIE) + restore WYSYLKA
06 SoftDeleteLog + receivery sygnałów + atrybucja usera (thread-local)
07 admin superuser-only (kosz/filtr/przywróć/usuń-trwale/powód)
08 testy regresji E2E
Plany rozpisane przez równoległych agentów z wglądem w realny kod.
Wykryte rozbieżności spec↔kod do naniesienia osobnym commitem.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2026-06-04-soft-delete-00-overview.md | 138 ++
...-04-soft-delete-01-autor-trigger-widoki.md | 770 ++++++++++
.../2026-06-04-soft-delete-02-publikacje.md | 558 ++++++++
...-06-04-soft-delete-03-audyt-kategorii-b.md | 938 ++++++++++++
...026-06-04-soft-delete-04-guardy-protect.md | 502 +++++++
...2026-06-04-soft-delete-05-pbn-wycofanie.md | 668 +++++++++
...2026-06-04-soft-delete-06-softdeletelog.md | 838 +++++++++++
.../plans/2026-06-04-soft-delete-07-admin.md | 1260 +++++++++++++++++
...026-06-04-soft-delete-08-testy-regresji.md | 1179 +++++++++++++++
9 files changed, 6851 insertions(+)
create mode 100644 docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
create mode 100644 docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
create mode 100644 docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
create mode 100644 docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md
create mode 100644 docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md
create mode 100644 docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md
create mode 100644 docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md
create mode 100644 docs/superpowers/plans/2026-06-04-soft-delete-07-admin.md
create mode 100644 docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
new file mode 100644
index 000000000..a0bd5ce23
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
@@ -0,0 +1,138 @@
+# Soft-delete publikacji + autorów — Plan-indeks (00)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement these plans task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Wdrożyć odwracalny soft-delete dla 5 typów publikacji + wąską kaskadę na `*_Autor`, soft-delete autora bez prac (z PROTECT dla autora/książki z zależnościami), wycofanie z PBN przez kolejkę, audyt `SoftDeleteLog` i wsparcie w adminie superusera.
+
+**Architecture:** `django-soft-delete` (`SoftDeleteModel`) na 5 modelach publikacji + 3 through-modelach `*_Autor`; spójność cache w JEDNYM punkcie — filtr `deleted_at IS NULL` w widokach źródłowych PostgreSQL (mechanizm #1) + opcjonalny trigger-skip. Override `delete()` robi wąską kaskadę na `*_Autor`. PBN-wycofanie idzie przez rozszerzoną `pbn_export_queue`. `SoftDeleteLog` zasilany sygnałami pakietu.
+
+**Tech Stack:** Django, PostgreSQL (`plpython3u` triggery + widoki), `django-soft-delete>=1.0.23`, `django-denorm-iplweb`, Celery + `pbn_export_queue`, pytest + model_bakery.
+
+**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md)
+
+---
+
+## Plany fazowe (wykonywać w kolejności)
+
+| # | Plik | Zakres | Zależy od |
+|---|---|---|---|
+| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; filtr `deleted_at` w widokach źródłowych; trigger-skip; testy spójności cache + `verify_cache` | — |
+| 02 | `2026-06-04-soft-delete-02-publikacje.md` | 5 modeli → SoftDeleteModel; override `delete()`/`restore()` z wąską kaskadą na `*_Autor`; `slug` warunkowy unique; przeplecenie menedżerów | 01 |
+| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 90 miejsc `*_Autor.objects` | 02 |
+| 04 | `2026-06-04-soft-delete-04-guardy-protect.md` | flip FK `CASCADE→PROTECT` (autor, doktorat, `wydawnictwo_nadrzedne`); guard w soft `delete()` (autor + książka-matka); soft-delete husku autora | 02 |
+| 05 | `2026-06-04-soft-delete-05-pbn-wycofanie.md` | `pbn_export_queue.operacja = WYSYLKA\|WYCOFANIE`; `WYCOFANIE` → `delete_all_publication_statements`; restore → `WYSYLKA`; integracja `SentData` | 02 |
+| 06 | `2026-06-04-soft-delete-06-softdeletelog.md` | model `SoftDeleteLog`; receivery `post_soft_delete`/`post_restore`/`post_hard_delete`; wstrzykiwanie `user` | 02, 05 |
+| 07 | `2026-06-04-soft-delete-07-admin.md` | admin superuser-only: kosz/filtr/przywróć/usuń-trwale/powód (5 modeli + Autor); jeden hook usera | 04, 06 |
+| 08 | `2026-06-04-soft-delete-08-testy-regresji.md` | pełna suita regresji: PBN duplikaty/wycofanie, dashboard, import, ewaluacja, merge autorów, API | 01–07 |
+
+---
+
+## Wspólne kontrakty (PINNED — wszystkie fazy używają tych nazw VERBATIM)
+
+### Pakiet `django-soft-delete` (punkt wyjścia, nie zmieniamy)
+- `SoftDeleteModel` — abstrakcyjny; pola `deleted_at`, `restored_at`, `transaction_id`.
+- Menedżery: `objects` (`SoftDeleteManager`, ukrywa skasowane), `global_objects` (`GlobalManager`, wszystkie), `deleted_objects` (`DeletedManager`, tylko skasowane).
+- Metody instancji: `.delete()` (soft, woła `self.save(update_fields=[...])` + `post_soft_delete`), `.hard_delete()`, `.restore()`.
+- `SoftDeleteQuerySet.delete()` iteruje per-instancję (`for obj in self.iterator(): obj.delete()`) — bezpieczny dla sygnałów. **NIE** robi bulk update.
+- Sygnały (`django_softdelete/signals.py`): `post_soft_delete`, `post_hard_delete`, `post_restore`.
+
+### Nowy moduł `src/bpp/models/soft_delete.py` (tworzy faza 01)
+```python
+from django_softdelete.managers import (
+ SoftDeleteQuerySet, SoftDeleteManager, DeletedManager, GlobalManager,
+)
+
+
+class BppSoftDeleteQuerySet(SoftDeleteQuerySet):
+ """Gate: blokuje bulk-ustawienie deleted_at/restored_at przez .update()
+ (omijałoby post_save, kaskadę *_Autor, SoftDeleteLog i reversion)."""
+
+ def update(self, **kwargs):
+ if "deleted_at" in kwargs or "restored_at" in kwargs:
+ raise RuntimeError(
+ "Nie ustawiaj deleted_at/restored_at przez .update() — "
+ "użyj .delete()/.restore(). Bulk update omija post_save, "
+ "kaskadę *_Autor, SoftDeleteLog i reversion."
+ )
+ return super().update(**kwargs)
+
+
+class BppSoftDeleteManager(SoftDeleteManager):
+ def get_queryset(self):
+ return BppSoftDeleteQuerySet(self.model, using=self._db).filter(
+ deleted_at__isnull=True
+ )
+
+
+class BppGlobalManager(GlobalManager):
+ def get_queryset(self):
+ return BppSoftDeleteQuerySet(self.model, using=self._db)
+```
+- Tu też ląduje **guard zależności** (faza 04):
+```python
+def raise_if_has_protected_children(instance, relations: list[str], label: str):
+ """relations: nazwy reverse-managerów liczone przez global_objects.
+ Rzuca django.db.models.ProtectedError gdy są dzieci."""
+```
+
+### `SoftDeleteLog` — `src/bpp/models/soft_delete_log.py` (tworzy faza 06)
+Pola PINNED: `content_type` (FK ContentType), `object_id` (PositiveIntegerField, db_index), `content_object` (GenericForeignKey), `akcja` (`models.TextChoices`: `DELETE="delete"`, `RESTORE="restore"`, `HARD_DELETE="hard_delete"`), `user` (FK `AUTH_USER_MODEL`, null=True, on_delete=SET_NULL), `timestamp` (DateTimeField auto_now_add, db_index), `powod` (TextField blank, default=""), `pbn_queue_entry` (FK `pbn_export_queue.PBN_Export_Queue`, null=True, on_delete=SET_NULL), `pbn_status` (CharField blank).
+
+### `pbn_export_queue` rozszerzenie (faza 05)
+- Nowe pole na `PBN_Export_Queue`: `operacja = models.CharField(choices=Operacja.choices, default=Operacja.WYSYLKA)` gdzie `class Operacja(models.TextChoices): WYSYLKA="wysylka"; WYCOFANIE="wycofanie"`.
+- Gałąź w logice wysyłki: `WYCOFANIE` → `client.delete_all_publication_statements(pbn_uid)` (`src/pbn_api/client/mixins/institutions.py:87`).
+
+### Wstrzykiwanie `user` (PINNED, faza 06/07)
+- Override sygnatury: `delete(self, *args, user=None, reason="", **kwargs)` i `restore(self, *args, user=None, **kwargs)`.
+- W adminie **jeden hook** (`delete_model`/`delete_queryset`/akcja „Przywróć") ustawia usera; ten sam moment ma w przyszłości zasilić `reversion.set_user` (patrz „Kontrakty z reversion").
+- Sygnał nie niesie usera → przekazujemy go do receiverów przez argument akcji / thread-local ustawiony w adminie. Operacje systemowe (merge, celery): `user=None`.
+
+### Punkty zaczepienia w istniejącym kodzie (zweryfikowane)
+- Rejestracja sygnałów: `src/bpp/apps.py` → `BppConfig.ready()` (linia 8).
+- Menedżery publikacji: `src/bpp/models/wydawnictwo_ciagle.py:87` (`Wydawnictwo_Ciagle_Manager`), `wydawnictwo_zwarte.py:167` (`Wydawnictwo_Zwarte_Manager`), oba po `ManagerModeliZOplataZaPublikacjeMixin` (`src/bpp/models/abstract/fees.py`).
+- Through-model FK autora: `src/bpp/models/abstract/authors.py:22` (`autor = ForeignKey("bpp.Autor", CASCADE)`).
+- Doktorat FK: `src/bpp/models/praca_doktorska.py:136` (CASCADE). Habilitacja: `praca_habilitacyjna.py:42` (O2O PROTECT, bez zmian).
+- Self-FK rozdziałów: `src/bpp/models/wydawnictwo_zwarte.py:202` (`wydawnictwo_nadrzedne`).
+- Trigger/widoki: `src/bpp/migrations/0001_cache_functions.sql` (funkcja `bpp_refresh_cache`), `src/bpp/migrations/0001_widoki_autorzy.sql`, `0001_widoki_rekord.sql`.
+- `Rekord` czyta widok `bpp_rekord`: `src/bpp/models/cache/rekord.py:357`. Mat-tabela: `:347`.
+- `verify_cache`: `src/bpp/management/commands/verify_cache.py`.
+- Admin tych modeli: `src/bpp/admin/{wydawnictwo_ciagle,wydawnictwo_zwarte,patent,praca_doktorska,praca_habilitacyjna,autor}.py`; mixiny `src/bpp/admin/helpers/mixins.py`.
+- PBN klient: `src/pbn_api/client/mixins/institutions.py:87`. `SentData`: `src/pbn_api/models/sentdata.py`. Kolejka: `src/pbn_export_queue/{models,tasks,admin}.py`.
+- Merge autorów: `src/deduplikator_autorow/views/merge.py:155`, `utils/merge.py:191,284,354`.
+- Precedens wzorca: `src/zglos_publikacje/models.py` (`Zgłoszenie_Publikacji` już `SoftDeleteModel`).
+
+---
+
+## Kontrakty z django-reversion (NIE implementujemy — odłożone; tylko nie łamiemy)
+
+Równoległy spec [`../specs/2026-06-04-historia-zmian-reversion-design.md`](../specs/2026-06-04-historia-zmian-reversion-design.md) (odłożony do PO soft-delete) wymaga zostawienia czystych szwów:
+
+1. **`save()` per-instancja (twardy warunek).** Override `delete()`/`restore()` oraz kaskada na `*_Autor` MUSZĄ iść przez per-instancję `.delete()`/`save()`, **nigdy** bulk `queryset.update(deleted_at=...)`. Inaczej `post_save` nie odpali → przyszła historia reversion cicho zniknie. Gate w `BppSoftDeleteQuerySet.update()` to egzekwuje fail-fast.
+2. **Jeden hook usera.** Punkt wstrzyknięcia `request.user` w adminie (faza 07) ma być jedną metodą, którą reversion później doczepi do `set_user`.
+3. **Świadomość recover.** Warstwa admina (faza 07) zostawia miejsce na późniejsze ukrycie reversion „recover deleted" (recover po `hard_delete` wskrzeszałby rekord poza przepływem — bez `WYSYLKA`, bez `SoftDeleteLog`, łamiąc warunkowy unique `slug`).
+
+---
+
+## Mapa plików (tworzonych/modyfikowanych w całym wdrożeniu)
+
+**Tworzone:**
+- `src/bpp/models/soft_delete.py` (queryset+gate, managery, guard helper) — faza 01/04
+- `src/bpp/models/soft_delete_log.py` (model `SoftDeleteLog`) — faza 06
+- `src/bpp/migrations/0XXX_*` — migracje pól soft-delete (`*_Autor`, 5 publikacji), `slug` constraint, FK flips, `SoftDeleteLog`, `pbn_export_queue.operacja`
+- `src/bpp/migrations/0XXX_soft_delete_views.sql` — filtr `deleted_at` w `bpp_rekord`/`bpp_*_autorzy` + trigger-skip
+- `src/bpp/receivers/soft_delete.py` (lub w istniejącym module sygnałów) — receivery — faza 06
+
+**Modyfikowane (główne):**
+- modele: `wydawnictwo_ciagle.py`, `wydawnictwo_zwarte.py`, `patent.py`, `praca_doktorska.py`, `praca_habilitacyjna.py`, `autor.py`, `abstract/authors.py`
+- admin: jw. 6 plików + `admin/helpers/mixins.py`
+- `pbn_export_queue/models.py`, `tasks.py`, `admin.py`
+- `pbn_api/models/sentdata.py`
+- `pbn_import/utils/publication_import.py` (jawny `.hard_delete()`)
+- `import_common/`, `crossref_bpp/`, `deduplikator_publikacji/`, `pbn_integrator/`, `ewaluacja_optymalizacja/` (audyt `global_objects`)
+- `src/bpp/apps.py` (rejestracja receiverów)
+
+---
+
+## Wykonanie
+
+Fazy 01→08 sekwencyjnie. Po każdej fazie: pełne testy danej fazy zielone + `ruff check`/`format` + commit. Trigger/cache (01) najwrażliwsze — testy spójności przed czymkolwiek innym. Gałąź: `feat/soft-delete` (ten worktree).
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
new file mode 100644
index 000000000..0b8e995b9
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
@@ -0,0 +1,770 @@
+# Soft-delete — Faza 01: `*_Autor` → SoftDeleteModel + widoki źródłowe + trigger Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez wspólną bazę `BazaModeluOdpowiedzialnosciAutorow`), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, oraz wpiąć filtr `deleted_at IS NULL` do widoków źródłowych PostgreSQL (`bpp_*_autorzy` + gałęzie UNION `bpp_rekord`) tak, by soft-deletowane autorstwa znikały z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i wracały po `restore`. Opcjonalnie: trigger-skip w `bpp_refresh_cache()`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować.
+
+**Architecture:** Mechanizm nadrzędny to **filtr widoku (#1)** — każda tabela `bpp_*_autor` ma własną kolumnę `deleted_at`, a widoki źródłowe `bpp_wydawnictwo_ciagle_autorzy` / `bpp_wydawnictwo_zwarte_autorzy` / `bpp_patent_autorzy` (`0001_widoki_autorzy.sql`) dostają `AND .deleted_at IS NULL` po **własnej** kolumnie (bez JOIN do rekordu nadrzędnego). To pokrywa WSZYSTKIE ścieżki: re-insert triggera `bpp_refresh_cache()`, bezpośredni odczyt `Rekord`/`RekordView` z widoku `bpp_rekord`, oraz pełną re-projekcję cache. Gałęzie `UNION` w `bpp_rekord` (`0001_widoki_rekord.sql`) per typ publikacji NIE filtrują po `*_autor.deleted_at` (rekord publikacji żyje niezależnie od soft-delete pojedynczego autorstwa — soft-delete publikacji to faza 02), ale dla spójności kontraktu dodajemy filtr `deleted_at IS NULL` na poziomie tabeli autorskiej tylko w widokach `bpp_*_autorzy`. Trigger-skip (#2) to opcjonalna optymalizacja w gałęzi `UPDATE/INSERT` funkcji `bpp_refresh_cache()` (aktualna wersja: `0399_fix_refresh_cache_upsert.sql`): gdy `TD['new']['deleted_at'] is not None` → pomiń upsert (delete-only). Nie zastępuje #1.
+
+**Tech Stack:** Django 4.2, PostgreSQL (`plpython3u` trigger + widoki), `django-soft-delete>=1.0.23` (`SoftDeleteModel`, `SoftDeleteManager`/`GlobalManager`/`DeletedManager`), pytest + model_bakery, `denorm` (django-denorm-iplweb). Python wyłącznie przez `uv run`.
+
+**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§1, §2.1, §2.2, §8 pkt 1). Indeks: [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md).
+
+**Fakty z kodu (zweryfikowane, NIE zmieniać bez ponownej weryfikacji):**
+- `BazaModeluOdpowiedzialnosciAutorow` jest `models.Model` (abstract), `src/bpp/models/abstract/authors.py:16`. Po niej dziedziczą wszystkie 3 through-modele.
+- `Wydawnictwo_Ciagle_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_ciagle.py:52`. FK `rekord` → `Wydawnictwo_Ciagle`, `related_name="autorzy_set"`, `src/bpp/models/wydawnictwo_ciagle.py:58`.
+- `Wydawnictwo_Zwarte_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_zwarte.py:60`. FK `rekord`, `related_name="autorzy_set"`, `:67`.
+- `Patent_Autor(BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/patent.py:32`. FK `rekord`, `related_name="autorzy_set"`, `:35`.
+- Wszystkie 3 mają `Meta.unique_together` (NIE ruszamy; `deleted_at` nie wchodzi w `unique_together` — autorstwa nie mają warunkowego unique w tej fazie, sług to faza 02).
+- `BazaModeluOdpowiedzialnosciAutorow.objects` NIE jest jawnie zdefiniowany → po wpięciu `SoftDeleteModel` domyślne `objects` = `SoftDeleteManager` (z pakietu). Nadpiszemy je naszymi `Bpp*` z `src/bpp/models/soft_delete.py`.
+- `SoftDeleteModel.delete()` (pakiet, `django_softdelete/models.py`) robi **refleksyjną kaskadę** po reverse relacjach — dla `*_Autor` reverse relacji do soft-delete dzieci NIE ma (ich dzieci to nie-soft `Autor`/`Jednostka` przez FK forward), więc kaskada jest no-op. `delete()` woła `self.save(update_fields=['deleted_at','restored_at','transaction_id'])` → odpala trigger `bpp_*_autor_cache_trigger` jako `UPDATE`. To jest pożądane.
+- Widok `bpp_autorzy_mat` (model `Autorzy`, `src/bpp/models/cache/autorzy.py:39`, `db_table="bpp_autorzy_mat"`) zasilany triggerem z `bpp_autorzy` (UNION `bpp_*_autorzy`).
+- Aktualna funkcja triggera to `0399_fix_refresh_cache_upsert.sql` (NIE `0001_cache_functions.sql` — ta jest baseline, nadpisana przez 0399). Trigger-skip dopisujemy do **kopii treści 0399** w nowym pliku SQL.
+- `transactional_db` fixture wymagany dla testów dotykających trigger/cache (trigger działa tylko z prawdziwym commitem). Fixture `denorms` (`src/fixtures/conftest_system.py:193`) daje `denorms.flush()`. Fixtury: `wydawnictwo_ciagle_z_dwoma_autorami`, `wydawnictwo_ciagle_z_autorem`, `autor_jan_kowalski`, `jednostka`, `standard_data`, `typy_odpowiedzialnosci`.
+- Jedyny liść migracji `bpp`: `0420_autor_pokazuj_siec_powiazan_and_more`. Nowe migracje od niego zależą i są łańcuchowane: `0421 → 0422 (SQL)`.
+
+**Kontrakt z reversion (PINNED):** soft-delete idzie WYŁĄCZNIE per-instancja przez `.delete()`/`.save()` (nigdy `queryset.update(deleted_at=...)`). `BppSoftDeleteQuerySet.update()` to egzekwuje fail-fast (gate). W tej fazie testujemy gate i kaskadę queryset-ową.
+
+---
+
+## Task 1 — Moduł `src/bpp/models/soft_delete.py` (queryset gate + managery)
+
+Tworzy współdzielony fundament menedżerów dla całego wdrożenia. Guard zależności (`raise_if_has_protected_children`) dopisuje faza 04 — tu tylko QuerySet + 3 managery (PINNED z indeksu §39-69).
+
+**Files:**
+- Create: `src/bpp/models/soft_delete.py`
+- Test (create): `src/bpp/tests/test_soft_delete/__init__.py`, `src/bpp/tests/test_soft_delete/test_managers.py`
+
+**Steps:**
+
+- [ ] Utwórz katalog testowy i pusty `__init__.py`:
+ ```bash
+ mkdir -p src/bpp/tests/test_soft_delete && touch src/bpp/tests/test_soft_delete/__init__.py
+ ```
+
+- [ ] Napisz failing test gate'a `update()` — `src/bpp/tests/test_soft_delete/test_managers.py`:
+ ```python
+ """Testy menedżerów i queryset-gate'a soft-delete."""
+
+ import pytest
+
+ from bpp.models.soft_delete import (
+ BppGlobalManager,
+ BppSoftDeleteManager,
+ BppSoftDeleteQuerySet,
+ )
+
+
+ def test_queryset_gate_blokuje_deleted_at():
+ """update(deleted_at=...) musi rzucić RuntimeError (omija post_save,
+ kaskadę *_Autor, SoftDeleteLog i reversion)."""
+ from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+
+ qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor)
+ with pytest.raises(RuntimeError, match="Nie ustawiaj deleted_at"):
+ qs.update(deleted_at="2026-06-04")
+
+
+ def test_queryset_gate_blokuje_restored_at():
+ from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+
+ qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor)
+ with pytest.raises(RuntimeError, match="Nie ustawiaj deleted_at"):
+ qs.update(restored_at="2026-06-04")
+
+
+ def test_queryset_gate_przepuszcza_inne_pola():
+ """update() na zwykłym polu działa normalnie (nie rzuca)."""
+ from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+
+ qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor).none()
+ assert qs.update(kolejnosc=5) == 0 # pusty QS, ale nie rzuca
+
+
+ def test_managery_sa_wlasciwych_klas():
+ assert issubclass(BppSoftDeleteManager.__bases__[0].__mro__[0], object)
+ assert isinstance(
+ BppSoftDeleteManager().get_queryset.__func__.__qualname__, str
+ )
+ ```
+
+- [ ] Uruchom (oczekiwany FAIL — `ModuleNotFoundError: bpp.models.soft_delete`):
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete/test_managers.py -q
+ ```
+
+- [ ] Minimalna implementacja — `src/bpp/models/soft_delete.py` (VERBATIM z indeksu §40-69):
+ ```python
+ """Wspólny fundament soft-delete dla BPP: queryset-gate blokujący bulk
+ ustawienie deleted_at/restored_at + managery przepleciające filtr soft-delete
+ z naszą podklasą queryset (gate). Guard zależności (PROTECT) dokłada faza 04.
+ """
+
+ from django_softdelete.managers import (
+ GlobalManager,
+ SoftDeleteManager,
+ SoftDeleteQuerySet,
+ )
+
+
+ class BppSoftDeleteQuerySet(SoftDeleteQuerySet):
+ """Gate: blokuje bulk-ustawienie deleted_at/restored_at przez .update()
+ (omijałoby post_save, kaskadę *_Autor, SoftDeleteLog i reversion)."""
+
+ def update(self, **kwargs):
+ if "deleted_at" in kwargs or "restored_at" in kwargs:
+ raise RuntimeError(
+ "Nie ustawiaj deleted_at/restored_at przez .update() — "
+ "użyj .delete()/.restore(). Bulk update omija post_save, "
+ "kaskadę *_Autor, SoftDeleteLog i reversion."
+ )
+ return super().update(**kwargs)
+
+
+ class BppSoftDeleteManager(SoftDeleteManager):
+ def get_queryset(self):
+ return BppSoftDeleteQuerySet(self.model, using=self._db).filter(
+ deleted_at__isnull=True
+ )
+
+
+ class BppGlobalManager(GlobalManager):
+ def get_queryset(self):
+ return BppSoftDeleteQuerySet(self.model, using=self._db)
+ ```
+
+- [ ] Uruchom (oczekiwany PASS):
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete/test_managers.py -q
+ ```
+
+- [ ] Commit:
+ ```bash
+ git add src/bpp/models/soft_delete.py src/bpp/tests/test_soft_delete/
+ git commit -m "feat(soft-delete): moduł soft_delete.py — gate na update() + managery
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Task 2 — `BazaModeluOdpowiedzialnosciAutorow` dziedziczy `SoftDeleteModel` + migracja pól
+
+Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`restored_at`/`transaction_id` do WSZYSTKICH 3 konkretnych tabel `*_autor`. Nadpisujemy managery (`objects`/`global_objects`/`deleted_objects`) naszymi `Bpp*` z Task 1, żeby gate był aktywny. Migracja dodaje 3 pola × 3 tabele + indeks na `deleted_at` × 3.
+
+**Files:**
+- Modify: `src/bpp/models/abstract/authors.py:16` (deklaracja klasy + managery), import `:1-13`.
+- Create: `src/bpp/migrations/0421_autor_soft_delete_fields.py`
+- Test (create): `src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py`
+
+**Steps:**
+
+- [ ] Napisz failing test — `src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py`:
+ ```python
+ """*_Autor jako SoftDeleteModel: pola, managery, soft-delete/restore per
+ instancja (bez sprawdzania cache — to Task 4)."""
+
+ import pytest
+ from django_softdelete.models import SoftDeleteModel
+
+ from bpp.models.patent import Patent_Autor
+ from bpp.models.soft_delete import BppGlobalManager, BppSoftDeleteManager
+ from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+ from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte_Autor
+
+ THROUGH_MODELE = [
+ Wydawnictwo_Ciagle_Autor,
+ Wydawnictwo_Zwarte_Autor,
+ Patent_Autor,
+ ]
+
+
+ @pytest.mark.parametrize("klass", THROUGH_MODELE)
+ def test_through_jest_softdeletemodel(klass):
+ assert issubclass(klass, SoftDeleteModel)
+
+
+ @pytest.mark.parametrize("klass", THROUGH_MODELE)
+ def test_through_ma_pola_soft_delete(klass):
+ nazwy = {f.name for f in klass._meta.get_fields()}
+ assert {"deleted_at", "restored_at", "transaction_id"} <= nazwy
+
+
+ @pytest.mark.parametrize("klass", THROUGH_MODELE)
+ def test_through_ma_nasze_managery(klass):
+ assert isinstance(klass.objects, BppSoftDeleteManager)
+ assert isinstance(klass.global_objects, BppGlobalManager)
+
+
+ @pytest.mark.django_db
+ def test_soft_delete_ukrywa_w_objects_widoczne_w_global(
+ wydawnictwo_ciagle_z_autorem,
+ ):
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ pk = wca.pk
+ wca.delete()
+ assert not Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists()
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(pk=pk).exists()
+ assert Wydawnictwo_Ciagle_Autor.deleted_objects.filter(pk=pk).exists()
+
+
+ @pytest.mark.django_db
+ def test_restore_przywraca_do_objects(wydawnictwo_ciagle_z_autorem):
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ pk = wca.pk
+ wca.delete()
+ Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore()
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists()
+ ```
+
+- [ ] Uruchom (oczekiwany FAIL — `test_through_jest_softdeletemodel`: brak `SoftDeleteModel` w MRO; brak pól):
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py -q
+ ```
+
+- [ ] Zmodyfikuj import w `src/bpp/models/abstract/authors.py` — dodaj po linii `from django.db.models import CASCADE, SET_NULL, Q, Sum` (`:10`):
+ ```python
+ from django_softdelete.models import SoftDeleteModel
+
+ from bpp.models.soft_delete import (
+ BppGlobalManager,
+ BppSoftDeleteManager,
+ )
+ from django_softdelete.managers import DeletedManager
+ ```
+ (UWAGA na cykl importów: `soft_delete.py` nie importuje modeli BPP, więc bezpieczne. `authors.py` już importuje z `bpp.models.dyscyplina_naukowa` — kolejność OK.)
+
+- [ ] Zmień deklarację klasy `src/bpp/models/abstract/authors.py:16` z:
+ ```python
+ class BazaModeluOdpowiedzialnosciAutorow(models.Model):
+ ```
+ na:
+ ```python
+ class BazaModeluOdpowiedzialnosciAutorow(SoftDeleteModel):
+ ```
+
+- [ ] Dodaj jawne managery w ciele klasy `BazaModeluOdpowiedzialnosciAutorow`, tuż przed `class Meta:` (`:92`). Wstaw przed linią ` class Meta:`:
+ ```python
+ # Nadpisujemy managery pakietu naszymi (gate na update()).
+ # Kolejność: pierwszy zdefiniowany manager = _default_manager.
+ objects = BppSoftDeleteManager()
+ global_objects = BppGlobalManager()
+ deleted_objects = DeletedManager()
+
+ ```
+
+- [ ] Uruchom `makemigrations` — wygeneruje migrację dla 3 konkretnych modeli:
+ ```bash
+ uv run python src/manage.py makemigrations bpp --name autor_soft_delete_fields
+ ```
+ (Spodziewany plik: `src/bpp/migrations/0421_autor_soft_delete_fields.py`, 3 pola × 3 modele = 9 `AddField`. Manager-y są `use_in_migrations=False` domyślnie, więc nie pojawią się w migracji.)
+
+- [ ] Zweryfikuj treść wygenerowanej migracji — musi zawierać `AddField` `deleted_at`/`restored_at`/`transaction_id` dla `wydawnictwo_ciagle_autor`, `wydawnictwo_zwarte_autor`, `patent_autor`. Jeśli Django dorzuciło `AlterModelManagers` — usuń tę operację ręcznie (Edit), bo managery soft-delete nie idą do schematu. Dependency MUSI być `("bpp", "0420_autor_pokazuj_siec_powiazan_and_more")`.
+
+- [ ] Dodaj indeks na `deleted_at` do każdej z 3 tabel. Dopisz do `operations` w `0421_autor_soft_delete_fields.py` (po `AddField`-ach), używając `AddIndex`:
+ ```python
+ migrations.AddIndex(
+ model_name="wydawnictwo_ciagle_autor",
+ index=models.Index(
+ fields=["deleted_at"],
+ name="wc_autor_deleted_at_idx",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="wydawnictwo_zwarte_autor",
+ index=models.Index(
+ fields=["deleted_at"],
+ name="wz_autor_deleted_at_idx",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="patent_autor",
+ index=models.Index(
+ fields=["deleted_at"],
+ name="patent_autor_deleted_at_idx",
+ ),
+ ),
+ ```
+ (Nazwy indeksów ≤ 30 znaków — wymóg PostgreSQL/Django. Jeśli `makemigrations` samo dodało `Meta.indexes` przez zmianę modelu — nie dublować; w tej fazie indeks definiujemy WYŁĄCZNIE w migracji, bo `Meta.indexes` w abstrakcyjnej bazie dałby kolizję nazw między 3 tabelami.)
+
+- [ ] Uruchom `makemigrations --check` (oczekiwane: brak nowych zmian — model i migracja zgodne):
+ ```bash
+ uv run python src/manage.py makemigrations bpp --check --dry-run
+ ```
+
+- [ ] Uruchom test (oczekiwany PASS):
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py -q
+ ```
+
+- [ ] Sanity: czy nie rozjechały się inne testy modeli/adminu autorstwa (manager `objects` zmienił klasę):
+ ```bash
+ uv run pytest src/bpp/tests/test_cache/ -q
+ ```
+ (Oczekiwany PASS — filtr `deleted_at__isnull=True` na świeżych danych = no-op.)
+
+- [ ] Commit:
+ ```bash
+ git add src/bpp/models/abstract/authors.py src/bpp/migrations/0421_autor_soft_delete_fields.py src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py
+ git commit -m "feat(soft-delete): *_Autor → SoftDeleteModel + migracja pól deleted_at + indeks
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Task 3 — Migracja SQL: filtr `deleted_at IS NULL` w widokach `bpp_*_autorzy` + trigger-skip
+
+Przedefiniowanie 3 widoków źródłowych (`bpp_wydawnictwo_ciagle_autorzy`, `bpp_wydawnictwo_zwarte_autorzy`, `bpp_patent_autorzy`) z filtrem po **własnej** kolumnie `deleted_at` tabeli `*_autor` (mechanizm #1, obowiązkowy). Po `DROP ... CASCADE` widoku `bpp_*_autorzy` trzeba odtworzyć też zależny `bpp_autorzy` (UNION). Dodatkowo trigger-skip (#2, opcjonalny) — przedefiniowanie `bpp_refresh_cache()` na bazie 0399 z regułą „deleted_at NOT NULL → delete-only". Migracja ładuje plik `.sql` wzorcem `0399`.
+
+**Files:**
+- Create: `src/bpp/migrations/0422_soft_delete_views.sql`
+- Create: `src/bpp/migrations/0422_soft_delete_views.py`
+- Test: pokrycie w Task 4 (testy spójności cache) — tu tylko migracja stosuje się czysto.
+
+**Steps:**
+
+- [ ] Napisz failing test smoke — dopisz do `src/bpp/tests/test_soft_delete/test_views_sql.py`:
+ ```python
+ """Widoki źródłowe bpp_*_autorzy filtrują po własnym deleted_at."""
+
+ import pytest
+ from django.db import connection
+
+
+ WIDOKI = [
+ "bpp_wydawnictwo_ciagle_autorzy",
+ "bpp_wydawnictwo_zwarte_autorzy",
+ "bpp_patent_autorzy",
+ ]
+
+
+ @pytest.mark.django_db
+ @pytest.mark.parametrize("widok", WIDOKI)
+ def test_widok_zrodlowy_ma_filtr_deleted_at(widok):
+ """Definicja widoku w pg_get_viewdef musi zawierać 'deleted_at'
+ (filtr po własnej kolumnie tabeli autorskiej)."""
+ with connection.cursor() as cur:
+ cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok])
+ defn = cur.fetchone()[0]
+ assert "deleted_at" in defn, f"{widok} nie filtruje po deleted_at"
+ ```
+
+- [ ] Uruchom (oczekiwany FAIL — widoki jeszcze bez `deleted_at`):
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete/test_views_sql.py -q
+ ```
+
+- [ ] Utwórz `src/bpp/migrations/0422_soft_delete_views.sql`. Treść = 3 widoki `bpp_*_autorzy` z dodanym `AND .deleted_at IS NULL`, odtworzenie `bpp_autorzy` (UNION, bo `DROP CASCADE` go skasuje), oraz przedefiniowanie `bpp_refresh_cache()` skopiowane z `0399_fix_refresh_cache_upsert.sql` z dopisanym trigger-skip w gałęzi `UPDATE/INSERT`:
+ ```sql
+ BEGIN;
+
+ -- ── Mechanizm #1 (OBOWIĄZKOWY): filtr deleted_at w widokach źródłowych ──
+ -- Po DROP ... CASCADE widoku bpp_*_autorzy znika też zależny bpp_autorzy,
+ -- więc odtwarzamy go niżej. Filtr po WŁASNEJ kolumnie deleted_at tabeli
+ -- autorskiej (bez JOIN do rekordu nadrzędnego) — patrz spec §2.1.
+
+ DROP VIEW IF EXISTS bpp_wydawnictwo_ciagle_autorzy CASCADE;
+ CREATE OR REPLACE VIEW bpp_wydawnictwo_ciagle_autorzy AS
+ select
+ django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id,
+ django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id,
+ django_content_type.id AS content_type_id,
+ rekord_id as object_id,
+ autor_id,
+ jednostka_id,
+ kolejnosc,
+ typ_odpowiedzialnosci_id,
+ zapisany_jako
+ from bpp_wydawnictwo_ciagle_autor, django_content_type
+ WHERE django_content_type.model = 'wydawnictwo_ciagle'
+ AND django_content_type.app_label = 'bpp'
+ AND bpp_wydawnictwo_ciagle_autor.deleted_at IS NULL;
+
+ DROP VIEW IF EXISTS bpp_wydawnictwo_zwarte_autorzy CASCADE;
+ CREATE OR REPLACE VIEW bpp_wydawnictwo_zwarte_autorzy AS
+ select
+ django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id,
+ django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id,
+ django_content_type.id AS content_type_id,
+ rekord_id as object_id,
+ autor_id,
+ jednostka_id,
+ kolejnosc,
+ typ_odpowiedzialnosci_id,
+ zapisany_jako
+ from bpp_wydawnictwo_zwarte_autor, django_content_type
+ WHERE django_content_type.model = 'wydawnictwo_zwarte'
+ AND django_content_type.app_label = 'bpp'
+ AND bpp_wydawnictwo_zwarte_autor.deleted_at IS NULL;
+
+ DROP VIEW IF EXISTS bpp_patent_autorzy CASCADE;
+ CREATE OR REPLACE VIEW bpp_patent_autorzy AS
+ select
+ django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id,
+ django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id,
+ django_content_type.id AS content_type_id,
+ rekord_id as object_id,
+ autor_id,
+ jednostka_id,
+ kolejnosc,
+ typ_odpowiedzialnosci_id,
+ zapisany_jako
+ from bpp_patent_autor, django_content_type
+ WHERE django_content_type.model = 'patent'
+ AND django_content_type.app_label = 'bpp'
+ AND bpp_patent_autor.deleted_at IS NULL;
+
+ -- Odtworzenie UNION bpp_autorzy (skasowany przez DROP ... CASCADE powyżej).
+ -- bpp_praca_doktorska_autorzy / bpp_praca_habilitacyjna_autorzy NIE były
+ -- ruszane (autorstwo doktoratu/habilitacji nie jest *_Autor SoftDeleteModel
+ -- w tej fazie) — wciąż istnieją, więc UNION je dociągnie.
+ DROP VIEW IF EXISTS bpp_autorzy;
+ CREATE VIEW bpp_autorzy AS
+ SELECT * FROM bpp_wydawnictwo_ciagle_autorzy
+ UNION
+ SELECT * FROM bpp_wydawnictwo_zwarte_autorzy
+ UNION
+ SELECT * FROM bpp_patent_autorzy
+ UNION
+ SELECT * FROM bpp_praca_doktorska_autorzy
+ UNION
+ SELECT * FROM bpp_praca_habilitacyjna_autorzy;
+
+ -- ── Mechanizm #2 (OPCJONALNY): trigger-skip w bpp_refresh_cache() ──
+ -- Kopia 0399_fix_refresh_cache_upsert.sql z jedną zmianą: w gałęzi
+ -- UPDATE/INSERT, gdy nowy wiersz ma deleted_at IS NOT NULL, pomijamy upsert
+ -- (zostaje samo DELETE z _mat). Filtr widoku #1 i tak pokrywa odczyt, ale to
+ -- oszczędza no-op SELECT/INSERT przy kaskadzie soft-delete na *_Autor.
+ CREATE OR REPLACE FUNCTION bpp_refresh_cache()
+ RETURNS TRIGGER
+ LANGUAGE plpython3u
+ AS $$
+ cache_key = "django_content_type_ver_1"
+ columns_cache_key = "table_columns_ver_1"
+ table_name = TD["table_name"]
+ app_name, model_name = table_name.split("_", 1)
+
+ refresh_rekord = True
+ refresh_autor = False
+
+ trigger_field_name = "new"
+ if TD['event'] in ["DELETE", "UPDATE"]:
+ trigger_field_name = "old"
+
+ TABELE_AUTORSKIE = ['bpp_wydawnictwo_ciagle_autor', 'bpp_wydawnictwo_zwarte_autor', 'bpp_patent_autor']
+ id_field_name = 'id'
+ extra_where = ''
+ if table_name in TABELE_AUTORSKIE:
+ id_field_name = 'rekord_id'
+ model_name = model_name.replace("_autor", "")
+ refresh_autor = True
+ refresh_rekord = False
+ extra_where = ' AND autor_id = %s' % TD[trigger_field_name]['autor_id']
+
+ object_id = TD[trigger_field_name][id_field_name]
+
+ if GD.get(cache_key) is None:
+ GD[cache_key] = {}
+
+ if GD.get(columns_cache_key) is None:
+ GD[columns_cache_key] = {}
+
+ try:
+ content_type_id = GD[cache_key][table_name]
+ except KeyError:
+ query = "SELECT id FROM django_content_type WHERE app_label = '%s' AND model = '%s'" % (app_name, model_name)
+ res = plpy.execute(query)
+ GD[cache_key][table_name] = res[0]['id']
+ content_type_id = GD[cache_key][table_name]
+
+ if TD["table_name"] in ["bpp_praca_doktorska", "bpp_praca_habilitacyjna"]:
+ refresh_autor = True
+
+ where = "WHERE %%s = ARRAY[%s, %s]::INTEGER[2]" % (content_type_id, object_id)
+ where += extra_where
+
+ # ── trigger-skip: soft-delete (UPDATE z deleted_at IS NOT NULL) ──
+ # zachowuje się jak DELETE (samo wyczyszczenie _mat, bez re-insertu).
+ skip_reinsert = (
+ TD["event"] in ["UPDATE", "INSERT"]
+ and TD["new"] is not None
+ and TD["new"].get("deleted_at") is not None
+ )
+
+ refresh_tables = []
+ if refresh_rekord:
+ refresh_tables.append(("bpp_rekord_mat", "id"))
+ refresh_tables.append(("bpp_autorzy_mat", "rekord_id"))
+ if refresh_autor:
+ if "bpp_autorzy_mat" not in [t for t, _ in refresh_tables]:
+ refresh_tables.append(("bpp_autorzy_mat", "rekord_id"))
+
+ def get_table_columns(mat_table):
+ if mat_table not in GD[columns_cache_key]:
+ query = """
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = '%s'
+ ORDER BY ordinal_position
+ """ % mat_table
+ res = plpy.execute(query)
+ GD[columns_cache_key][mat_table] = [row['column_name'] for row in res]
+ return GD[columns_cache_key][mat_table]
+
+ def get_unique_constraint_column(mat_table):
+ return "id"
+
+ with plpy.subtransaction():
+ for table, id_col in refresh_tables:
+ lock_key = hash(f"{table}_{content_type_id}_{object_id}") % (2**31)
+ plpy.execute(f"SELECT pg_advisory_xact_lock({lock_key})")
+
+ if TD["event"] == "DELETE" or skip_reinsert:
+ query = "DELETE FROM " + table + " " + (where % id_col)
+ plpy.execute(query)
+ elif TD["event"] in ["UPDATE", "INSERT"]:
+ source_view = table.replace("_mat", "")
+ columns = get_table_columns(table)
+ conflict_col = get_unique_constraint_column(table)
+ columns_str = ", ".join(columns)
+ update_columns = [col for col in columns if col != conflict_col]
+ set_clause = ", ".join([f"{col} = EXCLUDED.{col}" for col in update_columns])
+ delete_query = "DELETE FROM " + table + " " + (where % id_col)
+ plpy.execute(delete_query)
+ select_query = f"SELECT {columns_str} FROM {source_view} " + (where % id_col)
+ upsert_query = f"""
+ INSERT INTO {table} ({columns_str})
+ {select_query}
+ ON CONFLICT ({conflict_col}) DO UPDATE SET {set_clause}
+ """
+ plpy.execute(upsert_query)
+ $$;
+
+ COMMIT;
+ ```
+ (UWAGA: `refresh_tables` w 0399 to lista krotek `(table, id_col)`, więc sprawdzenie `"bpp_autorzy_mat" not in refresh_tables` z 0399 było błędne dla krotek — tu poprawiamy na `not in [t for t, _ in refresh_tables]`. Reszta logiki 1:1 z 0399.)
+
+- [ ] Utwórz `src/bpp/migrations/0422_soft_delete_views.py` (wzorzec `0399`):
+ ```python
+ from pathlib import Path
+
+ from django.db import connection, migrations
+
+
+ def load_sql(apps, schema_editor):
+ sql_file = Path(__file__).parent / "0422_soft_delete_views.sql"
+ with open(sql_file) as f:
+ sql = f.read()
+ # connection.cursor() zamiast schema_editor.execute(): schema_editor
+ # interpretuje %s jako placeholdery parametrów (a w plpython3u są %s
+ # w stringach SQL budowanych ręcznie).
+ with connection.cursor() as cursor:
+ cursor.execute(sql)
+
+
+ class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bpp", "0421_autor_soft_delete_fields"),
+ ]
+
+ operations = [
+ migrations.RunPython(load_sql, migrations.RunPython.noop),
+ ]
+ ```
+
+- [ ] Uruchom test smoke (oczekiwany PASS — migracja zastosuje się przy starcie testowej bazy, widoki będą miały `deleted_at`):
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete/test_views_sql.py -q
+ ```
+
+- [ ] Zweryfikuj brak driftu migracji i czystość modeli:
+ ```bash
+ uv run python src/manage.py makemigrations bpp --check --dry-run
+ ```
+
+- [ ] Commit:
+ ```bash
+ git add src/bpp/migrations/0422_soft_delete_views.sql src/bpp/migrations/0422_soft_delete_views.py src/bpp/tests/test_soft_delete/test_views_sql.py
+ git commit -m "feat(soft-delete): filtr deleted_at w widokach bpp_*_autorzy + trigger-skip
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Task 4 — Testy spójności cache (mat-view) po soft-delete `*_Autor`
+
+Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` (model `Autorzy`) i z `bpp_autorzy` (model `AutorzyView`); restore → wraca; edycja autorstwa skasowanej publikacji nie wskrzesza wiersza w cache; kaskada queryset-owa (`.delete()` na QS) działa per-instancja. Testy wymagają `transactional_db` (trigger działa tylko z prawdziwym commitem).
+
+**Files:**
+- Test (create): `src/bpp/tests/test_soft_delete/test_cache_consistency.py`
+- Modify (jeśli testy ujawnią drift): brak planowanych — testy mają przejść na implementacji z Task 2-3.
+
+**Steps:**
+
+- [ ] Napisz testy spójności — `src/bpp/tests/test_soft_delete/test_cache_consistency.py`:
+ ```python
+ """Spójność materializowanego cache (bpp_autorzy_mat / model Autorzy) po
+ soft-delete wierszy *_Autor. Wymaga transactional_db — trigger plpython3u
+ odpala się dopiero przy realnym commicie."""
+
+ import pytest
+
+ from bpp.models.cache import Autorzy, AutorzyView
+ from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+
+
+ def _autorzy_mat_dla(wca):
+ """Wiersze bpp_autorzy_mat (model Autorzy) wskazujące na danego autora
+ w danym rekordzie."""
+ return Autorzy.objects.filter(
+ autor_id=wca.autor_id,
+ rekord_id=[wca.rekord.content_type_id, wca.rekord_id],
+ )
+
+
+ def test_soft_delete_autorstwa_znika_z_mat(
+ transactional_db, denorms, wydawnictwo_ciagle_z_dwoma_autorami
+ ):
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ denorms.flush()
+ wca = wc.autorzy_set.first()
+ autor_id = wca.autor_id
+
+ # Przed: autor jest w bpp_autorzy_mat
+ assert Autorzy.objects.filter(autor_id=autor_id).exists()
+ # ... i w bpp_autorzy (widok źródłowy)
+ assert AutorzyView.objects.filter(autor_id=autor_id).exists()
+
+ wca.delete() # soft-delete per instancja
+
+ # Po: znika z mat-view (trigger + filtr widoku) ...
+ assert not Autorzy.objects.filter(autor_id=autor_id).exists()
+ # ... i z widoku źródłowego (mechanizm #1)
+ assert not AutorzyView.objects.filter(autor_id=autor_id).exists()
+ # Drugi autor pracy NIE zniknął
+ assert Autorzy.objects.filter(rekord_id__isnull=False).exists()
+
+
+ def test_restore_autorstwa_wraca_do_mat(
+ transactional_db, denorms, wydawnictwo_ciagle_z_autorem
+ ):
+ wc = wydawnictwo_ciagle_z_autorem
+ denorms.flush()
+ wca = wc.autorzy_set.first()
+ autor_id = wca.autor_id
+ pk = wca.pk
+
+ wca.delete()
+ assert not Autorzy.objects.filter(autor_id=autor_id).exists()
+
+ Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore()
+
+ # Restore → re-insert do mat-view
+ assert Autorzy.objects.filter(autor_id=autor_id).exists()
+ assert AutorzyView.objects.filter(autor_id=autor_id).exists()
+
+
+ def test_edycja_skasowanego_autorstwa_nie_wskrzesza_w_mat(
+ transactional_db, denorms, wydawnictwo_ciagle_z_autorem
+ ):
+ """Zapis skasowanego wiersza *_Autor (np. zmiana kolejnosc) NIE wraca
+ do bpp_autorzy_mat — widok źródłowy go odfiltrowuje po własnym
+ deleted_at (mechanizm #1)."""
+ wc = wydawnictwo_ciagle_z_autorem
+ denorms.flush()
+ wca = wc.autorzy_set.first()
+ autor_id = wca.autor_id
+ pk = wca.pk
+
+ wca.delete()
+ assert not Autorzy.objects.filter(autor_id=autor_id).exists()
+
+ # Edycja skasowanego wiersza (przez global_objects, bo objects ukrywa)
+ skasowany = Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk)
+ skasowany.kolejnosc = 99
+ skasowany.save() # odpala trigger jako UPDATE z deleted_at NOT NULL
+
+ # Nadal nie ma go w mat-view (kluczowy przypadek brzegowy ze spec §2.1)
+ assert not Autorzy.objects.filter(autor_id=autor_id).exists()
+
+
+ def test_queryset_delete_kaskaduje_per_instancja(
+ transactional_db, denorms, wydawnictwo_ciagle_z_dwoma_autorami
+ ):
+ """.delete() na QuerySet soft-deletuje per instancję (iterator) —
+ wszystkie wiersze znikają z mat-view, gate update() nie blokuje QS-delete."""
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ denorms.flush()
+ assert Autorzy.objects.count() >= 2
+
+ Wydawnictwo_Ciagle_Autor.objects.filter(rekord=wc).delete()
+
+ # Wszystkie autorstwa tej pracy zniknęły z mat-view
+ assert not Autorzy.objects.filter(
+ rekord_id=[wc.content_type_id, wc.pk]
+ ).exists()
+ # ... ale wiersze fizycznie żyją (soft, nie hard)
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(rekord=wc).count() >= 2
+ ```
+
+- [ ] Uruchom (oczekiwany PASS — implementacja z Task 2+3 pokrywa wszystkie ścieżki):
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete/test_cache_consistency.py -q
+ ```
+ Jeśli `test_edycja_skasowanego_autorstwa_nie_wskrzesza_w_mat` FAIL → znaczy, że trigger-skip lub filtr widoku nie działa. Diagnoza: sprawdź `pg_get_viewdef('bpp_wydawnictwo_ciagle_autorzy')` (czy `deleted_at IS NULL` obecne) — to obowiązkowy mechanizm #1; trigger-skip sam nie wystarcza dla tej ścieżki (potwierdza spec §2.1). Użyj superpowers:systematic-debugging, NIE łataj testu.
+
+- [ ] Commit:
+ ```bash
+ git add src/bpp/tests/test_soft_delete/test_cache_consistency.py
+ git commit -m "test(soft-delete): spójność bpp_autorzy_mat po soft-delete/restore *_Autor
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Task 5 — Weryfikacja całości fazy (ruff + regresja cache/autorstwa)
+
+Gejt zamykający fazę: lint czysty, brak driftu migracji, testy cache + admin autorstwa + API nie regresują przez zmianę domyślnego managera `objects` na `BppSoftDeleteManager`.
+
+**Files:** brak (tylko uruchomienia).
+
+**Steps:**
+
+- [ ] Lint i format (NIE używaj `--fix`; fixy ręczne przez Edit):
+ ```bash
+ ruff format src/bpp/models/soft_delete.py src/bpp/models/abstract/authors.py src/bpp/tests/test_soft_delete/
+ ruff check src/bpp/models/soft_delete.py src/bpp/models/abstract/authors.py src/bpp/tests/test_soft_delete/
+ ```
+
+- [ ] Brak driftu migracji:
+ ```bash
+ uv run python src/manage.py makemigrations --check --dry-run
+ ```
+
+- [ ] Pełna regresja podsystemów dotkniętych zmianą managera `*_Autor.objects` (cache, admin, API autorstwa). To wyłapie ewentualne miejsca, gdzie kod zakładał, że `objects` zwraca też „skasowane" (w tej fazie nic nie jest skasowane na świeżych fixtach → musi przejść):
+ ```bash
+ uv run pytest src/bpp/tests/test_cache/ src/bpp/tests/test_soft_delete/ src/api_v1/ -q
+ ```
+
+- [ ] Jeśli wszystko zielone — faza 01 gotowa. Commit jeśli ruff coś poprawił:
+ ```bash
+ git add -A
+ git commit -m "chore(soft-delete): ruff + weryfikacja regresji fazy 01
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Założenia i ostrzeżenia między-fazowe (dla faz 02+)
+
+1. **Domyślny manager `*_Autor.objects` zmienił klasę** na `BppSoftDeleteManager` (filtruje `deleted_at__isnull=True`). Faza 03 (audyt kat. B) MUSI przejść 90 miejsc `*_Autor.objects` — w fazie 01 nic nie jest skasowane, więc filtr jest no-op, ale od fazy 02 (kaskada soft-delete publikacji) zacznie ukrywać. Guard autora (faza 04) MUSI liczyć przez `global_objects` (spec §3.2).
+2. **Trigger-skip oparty na 0399**, nie 0001. Każda przyszła zmiana `bpp_refresh_cache()` musi wychodzić od `0422_soft_delete_views.sql` (nie od 0399 ani 0001). Naprawiono przy okazji błąd `"bpp_autorzy_mat" not in refresh_tables` (lista krotek) z 0399 — zweryfikować, czy 0399 faktycznie nie dublował `bpp_autorzy_mat` (jeśli dublował, to drobny regres wydajności, nie poprawności).
+3. **Widoki `bpp_praca_doktorska_autorzy` / `bpp_praca_habilitacyjna_autorzy` NIE filtrowane** — autorstwo doktoratu/habilitacji nie jest `*_Autor` SoftDeleteModel (autor doktoratu to FK `Praca_Doktorska.autor`, nie through). Faza 02 (soft-delete publikacji doktorat/habilitacja) musi zadbać o ich zniknięcie z `bpp_rekord` przez własne `deleted_at` na tabeli publikacji — to NIE jest pokryte tą fazą.
+4. **Gałęzie UNION `bpp_rekord` NIE dotknięte** w fazie 01 — soft-delete publikacji (kolumna `deleted_at` na `bpp_wydawnictwo_ciagle` itd.) to faza 02; dopiero ona doda filtr `deleted_at IS NULL` do `bpp_*_view`. Faza 01 dotyka wyłącznie ścieżki autorstwa.
+5. **`unique_together` na `*_Autor` zachowane bez `deleted_at`** — w tej fazie autorstwa nie mają warunkowego unique. Jeśli przyszła faza pozwoli na re-add tego samego autora po soft-delete (kolizja `(rekord, autor, typ_odpowiedzialnosci)`), trzeba będzie przejść na `UniqueConstraint(condition=Q(deleted_at__isnull=True))` — odłożone, poza zakresem 01.
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
new file mode 100644
index 000000000..a88c879f3
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
@@ -0,0 +1,558 @@
+# Soft-delete — Faza 02: Publikacje (5 modeli → SoftDeleteModel + wąska kaskada na `*_Autor`) — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Każdy krok TDD: napisz padający test → uruchom (oczekiwany FAIL) → minimalna implementacja → uruchom (PASS) → commit.
+
+**Goal:** Uczynić 5 modeli publikacji (`Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`, `Praca_Habilitacyjna`, `Patent`) `SoftDeleteModel`-ami z **wąską, kontrolowaną kaskadą** soft-delete na własne wiersze `*_Autor` (`Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) pod wspólnym `transaction_id`, **bez** refleksyjnej kaskady pakietu (która ruszyłaby `*_Streszczenie` itd.). Zamienić `slug unique=True` na warunkowy `UniqueConstraint` (reuse slug po soft-delete). Przepleść filtr soft-delete z istniejącymi menedżerami `Wydawnictwo_*_Manager` (mixin opłat) przez wspólny QuerySet/MRO, bez nadpisywania metod fees.
+
+**Architecture:** `django-soft-delete` daje `SoftDeleteModel` (pola `deleted_at`/`restored_at`/`transaction_id`, menedżery `objects`/`global_objects`/`deleted_objects`, sygnały `post_soft_delete`/`post_restore`/`post_hard_delete`). Faza 01 utworzyła `src/bpp/models/soft_delete.py` z `BppSoftDeleteQuerySet` (gate na bulk `update(deleted_at=...)`), `BppSoftDeleteManager`, `BppGlobalManager` oraz uczyniła 3 modele `*_Autor` SoftDeleteModel-ami (+ filtr `deleted_at` w widokach źródłowych). **Ta faza zależy od 01.** Tu nadpisujemy `delete()`/`restore()` na 5 modelach: per-instancja `save()` (NIGDY bulk `update`), jawna wąska kaskada na `autorzy_set` (related_name `*_Autor`→publikacja) przez `.delete(transaction_id=...)`/`.restore(transaction_id=...)` na każdym wierszu (kontrakt z reversion: zawsze per-instancja).
+
+**Tech Stack:** Django, PostgreSQL, `django-soft-delete>=1.0.23`, `django-denorm-iplweb` (slug jest polem `@denormalized`!), pytest + `model_bakery.baker`. Python wyłącznie przez `uv run`. Linia ≤88 znaków (ruff). Komentarze/komunikaty po polsku.
+
+**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§2.2 wąska kaskada, §2.3 slug, §2.4 menedżery). **Indeks (kontrakty PINNED):** [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md).
+
+---
+
+## Założenia wejściowe (z fazy 01 — VERBATIM, nie zmieniać)
+
+- `src/bpp/models/soft_delete.py` istnieje i eksportuje `BppSoftDeleteQuerySet`, `BppSoftDeleteManager`, `BppGlobalManager` (kod w indeksie §"Nowy moduł").
+- `BppSoftDeleteQuerySet.update()` rzuca `RuntimeError`, gdy w kwargs jest `deleted_at` lub `restored_at` (gate fail-fast). **Z tego wynika twardy zakaz `.update(deleted_at=...)` w tej fazie — kaskada MUSI iść per-instancja.**
+- `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` są już `SoftDeleteModel` (mają `deleted_at`, `.delete()`/`.restore()`/`global_objects`/`deleted_objects`).
+- Related name `*_Autor` → publikacja: `autorzy_set` (potwierdzone: `wydawnictwo_ciagle.py:59`, `wydawnictwo_zwarte.py:68`, `patent.py:35`).
+
+## Fakty z kodu (zweryfikowane — używaj tych nazw VERBATIM)
+
+- `Wydawnictwo_Ciagle` (`src/bpp/models/wydawnictwo_ciagle.py:91`): `objects = Wydawnictwo_Ciagle_Manager()` (`:185`). Manager `Wydawnictwo_Ciagle_Manager(ManagerModeliZOplataZaPublikacjeMixin, models.Manager)` (`:87`).
+- `Wydawnictwo_Zwarte` (`:176`): `objects = Wydawnictwo_Zwarte_Manager()` (`:197`). Manager `Wydawnictwo_Zwarte_Manager(ManagerModeliZOplataZaPublikacjeMixin, models.Manager)` (`:167`) z metodą `wydawnictwa_nadrzedne_dla_innych()`. Self-FK `wydawnictwo_nadrzedne` (`:202`, CASCADE — flip na PROTECT robi faza 04, NIE tu).
+- `ManagerModeliZOplataZaPublikacjeMixin` (`src/bpp/models/abstract/fees.py:9`) — czysty mixin (NIE Manager), jedyna metoda `rekordy_z_oplata(self)` → `self.exclude(opl_pub_cost_free=None)`. Operuje na queryset menedżera, więc działa poprawnie nad każdym QuerySet-em.
+- `Patent`, `Praca_Doktorska`, `Praca_Habilitacyjna` — **NIE mają** własnego menedżera (używają domyślnego `models.Manager` jako `objects`).
+- `Praca_Doktorska.autor` FK CASCADE (`praca_doktorska.py:136`), `Praca_Habilitacyjna.autor` O2O PROTECT (`praca_habilitacyjna.py:42`). Te FK to **faza 04** — NIE ruszamy tu.
+- **`slug` jest polem `@denormalized(models.SlugField, max_length=400, unique=True, db_index=True, null=True, blank=True)`** (denorm z `django-denorm-iplweb`), w: `wydawnictwo_ciagle.py:246`, `wydawnictwo_zwarte.py:325` (w `Wydawnictwo_Zwarte`), `patent.py:180`, `praca_doktorska.py:105` (w `Praca_Doktorska_Baza` → dziedziczone przez `Praca_Doktorska` **i** `Praca_Habilitacyjna`). Denorm field jest fizyczną kolumną w DB → migracja zmiany `unique=True`→`UniqueConstraint` jest realną migracją schematu.
+- `Praca_Habilitacyjna` i `Praca_Doktorska` dziedziczą slug z `Praca_Doktorska_Baza` (abstract) — zmiana atrybutu pola w abstrakcie dotyka OBU modeli; migracje per model (każdy ma własną kolumnę `slug`).
+- Następny numer migracji: `0421` (ostatnia: `0420_autor_pokazuj_siec_powiazan_and_more.py`). NIE modyfikuj istniejących migracji.
+- `Zgloszenie_Publikacji` (`src/zglos_publikacje/models.py:60`) — precedens: po prostu dziedziczy `SoftDeleteModel` bez własnego menedżera.
+
+## Kontrakt z reversion (PINNED — NIE łamać)
+
+- `delete()`/`restore()` i kaskada na `*_Autor` idą **wyłącznie** przez per-instancja `.delete()`/`.restore()`/`save()`. **NIGDY** `autorzy_set.update(deleted_at=...)` ani `autorzy_set.all().delete()` jeśli to bulk — używamy pętli per-instancja, żeby `post_save`/sygnały odpaliły. Gate w `BppSoftDeleteQuerySet.update()` egzekwuje to fail-fast (test to weryfikuje).
+- Sygnatury override: `delete(self, *args, user=None, reason="", **kwargs)` i `restore(self, *args, user=None, **kwargs)`. `user`/`reason` na razie tylko przepuszczamy do `super()`/sygnałów (konsumuje je faza 06); tu MUSZĄ istnieć w sygnaturze.
+
+---
+
+## Task 1: Mixin `delete()`/`restore()` z wąską kaskadą — `BppPublikacjaSoftDeleteMixin`
+
+**Files:**
+- Modify: `src/bpp/models/soft_delete.py` (dodaj klasę mixinu na końcu)
+- Test path: `src/bpp/tests/test_soft_delete_publikacje.py` (nowy plik)
+
+Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-instancja `save()` rodzica, jawna wąska kaskada na `autorzy_set` (każdy wiersz `*_Autor` przez `.delete(transaction_id=...)`), bez refleksyjnej kaskady pakietu (`super().delete()` nie wołamy — sami ustawiamy `deleted_at` + emitujemy sygnał, żeby NIE ruszać `*_Streszczenie`). Wszystkie 5 modeli mają `autorzy_set` (potwierdzone), więc kaskada jest jednolita.
+
+- [ ] **Krok 1.1 — padający test: soft-delete publikacji kaskaduje na `*_Autor` tym samym `transaction_id`.**
+ Dopisz do `src/bpp/tests/test_soft_delete_publikacje.py`:
+ ```python
+ import pytest
+ from model_bakery import baker
+
+ from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor
+
+
+ @pytest.mark.django_db
+ def test_soft_delete_publikacji_kaskaduje_na_autor_wspolny_txid():
+ wc = baker.make(Wydawnictwo_Ciagle)
+ a1 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc)
+ a2 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc)
+
+ wc.delete()
+
+ wc.refresh_from_db()
+ assert wc.deleted_at is not None
+ assert wc.transaction_id is not None
+
+ for a in (a1, a2):
+ row = Wydawnictwo_Ciagle_Autor.global_objects.get(pk=a.pk)
+ assert row.deleted_at is not None, "autorstwo nie zostało soft-skasowane"
+ assert row.transaction_id == wc.transaction_id, "różny transaction_id"
+ ```
+- [ ] **Krok 1.2 — uruchom, oczekiwany FAIL** (kaskady jeszcze nie ma; `*_Autor` zostaje nieskasowane):
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_soft_delete_publikacji_kaskaduje_na_autor_wspolny_txid -x
+ ```
+ Oczekiwane: `AssertionError: autorstwo nie zostało soft-skasowane` (lub `Wydawnictwo_Ciagle` nie jest jeszcze SoftDeleteModel → `AttributeError`/błąd importu; obie wersje to FAIL przed implementacją — implementację robią Task 1+2 razem).
+- [ ] **Krok 1.3 — minimalna implementacja: mixin.** Dopisz do `src/bpp/models/soft_delete.py`:
+ ```python
+ import uuid
+
+ from django.db import transaction
+ from django.utils import timezone
+ from django_softdelete.models import SoftDeleteModel
+ from django_softdelete.signals import post_restore, post_soft_delete
+
+
+ class BppPublikacjaSoftDeleteMixin(SoftDeleteModel):
+ """Wąska, kontrolowana kaskada soft-delete: rodzic + własne wiersze
+ `*_Autor` (related_name `autorzy_set`) pod wspólnym `transaction_id`.
+
+ NIE używa refleksyjnej kaskady pakietu (rzuciłaby SoftDeleteException
+ na `*_Streszczenie`/`*_Zewnetrzna_Baza_Danych`/`Publikacja_Habilitacyjna`
+ przy strict=True, albo twardo skasowała je przy strict=False). Kaskada
+ zatrzymuje się na `*_Autor`. Kontrakt z reversion: zawsze per-instancja
+ save()/delete(), NIGDY bulk update(deleted_at=...).
+ """
+
+ class Meta:
+ abstract = True
+
+ def delete(self, *args, user=None, reason="", **kwargs):
+ now = timezone.now()
+ txid = kwargs.pop("transaction_id", None) or uuid.uuid4()
+ with transaction.atomic():
+ # 1. wąska kaskada na własne *_Autor (per-instancja!)
+ for autorstwo in self.autorzy_set.all():
+ autorstwo.delete(transaction_id=txid)
+ # 2. własne deleted_at + save (NIGDY bulk update)
+ self.deleted_at = now
+ self.restored_at = None
+ self.transaction_id = txid
+ self.save(
+ update_fields=["deleted_at", "restored_at", "transaction_id"]
+ )
+ post_soft_delete.send(sender=self.__class__, instance=self)
+ return 1, {self._meta.label: 1}
+
+ delete.alters_data = True
+
+ def restore(self, *args, user=None, **kwargs):
+ txid = self.transaction_id
+ with transaction.atomic():
+ # przywróć własne *_Autor skasowane tym samym transaction_id
+ if txid is not None:
+ for autorstwo in self.autorzy_set.model.deleted_objects.filter(
+ rekord=self, transaction_id=txid
+ ):
+ autorstwo.restore(transaction_id=txid)
+ self.deleted_at = None
+ self.restored_at = timezone.now()
+ self.transaction_id = None
+ self.save(
+ update_fields=["deleted_at", "restored_at", "transaction_id"]
+ )
+ post_restore.send(
+ sender=self.__class__, instance=self, transaction_id=txid
+ )
+
+ restore.alters_data = True
+ ```
+ > Uwaga: `self.autorzy_set.all()` używa **domyślnego** menedżera `*_Autor` (`objects` ukrywa już-skasowane) — przy delete to OK (skasowane drugi raz nie szkodzi, a zwykle nie ma takich). `restore()` celowo czyta przez `deleted_objects` po `transaction_id`, bo `objects` ukrywa skasowane. `*_Autor.restore()` pochodzi z `SoftDeleteModel` (faza 01) — bez własnej kaskady (liść).
+- [ ] **Krok 1.4** — implementacja sama nie przejdzie testu, dopóki `Wydawnictwo_Ciagle` nie dziedziczy mixinu (Task 2). NIE uruchamiaj jeszcze testu na PASS — przejdź do Task 2 (mixin + model muszą być razem). Po Task 2 wrócimy.
+- [ ] **Krok 1.5 — commit szkieletu mixinu:**
+ ```bash
+ git add src/bpp/models/soft_delete.py src/bpp/tests/test_soft_delete_publikacje.py
+ git commit -m "feat(soft-delete): mixin waskiej kaskady delete/restore na *_Autor
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Task 2: 5 modeli dziedziczy mixin + migracje pól `deleted_at`/`restored_at`/`transaction_id` + indeks
+
+**Files:**
+- Modify: `src/bpp/models/wydawnictwo_ciagle.py:91` (klasa `Wydawnictwo_Ciagle` — dopisz `BppPublikacjaSoftDeleteMixin` do baz)
+- Modify: `src/bpp/models/wydawnictwo_zwarte.py:176` (klasa `Wydawnictwo_Zwarte`)
+- Modify: `src/bpp/models/patent.py:62` (klasa `Patent`)
+- Modify: `src/bpp/models/praca_doktorska.py:135` (klasa `Praca_Doktorska`)
+- Modify: `src/bpp/models/praca_habilitacyjna.py:41` (klasa `Praca_Habilitacyjna`)
+- Create: `src/bpp/migrations/0421_publikacje_soft_delete_fields.py`
+- Test path: `src/bpp/tests/test_soft_delete_publikacje.py`
+
+> **Kolejność MRO:** mixin dopisujemy jako **pierwszą** bazę (przed pozostałymi mixinami modelu), żeby jego `delete()`/`restore()` wygrały w MRO nad `models.Model.delete()`. NIE jako ostatnią. `BppPublikacjaSoftDeleteMixin(SoftDeleteModel)` wnosi też pola `deleted_at`/`restored_at`/`transaction_id` i menedżery — ale menedżery dla `Wydawnictwo_*` nadpiszemy w Task 4 (interleaving fees); dla `Patent`/`Praca_*` zostaną menedżery z `SoftDeleteModel`.
+
+- [ ] **Krok 2.1 — dopisz mixin do baz 5 modeli.** Import na górze każdego pliku:
+ ```python
+ from bpp.models.soft_delete import BppPublikacjaSoftDeleteMixin
+ ```
+ i dodaj `BppPublikacjaSoftDeleteMixin,` jako **pierwszą** bazę klasy modelu. Np. w `wydawnictwo_ciagle.py`:
+ ```python
+ class Wydawnictwo_Ciagle(
+ BppPublikacjaSoftDeleteMixin,
+ ZapobiegajNiewlasciwymCharakterom,
+ Wydawnictwo_Baza,
+ ...
+ ```
+ Analogicznie `Wydawnictwo_Zwarte`, `Patent`, `Praca_Doktorska`, `Praca_Habilitacyjna`.
+ > Uwaga `Praca_Doktorska`/`Praca_Habilitacyjna`: dziedziczą po `Praca_Doktorska_Baza`. Dodaj mixin jako pierwszą bazę **konkretnej** klasy (`Praca_Doktorska`, `Praca_Habilitacyjna`), NIE do abstraktu `Praca_Doktorska_Baza` (inaczej `Praca_Habilitacyjna.autor` O2O PROTECT + abstrakt namieszają w MRO; trzymamy mixin na klasach konkretnych).
+- [ ] **Krok 2.2 — wygeneruj migrację pól:**
+ ```bash
+ uv run python src/manage.py makemigrations bpp --name publikacje_soft_delete_fields
+ ```
+ Oczekiwane: nowa migracja `0421_publikacje_soft_delete_fields.py` z `AddField` `deleted_at`/`restored_at`/`transaction_id` dla 5 modeli. Zweryfikuj nazwę pliku (`0421_`); jeśli numer inny — użyj faktycznego.
+- [ ] **Krok 2.3 — dopisz indeks per model na `deleted_at`.** Do wygenerowanej migracji dołóż operacje `AddIndex` (lub edytuj `Meta.indexes` modeli i przegeneruj). Ręcznie w migracji, po `AddField`-ach:
+ ```python
+ from django.db import migrations, models
+
+ # w operations, dla każdego z 5 modeli:
+ migrations.AddIndex(
+ model_name="wydawnictwo_ciagle",
+ index=models.Index(
+ fields=["deleted_at"], name="wc_deleted_at_idx"
+ ),
+ ),
+ # ... analogicznie: wz_deleted_at_idx, patent_deleted_at_idx,
+ # pdok_deleted_at_idx, phab_deleted_at_idx
+ ```
+ > Nazwy indeksów ≤30 znaków (limit Postgres dla auto-nazw nie obowiązuje przy jawnej nazwie, ale trzymaj krótkie i unikalne). Na produkcji rozważ `AddIndexConcurrently` (osobna migracja `atomic=False`) — dla dużych tabel; tu wystarcza zwykły `AddIndex` (decyzja deploymentu, poza zakresem testów).
+- [ ] **Krok 2.4 — sprawdź spójność migracji:**
+ ```bash
+ uv run python src/manage.py makemigrations --check --dry-run bpp
+ ```
+ Oczekiwane: `No changes detected` (po dołożeniu indeksów ręcznie — jeśli zgłasza zmiany, dorównaj `Meta.indexes` modeli do migracji).
+- [ ] **Krok 2.5 — uruchom test z Task 1 (teraz PASS):**
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_soft_delete_publikacji_kaskaduje_na_autor_wspolny_txid -x
+ ```
+ Oczekiwane: PASS.
+- [ ] **Krok 2.6 — commit:**
+ ```bash
+ git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0421_publikacje_soft_delete_fields.py
+ git commit -m "feat(soft-delete): 5 modeli publikacji -> SoftDeleteModel + migracje pol/indeksow
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Task 3: `slug` — warunkowy `UniqueConstraint` (reuse slug po soft-delete)
+
+**Files:**
+- Modify: `src/bpp/models/wydawnictwo_ciagle.py:246` (denorm `slug`: `unique=True`→brak unique; `Meta.constraints`)
+- Modify: `src/bpp/models/wydawnictwo_zwarte.py:325`
+- Modify: `src/bpp/models/patent.py:180`
+- Modify: `src/bpp/models/praca_doktorska.py:105` (w `Praca_Doktorska_Baza`)
+- Create: `src/bpp/migrations/0422_publikacje_slug_warunkowy_unique.py`
+- Test path: `src/bpp/tests/test_soft_delete_publikacje.py`
+
+Zamiana `unique=True` na `models.UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True), name="...")` per model. Skasowany rekord trzyma slug → nowy rekord z tym samym slug-iem nie koliduje (constraint pomija `deleted_at IS NOT NULL`).
+
+> **Wrinkle denorm:** `slug` to `@denormalized(models.SlugField, ..., unique=True, ...)`. `unique=True` jest kwargiem przekazywanym do `SlugField`. Usuwamy `unique=True` z denormalized-deklaracji i dodajemy `UniqueConstraint` w `Meta`. Denorm regeneruje wartość pola po zapisie — sam constraint go nie dotyka, działa na poziomie DB. `Praca_Doktorska_Baza.slug` jest abstrakcyjny → constraint w `Meta` abstraktu **nie** propaguje automatycznie do konkretnych klas; dlatego `UniqueConstraint` dla `Praca_Doktorska`/`Praca_Habilitacyjna` dodaj w `Meta` KAŻDEJ konkretnej klasy (każda ma własną kolumnę `slug`).
+
+- [ ] **Krok 3.1 — padający test: reuse slug po soft-delete.** Dopisz:
+ ```python
+ @pytest.mark.django_db
+ def test_reuse_slug_po_soft_delete():
+ wc1 = baker.make(Wydawnictwo_Ciagle)
+ wc1.refresh_from_db()
+ slug = wc1.slug
+ assert slug
+
+ wc1.delete() # soft
+
+ wc2 = baker.make(Wydawnictwo_Ciagle)
+ wc2.slug = slug
+ wc2.save(update_fields=["slug"]) # nie może rzucić IntegrityError
+ wc2.refresh_from_db()
+ assert wc2.slug == slug
+ ```
+- [ ] **Krok 3.2 — uruchom, oczekiwany FAIL** (`IntegrityError: duplicate key value violates unique constraint` na `slug`, bo `unique=True` jeszcze żyje):
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_reuse_slug_po_soft_delete -x
+ ```
+- [ ] **Krok 3.3 — implementacja: usuń `unique=True`, dodaj constraint.** W każdym z 4 miejsc denorm-deklaracji `slug` usuń linię `unique=True,` (zostaw `db_index=True` — indeks nadal pożądany). W `Meta` każdego z 5 modeli (`Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Patent`, `Praca_Doktorska`, `Praca_Habilitacyjna`) dodaj:
+ ```python
+ from django.db.models import Q # jeśli brak importu w pliku
+
+ class Meta:
+ ...
+ constraints = [
+ models.UniqueConstraint(
+ fields=["slug"],
+ condition=Q(deleted_at__isnull=True),
+ name="wc_slug_uniq_zywe", # unikalna nazwa per model
+ ),
+ ]
+ ```
+ Nazwy: `wc_slug_uniq_zywe`, `wz_slug_uniq_zywe`, `patent_slug_uniq_zywe`, `pdok_slug_uniq_zywe`, `phab_slug_uniq_zywe`.
+- [ ] **Krok 3.4 — migracja:**
+ ```bash
+ uv run python src/manage.py makemigrations bpp --name publikacje_slug_warunkowy_unique
+ ```
+ Oczekiwane: `RemoveField`/`AlterField` (zdjęcie `unique`) + `AddConstraint` dla 5 modeli. Zweryfikuj numer `0422_`.
+- [ ] **Krok 3.5 — uruchom test (PASS) + check migracji:**
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_reuse_slug_po_soft_delete -x
+ uv run python src/manage.py makemigrations --check --dry-run bpp
+ ```
+ Oczekiwane: PASS + `No changes detected`.
+- [ ] **Krok 3.6 — commit:**
+ ```bash
+ git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0422_publikacje_slug_warunkowy_unique.py
+ git commit -m "feat(soft-delete): slug -> warunkowy UniqueConstraint (reuse po soft-delete)
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Task 4: Przeplecenie menedżerów `Wydawnictwo_*_Manager` z filtrem soft-delete (wspólny QuerySet/MRO)
+
+**Files:**
+- Modify: `src/bpp/models/wydawnictwo_ciagle.py:87` (`Wydawnictwo_Ciagle_Manager`)
+- Modify: `src/bpp/models/wydawnictwo_zwarte.py:167` (`Wydawnictwo_Zwarte_Manager` + metoda `wydawnictwa_nadrzedne_dla_innych`)
+- Modify: `src/bpp/models/wydawnictwo_ciagle.py:185`, `wydawnictwo_zwarte.py:197` (przypisanie `objects`/`global_objects`/`deleted_objects`)
+- Test path: `src/bpp/tests/test_soft_delete_publikacje.py`
+
+Po wpięciu mixinu `Wydawnictwo_Ciagle`/`Wydawnictwo_Zwarte` dostają z `SoftDeleteModel` menedżery `objects`/`global_objects`/`deleted_objects` — ALE one nadpisują/kolidują z istniejącym `objects = Wydawnictwo_*_Manager()` (mixin opłat). Trzeba **przepleść**: menedżer publikacji ma równocześnie (a) filtrować `deleted_at__isnull=True`, (b) zachować `rekordy_z_oplata()`/`wydawnictwa_nadrzedne_dla_innych()`. Robimy to przez wspólny `BppSoftDeleteQuerySet` + MRO (mixin opłat operuje na queryset, więc działa nad każdym QS), **bez** nadpisywania metod fees.
+
+> **Klucz:** `ManagerModeliZOplataZaPublikacjeMixin` to mixin metod menedżera (`self.exclude(...)`), niezależny od źródła QS. `BppSoftDeleteManager` z fazy 01 już zwraca `BppSoftDeleteQuerySet(...).filter(deleted_at__isnull=True)`. Składamy: `class Wydawnictwo_Ciagle_Manager(ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager)`. `rekordy_z_oplata()` woła `self.exclude(...)` → działa na już-przefiltrowanym (żywym) QS. Zero nadpisywania fees.
+
+- [ ] **Krok 4.1 — padający test: `objects` ukrywa skasowane, `rekordy_z_oplata` też, `global_objects` widzi wszystko.** Dopisz:
+ ```python
+ @pytest.mark.django_db
+ def test_menedzery_publikacji_filtruja_soft_delete():
+ wc_zywy = baker.make(Wydawnictwo_Ciagle, opl_pub_cost_free=True)
+ wc_kosz = baker.make(Wydawnictwo_Ciagle, opl_pub_cost_free=True)
+ wc_kosz.delete()
+
+ ids = set(Wydawnictwo_Ciagle.objects.values_list("pk", flat=True))
+ assert wc_zywy.pk in ids
+ assert wc_kosz.pk not in ids, "objects nie ukrywa skasowanych"
+
+ # metoda fees nadal działa i też pomija kosz:
+ oplata_ids = set(
+ Wydawnictwo_Ciagle.objects.rekordy_z_oplata().values_list(
+ "pk", flat=True
+ )
+ )
+ assert wc_zywy.pk in oplata_ids
+ assert wc_kosz.pk not in oplata_ids
+
+ all_ids = set(
+ Wydawnictwo_Ciagle.global_objects.values_list("pk", flat=True)
+ )
+ assert wc_kosz.pk in all_ids, "global_objects nie widzi skasowanych"
+ ```
+- [ ] **Krok 4.2 — uruchom, oczekiwany FAIL.** Przed implementacją `objects` to wciąż stary `Wydawnictwo_Ciagle_Manager(... models.Manager)` (NIE filtruje `deleted_at`) → `wc_kosz.pk` jest w `ids`:
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_menedzery_publikacji_filtruja_soft_delete -x
+ ```
+ Oczekiwane: `AssertionError: objects nie ukrywa skasowanych` (albo `AttributeError: global_objects` jeśli mixin opłat przesłonił menedżery SoftDeleteModel).
+- [ ] **Krok 4.3 — implementacja: przeplecione menedżery.** Import w obu plikach:
+ ```python
+ from bpp.models.soft_delete import BppGlobalManager, BppSoftDeleteManager
+ ```
+ W `wydawnictwo_ciagle.py` zamień definicję menedżera:
+ ```python
+ class Wydawnictwo_Ciagle_Manager(
+ ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager
+ ):
+ pass
+ ```
+ i w ciele `Wydawnictwo_Ciagle` (przy `objects = ...`):
+ ```python
+ objects = Wydawnictwo_Ciagle_Manager()
+ global_objects = BppGlobalManager()
+ deleted_objects = DeletedManager() # import: from django_softdelete.managers import DeletedManager
+ ```
+ W `wydawnictwo_zwarte.py`:
+ ```python
+ class Wydawnictwo_Zwarte_Manager(
+ ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager
+ ):
+ def wydawnictwa_nadrzedne_dla_innych(self):
+ return (
+ self.exclude(wydawnictwo_nadrzedne_id=None)
+ .values_list("wydawnictwo_nadrzedne_id", flat=True)
+ .distinct()
+ )
+ ```
+ i w ciele `Wydawnictwo_Zwarte`:
+ ```python
+ objects = Wydawnictwo_Zwarte_Manager()
+ global_objects = BppGlobalManager()
+ deleted_objects = DeletedManager()
+ ```
+ > `Patent`/`Praca_Doktorska`/`Praca_Habilitacyjna` NIE mają mixinu opłat — dostają `objects`/`global_objects`/`deleted_objects` wprost z `SoftDeleteModel` (przez `BppPublikacjaSoftDeleteMixin`). NIC tu nie zmieniamy dla nich. Ale upewnij się, że ich `objects` to `BppSoftDeleteManager` — jeśli mixin dziedziczy gołe `SoftDeleteModel`, menedżer to package-owy `SoftDeleteManager` (też filtruje `deleted_at`, ale bez gate na `.update()`). **Decyzja:** w `BppPublikacjaSoftDeleteMixin` ustaw jawnie `objects = BppSoftDeleteManager()`, `global_objects = BppGlobalManager()`, `deleted_objects = DeletedManager()` w ciele mixinu — wtedy 3 proste modele dostają gate'owany QuerySet z fazy 01 za darmo, a `Wydawnictwo_*` nadpisują `objects` własnym (przeplecionym) menedżerem.
+- [ ] **Krok 4.4 — dopisz menedżery do mixinu (Task 1).** W `BppPublikacjaSoftDeleteMixin` (ciało, przed `Meta`):
+ ```python
+ from django_softdelete.managers import DeletedManager
+
+ objects = BppSoftDeleteManager()
+ global_objects = BppGlobalManager()
+ deleted_objects = DeletedManager()
+ ```
+ (importy `BppSoftDeleteManager`/`BppGlobalManager` są już w `soft_delete.py`).
+- [ ] **Krok 4.5 — uruchom test (PASS) + sprawdź, że nie powstała migracja menedżera.** Menedżery nie tworzą migracji schematu (chyba że `use_in_migrations`):
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_menedzery_publikacji_filtruja_soft_delete -x
+ uv run python src/manage.py makemigrations --check --dry-run bpp
+ ```
+ Oczekiwane: PASS + `No changes detected` (jeśli Django chce migrację `default_manager` — wygeneruj ją: `--name publikacje_menedzery` i dołącz do commitu).
+- [ ] **Krok 4.6 — commit:**
+ ```bash
+ git add -A
+ git commit -m "feat(soft-delete): przeplecenie menedzerow Wydawnictwo_* z filtrem soft-delete
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Task 5: Testy integracyjne — znika z Rekord/Autorzy, restore, post_soft_delete, *_Streszczenie nietknięte, gate bulk-update
+
+**Files:**
+- Modify: `src/bpp/tests/test_soft_delete_publikacje.py`
+- Test path: ten sam
+
+> Te testy zależą od fazy 01 (trigger/widoki muszą usuwać z `bpp_rekord_mat`/`bpp_autorzy_mat` na podstawie `deleted_at`). Jeśli któryś z testów Rekord/Autorzy padnie z powodu cache, to regresja fazy 01 — zgłoś, NIE łataj tu.
+
+- [ ] **Krok 5.1 — test: publikacja znika z `Rekord` i `Autorzy`, restore wraca.** Dopisz:
+ ```python
+ from bpp.models import Autorzy, Rekord
+
+
+ @pytest.mark.django_db
+ def test_soft_delete_znika_z_rekord_i_autorzy_restore_wraca(denorms):
+ wc = baker.make(Wydawnictwo_Ciagle)
+ baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc)
+ wc.refresh_from_db()
+ ct_pk = wc.content_type_id if hasattr(wc, "content_type_id") else None
+
+ assert Rekord.objects.filter(
+ id=(wc.content_type_id, wc.pk)
+ ).exists() or Rekord.objects.filter(tytul_oryginalny=wc.tytul_oryginalny).exists()
+
+ wc.delete()
+ assert not Rekord.objects.filter(
+ tytul_oryginalny=wc.tytul_oryginalny
+ ).exists(), "skasowany rekord wciąż w Rekord"
+ assert not Autorzy.objects.filter(rekord_id=(wc.content_type_id, wc.pk)).exists()
+
+ wc.restore()
+ assert Rekord.objects.filter(
+ tytul_oryginalny=wc.tytul_oryginalny
+ ).exists(), "po restore rekord nie wrócił do Rekord"
+ assert Autorzy.objects.filter(rekord_id=(wc.content_type_id, wc.pk)).exists()
+ ```
+ > `Rekord.id` to tuple `(content_type_id, object_id)`. Jeśli `Rekord`/`Autorzy` API różni się — dostosuj filtr po realnym kontrakcie `src/bpp/models/cache/`. Fixture `denorms` (z `src/conftest.py`) odpala denorm flush — sprawdź czy istnieje; jeśli nie, użyj właściwej fixtury cache z repo (`flush_denorm`/`denorm_rebuild`).
+- [ ] **Krok 5.2 — test: restore przywraca `*_Autor` po tym samym transaction_id.**
+ ```python
+ @pytest.mark.django_db
+ def test_restore_przywraca_autorow():
+ wc = baker.make(Wydawnictwo_Ciagle)
+ a1 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc)
+ wc.delete()
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=a1.pk).count() == 0
+
+ wc.restore()
+ row = Wydawnictwo_Ciagle_Autor.objects.get(pk=a1.pk)
+ assert row.deleted_at is None
+ assert row.transaction_id is None
+ ```
+- [ ] **Krok 5.3 — test: `post_soft_delete` emitowany.**
+ ```python
+ from django_softdelete.signals import post_soft_delete
+
+
+ @pytest.mark.django_db
+ def test_post_soft_delete_emitowany():
+ odebrane = []
+
+ def receiver(sender, instance, **kwargs):
+ odebrane.append(instance)
+
+ post_soft_delete.connect(receiver, sender=Wydawnictwo_Ciagle)
+ try:
+ wc = baker.make(Wydawnictwo_Ciagle)
+ wc.delete()
+ finally:
+ post_soft_delete.disconnect(receiver, sender=Wydawnictwo_Ciagle)
+
+ assert len(odebrane) == 1
+ assert odebrane[0].pk == wc.pk
+ ```
+- [ ] **Krok 5.4 — test: `*_Streszczenie` NIE jest ruszane przez kaskadę.** Znajdź realny model streszczeń (`grep -rn "class Streszczenie\|Wydawnictwo_Ciagle_Streszczenie" src/bpp/models/` — prawdopodobnie `Wydawnictwo_Ciagle_Streszczenie` z FK `rekord`). Dopisz:
+ ```python
+ from bpp.models import Wydawnictwo_Ciagle_Streszczenie # zweryfikuj nazwę/import
+
+
+ @pytest.mark.django_db
+ def test_kaskada_nie_rusza_streszczenia():
+ wc = baker.make(Wydawnictwo_Ciagle)
+ strz = baker.make(Wydawnictwo_Ciagle_Streszczenie, rekord=wc)
+ wc.delete() # NIE może rzucić SoftDeleteException
+ # streszczenie fizycznie istnieje, nietknięte (nie jest SoftDeleteModel)
+ assert Wydawnictwo_Ciagle_Streszczenie.objects.filter(pk=strz.pk).exists()
+ ```
+ > Jeśli `Wydawnictwo_Ciagle_Streszczenie` ma inną nazwę pola FK niż `rekord` — sprawdź modelem. Kluczowy asercja: `wc.delete()` NIE rzuca `SoftDeleteException` (dowód, że nie używamy refleksyjnej kaskady pakietu) i streszczenie zostaje.
+- [ ] **Krok 5.5 — test: gate `BppSoftDeleteQuerySet.update(deleted_at=...)` rzuca (kontrakt reversion).**
+ ```python
+ @pytest.mark.django_db
+ def test_bulk_update_deleted_at_zabroniony():
+ baker.make(Wydawnictwo_Ciagle)
+ with pytest.raises(RuntimeError):
+ Wydawnictwo_Ciagle.objects.update(deleted_at="2026-01-01")
+ ```
+- [ ] **Krok 5.6 — uruchom cały plik:**
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete_publikacje.py -x
+ ```
+ Oczekiwane: wszystkie testy PASS. Jeśli test Rekord/Autorzy (5.1) padnie na cache — to regresja fazy 01, zgłoś.
+- [ ] **Krok 5.7 — lint:**
+ ```bash
+ ruff format src/bpp/tests/test_soft_delete_publikacje.py src/bpp/models/soft_delete.py src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py
+ ruff check src/bpp/models/soft_delete.py src/bpp/tests/test_soft_delete_publikacje.py
+ ```
+ Oczekiwane: czysto (linia ≤88). Napraw każdy zgłoszony problem ręcznie (Edit), NIE `--fix`.
+- [ ] **Krok 5.8 — commit:**
+ ```bash
+ git add src/bpp/tests/test_soft_delete_publikacje.py
+ git commit -m "test(soft-delete): integracja publikacji - Rekord/Autorzy/restore/sygnaly/streszczenie/gate
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Task 6: Weryfikacja końcowa fazy
+
+**Files:** —
+
+- [ ] **Krok 6.1 — pełny check migracji + testy soft-delete + sąsiednie regresje publikacji:**
+ ```bash
+ uv run python src/manage.py makemigrations --check --dry-run bpp
+ uv run pytest src/bpp/tests/test_soft_delete_publikacje.py
+ uv run pytest src/bpp/tests/ -k "wydawnictwo or patent or doktor or habilit" -q
+ ```
+ Oczekiwane: `No changes detected` + zielone testy. Jeśli istniejące testy publikacji padają, bo zakładały hard-delete — przejrzyj: prawdziwa regresja vs. test do aktualizacji (hard-delete → `.hard_delete()`). Aktualizuj tylko testy jawnie testujące kasowanie; NIE maskuj realnych regresji.
+- [ ] **Krok 6.2 — `pre-commit` na zmienionych plikach (bez argumentów-akcji):**
+ ```bash
+ pre-commit run --files src/bpp/models/soft_delete.py src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/tests/test_soft_delete_publikacje.py
+ ```
+ Napraw issues ręcznie, NIE batch-fix.
+- [ ] **Krok 6.3 — finalny commit (jeśli zostały zmiany po lintach):**
+ ```bash
+ git add -A
+ git commit -m "chore(soft-delete): finalizacja fazy 02 publikacje (lint + regresje)
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+ ```
+
+---
+
+## Definition of Done (faza 02)
+
+- [ ] 5 modeli publikacji to `SoftDeleteModel` (przez `BppPublikacjaSoftDeleteMixin`); migracje `deleted_at`/`restored_at`/`transaction_id` + indeks per model (`0421_`).
+- [ ] `delete(self, *args, user=None, reason="", **kwargs)` / `restore(self, *args, user=None, **kwargs)` — per-instancja `save()`, wąska kaskada na `autorzy_set` pod wspólnym `transaction_id`, BEZ refleksyjnej kaskady pakietu, BEZ bulk `update(deleted_at=)`.
+- [ ] `*_Streszczenie` (i pozostałe nie-soft dzieci) nietknięte; `delete()` nie rzuca `SoftDeleteException`.
+- [ ] `slug` → warunkowy `UniqueConstraint(condition=Q(deleted_at__isnull=True))` (`0422_`); reuse slug po soft-delete działa.
+- [ ] `Wydawnictwo_*_Manager` przeplecione: `objects` filtruje `deleted_at` ORAZ ma `rekordy_z_oplata()`/`wydawnictwa_nadrzedne_dla_innych()`; `global_objects`/`deleted_objects` dostępne na wszystkich 5 modelach.
+- [ ] Testy: kaskada wspólny txid, znika z Rekord/Autorzy + restore, restore `*_Autor`, `post_soft_delete`, `*_Streszczenie` nietknięte, gate bulk-update — zielone.
+- [ ] `makemigrations --check --dry-run bpp` → `No changes detected`. Istniejące migracje NIE modyfikowane.
+
+---
+
+## Podsumowanie (3 punkty)
+
+1. **Co robi faza:** wpina `SoftDeleteModel` w 5 modeli publikacji przez nowy mixin `BppPublikacjaSoftDeleteMixin` (w `src/bpp/models/soft_delete.py`), który nadpisuje `delete()`/`restore()` na **jawną wąską kaskadę** soft-delete na `autorzy_set` (`*_Autor`) pod wspólnym `transaction_id` — z pominięciem refleksyjnej kaskady pakietu (żeby nie ruszać `*_Streszczenie` i innych nie-soft dzieci). Dokłada migracje pól+indeksów (`0421`), warunkowy `UniqueConstraint` na `slug` (`0422`, reuse po soft-delete) i przeplata menedżery `Wydawnictwo_*_Manager` (mixin opłat × `BppSoftDeleteManager`) tak, by `objects` filtrowały `deleted_at`, zachowując `rekordy_z_oplata()`/`wydawnictwa_nadrzedne_dla_innych()`.
+
+2. **Krytyczne kontrakty utrzymane:** (a) zawsze per-instancja `save()`/`.delete()` — NIGDY bulk `update(deleted_at=)` (szew pod reversion, egzekwowany gate'em fazy 01); (b) sygnatury `delete(... user=None, reason="")` / `restore(... user=None)` (konsumuje je faza 06/07, tu tylko obecne); (c) wspólny `transaction_id` rodzic↔`*_Autor` (restore po nim); (d) emisja `post_soft_delete`/`post_restore`.
+
+3. **Założenia między-fazowe (zweryfikowane):** zależy od **fazy 01** (moduł `soft_delete.py` z `BppSoftDeleteQuerySet`/`BppSoftDeleteManager`/`BppGlobalManager`, `*_Autor` już SoftDeleteModel, filtr `deleted_at` w widokach `bpp_rekord`/`bpp_*_autorzy`). Testy Rekord/Autorzy (Task 5) walidują integrację z fazą 01 — ich porażka = regresja 01, NIE łatać tu. Świadomie **NIE** ruszamy: FK flips `CASCADE→PROTECT` i guardy (autor, `wydawnictwo_nadrzedne`) → **faza 04**; audyt `global_objects` w imporcie/dedup/PBN → **faza 03**; PBN-wycofanie → **faza 05**; `SoftDeleteLog`+receivery (konsumują `user`/`reason`) → **faza 06**; admin (kosz/przywróć/usuń-trwale, hook usera) → **faza 07**. **Wrinkle do pilnowania:** `slug` jest polem `@denormalized` (`django-denorm-iplweb`) z `unique=True` jako kwargiem — zdejmujemy `unique`, dodajemy `UniqueConstraint` w `Meta` każdej konkretnej klasy (dla `Praca_Doktorska`/`Praca_Habilitacyjna` slug pochodzi ze wspólnego abstraktu `Praca_Doktorska_Baza`, ale constraint per konkretny model).
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md
new file mode 100644
index 000000000..f94b48e8a
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md
@@ -0,0 +1,938 @@
+# Soft-delete — Faza 03: audyt kategorii B Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Przełączyć miejsca matchingu importu/dedupu/PBN na `global_objects`, żeby re-import lub deduplikacja NIE tworzyły duplikatów rekordów skasowanych miękko, oraz wymusić jawny `.hard_delete()` tam, gdzie kod celowo czyści fizycznie przed re-importem.
+
+**Architecture:** Po fazie 02 modele 5 publikacji są `SoftDeleteModel`; domyślny menedżer `objects` ukrywa skasowane (kategoria A — czysta automatycznie). Kategoria B to miejsca, które MUSZĄ widzieć skasowane: matching przez `pbn_uid` / DOI / ISBN / tytuł podczas re-importu i dedup. Jeśli te miejsca użyją ukrywającego menedżera, skasowany rekord stanie się „niewidzialny" → import utworzy DUPLIKAT (a denormalizowany warunkowy `slug` z fazy 02 i tak będzie kolidował dopiero przy restore). Dlatego matching przełączamy na `global_objects`. Osobno: `pbn_import` czyści publikacje PBN fizycznie przed re-importem — po fazie 02 `.delete()` na querysecie stałby się soft → trzeba jawnie `.hard_delete()`.
+
+**Tech Stack:** Django, PostgreSQL, `django-soft-delete>=1.0.23` (`objects` / `global_objects` / `deleted_objects`, `.hard_delete()`), pytest + model_bakery (`baker.make`).
+
+---
+
+## Zależności i kontrakty (z fazy 02 + indeksu 00)
+
+- **Zależy od fazy 02.** Po fazie 02:
+ - `Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`,
+ `Praca_Habilitacyjna`, `Patent`, oraz `Wydawnictwo_Ciagle_Autor`,
+ `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` są `SoftDeleteModel`.
+ - Menedżery (VERBATIM z indeksu 00): `objects` (ukrywa skasowane),
+ `global_objects` (wszystkie), `deleted_objects` (tylko skasowane).
+ - Metody instancji: `.delete()` (soft), `.hard_delete()` (fizyczny),
+ `.restore()`.
+ - Menedżery publikacji (`Wydawnictwo_Ciagle_Manager`,
+ `Wydawnictwo_Zwarte_Manager`) mają przepleciony filtr soft-delete —
+ `.objects` zwraca tylko nieusunięte, ale nadal udostępnia metody
+ domenowe (`wydawnictwa_nadrzedne_dla_innych()` itd.). **`global_objects`
+ pochodzi z `GlobalManager` pakietu i NIE ma metod domenowych** — przy
+ przełączaniu sprawdzić, czy dane miejsce nie woła metody domenowej (jeśli
+ woła — patrz nota w odpowiednim Tasku).
+- **Niezmienna reguła BPP:** NIE modyfikować istniejących plików migracji
+ w `src/*/migrations/`. Ten plan nie tworzy migracji (zmiany tylko w kodzie
+ zapytań + testy).
+- **Linia ≤88 znaków** (ruff). Komendy Pythona przez `uv run`.
+- **Testy:** pytest, standalone functions, `@pytest.mark.django_db`,
+ `baker.make`. Bez `unittest.TestCase`.
+
+---
+
+## Mapa plików tej fazy
+
+**Modyfikowane (produkcyjne):**
+- `src/pbn_api/models/publication.py` — `rekord_w_bpp`, `get_bpp_publication`,
+ `matchuj_do_rekordu_bpp` matchują przez `Rekord` (cache-view filtrowany po
+ `deleted_at`) → przełączyć na matching widzący skasowane (`global_objects`
+ modeli źródłowych).
+- `src/import_common/core/publikacja.py` — `matchuj_publikacje` i 6 helperów
+ `_try_match_pub_by_*` używają `klass.objects` → `klass` ma dostać menedżer
+ widzący skasowane.
+- `src/deduplikator_publikacji/tasks.py:140,147` — skanowanie do dedupu na
+ `.objects` (pomija skasowane — poprawne, ale udokumentować decyzję; bez
+ zmiany kodu).
+- `src/pbn_integrator/importer/chapters.py:64` — `Wydawnictwo_Zwarte.objects
+ .get(pbn_uid_id=...)` (matching książki-matki rozdziału) → `global_objects`.
+- `src/pbn_import/utils/publication_import.py:115-116` — jawny `.hard_delete()`
+ zamiast `.delete()` (kod celowo czyści fizycznie przed re-importem).
+- `src/deduplikator_autorow/utils/merge.py:172,178,265,271,335,341` —
+ transfer through-rows duplikatu → `global_objects` (łapie kaskadowo
+ soft-deletowane autorstwa; inaczej zostaną sieroty blokujące guard fazy 04).
+
+**Tworzone (testy):**
+- `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`
+
+**Bez zmian (decyzja audytu udokumentowana w planie):**
+- 90 miejsc `*_Autor.objects` w ewaluacji / API / przemapuj — patrz Task 7.
+- `src/komparator_pbn/views.py`, `src/snapshot_odpiec/tasks.py`,
+ `src/ewaluacja_dwudyscyplinowcy/core.py` — patrz Task 7.
+
+---
+
+## Task 1: Test bazowy — re-import po soft-delete znajduje rekord przez `pbn_uid` (FAIL przed zmianą)
+
+Po fazie 02 `Rekord` (cache-view `bpp_rekord_mat`/`bpp_rekord`) jest filtrowany
+po `deleted_at` (faza 01). `pbn_api.Publication.rekord_w_bpp` matchuje przez
+`Rekord.objects.get(pbn_uid_id=...)` — więc dla soft-deletowanej publikacji
+zwróci `None` (rekord zniknął z widoku), a importer utworzy DUPLIKAT. Ten test
+to pokazuje.
+
+**Files:**
+- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`
+
+- [ ] **Step 1: Utwórz katalog testów i napisz failing test**
+
+Utwórz `src/bpp/tests/test_soft_delete/__init__.py` (pusty) oraz
+`src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`:
+
+```python
+import pytest
+from model_bakery import baker
+
+from bpp.models import Wydawnictwo_Ciagle
+
+
+@pytest.mark.django_db
+def test_get_bpp_publication_widzi_soft_deletowany_rekord():
+ """Re-import: matching po pbn_uid MUSI znaleźć soft-deletowaną
+ publikację, inaczej importer utworzy duplikat."""
+ from pbn_api.models import Publication
+
+ publication = baker.make(Publication)
+ rec = baker.make(
+ Wydawnictwo_Ciagle,
+ tytul_oryginalny="Testowy artykuł",
+ rok=2020,
+ pbn_uid=publication,
+ )
+ rec.delete() # soft-delete
+
+ assert Wydawnictwo_Ciagle.objects.filter(pk=rec.pk).count() == 0
+ assert Wydawnictwo_Ciagle.global_objects.filter(pk=rec.pk).count() == 1
+
+ znaleziony = publication.get_bpp_publication()
+ assert znaleziony is not None, (
+ "matching po pbn_uid musi widzieć soft-deletowany rekord"
+ )
+ assert znaleziony.pk == rec.pk
+```
+
+- [ ] **Step 2: Uruchom test — ma FAILować**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_get_bpp_publication_widzi_soft_deletowany_rekord -v`
+Expected: FAIL — `znaleziony is None` (`Rekord.objects.get(pbn_uid_id=...)`
+nie widzi soft-deletowanego rekordu, bo widok `bpp_rekord` jest filtrowany).
+
+- [ ] **Step 3: Commit testu**
+
+```bash
+git add src/bpp/tests/test_soft_delete/__init__.py \
+ src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py
+git commit -m "test(soft-delete): re-import po pbn_uid widzi soft-deletowany rekord (failing)"
+```
+
+---
+
+## Task 2: `get_bpp_publication` matchuje po `pbn_uid` przez `global_objects` modeli źródłowych
+
+`Rekord` to widok (`managed=False`), NIE `SoftDeleteModel` — nie ma
+`global_objects`. Matching po `pbn_uid` musi odpytać modele źródłowe ich
+menedżerem `global_objects`. `pbn_uid` jest unikalny w obrębie typu, więc
+przeszukujemy 5 modeli i zwracamy pierwsze trafienie.
+
+**Files:**
+- Modify: `src/pbn_api/models/publication.py:121-128` (`get_bpp_publication`)
+
+- [ ] **Step 1: Podejrzyj obecny kod (kontekst)**
+
+`get_bpp_publication` (linie 121-128) i `rekord_w_bpp` (130-143) matchują
+przez `Rekord.objects.get(pbn_uid_id=self.pk)`.
+
+- [ ] **Step 2: Dodaj helper i przepisz `get_bpp_publication`**
+
+Zamień metodę `get_bpp_publication` (linie 121-128) na:
+
+```python
+ def _modele_publikacji_global(self):
+ """Modele publikacji z menedżerem widzącym soft-deletowane.
+ Rekord (widok) NIE ma global_objects, więc matching po pbn_uid
+ idzie po modelach źródłowych."""
+ from bpp.models import (
+ Patent,
+ Praca_Doktorska,
+ Praca_Habilitacyjna,
+ Wydawnictwo_Ciagle,
+ Wydawnictwo_Zwarte,
+ )
+
+ return [
+ Wydawnictwo_Ciagle,
+ Wydawnictwo_Zwarte,
+ Praca_Doktorska,
+ Praca_Habilitacyjna,
+ Patent,
+ ]
+
+ def get_bpp_publication(self):
+ """Zwraca rekord BPP powiązany przez PBN UID (bez fuzzy matching).
+
+ Używa global_objects, więc widzi też soft-deletowane rekordy —
+ inaczej re-import utworzyłby duplikat skasowanej publikacji.
+ """
+ for klass in self._modele_publikacji_global():
+ obj = klass.global_objects.filter(pbn_uid_id=self.pk).first()
+ if obj is not None:
+ return obj
+ return None
+```
+
+- [ ] **Step 3: Uruchom test Task 1 — ma PASS**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_get_bpp_publication_widzi_soft_deletowany_rekord -v`
+Expected: PASS
+
+- [ ] **Step 4: ruff**
+
+Run: `ruff format src/pbn_api/models/publication.py && ruff check src/pbn_api/models/publication.py`
+Expected: brak błędów
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/pbn_api/models/publication.py
+git commit -m "fix(soft-delete): get_bpp_publication matchuje po pbn_uid przez global_objects"
+```
+
+---
+
+## Task 3: `rekord_w_bpp` widzi soft-deletowany rekord (re-import przez pbn_integrator)
+
+`pbn_integrator/importer/books.py:44` i `articles.py:62` matchują istniejący
+rekord przez `pbn_publication.rekord_w_bpp` i pomijają tworzenie, gdy
+`ret is not None`. `rekord_w_bpp` (linie 130-143) wciąż używa
+`Rekord.objects.get(pbn_uid_id=...)` → dla soft-deletowanego zwróci None →
+duplikat. Trzeba je oprzeć na `get_bpp_publication` (już naprawione w Task 2),
+zachowując dotychczasowy fallback do fuzzy-matchingu (`matchuj_do_rekordu_bpp`)
+i obsługę „wielu rekordów o tym samym pbn_uid".
+
+**Files:**
+- Modify: `src/pbn_api/models/publication.py:130-143` (`rekord_w_bpp`)
+- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`
+
+- [ ] **Step 1: Dopisz failing test re-importu (pbn_integrator)**
+
+Dopisz do `test_audyt_kategorii_b.py`:
+
+```python
+@pytest.mark.django_db
+def test_rekord_w_bpp_widzi_soft_deletowany_rekord():
+ """rekord_w_bpp (używany przez pbn_integrator do pominięcia tworzenia)
+ musi zwrócić soft-deletowany rekord, inaczej powstanie duplikat."""
+ from pbn_api.models import Publication
+
+ publication = baker.make(Publication)
+ rec = baker.make(
+ Wydawnictwo_Ciagle,
+ tytul_oryginalny="Artykuł do re-importu",
+ rok=2021,
+ pbn_uid=publication,
+ )
+ rec.delete()
+
+ # cached_property — świeża instancja, żeby nie czytać cache
+ publication_fresh = Publication.objects.get(pk=publication.pk)
+ assert publication_fresh.rekord_w_bpp is not None
+ assert publication_fresh.rekord_w_bpp.pk == rec.pk
+```
+
+- [ ] **Step 2: Uruchom — ma FAILować**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_rekord_w_bpp_widzi_soft_deletowany_rekord -v`
+Expected: FAIL — `rekord_w_bpp is None` (matching przez widok `bpp_rekord`).
+
+- [ ] **Step 3: Przepisz `rekord_w_bpp`**
+
+Zamień metodę `rekord_w_bpp` (linie 130-143) na:
+
+```python
+ @cached_property
+ def rekord_w_bpp(self):
+ from bpp.models.cache import Rekord
+
+ # Najpierw szybki lookup po pbn_uid w modelach źródłowych
+ # (global_objects — widzi też soft-deletowane, by re-import nie
+ # tworzył duplikatu). Obsługa "wielu rekordów o tym samym pbn_uid"
+ # jak dotychczas: zwróć łańcuch tytułów (sygnał błędu danych).
+ trafienia = [
+ obj
+ for klass in self._modele_publikacji_global()
+ for obj in klass.global_objects.filter(pbn_uid_id=self.pk)
+ ]
+ if len(trafienia) == 1:
+ # Zwróć obiekt Rekord (zachowanie zgodne z poprzednim API),
+ # czytając z global widoku po pk modelu źródłowego.
+ obj = trafienia[0]
+ from django.contrib.contenttypes.models import ContentType
+
+ ct = ContentType.objects.get_for_model(type(obj))
+ rec = Rekord.objects.filter(
+ content_type=ct, object_id=obj.pk
+ ).first()
+ # Soft-deletowany rekord znika z widoku Rekord — wtedy zwróć
+ # obiekt źródłowy (importer i tak używa go tylko do .pk).
+ return rec if rec is not None else obj
+ if len(trafienia) > 1:
+ return ";; ".join(o.tytul_oryginalny for o in trafienia)
+
+ return self.matchuj_do_rekordu_bpp()
+```
+
+> **Nota:** importer (`books.py`/`articles.py`) używa `rekord_w_bpp` wyłącznie
+> jako „czy istnieje" + dostęp do pól; zwrócenie obiektu źródłowego zamiast
+> `Rekord` dla soft-deletowanego rekordu jest bezpieczne (oba mają `pk`,
+> `tytul_oryginalny`). Dla niesoft-deletowanych zachowujemy zwrot `Rekord`.
+
+- [ ] **Step 4: Uruchom oba testy pbn_uid — mają PASS**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py -k "widzi_soft_deletowany" -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 5: Regresja — pbn_api publication**
+
+Run: `uv run pytest src/pbn_api/ -k "rekord_w_bpp or get_bpp_publication or publication" -q`
+Expected: PASS (brak regresji na istniejących testach matchingu).
+
+- [ ] **Step 6: ruff + commit**
+
+```bash
+ruff format src/pbn_api/models/publication.py
+ruff check src/pbn_api/models/publication.py
+git add src/pbn_api/models/publication.py src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py
+git commit -m "fix(soft-delete): rekord_w_bpp widzi soft-deletowane rekordy (re-import bez duplikatów)"
+```
+
+---
+
+## Task 4: `import_common.matchuj_publikacje` matchuje przez `global_objects`
+
+`matchuj_publikacje(klass, ...)` (`src/import_common/core/publikacja.py:264`)
+i 6 helperów (`_try_match_pub_by_doi/zrodlo/isbn/uri/title`,
+`_build_isbn_query`) używają `klass.objects`. Przy `klass = Wydawnictwo_*`
+soft-deletowane rekordy są ukryte → fuzzy-matching nie znajdzie skasowanego
+duplikatu → re-import go odtworzy. Trzeba odpytywać widzącym menedżerem.
+
+> **Uwaga na metodę domenową:** `_build_isbn_query` woła
+> `Wydawnictwo_Zwarte.objects.wydawnictwa_nadrzedne_dla_innych()` — to metoda
+> menedżera domenowego, której `global_objects` (`GlobalManager` pakietu) NIE
+> ma. Dla matchingu po ISBN „tylko nadrzędne" akceptujemy, że nadrzędne
+> liczone są spośród nieusuniętych (książka-matka skasowana → i tak PROTECT
+> w fazie 04). Tę jedną ścieżkę zostawiamy na `objects`; zmieniamy tylko
+> pozostałe lookupy w helperach na widzący menedżer.
+
+**Files:**
+- Modify: `src/import_common/core/publikacja.py` — helper + `klass.objects`
+ w `_try_match_pub_by_doi:87`, `_try_match_pub_by_zrodlo:108`,
+ `_try_match_pub_by_uri:181`, `_try_match_pub_by_title:235,249`.
+- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`
+
+- [ ] **Step 1: Failing test — matchuj_publikacje po tytule widzi skasowany**
+
+Dopisz do `test_audyt_kategorii_b.py`:
+
+```python
+@pytest.mark.django_db
+def test_matchuj_publikacje_widzi_soft_deletowany():
+ """Fuzzy matching importu po tytule+rok musi znaleźć soft-deletowaną
+ publikację, inaczej re-import utworzy duplikat."""
+ from import_common.core.publikacja import matchuj_publikacje
+
+ rec = baker.make(
+ Wydawnictwo_Ciagle,
+ tytul_oryginalny="Unikalny tytul do matchowania importu",
+ rok=2019,
+ )
+ rec.delete()
+
+ wynik = matchuj_publikacje(
+ Wydawnictwo_Ciagle,
+ title="Unikalny tytul do matchowania importu",
+ year=2019,
+ )
+ assert wynik is not None
+ assert wynik.pk == rec.pk
+```
+
+- [ ] **Step 2: Uruchom — ma FAILować**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_matchuj_publikacje_widzi_soft_deletowany -v`
+Expected: FAIL — `wynik is None` (`klass.objects` ukrywa skasowany rekord).
+
+- [ ] **Step 3: Dodaj helper `_manager_dla_matchingu` i podmień lookupy**
+
+Na początku `src/import_common/core/publikacja.py` (po importach) dodaj:
+
+```python
+def _manager_dla_matchingu(klass):
+ """Menedżer widzący także soft-deletowane rekordy (kat. B: re-import
+ NIE może omijać skasowanych, inaczej tworzy duplikaty). Modele bez
+ soft-delete (np. Rekord-view) nie mają global_objects → fallback do
+ objects."""
+ return getattr(klass, "global_objects", klass.objects)
+```
+
+Następnie podmień w helperach `klass.objects` na `_manager_dla_matchingu(klass)`:
+
+- `_try_match_pub_by_doi` (linia 87):
+ `zapytanie = klass.objects.filter(doi__istartswith=doi, rok=year)`
+ → `zapytanie = _manager_dla_matchingu(klass).filter(doi__istartswith=doi, rok=year)`
+- `_try_match_pub_by_zrodlo` (linia 108):
+ `return klass.objects.get(` → `return _manager_dla_matchingu(klass).get(`
+- `_try_match_pub_by_uri` (linia 181):
+ `klass.objects.filter(Q(www=public_uri) | Q(public_www=public_uri))`
+ → `_manager_dla_matchingu(klass).filter(Q(www=public_uri) | Q(public_www=public_uri))`
+- `_try_match_pub_by_title` (linia 235):
+ `klass.objects.filter(tytul_oryginalny__istartswith=title, rok=year)`
+ → `_manager_dla_matchingu(klass).filter(tytul_oryginalny__istartswith=title, rok=year)`
+- `_try_match_pub_by_title` (linia 249):
+ `klass.objects.filter(rok=year)`
+ → `_manager_dla_matchingu(klass).filter(rok=year)`
+
+> `_build_isbn_query` zostaje na `klass.objects` (patrz Uwaga w nagłówku
+> Tasku — woła metodę domenową `wydawnictwa_nadrzedne_dla_innych()`).
+
+- [ ] **Step 4: Uruchom test — ma PASS**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_matchuj_publikacje_widzi_soft_deletowany -v`
+Expected: PASS
+
+- [ ] **Step 5: Regresja matchingu importu**
+
+Run: `uv run pytest src/import_common/ -q`
+Expected: PASS
+
+- [ ] **Step 6: ruff + commit**
+
+```bash
+ruff format src/import_common/core/publikacja.py
+ruff check src/import_common/core/publikacja.py
+git add src/import_common/core/publikacja.py src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py
+git commit -m "fix(soft-delete): matchuj_publikacje widzi soft-deletowane rekordy (import bez duplikatów)"
+```
+
+---
+
+## Task 5: `pbn_integrator/importer/chapters.py` — matching książki-matki przez `global_objects`
+
+`chapters.py:64` woła `Wydawnictwo_Zwarte.objects.get(pbn_uid_id=pbn_book_id)`
+żeby znaleźć książkę-matkę rozdziału. Jeśli książka jest soft-deletowana,
+`objects.get` rzuci `DoesNotExist` → import rozdziału stworzy nową książkę
+(duplikat) lub się wywali. Matching musi widzieć skasowaną książkę-matkę.
+
+> **Spójność z fazą 04:** faza 04 ustawia `wydawnictwo_nadrzedne` na PROTECT
+> (książka z rozdziałami nie da się skasować). Tu chodzi o sytuację, gdy
+> książka-matka została skasowana zanim importowano rozdział — matching ma ją
+> odnaleźć przez `global_objects`, nie tworzyć duplikatu.
+
+**Files:**
+- Modify: `src/pbn_integrator/importer/chapters.py:64`
+- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`
+
+- [ ] **Step 1: Failing test**
+
+Dopisz do `test_audyt_kategorii_b.py`:
+
+```python
+@pytest.mark.django_db
+def test_chapters_matchuje_soft_deletowana_ksiazke_matke():
+ """Import rozdziału po pbn_uid książki-matki musi znaleźć soft-deletowaną
+ książkę przez global_objects, nie tworzyć duplikatu."""
+ from bpp.models import Wydawnictwo_Zwarte
+ from pbn_api.models import Publication
+
+ pub_ksiazki = baker.make(Publication)
+ ksiazka = baker.make(
+ Wydawnictwo_Zwarte,
+ tytul_oryginalny="Ksiazka matka",
+ rok=2018,
+ pbn_uid=pub_ksiazki,
+ )
+ ksiazka.delete()
+
+ znaleziona = Wydawnictwo_Zwarte.global_objects.get(
+ pbn_uid_id=pub_ksiazki.pk
+ )
+ assert znaleziona.pk == ksiazka.pk
+ # objects (ukrywający) NIE znajdzie — to różnica, którą naprawiamy
+ with pytest.raises(Wydawnictwo_Zwarte.DoesNotExist):
+ Wydawnictwo_Zwarte.objects.get(pbn_uid_id=pub_ksiazki.pk)
+```
+
+- [ ] **Step 2: Uruchom — ma PASS już teraz (test kontraktu menedżera)**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_chapters_matchuje_soft_deletowana_ksiazke_matke -v`
+Expected: PASS — to test kontraktu (`global_objects` widzi, `objects` nie).
+Potwierdza powód zmiany kodu w Step 3.
+
+- [ ] **Step 3: Zmień lookup w chapters.py**
+
+`src/pbn_integrator/importer/chapters.py:64`:
+```python
+ wydawnictwo_nadrzedne = Wydawnictwo_Zwarte.objects.get(pbn_uid_id=pbn_book_id)
+```
+→
+```python
+ wydawnictwo_nadrzedne = Wydawnictwo_Zwarte.global_objects.get(
+ pbn_uid_id=pbn_book_id
+ )
+```
+
+- [ ] **Step 4: Regresja pbn_integrator chapters**
+
+Run: `uv run pytest src/pbn_integrator/ -k "chapter or rozdzial" -q`
+Expected: PASS (jeśli brak testów dla chapters — `no tests ran`, OK).
+
+- [ ] **Step 5: ruff + commit**
+
+```bash
+ruff format src/pbn_integrator/importer/chapters.py
+ruff check src/pbn_integrator/importer/chapters.py
+git add src/pbn_integrator/importer/chapters.py src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py
+git commit -m "fix(soft-delete): chapters matchuje soft-deletowana ksiazke-matke przez global_objects"
+```
+
+---
+
+## Task 6: `pbn_import` — jawny `.hard_delete()` przy czyszczeniu przed re-importem
+
+`src/pbn_import/utils/publication_import.py:115-116` celowo USUWA FIZYCZNIE
+publikacje PBN przed pełnym re-importem z PBN. Po fazie 02
+`.objects.exclude(...).delete()` na querysecie stałby się soft-delete → stare
+rekordy zostałyby w koszu, a re-import (Task 2/3 — matching widzący skasowane)
+zwróciłby je jako „istniejące", więc re-import by ich nie odtworzył ALE też
+nie zaktualizował, a slug-i (warunkowy unique tylko dla nieusuniętych z fazy
+02) by nie kolidowały — efekt: dryf danych i rosnący kosz. Intencja kodu to
+czystka fizyczna → jawny `.hard_delete()`.
+
+**Files:**
+- Modify: `src/pbn_import/utils/publication_import.py:115-116`
+- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`
+
+- [ ] **Step 1: Failing test — hard-delete fizycznie usuwa (nie soft)**
+
+Dopisz do `test_audyt_kategorii_b.py`:
+
+```python
+@pytest.mark.django_db
+def test_pbn_import_czyszczenie_jest_hard_delete():
+ """_delete_existing_publications musi FIZYCZNIE usunąć publikacje PBN
+ (hard_delete), nie zostawiać ich w koszu (soft)."""
+ from bpp.models import Wydawnictwo_Zwarte
+ from pbn_api.models import Publication
+
+ pub = baker.make(Publication)
+ baker.make(Wydawnictwo_Zwarte, rok=2020, pbn_uid=pub)
+
+ # Symulacja linii czyszczenia z publication_import.py:115
+ Wydawnictwo_Zwarte.objects.exclude(pbn_uid_id=None).hard_delete()
+
+ # Nic nie zostaje — ani w objects, ani w global_objects (kosz pusty)
+ assert Wydawnictwo_Zwarte.global_objects.filter(
+ pbn_uid_id=pub.pk
+ ).count() == 0
+```
+
+- [ ] **Step 2: Uruchom — ma PASS (test kontraktu hard_delete)**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_pbn_import_czyszczenie_jest_hard_delete -v`
+Expected: PASS — potwierdza, że `.hard_delete()` na querysecie czyści fizycznie
+(uzasadnia zmianę w Step 3).
+
+- [ ] **Step 3: Zmień kod czyszczenia na `.hard_delete()`**
+
+`src/pbn_import/utils/publication_import.py:115-116`:
+```python
+ deleted_zwarte = Wydawnictwo_Zwarte.objects.exclude(pbn_uid_id=None).delete()[0]
+ deleted_ciagle = Wydawnictwo_Ciagle.objects.exclude(pbn_uid_id=None).delete()[0]
+```
+→
+```python
+ # Re-import PBN wymaga fizycznego czyszczenia (NIE soft-delete) —
+ # inaczej stare rekordy zostają w koszu i kolidują przy re-imporcie.
+ deleted_zwarte = Wydawnictwo_Zwarte.objects.exclude(
+ pbn_uid_id=None
+ ).hard_delete()
+ deleted_ciagle = Wydawnictwo_Ciagle.objects.exclude(
+ pbn_uid_id=None
+ ).hard_delete()
+```
+
+> **Uwaga na wartość zwracaną:** stare `.delete()` zwracało krotkę
+> `(liczba, {model: liczba})`, stąd `[0]`. `SoftDeleteQuerySet.hard_delete()`
+> w `django-soft-delete` zwraca wynik bazowego `QuerySet.delete()`
+> (krotkę) — ale to zależy od wersji pakietu. Następny krok to weryfikuje
+> i, jeśli trzeba, koryguje rozpakowanie.
+
+- [ ] **Step 4: Zweryfikuj typ zwracany `hard_delete()` i skoryguj rozpakowanie**
+
+Run: `uv run python -c "import inspect, django_softdelete.managers as m; print(inspect.getsource(m.SoftDeleteQuerySet.hard_delete))"`
+Expected: zobacz, co zwraca. Jeśli zwraca krotkę `(int, dict)` — zachowaj
+`[0]` (usuń je z powyższego diffu: `... .hard_delete()[0]`). Jeśli zwraca
+`int` lub `None` — dostosuj: gdy `int`, zostaw bez `[0]`; gdy `None`, policz
+przed usunięciem:
+```python
+ zwarte_qs = Wydawnictwo_Zwarte.objects.exclude(pbn_uid_id=None)
+ deleted_zwarte = zwarte_qs.count()
+ zwarte_qs.hard_delete()
+ ciagle_qs = Wydawnictwo_Ciagle.objects.exclude(pbn_uid_id=None)
+ deleted_ciagle = ciagle_qs.count()
+ ciagle_qs.hard_delete()
+```
+Zastosuj wariant zgodny z faktycznym zwrotem (log używa `deleted_zwarte`/
+`deleted_ciagle` jako liczb w komunikacie linii 118-121).
+
+- [ ] **Step 5: Regresja pbn_import**
+
+Run: `uv run pytest src/pbn_import/ -q`
+Expected: PASS
+
+- [ ] **Step 6: ruff + commit**
+
+```bash
+ruff format src/pbn_import/utils/publication_import.py
+ruff check src/pbn_import/utils/publication_import.py
+git add src/pbn_import/utils/publication_import.py src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py
+git commit -m "fix(soft-delete): pbn_import czysci publikacje przez hard_delete przed re-importem"
+```
+
+---
+
+## Task 7: Audyt 90 miejsc `*_Autor.objects` — decyzje (zostaw `objects` / zmień na `global_objects`)
+
+Po fazie 02 `*_Autor.objects` ukrywa kaskadowo soft-deletowane autorstwa
+(kaskada §2.2). To jest **poprawny default dla ewaluacji** (praca w koszu nie
+liczy się do punktacji). Audyt: dla każdej grupy miejsc zapada decyzja
+z uzasadnieniem. Większość = ZOSTAW `objects`. Wyjątek kat. B (musi widzieć
+skasowane) → `global_objects` (tu: tylko transfer w merge autorów, Step „merge").
+
+> **Reguła nadrzędna (spec §2.5):** default „pomijaj skasowane" jest tu
+> znacznie bezpieczniejszy niż przeciwny. Zmieniamy tylko miejsca, które
+> *muszą* widzieć skasowane, by nie zostawić sierot lub nie zgubić transferu.
+>
+> **Gate `BppSoftDeleteQuerySet.update()` (z fazy 01):** miejsca robiące
+> `*_Autor.objects.filter(...).update(...)` są bezpieczne, DOPÓKI nie ustawiają
+> `deleted_at`/`restored_at`. Audyt potwierdza, że wszystkie poniższe `.update()`
+> dotyczą `przypieta` / `dyscyplina_naukowa` / `afiliuje` — gate nie zadziała.
+
+**Files (tylko decyzje — zmiana kodu tylko w „merge"):**
+- Decyzje (bez zmian): pliki ewaluacji, API, przemapuj, snapshot, komparator,
+ dwudyscyplinowcy.
+- Modify: `src/deduplikator_autorow/utils/merge.py:172,178,265,271,335,341`
+- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`
+
+- [ ] **Step 1: Udokumentuj decyzje audytu (komentarz w teście jako rejestr)**
+
+Decyzje per-miejsce (uzasadnienie w nagłówku — w kodzie bez zmian):
+
+ **ZOSTAW `objects` (default „pomijaj skasowane" poprawny):**
+ - `src/ewaluacja_optymalizacja/tasks/reset_pins.py:34,54,75`
+ (`.update(przypieta=...)`) — reset przypięć dotyczy tylko prac liczonych
+ do ewaluacji; prace w koszu nie biorą udziału w optymalizacji. `.update`
+ nie tyka `deleted_at` → gate nie zadziała. **Zostaw.**
+ - `src/ewaluacja_optymalizacja/management/commands/reset_disciplines.py:59,63,67,90,100,108`
+ (count + `.update(dyscyplina_naukowa=...)`) — jw., reset dyscyplin tylko
+ dla prac w ewaluacji. **Zostaw.**
+ - `src/ewaluacja_optymalizacja/tasks/unpin_all_sensible.py:134,140,146`
+ (`.update`) — odpinanie prac ewaluowanych. **Zostaw.**
+ - `src/ewaluacja_optymalizacja/solve_helpers/unpinning.py:54,62` — jw.
+ **Zostaw.**
+ - `src/ewaluacja_optymalizacja/tasks/helpers.py:121,139`,
+ `tasks/optimization.py:536,539,573,576` (liczenie udziałów / przypiętych) —
+ optymalizacja MA pomijać prace w koszu. **Zostaw.**
+ - `src/ewaluacja_optymalizacja/views/author_works.py:75,93,111`,
+ `views/evaluation_browser/builders.py:136,141,170,174`,
+ `views/evaluation_browser/filters.py:44,55`, `views/verification.py:52,57`
+ (przeglądarka/weryfikacja ewaluacji) — prezentują stan ewaluacji; praca
+ w koszu nie powinna się pokazywać. **Zostaw.**
+ - `src/ewaluacja_dwudyscyplinowcy/core.py:125` — analiza dwudyscyplinowości
+ dla ewaluacji; kosz pomijamy. **Zostaw.**
+ - `src/snapshot_odpiec/tasks.py:6,9,12,20,23,26` — snapshot stanu odpięć
+ dyscyplin dla bieżącej ewaluacji; praca w koszu = brak odpięcia do
+ zapisania. **Zostaw.**
+ - `src/komparator_pbn/views.py:85,89,267,301` — porównanie „co BPP
+ deklaruje" vs „co jest w PBN". Soft-delete publikacji wycofuje oświadczenia
+ z PBN (faza 05), więc BPP NIE deklaruje już tej pracy → `objects`
+ (ukrywający) daje spójny obraz z PBN. **Zostaw.**
+ - `src/api_v1/viewsets/{wydawnictwo_ciagle.py:21,wydawnictwo_zwarte.py:19,patent.py:10}`
+ (`queryset = *_Autor.objects.all()`) — publiczne/REST API NIE może
+ serwować autorstw skasowanych prac (spójność z resztą API, gdzie sama
+ publikacja znika). **Zostaw.**
+ - `src/przemapuj_prace_autora/{views.py,forms.py}` (wiele linii) — narzędzie
+ przemapowania prac autora operuje na pracach aktywnych; skasowane (w koszu)
+ nie powinny być przemapowywane (operator najpierw je przywraca). **Zostaw.**
+ - `src/bpp/views/autocomplete/authors.py`, `src/ranking_autorow/forms.py` —
+ publiczne UI; kosz pomijamy. **Zostaw.**
+ - `src/bpp/management/commands/{ukryj_nieuzywane_dyscypliny.py,ustaw_daty_oswiadczenia_pbn.py}` —
+ operują na aktywnych autorstwach. **Zostaw.**
+ - `src/bpp/migrations/0403_*`, `src/zglos_publikacje/migrations/0019_*` —
+ **migracje: NIE RUSZAĆ** (reguła BPP). Działają na stanie historycznym.
+ - `src/conftest.py`, `src/bpp/tests/**`, `src/*/tests*.py`,
+ `src/integration_tests/test_conftest.py`,
+ `src/bpp/demo_data/generators/*` — fixtures/testy/demo: tworzą obiekty
+ (`.create`) — `objects` poprawne. **Zostaw.**
+
+ **ZMIEŃ na `global_objects` (kat. B — MUSI widzieć skasowane):**
+ - `src/deduplikator_autorow/utils/merge.py:172,178,265,271,335,341` —
+ transfer through-rows z autora-duplikatu na głównego. Jeśli duplikat ma
+ autorstwo wskazujące na soft-deletowaną publikację (kaskada §2.2), to
+ autorstwo jest też soft-deletowane → `objects` go NIE przeniesie →
+ zostanie sierota wskazująca duplikat → guard fazy 04 (liczący przez
+ `global_objects`) zablokuje usunięcie husku duplikatu. Transfer MUSI
+ widzieć wszystkie autorstwa. **Zmień na `global_objects`.**
+
+- [ ] **Step 2: Failing test — merge przenosi też autorstwo soft-deletowanej pracy**
+
+Dopisz do `test_audyt_kategorii_b.py`:
+
+```python
+@pytest.mark.django_db
+def test_merge_przenosi_autorstwo_soft_deletowanej_pracy():
+ """Merge autorów musi przenieść także autorstwo wskazujące na
+ soft-deletowaną publikację (kaskadowo skasowane *_Autor), inaczej
+ zostaje sierota blokująca guard usunięcia duplikatu (faza 04)."""
+ from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor
+ from deduplikator_autorow.utils.merge import przenies_wydawnictwa_ciagle
+
+ autor_glowny = baker.make("bpp.Autor")
+ autor_duplikat = baker.make("bpp.Autor")
+ jednostka = baker.make("bpp.Jednostka")
+
+ praca = baker.make(Wydawnictwo_Ciagle, rok=2020)
+ Wydawnictwo_Ciagle_Autor.objects.create(
+ rekord=praca,
+ autor=autor_duplikat,
+ jednostka=jednostka,
+ kolejnosc=0,
+ typ_odpowiedzialnosci_id=1,
+ )
+ praca.delete() # kaskadowo soft-deletuje też Wydawnictwo_Ciagle_Autor
+
+ # autorstwo jest teraz ukryte w objects, widoczne w global_objects
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(
+ autor=autor_duplikat
+ ).count() == 0
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(
+ autor=autor_duplikat
+ ).count() == 1
+
+ przenies_wydawnictwa_ciagle(autor_duplikat, autor_glowny)
+
+ # po transferze autorstwo wskazuje na autora głównego, duplikat czysty
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(
+ autor=autor_duplikat
+ ).count() == 0
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(
+ autor=autor_glowny
+ ).count() == 1
+```
+
+> **Uwaga:** dopasuj nazwę funkcji (`przenies_wydawnictwa_ciagle`) i jej
+> sygnaturę do faktycznego API `src/deduplikator_autorow/utils/merge.py`
+> (przeczytaj linie 160-200 przed uruchomieniem). Jeśli nazwa/sygnatura inna —
+> popraw wywołanie w teście. `typ_odpowiedzialnosci_id=1` zakłada istnienie
+> rekordu w bazie testowej; jeśli go nie ma, użyj
+> `baker.make("bpp.Typ_Odpowiedzialnosci")` i przekaż obiekt.
+
+- [ ] **Step 3: Uruchom — ma FAILować**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_merge_przenosi_autorstwo_soft_deletowanej_pracy -v`
+Expected: FAIL — autorstwo nie zostało przeniesione (`objects` go nie widział),
+duplikat nadal ma 1 wiersz w `global_objects`.
+
+- [ ] **Step 4: Zmień lookupy transferu na `global_objects`**
+
+W `src/deduplikator_autorow/utils/merge.py` zamień (po przeczytaniu kontekstu
+każdej linii — zachowaj resztę wyrażenia):
+
+- linia 172: `wc_autorzy = Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor_duplikat)`
+ → `wc_autorzy = Wydawnictwo_Ciagle_Autor.global_objects.filter(autor=autor_duplikat)`
+- linia 178: `existing = Wydawnictwo_Ciagle_Autor.objects.filter(`
+ → `existing = Wydawnictwo_Ciagle_Autor.global_objects.filter(`
+- linia 265: `wz_autorzy = Wydawnictwo_Zwarte_Autor.objects.filter(autor=autor_duplikat)`
+ → `wz_autorzy = Wydawnictwo_Zwarte_Autor.global_objects.filter(autor=autor_duplikat)`
+- linia 271: `existing = Wydawnictwo_Zwarte_Autor.objects.filter(`
+ → `existing = Wydawnictwo_Zwarte_Autor.global_objects.filter(`
+- linia 335: `patent_autorzy = Patent_Autor.objects.filter(autor=autor_duplikat)`
+ → `patent_autorzy = Patent_Autor.global_objects.filter(autor=autor_duplikat)`
+- linia 341: `existing = Patent_Autor.objects.filter(`
+ → `existing = Patent_Autor.global_objects.filter(`
+
+> **Nota:** „existing" to sprawdzenie kolizji (czy autor główny ma już to
+> autorstwo). Liczenie kolizji przez `global_objects` jest poprawne — kolizja
+> z soft-deletowanym autorstwem głównego też ma być wykryta, by nie powstał
+> duplikat through-row po restore.
+
+- [ ] **Step 5: Uruchom test — ma PASS**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_merge_przenosi_autorstwo_soft_deletowanej_pracy -v`
+Expected: PASS
+
+- [ ] **Step 6: Regresja merge autorów**
+
+Run: `uv run pytest src/deduplikator_autorow/ -q`
+Expected: PASS
+
+- [ ] **Step 7: ruff + commit**
+
+```bash
+ruff format src/deduplikator_autorow/utils/merge.py
+ruff check src/deduplikator_autorow/utils/merge.py
+git add src/deduplikator_autorow/utils/merge.py src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py
+git commit -m "fix(soft-delete): merge autorow przenosi tez kaskadowo-skasowane autorstwa (global_objects)"
+```
+
+---
+
+## Task 8: Dedup publikacji — potwierdź `objects` (ZOSTAW) + test regresji „dedup pomija kosz"
+
+`src/deduplikator_publikacji/tasks.py:140,147` skanuje publikacje do
+wyszukiwania duplikatów na `.objects`. Soft-deletowana publikacja jest „w
+koszu" — NIE chcemy jej raportować jako duplikatu (operator ją świadomie
+usunął). Default `objects` (ukrywający) jest poprawny. Bez zmiany kodu; test
+broni decyzji przed regresją.
+
+**Files:**
+- Bez zmian: `src/deduplikator_publikacji/tasks.py:140,147`
+- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`
+
+- [ ] **Step 1: Test regresji — skan dedupu pomija soft-deletowane**
+
+Dopisz do `test_audyt_kategorii_b.py`:
+
+```python
+@pytest.mark.django_db
+def test_dedup_skan_pomija_soft_deletowane():
+ """Skaner duplikatów NIE może zgłaszać prac z kosza (operator je
+ świadomie usunął) — _get_publications_to_scan używa objects."""
+ from deduplikator_publikacji.tasks import _get_publications_to_scan
+
+ aktywna = baker.make(Wydawnictwo_Ciagle, rok=2020)
+ skasowana = baker.make(Wydawnictwo_Ciagle, rok=2020)
+ skasowana.delete()
+
+ publikacje = _get_publications_to_scan(2020, 2020)
+ pks = {pub.pk for _ct, pub in publikacje}
+ assert aktywna.pk in pks
+ assert skasowana.pk not in pks
+```
+
+- [ ] **Step 2: Uruchom — ma PASS od razu (potwierdza decyzję ZOSTAW)**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_dedup_skan_pomija_soft_deletowane -v`
+Expected: PASS (kod już używa `objects`; test broni przed przyszłą regresją).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py
+git commit -m "test(soft-delete): dedup publikacji pomija prace w koszu (regresja)"
+```
+
+---
+
+## Task 9: Pełny test fazy + ruff + zamknięcie
+
+- [ ] **Step 1: Cała suita testów tej fazy**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py -v`
+Expected: PASS (wszystkie testy zielone).
+
+- [ ] **Step 2: Regresja przylegających podsystemów**
+
+Run: `uv run pytest src/import_common/ src/pbn_integrator/ src/pbn_import/ src/deduplikator_autorow/ src/deduplikator_publikacji/ -q`
+Expected: PASS (brak regresji matchingu / merge / importu).
+
+- [ ] **Step 3: ruff całość zmienionych plików**
+
+Run:
+```bash
+ruff format src/pbn_api/models/publication.py src/import_common/core/publikacja.py \
+ src/pbn_integrator/importer/chapters.py src/pbn_import/utils/publication_import.py \
+ src/deduplikator_autorow/utils/merge.py \
+ src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py
+ruff check src/pbn_api/models/publication.py src/import_common/core/publikacja.py \
+ src/pbn_integrator/importer/chapters.py src/pbn_import/utils/publication_import.py \
+ src/deduplikator_autorow/utils/merge.py \
+ src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py
+```
+Expected: brak błędów.
+
+- [ ] **Step 4: Commit zamykający (jeśli ruff coś zmienił)**
+
+```bash
+git add -A
+git commit -m "chore(soft-delete): faza 03 audyt kat. B — format/lint"
+```
+
+---
+
+## Self-Review (autor planu)
+
+**Spec coverage (§2.5 + lista miejsc):**
+- Matching importu (`import_common`) → Task 4. ✓
+- Crossref dedup (`crossref_bpp/core.py:178,182` — `znajdz_w_tabelach` używa
+ `Wydawnictwo_*.objects`): patrz **Nota** niżej.
+- `deduplikator_publikacji/tasks.py` → Task 8 (ZOSTAW + test). ✓
+- PBN matching po `pbn_uid` (`pbn_integrator/utils/synchronization.py`,
+ `importer/chapters.py`, `pbn_api`): Task 2, 3 (rdzeń: `rekord_w_bpp`/
+ `get_bpp_publication` — używane przez `books.py`/`articles.py`), Task 5
+ (chapters). `synchronization.py` matchuje przez `pbn_uid_id` na `rec`
+ (obiekt już w ręku) i `_pobierz_prace_po_elemencie` (po stronie PBN) — nie
+ tworzy duplikatów BPP z ukrywającego menedżera; **decyzja: bez zmian**
+ (patrz Nota). `pbn_api/management/*` — komendy operacyjne na istniejących
+ rekordach, nie matching tworzący duplikaty; **bez zmian**.
+- `pbn_import/utils/publication_import.py:115-116` jawny `.hard_delete()` →
+ Task 6. ✓
+- Audyt 90 miejsc `*_Autor.objects` → Task 7 (decyzje per-miejsce; jedyna
+ zmiana: merge → `global_objects`). ✓
+- Testy wymagane przez zlecenie: re-import nie tworzy duplikatu (Task 1-4),
+ matching po `pbn_uid` znajduje soft-deletowaną (Task 1-3), ewaluacja pomija
+ kosz (Task 7 decyzje + Task 8 test), pbn_import hard-delete fizycznie
+ usuwa (Task 6). ✓
+
+**Nota — `crossref_bpp/core.py:178,182` (`znajdz_w_tabelach`):** używa
+`Wydawnictwo_Zwarte.objects` / `Wydawnictwo_Ciagle.objects` do podpowiedzi
+„czy w BPP jest już taki rekord (po DOI/tytule)" w UI crossref. To kat. B
+(matching importu), ale ZESPÓŁ helperów matchingu używanym przez crossref
+import jest `import_common.matchuj_publikacje` (Task 4 — naprawione).
+`znajdz_w_tabelach` to *podgląd dla operatora* (top-10 kandydatów), nie
+automatyczny dedup tworzący rekordy. **Decyzja: ZOSTAW `objects`** —
+pokazywanie operatorowi rekordów z kosza jako „kandydatów do scalenia"
+byłoby mylące; jeśli rekord jest w koszu, operator najpierw go przywraca.
+Gdyby w fazie 08 (regresja) okazało się, że crossref-import dubluje rekordy
+przez `znajdz_w_tabelach`, dodać `global_objects` tam punktowo. Zapis tej
+decyzji wystarcza dla kat. B (brak ścieżki automatycznego tworzenia duplikatu
+przez tę metodę).
+
+**Placeholder scan:** brak „TBD"/„handle edge cases"; każdy krok ma kod lub
+konkretną komendę. Kroki z zależnością od wersji pakietu (`hard_delete` zwrot,
+Task 6 Step 4) i od faktycznej sygnatury merge (Task 7 Step 2) mają jawną
+instrukcję weryfikacji przed użyciem — to świadome, nie placeholder.
+
+**Type consistency:** `_modele_publikacji_global()` zdefiniowane w Task 2,
+użyte w Task 3 — ta sama nazwa. `_manager_dla_matchingu` zdefiniowane raz
+(Task 4), użyte w 5 helperach. `global_objects` / `objects` / `hard_delete`
+zgodne z kontraktem PINNED w indeksie 00.
+
+---
+
+## Execution Handoff
+
+Plan complete and saved to
+`docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md`.
+
+Dwie opcje wykonania:
+1. **Subagent-Driven (zalecane)** — świeży subagent per Task, review między
+ Taskami (`superpowers:subagent-driven-development`).
+2. **Inline Execution** — wykonanie w tej sesji z checkpointami
+ (`superpowers:executing-plans`).
+
+Faza 03 zależy od ukończonej fazy 02 (modele już `SoftDeleteModel`,
+`global_objects` istnieje). Bez fazy 02 testy nie ruszą.
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md b/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md
new file mode 100644
index 000000000..8b2042427
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md
@@ -0,0 +1,502 @@
+# Soft-delete — Faza 04: Guardy PROTECT (autor + książka-matka)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Wykonuj zadania po kolei; po każdym zadaniu uruchom podaną komendę i zacommituj.
+
+**Goal:** Dodać dwuwarstwową ochronę przed kasowaniem rekordów z zależnościami: (1) flip FK `CASCADE→PROTECT` na powiązaniach autora (`Wydawnictwo_*_Autor.autor`, `Praca_Doktorska.autor`) oraz na self-FK rozdziałów (`Wydawnictwo_Zwarte.wydawnictwo_nadrzedne`) — obrona przed hard-delete; (2) guard w soft-`delete()` modeli `Autor` i `Wydawnictwo_Zwarte`, liczący dzieci przez `global_objects` (widzi też kaskadowo-skasowane autorstwa z fazy 02) i rzucający `ProtectedError`. Autor bez prac → soft-delete (husk); książka bez rozdziałów → soft-delete. `Praca_Habilitacyjna.autor` już jest `PROTECT` — bez zmian.
+
+**Architecture:** Warstwa 1 to migracje **state-only** (`migrations.AlterField` — Django implementuje `on_delete` w ORM, nie jako constraint DB, więc brak zmiany schematu; NIE modyfikujemy istniejących migracji). Warstwa 2 to override `delete(self, *args, user=None, reason="", **kwargs)` wołający helper `raise_if_has_protected_children(instance, relations, label)` z `src/bpp/models/soft_delete.py`. Guard MUSI liczyć przez `global_objects` (nie `objects`), bo `objects` ukrywa kaskadowo-skasowane `*_Autor` (faza 02) i autor „cały w koszu" fałszywie wyglądałby na pustego (spec §3.2, §9). Override `Autor.delete()` **nie kaskaduje** do `*_Autor` (spec §1, §10.1). Override `Wydawnictwo_Zwarte.delete()` dokłada guard **PRZED** kaskadą na `*_Autor` z fazy 02 — kaskady NIE gubimy.
+
+**Tech Stack:** Django, `django-soft-delete>=1.0.23` (`SoftDeleteModel`, menedżery `objects`/`global_objects`/`deleted_objects`, sygnały `post_soft_delete`/`post_restore`), `django.db.models.ProtectedError`, pytest + `model_bakery.baker`, `uv run` dla wszystkich komend Python.
+
+**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) — §1 (asymetria), §2.6 (self-FK rozdziały → PROTECT), §3 (autor: dwie warstwy + soft-delete husków), §9 (ryzyka), §10.1/§10.11 (decyzje).
+**Plan-indeks:** [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md) — kontrakty PINNED (helper `raise_if_has_protected_children`, sygnatura `delete(self, *args, user=None, reason="", **kwargs)`).
+
+**Zależy od:** Faza 02 (modele `Wydawnictwo_Ciagle`/`Wydawnictwo_Zwarte`/`Praca_Doktorska`/`Praca_Habilitacyjna`/`Patent` + 3 through-modele `*_Autor` są już `SoftDeleteModel`; menedżer `global_objects` dostępny; override `Wydawnictwo_Zwarte.delete()` z wąską kaskadą na `*_Autor` już istnieje — faza 04 dokłada do niego guard). Moduł `src/bpp/models/soft_delete.py` istnieje (utworzony fazą 01/04 — tu dopisujemy helper).
+
+---
+
+## Reguły BPP (obowiązują w każdym zadaniu)
+
+- **Wszystkie komendy Python przez `uv run`** (np. `uv run pytest ...`). Nigdy goły `python`.
+- **NIE modyfikuj istniejących migracji** w `src/*/migrations/`. Nowe migracje tworzymy ręcznie / przez `makemigrations`.
+- **Max długość linii 88 znaków** (ruff). Komentarze/komunikaty po polsku.
+- **Testy:** pytest-only, standalone funkcje (bez klas `unittest.TestCase`), `@pytest.mark.django_db`, `model_bakery.baker.make`. Fixtury z `src/fixtures/` (`autor_jan_nowak`, `autor_jan_kowalski`, `jednostka`, `typy_odpowiedzialnosci`, `wydawnictwo_zwarte`, `wydawnictwo_ciagle`, `praca_doktorska`, `patent`).
+- **Kontrakt z reversion (NIE łamać):** override `delete()`/`restore()` idzie per-instancja przez `self.save()` / `super().delete()` — **nigdy** bulk `queryset.update(deleted_at=...)`. Faza 04 nie ustawia `deleted_at` ręcznie; deleguje do `super().delete()` pakietu.
+- **Po każdym zadaniu:** `ruff format src/bpp` + `ruff check src/bpp` (tylko zmienione), komenda testu z zadania na zielono, commit.
+
+## Stan zweryfikowany w kodzie (punkt wyjścia)
+
+- `Wydawnictwo_*_Autor.autor = ForeignKey("bpp.Autor", CASCADE)` — w abstrakcie `BazaModeluOdpowiedzialnosciAutorow` (`src/bpp/models/abstract/authors.py:22`), dziedziczą `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor`. **Bez `related_name`** → reverse default `autor.wydawnictwo_ciagle_autor_set` / `wydawnictwo_zwarte_autor_set` / `patent_autor_set`.
+- `Praca_Doktorska.autor = ForeignKey(Autor, CASCADE)` (`src/bpp/models/praca_doktorska.py:136`), reverse default `autor.praca_doktorska_set`.
+- `Praca_Habilitacyjna.autor = OneToOneField(Autor, PROTECT)` (`src/bpp/models/praca_habilitacyjna.py:42`) — **już PROTECT, nie ruszamy**; reverse `autor.praca_habilitacyjna`.
+- `Wydawnictwo_Zwarte.wydawnictwo_nadrzedne = ForeignKey("self", CASCADE, related_name="wydawnictwa_powiazane_set")` (`src/bpp/models/wydawnictwo_zwarte.py:202`) — rozdziały → książka-matka.
+- `Autor` (`src/bpp/models/autor.py:81`) dziedziczy `LinkDoPBNMixin, ModelZAdnotacjami, ModelZPBN_ID` — **brak custom `delete()`**. Faza 04 dorzuca `SoftDeleteModel` do bazy i override `delete()`.
+- Merge (`src/deduplikator_autorow/utils/merge.py`) przenosi przed `autor.delete()` WSZYSTKIE 5 typów: `Wydawnictwo_Ciagle_Autor` (:191/:223), `Wydawnictwo_Zwarte_Autor` (:265/:317), `Patent_Autor` (:335), `Praca_Habilitacyjna` (:410), `Praca_Doktorska` (:430). Po transferze husk jest pusty → guard go przepuści (test w Zadaniu 6).
+
+**Mapowanie relacji liczonych przez guard (model docelowy → pole FK do autora):**
+
+| relacja (label) | model | pole FK do autora |
+|---|---|---|
+| autorstwo ciągłe | `Wydawnictwo_Ciagle_Autor` | `autor` |
+| autorstwo zwarte | `Wydawnictwo_Zwarte_Autor` | `autor` |
+| autorstwo patentu | `Patent_Autor` | `autor` |
+| doktorat | `Praca_Doktorska` | `autor` |
+| habilitacja | `Praca_Habilitacyjna` | `autor` (O2O) |
+
+Helper liczy przez `Model.global_objects.filter(=instance)`, więc operuje na **modelach docelowych**, nie reverse-managerach (reverse manager nie wystawia `global_objects`).
+
+---
+
+## Tasks
+
+### Zadanie 1 — Helper `raise_if_has_protected_children` w `soft_delete.py`
+
+**Files:**
+- `src/bpp/models/soft_delete.py` — dopisz funkcję `raise_if_has_protected_children` (po istniejących klasach menedżerów z fazy 01).
+- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py` (nowy plik).
+
+Helper przyjmuje listę krotek `(model, pole_fk)` lub listę nazw — wg PINNED kontraktu sygnatura to `raise_if_has_protected_children(instance, relations, label)`, gdzie `relations` to lista `(Model, "pole_fk")`. Liczy sumę dzieci przez `Model.global_objects.filter(**{pole: instance})`; jeśli >0 — rzuca `django.db.models.ProtectedError` z czytelnym komunikatem PL zawierającym `label`, liczbę dzieci i nazwę instancji.
+
+- [ ] **Failing test** — napisz `test_raise_if_has_protected_children_blokuje_gdy_sa_dzieci` i `test_raise_if_has_protected_children_przepuszcza_gdy_brak`:
+ ```python
+ import pytest
+ from django.db.models import ProtectedError
+ from model_bakery import baker
+
+ from bpp.models import (
+ Autor,
+ Wydawnictwo_Ciagle_Autor,
+ Praca_Doktorska,
+ )
+ from bpp.models.soft_delete import raise_if_has_protected_children
+
+
+ @pytest.mark.django_db
+ def test_raise_if_has_protected_children_przepuszcza_gdy_brak():
+ autor = baker.make(Autor)
+ # Nie rzuca — brak dzieci w podanych relacjach.
+ raise_if_has_protected_children(
+ autor,
+ [(Wydawnictwo_Ciagle_Autor, "autor"), (Praca_Doktorska, "autor")],
+ label="autora",
+ )
+
+
+ @pytest.mark.django_db
+ def test_raise_if_has_protected_children_blokuje_gdy_sa_dzieci():
+ autor = baker.make(Autor)
+ baker.make(Wydawnictwo_Ciagle_Autor, autor=autor)
+ with pytest.raises(ProtectedError):
+ raise_if_has_protected_children(
+ autor,
+ [(Wydawnictwo_Ciagle_Autor, "autor")],
+ label="autora",
+ )
+ ```
+- [ ] **Run → FAIL:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k raise_if_has_protected_children` — `ImportError` / `AttributeError` (helper nie istnieje).
+- [ ] **Implementacja** — dopisz w `src/bpp/models/soft_delete.py`:
+ ```python
+ from django.db.models import ProtectedError
+
+
+ def raise_if_has_protected_children(instance, relations, label):
+ """Blokuje soft-delete `instance`, jeśli ma „chronione" dzieci.
+
+ `relations` to lista krotek ``(Model, "pole_fk")`` — liczymy dzieci
+ przez ``Model.global_objects`` (nie ``objects``!), żeby widzieć także
+ rekordy soft-deletowane kaskadą z fazy 02 (autor „cały w koszu" nadal
+ jest chroniony — spec §3.2). Rzuca ``ProtectedError`` z czytelnym
+ komunikatem PL, jeśli jest co najmniej jedno dziecko.
+ """
+ protected = []
+ for model, pole in relations:
+ qs = model.global_objects.filter(**{pole: instance})
+ protected.extend(qs)
+
+ if protected:
+ raise ProtectedError(
+ f"Nie można usunąć {label} „{instance}" — rekord ma "
+ f"{len(protected)} powiązanych prac (autorstwa / doktorat / "
+ f"habilitacja / rozdziały). Najpierw przenieś lub usuń te "
+ f"powiązania.",
+ protected,
+ )
+ ```
+ Uwaga: `ProtectedError(msg, protected_objects)` — drugi argument to iterowalny zbiór chronionych obiektów (kontrakt Django). Importuj `ProtectedError` z `django.db.models`.
+- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k raise_if_has_protected_children`
+- [ ] `ruff format src/bpp/models/soft_delete.py src/bpp/tests/test_models/test_soft_delete_guards.py` + `ruff check` na tych plikach.
+- [ ] **Commit:** `feat(soft-delete): helper raise_if_has_protected_children (guard PROTECT)`
+
+---
+
+### Zadanie 2 — Flip FK `CASCADE→PROTECT` na powiązaniach autora i rozdziałów (migracja state-only)
+
+**Files:**
+- `src/bpp/models/abstract/authors.py:22` — `autor = ForeignKey("bpp.Autor", CASCADE)` → `PROTECT`.
+- `src/bpp/models/praca_doktorska.py:136` — `autor = ForeignKey(Autor, CASCADE)` → `PROTECT`.
+- `src/bpp/models/wydawnictwo_zwarte.py:202` — `wydawnictwo_nadrzedne = ForeignKey("self", CASCADE, ...)` → `PROTECT` (zachowaj `blank`, `null`, `help_text`, `related_name="wydawnictwa_powiazane_set"`).
+- Nowa migracja: `src/bpp/migrations/0XXX_soft_delete_protect_fk.py` (numer = następny wolny; `migrations.AlterField` × 4 — bo `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` dziedziczą pole z abstraktu, więc każdy konkretny model dostaje własny `AlterField`).
+- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py`.
+
+- [ ] **Failing test** — `test_fk_autora_jest_protect` i `test_wydawnictwo_nadrzedne_jest_protect`, sprawdzające `on_delete` przez introspekcję pola:
+ ```python
+ from django.db.models import PROTECT
+
+ from bpp.models import (
+ Patent_Autor,
+ Praca_Doktorska,
+ Wydawnictwo_Ciagle_Autor,
+ Wydawnictwo_Zwarte,
+ Wydawnictwo_Zwarte_Autor,
+ )
+
+
+ def test_fk_autora_jest_protect():
+ for model in (
+ Wydawnictwo_Ciagle_Autor,
+ Wydawnictwo_Zwarte_Autor,
+ Patent_Autor,
+ Praca_Doktorska,
+ ):
+ field = model._meta.get_field("autor")
+ assert field.remote_field.on_delete is PROTECT, model
+
+
+ def test_wydawnictwo_nadrzedne_jest_protect():
+ field = Wydawnictwo_Zwarte._meta.get_field("wydawnictwo_nadrzedne")
+ assert field.remote_field.on_delete is PROTECT
+ ```
+- [ ] **Run → FAIL:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "protect"` — `on_delete` to nadal `CASCADE`.
+- [ ] **Implementacja (modele):**
+ - `src/bpp/models/abstract/authors.py:22` — zmień `from django.db.models import CASCADE, ...` jeśli trzeba dołożyć `PROTECT`; `autor = models.ForeignKey("bpp.Autor", models.PROTECT)`.
+ - `src/bpp/models/praca_doktorska.py:136` — `autor = models.ForeignKey(Autor, PROTECT)` (zadbaj o import `PROTECT`).
+ - `src/bpp/models/wydawnictwo_zwarte.py:202` — pierwszy arg pozycyjny `CASCADE` → `PROTECT` (zachowaj resztę kwargs i `related_name`).
+- [ ] **Implementacja (migracja):** `uv run python src/manage.py makemigrations bpp --name soft_delete_protect_fk`, potem otwórz wygenerowany plik i **dodaj `state_operations` wrapper**, żeby była state-only (bez DDL — `on_delete` żyje tylko w ORM):
+ ```python
+ from django.db import migrations, models
+ import django.db.models.deletion
+
+
+ class Migration(migrations.Migration):
+ dependencies = [
+ ("bpp", "0XXX_poprzednia"), # ostatnia migracja bpp
+ ]
+
+ operations = [
+ migrations.SeparateDatabaseAndState(
+ database_operations=[],
+ state_operations=[
+ migrations.AlterField(
+ model_name="wydawnictwo_ciagle_autor",
+ name="autor",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bpp.autor",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="wydawnictwo_zwarte_autor",
+ name="autor",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bpp.autor",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="patent_autor",
+ name="autor",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bpp.autor",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="praca_doktorska",
+ name="autor",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bpp.autor",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="wydawnictwo_zwarte",
+ name="wydawnictwo_nadrzedne",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="wydawnictwa_powiazane_set",
+ to="bpp.wydawnictwo_zwarte",
+ help_text=(
+ "Jeżeli dodajesz rozdział,\n tu wybierz "
+ "pracę, w ramach której dany rozdział występuje."
+ ),
+ ),
+ ),
+ ],
+ ),
+ ]
+ ```
+ Dopasuj `help_text` 1:1 do tekstu z modelu (skopiuj dokładnie, żeby `makemigrations --check` nie wykrył driftu). Jeśli auto-wygenerowany `AlterField` różni się polami od powyższego — użyj wygenerowanego (jest source-of-truth dla state), tylko owiń w `SeparateDatabaseAndState(database_operations=[], state_operations=[...])`.
+- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "protect"`
+- [ ] **Sanity (brak driftu schematu):** `uv run python src/manage.py makemigrations bpp --check --dry-run` → „No changes detected".
+- [ ] `ruff format` + `ruff check` na zmienionych plikach modeli i migracji.
+- [ ] **Commit:** `feat(soft-delete): flip FK autor/doktorat/rozdziały CASCADE→PROTECT (state-only)`
+
+---
+
+### Zadanie 3 — `Autor` → `SoftDeleteModel` + override soft `delete()` z guardem
+
+**Files:**
+- `src/bpp/models/autor.py:81` — dodaj `SoftDeleteModel` do bazy klasy `Autor`; override `delete(self, *args, user=None, reason="", **kwargs)`.
+- Nowa migracja: `src/bpp/migrations/0XXX_autor_soft_delete.py` (`deleted_at`, `restored_at`, `transaction_id` + indeks — wg pól `SoftDeleteModel`; menedżery `objects`/`global_objects`/`deleted_objects` pakietu).
+- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py`.
+
+Uwaga MRO: `Autor` ma własny menedżer `objects = AutorManager()` (`src/bpp/models/autor.py:200`) i własny `save()`. `SoftDeleteModel` wnosi własne menedżery. Tu **zachowujemy** semantykę: `objects` ma nadal działać jak dotąd dla widoków, ale musi ukrywać skasowane. Najprościej: dodać `SoftDeleteModel` jako bazę, a `objects = AutorManager()` zostaje jako menedżer publiczny — pod warunkiem, że `AutorManager` po fazie 02/04 przepleciony jest z filtrem `deleted_at__isnull=True`. **W tej fazie skupiamy się na guardzie**; jeśli przeplecenie menedżera `AutorManager` nie zostało zrobione w fazie 02, dodaj `global_objects`/`deleted_objects` z pakietu i upewnij się, że `objects` filtruje skasowane (przez `BppSoftDeleteManager` z fazy 01 lub przepleciony `AutorManager`). To zadanie traktuje przeplecenie menedżera jako warunek wstępny; jeśli go brak — najpierw dorób (poza-zakresowy hot-fix odnotuj w commicie).
+
+- [ ] **Failing test** — autor bez prac soft-deletuje się (husk):
+ ```python
+ @pytest.mark.django_db
+ def test_autor_bez_prac_soft_delete_ok(autor_jan_nowak):
+ autor_jan_nowak.delete()
+ autor_jan_nowak.refresh_from_db()
+ assert autor_jan_nowak.deleted_at is not None
+ assert not Autor.objects.filter(pk=autor_jan_nowak.pk).exists()
+ assert Autor.global_objects.filter(pk=autor_jan_nowak.pk).exists()
+ ```
+- [ ] **Run → FAIL:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k test_autor_bez_prac_soft_delete_ok` — `AttributeError: deleted_at` lub `global_objects` (Autor nie jest jeszcze SoftDeleteModel).
+- [ ] **Implementacja (model):**
+ - Import: `from django_softdelete.models import SoftDeleteModel`.
+ - Baza klasy: `class Autor(LinkDoPBNMixin, ModelZAdnotacjami, ModelZPBN_ID, SoftDeleteModel):`.
+ - Override (po `save()`):
+ ```python
+ def delete(self, *args, user=None, reason="", **kwargs):
+ """Soft-delete autora — DOZWOLONY tylko dla autora bez prac (husk).
+
+ Guard liczy WSZYSTKIE powiązania przez global_objects (także
+ kaskadowo-skasowane autorstwa z fazy 02), spec §3.2. NIE kaskaduje
+ do *_Autor (spec §1). Autor z jakąkolwiek pracą → ProtectedError.
+ """
+ from bpp.models import (
+ Patent_Autor,
+ Praca_Doktorska,
+ Praca_Habilitacyjna,
+ Wydawnictwo_Ciagle_Autor,
+ Wydawnictwo_Zwarte_Autor,
+ )
+ from bpp.models.soft_delete import raise_if_has_protected_children
+
+ raise_if_has_protected_children(
+ self,
+ [
+ (Wydawnictwo_Ciagle_Autor, "autor"),
+ (Wydawnictwo_Zwarte_Autor, "autor"),
+ (Patent_Autor, "autor"),
+ (Praca_Doktorska, "autor"),
+ (Praca_Habilitacyjna, "autor"),
+ ],
+ label="autora",
+ )
+ return super().delete(*args, **kwargs)
+ ```
+ `user`/`reason` przyjmujemy w sygnaturze (kontrakt PINNED dla fazy 06/07) — w tej fazie nie używamy ich dalej; `super().delete()` pakietu nie przyjmuje tych kwargs, więc NIE przekazujemy ich w `**kwargs` do super (odfiltrowane przez nazwane parametry).
+- [ ] **Implementacja (migracja):** `uv run python src/manage.py makemigrations bpp --name autor_soft_delete`. Sprawdź, że dodaje `deleted_at`, `restored_at`, `transaction_id` (oraz ewentualne menedżery). Pola domyślnie `NULL` — bez backfillu (spec §9, duże tabele).
+- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k test_autor_bez_prac_soft_delete_ok`
+- [ ] `uv run python src/manage.py makemigrations bpp --check --dry-run` → „No changes detected".
+- [ ] `ruff format` + `ruff check` na `autor.py`, migracji, teście.
+- [ ] **Commit:** `feat(soft-delete): Autor → SoftDeleteModel + guard soft-delete (husk only)`
+
+---
+
+### Zadanie 4 — Testy guarda `Autor.delete()` dla każdego typu pracy + przypadek „tylko w koszu"
+
+**Files:**
+- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py` (dopisz funkcje).
+
+Pokrywamy: ciągłe / zwarte / patent / doktorat / habilitacja → `ProtectedError`; oraz krytyczny przypadek z §3.2 — praca soft-deletowana (kaskada fazy 02) nadal blokuje, bo guard liczy przez `global_objects`.
+
+- [ ] **Failing test** — pięć przypadków + przypadek „tylko w koszu":
+ ```python
+ @pytest.mark.django_db
+ def test_autor_z_praca_ciagla_protect(
+ wydawnictwo_ciagle, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci
+ ):
+ wydawnictwo_ciagle.dodaj_autora(autor_jan_kowalski, jednostka)
+ with pytest.raises(ProtectedError):
+ autor_jan_kowalski.delete()
+
+
+ @pytest.mark.django_db
+ def test_autor_z_praca_zwarta_protect(
+ wydawnictwo_zwarte, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci
+ ):
+ wydawnictwo_zwarte.dodaj_autora(autor_jan_kowalski, jednostka)
+ with pytest.raises(ProtectedError):
+ autor_jan_kowalski.delete()
+
+
+ @pytest.mark.django_db
+ def test_autor_z_patentem_protect(
+ patent, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci
+ ):
+ patent.dodaj_autora(autor_jan_kowalski, jednostka)
+ with pytest.raises(ProtectedError):
+ autor_jan_kowalski.delete()
+
+
+ @pytest.mark.django_db
+ def test_autor_z_doktoratem_protect(autor_jan_nowak):
+ baker.make(Praca_Doktorska, autor=autor_jan_nowak)
+ with pytest.raises(ProtectedError):
+ autor_jan_nowak.delete()
+
+
+ @pytest.mark.django_db
+ def test_autor_z_habilitacja_protect(autor_jan_nowak):
+ baker.make(Praca_Habilitacyjna, autor=autor_jan_nowak)
+ with pytest.raises(ProtectedError):
+ autor_jan_nowak.delete()
+
+
+ @pytest.mark.django_db
+ def test_autor_z_praca_tylko_w_koszu_nadal_protect(
+ wydawnictwo_ciagle, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci
+ ):
+ # Praca + jej *_Autor są soft-deletowane (kaskada fazy 02).
+ wydawnictwo_ciagle.dodaj_autora(autor_jan_kowalski, jednostka)
+ wydawnictwo_ciagle.delete()
+ # objects ukrywa kaskadowo-skasowane autorstwo, ale guard liczy
+ # przez global_objects → autor nadal chroniony (spec §3.2).
+ assert not Wydawnictwo_Ciagle_Autor.objects.filter(
+ autor=autor_jan_kowalski
+ ).exists()
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(
+ autor=autor_jan_kowalski
+ ).exists()
+ with pytest.raises(ProtectedError):
+ autor_jan_kowalski.delete()
+ ```
+ Dodaj brakujące importy do nagłówka pliku: `Praca_Habilitacyjna`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor`, `Wydawnictwo_Ciagle_Autor`.
+- [ ] **Run → FAIL (najpierw napisz, potem uruchom):** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "protect"` — przy poprawnym guardzie z Zadania 3 powinny przejść; jeśli któryś `FAIL`, debuguj guard/relacje (np. zła nazwa pola FK, użyto `objects` zamiast `global_objects`). To zadanie jest „dowodem regresji" guarda — przy błędzie w guardzie tu pęka.
+- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "protect"`
+- [ ] `ruff format` + `ruff check` na teście.
+- [ ] **Commit:** `test(soft-delete): guard Autor.delete() — ciągłe/zwarte/patent/doktorat/habilitacja + kosz`
+
+---
+
+### Zadanie 5 — `Wydawnictwo_Zwarte.delete()` guard na rozdziały (PRZED kaskadą fazy 02)
+
+**Files:**
+- `src/bpp/models/wydawnictwo_zwarte.py` — w istniejącym override `delete()` (z fazy 02, wąska kaskada na `*_Autor`) dodaj guard **na samym początku**, PRZED ustawieniem `deleted_at` i PRZED kaskadą na `*_Autor`.
+- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py`.
+
+Guard liczy rozdziały (dzieci self-FK) przez `global_objects` (spec §2.6 — soft-deletowane rozdziały też blokują). Reverse `related_name="wydawnictwa_powiazane_set"`, ale liczymy spójnie helperem przez model docelowy: `(Wydawnictwo_Zwarte, "wydawnictwo_nadrzedne")`.
+
+- [ ] **Failing test** — książka z rozdziałem → `ProtectedError`; bez rozdziałów → soft-delete OK:
+ ```python
+ @pytest.mark.django_db
+ def test_ksiazka_matka_z_rozdzialem_protect(wydawnictwo_zwarte):
+ rozdzial = baker.make(
+ Wydawnictwo_Zwarte, wydawnictwo_nadrzedne=wydawnictwo_zwarte
+ )
+ assert rozdzial.wydawnictwo_nadrzedne_id == wydawnictwo_zwarte.pk
+ with pytest.raises(ProtectedError):
+ wydawnictwo_zwarte.delete()
+ wydawnictwo_zwarte.refresh_from_db()
+ assert wydawnictwo_zwarte.deleted_at is None # guard zablokował
+
+
+ @pytest.mark.django_db
+ def test_ksiazka_bez_rozdzialow_soft_delete_ok(wydawnictwo_zwarte):
+ wydawnictwo_zwarte.delete()
+ wydawnictwo_zwarte.refresh_from_db()
+ assert wydawnictwo_zwarte.deleted_at is not None
+
+
+ @pytest.mark.django_db
+ def test_ksiazka_matka_z_rozdzialem_w_koszu_nadal_protect(wydawnictwo_zwarte):
+ rozdzial = baker.make(
+ Wydawnictwo_Zwarte, wydawnictwo_nadrzedne=wydawnictwo_zwarte
+ )
+ rozdzial.delete() # rozdział w koszu
+ assert not Wydawnictwo_Zwarte.objects.filter(pk=rozdzial.pk).exists()
+ with pytest.raises(ProtectedError):
+ wydawnictwo_zwarte.delete() # soft-deletowany rozdział też blokuje
+ ```
+- [ ] **Run → FAIL:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "ksiazka"` — bez guarda książka-matka skasuje się mimo rozdziału (test PROTECT pęka) lub `deleted_at` zostanie ustawione.
+- [ ] **Implementacja** — w `Wydawnictwo_Zwarte.delete()` (z fazy 02) dodaj na początku:
+ ```python
+ def delete(self, *args, user=None, reason="", **kwargs):
+ from bpp.models.soft_delete import raise_if_has_protected_children
+
+ raise_if_has_protected_children(
+ self,
+ [(Wydawnictwo_Zwarte, "wydawnictwo_nadrzedne")],
+ label="książki (ma rozdziały)",
+ )
+ # ... istniejąca logika fazy 02: ustaw deleted_at, kaskada na *_Autor ...
+ return super().delete(*args, **kwargs) # lub istniejący return fazy 02
+ ```
+ **NIE gub kaskady na `*_Autor` z fazy 02** — guard wstawiamy WYŁĄCZNIE przed nią; reszta ciała `delete()` bez zmian.
+- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "ksiazka"`
+- [ ] **Regresja kaskady fazy 02:** `uv run pytest src/bpp/tests/test_models/test_wydawnictwo_zwarte.py` — kaskada na `*_Autor` nadal działa dla książki bez rozdziałów.
+- [ ] `ruff format` + `ruff check` na `wydawnictwo_zwarte.py`, teście.
+- [ ] **Commit:** `feat(soft-delete): guard Wydawnictwo_Zwarte.delete() blokuje gdy ma rozdziały`
+
+---
+
+### Zadanie 6 — Symulacja merge: husk po transferze prac soft-deletuje się
+
+**Files:**
+- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py`.
+
+Weryfikuje spec §3.3 / overview: merge przenosi wszystkie typy prac na autora głównego, potem woła `autor.delete()` na pustym duplikacie — guard go przepuszcza. Test symuluje to bez wołania całego merge'a (przenosi `Wydawnictwo_Ciagle_Autor` na głównego, potem usuwa husk).
+
+- [ ] **Failing test** — (powinien przejść od razu, bo guard z Zadania 3 już działa; pełni rolę dowodu „merge nie jest zablokowany"):
+ ```python
+ @pytest.mark.django_db
+ def test_husk_po_transferze_prac_soft_delete_ok(
+ wydawnictwo_ciagle,
+ autor_jan_kowalski,
+ autor_jan_nowak,
+ jednostka,
+ typy_odpowiedzialnosci,
+ ):
+ # duplikat ma pracę
+ wydawnictwo_ciagle.dodaj_autora(autor_jan_nowak, jednostka)
+ wca = Wydawnictwo_Ciagle_Autor.global_objects.get(autor=autor_jan_nowak)
+ # merge: przenieś autorstwo na autora głównego (jak utils/merge.py:223)
+ wca.autor = autor_jan_kowalski
+ wca.save()
+ # husk (autor_jan_nowak) jest teraz pusty → guard przepuszcza
+ autor_jan_nowak.delete()
+ autor_jan_nowak.refresh_from_db()
+ assert autor_jan_nowak.deleted_at is not None
+ ```
+- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k test_husk_po_transferze`
+- [ ] **Regresja merge (smoke):** `uv run pytest src/deduplikator_autorow/` — merge nadal przechodzi (PROTECT nie psuje, bo husk pusty w chwili `delete()`).
+- [ ] `ruff format` + `ruff check` na teście.
+- [ ] **Commit:** `test(soft-delete): husk autora po transferze prac soft-deletuje się (merge)`
+
+---
+
+### Zadanie 7 — Pełny przebieg testów fazy + sanity migracji
+
+**Files:** brak zmian; tylko weryfikacja.
+
+- [ ] **Cały plik guardów:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -v` — wszystkie zielone.
+- [ ] **Brak driftu migracji:** `uv run python src/manage.py makemigrations bpp --check --dry-run` → „No changes detected".
+- [ ] **Regresja modeli + merge:** `uv run pytest src/bpp/tests/test_models/ src/deduplikator_autorow/` — zielone (PROTECT/guard nie psują istniejących ścieżek).
+- [ ] `ruff check src/bpp` (zmienione) + `ruff format --check` na plikach fazy.
+- [ ] **Commit (jeśli cokolwiek doszło):** `chore(soft-delete): zielona faza 04 — guardy PROTECT`
+
+---
+
+## Definicja ukończenia fazy 04
+
+- Helper `raise_if_has_protected_children(instance, relations, label)` w `src/bpp/models/soft_delete.py` — liczy przez `global_objects`, rzuca `django.db.models.ProtectedError` z komunikatem PL.
+- FK `CASCADE→PROTECT` (migracja state-only) na: `Wydawnictwo_Ciagle_Autor.autor`, `Wydawnictwo_Zwarte_Autor.autor`, `Patent_Autor.autor`, `Praca_Doktorska.autor`, `Wydawnictwo_Zwarte.wydawnictwo_nadrzedne`. Habilitacja bez zmian (już PROTECT).
+- `Autor` jest `SoftDeleteModel`; override `delete(self, *args, user=None, reason="", **kwargs)` z guardem (5 relacji przez `global_objects`), bez kaskady do `*_Autor`. Autor bez prac → husk.
+- `Wydawnictwo_Zwarte.delete()` ma guard na rozdziały PRZED kaskadą fazy 02 (kaskada zachowana).
+- Testy: każdy typ pracy blokuje, praca-tylko-w-koszu blokuje, husk po merge przechodzi, książka z rozdziałem (też w koszu) blokuje, książka bez rozdziałów soft-deletuje się.
+- `makemigrations --check --dry-run` czysty; regresja `test_models/` + `deduplikator_autorow/` zielona.
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md
new file mode 100644
index 000000000..f5188c9cc
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md
@@ -0,0 +1,668 @@
+# Soft-delete — Faza 05: PBN wycofanie przez kolejkę
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. TDD: każdy krok najpierw PRAWDZIWY failing test → komenda + FAIL → PRAWDZIWA implementacja → komenda + PASS → commit.
+
+**Goal:** Rozszerzyć `pbn_export_queue` o operację `WYCOFANIE` (obok dotychczasowej `WYSYLKA`), tak by soft-delete publikacji mógł asynchronicznie wycofać oświadczenia dyscyplin z profilu instytucji PBN przez `client.delete_all_publication_statements(pbn_uid)`, z retry/locking/błędami jak istniejąca ścieżka wysyłki. Dostarczyć publiczne funkcje zakolejkowujące (`zakolejkuj_wycofanie`, `zakolejkuj_wysylke`) wołane potem z fazy 06, oraz zaktualizować `SentData` po udanym wycofaniu (`submitted_successfully=False` + znacznik `withdrawn_at`), bez kasowania wiersza.
+
+**Architecture:** Nowe pole `operacja` (`TextChoices` `WYSYLKA="wysylka"`/`WYCOFANIE="wycofanie"`, default `WYSYLKA` dla kompatybilności wstecznej) na `PBN_Export_Queue`. `send_to_pbn()` rozgałęzia się na początku: `WYCOFANIE` → nowa metoda `withdraw_from_pbn()` (GET klienta jak w wysyłce, `delete_all_publication_statements` z retry, aktualizacja `SentData`, status przez istniejące `_handle_successful_send`-analog / `error()`); `WYSYLKA` → dotychczasowa ścieżka bez zmian. Gate zakolejkowania: wycofanie tylko gdy rekord ma `pbn_uid_id`.
+
+**Tech Stack:** Django, PostgreSQL, Celery + `pbn_export_queue`, `pbn_api` (`PBNClient`, `SentData`), pytest + model_bakery + `unittest.mock`.
+
+**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§4 całość) · Indeks: [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md) (kontrakt `pbn_export_queue` rozszerzenie + `SentData`).
+
+**Zależność:** Faza 02 (publikacje `SoftDeleteModel`). Faza 06 woła `zakolejkuj_wycofanie`/`zakolejkuj_wysylke` z receiverów sygnałów — tu budujemy mechanizm + funkcje, NIE podpinamy sygnałów.
+
+---
+
+## Reguły BPP (obowiązują w każdym kroku)
+
+- Wszystkie komendy Pythona przez `uv run` (np. `uv run pytest ...`).
+- Testy: pytest, standalone functions / klasy bez `unittest.TestCase`, `@pytest.mark.django_db`, `model_bakery.baker.make`, mock klienta PBN przez `unittest.mock`.
+- Max długość linii 88 znaków (ruff). Komentarze/teksty po polsku.
+- **NIE modyfikować istniejących migracji** w `src/*/migrations/`. Ostatnia migracja `pbn_export_queue`: `0007_reclassify_doiorwwwmissing_errors.py` → nowe to `0008_*`.
+- Po każdym kroku z kodem produkcyjnym: `ruff check` + `ruff format` na dotkniętych plikach, potem commit.
+
+---
+
+## Stan zastany (zweryfikowany w kodzie — używać tych nazw VERBATIM)
+
+`src/pbn_export_queue/models.py`:
+- `PBN_Export_Queue`: `object_id`, `content_type`, `rekord_do_wysylki` (GFK), `zamowil` (FK user, `on_delete=CASCADE`), `zamowiono`, `wysylke_podjeto`, `wysylke_zakonczono`, `ilosc_prob`, `zakonczono_pomyslnie` (`BooleanField(null=True, default=None)`), `komunikat`, `retry_after_user_authorised`, `rodzaj_bledu` (`RodzajBledu.TECHNICZNY/MERYTORYCZNY`), `wykluczone`.
+- Manager `PBN_Export_QueueManager`: `filter_rekord_do_wysylki(rekord)` (filtr `wysylke_zakonczono=None`), `sprobuj_utowrzyc_wpis(user, rekord)` (rzuca `AlreadyEnqueuedError` gdy już w kolejce).
+- Metody: `send_to_pbn()` (`refresh_from_db`; jeśli `wysylke_zakonczono` → `FINISHED_OKAY`; gdy rekord zniknął → `error(...)`; inkrementuje `ilosc_prob`; woła `sprobuj_wyslac_do_pbn_celery(user=self.zamowil.get_pbn_user(), obj=self.rekord_do_wysylki, force_upload=True)`; `_handle_pbn_exception(exc)` na wyjątek; `_handle_successful_send(sent_data, notificator)` na sukces), `error(msg, rodzaj=None)` (ustawia `wysylke_zakonczono`, `zakonczono_pomyslnie=False`, zwraca `SendStatus.FINISHED_ERROR`), `dopisz_komunikat(msg)`, `check_if_record_still_exists()`, `prepare_for_resend(user, message_suffix)`, `sprobuj_wyslac_do_pbn()` (deleguje `task_sprobuj_wyslac_do_pbn.delay(self.pk)`).
+- `SendStatus` (Enum): `RETRY_SOON`, `RETRY_LATER`, `RETRY_MUCH_LATER`, `RETRY_AFTER_USER_AUTHORISED`, `WYKLUCZONE`, `FINISHED_OKAY`, `FINISHED_ERROR`.
+
+`src/pbn_export_queue/tasks.py`:
+- `task_sprobuj_wyslac_do_pbn(pk)` — lock przez `cache.add(LOCK_PREFIX+pk)`, `wait_for_object`, `p.send_to_pbn()`, `match` na `SendStatus` (RETRY_* → `apply_async(countdown=...)`, `FINISHED_OKAY` → `check_and_send_next_in_queue()`). Lock zwalniany w `finally`. **Ta sama maszyneria obsłuży WYCOFANIE bez zmian** — `send_to_pbn()` zwraca `SendStatus`.
+
+`src/pbn_api/client/mixins/institutions.py`:
+- `delete_all_publication_statements(publicationId)` (`:87`) — DELETE; może rzucić `ResourceLockedException`, `CannotDeleteStatementsException` (gdy PBN: "nie istnieją oświadczenia"), `HttpException`.
+
+`src/pbn_api/client/publication_sync.py`:
+- `_delete_statements_with_retry(pbn_uid_id, max_tries=5)` (`:411`) — pętla: `delete_all_publication_statements` → przy `CannotDeleteStatementsException` retry (5 prób, `sleep(0.5)`), inne wyjątki lecą w górę. Wzorzec retry dla wycofania.
+
+`src/pbn_api/models/sentdata.py`:
+- `SentDataManager`: `get_for_rec(rec)` (rzuca `SentData.DoesNotExist`), `mark_as_successful(rec, pbn_uid_id=None, api_response_status="")` (ustawia `submitted_successfully=True`, `uploaded_okay=True`), `mark_as_failed(...)`, `create_or_update_before_upload(...)`.
+- `SentData` pola: `content_type`/`object_id`/`object` (GFK), `submitted_successfully` (`BooleanField`), `submitted_at`, `api_response_status` (`TextField`), `uploaded_okay`, `pbn_uid` (FK `pbn_api.Publication`, `SET_NULL`).
+
+`src/bpp/admin/helpers/pbn_api/cli.py`:
+- `sprobuj_wyslac_do_pbn_celery(user, obj, force_upload=False, pbn_client=None)` — buduje `pbn_client = uczelnia.pbn_client(user.pbn_token)`. Wzorzec pozyskania klienta dla wycofania.
+
+`src/bpp/models/abstract/pbn.py`: rekordy mają `pbn_uid = OneToOneField("pbn_api.Publication")` → `rec.pbn_uid_id` to PBN UID (string id publikacji w PBN).
+
+---
+
+## Decyzja: znacznik wycofania w `SentData` — nowe pole `withdrawn_at`
+
+**Wybór: dodać nowe pole `withdrawn_at = models.DateTimeField(null=True, blank=True)` na `SentData`** (migracja `pbn_api/migrations/0XXX`).
+
+**Uzasadnienie (dlaczego NIE `api_response_status`):** `api_response_status` to swobodny `TextField` nadpisywany przy KAŻDEJ operacji (`mark_as_successful`/`mark_as_failed`/`create_or_update_before_upload` go czyszczą/ustawiają surową odpowiedzią API). Użycie go jako znacznika stanu byłoby kruche — pierwsza kolejna wysyłka by go skasowała, a parsowanie statusu z tekstu odpowiedzi PBN jest nieodporne. Dedykowane `withdrawn_at` (timestamp) daje: (1) jednoznaczny, kwerowalny stan "rekord wycofany w PBN dnia X", (2) audyt kiedy, (3) symetrię: restore→`WYSYLKA`→`mark_as_successful` musi je wyzerować (`withdrawn_at=None`). Wiersza `SentData` NIE kasujemy (zostaje dla re-matchingu przy restore i dla `SoftDeleteLog` w fazie 06).
+
+---
+
+## Tasks
+
+### Task 05.1 — Pole `operacja` na `PBN_Export_Queue` + migracja
+
+**Files:**
+- `src/pbn_export_queue/models.py` (klasa `PBN_Export_Queue`, dodać `Operacja` TextChoices + pole `operacja`)
+- `src/pbn_export_queue/migrations/0008_pbn_export_queue_operacja.py` (NOWA)
+- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` (NOWY)
+
+- [ ] **Failing test — pole `operacja` istnieje z defaultem `WYSYLKA`.** W nowym pliku `src/pbn_export_queue/tests/test_operacja_wycofanie.py`:
+ ```python
+ from unittest.mock import MagicMock, patch
+
+ import pytest
+ from model_bakery import baker
+
+ from pbn_export_queue.models import (
+ PBN_Export_Queue,
+ SendStatus,
+ )
+
+
+ @pytest.mark.django_db
+ def test_operacja_default_wysylka(wydawnictwo_ciagle, admin_user):
+ wpis = baker.make(
+ PBN_Export_Queue,
+ rekord_do_wysylki=wydawnictwo_ciagle,
+ zamowil=admin_user,
+ )
+ wpis.refresh_from_db()
+ assert wpis.operacja == PBN_Export_Queue.Operacja.WYSYLKA
+ assert PBN_Export_Queue.Operacja.WYSYLKA == "wysylka"
+ assert PBN_Export_Queue.Operacja.WYCOFANIE == "wycofanie"
+ ```
+- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_operacja_default_wysylka -x` → `AttributeError`/`FieldError` (brak `operacja`).
+- [ ] **Implementacja — TextChoices + pole.** W `src/pbn_export_queue/models.py`, w klasie `PBN_Export_Queue` (po polach, przed `objects = ...`):
+ ```python
+ class Operacja(models.TextChoices):
+ WYSYLKA = "wysylka", "Wysyłka"
+ WYCOFANIE = "wycofanie", "Wycofanie oświadczeń"
+
+ operacja = models.CharField(
+ max_length=16,
+ choices=Operacja.choices,
+ default=Operacja.WYSYLKA,
+ db_index=True,
+ verbose_name="Operacja",
+ )
+ ```
+- [ ] **Migracja:** `uv run python src/manage.py makemigrations pbn_export_queue --name pbn_export_queue_operacja`. Zweryfikuj, że plik to `0008_pbn_export_queue_operacja.py` i dodaje wyłącznie pole `operacja` (`AddField`, default `wysylka`). NIE edytować wcześniejszych migracji.
+- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_operacja_default_wysylka -x` → PASS.
+- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/migrations/0008_pbn_export_queue_operacja.py src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_export_queue): pole operacja (WYSYLKA|WYCOFANIE) + migracja"`
+
+---
+
+### Task 05.2 — `withdrawn_at` na `SentData` + symetria w managerze
+
+**Files:**
+- `src/pbn_api/models/sentdata.py` (pole `withdrawn_at`; manager: `mark_as_withdrawn`; reset `withdrawn_at` w `mark_as_successful`)
+- `src/pbn_api/migrations/0XXX_sentdata_withdrawn_at.py` (NOWA — numer wg `makemigrations`)
+- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+
+- [ ] **Failing test — `mark_as_withdrawn` ustawia stan, `mark_as_successful` go zeruje.** Dopisz do `test_operacja_wycofanie.py`:
+ ```python
+ @pytest.mark.django_db
+ def test_sentdata_mark_as_withdrawn(wydawnictwo_ciagle):
+ from pbn_api.models.sentdata import SentData
+
+ SentData.objects.create(
+ object=wydawnictwo_ciagle,
+ data_sent={},
+ submitted_successfully=True,
+ uploaded_okay=True,
+ )
+
+ SentData.objects.mark_as_withdrawn(wydawnictwo_ciagle)
+
+ sd = SentData.objects.get_for_rec(wydawnictwo_ciagle)
+ assert sd.submitted_successfully is False
+ assert sd.withdrawn_at is not None
+
+ # restore → ponowna wysyłka zeruje znacznik wycofania
+ SentData.objects.mark_as_successful(wydawnictwo_ciagle)
+ sd = SentData.objects.get_for_rec(wydawnictwo_ciagle)
+ assert sd.submitted_successfully is True
+ assert sd.withdrawn_at is None
+ ```
+- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_sentdata_mark_as_withdrawn -x` → `AttributeError` (`mark_as_withdrawn`/`withdrawn_at`).
+- [ ] **Implementacja — pole.** W `src/pbn_api/models/sentdata.py`, w klasie `SentData` (przy polach śledzenia, po `api_url`):
+ ```python
+ withdrawn_at = models.DateTimeField(
+ "Data wycofania oświadczeń",
+ null=True,
+ blank=True,
+ db_index=True,
+ help_text="Ustawiane po udanym wycofaniu oświadczeń z PBN "
+ "(soft-delete publikacji). Zerowane przy ponownej wysyłce.",
+ )
+ ```
+- [ ] **Implementacja — manager `mark_as_withdrawn`.** W `SentDataManager` dodaj:
+ ```python
+ def mark_as_withdrawn(self, rec, api_response_status=""):
+ """Oznacza rekord jako wycofany z PBN (oświadczenia usunięte).
+
+ Wiersza SentData NIE kasujemy — zostaje dla audytu i
+ re-matchingu przy restore. submitted_successfully=False, bo
+ rekord nie jest już "wystawiony" w PBN.
+ """
+ sd = self.get_for_rec(rec)
+ sd.submitted_successfully = False
+ sd.withdrawn_at = timezone.now()
+ if api_response_status:
+ sd.api_response_status = api_response_status
+ sd.save()
+ return sd
+ ```
+- [ ] **Implementacja — symetria w `mark_as_successful`.** W `SentDataManager.mark_as_successful`, po `sd.submitted_successfully = True`, dodaj `sd.withdrawn_at = None` (restore→WYSYLKA czyści znacznik wycofania). (`timezone` jest już zaimportowany w pliku.)
+- [ ] **Migracja:** `uv run python src/manage.py makemigrations pbn_api --name sentdata_withdrawn_at`. Zweryfikuj, że dodaje wyłącznie pole `withdrawn_at`.
+- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_sentdata_mark_as_withdrawn -x` → PASS.
+- [ ] **Lint:** `uv run ruff check src/pbn_api/models/sentdata.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_api/models/sentdata.py src/pbn_api/migrations/*sentdata_withdrawn_at*.py src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_api): SentData.withdrawn_at + mark_as_withdrawn; reset przy mark_as_successful"`
+
+---
+
+### Task 05.3 — `withdraw_from_pbn()` + rozgałęzienie w `send_to_pbn()`
+
+Gałąź `WYCOFANIE` woła `client.delete_all_publication_statements(pbn_uid)` z retry analogicznym do `_delete_statements_with_retry` (obsługa `CannotDeleteStatementsException` jako sukces "nic do wycofania"), aktualizuje `SentData` i zwraca `SendStatus`. `WYSYLKA` → ścieżka bez zmian. Lock/retry/`ilosc_prob`/`task_sprobuj_wyslac_do_pbn` działają niezmienione (zwracamy ten sam typ `SendStatus`).
+
+**Files:**
+- `src/pbn_export_queue/models.py` (`send_to_pbn` rozgałęzienie; nowa `withdraw_from_pbn`; helper `_pozyskaj_klienta_pbn`)
+- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+
+- [ ] **Failing test — WYCOFANIE woła `delete_all_publication_statements` z właściwym pbn_uid + oznacza SentData.** Dopisz:
+ ```python
+ @pytest.mark.django_db
+ def test_wycofanie_wola_delete_all_statements(wydawnictwo_ciagle, admin_user):
+ from pbn_api.models import Publication
+ from pbn_api.models.sentdata import SentData
+
+ pub = baker.make(Publication, pk="PBN-UID-123")
+ wydawnictwo_ciagle.pbn_uid = pub
+ wydawnictwo_ciagle.save()
+ SentData.objects.create(
+ object=wydawnictwo_ciagle,
+ data_sent={},
+ submitted_successfully=True,
+ uploaded_okay=True,
+ )
+
+ wpis = baker.make(
+ PBN_Export_Queue,
+ rekord_do_wysylki=wydawnictwo_ciagle,
+ zamowil=admin_user,
+ operacja=PBN_Export_Queue.Operacja.WYCOFANIE,
+ wysylke_zakonczono=None,
+ )
+
+ mock_client = MagicMock()
+ with patch.object(
+ PBN_Export_Queue, "_pozyskaj_klienta_pbn", return_value=mock_client
+ ):
+ result = wpis.send_to_pbn()
+
+ assert result == SendStatus.FINISHED_OKAY
+ mock_client.delete_all_publication_statements.assert_called_once_with(
+ "PBN-UID-123"
+ )
+ wpis.refresh_from_db()
+ assert wpis.zakonczono_pomyslnie is True
+ sd = SentData.objects.get_for_rec(wydawnictwo_ciagle)
+ assert sd.submitted_successfully is False
+ assert sd.withdrawn_at is not None
+ ```
+ (Uwaga: `Publication.pk` jest stringiem — `pbn_uid_id` to ten string. Jeśli baker nie pozwoli ustawić `pk`, użyj `baker.make(Publication, mongoId="PBN-UID-123")` i odczytaj `wydawnictwo_ciagle.pbn_uid_id` w asercji zamiast literału — dostosuj po sprawdzeniu modelu `Publication`.)
+- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_wola_delete_all_statements -x` → FAIL (`send_to_pbn` idzie ścieżką wysyłki / brak `_pozyskaj_klienta_pbn`).
+- [ ] **Implementacja — helper klienta.** W `PBN_Export_Queue` dodaj metodę pozyskania klienta (wzorzec z `cli.py`):
+ ```python
+ def _pozyskaj_klienta_pbn(self):
+ """Buduje klienta PBN dla użytkownika, który zlecił operację.
+
+ Analogicznie do sprobuj_wyslac_do_pbn_celery: token z konta PBN
+ zamawiającego, klient z parametrów Uczelni.
+ """
+ from bpp.models import Uczelnia
+
+ pbn_user = self.zamowil.get_pbn_user()
+ uczelnia = Uczelnia.objects.get_default()
+ return uczelnia.pbn_client(pbn_user.pbn_token)
+ ```
+- [ ] **Implementacja — `withdraw_from_pbn`.** Dodaj metodę (retry wzorowany na `_delete_statements_with_retry`, ale `CannotDeleteStatementsException` traktujemy jako sukces — nic nie ma do wycofania):
+ ```python
+ def withdraw_from_pbn(self):
+ """Wycofuje oświadczenia dyscyplin publikacji z profilu instytucji.
+
+ Gałąź operacji WYCOFANIE: woła delete_all_publication_statements
+ z retry. CannotDeleteStatementsException = oświadczeń już nie ma
+ → traktujemy jako sukces (idempotencja). Po sukcesie oznacza
+ SentData jako wycofany. :return: SendStatus
+ """
+ import time
+
+ from pbn_api.exceptions import (
+ CannotDeleteStatementsException,
+ ResourceLockedException,
+ )
+
+ rec = self.rekord_do_wysylki
+ pbn_uid = getattr(rec, "pbn_uid_id", None)
+ if not pbn_uid:
+ # Gate obronny: nic do wycofania (rekord nigdy nie poszedł
+ # do PBN). Nie powinno się zdarzyć — zakolejkuj_wycofanie
+ # nie tworzy takich wpisów — ale na wszelki wypadek.
+ self.wysylke_zakonczono = timezone.now()
+ self.zakonczono_pomyslnie = True
+ self.dopisz_komunikat(
+ "Wycofanie pominięte: rekord nie ma PBN UID."
+ )
+ self.save()
+ return SendStatus.FINISHED_OKAY
+
+ try:
+ client = self._pozyskaj_klienta_pbn()
+ except Exception as exc:
+ return self._handle_pbn_exception(exc)
+
+ no_tries = 5
+ while True:
+ try:
+ client.delete_all_publication_statements(pbn_uid)
+ break
+ except CannotDeleteStatementsException:
+ # Oświadczeń już nie ma w PBN — cel osiągnięty.
+ break
+ except ResourceLockedException as exc:
+ self.dopisz_komunikat(
+ f"{exc}, ponawiam wycofanie za kilka minut..."
+ )
+ self.save()
+ return SendStatus.RETRY_LATER
+ except Exception as exc:
+ if no_tries <= 0:
+ return self._handle_pbn_exception(exc)
+ no_tries -= 1
+ time.sleep(0.5)
+
+ from pbn_api.models.sentdata import SentData
+
+ try:
+ SentData.objects.mark_as_withdrawn(rec)
+ except SentData.DoesNotExist:
+ # Brak wiersza SentData (rekord nigdy realnie nie wysłany,
+ # mimo pbn_uid) — nie jest błędem wycofania.
+ pass
+
+ self.wysylke_zakonczono = timezone.now()
+ self.zakonczono_pomyslnie = True
+ self.dopisz_komunikat(
+ f"Wycofano oświadczenia dyscyplin z PBN (UID={pbn_uid})."
+ )
+ self.save()
+ return SendStatus.FINISHED_OKAY
+ ```
+- [ ] **Implementacja — rozgałęzienie w `send_to_pbn`.** Na początku `send_to_pbn`, PO `self.refresh_from_db()` i PO wczesnym zwrocie `if self.wysylke_zakonczono is not None: return SendStatus.FINISHED_OKAY`, ale PRZED `check_if_record_still_exists`/inkrementacją prób, dodaj:
+ ```python
+ if not self.check_if_record_still_exists():
+ return self.error(
+ "Rekord został usunięty nim wysyłka była możliwa.",
+ rodzaj=RodzajBledu.TECHNICZNY,
+ )
+
+ self.wysylke_podjeto = timezone.now()
+ if self.retry_after_user_authorised:
+ self.retry_after_user_authorised = None
+ self.ilosc_prob += 1
+ self.save()
+
+ if self.operacja == self.Operacja.WYCOFANIE:
+ return self.withdraw_from_pbn()
+ ```
+ (Przenieś istniejący blok `check_if_record_still_exists` + `wysylke_podjeto`/`ilosc_prob` tak, by gałąź WYCOFANIE następowała PO inkrementacji prób — wycofanie ma korzystać z tego samego licznika `ilosc_prob` i tego samego guardu "rekord zniknął". Reszta `send_to_pbn` — ścieżka WYSYLKA — bez zmian.)
+- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_wola_delete_all_statements -x` → PASS.
+- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_export_queue): withdraw_from_pbn + gałąź WYCOFANIE w send_to_pbn"`
+
+---
+
+### Task 05.4 — WYSYLKA dalej działa (regresja rozgałęzienia)
+
+Upewnij się, że dodanie gałęzi WYCOFANIE nie zmieniło ścieżki WYSYLKA: wpis z domyślną operacją nadal woła `sprobuj_wyslac_do_pbn_celery`, NIE `delete_all_publication_statements`.
+
+**Files:**
+- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+
+- [ ] **Failing test — WYSYLKA nie woła delete_all_statements.** Dopisz:
+ ```python
+ @pytest.mark.django_db
+ def test_wysylka_nie_wola_delete_all_statements(wydawnictwo_ciagle, admin_user):
+ wpis = baker.make(
+ PBN_Export_Queue,
+ rekord_do_wysylki=wydawnictwo_ciagle,
+ zamowil=admin_user,
+ operacja=PBN_Export_Queue.Operacja.WYSYLKA,
+ wysylke_zakonczono=None,
+ )
+
+ sent_data = MagicMock()
+ with patch.object(admin_user, "get_pbn_user"), patch(
+ "pbn_export_queue.models.PBN_Export_Queue._pozyskaj_klienta_pbn"
+ ) as mock_klient, patch(
+ "bpp.admin.helpers.pbn_api.cli.sprobuj_wyslac_do_pbn_celery",
+ return_value=(sent_data, ["ok"]),
+ ) as mock_send:
+ result = wpis.send_to_pbn()
+
+ assert result == SendStatus.FINISHED_OKAY
+ mock_send.assert_called_once()
+ mock_klient.assert_not_called()
+ ```
+- [ ] **Komenda:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wysylka_nie_wola_delete_all_statements -x`. Jeśli przechodzi od razu — to dowód regresyjny, że ścieżka WYSYLKA jest nietknięta; zostaw test jako guard (nie wymaga zmian implementacji). Jeśli FAIL — popraw rozgałęzienie w 05.3, by WYSYLKA nie wpadała w gałąź wycofania.
+- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+- [ ] **Commit:** `git add -A && git commit -m "test(pbn_export_queue): guard regresyjny — WYSYLKA nie woła delete_all_statements"`
+
+---
+
+### Task 05.5 — Funkcje zakolejkowujące + gate na `pbn_uid`
+
+`zakolejkuj_wycofanie(rekord, user=None)` i `zakolejkuj_wysylke(rekord, user=None)` — publiczne, wołane potem z fazy 06 (receivery sygnałów). Tworzą wpis przez manager z odpowiednią `operacja` i delegują do `task_sprobuj_wyslac_do_pbn.delay(pk)`. Gate: wycofanie tylko gdy `rekord.pbn_uid_id` ustawione (brak PBN UID → no-op, brak wpisu). Idempotencja: jeśli rekord już w kolejce (`AlreadyEnqueuedError`) → no-op.
+
+**Files:**
+- `src/pbn_export_queue/models.py` (manager `PBN_Export_QueueManager`: metody `zakolejkuj_wycofanie`, `zakolejkuj_wysylke`; rozszerz `sprobuj_utowrzyc_wpis` o argument `operacja`)
+- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+
+- [ ] **Failing test — gate + utworzenie wpisu WYCOFANIE.** Dopisz:
+ ```python
+ @pytest.mark.django_db
+ def test_zakolejkuj_wycofanie_gate_brak_pbn_uid(wydawnictwo_ciagle, admin_user):
+ assert wydawnictwo_ciagle.pbn_uid_id is None
+ with patch(
+ "pbn_export_queue.models.task_sprobuj_wyslac_do_pbn"
+ ) as mock_task:
+ wpis = PBN_Export_Queue.objects.zakolejkuj_wycofanie(
+ wydawnictwo_ciagle, user=admin_user
+ )
+ assert wpis is None
+ assert PBN_Export_Queue.objects.filter_rekord_do_wysylki(
+ wydawnictwo_ciagle
+ ).count() == 0
+ mock_task.delay.assert_not_called()
+
+
+ @pytest.mark.django_db
+ def test_zakolejkuj_wycofanie_tworzy_wpis(wydawnictwo_ciagle, admin_user):
+ from pbn_api.models import Publication
+
+ pub = baker.make(Publication)
+ wydawnictwo_ciagle.pbn_uid = pub
+ wydawnictwo_ciagle.save()
+
+ with patch(
+ "pbn_export_queue.models.task_sprobuj_wyslac_do_pbn"
+ ) as mock_task:
+ wpis = PBN_Export_Queue.objects.zakolejkuj_wycofanie(
+ wydawnictwo_ciagle, user=admin_user
+ )
+
+ assert wpis is not None
+ assert wpis.operacja == PBN_Export_Queue.Operacja.WYCOFANIE
+ mock_task.delay.assert_called_once_with(wpis.pk)
+
+
+ @pytest.mark.django_db
+ def test_zakolejkuj_wysylke_tworzy_wpis(wydawnictwo_ciagle, admin_user):
+ with patch(
+ "pbn_export_queue.models.task_sprobuj_wyslac_do_pbn"
+ ) as mock_task:
+ wpis = PBN_Export_Queue.objects.zakolejkuj_wysylke(
+ wydawnictwo_ciagle, user=admin_user
+ )
+ assert wpis is not None
+ assert wpis.operacja == PBN_Export_Queue.Operacja.WYSYLKA
+ mock_task.delay.assert_called_once_with(wpis.pk)
+
+
+ @pytest.mark.django_db
+ def test_zakolejkuj_idempotentne(wydawnictwo_ciagle, admin_user):
+ from pbn_api.models import Publication
+
+ wydawnictwo_ciagle.pbn_uid = baker.make(Publication)
+ wydawnictwo_ciagle.save()
+ with patch("pbn_export_queue.models.task_sprobuj_wyslac_do_pbn"):
+ first = PBN_Export_Queue.objects.zakolejkuj_wycofanie(
+ wydawnictwo_ciagle, user=admin_user
+ )
+ second = PBN_Export_Queue.objects.zakolejkuj_wycofanie(
+ wydawnictwo_ciagle, user=admin_user
+ )
+ assert first is not None
+ assert second is None
+ assert PBN_Export_Queue.objects.filter_rekord_do_wysylki(
+ wydawnictwo_ciagle
+ ).count() == 1
+ ```
+- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -k zakolejkuj -x` → `AttributeError` (`zakolejkuj_wycofanie`).
+- [ ] **Implementacja — import tasku w models.py.** Na górze `withdraw_from_pbn`/managerze potrzebny `task_sprobuj_wyslac_do_pbn`. Żeby testy mogły patchować `pbn_export_queue.models.task_sprobuj_wyslac_do_pbn`, zaimportuj go **lokalnie w funkcji** i przypisz do nazwy modułowej — najprościej: w metodach managera użyj importu modułu i odwołania, które patch przechwyci. Wzorzec (unikamy cyklicznego importu na top-level — `tasks.py` importuje `models`):
+ ```python
+ def _delay_task(self, pk):
+ from pbn_export_queue import tasks
+
+ tasks.task_sprobuj_wyslac_do_pbn.delay(pk)
+ ```
+ ORAZ w testach patchuj `pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn` zamiast `pbn_export_queue.models...` — **popraw ścieżki patcha w testach 05.5 na `pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn`** (zaktualizuj literały w testach przy pierwszym uruchomieniu, gdy zobaczysz gdzie realnie żyje symbol). Cel: brak cyklicznego importu top-level.
+- [ ] **Implementacja — rozszerz `sprobuj_utowrzyc_wpis` o `operacja`.** W managerze:
+ ```python
+ def sprobuj_utowrzyc_wpis(self, user, rekord, operacja=None):
+ if self.filter_rekord_do_wysylki(rekord).exists():
+ raise AlreadyEnqueuedError(
+ "ten rekord jest już w kolejce do wysyłki"
+ )
+ kwargs = {"rekord_do_wysylki": rekord, "zamowil": user}
+ if operacja is not None:
+ kwargs["operacja"] = operacja
+ return self.create(**kwargs)
+ ```
+ (Zachowuje dotychczasową sygnaturę dla istniejących wywołań — `operacja` opcjonalna, default modelu `WYSYLKA`.)
+- [ ] **Implementacja — `zakolejkuj_wycofanie` / `zakolejkuj_wysylke`.** W managerze:
+ ```python
+ def zakolejkuj_wysylke(self, rekord, user=None):
+ """Tworzy wpis WYSYLKA i uruchamia wysyłkę w tle.
+
+ Wołane m.in. przy restore publikacji (faza 06). Idempotentne —
+ gdy rekord już w kolejce, zwraca None.
+ """
+ from pbn_api.exceptions import AlreadyEnqueuedError
+
+ try:
+ wpis = self.sprobuj_utowrzyc_wpis(
+ user, rekord, operacja=self.model.Operacja.WYSYLKA
+ )
+ except AlreadyEnqueuedError:
+ return None
+ self._delay_task(wpis.pk)
+ return wpis
+
+ def zakolejkuj_wycofanie(self, rekord, user=None):
+ """Tworzy wpis WYCOFANIE i uruchamia wycofanie w tle.
+
+ Gate: tylko gdy rekord ma PBN UID (inaczej nic nie poszło do
+ PBN — no-op, zwraca None). Idempotentne — gdy rekord już
+ w kolejce, zwraca None.
+ """
+ from pbn_api.exceptions import AlreadyEnqueuedError
+
+ if not getattr(rekord, "pbn_uid_id", None):
+ return None
+ try:
+ wpis = self.sprobuj_utowrzyc_wpis(
+ user, rekord, operacja=self.model.Operacja.WYCOFANIE
+ )
+ except AlreadyEnqueuedError:
+ return None
+ self._delay_task(wpis.pk)
+ return wpis
+ ```
+ (`user=None` dozwolone — operacje systemowe/celery; pole `zamowil` jest `on_delete=CASCADE` i NOT NULL, więc gdy `user is None` ścieżka wymaga konta technicznego. **Zweryfikuj w fazie 06**, czy receiver zawsze poda usera; tu zostawiamy sygnaturę `user=None` zgodną z kontraktem PINNED, a faktyczne wymaganie NOT NULL na `zamowil` rozwiązuje faza 06/07 przekazując konto. Jeśli test z `user=None` jest potrzebny — dodać dopiero gdy faza 06 ustali konto techniczne.)
+- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -k zakolejkuj -x` → PASS.
+- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_export_queue): zakolejkuj_wycofanie/zakolejkuj_wysylke + gate pbn_uid"`
+
+---
+
+### Task 05.6 — Idempotencja/retry wycofania (ResourceLocked + CannotDelete)
+
+**Files:**
+- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+
+- [ ] **Failing test — `CannotDeleteStatementsException` traktowany jak sukces.** Dopisz:
+ ```python
+ @pytest.mark.django_db
+ def test_wycofanie_brak_oswiadczen_to_sukces(wydawnictwo_ciagle, admin_user):
+ from pbn_api.exceptions import CannotDeleteStatementsException
+ from pbn_api.models import Publication
+ from pbn_api.models.sentdata import SentData
+
+ wydawnictwo_ciagle.pbn_uid = baker.make(Publication)
+ wydawnictwo_ciagle.save()
+ SentData.objects.create(
+ object=wydawnictwo_ciagle,
+ data_sent={},
+ submitted_successfully=True,
+ uploaded_okay=True,
+ )
+ wpis = baker.make(
+ PBN_Export_Queue,
+ rekord_do_wysylki=wydawnictwo_ciagle,
+ zamowil=admin_user,
+ operacja=PBN_Export_Queue.Operacja.WYCOFANIE,
+ wysylke_zakonczono=None,
+ )
+
+ mock_client = MagicMock()
+ mock_client.delete_all_publication_statements.side_effect = (
+ CannotDeleteStatementsException("brak oświadczeń")
+ )
+ with patch.object(
+ PBN_Export_Queue, "_pozyskaj_klienta_pbn", return_value=mock_client
+ ):
+ result = wpis.send_to_pbn()
+
+ assert result == SendStatus.FINISHED_OKAY
+ wpis.refresh_from_db()
+ assert wpis.zakonczono_pomyslnie is True
+ assert SentData.objects.get_for_rec(
+ wydawnictwo_ciagle
+ ).withdrawn_at is not None
+ ```
+- [ ] **Komenda:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_brak_oswiadczen_to_sukces -x` → powinien przejść (logika z 05.3 traktuje `CannotDeleteStatementsException` jako break/sukces). Jeśli FAIL — dopracuj `withdraw_from_pbn`.
+
+- [ ] **Failing test — `ResourceLockedException` → RETRY_LATER, bez oznaczenia SentData.** Dopisz:
+ ```python
+ @pytest.mark.django_db
+ def test_wycofanie_locked_retry_later(wydawnictwo_ciagle, admin_user):
+ from pbn_api.exceptions import ResourceLockedException
+ from pbn_api.models import Publication
+ from pbn_api.models.sentdata import SentData
+
+ wydawnictwo_ciagle.pbn_uid = baker.make(Publication)
+ wydawnictwo_ciagle.save()
+ SentData.objects.create(
+ object=wydawnictwo_ciagle,
+ data_sent={},
+ submitted_successfully=True,
+ uploaded_okay=True,
+ )
+ wpis = baker.make(
+ PBN_Export_Queue,
+ rekord_do_wysylki=wydawnictwo_ciagle,
+ zamowil=admin_user,
+ operacja=PBN_Export_Queue.Operacja.WYCOFANIE,
+ wysylke_zakonczono=None,
+ )
+
+ mock_client = MagicMock()
+ mock_client.delete_all_publication_statements.side_effect = (
+ ResourceLockedException("zablokowane")
+ )
+ with patch.object(
+ PBN_Export_Queue, "_pozyskaj_klienta_pbn", return_value=mock_client
+ ):
+ result = wpis.send_to_pbn()
+
+ assert result == SendStatus.RETRY_LATER
+ wpis.refresh_from_db()
+ # wycofanie nie zakończone — zostanie ponowione
+ assert wpis.wysylke_zakonczono is None
+ assert SentData.objects.get_for_rec(
+ wydawnictwo_ciagle
+ ).withdrawn_at is None
+ ```
+- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_locked_retry_later -x` → PASS (logika z 05.3 zwraca `RETRY_LATER` na `ResourceLockedException` bez ustawiania `wysylke_zakonczono`). Jeśli FAIL — popraw kolejność `except` w `withdraw_from_pbn`.
+- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/tests/test_operacja_wycofanie.py`
+- [ ] **Commit:** `git add -A && git commit -m "test(pbn_export_queue): wycofanie — idempotencja CannotDelete + retry ResourceLocked"`
+
+---
+
+### Task 05.7 — Admin: kolumna `operacja` widoczna w kolejce
+
+Drobne wsparcie operacyjne: pokaż operację na liście kolejki, by superuser odróżnił wpisy wycofania od wysyłki.
+
+**Files:**
+- `src/pbn_export_queue/admin.py` (`list_display`, `list_filter`, `readonly_fields`)
+- Test path: `src/pbn_export_queue/tests/test_admin.py` (dopisać 1 asercję) lub `test_operacja_wycofanie.py`
+
+- [ ] **Failing test — `operacja` w `list_display`.** Dopisz do `test_operacja_wycofanie.py`:
+ ```python
+ def test_admin_pokazuje_operacje():
+ from pbn_export_queue.admin import PBN_Export_QueueAdmin
+
+ assert "operacja" in PBN_Export_QueueAdmin.list_display
+ assert "operacja" in PBN_Export_QueueAdmin.list_filter
+ ```
+- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_admin_pokazuje_operacje -x` → AssertionError.
+- [ ] **Implementacja — admin.** W `src/pbn_export_queue/admin.py`: dodaj `"operacja"` do `list_display` (np. zaraz po `"rekord_do_wysylki"`), do `list_filter` i do `readonly_fields`.
+- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_admin_pokazuje_operacje -x` → PASS.
+- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/admin.py && uv run ruff format src/pbn_export_queue/admin.py`
+- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_export_queue): admin pokazuje kolumnę/filtr operacja"`
+
+---
+
+### Task 05.8 — Pełna weryfikacja fazy + brak driftu migracji
+
+**Files:** —
+
+- [ ] **Cały plik testowy fazy:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -x` → wszystkie PASS.
+- [ ] **Regresja całego app kolejki + sentdata:** `uv run pytest src/pbn_export_queue/ src/pbn_api/tests/ -q` → zielono (gałąź WYCOFANIE nie zepsuła istniejących testów wysyłki/managerów/admina).
+- [ ] **Brak driftu migracji:** `uv run python src/manage.py makemigrations --check --dry-run` → "No changes detected".
+- [ ] **Lint całości fazy:** `uv run ruff check src/pbn_export_queue/ src/pbn_api/models/sentdata.py` → czysto.
+- [ ] **Commit (jeśli cokolwiek dopięte):** `git add -A && git commit -m "chore(soft-delete): faza 05 PBN wycofanie — weryfikacja końcowa"`
+
+---
+
+## Podsumowanie zakresu (co ta faza dostarcza fazie 06)
+
+- `PBN_Export_Queue.Operacja` (`WYSYLKA`/`WYCOFANIE`) + pole `operacja` (default `WYSYLKA`).
+- `PBN_Export_Queue.objects.zakolejkuj_wycofanie(rekord, user=None)` i `zakolejkuj_wysylke(rekord, user=None)` — publiczny kontrakt dla receiverów fazy 06 (`post_soft_delete`→wycofanie, `post_restore`→wysyłka). Gate wycofania na `pbn_uid_id`, idempotentne.
+- Po udanym wycofaniu: `SentData.withdrawn_at` ustawione, `submitted_successfully=False`, wiersz NIE skasowany. Restore→WYSYLKA→`mark_as_successful` zeruje `withdrawn_at`.
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md
new file mode 100644
index 000000000..3ce7c9ebf
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md
@@ -0,0 +1,838 @@
+# Soft-delete — Faza 06: SoftDeleteLog + receivery sygnałów + atrybucja usera
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Zbudować dedykowany audyt soft-delete: model `SoftDeleteLog` (kto / kiedy / dlaczego / status PBN) zasilany przez receivery sygnałów pakietu `django-soft-delete` (`post_soft_delete`, `post_restore`, `post_hard_delete`). Receiver `DELETE` dla publikacji z `pbn_uid` kolejkuje wycofanie oświadczeń PBN (`zakolejkuj_wycofanie` z fazy 05) i podpina wpis kolejki do logu; `RESTORE` kolejkuje ponowną wysyłkę (`zakolejkuj_wysylke`). Atrybucja „kto" rozwiązana przez thread-local context manager ustawiany w override `delete(user=, reason=)`/`restore(user=)` (fazy 02/04) — sygnał pakietu sam nie niesie usera.
+
+**Architecture:** Pakiet `django-soft-delete` wysyła `post_soft_delete`/`post_hard_delete`/`post_restore` z `sender`+`instance` (zweryfikowane: `models.py:174,85,234`), ale BEZ usera/powodu. Override `delete()`/`restore()` (fazy 02/04) ustawia thread-local przez context manager `soft_delete_context(user=, reason=)` tuż przed wywołaniem pakietowego `super().delete()`. Receivery (jeden punkt podpięcia dla WSZYSTKICH soft-deletowalnych modeli) czytają thread-local i tworzą `SoftDeleteLog`. Receiver `DELETE`/`RESTORE` woła funkcje kolejkujące PBN z fazy 05 i zapisuje `pbn_queue_entry` + `pbn_status` na logu. Rejestracja w `BppConfig.ready()` (`src/bpp/apps.py:8`).
+
+**Tech Stack:** Django 4.2, `django-soft-delete>=1.0.23`, `pbn_export_queue` (faza 05), pytest + model_bakery. Python przez `uv run`. Max 88 znaków (ruff). Polski w komunikatach/verbose_name.
+
+**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§5)
+**Plan-indeks (kontrakty PINNED):** [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md)
+
+**Zależności:** Faza 02 (modele publikacji emitują sygnały + override `delete(user=, reason=)`/`restore(user=)`), Faza 05 (funkcje kolejkujące PBN). Patrz „Kontrakty z fazami 02 i 05" niżej.
+
+---
+
+## Kontrakty z fazami 02 i 05 (czytaj PRZED implementacją)
+
+### Co ta faza DOSTARCZA fazom 02/04 (one tego importują)
+- `src/bpp/models/soft_delete_context.py` — context manager + akcesory:
+ ```python
+ from contextlib import contextmanager
+ import threading
+
+ _ctx = threading.local()
+
+ @contextmanager
+ def soft_delete_context(user=None, reason=""):
+ """Ustawia thread-local user/reason na czas pakietowego
+ delete()/restore(). Receiver post_* czyta to przez
+ current_soft_delete_user()/current_soft_delete_reason()."""
+ prev_user = getattr(_ctx, "user", None)
+ prev_reason = getattr(_ctx, "reason", "")
+ _ctx.user = user
+ _ctx.reason = reason
+ try:
+ yield
+ finally:
+ _ctx.user = prev_user
+ _ctx.reason = prev_reason
+
+ def current_soft_delete_user():
+ return getattr(_ctx, "user", None)
+
+ def current_soft_delete_reason():
+ return getattr(_ctx, "reason", "")
+ ```
+- **Faza 02/04 owija** wnętrze swojego override `delete()`/`restore()`:
+ ```python
+ def delete(self, *args, user=None, reason="", **kwargs):
+ with soft_delete_context(user=user, reason=reason):
+ return super().delete(*args, **kwargs) # tu leci post_soft_delete
+ ```
+ Dzięki temu, gdy pakiet wyśle `post_soft_delete` (wewnątrz `super().delete()`),
+ thread-local wciąż jest ustawiony i receiver odczyta usera. Reentrancja jest
+ bezpieczna (zapamiętanie prev_*), więc wąska kaskada na `*_Autor`
+ (faza 02) dziedziczy ten sam kontekst.
+- Operacje BEZ usera (merge autorów, celery, `.delete()` w kodzie bez kontekstu)
+ → thread-local nieustawiony → `current_soft_delete_user()` zwraca `None`.
+
+> **Realny stan na dziś:** `src/bpp/models/soft_delete_context.py` NIE istnieje
+> (zweryfikowano — w `src/bpp/models/` jest tylko `soft_delete.py` z fazy 01,
+> managery). Tę fazę tworzy plik od zera; fazy 02/04 już go importują (są
+> przed 06 w kolejce, ale plik dostarcza 06 — jeśli fazy 02/04 były robione
+> wcześniej i dodały tymczasowy stub, scal go z tym kontraktem VERBATIM).
+
+### Czego ta faza WYMAGA od fazy 05 (PINNED — VERBATIM nazwy)
+Faza 05 dostarcza w `src/pbn_export_queue/operacje.py` (lub `tasks.py`):
+```python
+def zakolejkuj_wycofanie(instance, user=None):
+ """Tworzy PBN_Export_Queue(operacja=WYCOFANIE) dla publikacji z pbn_uid.
+ Zwraca utworzony PBN_Export_Queue albo None (gdy brak pbn_uid)."""
+
+def zakolejkuj_wysylke(instance, user=None):
+ """Tworzy PBN_Export_Queue(operacja=WYSYLKA) dla publikacji z pbn_uid.
+ Zwraca utworzony PBN_Export_Queue albo None (gdy brak pbn_uid)."""
+```
+- Obie przyjmują `instance` (rekord publikacji) + `user` (może być `None`).
+- Obie zwracają instancję `PBN_Export_Queue` (do podpięcia w `SoftDeleteLog.
+ pbn_queue_entry`) albo `None` gdy gate `pbn_uid` nie spełniony.
+- **Jeśli faza 05 jeszcze nie istnieje w worktree** — task 5 niżej zawiera
+ cienki shim, który faza 05 zastąpi pełną implementacją. Receiver woła te
+ funkcje przez import w środku, więc shim nie blokuje testów fazy 06.
+
+### Detekcja „publikacja z `pbn_uid`" (zweryfikowane)
+Receiver NIE zna typu instancji. Gate uniwersalny:
+`getattr(instance, "pbn_uid_id", None) is not None`. Publikacje
+(`Wydawnictwo_*`) mają `pbn_uid` (FK, `pbn_uid_id`); `Autor` i `*_Autor` nie
+mają → `getattr(..., None)` zwraca `None` → PBN pomijamy automatycznie.
+(Zweryfikowano: `src/bpp/models/abstract/pbn.py:35` `hasattr(self, "pbn_uid")`.)
+
+### Sygnatury sygnałów pakietu (zweryfikowane w `django_softdelete/models.py`)
+- `post_soft_delete.send(sender=cls, instance=self, using=using)` (`:174`).
+- `post_hard_delete.send(sender=cls, instance=self)` (`:85`) — BEZ `using`.
+- `post_restore.send(sender=cls, instance=self, transaction_id=...)` (`:234`)
+ — BEZ `using`, ZA TO z `transaction_id`.
+- **Wniosek dla receiverów:** sygnatura `def receiver(sender, instance,
+ **kwargs)` — `**kwargs` połyka `using`/`transaction_id` (różnią się między
+ sygnałami). Nie polegaj na `using`/`transaction_id`.
+
+---
+
+## Wspólne kontrakty (PINNED — VERBATIM z indeksu 00)
+
+### `SoftDeleteLog` — `src/bpp/models/soft_delete_log.py`
+Pola DOKŁADNIE:
+- `content_type` — `FK(ContentType, on_delete=CASCADE)`,
+- `object_id` — `PositiveIntegerField(db_index=True)`,
+- `content_object` — `GenericForeignKey("content_type", "object_id")`,
+- `akcja` — `CharField(choices=Akcja.choices)` gdzie
+ `class Akcja(models.TextChoices): DELETE="delete"; RESTORE="restore";
+ HARD_DELETE="hard_delete"`,
+- `user` — `FK(AUTH_USER_MODEL, null=True, blank=True, on_delete=SET_NULL)`,
+- `timestamp` — `DateTimeField(auto_now_add=True, db_index=True)`,
+- `powod` — `TextField(blank=True, default="")`,
+- `pbn_queue_entry` — `FK("pbn_export_queue.PBN_Export_Queue", null=True,
+ blank=True, on_delete=SET_NULL)`,
+- `pbn_status` — `CharField(max_length=50, blank=True, default="")`.
+
+### Receivery — `src/bpp/receivers/soft_delete.py`
+- `post_soft_delete` → `SoftDeleteLog(akcja=DELETE, user, powod)`; jeśli
+ publikacja z `pbn_uid` → `zakolejkuj_wycofanie(instance, user)` i podepnij
+ `pbn_queue_entry` + ustaw `pbn_status="WYCOFANIE"`.
+- `post_restore` → `SoftDeleteLog(akcja=RESTORE)`; jeśli z `pbn_uid` →
+ `zakolejkuj_wysylke(instance, user)` + `pbn_queue_entry` + `pbn_status=
+ "WYSYLKA"`.
+- `post_hard_delete` → `SoftDeleteLog(akcja=HARD_DELETE)` (bez PBN — rekord
+ fizycznie znika).
+- Rejestracja w `src/bpp/apps.py` → `BppConfig.ready()`.
+
+### Kontrakt z reversion (NIE łamać)
+Punkt wstrzyknięcia usera (`soft_delete_context`) to JEDEN hook — ten sam
+moment, w którym przyszła integracja `reversion.set_user` doczepi usera.
+Receivery NIE robią bulk-update i nie omijają `post_save` (tylko czytają
+sygnały + tworzą wiersze logu).
+
+---
+
+## Tasks
+
+### Task 1: Context manager atrybucji usera (thread-local)
+
+**Files:**
+- Create: `src/bpp/models/soft_delete_context.py`
+- Test: `src/bpp/tests/test_soft_delete/test_soft_delete_context.py`
+
+**Step 1 — Failing test:**
+- [ ] Utwórz `src/bpp/tests/test_soft_delete/__init__.py` (pusty) jeśli katalog
+ nie istnieje.
+- [ ] Napisz `src/bpp/tests/test_soft_delete/test_soft_delete_context.py`:
+```python
+from bpp.models.soft_delete_context import (
+ current_soft_delete_reason,
+ current_soft_delete_user,
+ soft_delete_context,
+)
+
+
+def test_context_brak_usera_domyslnie():
+ assert current_soft_delete_user() is None
+ assert current_soft_delete_reason() == ""
+
+
+def test_context_ustawia_i_czysci(django_user_model, db):
+ u = django_user_model.objects.create(username="ktos")
+ with soft_delete_context(user=u, reason="literówka"):
+ assert current_soft_delete_user() == u
+ assert current_soft_delete_reason() == "literówka"
+ assert current_soft_delete_user() is None
+ assert current_soft_delete_reason() == ""
+
+
+def test_context_zagniezdzony_przywraca_zewnetrzny(django_user_model, db):
+ a = django_user_model.objects.create(username="a")
+ b = django_user_model.objects.create(username="b")
+ with soft_delete_context(user=a, reason="zewn"):
+ with soft_delete_context(user=b, reason="wewn"):
+ assert current_soft_delete_user() == b
+ assert current_soft_delete_reason() == "wewn"
+ assert current_soft_delete_user() == a
+ assert current_soft_delete_reason() == "zewn"
+ assert current_soft_delete_user() is None
+```
+**Step 2 — Run → FAIL:**
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_context.py`
+ → FAIL (ModuleNotFoundError: `bpp.models.soft_delete_context`).
+**Step 3 — Implementation:**
+- [ ] Utwórz `src/bpp/models/soft_delete_context.py` z treścią VERBATIM jak
+ w sekcji „Co ta faza DOSTARCZA fazom 02/04" wyżej (`soft_delete_context`,
+ `current_soft_delete_user`, `current_soft_delete_reason`, `threading.local`).
+**Step 4 — Run → PASS:**
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_context.py`
+ → 3 passed.
+- [ ] `ruff check src/bpp/models/soft_delete_context.py && ruff format
+ --check src/bpp/models/soft_delete_context.py`
+**Step 5 — Commit:**
+- [ ] `git add src/bpp/models/soft_delete_context.py
+ src/bpp/tests/test_soft_delete/` i commit:
+ `feat(soft-delete): context manager atrybucji usera (thread-local)`
+
+---
+
+### Task 2: Model `SoftDeleteLog` + migracja
+
+**Files:**
+- Create: `src/bpp/models/soft_delete_log.py`
+- Modify: `src/bpp/models/__init__.py` (dopisz import, wzorzec `oplaty_log`
+ na `:52`)
+- Create: `src/bpp/migrations/0421_softdeletelog.py` (NUMER: sprawdź najwyższą
+ istniejącą migrację `bpp` przez `ls src/bpp/migrations/ | grep -E '^04' |
+ sort | tail -1` i nadaj kolejny — **NIE modyfikuj istniejących migracji**)
+- Test: `src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py`
+
+**Step 1 — Failing test:**
+- [ ] Napisz `test_soft_delete_log_model.py`:
+```python
+import pytest
+from django.contrib.contenttypes.models import ContentType
+from model_bakery import baker
+
+from bpp.models.soft_delete_log import SoftDeleteLog
+
+
+@pytest.mark.django_db
+def test_softdeletelog_gfk_wskazuje_na_rekord(wydawnictwo_ciagle):
+ log = SoftDeleteLog.objects.create(
+ content_type=ContentType.objects.get_for_model(wydawnictwo_ciagle),
+ object_id=wydawnictwo_ciagle.pk,
+ akcja=SoftDeleteLog.Akcja.DELETE,
+ powod="test",
+ )
+ assert log.content_object == wydawnictwo_ciagle
+ assert log.timestamp is not None
+ assert log.user is None
+ assert log.pbn_queue_entry is None
+ assert log.pbn_status == ""
+
+
+@pytest.mark.django_db
+def test_softdeletelog_akcja_choices():
+ assert SoftDeleteLog.Akcja.DELETE == "delete"
+ assert SoftDeleteLog.Akcja.RESTORE == "restore"
+ assert SoftDeleteLog.Akcja.HARD_DELETE == "hard_delete"
+
+
+@pytest.mark.django_db
+def test_softdeletelog_user_set_null(wydawnictwo_ciagle, django_user_model):
+ u = baker.make(django_user_model)
+ log = SoftDeleteLog.objects.create(
+ content_type=ContentType.objects.get_for_model(wydawnictwo_ciagle),
+ object_id=wydawnictwo_ciagle.pk,
+ akcja=SoftDeleteLog.Akcja.DELETE,
+ user=u,
+ )
+ u.delete()
+ log.refresh_from_db()
+ assert log.user is None
+```
+**Step 2 — Run → FAIL:**
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py`
+ → FAIL (ImportError SoftDeleteLog).
+**Step 3 — Implementation:**
+- [ ] Utwórz `src/bpp/models/soft_delete_log.py` (wzorzec `oplaty_log.py`):
+```python
+"""Dedykowany audyt operacji soft-delete (kto / kiedy / dlaczego / PBN)."""
+
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+
+from django_bpp.settings.base import AUTH_USER_MODEL
+
+__all__ = ["SoftDeleteLog"]
+
+
+class SoftDeleteLog(models.Model):
+ """Wpis audytu pojedynczej operacji soft-delete / restore / hard-delete."""
+
+ class Akcja(models.TextChoices):
+ DELETE = "delete", "Usunięcie (kosz)"
+ RESTORE = "restore", "Przywrócenie"
+ HARD_DELETE = "hard_delete", "Usunięcie trwałe"
+
+ content_type = models.ForeignKey(
+ ContentType, on_delete=models.CASCADE, verbose_name="Typ rekordu"
+ )
+ object_id = models.PositiveIntegerField(db_index=True, verbose_name="ID obiektu")
+ content_object = GenericForeignKey("content_type", "object_id")
+
+ akcja = models.CharField(
+ max_length=20, choices=Akcja.choices, db_index=True, verbose_name="Akcja"
+ )
+ user = models.ForeignKey(
+ AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ verbose_name="Użytkownik",
+ )
+ timestamp = models.DateTimeField(
+ auto_now_add=True, db_index=True, verbose_name="Data operacji"
+ )
+ powod = models.TextField(blank=True, default="", verbose_name="Powód")
+
+ pbn_queue_entry = models.ForeignKey(
+ "pbn_export_queue.PBN_Export_Queue",
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ verbose_name="Wpis kolejki PBN",
+ )
+ pbn_status = models.CharField(
+ max_length=50, blank=True, default="", verbose_name="Status PBN"
+ )
+
+ class Meta:
+ verbose_name = "Log operacji soft-delete"
+ verbose_name_plural = "Logi operacji soft-delete"
+ ordering = ["-timestamp"]
+ indexes = [
+ models.Index(fields=["content_type", "object_id"]),
+ models.Index(fields=["timestamp"]),
+ ]
+
+ def __str__(self):
+ return f"{self.get_akcja_display()}: {self.content_object} ({self.timestamp})"
+```
+- [ ] Dopisz w `src/bpp/models/__init__.py` (po linii z `oplaty_log`):
+ `from .soft_delete_log import * # noqa`
+- [ ] Wygeneruj migrację: `uv run python src/manage.py makemigrations bpp`
+ (sprawdź, że dependency na `pbn_export_queue` jest w wygenerowanej migracji —
+ Django doda je automatycznie przez FK; jeśli nie, dopisz ręcznie
+ `("pbn_export_queue", "")` do `dependencies`).
+**Step 4 — Run → PASS:**
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py`
+ → 3 passed.
+- [ ] `uv run python src/manage.py makemigrations --check --dry-run bpp`
+ → „No changes detected" (migracja kompletna).
+- [ ] `ruff check src/bpp/models/soft_delete_log.py`
+**Step 5 — Commit:**
+- [ ] Commit: `feat(soft-delete): model SoftDeleteLog + migracja`
+
+---
+
+### Task 3: Receiver `post_hard_delete` → log HARD_DELETE
+
+(robiony pierwszy z receiverów — najprostszy, bez PBN, bez kolejki)
+
+**Files:**
+- Create: `src/bpp/receivers/__init__.py` (pusty)
+- Create: `src/bpp/receivers/soft_delete.py`
+- Modify: `src/bpp/apps.py` (`BppConfig.ready()` — rejestracja)
+- Test: `src/bpp/tests/test_soft_delete/test_receivers.py`
+
+**Step 1 — Failing test:**
+- [ ] Napisz `test_receivers.py` (pierwszy test):
+```python
+import pytest
+from django.contrib.contenttypes.models import ContentType
+
+from bpp.models.soft_delete_context import soft_delete_context
+from bpp.models.soft_delete_log import SoftDeleteLog
+
+
+def _logi(instance, akcja):
+ return SoftDeleteLog.objects.filter(
+ content_type=ContentType.objects.get_for_model(instance),
+ object_id=instance.pk,
+ akcja=akcja,
+ )
+
+
+@pytest.mark.django_db
+def test_hard_delete_tworzy_log(wydawnictwo_ciagle, superuser):
+ pk = wydawnictwo_ciagle.pk
+ ct = ContentType.objects.get_for_model(wydawnictwo_ciagle)
+ with soft_delete_context(user=superuser, reason="trwałe"):
+ wydawnictwo_ciagle.hard_delete()
+ log = SoftDeleteLog.objects.get(
+ content_type=ct, object_id=pk, akcja=SoftDeleteLog.Akcja.HARD_DELETE
+ )
+ assert log.user == superuser
+ assert log.powod == "trwałe"
+ assert log.pbn_queue_entry is None
+```
+**Step 2 — Run → FAIL:**
+- [ ] `uv run pytest "src/bpp/tests/test_soft_delete/test_receivers.py::test_hard_delete_tworzy_log"`
+ → FAIL (brak receiverów → log nie powstaje → `SoftDeleteLog.DoesNotExist`).
+**Step 3 — Implementation:**
+- [ ] Utwórz `src/bpp/receivers/__init__.py` (pusty).
+- [ ] Utwórz `src/bpp/receivers/soft_delete.py`:
+```python
+"""Receivery sygnałów django-soft-delete → zasilanie SoftDeleteLog + PBN."""
+
+from django.contrib.contenttypes.models import ContentType
+
+from bpp.models.soft_delete_context import (
+ current_soft_delete_reason,
+ current_soft_delete_user,
+)
+from bpp.models.soft_delete_log import SoftDeleteLog
+
+
+def _utworz_log(instance, akcja, pbn_queue_entry=None, pbn_status=""):
+ return SoftDeleteLog.objects.create(
+ content_type=ContentType.objects.get_for_model(instance),
+ object_id=instance.pk,
+ akcja=akcja,
+ user=current_soft_delete_user(),
+ powod=current_soft_delete_reason(),
+ pbn_queue_entry=pbn_queue_entry,
+ pbn_status=pbn_status,
+ )
+
+
+def on_post_hard_delete(sender, instance, **kwargs):
+ """Hard-delete: rekord fizycznie znika, więc bez operacji PBN."""
+ _utworz_log(instance, SoftDeleteLog.Akcja.HARD_DELETE)
+
+
+def register():
+ from django_softdelete.signals import (
+ post_hard_delete,
+ post_restore,
+ post_soft_delete,
+ )
+
+ post_hard_delete.connect(
+ on_post_hard_delete, dispatch_uid="bpp.soft_delete.post_hard_delete"
+ )
+```
+> **UWAGA `pk` przy hard-delete:** `post_hard_delete` jest wysyłany PO
+> `super().delete()` (pakiet, `models.py:84-85`). Django zeruje `instance.pk`
+> dopiero gdy delete idzie przez kolektor — `SoftDeleteModel.hard_delete`
+> woła `models.Model.delete()`, które ustawia `pk=None` po usunięciu. Test
+> wyżej zapisuje `pk = ...` PRZED `hard_delete()`. W receiverze
+> `instance.pk` może być `None` → **zapisz `object_id` z `instance.pk` jeśli
+> nie-None, inaczej trzeba przekazać pk inaczej.** Zweryfikuj w teście: jeśli
+> `instance.pk is None` w receiverze, zmień `on_post_hard_delete` by czytał pk
+> z `instance.pk or kwargs`. Jeśli pakiet zachowuje pk (bo `hard_delete`
+> nie czyści atrybutu instancji) — zostaw prosto. **Dostosuj implementację do
+> faktycznego zachowania potwierdzonego testem, nie zgaduj.**
+- [ ] W `src/bpp/apps.py`, w `BppConfig.ready()` (po `configure_rollbar()`,
+ linia ~34) dopisz:
+```python
+ # Receivery soft-delete → SoftDeleteLog + kolejka PBN
+ from bpp.receivers import soft_delete as soft_delete_receivers
+
+ soft_delete_receivers.register()
+```
+**Step 4 — Run → PASS:**
+- [ ] `uv run pytest "src/bpp/tests/test_soft_delete/test_receivers.py::test_hard_delete_tworzy_log"`
+ → passed. Jeśli FAIL przez `pk is None` — popraw wg uwagi wyżej, ponów do PASS.
+- [ ] `ruff check src/bpp/receivers/soft_delete.py src/bpp/apps.py`
+**Step 5 — Commit:**
+- [ ] Commit: `feat(soft-delete): receiver post_hard_delete → log HARD_DELETE`
+
+---
+
+### Task 4: Receiver `post_soft_delete` → log DELETE + atrybucja usera (bez PBN)
+
+(PBN dochodzi w Tasku 6 — tu izolujemy log + usera/powód dla DELETE)
+
+**Files:**
+- Modify: `src/bpp/receivers/soft_delete.py`
+- Test: `src/bpp/tests/test_soft_delete/test_receivers.py`
+
+**Step 1 — Failing test:**
+- [ ] Dopisz testy:
+```python
+@pytest.mark.django_db
+def test_soft_delete_tworzy_log_z_userem(wydawnictwo_ciagle, superuser):
+ with soft_delete_context(user=superuser, reason="duplikat"):
+ wydawnictwo_ciagle.delete()
+ log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get()
+ assert log.user == superuser
+ assert log.powod == "duplikat"
+
+
+@pytest.mark.django_db
+def test_soft_delete_bez_usera_loguje_none(wydawnictwo_ciagle):
+ wydawnictwo_ciagle.delete()
+ log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get()
+ assert log.user is None
+ assert log.powod == ""
+```
+> **Założenie:** `wydawnictwo_ciagle.delete()` emituje `post_soft_delete`
+> (faza 02 wpięła `SoftDeleteModel` + override owijający `soft_delete_context`).
+> Jeśli faza 02 NIE jest jeszcze w worktree, fixture `wydawnictwo_ciagle` nie
+> jest `SoftDeleteModel` → `.delete()` zrobi hard delete bez sygnału.
+> Wtedy w teście wyślij sygnał ręcznie przez
+> `post_soft_delete.send(sender=type(wc), instance=wc)` wewnątrz
+> `soft_delete_context(...)`, by testować SAM receiver w izolacji. Wybierz
+> wariant zgodny ze stanem worktree i udokumentuj w docstringu testu.
+**Step 2 — Run → FAIL:**
+- [ ] `uv run pytest "src/bpp/tests/test_soft_delete/test_receivers.py::test_soft_delete_tworzy_log_z_userem" "src/bpp/tests/test_soft_delete/test_receivers.py::test_soft_delete_bez_usera_loguje_none"`
+ → FAIL (brak receivera DELETE → brak logu).
+**Step 3 — Implementation:**
+- [ ] Dodaj do `src/bpp/receivers/soft_delete.py`:
+```python
+def on_post_soft_delete(sender, instance, **kwargs):
+ _utworz_log(instance, SoftDeleteLog.Akcja.DELETE)
+```
+- [ ] W `register()` dopisz:
+```python
+ post_soft_delete.connect(
+ on_post_soft_delete, dispatch_uid="bpp.soft_delete.post_soft_delete"
+ )
+```
+**Step 4 — Run → PASS:**
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_receivers.py` → all passed.
+- [ ] `ruff check src/bpp/receivers/soft_delete.py`
+**Step 5 — Commit:**
+- [ ] Commit: `feat(soft-delete): receiver post_soft_delete → log DELETE + user`
+
+---
+
+### Task 5: Shim funkcji kolejkujących PBN (kontrakt z fazą 05)
+
+> Cienka warstwa, by faza 06 była testowalna niezależnie od kolejności
+> wykonania faz. **Jeśli faza 05 już istnieje** (`zakolejkuj_wycofanie`/
+> `zakolejkuj_wysylke` w `src/pbn_export_queue/`) — POMIŃ ten task, użyj
+> realnych funkcji. Sprawdź: `uv run python -c "from pbn_export_queue.operacje
+> import zakolejkuj_wycofanie, zakolejkuj_wysylke"`.
+
+**Files:**
+- Create (tylko jeśli brak fazy 05): `src/pbn_export_queue/operacje.py`
+- Test: `src/bpp/tests/test_soft_delete/test_pbn_queue_shim.py`
+
+**Step 1 — Failing test:**
+- [ ] Napisz test (gate `pbn_uid`):
+```python
+import pytest
+from model_bakery import baker
+
+from pbn_export_queue.operacje import zakolejkuj_wycofanie, zakolejkuj_wysylke
+from pbn_export_queue.models import PBN_Export_Queue
+
+
+@pytest.mark.django_db
+def test_zakolejkuj_wycofanie_bez_pbn_uid_zwraca_none(autor):
+ # autor nie ma pbn_uid_id → None
+ assert zakolejkuj_wycofanie(autor) is None
+
+
+@pytest.mark.django_db
+def test_zakolejkuj_wycofanie_z_pbn_uid_tworzy_wpis(
+ wydawnictwo_ciagle, superuser
+):
+ wydawnictwo_ciagle.pbn_uid_id = "00000000-0000-0000-0000-000000000001"
+ wydawnictwo_ciagle.save()
+ wpis = zakolejkuj_wycofanie(wydawnictwo_ciagle, user=superuser)
+ assert isinstance(wpis, PBN_Export_Queue)
+ assert wpis.object_id == wydawnictwo_ciagle.pk
+
+
+@pytest.mark.django_db
+def test_zakolejkuj_wysylke_z_pbn_uid_tworzy_wpis(wydawnictwo_ciagle, superuser):
+ wydawnictwo_ciagle.pbn_uid_id = "00000000-0000-0000-0000-000000000002"
+ wydawnictwo_ciagle.save()
+ wpis = zakolejkuj_wysylke(wydawnictwo_ciagle, user=superuser)
+ assert isinstance(wpis, PBN_Export_Queue)
+```
+> **Uwaga do fazy 02/05:** `pbn_uid_id` musi przyjąć wartość. Jeśli FK celuje
+> w `pbn_api.Publication`, baker/`save()` z surowym UUID-em może wymagać
+> istniejącego rekordu — wtedy w teście stwórz `baker.make("pbn_api.
+> Publication")` i przypisz `wydawnictwo_ciagle.pbn_uid = pub`. Dostosuj do
+> realnego typu FK potwierdzonego w `wydawnictwo_ciagle.py`.
+**Step 2 — Run → FAIL:**
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_pbn_queue_shim.py`
+ → FAIL (ModuleNotFoundError `pbn_export_queue.operacje`).
+**Step 3 — Implementation (shim — faza 05 nadpisze pełną logiką):**
+- [ ] Utwórz `src/pbn_export_queue/operacje.py`:
+```python
+"""Operacje kolejkowania PBN dla soft-delete (WYCOFANIE / WYSYLKA).
+
+UWAGA: cienki shim z fazy 06. Faza 05 nadpisuje go pełną implementacją
+(pole `operacja`, gałąź `delete_all_publication_statements`, integracja
+SentData). Tu utrzymujemy wyłącznie kontrakt sygnatur + gate `pbn_uid`.
+"""
+
+from contextlib import suppress
+
+from pbn_export_queue.models import PBN_Export_Queue
+
+
+def _ma_pbn_uid(instance):
+ return getattr(instance, "pbn_uid_id", None) is not None
+
+
+def _utworz_wpis(instance, user):
+ return PBN_Export_Queue.objects.create(
+ rekord_do_wysylki=instance,
+ zamowil=user,
+ )
+
+
+def zakolejkuj_wycofanie(instance, user=None):
+ """Kolejkuje wycofanie oświadczeń PBN. None gdy brak pbn_uid."""
+ if not _ma_pbn_uid(instance):
+ return None
+ return _utworz_wpis(instance, user)
+
+
+def zakolejkuj_wysylke(instance, user=None):
+ """Kolejkuje ponowną wysyłkę do PBN. None gdy brak pbn_uid."""
+ if not _ma_pbn_uid(instance):
+ return None
+ return _utworz_wpis(instance, user)
+```
+> **Niuans `zamowil`:** `PBN_Export_Queue.zamowil` to `FK(AUTH_USER_MODEL,
+> on_delete=CASCADE)` BEZ `null=True` (zweryfikowano `models.py:79`). Dla
+> operacji systemowych `user=None` ten FK się wywali. Faza 05 to rozwiąże
+> (np. konto techniczne lub `null=True`). W teście Tasku 5 przekazuj
+> `user=superuser`. W receiverze (Task 6) gate `pbn_uid` i tak zwykle idzie
+> z akcji admina (user jest). Jeśli `user is None` w receiverze przy
+> publikacji z `pbn_uid` — owiń wywołanie w `suppress(...)`/log, NIE wywal
+> całej operacji delete. **To dług fazy 05; udokumentuj `# TODO(faza 05)`.**
+> Usuń niewykorzystany import `suppress`, jeśli go nie użyjesz.
+**Step 4 — Run → PASS:**
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_pbn_queue_shim.py`
+ → passed.
+- [ ] `ruff check src/pbn_export_queue/operacje.py`
+**Step 5 — Commit:**
+- [ ] Commit: `feat(soft-delete): shim zakolejkuj_wycofanie/wysylke (kontrakt 05)`
+
+---
+
+### Task 6: PBN w receiverach — DELETE→WYCOFANIE, RESTORE→WYSYLKA, podpięcie kolejki
+
+**Files:**
+- Modify: `src/bpp/receivers/soft_delete.py`
+- Test: `src/bpp/tests/test_soft_delete/test_receivers.py`
+
+**Step 1 — Failing test:**
+- [ ] Dopisz testy (publikacja z `pbn_uid` + restore):
+```python
+@pytest.mark.django_db
+def test_soft_delete_z_pbn_uid_kolejkuje_wycofanie(
+ wydawnictwo_ciagle, superuser
+):
+ wydawnictwo_ciagle.pbn_uid_id = "00000000-0000-0000-0000-000000000010"
+ wydawnictwo_ciagle.save()
+ with soft_delete_context(user=superuser, reason="x"):
+ wydawnictwo_ciagle.delete()
+ log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get()
+ assert log.pbn_queue_entry is not None
+ assert log.pbn_status == "WYCOFANIE"
+
+
+@pytest.mark.django_db
+def test_soft_delete_bez_pbn_uid_nie_kolejkuje(wydawnictwo_ciagle, superuser):
+ # brak pbn_uid → log bez wpisu kolejki
+ with soft_delete_context(user=superuser):
+ wydawnictwo_ciagle.delete()
+ log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get()
+ assert log.pbn_queue_entry is None
+ assert log.pbn_status == ""
+
+
+@pytest.mark.django_db
+def test_restore_z_pbn_uid_kolejkuje_wysylke(wydawnictwo_ciagle, superuser):
+ wydawnictwo_ciagle.pbn_uid_id = "00000000-0000-0000-0000-000000000011"
+ wydawnictwo_ciagle.save()
+ with soft_delete_context(user=superuser):
+ wydawnictwo_ciagle.delete()
+ with soft_delete_context(user=superuser):
+ wydawnictwo_ciagle.restore()
+ log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.RESTORE).get()
+ assert log.pbn_queue_entry is not None
+ assert log.pbn_status == "WYSYLKA"
+```
+> Jeśli faza 02 nie wpięła `SoftDeleteModel`/override — testuj receiver
+> przez ręczny `post_soft_delete.send(...)` / `post_restore.send(...)`
+> wewnątrz `soft_delete_context`, jak w uwadze Tasku 4.
+**Step 2 — Run → FAIL:**
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_receivers.py`
+ → FAIL (receivery nie kolejkują PBN, brak receivera RESTORE).
+**Step 3 — Implementation:**
+- [ ] Przebuduj receivery w `src/bpp/receivers/soft_delete.py`:
+```python
+def _kolejkuj_pbn(instance, operacja):
+ """Woła funkcję kolejkującą z fazy 05. Zwraca (wpis, status_str)."""
+ from pbn_export_queue.operacje import (
+ zakolejkuj_wycofanie,
+ zakolejkuj_wysylke,
+ )
+
+ user = current_soft_delete_user()
+ if operacja == "WYCOFANIE":
+ wpis = zakolejkuj_wycofanie(instance, user=user)
+ return wpis, ("WYCOFANIE" if wpis is not None else "")
+ wpis = zakolejkuj_wysylke(instance, user=user)
+ return wpis, ("WYSYLKA" if wpis is not None else "")
+
+
+def on_post_soft_delete(sender, instance, **kwargs):
+ wpis, status = _kolejkuj_pbn(instance, "WYCOFANIE")
+ _utworz_log(
+ instance,
+ SoftDeleteLog.Akcja.DELETE,
+ pbn_queue_entry=wpis,
+ pbn_status=status,
+ )
+
+
+def on_post_restore(sender, instance, **kwargs):
+ wpis, status = _kolejkuj_pbn(instance, "WYSYLKA")
+ _utworz_log(
+ instance,
+ SoftDeleteLog.Akcja.RESTORE,
+ pbn_queue_entry=wpis,
+ pbn_status=status,
+ )
+```
+- [ ] W `register()` dopisz `post_restore.connect(on_post_restore,
+ dispatch_uid="bpp.soft_delete.post_restore")`.
+- [ ] Import `post_restore` jest już w lokalnym imporcie `register()`.
+**Step 4 — Run → PASS:**
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_receivers.py` → all passed.
+- [ ] `ruff check src/bpp/receivers/soft_delete.py`
+**Step 5 — Commit:**
+- [ ] Commit: `feat(soft-delete): receivery kolejkują PBN (WYCOFANIE/WYSYLKA)`
+
+---
+
+### Task 7: Test integracyjny end-to-end + weryfikacja rejestracji w apps.ready
+
+**Files:**
+- Test: `src/bpp/tests/test_soft_delete/test_receivers.py` (dopisz)
+
+**Step 1 — Failing test (lub regresyjny — guard rejestracji):**
+- [ ] Dopisz test sprawdzający, że receivery są PODŁĄCZONE przez
+ `apps.ready()` (nie tylko gdy test wywoła `register()` ręcznie):
+```python
+@pytest.mark.django_db
+def test_receivery_zarejestrowane_przez_apps_ready():
+ from django_softdelete.signals import (
+ post_hard_delete,
+ post_restore,
+ post_soft_delete,
+ )
+
+ def _uids(sig):
+ return {
+ r[0][0]
+ for r in sig.receivers
+ if isinstance(r[0], tuple)
+ }
+
+ assert "bpp.soft_delete.post_soft_delete" in _uids(post_soft_delete)
+ assert "bpp.soft_delete.post_restore" in _uids(post_restore)
+ assert "bpp.soft_delete.post_hard_delete" in _uids(post_hard_delete)
+```
+> `Signal.receivers` to lista `((dispatch_uid, sender_id), ref)`. Sprawdź
+> realny kształt przez `uv run python -c "..."` jeśli asercja nie trafia —
+> dostosuj ekstrakcję uid. Cel: udowodnić, że `BppConfig.ready()` faktycznie
+> woła `register()`.
+**Step 2 — Run:**
+- [ ] `uv run pytest "src/bpp/tests/test_soft_delete/test_receivers.py::test_receivery_zarejestrowane_przez_apps_ready"`
+ — jeśli FAIL, popraw `register()`/`apps.py` lub ekstrakcję uid; jeśli PASS
+ od razu (bo Task 3/4/6 już wpięły rejestrację) — to regresyjny strażnik.
+**Step 3 — Implementation:**
+- [ ] Jeśli test FAIL z powodu kształtu `receivers` — popraw asercję
+ (NIE produkcję, chyba że rejestracja faktycznie brakuje).
+**Step 4 — Run → PASS + cała faza:**
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/` → wszystkie zielone.
+- [ ] `ruff check src/bpp/ src/pbn_export_queue/operacje.py`
+- [ ] `ruff format --check src/bpp/models/soft_delete_log.py
+ src/bpp/models/soft_delete_context.py src/bpp/receivers/soft_delete.py`
+- [ ] `uv run python src/manage.py makemigrations --check --dry-run`
+ → „No changes detected".
+**Step 5 — Commit:**
+- [ ] Commit: `test(soft-delete): e2e + guard rejestracji receiverów (apps.ready)`
+
+---
+
+## Definition of Done (faza 06)
+
+- [ ] `SoftDeleteLog` (pola PINNED VERBATIM) + migracja; `makemigrations
+ --check` czyste.
+- [ ] `soft_delete_context` (thread-local CM) + akcesory; reentrancja OK.
+- [ ] Trzy receivery (`post_soft_delete`→DELETE, `post_restore`→RESTORE,
+ `post_hard_delete`→HARD_DELETE) zarejestrowane w `BppConfig.ready()`.
+- [ ] DELETE publikacji z `pbn_uid` → log DELETE + `zakolejkuj_wycofanie` +
+ `pbn_queue_entry` podpięty + `pbn_status="WYCOFANIE"`.
+- [ ] RESTORE → log RESTORE + `zakolejkuj_wysylke` + `pbn_status="WYSYLKA"`.
+- [ ] HARD_DELETE → log HARD_DELETE, bez PBN.
+- [ ] User poprawnie z `soft_delete_context`; brak kontekstu → `user=None`,
+ `powod=""`.
+- [ ] Publikacja bez `pbn_uid` / `Autor` → log bez wpisu kolejki.
+- [ ] `uv run pytest src/bpp/tests/test_soft_delete/` zielone; `ruff
+ check`/`format` czyste; commit per task.
+
+---
+
+## Podsumowanie (3 punkty) + założenia
+
+**1. Co robi ta faza.** Tworzy `SoftDeleteLog` (GFK + akcja + user + powód +
+podpięcie do `PBN_Export_Queue` + status PBN) i trzy receivery sygnałów
+pakietu `django-soft-delete`, zarejestrowane w jednym punkcie
+(`BppConfig.ready()`). Atrybucję „kto" rozwiązuje thread-local context
+manager `soft_delete_context(user=, reason=)` ustawiany w override
+`delete()`/`restore()` (fazy 02/04) — sygnał pakietu usera nie niesie. Receiver
+DELETE kolejkuje wycofanie z PBN, RESTORE — ponowną wysyłkę (gate `pbn_uid`).
+
+**2. Kluczowe ustalenia z weryfikacji kodu.** (a) Sygnały mają RÓŻNE kwargs:
+`post_soft_delete` niesie `using`, `post_restore` — `transaction_id`,
+`post_hard_delete` — nic poza `instance`; stąd receivery używają
+`**kwargs`. (b) `post_hard_delete` leci PO `Model.delete()` → `instance.pk`
+może być `None` (uwaga w Tasku 3 — dostosować do faktu, nie zgadywać).
+(c) `PBN_Export_Queue.zamowil` jest `NOT NULL` (`on_delete=CASCADE`) — operacje
+systemowe bez usera to dług fazy 05 (oznaczone `TODO(faza 05)`). (d) Detekcja
+publikacji = `getattr(instance, "pbn_uid_id", None)` (Autor/`*_Autor` nie mają
+→ None → PBN pomijane).
+
+**3. Kontrakt z reversion zachowany.** Jeden hook usera (`soft_delete_context`)
+to dokładnie ten sam moment, w który przyszły `reversion.set_user` się wepnie;
+receivery tylko czytają sygnały i tworzą wiersze logu — bez bulk-update, bez
+omijania `post_save`.
+
+**Założenia:** (i) **Faza 05 dostarcza** `zakolejkuj_wycofanie`/
+`zakolejkuj_wysylke` w `src/pbn_export_queue/operacje.py` (sygnatury PINNED) —
+jeśli jeszcze nie istnieją, Task 5 daje shim, który faza 05 nadpisze; jeśli
+istnieją, Task 5 pomijamy. (ii) **Faza 02 wpina** `SoftDeleteModel` + override
+owijający `soft_delete_context` na publikacjach — jeśli nie ma jeszcze tego
+w worktree, testy receiverów Tasków 4/6 idą przez ręczny
+`post_soft_delete.send(...)` w izolacji (wariant udokumentowany w docstringu
+testu). (iii) Numer migracji `0421_*` orientacyjny — wykonawca nadaje kolejny
+po sprawdzeniu `ls src/bpp/migrations/`. (iv) `soft_delete_context.py` tworzy
+ta faza; gdy fazy 02/04 dodały wcześniej stub — scalić VERBATIM z kontraktem.
+
+---
+
+**Ścieżka tego planu:**
+`/Users/mpasternak/Programowanie/bpp-soft-delete/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md`
+`file:///Volumes/mpasternak/Programowanie/bpp-soft-delete/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md`
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-07-admin.md b/docs/superpowers/plans/2026-06-04-soft-delete-07-admin.md
new file mode 100644
index 000000000..fa502d29b
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-07-admin.md
@@ -0,0 +1,1260 @@
+# Soft-delete — Faza 07: Admin superuser-only (kosz / przywróć / usuń trwale / powód)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Dać superuserowi w adminie BPP odwracalny „kosz" dla 5 typów publikacji + `Autor`: „Usuń" = soft-delete (z powodem do `SoftDeleteLog`), filtr „Pokaż skasowane", akcja „Przywróć", osobna jawna akcja „Usuń trwale" (tylko superuser) — wszystko przez JEDEN punkt wstrzyknięcia `request.user`.
+
+**Architecture:** Nowy mixin `BppSoftDeleteAdminMixin` w `src/bpp/admin/helpers/mixins.py` komponuje się PRZED istniejącymi klasami admina (`Wydawnictwo_CiagleAdmin`, `Wydawnictwo_ZwarteAdmin`, `Patent_Admin`, `Praca_DoktorskaAdmin`, `Praca_HabilitacyjnaAdmin`, `AutorAdmin`). Mixin: (a) `get_queryset()` → `global_objects` (otwieranie/przywracanie skasowanych); (b) jeden hook usera `_soft_delete_user_context(request)` ustawiający thread-local z fazy 06, używany przez `delete_model`/`delete_queryset`/akcje; (c) akcje `przywroc_zaznaczone`, `usun_trwale_zaznaczone` (superuser-only); (d) filtr `SoftDeleteFilter` (pakiet) → „Pokaż skasowane"; (e) pole „powód" przez intermediate-page (jak Django delete confirmation). NIE używamy `GlobalObjectsModelAdmin`/`SoftDeletedModelAdmin` z pakietu — wołają `obj.delete()` bez `user=` (łamią kontrakt jednego hooka).
+
+**Tech Stack:** Django admin, `django-soft-delete>=1.0.23` (`global_objects`, `deleted_objects`, `SoftDeleteFilter`, `.delete()/.restore()/.hard_delete()`), pytest + model_bakery, `django.test.Client`.
+
+**Zależy od:** faza 04 (guardy/PROTECT + `Autor` jest `SoftDeleteModel`), faza 06 (`delete(self, *args, user=None, reason="", **kwargs)` / `restore(self, *args, user=None, **kwargs)`, `SoftDeleteLog`, thread-local `set_soft_delete_user`/`get_soft_delete_user`).
+
+**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) §6. Indeks: [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md).
+
+---
+
+## Reguły BPP (obowiązują w każdym kroku)
+
+- Python wyłącznie przez `uv run` (`uv run pytest ...`). NIGDY gołe `python`/`pytest`.
+- Max długość linii **88** znaków (ruff). Po implementacji `ruff format .` + `ruff check .` (ręcznie fixować, NIE `--fix`).
+- Komentarze/komunikaty po polsku.
+- Admin templates: **emoji**, NIE Foundation-Icons. (Etykiety akcji: „🗑️ Usuń do kosza", „♻️ Przywróć", „❌ Usuń trwale".)
+- NIE modyfikować istniejących plików migracji.
+- Komentarze Django `{# #}` — każda linia własne `{# ... #}`.
+
+---
+
+## Kontrakty z fazą 06 (PINNED — używaj VERBATIM)
+
+Faza 06 dostarcza (zakładamy, że istnieją; jeśli nazwa się różni — to bug fazy 06, NIE zmieniaj go tutaj, zgłoś):
+
+```python
+# src/bpp/models/soft_delete.py (thread-local hook usera, faza 06)
+def set_soft_delete_user(user):
+ """Ustawia użytkownika dla bieżącego wątku; czytany przez receivery
+ sygnałów post_soft_delete/post_restore/post_hard_delete (faza 06)."""
+
+def get_soft_delete_user():
+ """Zwraca usera ustawionego przez set_soft_delete_user lub None."""
+
+def clear_soft_delete_user():
+ """Czyści thread-local (wołać w finally)."""
+```
+
+Sygnatury modeli (faza 06, na 5 publikacjach + `Autor`):
+```python
+def delete(self, *args, user=None, reason="", **kwargs): ...
+def restore(self, *args, user=None, **kwargs): ...
+def hard_delete(self, *args, user=None, reason="", **kwargs): ...
+```
+
+`SoftDeleteLog` (`src/bpp/models/soft_delete_log.py`) — pole `powod` (TextField), `user` (FK), zasilane przez receivery sygnałów. Admin **nie zapisuje** `SoftDeleteLog` bezpośrednio — tylko przekazuje `user`/`reason` do `model.delete(...)`, receiver robi resztę.
+
+> **Jeden hook usera (kontrakt reversion #2).** Punkt wstrzyknięcia `request.user`
+> to JEDNA metoda `BppSoftDeleteAdminMixin._soft_delete_user_context(request)`
+> (context manager owijający thread-local). `delete_model`, `delete_queryset`,
+> `przywroc_zaznaczone`, `usun_trwale_zaznaczone` — WSZYSTKIE wołają ten sam
+> punkt. Reversion (odłożone) doczepi tu w przyszłości `reversion.set_user`.
+> SZEW: w `_soft_delete_user_context` zostaw komentarz `# SZEW reversion`.
+
+> **Świadomość recover (kontrakt reversion #3).** Reversion „recover deleted"
+> (odłożone) wskrzeszałby rekord poza przepływem soft-delete (bez `WYSYLKA`,
+> bez `SoftDeleteLog`, łamiąc warunkowy unique `slug`). SZEW: w mixinie
+> `get_urls()` zostaw komentarz, że recover-URL reversion ma być tu w
+> przyszłości ukryty/przekierowany na `restore()`.
+
+---
+
+## Stan zastany (zweryfikowany w kodzie — nie zgaduj)
+
+- **Wszystkie 5 adminów publikacji dziedziczą finalnie po `admin.ModelAdmin`:**
+ - `Wydawnictwo_CiagleAdmin` (`src/bpp/admin/wydawnictwo_ciagle.py:262`) — wprost `..., RestrictDeletionWhenPBNUIDSetMixin, admin.ModelAdmin`.
+ - `Wydawnictwo_ZwarteAdmin` (`wydawnictwo_zwarte.py:438`) → `Wydawnictwo_ZwarteAdmin_Baza` (`:76` `BaseBppAdminMixin, admin.ModelAdmin`).
+ - `Patent_Admin` (`patent.py:83`) → `Wydawnictwo_ZwarteAdmin_Baza`.
+ - `Praca_DoktorskaAdmin` (`praca_doktorska.py:197`) → `Praca_Doktorska_Habilitacyjna_Admin_Base` (`:57` `AdnotacjeZDatamiMixin, BaseBppAdminMixin, admin.ModelAdmin`).
+ - `Praca_HabilitacyjnaAdmin` (`praca_habilitacyjna.py:172`) → ten sam base.
+ - `AutorAdmin` (`autor.py:192`) — wprost `..., BaseBppAdminMixin, DynamicColumnsMixin, admin.ModelAdmin`.
+- **`RestrictDeletionWhenPBNUIDSetMixin`** (`helpers/mixins.py:87`) nadpisuje `has_delete_permission`: zwraca `False` gdy `obj.pbn_uid_id is not None`. Jest na `Wydawnictwo_Ciagle/Zwarte`. **UWAGA MRO:** nasz mixin musi stać PRZED nim, ale `has_delete_permission` ma wołać `super()` — to znaczy, że dla rekordu z PBN superuser nadal nie usunie (zgodne ze spec — soft-delete z `pbn_uid` to osobny, wrażliwy przypadek; obrona PBN zostaje). Soft-delete rekordu z `pbn_uid` realizujemy mimo to, bo `has_soft_delete_permission` (nasza) jest niezależna od `has_delete_permission`. Patrz Task 4 — rozdzielamy uprawnienia.
+- **Pakiet `django_softdelete.admin`** (`.venv/.../django_softdelete/admin.py`): `GlobalObjectsModelAdmin.get_queryset` → `global_objects`; `SoftDeleteFilter` (param `is_deleted`, lookupy `true`/`false`). Akcje pakietu (`soft_delete_selected`, `hard_delete_selected`, `restore_selected`) wołają `obj.delete()`/`queryset.restore()` **bez `user=`** → NIE używamy ich (łamią jeden-hook). `SoftDeleteFilter.queryset` filtruje po `deleted_at__isnull`.
+- **Fixtures (`src/conftest.py`):** `superuser` (`:172`, `create_superuser`, login `user`/`foo`), `superuser_client` (`:191`), `test_user` (`:162`, zwykły user — **NIE staff**), `client` (pytest-django). Brak gotowego „staff-not-superuser" — **dodajemy** w Task 8.
+- **Wzorce superuser-only:** `oplaty_log.py:58-65` (`has_*_permission` → `False`), `__init__.py:265` (`has_delete_permission` z logiką), `uczelnia.py:31`.
+
+---
+
+## File Structure
+
+**Modyfikowane:**
+- `src/bpp/admin/helpers/mixins.py` — NOWY `BppSoftDeleteAdminMixin` + formularz `PowodSoftDeleteForm` (intermediate page).
+- `src/bpp/admin/wydawnictwo_ciagle.py:262` — wpięcie mixinu w `Wydawnictwo_CiagleAdmin`.
+- `src/bpp/admin/wydawnictwo_zwarte.py:438` — wpięcie w `Wydawnictwo_ZwarteAdmin`.
+- `src/bpp/admin/patent.py:83` — wpięcie w `Patent_Admin`.
+- `src/bpp/admin/praca_doktorska.py:197` — wpięcie w `Praca_DoktorskaAdmin`.
+- `src/bpp/admin/praca_habilitacyjna.py:172` — wpięcie w `Praca_HabilitacyjnaAdmin`.
+- `src/bpp/admin/autor.py:192` — wpięcie w `AutorAdmin`.
+
+**Tworzone:**
+- `templates/admin/bpp/soft_delete_powod.html` — intermediate page „podaj powód" (emoji).
+- `src/bpp/tests/test_admin_soft_delete.py` — testy fazy.
+- (Task 8) fixture `staff_user` / `staff_client` w `src/conftest.py` jeśli nie istnieje.
+
+> **Decyzja: jeden mixin, sześć adminów.** Mixin nie zna konkretnego modelu —
+> używa `self.model.global_objects` / `self.model.deleted_objects`. Działa dla
+> publikacji i `Autor` identycznie. `Autor.delete()` z guardem (faza 04) rzuca
+> `ProtectedError` gdy ma prace → mixin łapie i pokazuje komunikat (Task 7).
+
+---
+
+## Task 1: Mixin szkielet + `get_queryset` → `global_objects` + filtr „Pokaż skasowane"
+
+**Files:**
+- Modify: `src/bpp/admin/helpers/mixins.py` (dopisz na końcu).
+- Test: `src/bpp/tests/test_admin_soft_delete.py` (utwórz).
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# src/bpp/tests/test_admin_soft_delete.py
+import pytest
+from django.urls import reverse
+from model_bakery import baker
+
+from bpp.models import Wydawnictwo_Ciagle
+
+
+@pytest.mark.django_db
+def test_changelist_pokazuje_nieskasowane_domyslnie(superuser_client):
+ żywy = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Żywa praca")
+ skasowany = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Praca w koszu")
+ skasowany.delete(user=None, reason="test")
+
+ url = reverse("admin:bpp_wydawnictwo_ciagle_changelist")
+ resp = superuser_client.get(url)
+ content = resp.content.decode("utf-8")
+
+ assert resp.status_code == 200
+ assert "Żywa praca" in content
+ # global_objects pozwala otworzyć skasowany rekord po ID, ale changelist
+ # domyślnie filtruje (SoftDeleteFilter default = nieskasowane):
+ assert "Praca w koszu" not in content
+ assert żywy.pk is not None
+
+
+@pytest.mark.django_db
+def test_filtr_pokaz_skasowane_pokazuje_kosz(superuser_client):
+ baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Żywa praca")
+ skasowany = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Praca w koszu")
+ skasowany.delete(user=None, reason="test")
+
+ url = reverse("admin:bpp_wydawnictwo_ciagle_changelist")
+ resp = superuser_client.get(url, {"is_deleted": "true"})
+ content = resp.content.decode("utf-8")
+
+ assert resp.status_code == 200
+ assert "Praca w koszu" in content
+
+
+@pytest.mark.django_db
+def test_changeform_otwiera_skasowany_rekord(superuser_client):
+ skasowany = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Praca w koszu")
+ skasowany.delete(user=None, reason="test")
+
+ url = reverse(
+ "admin:bpp_wydawnictwo_ciagle_change", args=[skasowany.pk]
+ )
+ resp = superuser_client.get(url)
+ # get_queryset = global_objects → da się otworzyć skasowany rekord:
+ assert resp.status_code == 200
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py -v`
+Expected: FAIL — `test_filtr_pokaz_skasowane_pokazuje_kosz` i `test_changeform_otwiera_skasowany_rekord` padają (domyślny `objects` ukrywa skasowane, brak filtra `is_deleted`).
+
+- [ ] **Step 3: Write minimal implementation — mixin szkielet w `helpers/mixins.py`**
+
+Dopisz na końcu `src/bpp/admin/helpers/mixins.py`:
+
+```python
+from django.contrib import admin, messages # noqa: E402 (na górze pliku)
+from django.db.models import ProtectedError # noqa: E402
+from django_softdelete.filters import SoftDeleteFilter # noqa: E402
+
+
+class BppSoftDeleteAdminMixin:
+ """Admin superuser-only dla modeli SoftDeleteModel (5 publikacji + Autor).
+
+ Zapewnia:
+ - get_queryset -> global_objects (otwieranie/przywracanie skasowanych),
+ - filtr "Pokaż skasowane" (SoftDeleteFilter, param is_deleted),
+ - JEDEN hook usera (_soft_delete_user_context) dla delete/restore/hard,
+ - akcje "Przywróć" i "Usuń trwale" (ta druga superuser-only).
+
+ Komponuj PRZED istniejącymi klasami admina (przed admin.ModelAdmin).
+ """
+
+ def get_queryset(self, request):
+ # global_objects: zawiera skasowane, żeby dało się je otworzyć
+ # i przywrócić. SoftDeleteFilter (default) i tak ukrywa kosz na
+ # liście, póki użytkownik nie wybierze "Pokaż skasowane".
+ qs = self.model.global_objects.get_queryset()
+ ordering = self.get_ordering(request)
+ if ordering:
+ qs = qs.order_by(*ordering)
+ return qs
+
+ def get_list_filter(self, request):
+ list_filter = super().get_list_filter(request) or []
+ list_filter = list(list_filter)
+ if SoftDeleteFilter not in list_filter:
+ list_filter = [SoftDeleteFilter] + list_filter
+ return list_filter
+```
+
+> Uwaga: `SoftDeleteFilter.queryset` traktuje brak parametru (`None`) jak
+> `'all'`... a faktycznie zwraca `'ALL'` tylko dla `'all'`; dla `None` mapuje
+> na `'all'` → `'ALL'` → zwraca cały queryset. To znaczy: **bez parametru
+> pokazuje wszystko** (żywe + kosz). Spec wymaga „domyślnie ukryj kosz".
+> Dlatego w Step 5 nadpisujemy zachowanie własnym filtrem (Task 1b).
+
+- [ ] **Step 4: Run partial — sprawdź changeform i filtr**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_changeform_otwiera_skasowany_rekord -v`
+Expected: PASS (po dodaniu `get_queryset` — ale dopiero gdy mixin wpięty; jeśli jeszcze nie wpięty do `Wydawnictwo_CiagleAdmin`, to nadal FAIL — wpinamy w Task 4). Tymczasowo: dopnij mixin do `Wydawnictwo_CiagleAdmin` na czas tego testu **albo** wykonaj Task 4 przed re-runem. **Decyzja porządkująca:** wpięcie do `Wydawnictwo_CiagleAdmin` robimy już teraz minimalnie (jedna linia + import), pełne 6 adminów w Task 4.
+
+Minimalne wpięcie teraz — `src/bpp/admin/wydawnictwo_ciagle.py`:
+```python
+from .helpers.mixins import ( # dołącz do istniejącego importu z .helpers.mixins
+ BppSoftDeleteAdminMixin,
+ OptionalPBNSaveMixin,
+ RestrictDeletionWhenPBNUIDSetMixin,
+)
+```
+```python
+class Wydawnictwo_CiagleAdmin(
+ BppSoftDeleteAdminMixin, # <-- PIERWSZY
+ ConstanceScoringFieldsMixin,
+ # ... reszta bez zmian ...
+ RestrictDeletionWhenPBNUIDSetMixin,
+ admin.ModelAdmin,
+):
+```
+
+- [ ] **Step 5: Własny filtr „Pokaż skasowane" z domyślnym ukrywaniem kosza (Task 1b)**
+
+Dopisz w `helpers/mixins.py` PRZED `BppSoftDeleteAdminMixin` i podmień w `get_list_filter`:
+
+```python
+class PokazSkasowaneFilter(SoftDeleteFilter):
+ """Jak SoftDeleteFilter, ale DOMYŚLNIE (brak parametru) ukrywa kosz.
+
+ Pakietowy SoftDeleteFilter bez parametru pokazuje wszystko; spec wymaga,
+ by changelist domyślnie pokazywał tylko żywe rekordy.
+ """
+
+ title = "Stan (kosz)"
+
+ def lookups(self, request, model_admin):
+ return (
+ ("false", "🗑️ Tylko skasowane"),
+ ("all", "Wszystkie (z koszem)"),
+ )
+
+ def queryset(self, request, queryset):
+ value = self.value()
+ if value is None:
+ # Domyślnie: tylko żywe.
+ return queryset.filter(deleted_at__isnull=True)
+ if value == "all":
+ return queryset
+ if value == "false":
+ # Etykieta "Tylko skasowane" -> deleted_at NOT NULL.
+ return queryset.filter(deleted_at__isnull=False)
+ return queryset
+```
+
+> **Uwaga na semantykę pakietu:** w pakietowym `SoftDeleteFilter` lookup
+> `'true'`→"Deleted Softly" mapuje przez `{'true': False}` na
+> `deleted_at__isnull=False`. Mylące. Dlatego pełnym własnym filtrem
+> `PokazSkasowaneFilter` (powyżej) jawnie sterujemy: `value="false"` w naszym
+> filtrze = pokaż kosz. Test używa `is_deleted=true`? — NIE. Poprawiamy test
+> w Step 6, żeby używał naszego kontraktu.
+
+Podmień w `BppSoftDeleteAdminMixin.get_list_filter`: `SoftDeleteFilter` → `PokazSkasowaneFilter`.
+
+- [ ] **Step 6: Popraw test filtra na nasz kontrakt parametru**
+
+W `test_filtr_pokaz_skasowane_pokazuje_kosz` zamień `{"is_deleted": "true"}` na `{"is_deleted": "false"}` (etykieta „🗑️ Tylko skasowane"). Param `is_deleted` zachowany (dziedziczony `parameter_name`).
+
+- [ ] **Step 7: Run tests to verify they pass**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py -v`
+Expected: PASS (3 testy).
+
+- [ ] **Step 8: Lint**
+
+Run: `uv run ruff format src/bpp/admin/helpers/mixins.py src/bpp/admin/wydawnictwo_ciagle.py src/bpp/tests/test_admin_soft_delete.py && uv run ruff check src/bpp/admin/helpers/mixins.py src/bpp/admin/wydawnictwo_ciagle.py src/bpp/tests/test_admin_soft_delete.py`
+Expected: brak błędów (przenieś importy z `noqa: E402` na górę pliku `mixins.py`; usuń `noqa` po przeniesieniu).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/bpp/admin/helpers/mixins.py src/bpp/admin/wydawnictwo_ciagle.py \
+ src/bpp/tests/test_admin_soft_delete.py
+git commit -m "feat(soft-delete): admin mixin get_queryset global_objects + filtr kosza"
+```
+
+---
+
+## Task 2: Jeden hook usera — `_soft_delete_user_context` + `delete_model`/`delete_queryset` = soft-delete
+
+**Files:**
+- Modify: `src/bpp/admin/helpers/mixins.py` (`BppSoftDeleteAdminMixin`).
+- Test: `src/bpp/tests/test_admin_soft_delete.py`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+@pytest.mark.django_db
+def test_delete_w_adminie_soft_deletuje_i_zapisuje_usera(superuser, superuser_client):
+ from bpp.models import SoftDeleteLog
+
+ obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Do kosza")
+ pk = obj.pk
+ url = reverse("admin:bpp_wydawnictwo_ciagle_delete", args=[pk])
+
+ # GET = strona potwierdzenia
+ resp_get = superuser_client.get(url)
+ assert resp_get.status_code == 200
+
+ # POST = wykonaj soft-delete (Django delete confirmation: post=yes)
+ resp = superuser_client.post(url, {"post": "yes"})
+ assert resp.status_code == 302
+
+ # Zniknął z objects, jest w global_objects z deleted_at:
+ assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists()
+ g = Wydawnictwo_Ciagle.global_objects.get(pk=pk)
+ assert g.deleted_at is not None
+
+ # SoftDeleteLog (faza 06 receiver) ma usera = superuser:
+ log = SoftDeleteLog.objects.filter(object_id=pk).latest("timestamp")
+ assert log.user_id == superuser.pk
+ assert log.akcja == "delete"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_delete_w_adminie_soft_deletuje_i_zapisuje_usera -v`
+Expected: FAIL — domyślny `delete_model` robi hard-delete (rekord znika z `global_objects`) i `log.user_id` to `None` (brak hooka).
+
+- [ ] **Step 3: Write implementation — hook usera + delete_model/delete_queryset**
+
+Dopisz import na górze `helpers/mixins.py`:
+```python
+from contextlib import contextmanager
+
+from bpp.models.soft_delete import (
+ clear_soft_delete_user,
+ set_soft_delete_user,
+)
+```
+
+W `BppSoftDeleteAdminMixin`:
+```python
+ @contextmanager
+ def _soft_delete_user_context(self, request):
+ """JEDEN punkt wstrzyknięcia request.user dla całego przepływu
+ soft-delete/restore/hard-delete w adminie.
+
+ Ustawia thread-local (faza 06) czytany przez receivery sygnałów,
+ które zapisują SoftDeleteLog.user. To samo miejsce w przyszłości
+ zasili reversion.set_user.
+ """
+ set_soft_delete_user(request.user)
+ # SZEW reversion: tu w przyszłości reversion.set_user(request.user)
+ # (django-reversion, odłożone — patrz overview "Kontrakty z reversion").
+ try:
+ yield
+ finally:
+ clear_soft_delete_user()
+
+ def _powod_z_requestu(self, request):
+ """Powód kasowania z intermediate-page (Task 5). Domyślnie pusty."""
+ return request.POST.get("powod", "")
+
+ def delete_model(self, request, obj):
+ # "Usuń" w adminie = soft-delete (kosz), NIE hard-delete.
+ with self._soft_delete_user_context(request):
+ obj.delete(user=request.user, reason=self._powod_z_requestu(request))
+
+ def delete_queryset(self, request, queryset):
+ # Akcja "delete_selected" przechodzi tędy: per-instancja soft-delete.
+ with self._soft_delete_user_context(request):
+ powod = self._powod_z_requestu(request)
+ for obj in queryset:
+ obj.delete(user=request.user, reason=powod)
+```
+
+> **Dlaczego per-instancja w `delete_queryset`?** Kontrakt reversion #1 +
+> kaskada `*_Autor` (faza 02) + `SoftDeleteLog` wymagają `post_save`/sygnałów
+> per obiekt. `BppSoftDeleteQuerySet.update()` (faza 01) i tak blokuje bulk
+> ustawienie `deleted_at`. NIE wołaj `queryset.delete()` zbiorczo bez usera.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_delete_w_adminie_soft_deletuje_i_zapisuje_usera -v`
+Expected: PASS.
+
+- [ ] **Step 5: Lint + commit**
+
+```bash
+uv run ruff format src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+uv run ruff check src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+git add src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+git commit -m "feat(soft-delete): jeden hook usera + delete_model/delete_queryset = kosz"
+```
+
+---
+
+## Task 3: Akcja „Przywróć" (restore) przez ten sam hook usera
+
+**Files:**
+- Modify: `src/bpp/admin/helpers/mixins.py` (`BppSoftDeleteAdminMixin`).
+- Test: `src/bpp/tests/test_admin_soft_delete.py`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+@pytest.mark.django_db
+def test_akcja_przywroc_dziala_i_zapisuje_usera(superuser, superuser_client):
+ from bpp.models import SoftDeleteLog
+
+ obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Wraca z kosza")
+ pk = obj.pk
+ obj.delete(user=None, reason="test")
+ assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists()
+
+ url = reverse("admin:bpp_wydawnictwo_ciagle_changelist")
+ resp = superuser_client.post(
+ url,
+ {
+ "action": "przywroc_zaznaczone",
+ "_selected_action": [str(pk)],
+ },
+ )
+ assert resp.status_code in (200, 302)
+
+ # Wrócił do objects, deleted_at = NULL:
+ assert Wydawnictwo_Ciagle.objects.filter(pk=pk).exists()
+ g = Wydawnictwo_Ciagle.global_objects.get(pk=pk)
+ assert g.deleted_at is None
+
+ log = SoftDeleteLog.objects.filter(object_id=pk, akcja="restore").latest(
+ "timestamp"
+ )
+ assert log.user_id == superuser.pk
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_akcja_przywroc_dziala_i_zapisuje_usera -v`
+Expected: FAIL — akcja `przywroc_zaznaczone` nie istnieje (`'przywroc_zaznaczone' is not a registered action`).
+
+- [ ] **Step 3: Write implementation — akcja restore**
+
+W `BppSoftDeleteAdminMixin` dopisz akcję i zarejestruj ją w `get_actions`:
+
+```python
+ @admin.action(description="♻️ Przywróć zaznaczone (z kosza)")
+ def przywroc_zaznaczone(self, request, queryset):
+ # queryset z global_objects może zawierać też nieskasowane — restore
+ # nieskasowanego jest no-op po stronie pakietu, więc bezpieczne.
+ with self._soft_delete_user_context(request):
+ przywrocono = 0
+ for obj in queryset:
+ if obj.deleted_at is not None:
+ obj.restore(user=request.user)
+ przywrocono += 1
+ self.message_user(
+ request,
+ f"Przywrócono z kosza: {przywrocono}.",
+ level=messages.SUCCESS,
+ )
+
+ def get_actions(self, request):
+ actions = super().get_actions(request)
+ actions["przywroc_zaznaczone"] = self.get_action("przywroc_zaznaczone")
+ return actions
+```
+
+> `self.get_action(name)` zwraca krotkę `(func, name, description)` wymaganą
+> przez Django dla `get_actions`. Działa, bo `przywroc_zaznaczone` jest metodą
+> klasy z dekoratorem `@admin.action`.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_akcja_przywroc_dziala_i_zapisuje_usera -v`
+Expected: PASS.
+
+- [ ] **Step 5: Lint + commit**
+
+```bash
+uv run ruff format src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+uv run ruff check src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+git add src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+git commit -m "feat(soft-delete): akcja admina Przywróć przez jeden hook usera"
+```
+
+---
+
+## Task 4: Akcja „Usuń trwale" (hard_delete) — TYLKO superuser
+
+**Files:**
+- Modify: `src/bpp/admin/helpers/mixins.py` (`BppSoftDeleteAdminMixin`).
+- Test: `src/bpp/tests/test_admin_soft_delete.py`.
+
+- [ ] **Step 1: Write the failing test (superuser może; staff dostaje odmowę)**
+
+```python
+@pytest.mark.django_db
+def test_usun_trwale_dostepne_dla_superusera(superuser, superuser_client):
+ obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Do trwałego usunięcia")
+ pk = obj.pk
+ obj.delete(user=None, reason="test")
+
+ url = reverse("admin:bpp_wydawnictwo_ciagle_changelist")
+ # Akcja widoczna dla superusera:
+ resp_list = superuser_client.get(url, {"is_deleted": "false"})
+ assert b"usun_trwale_zaznaczone" in resp_list.content
+
+ resp = superuser_client.post(
+ url,
+ {
+ "action": "usun_trwale_zaznaczone",
+ "_selected_action": [str(pk)],
+ },
+ )
+ assert resp.status_code in (200, 302)
+ # Zniknął z global_objects (hard-delete):
+ assert not Wydawnictwo_Ciagle.global_objects.filter(pk=pk).exists()
+
+
+@pytest.mark.django_db
+def test_usun_trwale_niedostepne_dla_staff(staff_client):
+ obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Próba przez staff")
+ pk = obj.pk
+ obj.delete(user=None, reason="test")
+
+ url = reverse("admin:bpp_wydawnictwo_ciagle_changelist")
+ # Akcja NIE jest oferowana staffowi:
+ resp_list = staff_client.get(url, {"is_deleted": "false"})
+ assert b"usun_trwale_zaznaczone" not in resp_list.content
+
+ # Nawet wymuszony POST nie usuwa trwale:
+ resp = staff_client.post(
+ url,
+ {
+ "action": "usun_trwale_zaznaczone",
+ "_selected_action": [str(pk)],
+ },
+ )
+ # Django odrzuca nieznaną/niedozwoloną akcję (brak na liście get_actions):
+ assert resp.status_code in (200, 302, 403)
+ assert Wydawnictwo_Ciagle.global_objects.filter(pk=pk).exists()
+```
+
+> Fixture `staff_client` dodajemy w Task 8 (Step 0 poniżej najpierw upewnij się,
+> że istnieje — jeśli nie, Task 8 musi iść PRZED tym testem; w praktyce dodaj
+> fixture teraz w `conftest.py`, bo Task 4 go potrzebuje).
+
+- [ ] **Step 2: Dodaj fixture `staff_user`/`staff_client` (jeśli brak)**
+
+W `src/conftest.py` (po `superuser_client`, `:196`):
+
+```python
+@pytest.fixture
+def staff_user(db):
+ """Staff (dostęp do admina), ale NIE superuser."""
+ u = User.objects.create_user(
+ username="staff",
+ password="staffpass",
+ email="staff@example.com",
+ )
+ u.is_staff = True
+ u.save()
+ return u
+
+
+@pytest.fixture
+def staff_client(client, staff_user):
+ """Zalogowany staff (nie-superuser)."""
+ if not client.login(username="staff", password="staffpass"):
+ raise Exception("Cannot login staff")
+ return client
+```
+
+> Staff bez `is_superuser` i bez uprawnień modelowych nie zobaczy changelisty
+> w ogóle (403/redirect). Aby test sprawdzał *akcję* a nie brak dostępu, nadaj
+> staffowi uprawnienia do modelu w fixture lub w teście. Dopisz w `staff_user`:
+> ```python
+> from django.contrib.auth.models import Permission
+> u.user_permissions.add(
+> *Permission.objects.filter(
+> content_type__app_label="bpp",
+> content_type__model="wydawnictwo_ciagle",
+> )
+> )
+> ```
+> (zapewnia `view/change/delete` na `wydawnictwo_ciagle`, więc changelist się
+> renderuje, ale `usun_trwale_zaznaczone` jest superuser-only przez `get_actions`).
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_usun_trwale_dostepne_dla_superusera src/bpp/tests/test_admin_soft_delete.py::test_usun_trwale_niedostepne_dla_staff -v`
+Expected: FAIL — akcja `usun_trwale_zaznaczone` nie istnieje.
+
+- [ ] **Step 4: Write implementation — akcja hard-delete superuser-only**
+
+W `BppSoftDeleteAdminMixin`:
+
+```python
+ @admin.action(description="❌ Usuń TRWALE (nieodwracalnie, tylko superuser)")
+ def usun_trwale_zaznaczone(self, request, queryset):
+ if not request.user.is_superuser:
+ self.message_user(
+ request,
+ "Trwałe usuwanie jest dostępne wyłącznie dla superużytkownika.",
+ level=messages.ERROR,
+ )
+ return
+ with self._soft_delete_user_context(request):
+ powod = self._powod_z_requestu(request)
+ usunieto = 0
+ for obj in queryset:
+ obj.hard_delete(user=request.user, reason=powod)
+ usunieto += 1
+ self.message_user(
+ request,
+ f"Usunięto trwale: {usunieto}.",
+ level=messages.SUCCESS,
+ )
+```
+
+Rozszerz `get_actions` (z Task 3) — dodaj akcję hard-delete TYLKO dla superusera:
+
+```python
+ def get_actions(self, request):
+ actions = super().get_actions(request)
+ actions["przywroc_zaznaczone"] = self.get_action("przywroc_zaznaczone")
+ if request.user.is_superuser:
+ actions["usun_trwale_zaznaczone"] = self.get_action(
+ "usun_trwale_zaznaczone"
+ )
+ else:
+ # Staff nie dostaje ani trwałego usuwania, ani domyślnego
+ # delete_selected (które i tak idzie przez nasz soft-delete).
+ actions.pop("usun_trwale_zaznaczone", None)
+ return actions
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py -v`
+Expected: PASS (wszystkie dotychczasowe).
+
+- [ ] **Step 6: Lint + commit**
+
+```bash
+uv run ruff format src/bpp/admin/helpers/mixins.py src/conftest.py \
+ src/bpp/tests/test_admin_soft_delete.py
+uv run ruff check src/bpp/admin/helpers/mixins.py src/conftest.py \
+ src/bpp/tests/test_admin_soft_delete.py
+git add src/bpp/admin/helpers/mixins.py src/conftest.py \
+ src/bpp/tests/test_admin_soft_delete.py
+git commit -m "feat(soft-delete): akcja Usuń trwale superuser-only + fixture staff"
+```
+
+---
+
+## Task 5: Pole „powód" przy kasowaniu — intermediate page → `SoftDeleteLog.powod`
+
+**Files:**
+- Modify: `src/bpp/admin/helpers/mixins.py` (formularz + nadpisany przepływ delete).
+- Create: `templates/admin/bpp/soft_delete_powod.html`.
+- Test: `src/bpp/tests/test_admin_soft_delete.py`.
+
+> **Decyzja UX.** Pojedynczy „Usuń" (changeform delete) i akcja zbiorcza
+> „delete_selected" przechodzą przez stronę pośrednią pytającą o powód. Powód
+> ląduje w `SoftDeleteLog.powod` (przez `reason=` → receiver fazy 06). Aby nie
+> przepisywać całego Django delete-confirmation, dla pojedynczego rekordu
+> czytamy `powod` z POST formularza potwierdzenia (Django renderuje własny
+> `delete_confirmation.html`). Najprościej: własna akcja `usun_do_kosza` z
+> intermediate page (analogicznie do pakietowego wzorca), a domyślne
+> `delete_selected`/`delete_model` zostają jako soft-delete bez wymuszonego
+> powodu (powód opcjonalny).
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+@pytest.mark.django_db
+def test_akcja_usun_do_kosza_z_powodem_trafia_do_logu(superuser, superuser_client):
+ from bpp.models import SoftDeleteLog
+
+ obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Z powodem")
+ pk = obj.pk
+ url = reverse("admin:bpp_wydawnictwo_ciagle_changelist")
+
+ # Krok 1: wybór akcji bez 'powod_potwierdzony' -> intermediate page:
+ resp1 = superuser_client.post(
+ url,
+ {
+ "action": "usun_do_kosza",
+ "_selected_action": [str(pk)],
+ },
+ )
+ assert resp1.status_code == 200
+ assert b"powod" in resp1.content # formularz z polem powodu
+
+ # Krok 2: potwierdzenie z powodem:
+ resp2 = superuser_client.post(
+ url,
+ {
+ "action": "usun_do_kosza",
+ "_selected_action": [str(pk)],
+ "powod_potwierdzony": "1",
+ "powod": "Duplikat rekordu",
+ },
+ )
+ assert resp2.status_code in (200, 302)
+
+ assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists()
+ log = SoftDeleteLog.objects.filter(object_id=pk, akcja="delete").latest(
+ "timestamp"
+ )
+ assert log.powod == "Duplikat rekordu"
+ assert log.user_id == superuser.pk
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_akcja_usun_do_kosza_z_powodem_trafia_do_logu -v`
+Expected: FAIL — brak akcji `usun_do_kosza` / brak template.
+
+- [ ] **Step 3: Write template**
+
+`templates/admin/bpp/soft_delete_powod.html`:
+
+```django
+{% extends "admin/base_site.html" %}
+{% load i18n admin_urls %}
+
+{% block content %}
+{# Strona pośrednia: podaj powód usunięcia do kosza. #}
+🗑️ Zaznaczone rekordy zostaną przeniesione do kosza (soft-delete).
+Operacja jest odwracalna (akcja „♻️ Przywróć").
+
+
+ {% for obj in obiekty %}
+ - {{ obj }}
+ {% endfor %}
+
+
+
+{% endblock %}
+```
+
+- [ ] **Step 4: Write implementation — akcja `usun_do_kosza` z intermediate page**
+
+W `helpers/mixins.py` dodaj import:
+```python
+from django.contrib.admin import helpers as admin_helpers
+from django.template.response import TemplateResponse
+```
+
+W `BppSoftDeleteAdminMixin`:
+
+```python
+ @admin.action(description="🗑️ Usuń do kosza (z powodem)")
+ def usun_do_kosza(self, request, queryset):
+ if request.POST.get("powod_potwierdzony"):
+ with self._soft_delete_user_context(request):
+ powod = request.POST.get("powod", "")
+ usunieto = 0
+ for obj in queryset:
+ obj.delete(user=request.user, reason=powod)
+ usunieto += 1
+ self.message_user(
+ request,
+ f"Przeniesiono do kosza: {usunieto}.",
+ level=messages.SUCCESS,
+ )
+ return None
+
+ # Pierwszy krok: strona pośrednia z polem 'powod'.
+ context = {
+ **self.admin_site.each_context(request),
+ "title": "Usuń do kosza",
+ "obiekty": list(queryset),
+ "wybrane_pk": [str(o.pk) for o in queryset],
+ "opts": self.model._meta,
+ "action_checkbox_name": admin_helpers.ACTION_CHECKBOX_NAME,
+ }
+ return TemplateResponse(
+ request, "admin/bpp/soft_delete_powod.html", context
+ )
+```
+
+Dodaj do `get_actions` (rozszerz istniejące):
+```python
+ actions["usun_do_kosza"] = self.get_action("usun_do_kosza")
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_akcja_usun_do_kosza_z_powodem_trafia_do_logu -v`
+Expected: PASS.
+
+- [ ] **Step 6: Lint + commit**
+
+```bash
+uv run ruff format src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+uv run ruff check src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+git add src/bpp/admin/helpers/mixins.py templates/admin/bpp/soft_delete_powod.html \
+ src/bpp/tests/test_admin_soft_delete.py
+git commit -m "feat(soft-delete): akcja Usuń do kosza z powodem -> SoftDeleteLog"
+```
+
+---
+
+## Task 6: Wpięcie mixinu do pozostałych 5 adminów (MRO) + recover-szew
+
+**Files:**
+- Modify: `src/bpp/admin/wydawnictwo_zwarte.py:438`, `patent.py:83`, `praca_doktorska.py:197`, `praca_habilitacyjna.py:172`, `autor.py:192`.
+- Modify: `src/bpp/admin/helpers/mixins.py` (`get_urls` szew recover).
+- Test: `src/bpp/tests/test_admin_soft_delete.py`.
+
+> **MRO — reguła:** `BppSoftDeleteAdminMixin` jest **PIERWSZY** na liście baz
+> każdego admina, żeby jego `get_queryset`/`get_actions`/`delete_model`/
+> `get_list_filter` wygrywały. Wszystkie 6 adminów kończą się na
+> `admin.ModelAdmin` (bezpośrednio lub przez `Wydawnictwo_ZwarteAdmin_Baza` /
+> `Praca_Doktorska_Habilitacyjna_Admin_Base`), więc `super()` w naszych
+> metodach trafia poprawnie w łańcuch i finalnie w `ModelAdmin`. Mixin nie
+> definiuje `__init__` ani `Meta`, więc nie psuje istniejących mixinów.
+
+- [ ] **Step 1: Write the failing test (parametryzowany po 5 pozostałych modelach)**
+
+```python
+import pytest
+from django.urls import reverse
+from model_bakery import baker
+
+from bpp.models import (
+ Autor,
+ Patent,
+ Praca_Doktorska,
+ Praca_Habilitacyjna,
+ Wydawnictwo_Zwarte,
+)
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "model,admin_slug",
+ [
+ (Wydawnictwo_Zwarte, "wydawnictwo_zwarte"),
+ (Patent, "patent"),
+ (Praca_Doktorska, "praca_doktorska"),
+ (Praca_Habilitacyjna, "praca_habilitacyjna"),
+ (Autor, "autor"),
+ ],
+)
+def test_soft_delete_w_adminie_dla_kazdego_modelu(
+ model, admin_slug, superuser, superuser_client
+):
+ obj = baker.make(model)
+ pk = obj.pk
+ obj.delete(user=None, reason="test")
+
+ # Changeform otwiera skasowany (global_objects):
+ url_change = reverse(f"admin:bpp_{admin_slug}_change", args=[pk])
+ assert superuser_client.get(url_change).status_code == 200
+
+ # Filtr kosza pokazuje skasowany:
+ url_list = reverse(f"admin:bpp_{admin_slug}_changelist")
+ resp = superuser_client.get(url_list, {"is_deleted": "false"})
+ assert resp.status_code == 200
+
+ # Restore działa:
+ resp_r = superuser_client.post(
+ url_list,
+ {"action": "przywroc_zaznaczone", "_selected_action": [str(pk)]},
+ )
+ assert resp_r.status_code in (200, 302)
+ assert model.objects.filter(pk=pk).exists()
+```
+
+> `baker.make(Autor)` daje autora **bez prac** → soft-delete dozwolony (guard
+> fazy 04 nie blokuje). Test guarda autora-z-pracami jest w Task 7.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest "src/bpp/tests/test_admin_soft_delete.py::test_soft_delete_w_adminie_dla_kazdego_modelu" -v`
+Expected: FAIL — pozostałe 5 adminów nie mają mixinu (`change` na skasowanym → 404, brak akcji `przywroc_zaznaczone`).
+
+- [ ] **Step 3: Wpięcie mixinu — `wydawnictwo_zwarte.py`**
+
+```python
+from .helpers.mixins import (
+ BppSoftDeleteAdminMixin,
+ OptionalPBNSaveMixin,
+ RestrictDeletionWhenPBNUIDSetMixin,
+)
+```
+```python
+class Wydawnictwo_ZwarteAdmin(
+ BppSoftDeleteAdminMixin, # <-- PIERWSZY
+ ConstanceScoringFieldsMixin,
+ # ... reszta bez zmian ...
+ RestrictDeletionWhenPBNUIDSetMixin,
+ Wydawnictwo_ZwarteAdmin_Baza,
+):
+```
+
+- [ ] **Step 4: Wpięcie — `patent.py`**
+
+```python
+from .helpers.mixins import BppSoftDeleteAdminMixin
+```
+```python
+class Patent_Admin(
+ BppSoftDeleteAdminMixin, # <-- PIERWSZY
+ ConstanceScoringFieldsMixin,
+ AdnotacjeZDatamiMixin,
+ EksportDanychZFormatowanieMixin,
+ ExportActionsMixin,
+ Wydawnictwo_ZwarteAdmin_Baza,
+):
+```
+
+- [ ] **Step 5: Wpięcie — `praca_doktorska.py`**
+
+```python
+from .helpers.mixins import (
+ BppSoftDeleteAdminMixin,
+ DomyslnyStatusKorektyMixin,
+ Wycinaj_W_z_InformacjiMixin,
+)
+```
+```python
+class Praca_DoktorskaAdmin(
+ BppSoftDeleteAdminMixin, # <-- PIERWSZY
+ ConstanceScoringFieldsMixin,
+ EksportDanychZFormatowanieMixin,
+ ExportActionsMixin,
+ Praca_Doktorska_Habilitacyjna_Admin_Base,
+):
+```
+
+- [ ] **Step 6: Wpięcie — `praca_habilitacyjna.py`**
+
+```python
+from .helpers.mixins import (
+ BppSoftDeleteAdminMixin,
+ DomyslnyStatusKorektyMixin,
+ Wycinaj_W_z_InformacjiMixin,
+)
+```
+```python
+class Praca_HabilitacyjnaAdmin(
+ BppSoftDeleteAdminMixin, # <-- PIERWSZY
+ ConstanceScoringFieldsMixin,
+ EksportDanychZFormatowanieMixin,
+ ExportActionsMixin,
+ Praca_Doktorska_Habilitacyjna_Admin_Base,
+):
+```
+
+- [ ] **Step 7: Wpięcie — `autor.py`**
+
+```python
+from .core import BaseBppAdminMixin
+from .helpers.mixins import BppSoftDeleteAdminMixin
+```
+```python
+class AutorAdmin(
+ BppSoftDeleteAdminMixin, # <-- PIERWSZY
+ DjangoQLSearchMixin,
+ ZapiszZAdnotacjaMixin,
+ EksportDanychMixin,
+ BaseBppAdminMixin,
+ DynamicColumnsMixin,
+ admin.ModelAdmin,
+):
+```
+
+> `AutorAdmin.get_actions` (`autor.py:475`) nadpisuje `get_actions` i zmienia
+> opis `delete_selected`. Nasz mixin też nadpisuje `get_actions`. Ponieważ
+> mixin jest PIERWSZY, jego `get_actions` woła `super().get_actions()` → trafia
+> w `AutorAdmin.get_actions` (które woła swój `super()` itd.). Kolejność OK:
+> najpierw zostaje ustawiony opis `delete_selected` (Autor), potem mixin dokłada
+> `przywroc_zaznaczone`/`usun_do_kosza`/(superuser) `usun_trwale_zaznaczone`.
+> **Zweryfikuj w teście Task 6**, że akcje współistnieją.
+
+- [ ] **Step 8: Recover-szew w `get_urls`**
+
+W `BppSoftDeleteAdminMixin` dopisz:
+```python
+ def get_urls(self):
+ urls = super().get_urls()
+ # SZEW reversion (odłożone): gdy włączymy django-reversion, jego
+ # "recover deleted" URL (recover/) musi tu być UKRYTY albo
+ # przekierowany na restore() — recover wskrzeszałby rekord poza
+ # przepływem soft-delete (bez WYSYLKA do PBN, bez SoftDeleteLog,
+ # łamiąc warunkowy unique slug). Patrz overview "Kontrakty z reversion".
+ return urls
+```
+
+- [ ] **Step 9: Run test to verify it passes**
+
+Run: `uv run pytest "src/bpp/tests/test_admin_soft_delete.py::test_soft_delete_w_adminie_dla_kazdego_modelu" -v`
+Expected: PASS (5 parametryzacji).
+
+- [ ] **Step 10: Run full faza-07 suite + lint**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py -v`
+Expected: PASS (wszystkie).
+
+```bash
+uv run ruff format src/bpp/admin/wydawnictwo_zwarte.py src/bpp/admin/patent.py \
+ src/bpp/admin/praca_doktorska.py src/bpp/admin/praca_habilitacyjna.py \
+ src/bpp/admin/autor.py src/bpp/admin/helpers/mixins.py
+uv run ruff check src/bpp/admin/wydawnictwo_zwarte.py src/bpp/admin/patent.py \
+ src/bpp/admin/praca_doktorska.py src/bpp/admin/praca_habilitacyjna.py \
+ src/bpp/admin/autor.py src/bpp/admin/helpers/mixins.py
+```
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add src/bpp/admin/wydawnictwo_zwarte.py src/bpp/admin/patent.py \
+ src/bpp/admin/praca_doktorska.py src/bpp/admin/praca_habilitacyjna.py \
+ src/bpp/admin/autor.py src/bpp/admin/helpers/mixins.py \
+ src/bpp/tests/test_admin_soft_delete.py
+git commit -m "feat(soft-delete): wpięcie BppSoftDeleteAdminMixin do 5 adminów + Autor"
+```
+
+---
+
+## Task 7: Guard `Autor` z pracami — czytelny komunikat zamiast 500
+
+**Files:**
+- Modify: `src/bpp/admin/helpers/mixins.py` (`delete_queryset` / `usun_do_kosza` / `delete_model` łapią `ProtectedError`).
+- Test: `src/bpp/tests/test_admin_soft_delete.py`.
+
+> Faza 04: `Autor.delete()` rzuca `django.db.models.ProtectedError` (lub
+> `ValidationError`) gdy autor ma JAKIEKOLWIEK autorstwo/doktorat/habilitację
+> (liczone przez `global_objects`). Admin musi to złapać i pokazać komunikat,
+> a NIE wywalić 500.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+@pytest.mark.django_db
+def test_soft_delete_autora_z_pracami_pokazuje_komunikat(superuser, superuser_client):
+ from bpp.models import Wydawnictwo_Ciagle_Autor
+
+ autor = baker.make(Autor)
+ # Autor z autorstwem -> guard fazy 04 zablokuje soft-delete:
+ baker.make(Wydawnictwo_Ciagle_Autor, autor=autor)
+
+ url = reverse("admin:bpp_autor_changelist")
+ resp = superuser_client.post(
+ url,
+ {
+ "action": "usun_do_kosza",
+ "_selected_action": [str(autor.pk)],
+ "powod_potwierdzony": "1",
+ "powod": "próba",
+ },
+ follow=True,
+ )
+ assert resp.status_code == 200
+ # Autor NIE został skasowany:
+ assert Autor.objects.filter(pk=autor.pk).exists()
+ # Komunikat o blokadzie:
+ content = resp.content.decode("utf-8")
+ assert "nie można usunąć" in content.lower() or "prac" in content.lower()
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_soft_delete_autora_z_pracami_pokazuje_komunikat -v`
+Expected: FAIL — `ProtectedError` przepada jako 500 albo wyjątek wycieka.
+
+- [ ] **Step 3: Write implementation — łap ProtectedError w przepływach kasowania**
+
+Dodaj helper w `BppSoftDeleteAdminMixin` i użyj go w `delete_model`, `delete_queryset`, `usun_do_kosza`:
+
+```python
+ def _soft_delete_jeden(self, request, obj, powod):
+ """Soft-delete jednego obiektu z obsługą guarda (faza 04).
+
+ Zwraca True przy sukcesie, False gdy guard zablokował (komunikat
+ pokazany użytkownikowi)."""
+ try:
+ obj.delete(user=request.user, reason=powod)
+ return True
+ except ProtectedError:
+ self.message_user(
+ request,
+ f"Nie można usunąć „{obj}" — rekord ma powiązane prace "
+ "(autorstwa / doktorat / habilitacja). Najpierw usuń lub "
+ "przenieś powiązane prace.",
+ level=messages.ERROR,
+ )
+ return False
+```
+
+Podmień ciało pętli w `delete_queryset` i `usun_do_kosza` na:
+```python
+ for obj in queryset:
+ if self._soft_delete_jeden(request, obj, powod):
+ usunieto += 1
+```
+
+W `delete_model` (pojedynczy „Usuń" z changeform):
+```python
+ def delete_model(self, request, obj):
+ with self._soft_delete_user_context(request):
+ self._soft_delete_jeden(request, obj, self._powod_z_requestu(request))
+```
+
+> `delete_model` przy zablokowaniu nie kasuje, ale Django i tak zrobi redirect
+> z komunikatem błędu — akceptowalne (autor zostaje, error widoczny).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_soft_delete_autora_z_pracami_pokazuje_komunikat -v`
+Expected: PASS.
+
+> Jeśli faza 04 rzuca `ValidationError` zamiast `ProtectedError` — rozszerz
+> `except` o `from django.core.exceptions import ValidationError` i łap oba.
+> Zweryfikuj realny typ wyjątku z fazy 04 PRZED implementacją (przeczytaj
+> `Autor.delete()` w `src/bpp/models/autor.py` po fazie 04).
+
+- [ ] **Step 5: Lint + commit**
+
+```bash
+uv run ruff format src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+uv run ruff check src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+git add src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py
+git commit -m "feat(soft-delete): admin łapie guard autora-z-pracami (czytelny komunikat)"
+```
+
+---
+
+## Task 8: Test odmowy dla staff na pojedynczym hard-delete + domknięcie suity
+
+**Files:**
+- Test: `src/bpp/tests/test_admin_soft_delete.py`.
+
+- [ ] **Step 1: Write the test — staff może soft-deletować, ale nie hard**
+
+```python
+@pytest.mark.django_db
+def test_staff_moze_soft_delete_ale_nie_widzi_usun_trwale(staff_client):
+ obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Staff soft")
+ pk = obj.pk
+ url = reverse("admin:bpp_wydawnictwo_ciagle_changelist")
+
+ # delete_selected (domyślna) przechodzi przez nasz delete_queryset = kosz:
+ resp = staff_client.post(
+ url,
+ {"action": "delete_selected", "_selected_action": [str(pk)], "post": "yes"},
+ follow=True,
+ )
+ assert resp.status_code == 200
+ # Soft-delete (rekord w global_objects, nie w objects):
+ assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists()
+ assert Wydawnictwo_Ciagle.global_objects.filter(pk=pk).exists()
+
+ # "Usuń trwale" niedostępne staffowi:
+ resp_list = staff_client.get(url, {"is_deleted": "false"})
+ assert b"usun_trwale_zaznaczone" not in resp_list.content
+```
+
+> Jeśli staff nie ma uprawnienia `delete_wydawnictwo_ciagle`, `delete_selected`
+> nie pojawi się. Fixture `staff_user` (Task 4) nadaje uprawnienia modelowe →
+> `delete_selected` dostępne, a idzie przez nasz soft `delete_queryset`.
+
+- [ ] **Step 2: Run test**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_staff_moze_soft_delete_ale_nie_widzi_usun_trwale -v`
+Expected: PASS (cała logika już istnieje z Task 2/4).
+
+- [ ] **Step 3: Run CAŁĄ suitę fazy + smoke adminów**
+
+Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py -v`
+Expected: PASS (wszystkie testy fazy 07).
+
+Smoke regresji adminów publikacji/autora (że MRO nic nie zepsuło):
+Run: `uv run pytest src/bpp/tests/ -k "admin" -q`
+Expected: PASS (brak regresji w istniejących testach adminowych).
+
+- [ ] **Step 4: Lint całości fazy**
+
+Run: `uv run ruff format . && uv run ruff check .`
+Expected: brak błędów w plikach fazy.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/bpp/tests/test_admin_soft_delete.py
+git commit -m "test(soft-delete): staff soft-delete OK, hard-delete superuser-only"
+```
+
+---
+
+## Self-Review (wykonaj po napisaniu kodu wszystkich tasków)
+
+**1. Spec coverage (§6):**
+- „Usuń" = soft-delete → Task 2 (`delete_model`/`delete_queryset`) + Task 5 (`usun_do_kosza`). ✅
+- „Usuń trwale" superuser-only → Task 4. ✅
+- Filtr „Pokaż skasowane" → Task 1 (`PokazSkasowaneFilter`). ✅
+- Akcja „Przywróć" → Task 3. ✅
+- Pole „powód" → `SoftDeleteLog` → Task 5. ✅
+- `get_queryset` → `global_objects` → Task 1. ✅
+- Jeden hook usera (`_soft_delete_user_context`) → Task 2, używany przez wszystkie ścieżki. ✅
+- MRO/kompozycja z 6 adminami → Task 1 (Ciagle) + Task 6 (pozostałe). ✅
+- Guard autora z pracami → czytelny komunikat → Task 7. ✅
+- Szwy reversion (jeden hook + recover) → Task 2 (`# SZEW reversion`) + Task 6 (`get_urls`). ✅
+
+**2. Placeholder scan:** brak „TODO/TBD"; każdy krok ma realny kod + komendę + oczekiwany wynik.
+
+**3. Type consistency:** `_soft_delete_user_context` (Task 2), `_soft_delete_jeden` (Task 7), `_powod_z_requestu` (Task 2), `przywroc_zaznaczone`/`usun_trwale_zaznaczone`/`usun_do_kosza` (Task 3/4/5), `PokazSkasowaneFilter` (Task 1) — nazwy spójne we wszystkich taskach. `delete(user=, reason=)`/`restore(user=)`/`hard_delete(user=, reason=)` zgodne z kontraktem fazy 06.
+
+---
+
+## Założenia (zweryfikuj przed startem; jeśli nie zachodzą — to bug wcześniejszej fazy)
+
+1. **Faza 06 dostarcza** `set_soft_delete_user`/`get_soft_delete_user`/`clear_soft_delete_user` w `src/bpp/models/soft_delete.py` oraz receivery, które na podstawie thread-local zapisują `SoftDeleteLog.user`. Jeśli mechanizm „kto" jest inny (np. argument do sygnału), dostosuj `_soft_delete_user_context` — ale **JEDEN punkt** pozostaje.
+2. **Faza 06 modele** mają sygnatury `delete(self, *args, user=None, reason="", **kwargs)`, `restore(self, *args, user=None, **kwargs)`, `hard_delete(self, *args, user=None, reason="", **kwargs)` na 5 publikacjach + `Autor`. `SoftDeleteLog` ma pola `user`, `powod`, `akcja` (wartości `"delete"`/`"restore"`/`"hard_delete"`), `object_id`, `timestamp`.
+3. **Faza 04** sprawia, że `Autor.delete()` rzuca `ProtectedError` (lub `ValidationError`) gdy autor ma prace; `Autor` jest `SoftDeleteModel` z `global_objects`/`deleted_objects`. `baker.make(Autor)` daje autora bez prac (soft-delete dozwolony).
+4. **Faza 01/02** — 5 publikacji + `Autor` mają `global_objects`/`deleted_objects` (z `BppGlobalManager`), a `objects` ukrywa skasowane. `Wydawnictwo_Ciagle.delete(user=None, reason=...)` działa w teście (kaskada `*_Autor` jest no-op bez autorów).
+5. **`request.user` w adminie** to instancja `AUTH_USER_MODEL` — bezpośrednio przekazywalny do `delete(user=...)`. Operacje systemowe (merge/celery) używają `user=None` (poza tą fazą).
+6. **`SoftDeleteFilter`/`PokazSkasowaneFilter`** używają `parameter_name = "is_deleted"`; testy używają `is_deleted=false` = „pokaż kosz" (nasz kontrakt, nie pakietu).
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
new file mode 100644
index 000000000..2fadca503
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
@@ -0,0 +1,1179 @@
+# Soft-delete — Faza 08: pełna suita regresji E2E Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Domknąć soft-delete suitą regresji E2E, która udowadnia, że
+skasowane publikacje/autorstwa nie wyciekają do cache, ewaluacji, API,
+dashboardu, importu ani PBN, a wszystkie przepływy (wycofanie/restore PBN,
+merge autorów, guardy PROTECT, log audytu) działają end-to-end.
+
+**Architecture:** Faza testowa. Zależy od pełnej implementacji faz 01–07
+(`*_Autor` + 5 publikacji jako `SoftDeleteModel`, override
+`delete()`/`restore()` z wąską kaskadą, filtr `deleted_at IS NULL` w widokach
+źródłowych, audyt kat. B na `global_objects`, guardy PROTECT, operacja
+`WYCOFANIE` w `pbn_export_queue`, `SoftDeleteLog` + receivery sygnałów, admin
+superusera). Testy używają realnych fixture'ów z `src/conftest.py` /
+`src/fixtures/`, `model_bakery.baker.make`, materializowanego cache (`Rekord`,
+`Autorzy`) i mocków klienta PBN. Tam, gdzie test ujawnia lukę produkcyjną
+(np. dashboard liczący przez surowy SQL omijający menedżer `objects`),
+dorzucamy minimalną poprawkę produkcyjną wraz z testem.
+
+**Tech Stack:** pytest + `model_bakery`, Django, PostgreSQL (triggery
+materializujące cache), `django-soft-delete`, `pbn_export_queue` (Celery),
+`unittest.mock` dla klienta PBN.
+
+**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md)
+(§8 fazy, §9 ryzyka), indeks [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md).
+
+---
+
+## Uruchamianie suity
+
+- **Pełna suita** (do ~10 min): `uv run pytest` — wszystko, w tym Playwright.
+- **Szybciej, bez przeglądarki**: `make tests-without-playwright`.
+- **Sama regresja soft-delete** (te pliki): `uv run pytest -k soft_delete_regresja`.
+- Pojedynczy plik: `uv run pytest src//tests/test_soft_delete_regresja.py -v`.
+- Wszystkie testy poniżej są `@pytest.mark.django_db` (część wymaga
+ `transactional_db`, bo dotyka triggerów materializujących cache — fixture
+ `denorms`/`transactional_db` jak w `src/bpp/tests/test_cache/test_cache.py`).
+- Reguły BPP: `uv run pytest`, `baker.make`, linie ≤88 znaków, polskie nazwy
+ i docstringi, funkcje testowe bez klas, `ruff format .` + `ruff check .`
+ po każdym tasku.
+
+---
+
+## File Structure
+
+**Tworzone (pliki testowe):**
+- `src/bpp/tests/test_soft_delete/__init__.py` — pakiet testów regresji cache.
+- `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py` —
+ spójność `Rekord`/`Autorzy`/`Cache_Punktacja_*` + `verify_cache`.
+- `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py` —
+ guardy PROTECT (autor z pracami, książka-matka z rozdziałami) + admin.
+- `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_log.py` —
+ `SoftDeleteLog` (delete/restore/hard-delete + user).
+- `src/pbn_integrator/tests/test_soft_delete_regresja.py` — PBN: wycofanie
+ oświadczeń, restore→WYSYLKA, sync/re-import bez duplikatów.
+- `src/import_common/tests/test_soft_delete_regresja.py` — re-import matchuje
+ soft-deletowaną publikację (`global_objects`), nie duplikuje.
+- `src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py` —
+ ewaluacja pomija prace w koszu (pinning/unpinning), restore przywraca.
+- `src/deduplikator_autorow/tests/test_soft_delete_regresja.py` — merge
+ husk-duplikat soft-deletowany i odwracalny; PROTECT nie psuje merge.
+- `src/api_v1/tests/test_soft_delete_regresja.py` — skasowane rekordy /
+ autorstwa nie wyciekają przez API.
+- `src/admin_dashboard/tests/test_soft_delete_regresja.py` — liczniki
+ dashboardu pomijają skasowane.
+
+**Modyfikowane (drobne poprawki produkcyjne, jeśli test ujawni lukę):**
+- `src/admin_dashboard/views/charakter_stats.py` — `_get_charakter_counts`
+ (jeśli liczy przez surowy SQL/agregację omijającą filtr `deleted_at`).
+
+---
+
+## Task 1: Regresja cache — soft-delete publikacji znika z Rekord/Autorzy
+
+**Files:**
+- Create: `src/bpp/tests/test_soft_delete/__init__.py`
+- Create: `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py`
+
+- [ ] **Step 1: Utwórz pakiet testowy**
+
+```bash
+touch src/bpp/tests/test_soft_delete/__init__.py
+```
+
+- [ ] **Step 2: Napisz failing test — soft-delete znika z Rekord i Autorzy, restore wraca**
+
+`src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py`:
+
+```python
+"""Regresja E2E soft-delete: spójność materializowanego cache.
+
+Skasowana publikacja MUSI zniknąć z ``Rekord``/``Autorzy``
+(mat-view zasilany triggerem), a restore — przywrócić ją wraz z
+``*_Autor``. Patrz spec §2.1, §9 (ryzyko cache/trigger).
+"""
+
+import pytest
+
+from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor
+from bpp.models.cache import Autorzy, Rekord
+
+
+@pytest.mark.django_db
+def test_soft_delete_publikacji_znika_z_rekord_i_autorzy(
+ wydawnictwo_ciagle_z_dwoma_autorami, denorms
+):
+ denorms.flush()
+ assert Rekord.objects.count() == 1
+ assert Autorzy.objects.count() == 2
+
+ wydawnictwo_ciagle_z_dwoma_autorami.delete()
+ denorms.flush()
+
+ assert Rekord.objects.count() == 0
+ assert Autorzy.objects.count() == 0
+
+
+@pytest.mark.django_db
+def test_restore_publikacji_wraca_do_rekord_i_autorzy(
+ wydawnictwo_ciagle_z_dwoma_autorami, denorms
+):
+ wydawnictwo_ciagle_z_dwoma_autorami.delete()
+ denorms.flush()
+ assert Rekord.objects.count() == 0
+
+ wydawnictwo_ciagle_z_dwoma_autorami.restore()
+ denorms.flush()
+
+ assert Rekord.objects.count() == 1
+ assert Autorzy.objects.count() == 2
+
+
+@pytest.mark.django_db
+def test_kaskada_autor_soft_deletowany_razem_z_publikacja(
+ wydawnictwo_ciagle_z_dwoma_autorami,
+):
+ """Wąska kaskada §2.2 — *_Autor soft-deletowane razem z rodzicem,
+ domyślny menedżer ``objects`` je ukrywa, ``global_objects`` widzi."""
+ wydawnictwo_ciagle_z_dwoma_autorami.delete()
+
+ assert Wydawnictwo_Ciagle_Autor.objects.count() == 0
+ assert Wydawnictwo_Ciagle_Autor.global_objects.count() == 2
+ for wca in Wydawnictwo_Ciagle_Autor.global_objects.all():
+ assert wca.deleted_at is not None
+```
+
+- [ ] **Step 3: Uruchom — oczekuj PASS (implementacja faz 01–02 gotowa)**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py -v`
+Expected: 3 PASS. Jeśli `test_soft_delete_publikacji_znika_z_rekord_i_autorzy`
+FAIL (rekord wraca do mat-view) → **luka w fazie 01**: filtr `deleted_at IS
+NULL` nie pokrywa wszystkich gałęzi UNION `bpp_rekord` / `bpp_*_autorzy`.
+Zadanie naprawcze: dopisz brakujący `WHERE deleted_at IS NULL` w
+`src/bpp/migrations/0XXX_soft_delete_views.sql` (NOWA migracja, nie modyfikuj
+istniejących) i ponów.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/bpp/tests/test_soft_delete/
+git commit -m "test(soft-delete): regresja spójności cache Rekord/Autorzy"
+```
+
+---
+
+## Task 2: Regresja cache — Cache_Punktacja_* + verify_cache czysty
+
+**Files:**
+- Modify: `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py`
+
+- [ ] **Step 1: Sprawdź realny mechanizm weryfikacji cache**
+
+Run: `uv run python src/manage.py help | grep -iE "cache|rebuild|refresh"`
+Run: `uv run python src/manage.py verify_cache --help 2>&1 | head -5`
+Oczekiwane: ustal, czy `verify_cache` jest sprawny. **Znana luka:**
+`src/bpp/management/commands/verify_cache.py` to dziś stub
+(`raise NotImplementedError`, twarde `psycopg2.connect(database="b_med",
+host="linux-dev")`) — NIE da się go uruchomić w teście. Weryfikację spójności
+robimy przez `Rekord.objects.full_refresh()` (`src/bpp/models/cache/rekord.py:117`),
+która jest realnym, testowalnym odpowiednikiem „re-projekcji ze źródła"
+opisanym w spec §2.1. (Patrz „Luki wykryte" na końcu — `verify_cache` należy
+naprawić osobnym zadaniem, poza zakresem soft-delete.)
+
+- [ ] **Step 2: Napisz failing test — full_refresh nie wskrzesza skasowanych + Cache_Punktacja_* znika**
+
+Dopisz do `test_soft_delete_regresja_cache.py`:
+
+```python
+from bpp.models.cache.punktacja import Cache_Punktacja_Dyscypliny
+
+
+@pytest.mark.django_db
+def test_full_refresh_nie_wskrzesza_skasowanej_publikacji(
+ wydawnictwo_ciagle_z_dwoma_autorami,
+):
+ """Re-projekcja ze źródła (full_refresh) respektuje deleted_at —
+ inaczej skasowany rekord wróciłby do bpp_rekord_mat (spec §2.1)."""
+ wydawnictwo_ciagle_z_dwoma_autorami.delete()
+ assert Rekord.objects.count() == 0
+
+ Rekord.objects.full_refresh()
+
+ assert Rekord.objects.count() == 0
+ assert Autorzy.objects.count() == 0
+
+
+@pytest.mark.django_db
+def test_soft_delete_usuwa_cache_punktacji_dyscyplin(zwarte_z_dyscyplinami):
+ """Punktacja dyscyplin skasowanej pracy znika; restore ją przywraca."""
+ zwarte_z_dyscyplinami.przelicz_punkty_dyscyplin()
+ ct_pks = list(
+ Cache_Punktacja_Dyscypliny.objects.filter(
+ rekord_id=[zwarte_z_dyscyplinami.content_type_id, zwarte_z_dyscyplinami.pk]
+ ).values_list("pk", flat=True)
+ )
+ assert len(ct_pks) > 0
+
+ zwarte_z_dyscyplinami.delete()
+
+ assert (
+ Cache_Punktacja_Dyscypliny.objects.filter(pk__in=ct_pks).count() == 0
+ )
+
+ zwarte_z_dyscyplinami.restore()
+ zwarte_z_dyscyplinami.przelicz_punkty_dyscyplin()
+
+ assert (
+ Cache_Punktacja_Dyscypliny.objects.filter(
+ rekord_id=[
+ zwarte_z_dyscyplinami.content_type_id,
+ zwarte_z_dyscyplinami.pk,
+ ]
+ ).count()
+ > 0
+ )
+```
+
+- [ ] **Step 3: Uruchom — oczekuj PASS**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py -k "full_refresh or cache_punktacji" -v`
+Expected: PASS. Jeśli `Cache_Punktacja_Dyscypliny` wraca po delete →
+sprawdź, czy override `delete()` (faza 02) czyści punktację dyscyplin albo
+czy trigger usuwa wpisy `Cache_Punktacja_*` na podstawie `deleted_at`.
+Jeśli pole `rekord_id` w `Cache_Punktacja_Dyscypliny` ma inną strukturę niż
+`[content_type_id, pk]`, dostosuj filtr do realnego schematu modelu
+(`src/bpp/models/cache/punktacja.py:18`) — sprawdź `uv run python
+src/manage.py shell -c "from bpp.models.cache.punktacja import
+Cache_Punktacja_Dyscypliny as C; print(C._meta.get_field('rekord_id'))"`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py
+git commit -m "test(soft-delete): regresja Cache_Punktacja + full_refresh"
+```
+
+---
+
+## Task 3: Regresja guardy PROTECT — autor z pracami i książka-matka z rozdziałami
+
+**Files:**
+- Create: `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py`
+
+- [ ] **Step 1: Napisz failing test — guard autora z pracami (soft + hard)**
+
+```python
+"""Regresja guardów PROTECT (spec §3, §2.6).
+
+Autor z jakąkolwiek pracą oraz książka-matka z rozdziałami NIE mogą być
+soft- ani hard-deletowane. Guard liczy przez ``global_objects`` (widzi też
+kaskadowo-skasowane autorstwa) — patrz §3.2 i ryzyko w §9.
+"""
+
+import pytest
+from django.db.models import ProtectedError
+
+from bpp.models import (
+ Autor,
+ Wydawnictwo_Zwarte,
+ Wydawnictwo_Zwarte_Autor,
+)
+
+
+@pytest.mark.django_db
+def test_soft_delete_autora_z_pracami_odmowa(wydawnictwo_ciagle_z_dwoma_autorami):
+ autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor
+
+ with pytest.raises(ProtectedError):
+ autor.delete()
+
+ assert Autor.objects.filter(pk=autor.pk).exists()
+ assert autor.deleted_at is None
+
+
+@pytest.mark.django_db
+def test_guard_autora_widzi_kaskadowo_skasowane_autorstwa(
+ wydawnictwo_ciagle_z_dwoma_autorami,
+):
+ """Krytyczne §3.2: po soft-delete publikacji autorstwa są ukryte
+ w ``objects``, ale guard liczy przez ``global_objects`` — autor nadal
+ chroniony, NIE wygląda na pustego."""
+ autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor
+ wydawnictwo_ciagle_z_dwoma_autorami.delete()
+
+ with pytest.raises(ProtectedError):
+ autor.delete()
+
+ assert Autor.objects.filter(pk=autor.pk).exists()
+```
+
+- [ ] **Step 2: Uruchom — oczekuj PASS**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py -v`
+Expected: PASS. Jeśli `test_guard_autora_widzi_kaskadowo_skasowane_autorstwa`
+FAIL (autor da się skasować) → **luka w fazie 04**: guard używa `objects`
+zamiast `global_objects`. To dokładnie ryzyko z §9. Zadanie naprawcze: w
+`Autor.delete()` (guard) policz autorstwa przez `*_Autor.global_objects`.
+
+- [ ] **Step 3: Napisz failing test — soft-delete autora-husku (bez prac) działa i jest odwracalny**
+
+```python
+@pytest.mark.django_db
+def test_soft_delete_autora_husku_dziala_i_jest_odwracalny():
+ husk = baker_autor_bez_prac()
+
+ husk.delete()
+ assert Autor.objects.filter(pk=husk.pk).count() == 0
+ assert Autor.global_objects.filter(pk=husk.pk).count() == 1
+
+ husk.restore()
+ assert Autor.objects.filter(pk=husk.pk).count() == 1
+
+
+def baker_autor_bez_prac():
+ from model_bakery import baker
+
+ return baker.make(Autor, nazwisko="Husk", imiona="Pusty")
+```
+
+- [ ] **Step 4: Napisz failing test — książka-matka z rozdziałami chroniona**
+
+```python
+@pytest.mark.django_db
+def test_soft_delete_ksiazki_matki_z_rozdzialami_odmowa(jednostka):
+ from model_bakery import baker
+
+ matka = baker.make(Wydawnictwo_Zwarte, tytul_oryginalny="Matka")
+ baker.make(
+ Wydawnictwo_Zwarte,
+ tytul_oryginalny="Rozdzial",
+ wydawnictwo_nadrzedne=matka,
+ )
+
+ with pytest.raises(ProtectedError):
+ matka.delete()
+
+ assert Wydawnictwo_Zwarte.objects.filter(pk=matka.pk).exists()
+ assert matka.deleted_at is None
+```
+
+- [ ] **Step 5: Uruchom całość pliku — oczekuj PASS**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py -v`
+Expected: PASS (4 testy). Jeśli książka-matka się kasuje → luka w fazie 04
+(guard `wydawnictwo_nadrzedne` liczony przez `global_objects`, §2.6).
+
+- [ ] **Step 6: Napisz test — guard przez admina (próba kasowania autora z pracami)**
+
+Dopisz do pliku:
+
+```python
+from django.urls import reverse
+
+
+@pytest.mark.django_db
+def test_admin_nie_soft_deletuje_autora_z_pracami(
+ superuser_client, wydawnictwo_ciagle_z_dwoma_autorami
+):
+ autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor
+ url = reverse("admin:bpp_autor_delete", args=(autor.pk,))
+
+ resp = superuser_client.post(url, {"post": "yes"})
+
+ # Admin nie kasuje (guard) — rekord nadal żywy, nie soft-deletowany.
+ assert Autor.objects.filter(pk=autor.pk).exists()
+ assert Autor.objects.get(pk=autor.pk).deleted_at is None
+ assert resp.status_code in (200, 302)
+```
+
+- [ ] **Step 7: Uruchom — oczekuj PASS**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py -k admin -v`
+Expected: PASS. Jeśli admin twardo kasuje autora z pracami → luka w fazie 07
+(admin musi respektować guard z fazy 04 w `delete_model`/`delete_queryset`).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py
+git commit -m "test(soft-delete): regresja guardów PROTECT (autor, ksiazka-matka, admin)"
+```
+
+---
+
+## Task 4: Regresja SoftDeleteLog — delete/restore/hard-delete z userem
+
+**Files:**
+- Create: `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_log.py`
+
+- [ ] **Step 1: Napisz failing test — delete loguje DELETE z userem, restore RESTORE, hard-delete HARD_DELETE**
+
+```python
+"""Regresja SoftDeleteLog (spec §5).
+
+Każde zdarzenie soft-delete / restore / hard-delete jest logowane przez
+receiver sygnału z odpowiednią akcją; user wstrzykiwany z warstwy admina
+(operacje systemowe → ``user=None``).
+"""
+
+import pytest
+from django.contrib.contenttypes.models import ContentType
+from model_bakery import baker
+
+from bpp.models import Wydawnictwo_Ciagle
+from bpp.models.soft_delete_log import SoftDeleteLog
+
+
+def _logi_dla(obj, akcja):
+ return SoftDeleteLog.objects.filter(
+ content_type=ContentType.objects.get_for_model(type(obj)),
+ object_id=obj.pk,
+ akcja=akcja,
+ )
+
+
+@pytest.mark.django_db
+def test_soft_delete_loguje_delete(admin_user):
+ wc = baker.make(Wydawnictwo_Ciagle)
+
+ wc.delete(user=admin_user, reason="testowy powod")
+
+ log = _logi_dla(wc, SoftDeleteLog.Akcja.DELETE).get()
+ assert log.user == admin_user
+ assert log.powod == "testowy powod"
+
+
+@pytest.mark.django_db
+def test_restore_loguje_restore(admin_user):
+ wc = baker.make(Wydawnictwo_Ciagle)
+ wc.delete(user=admin_user)
+
+ wc.restore(user=admin_user)
+
+ assert _logi_dla(wc, SoftDeleteLog.Akcja.RESTORE).exists()
+
+
+@pytest.mark.django_db
+def test_hard_delete_loguje_hard_delete():
+ wc = baker.make(Wydawnictwo_Ciagle)
+ pk = wc.pk
+
+ wc.hard_delete()
+
+ ct = ContentType.objects.get_for_model(Wydawnictwo_Ciagle)
+ assert SoftDeleteLog.objects.filter(
+ content_type=ct, object_id=pk, akcja=SoftDeleteLog.Akcja.HARD_DELETE
+ ).exists()
+
+
+@pytest.mark.django_db
+def test_operacja_systemowa_loguje_user_none():
+ wc = baker.make(Wydawnictwo_Ciagle)
+
+ wc.delete()
+
+ log = _logi_dla(wc, SoftDeleteLog.Akcja.DELETE).get()
+ assert log.user is None
+```
+
+- [ ] **Step 2: Uruchom — oczekuj PASS**
+
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_log.py -v`
+Expected: 4 PASS. Jeśli `SoftDeleteLog.Akcja` ma inne nazwy enuma niż
+`DELETE/RESTORE/HARD_DELETE` — dostosuj do realnego modelu z fazy 06
+(`src/bpp/models/soft_delete_log.py`; PINNED w overview: `DELETE="delete"`,
+`RESTORE="restore"`, `HARD_DELETE="hard_delete"`). Jeśli sygnatura
+`delete(user=..., reason=...)` nie istnieje → luka w fazie 06 (wstrzykiwanie
+usera, PINNED kontrakt).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/bpp/tests/test_soft_delete/test_soft_delete_regresja_log.py
+git commit -m "test(soft-delete): regresja SoftDeleteLog (delete/restore/hard + user)"
+```
+
+---
+
+## Task 5: Regresja PBN — wycofanie oświadczeń, restore→WYSYLKA, brak duplikatów
+
+**Files:**
+- Create: `src/pbn_integrator/tests/test_soft_delete_regresja.py`
+
+- [ ] **Step 1: Ustal nazwy operacji i fixture publikacji z pbn_uid**
+
+Run: `uv run python src/manage.py shell -c "from pbn_export_queue.models import PBN_Export_Queue as Q; print([f.name for f in Q._meta.fields]); from pbn_export_queue.models import Operacja; print(list(Operacja))"`
+Expected: pole `operacja` z `Operacja.WYSYLKA`/`Operacja.WYCOFANIE` (faza 05).
+Fixture `pbn_wydawnictwo_ciagle_z_autorem_z_dyscyplina` (z `fixtures.pbn_api`,
+użyty w `src/pbn_integrator/tests/test_statements.py`) daje publikację, której
+można nadać `pbn_uid`.
+
+- [ ] **Step 2: Napisz failing test — soft-delete publikacji z pbn_uid kolejkuje WYCOFANIE**
+
+```python
+"""Regresja PBN dla soft-delete (spec §4).
+
+Soft-delete publikacji z ``pbn_uid`` → wpis ``WYCOFANIE`` w
+``pbn_export_queue`` wołający ``delete_all_publication_statements``; restore
+→ ``WYSYLKA``; sync/re-import po soft-delete NIE tworzy duplikatów
+(krytyczne, §9).
+"""
+
+from unittest.mock import MagicMock
+
+import pytest
+from model_bakery import baker
+
+from pbn_api.models import Publication
+from pbn_export_queue.models import Operacja, PBN_Export_Queue
+
+
+@pytest.mark.django_db
+def test_soft_delete_z_pbn_uid_kolejkuje_wycofanie(wydawnictwo_ciagle, admin_user):
+ wydawnictwo_ciagle.pbn_uid = baker.make(Publication, mongoId="pub-wycofanie")
+ wydawnictwo_ciagle.save()
+
+ wydawnictwo_ciagle.delete(user=admin_user)
+
+ assert PBN_Export_Queue.objects.filter(
+ operacja=Operacja.WYCOFANIE
+ ).count() == 1
+
+
+@pytest.mark.django_db
+def test_soft_delete_bez_pbn_uid_nie_kolejkuje_nic(wydawnictwo_ciagle, admin_user):
+ """Gate na pbn_uid — rekord, który nigdy nie poszedł do PBN, nic nie robi."""
+ assert wydawnictwo_ciagle.pbn_uid is None
+
+ wydawnictwo_ciagle.delete(user=admin_user)
+
+ assert PBN_Export_Queue.objects.filter(operacja=Operacja.WYCOFANIE).count() == 0
+
+
+@pytest.mark.django_db
+def test_restore_kolejkuje_wysylke(wydawnictwo_ciagle, admin_user):
+ wydawnictwo_ciagle.pbn_uid = baker.make(Publication, mongoId="pub-restore")
+ wydawnictwo_ciagle.save()
+ wydawnictwo_ciagle.delete(user=admin_user)
+
+ wydawnictwo_ciagle.restore(user=admin_user)
+
+ assert PBN_Export_Queue.objects.filter(
+ operacja=Operacja.WYSYLKA, rekord_do_wysylki_id=wydawnictwo_ciagle.pk
+ ).exists()
+```
+
+- [ ] **Step 3: Uruchom — oczekuj PASS**
+
+Run: `uv run pytest src/pbn_integrator/tests/test_soft_delete_regresja.py -k "kolejkuje or pbn_uid" -v`
+Expected: PASS. Pole `rekord_do_wysylki_id` widoczne w
+`test_pbn_queue_send.py` (`baker.make(PBN_Export_Queue,
+rekord_do_wysylki=wydawnictwo_ciagle, ...)`). Jeśli GFK ma inną nazwę pola
+ustal przez Step 1 i dostosuj filtr.
+
+- [ ] **Step 4: Napisz failing test — wpis WYCOFANIE woła delete_all_publication_statements**
+
+```python
+@pytest.mark.django_db
+def test_wycofanie_wola_delete_all_publication_statements(
+ wydawnictwo_ciagle, admin_user
+):
+ pub = baker.make(Publication, mongoId="pub-call-check")
+ wydawnictwo_ciagle.pbn_uid = pub
+ wydawnictwo_ciagle.save()
+ wydawnictwo_ciagle.delete(user=admin_user)
+
+ qi = PBN_Export_Queue.objects.get(operacja=Operacja.WYCOFANIE)
+
+ fake_client = MagicMock()
+ with pytest.MonkeyPatch().context() as mp:
+ mp.setattr(admin_user, "get_pbn_user", lambda *a, **k: object())
+ mp.setattr(
+ "pbn_export_queue.models.PBN_Export_Queue.get_client",
+ lambda self: fake_client,
+ raising=False,
+ )
+ qi.send_to_pbn()
+
+ fake_client.delete_all_publication_statements.assert_called_once_with(
+ str(pub.mongoId)
+ )
+```
+
+- [ ] **Step 5: Uruchom — oczekuj PASS lub dostosuj punkt wpięcia klienta**
+
+Run: `uv run pytest src/pbn_integrator/tests/test_soft_delete_regresja.py -k delete_all -v`
+Expected: PASS. Jeśli `send_to_pbn` pobiera klienta inaczej niż przez
+`get_client` (sprawdź `src/pbn_export_queue/models.py:350`), zmień mock na
+realny punkt wpięcia (wzorzec z `test_pbn_queue_send.py` — tam mockują
+`admin_user.get_pbn_user` i `model_table_exists`). Argument: klient PBN
+`delete_all_publication_statements(publicationId)`
+(`src/pbn_api/client/mixins/institutions.py:87`).
+
+- [ ] **Step 6: Napisz failing test — re-sync po soft-delete NIE tworzy duplikatu (KRYTYCZNE)**
+
+```python
+@pytest.mark.django_db
+def test_resync_po_soft_delete_nie_tworzy_duplikatu(wydawnictwo_ciagle):
+ """Matching po pbn_uid musi iść przez global_objects (spec §2.5).
+ Inaczej soft-delete = generator duplikatów."""
+ from bpp.models import Wydawnictwo_Ciagle
+
+ pub = baker.make(Publication, mongoId="pub-resync")
+ wydawnictwo_ciagle.pbn_uid = pub
+ wydawnictwo_ciagle.save()
+ wydawnictwo_ciagle.delete()
+
+ # Symulacja matchingu importera/synchronizatora po pbn_uid:
+ znaleziony = Wydawnictwo_Ciagle.global_objects.filter(pbn_uid=pub).first()
+
+ assert znaleziony is not None
+ assert znaleziony.pk == wydawnictwo_ciagle.pk
+ # Domyślny menedżer NIE widzi skasowanej — to właśnie pułapka duplikatu:
+ assert Wydawnictwo_Ciagle.objects.filter(pbn_uid=pub).first() is None
+```
+
+- [ ] **Step 7: Uruchom — oczekuj PASS**
+
+Run: `uv run pytest src/pbn_integrator/tests/test_soft_delete_regresja.py -k resync -v`
+Expected: PASS. Ten test dokumentuje kontrakt kat. B (faza 03). Jeśli realny
+kod synchronizacji (`pbn_integrator/utils/synchronization.py`) używa `objects`
+zamiast `global_objects` przy matchingu po `pbn_uid` → luka w fazie 03,
+zadanie naprawcze: przełącz matching na `global_objects`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/pbn_integrator/tests/test_soft_delete_regresja.py
+git commit -m "test(soft-delete): regresja PBN wycofanie/restore + brak duplikatow sync"
+```
+
+---
+
+## Task 6: Regresja import — re-import matchuje soft-deletowaną publikację
+
+**Files:**
+- Create: `src/import_common/tests/test_soft_delete_regresja.py`
+
+- [ ] **Step 1: Napisz failing test — matchuj_publikacje znajduje soft-deletowaną (global_objects)**
+
+```python
+"""Regresja importu (spec §2.5, §3 fazy).
+
+Re-import soft-deletowanej publikacji MUSI zmatchować istniejący rekord
+przez ``global_objects`` — inaczej powstaje duplikat (pułapka §9).
+"""
+
+import pytest
+from model_bakery import baker
+
+from bpp.models import Wydawnictwo_Ciagle
+from import_common.core import matchuj_publikacje
+
+
+@pytest.mark.django_db
+def test_matchuj_publikacje_matchuje_soft_deletowana(jezyki, typy_kbn):
+ wc = baker.make(
+ Wydawnictwo_Ciagle,
+ tytul_oryginalny="Bardzo specyficzny tytul do matchowania importu",
+ rok=2020,
+ )
+ wc.delete()
+
+ znaleziony = matchuj_publikacje(
+ Wydawnictwo_Ciagle,
+ title="Bardzo specyficzny tytul do matchowania importu",
+ year=2020,
+ )
+
+ assert znaleziony is not None
+ assert znaleziony.pk == wc.pk
+
+
+@pytest.mark.django_db
+def test_reimport_nie_tworzy_duplikatu_soft_deletowanej():
+ wc = baker.make(
+ Wydawnictwo_Ciagle,
+ tytul_oryginalny="Inny unikalny tytul publikacji importowanej",
+ rok=2021,
+ )
+ wc.delete()
+
+ przed = Wydawnictwo_Ciagle.global_objects.count()
+ znaleziony = matchuj_publikacje(
+ Wydawnictwo_Ciagle,
+ title="Inny unikalny tytul publikacji importowanej",
+ year=2021,
+ )
+ # Jeśli matchuje, importer nie utworzy nowego — liczba global bez zmian.
+ assert znaleziony is not None
+ assert Wydawnictwo_Ciagle.global_objects.count() == przed
+```
+
+- [ ] **Step 2: Uruchom — oczekuj PASS**
+
+Run: `uv run pytest src/import_common/tests/test_soft_delete_regresja.py -v`
+Expected: PASS. Jeśli `matchuj_publikacje` zwraca `None` dla skasowanej →
+**luka w fazie 03**: `import_common/core/publikacja.py` (helpery
+`_try_match_pub_by_*`) muszą szukać przez `klass.global_objects`, nie
+`klass.objects`. Zadanie naprawcze: przełącz menedżer w funkcjach matchujących.
+Jeśli fixture `typy_kbn` nie istnieje, usuń go z sygnatury — `baker.make`
+dociągnie wymagane FK; sprawdź `uv run pytest --fixtures -k typy 2>/dev/null`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/import_common/tests/test_soft_delete_regresja.py
+git commit -m "test(soft-delete): regresja importu matchuje soft-deletowana (bez duplikatow)"
+```
+
+---
+
+## Task 7: Regresja ewaluacja — pomija prace w koszu, restore przywraca punktację
+
+**Files:**
+- Create: `src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py`
+
+- [ ] **Step 1: Ustal realny punkt wejścia ewaluacji liczący *_Autor**
+
+Run: `grep -rln "Wydawnictwo_Ciagle_Autor.objects\|Wydawnictwo_Zwarte_Autor.objects" src/ewaluacja_optymalizacja/ | head`
+Run: `grep -rn "def reset_pins\|def unpin_all\|def author_works" src/ewaluacja_optymalizacja/ | head`
+Expected: ustal funkcję, która zlicza/iteruje autorstwa (spec §2.5 wymienia
+`reset_pins`, `unpin_all_sensible`, `author_works`). Test ma dowieść, że po
+soft-delete publikacji jej autorstwa znikają z liczenia (bo `*_Autor.objects`
+je ukrywa).
+
+- [ ] **Step 2: Napisz failing test — autorstwa skasowanej pracy znikają z liczenia**
+
+```python
+"""Regresja ewaluacji (spec §2.5, §3 fazy).
+
+90 miejsc czyta ``*_Autor.objects`` bezpośrednio; po wpięciu
+``SoftDeleteModel`` domyślny menedżer ukrywa kaskadowo-skasowane
+autorstwa, więc ewaluacja pomija prace w koszu. Restore przywraca punktację.
+"""
+
+import pytest
+
+from bpp.models import Wydawnictwo_Ciagle_Autor
+
+
+@pytest.mark.django_db
+def test_ewaluacja_pomija_autorstwa_pracy_w_koszu(
+ wydawnictwo_ciagle_z_dwoma_autorami,
+):
+ autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor
+
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor).count() == 1
+
+ wydawnictwo_ciagle_z_dwoma_autorami.delete()
+
+ # Domyślny menedżer (używany przez ewaluację) NIE widzi skasowanych:
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor).count() == 0
+ # global_objects nadal je trzyma (odwracalność):
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(autor=autor).count() == 1
+
+
+@pytest.mark.django_db
+def test_restore_przywraca_autorstwa_do_ewaluacji(
+ wydawnictwo_ciagle_z_dwoma_autorami,
+):
+ autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor
+ wydawnictwo_ciagle_z_dwoma_autorami.delete()
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor).count() == 0
+
+ wydawnictwo_ciagle_z_dwoma_autorami.restore()
+
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor).count() == 1
+```
+
+- [ ] **Step 3: Uruchom — oczekuj PASS**
+
+Run: `uv run pytest src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py -v`
+Expected: PASS. Jeśli skasowane autorstwa nadal są liczone przez `objects` →
+**luka w fazie 01/02**: `*_Autor` nie jest poprawnie `SoftDeleteModel` albo
+override `delete()` nie kaskaduje na `*_Autor`. To ryzyko „silent leak"
+z §1/§9.
+
+- [ ] **Step 4: Napisz test integracyjny — unpinning nie liczy skasowanej pracy**
+
+Dopisz (dostosuj nazwę funkcji do ustalonej w Step 1; przykład z
+`unpinning_opportunities` widocznym w
+`src/ewaluacja_optymalizacja/tests/test_unpinning_opportunities.py`):
+
+```python
+@pytest.mark.django_db
+def test_unpinning_nie_uwzglednia_pracy_w_koszu(
+ wydawnictwo_ciagle_z_dwoma_autorami, denorms
+):
+ """Soft-deletowana praca nie pojawia się wśród kandydatów ewaluacji,
+ bo Rekord/Autorzy ją odfiltrowuje (spec §2.1) i *_Autor.objects ukrywa."""
+ from bpp.models.cache import Rekord
+
+ denorms.flush()
+ autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor
+ assert Rekord.objects.prace_autora(autor).count() == 1
+
+ wydawnictwo_ciagle_z_dwoma_autorami.delete()
+ denorms.flush()
+
+ assert Rekord.objects.prace_autora(autor).count() == 0
+```
+
+- [ ] **Step 5: Uruchom — oczekuj PASS**
+
+Run: `uv run pytest src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py -k unpinning -v`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py
+git commit -m "test(soft-delete): regresja ewaluacji pomija prace w koszu, restore przywraca"
+```
+
+---
+
+## Task 8: Regresja merge autorów — husk soft-deletowany i odwracalny
+
+**Files:**
+- Create: `src/deduplikator_autorow/tests/test_soft_delete_regresja.py`
+
+- [ ] **Step 1: Ustal funkcję merge i widok**
+
+Run: `grep -n "def " src/deduplikator_autorow/utils/merge.py | head`
+Run: `grep -n "\.delete()" src/deduplikator_autorow/views/merge.py`
+Expected: merge przenosi wszystkie typy prac, potem woła `autor.delete()` na
+pustym duplikacie (`views/merge.py:155`). Po fazie 04 husk staje się soft-
+deletowany (odwracalny). PROTECT nie psuje merge, bo duplikat jest już pusty.
+
+- [ ] **Step 2: Napisz failing test — po scaleniu duplikat soft-deletowany i odwracalny**
+
+```python
+"""Regresja merge autorów (spec §3.3).
+
+Po scaleniu husk duplikata jest soft-deletowany (odwracalny, nie znika
+bezpowrotnie). PROTECT nie psuje merge (duplikat jest pusty w chwili
+delete). Patrz §3.3, §9 (ryzyko: merge musi przenieść WSZYSTKIE typy prac).
+"""
+
+import pytest
+from model_bakery import baker
+
+from bpp.models import Autor
+
+
+@pytest.mark.django_db
+def test_husk_duplikata_jest_soft_deletowany_i_odwracalny():
+ """Pusty duplikat usunięty w merge → soft-delete, da się przywrócić."""
+ duplikat = baker.make(Autor, nazwisko="Kowalski", imiona="Jan")
+
+ # Husk bez prac — usuwany jak w merge (utils/merge.py kończy delete()).
+ duplikat.delete()
+
+ assert Autor.objects.filter(pk=duplikat.pk).count() == 0
+ assert Autor.global_objects.filter(pk=duplikat.pk).count() == 1
+
+ duplikat.restore()
+ assert Autor.objects.filter(pk=duplikat.pk).count() == 1
+```
+
+- [ ] **Step 3: Napisz test E2E przez widok merge (jeśli istnieje endpoint scalania)**
+
+Run: `grep -rn "def merge\|name=\"" src/deduplikator_autorow/urls.py | head`
+Dopisz test wołający realny przepływ scalania, jeśli jest dostępny URL.
+Wzorzec z `src/deduplikator_autorow/tests/test_scal_view.py`. Jeśli przepływ
+jest złożony (wymaga `DuplicateScanRun` itd.), wywołaj bezpośrednio funkcję
+z `utils/merge.py`:
+
+```python
+@pytest.mark.django_db
+def test_merge_przenosi_prace_i_soft_deletuje_duplikat(
+ wydawnictwo_ciagle_z_dwoma_autorami, admin_user
+):
+ from deduplikator_autorow.utils.merge import merge_authors
+
+ autorzy = list(
+ a.autor for a in wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.all()
+ )
+ glowny, duplikat = autorzy[0], autorzy[1]
+
+ # Przenieś prace duplikata na głównego + usuń husk:
+ merge_authors(glowny, duplikat, user=admin_user)
+
+ # Duplikat soft-deletowany (odwracalny), główny żyje:
+ assert Autor.objects.filter(pk=glowny.pk).exists()
+ assert Autor.objects.filter(pk=duplikat.pk).count() == 0
+ assert Autor.global_objects.filter(pk=duplikat.pk).count() == 1
+```
+
+- [ ] **Step 4: Uruchom — oczekuj PASS, dostosuj sygnaturę merge**
+
+Run: `uv run pytest src/deduplikator_autorow/tests/test_soft_delete_regresja.py -v`
+Expected: PASS. Sygnatura `merge_authors` może się różnić — ustal przez Step 1
+(`grep -n "def merge" src/deduplikator_autorow/utils/merge.py`) i dostosuj
+wywołanie. Jeśli merge NIE przenosi któregoś typu prac przed `delete()` →
+guard/PROTECT zablokuje usunięcie husku → test FAIL z `ProtectedError`. To
+dokładnie ryzyko z §3.3/§9 — zadanie naprawcze: uzupełnij transfer brakującego
+typu w `utils/merge.py` (ciągłe/zwarte/patent/doktorat/habilitacja).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/deduplikator_autorow/tests/test_soft_delete_regresja.py
+git commit -m "test(soft-delete): regresja merge autorow (husk odwracalny, PROTECT nie psuje)"
+```
+
+---
+
+## Task 9: Regresja API — skasowane rekordy/autorstwa nie wyciekają
+
+**Files:**
+- Create: `src/api_v1/tests/test_soft_delete_regresja.py`
+
+- [ ] **Step 1: Napisz failing test — soft-deletowana publikacja nie wyciekła w API list/detail**
+
+```python
+"""Regresja API v1 (spec §2.5 kat. A — wyświetlanie/eksport).
+
+Skasowane publikacje i autorstwa NIE mogą wyciekać przez REST API.
+Domyślny menedżer ``objects`` (używany przez viewsety) je ukrywa.
+"""
+
+import pytest
+from django.urls import reverse
+
+
+@pytest.mark.django_db
+def test_api_list_pomija_soft_deletowana_publikacje(api_client, wydawnictwo_ciagle):
+ res = api_client.get(reverse("api_v1:wydawnictwo_ciagle-list"))
+ assert res.json()["count"] == 1
+
+ wydawnictwo_ciagle.delete()
+
+ res = api_client.get(reverse("api_v1:wydawnictwo_ciagle-list"))
+ assert res.json()["count"] == 0
+
+
+@pytest.mark.django_db
+def test_api_detail_soft_deletowanej_daje_404(client, wydawnictwo_ciagle):
+ pk = wydawnictwo_ciagle.pk
+ url = reverse("api_v1:wydawnictwo_ciagle-detail", args=(pk,))
+ assert client.get(url).status_code == 200
+
+ wydawnictwo_ciagle.delete()
+
+ assert client.get(url).status_code == 404
+
+
+@pytest.mark.django_db
+def test_api_rekord_pomija_soft_deletowana(api_client, wydawnictwo_ciagle, denorms):
+ denorms.flush()
+ res = api_client.get(reverse("api_v1:rekord-list"))
+ assert res.json()["count"] == 1
+
+ wydawnictwo_ciagle.delete()
+ denorms.flush()
+
+ res = api_client.get(reverse("api_v1:rekord-list"))
+ assert res.json()["count"] == 0
+```
+
+- [ ] **Step 2: Uruchom — oczekuj PASS (dostosuj nazwę route rekord)**
+
+Run: `uv run pytest src/api_v1/tests/test_soft_delete_regresja.py -v`
+Expected: PASS. Nazwę route sprawdź: `grep -rn "rekord" src/api_v1/urls.py
+src/api_v1/viewsets/*.py | grep -i basename`. Jeśli route nazywa się inaczej
+(np. `api_v1:rekord_mat-list`), dostosuj `reverse`. Jeśli list/detail nadal
+zwraca skasowaną → viewset używa `global_objects`/surowego querysetu zamiast
+`objects` (kat. A) — luka, zadanie naprawcze: w
+`src/api_v1/viewsets/` ustaw `queryset = Model.objects.all()`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/api_v1/tests/test_soft_delete_regresja.py
+git commit -m "test(soft-delete): regresja API nie wycieka skasowanych rekordow"
+```
+
+---
+
+## Task 10: Regresja dashboard — liczniki pomijają skasowane
+
+**Files:**
+- Create: `src/admin_dashboard/tests/test_soft_delete_regresja.py`
+- Modify (jeśli test ujawni lukę): `src/admin_dashboard/views/charakter_stats.py`
+
+- [ ] **Step 1: Sprawdź, jak dashboard liczy publikacje**
+
+Run: `grep -rn "objects\|raw\|connection.cursor\|Count" src/admin_dashboard/views/charakter_stats.py`
+Expected: `_get_charakter_counts` (`charakter_stats.py:41`). Jeśli używa
+`Wydawnictwo_Ciagle.objects` — po fazie 02 automatycznie pomija skasowane
+(kat. A czysta). Jeśli używa surowego SQL na `bpp_wydawnictwo_ciagle` z
+`connection.cursor()` — omija menedżer i policzy skasowane → luka do naprawy.
+
+- [ ] **Step 2: Napisz failing test — soft-delete zmniejsza licznik charakteru**
+
+```python
+"""Regresja dashboardu (spec §2.5 kat. A).
+
+Liczniki/statystyki MUSZĄ pomijać skasowane publikacje.
+"""
+
+import pytest
+from model_bakery import baker
+
+from admin_dashboard.views.charakter_stats import _get_charakter_counts
+from bpp.models import Charakter_Formalny, Wydawnictwo_Ciagle
+
+
+@pytest.mark.django_db
+def test_get_charakter_counts_pomija_soft_deletowana():
+ cf = baker.make(Charakter_Formalny, nazwa="Artykul", skrot="AR")
+ baker.make(Wydawnictwo_Ciagle, charakter_formalny=cf)
+ wc2 = baker.make(Wydawnictwo_Ciagle, charakter_formalny=cf)
+
+ przed = _licznik_dla(cf)
+ assert przed == 2
+
+ wc2.delete()
+
+ assert _licznik_dla(cf) == 1
+
+
+def _licznik_dla(cf):
+ total = 0
+ for row in _get_charakter_counts():
+ # row: (nazwa, count, skrot, id, ciagle_count, zwarte_count)
+ if row[2] == cf.skrot:
+ total += row[4] # ciagle_count
+ return total
+```
+
+- [ ] **Step 3: Uruchom — oczekuj PASS lub napraw produkcyjnie**
+
+Run: `uv run pytest src/admin_dashboard/tests/test_soft_delete_regresja.py -v`
+Expected: PASS, jeśli `_get_charakter_counts` liczy przez `.objects`. Jeśli
+FAIL (licznik nadal 2) → poprawka produkcyjna: w `charakter_stats.py`
+zamień surowy SQL/agregację na ORM przez menedżer `objects`, np.:
+
+```python
+from django.db.models import Count
+
+ciagle_counts = dict(
+ Wydawnictwo_Ciagle.objects.values("charakter_formalny")
+ .annotate(c=Count("id"))
+ .values_list("charakter_formalny", "c")
+)
+```
+
+albo (jeśli zostaje surowy SQL) dopisz `WHERE deleted_at IS NULL` do zapytania.
+Dostosuj indeksy w `_licznik_dla` do realnego kształtu krotki zwracanej przez
+`_get_charakter_counts` (sprawdź docstring funkcji w
+`charakter_stats.py:41`).
+
+- [ ] **Step 4: Napisz test widoku database_stats (rozkład typów pomija skasowane)**
+
+```python
+import json
+
+from django.urls import reverse
+
+
+@pytest.mark.django_db
+def test_database_stats_rozklad_typow_pomija_skasowane(client, staff_user):
+ cf = baker.make(Charakter_Formalny, nazwa="Ksiazka", skrot="KS")
+ baker.make(Wydawnictwo_Ciagle, charakter_formalny=cf)
+ wc = baker.make(Wydawnictwo_Ciagle, charakter_formalny=cf)
+ wc.delete()
+
+ client.force_login(staff_user)
+ res = client.get(reverse("admin_dashboard:database_stats"))
+ data = json.loads(res.content)
+
+ ciagle = {
+ e["charakter_formalny__nazwa"]: e["count"]
+ for e in data["type_distribution"]["ciagle"]
+ }
+ assert ciagle.get("Ksiazka") == 1
+```
+
+- [ ] **Step 5: Uruchom — oczekuj PASS**
+
+Run: `uv run pytest src/admin_dashboard/tests/test_soft_delete_regresja.py -k database_stats -v`
+Expected: PASS (widok `database_stats` w `views/base.py:51` liczy przez
+`Wydawnictwo_Ciagle.objects` → automatycznie czysty). Nazwę URL i klucze JSON
+sprawdź: `grep -rn "database_stats\|type_distribution" src/admin_dashboard/`.
+Jeśli fixture `staff_user` nie istnieje, użyj `admin_user` (superuser też
+spełnia `staff_member_required`).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/admin_dashboard/tests/test_soft_delete_regresja.py
+git add src/admin_dashboard/views/charakter_stats.py 2>/dev/null || true
+git commit -m "test(soft-delete): regresja dashboard liczniki pomijaja skasowane"
+```
+
+---
+
+## Task 11: Pełna suita + ruff
+
+**Files:** (brak nowych — gate jakości)
+
+- [ ] **Step 1: Lint i format na wszystkich nowych plikach**
+
+Run: `ruff format src/bpp/tests/test_soft_delete/ src/pbn_integrator/tests/test_soft_delete_regresja.py src/import_common/tests/test_soft_delete_regresja.py src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py src/deduplikator_autorow/tests/test_soft_delete_regresja.py src/api_v1/tests/test_soft_delete_regresja.py src/admin_dashboard/tests/test_soft_delete_regresja.py`
+Run: `ruff check src/bpp/tests/test_soft_delete/ src/pbn_integrator/tests/test_soft_delete_regresja.py src/import_common/tests/test_soft_delete_regresja.py src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py src/deduplikator_autorow/tests/test_soft_delete_regresja.py src/api_v1/tests/test_soft_delete_regresja.py src/admin_dashboard/tests/test_soft_delete_regresja.py`
+Expected: brak błędów (linie ≤88). Napraw ręcznie (Edit), NIE `--fix`.
+
+- [ ] **Step 2: Uruchom całą regresję soft-delete**
+
+Run: `uv run pytest -k soft_delete_regresja -v`
+Expected: wszystkie PASS. Każdy FAIL przeanalizuj wg odpowiadającego Tasku —
+część FAIL-i to luki w fazach 01–07 (opisane w krokach „oczekuj PASS").
+
+- [ ] **Step 3: Uruchom pełną suitę bez Playwrighta (regresja całego systemu)**
+
+Run: `make tests-without-playwright`
+Expected: zielono. To gwarantuje, że wpięcie `SoftDeleteModel` nie zepsuło
+istniejących testów cache/PBN/import/ewaluacja/API/dashboard. Jeśli istniejące
+testy padają — to regresja wprowadzona w fazach 01–07; zlokalizuj i napraw
+w odpowiedniej fazie.
+
+- [ ] **Step 4: Uruchom pełną suitę (do ~10 min, łącznie z Playwright)**
+
+Run: `uv run pytest`
+Expected: zielono (timeout ≥600000 ms). Po zieleni — gotowe.
+
+- [ ] **Step 5: Commit (jeśli były poprawki formatowania)**
+
+```bash
+git add -A
+git commit -m "chore(soft-delete): ruff format/check suity regresji"
+```
+
+---
+
+## Self-Review (wykonane przy pisaniu planu)
+
+**Spec coverage (§8 pkt 8 + §9):**
+- PBN duplikaty + wycofanie + restore → Task 5. ✅
+- Cache/ewaluacja (Rekord/Autorzy/Cache_Punktacja, verify_cache, pinning) →
+ Task 1, 2, 7. ✅
+- Import bez duplikatów → Task 6. ✅
+- Merge autorów (husk odwracalny, PROTECT) → Task 8. ✅
+- API nie wycieka → Task 9. ✅
+- Dashboard liczniki → Task 10. ✅
+- Guardy (autor z pracami, książka-matka, przez admin) → Task 3. ✅
+- SoftDeleteLog (delete/restore/hard + user) → Task 4. ✅
+
+**Type/nazewnictwo:** `global_objects`/`objects`/`deleted_objects`,
+`Operacja.WYSYLKA`/`WYCOFANIE`, `SoftDeleteLog.Akcja.{DELETE,RESTORE,
+HARD_DELETE}`, sygnatura `delete(user=, reason=)` — zgodne z PINNED w
+overview (00). Fixture `wydawnictwo_ciagle_z_dwoma_autorami`, `denorms`,
+`api_client`, `admin_user`, `zwarte_z_dyscyplinami`,
+`pbn_wydawnictwo_ciagle_z_autorem_z_dyscyplina` — zweryfikowane w
+`src/conftest.py` / `src/fixtures/` / istniejących testach.
From 66b2f4c8daa5ac341d4d85e03065d9f77ba50f2a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 4 Jun 2026 11:25:01 +0200
Subject: [PATCH 07/75] =?UTF-8?q?docs(soft-delete):=20korekty=20spec?=
=?UTF-8?q?=E2=86=94kod=20wykryte=20przy=20rozpisywaniu=20plan=C3=B3w?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Weryfikacja w realnym kodzie (przez agentów rozpisujących fazy) ujawniła:
- C1: aktualny trigger to 0399_fix_refresh_cache_upsert.sql (upsert ON CONFLICT
+ advisory locks, DELETE-przed-upsertem bezwarunkowy), nie baseline 0001;
+ nota o utajonym bugu (string in lista-krotek) do poprawy w fazie 01,
- C2: verify_cache.py to martwy stub (NotImplementedError + hardcoded host) —
spójność weryfikujemy przez Rekord.objects.full_refresh(), nie verify_cache,
- C3: slug to pole @denormalized (django-denorm-iplweb), nie proste unique=True
— zdjąć unique z kwargs denorm + UniqueConstraint w Meced konkretnych klas,
- C5: PBN_Export_Queue.zamowil jest NOT NULL → konto techniczne dla
zakolejkowań systemowych (nie nullable),
- reconcyliacja API thread-local: kanoniczne soft_delete_context(user,reason)
+ get_soft_delete_user/reason (faza 06 tworzy, 07 używa).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2026-06-04-soft-delete-00-overview.md | 8 ++-
...soft-delete-publikacje-i-autorzy-design.md | 63 ++++++++++++-------
2 files changed, 47 insertions(+), 24 deletions(-)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
index a0bd5ce23..4bfb2559f 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
@@ -82,10 +82,12 @@ Pola PINNED: `content_type` (FK ContentType), `object_id` (PositiveIntegerField,
- Nowe pole na `PBN_Export_Queue`: `operacja = models.CharField(choices=Operacja.choices, default=Operacja.WYSYLKA)` gdzie `class Operacja(models.TextChoices): WYSYLKA="wysylka"; WYCOFANIE="wycofanie"`.
- Gałąź w logice wysyłki: `WYCOFANIE` → `client.delete_all_publication_statements(pbn_uid)` (`src/pbn_api/client/mixins/institutions.py:87`).
-### Wstrzykiwanie `user` (PINNED, faza 06/07)
+### Wstrzykiwanie `user` (PINNED — API thread-local, faza 06 tworzy, 07 używa)
- Override sygnatury: `delete(self, *args, user=None, reason="", **kwargs)` i `restore(self, *args, user=None, **kwargs)`.
-- W adminie **jeden hook** (`delete_model`/`delete_queryset`/akcja „Przywróć") ustawia usera; ten sam moment ma w przyszłości zasilić `reversion.set_user` (patrz „Kontrakty z reversion").
-- Sygnał nie niesie usera → przekazujemy go do receiverów przez argument akcji / thread-local ustawiony w adminie. Operacje systemowe (merge, celery): `user=None`.
+- **Kanoniczne API (faza 06, `src/bpp/models/soft_delete_context.py`):** context manager `soft_delete_context(user=None, reason="")` (thread-local, reentrant — wąska kaskada `*_Autor` dziedziczy kontekst rodzica) + akcesory `get_soft_delete_user()` / `get_soft_delete_reason()`. Receivery sygnałów czytają akcesory (sygnał nie niesie usera).
+- **Faza 07 (admin) używa `soft_delete_context`** — jeden hook (`delete_model`/`delete_queryset`/akcja „Przywróć") owija operację w `with soft_delete_context(user=request.user, reason=...):`. Ten sam moment ma w przyszłości zasilić `reversion.set_user` (patrz „Kontrakty z reversion"). NIE wymyślać osobnego `set/get/clear_soft_delete_user` — używać `soft_delete_context`.
+- **`zamowil` w `pbn_export_queue` jest NOT NULL** — gdy `user is None` (operacje systemowe/celery), zakolejkowanie używa konta technicznego (`get_or_create`), nie `None`.
+- Operacje systemowe (merge autora, celery): `user=None` w `SoftDeleteLog` (FK nullable), konto techniczne tylko dla `zamowil` kolejki.
### Punkty zaczepienia w istniejącym kodzie (zweryfikowane)
- Rejestracja sygnałów: `src/bpp/apps.py` → `BppConfig.ready()` (linia 8).
diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
index c8f8131f6..8c6934185 100644
--- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
+++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
@@ -82,16 +82,22 @@ publikacji przed usunięciem czyni kasowanie bezużytecznym. Wąska semantyka
### 2.1 Trigger jako choke-point (najwrażliwszy, robiony PIERWSZY)
`Rekord` to UNION-view nad materializowaną tabelą `bpp_rekord_mat`, zasilaną
-triggerem `bpp_refresh_cache()`
-(**baseline: `src/bpp/migrations/0001_cache_functions.sql`** — uwaga: stary
-spec referował przed-squashowy `107_cache_functions.sql`). Z `bpp_rekord_mat`
-/ `bpp_autorzy_mat` czyta większość systemu (publiczny frontend, multiseek,
-global search, ewaluacja `Cache_Punktacja_*`, raporty).
-
-**Fakt z kodu** (`0001_cache_functions.sql:78-87`): na `DELETE` trigger usuwa
-wiersze z tabel `_mat`; na `UPDATE/INSERT` re-insertuje. Soft-delete to
-technicznie `UPDATE` → **bez zmiany triggera skasowany rekord wróciłby do
-mat-view**.
+triggerem `bpp_refresh_cache()`. **Aktualna wersja funkcji to
+`src/bpp/migrations/0399_fix_refresh_cache_upsert.sql`** (NIE baseline
+`0001_cache_functions.sql` — historyczny; funkcja ewoluowała przez `0112`,
+`0310`, `0387`, `0399`, + `0400_drop/restore_cache_triggers`). Z
+`bpp_rekord_mat`/`bpp_autorzy_mat` czyta większość systemu (publiczny frontend,
+multiseek, global search, ewaluacja `Cache_Punktacja_*`, raporty).
+
+**Fakt z kodu** (`0399_fix_refresh_cache_upsert.sql`): na `DELETE` trigger usuwa
+wiersze z `_mat`; na `UPDATE/INSERT` robi **`DELETE` + upsert**
+(`INSERT ... SELECT FROM ... ON CONFLICT DO UPDATE`, pod
+`pg_advisory_xact_lock`). `DELETE` przed upsertem jest **bezwarunkowy** (linie
+125-126), więc gdy widok źródłowy odfiltruje skasowane — upsert nic nie
+re-insertuje. Soft-delete to technicznie `UPDATE` → **bez filtra w widoku
+skasowany rekord wróciłby** (upsert wstawiłby go ponownie). (Uwaga: `0399` ma
+drobny utajony bug — `"bpp_autorzy_mat" not in refresh_tables` sprawdza string
+w liście krotek `(table, id_col)`; do poprawienia przy okazji, faza 01.)
**Zmiana:** ścieżka `UPDATE/INSERT` triggera uczona reguły:
> jeśli `TD['new']['deleted_at'] IS NOT NULL` → zachowaj się jak `DELETE`
@@ -114,13 +120,17 @@ Skutki:
(`Rekord` czyta `bpp_rekord`, `src/bpp/models/cache/rekord.py:357`), oraz
pełną re-projekcję/weryfikację cache.
- **Mechanizm #2 (optymalizacja) — trigger-skip:** w gałęzi `UPDATE/INSERT`
- `if TD['new'].get('deleted_at') is not None: `. Oszczędza
- no-op SELECT/INSERT, ale **sam nie wystarcza** (nie pokrywa odczytu z widoku
- ani `verify_cache`). Filtr widoku (#1) jest obowiązkowy; trigger-skip
- opcjonalny.
-- **`verify_cache` (`src/bpp/management/commands/verify_cache.py`)** porównuje
- `bpp_rekord_mat` ze źródłem — MUSI respektować `deleted_at` (przez filtr #1),
- inaczej zgłosi fałszywy rozjazd dla skasowanych i spróbuje je wskrzesić.
+ `if TD['new'].get('deleted_at') is not None: ` (DELETE i tak
+ już zaszedł). Oszczędza no-op SELECT/upsert, ale **sam nie pokrywa pełnej
+ re-projekcji** (ta re-selektuje z widoku). Filtr widoku (#1) jest
+ obowiązkowy; trigger-skip opcjonalny.
+- **Weryfikacja spójności — `Rekord.objects.full_refresh()`** (re-projekcja
+ `_mat` ze źródła), NIE `verify_cache`. ⚠️ `src/bpp/management/commands/verify_cache.py`
+ to **martwy stub** (`psycopg2.connect(database="b_med", host="linux-dev")` +
+ `raise NotImplementedError`) — nie da się go uruchomić; jego naprawa jest
+ POZA zakresem soft-delete. Testy spójności robią pełen `full_refresh` i
+ sprawdzają, że skasowane rekordy NIE wracają do `_mat` (to weryfikuje filtr
+ widoku #1).
- **Przypadek brzegowy znika strukturalnie:** edycja wiersza autorstwa
skasowanej publikacji nie wskrzesi go w `bpp_autorzy_mat`, bo widok
źródłowy go odfiltruje (ma własne `deleted_at` ustawione kaskadą).
@@ -128,8 +138,8 @@ Skutki:
triggerów through. Pomijalne.
Testy spójności mat-view obowiązkowe (soft-delete → znika z `Rekord` i
-`Autorzy`; restore → wraca; `verify_cache` czysty po soft-delete; brak
-rozjazdu `Cache_Punktacja_*`).
+`Autorzy`; restore → wraca; po `full_refresh()` skasowane NIE wracają do `_mat`;
+brak rozjazdu `Cache_Punktacja_*`).
### 2.2 Override `delete()` — wąska, kontrolowana kaskada na `*_Autor`
@@ -165,8 +175,12 @@ Zweryfikować, że nadpisany `delete()`/`restore()` nadal emituje sygnały
### 2.3 `slug` — warunkowy unique
-Wszystkie 5 modeli ma denormalizowany `slug unique=True`. Skasowany rekord
-trzyma slug → konflikt przy ponownym utworzeniu. Zamiana na
+⚠️ `slug` to **pole denormalizowane** (`@denormalized(models.SlugField, ...,
+unique=True, ...)` z `django-denorm-iplweb`), zadeklarowane w 4 miejscach:
+`wydawnictwo_ciagle.py:246`, `wydawnictwo_zwarte.py:325`, `patent.py:180`,
+`Praca_Doktorska_Baza:105` (dzielone przez doktorat i habilitację). Skasowany
+rekord trzyma slug → konflikt przy ponownym utworzeniu. Zmiana: **zdjąć
+`unique=True` z kwargs denorm** i dodać w `Meta` każdej konkretnej klasy
`UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True))`.
Migracja (NIE modyfikować istniejących migracji).
@@ -324,6 +338,13 @@ Rozszerzenie:
- status zapisywany jak dla wysyłki (`zakonczono_pomyslnie`, `komunikat`,
`ilosc_prob`) + odzwierciedlenie w `SentData` i `SoftDeleteLog`.
+⚠️ **`PBN_Export_Queue.zamowil` jest NOT NULL (`on_delete=CASCADE`).**
+Zakolejkowanie z admina ma `request.user`. Ale zakolejkowanie inicjowane
+sygnałem przy soft-delete bez usera (operacje programistyczne / celery) nie ma
+kogo wpisać. Rozwiązanie (faza 05/06): **konto techniczne** (np.
+`get_or_create` systemowego użytkownika) jako `zamowil` dla operacji
+systemowych — NIE robić `zamowil` nullable (psułoby istniejące zał. kolejki).
+
`SentData` (`src/pbn_api/models/sentdata.py`, GFK + `pbn_uid` +
`submitted_successfully` + `mark_as_successful`/`mark_as_failed`) trzyma stan
PBN per-rekord. **Po udanym wycofaniu:** ustawiamy `submitted_successfully =
From 408c4424bf9b190777e5ae08bf85084948bde315 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 4 Jun 2026 11:30:45 +0200
Subject: [PATCH 08/75] =?UTF-8?q?docs(soft-delete):=20koordynacja=20z=20r?=
=?UTF-8?q?=C3=B3wnoleg=C5=82=C4=85=20optymalizacj=C4=85=20triggera?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Funkcja bpp_refresh_cache optymalizowana w osobnej gałęzi (prace użytkownika).
Faza 01 = BLOKER do czasu wskazania tej gałęzi i aktualizacji feat/soft-delete.
Ortogonalność: faza 01 dotyka widoków źródłowych (filtr deleted_at), optymalizacja
dotyka funkcji triggera. Zapisany inwariant do weryfikacji: bezwarunkowy DELETE
przed re-insertem/upsertem (na nim wisi wystarczalność filtra widoku).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2026-06-04-soft-delete-00-overview.md | 24 ++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
index 4bfb2559f..6cace0329 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
@@ -16,7 +16,7 @@
| # | Plik | Zakres | Zależy od |
|---|---|---|---|
-| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; filtr `deleted_at` w widokach źródłowych; trigger-skip; testy spójności cache + `verify_cache` | — |
+| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; **filtr `deleted_at` w widokach źródłowych (mechanizm nadrzędny)**; trigger-skip OPCJONALNY; spójność przez `full_refresh()`. ⚠️ patrz „Koordynacja: trigger" niżej | — / trigger ⚠️ |
| 02 | `2026-06-04-soft-delete-02-publikacje.md` | 5 modeli → SoftDeleteModel; override `delete()`/`restore()` z wąską kaskadą na `*_Autor`; `slug` warunkowy unique; przeplecenie menedżerów | 01 |
| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 90 miejsc `*_Autor.objects` | 02 |
| 04 | `2026-06-04-soft-delete-04-guardy-protect.md` | flip FK `CASCADE→PROTECT` (autor, doktorat, `wydawnictwo_nadrzedne`); guard w soft `delete()` (autor + książka-matka); soft-delete husku autora | 02 |
@@ -27,6 +27,28 @@
---
+## ⚠️ Koordynacja: trigger `bpp_refresh_cache` (BLOKER fazy 01)
+
+Funkcja `bpp_refresh_cache()` jest **równolegle optymalizowana w osobnej gałęzi**
+(prace użytkownika). **Faza 01 NIE startuje, dopóki ta gałąź nie wyląduje** i
+`feat/soft-delete` nie zostanie na nią zaktualizowana (rebase na `dev` lub
+merge gałęzi optymalizacji).
+
+Ortogonalność: faza 01 dla POPRAWNOŚCI dotyka **widoków źródłowych**
+(`bpp_rekord`, `bpp_*_autorzy` — filtr `deleted_at IS NULL`), a optymalizacja
+dotyka **funkcji triggera** — w dużej mierze rozłączne. Modyfikacja funkcji
+triggera w fazie 01 jest **opcjonalna** (trigger-skip) i nakładana CIENKO na
+wierzch zoptymalizowanej funkcji, jeśli w ogóle.
+
+**Inwariant, który MUSI przetrwać optymalizację** (inaczej filtr widoku przestaje
+wystarczać i wraca konieczność trigger-skip): na `UPDATE/INSERT` trigger robi
+**bezwarunkowy `DELETE` z `_mat` przed re-insertem/upsertem** — tzn. nie
+re-insertuje wiersza, którego źródłowy widok nie zwraca. Po wskazaniu gałęzi:
+zweryfikować ten inwariant + czy optymalizacja rusza widoki + status utajonego
+buga (string w liście krotek `(table, id_col)`).
+
+---
+
## Wspólne kontrakty (PINNED — wszystkie fazy używają tych nazw VERBATIM)
### Pakiet `django-soft-delete` (punkt wyjścia, nie zmieniamy)
From fdd9a15a8ff498bff7c265b39bd5d455fa4877c7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 4 Jun 2026 11:33:57 +0200
Subject: [PATCH 09/75] =?UTF-8?q?docs(soft-delete):=20faza=2001=20?=
=?UTF-8?q?=E2=80=94=20tylko=20widoki,=20trigger=20NIE=20ruszany=20(decyzj?=
=?UTF-8?q?a)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Decyzja użytkownika: bpp_refresh_cache() optymalizowany osobno; faza 01 zmienia
WYŁĄCZNIE widoki źródłowe (filtr deleted_at IS NULL). Trigger-skip i fix
utajonego buga z krotkami WYCIĘTE z fazy 01. Box AKTUALIZACJA ZAKRESU na górze
planu 01 + jednoznaczna nota w indeksie. Inwariant do weryfikacji po wskazaniu
gałęzi optymalizacji: bezwarunkowy DELETE przed re-insertem/upsertem.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../plans/2026-06-04-soft-delete-00-overview.md | 12 ++++++------
...026-06-04-soft-delete-01-autor-trigger-widoki.md | 13 +++++++++++++
2 files changed, 19 insertions(+), 6 deletions(-)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
index 6cace0329..15eb87728 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
@@ -16,7 +16,7 @@
| # | Plik | Zakres | Zależy od |
|---|---|---|---|
-| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; **filtr `deleted_at` w widokach źródłowych (mechanizm nadrzędny)**; trigger-skip OPCJONALNY; spójność przez `full_refresh()`. ⚠️ patrz „Koordynacja: trigger" niżej | — / trigger ⚠️ |
+| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; **filtr `deleted_at` TYLKO w widokach źródłowych** (funkcja triggera NIE ruszana); spójność przez `full_refresh()`. ⚠️ patrz „Koordynacja: trigger" niżej | — / trigger ⚠️ |
| 02 | `2026-06-04-soft-delete-02-publikacje.md` | 5 modeli → SoftDeleteModel; override `delete()`/`restore()` z wąską kaskadą na `*_Autor`; `slug` warunkowy unique; przeplecenie menedżerów | 01 |
| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 90 miejsc `*_Autor.objects` | 02 |
| 04 | `2026-06-04-soft-delete-04-guardy-protect.md` | flip FK `CASCADE→PROTECT` (autor, doktorat, `wydawnictwo_nadrzedne`); guard w soft `delete()` (autor + książka-matka); soft-delete husku autora | 02 |
@@ -34,11 +34,11 @@ Funkcja `bpp_refresh_cache()` jest **równolegle optymalizowana w osobnej gałę
`feat/soft-delete` nie zostanie na nią zaktualizowana (rebase na `dev` lub
merge gałęzi optymalizacji).
-Ortogonalność: faza 01 dla POPRAWNOŚCI dotyka **widoków źródłowych**
-(`bpp_rekord`, `bpp_*_autorzy` — filtr `deleted_at IS NULL`), a optymalizacja
-dotyka **funkcji triggera** — w dużej mierze rozłączne. Modyfikacja funkcji
-triggera w fazie 01 jest **opcjonalna** (trigger-skip) i nakładana CIENKO na
-wierzch zoptymalizowanej funkcji, jeśli w ogóle.
+**DECYZJA: faza 01 NIE rusza funkcji `bpp_refresh_cache()` — zmieniamy WYŁĄCZNIE
+widoki źródłowe** (`bpp_rekord`, `bpp_*_autorzy` — filtr `deleted_at IS NULL`).
+Trigger-skip WYCIĘTY (zbędny przy zachowanym inwariancie delete-first). Utajony
+bug z krotkami `(table, id_col)` — NIE naprawiamy tutaj, zostaje optymalizacji
+triggera. Ortogonalność pełna: Ty = funkcja triggera, ja = widoki.
**Inwariant, który MUSI przetrwać optymalizację** (inaczej filtr widoku przestaje
wystarczać i wraca konieczność trigger-skip): na `UPDATE/INSERT` trigger robi
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
index 0b8e995b9..7f49fb58f 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
@@ -2,6 +2,19 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+> ⚠️ **AKTUALIZACJA ZAKRESU (decyzja użytkownika 2026-06-04) — CZYTAJ PRZED STARTEM:**
+> 1. **NIE ruszamy funkcji `bpp_refresh_cache()`.** Zmieniamy **wyłącznie widoki
+> źródłowe** (`bpp_*_autorzy`, ew. `bpp_rekord`) — filtr `deleted_at IS NULL`.
+> Każde zadanie tego planu dotyczące **trigger-skip** / modyfikacji kopii
+> `0399` / fixu utajonego buga z krotkami — **POMIŃ** (zostaje równoległej
+> optymalizacji triggera).
+> 2. **BLOKER:** funkcja `bpp_refresh_cache()` jest równolegle optymalizowana w
+> osobnej gałęzi. Tej fazy **NIE startować**, dopóki ta gałąź nie wyląduje i
+> `feat/soft-delete` nie zostanie na nią zaktualizowana. Po aktualizacji
+> zweryfikować inwariant: trigger na `UPDATE/INSERT` robi **bezwarunkowy
+> `DELETE` z `_mat` przed re-insertem/upsertem** (na tym wisi wystarczalność
+> filtra widoku). Jeśli inwariant zniknie → dopiero wtedy rozważyć trigger-skip.
+
**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez wspólną bazę `BazaModeluOdpowiedzialnosciAutorow`), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, oraz wpiąć filtr `deleted_at IS NULL` do widoków źródłowych PostgreSQL (`bpp_*_autorzy` + gałęzie UNION `bpp_rekord`) tak, by soft-deletowane autorstwa znikały z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i wracały po `restore`. Opcjonalnie: trigger-skip w `bpp_refresh_cache()`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować.
**Architecture:** Mechanizm nadrzędny to **filtr widoku (#1)** — każda tabela `bpp_*_autor` ma własną kolumnę `deleted_at`, a widoki źródłowe `bpp_wydawnictwo_ciagle_autorzy` / `bpp_wydawnictwo_zwarte_autorzy` / `bpp_patent_autorzy` (`0001_widoki_autorzy.sql`) dostają `AND .deleted_at IS NULL` po **własnej** kolumnie (bez JOIN do rekordu nadrzędnego). To pokrywa WSZYSTKIE ścieżki: re-insert triggera `bpp_refresh_cache()`, bezpośredni odczyt `Rekord`/`RekordView` z widoku `bpp_rekord`, oraz pełną re-projekcję cache. Gałęzie `UNION` w `bpp_rekord` (`0001_widoki_rekord.sql`) per typ publikacji NIE filtrują po `*_autor.deleted_at` (rekord publikacji żyje niezależnie od soft-delete pojedynczego autorstwa — soft-delete publikacji to faza 02), ale dla spójności kontraktu dodajemy filtr `deleted_at IS NULL` na poziomie tabeli autorskiej tylko w widokach `bpp_*_autorzy`. Trigger-skip (#2) to opcjonalna optymalizacja w gałęzi `UPDATE/INSERT` funkcji `bpp_refresh_cache()` (aktualna wersja: `0399_fix_refresh_cache_upsert.sql`): gdy `TD['new']['deleted_at'] is not None` → pomiń upsert (delete-only). Nie zastępuje #1.
From 0bde1dab41f9bffbc0c38d9bb789cdb4b7e45fee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 08:52:25 +0200
Subject: [PATCH 10/75] test(soft-delete): kanarki warunkow wstepnych wobec
triggerow cache
Spec soft-delete opieral sie na dwoch zalozeniach o warstwie cache, ktore
przestaly obowiazywac po PR #363 (migracje 0432/0433):
1. ze UPDATE ustawiajacy deleted_at doleci do triggera cache -- NIE doleci,
bo bramka WHEN z 0433 zna tylko kolumny zasilajace widok (z pg_depend),
a django-soft-delete zapisuje przez
save(update_fields=['deleted_at', 'restored_at', 'transaction_id']);
2. ze filtr "deleted_at IS NULL" w widoku zrodlowym WYSTARCZY, bo trigger
robi bezwarunkowy DELETE przed upsertem ("inwariant delete-first") --
NIE robi: funkcje refresh z 0432 to czysty
INSERT ... SELECT FROM widok ... ON CONFLICT DO UPDATE, wiec odfiltrowanie
wiersza ze zrodla daje no-op, a stary wiersz przezywa w _mat.
Oba testy sa ZIELONE na obecnym kodzie -- przypinaja stan "soft-delete by
nie zadzialal". Faza 01 odwroci ich asercje razem z wprowadzeniem galezi
kasujacej w funkcjach refresh i regeneracja bramki WHEN.
Kolumne deleted_at i filtr widoku symulujemy DDL-em wewnatrz transakcji
testowej (DDL w Postgresie jest transakcyjny). Cale DDL musi isc PRZED
utworzeniem rekordu -- ALTER TABLE odmawia, gdy tabela ma zakolejkowane
zdarzenia wyzwalaczy z INSERT-a w tej samej transakcji.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
.../test_soft_delete_preconditions.py | 153 ++++++++++++++++++
1 file changed, 153 insertions(+)
create mode 100644 src/bpp/tests/test_cache/test_soft_delete_preconditions.py
diff --git a/src/bpp/tests/test_cache/test_soft_delete_preconditions.py b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py
new file mode 100644
index 000000000..de916f1c1
--- /dev/null
+++ b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py
@@ -0,0 +1,153 @@
+"""Warunki wstepne soft-delete wobec triggerow cache (PR #312, faza 01).
+
+Spec soft-delete (2026-06-04) zaklada dwa fakty o warstwie cache:
+
+1. ``deleted_at`` ustawione UPDATE-em dolecialoby do triggera cache;
+2. filtr ``deleted_at IS NULL`` w widoku zrodlowym WYSTARCZY, bo trigger na
+ UPDATE robi bezwarunkowy DELETE z ``_mat`` przed upsertem ("inwariant,
+ ktory MUSI przetrwac optymalizacje" -- plan 00).
+
+Oba fakty przestaly obowiazywac po migracjach 0432 (port PL/pgSQL, upsert bez
+DELETE) i 0433 (bramka WHEN na liscie kolumn z pg_depend). Te testy to
+przypinaja: sa ZIELONE na obecnym kodzie, czyli dokumentuja stan "soft-delete
+by nie zadzialal". Faza 01 ma je odwrocic (zmienic asercje na docelowe) razem
+z wprowadzeniem gałęzi kasujacej w funkcjach refresh + regeneracja bramki.
+
+Surowy SQL, zeby izolowac sam trigger bazodanowy (bez denorm / sygnalow
+Django). Kolumne ``deleted_at`` dokladamy ALTER-em wewnatrz transakcji
+testowej -- DDL w Postgresie jest transakcyjny, wiec rollback ja sprzata.
+"""
+
+import pytest
+from django.db import connection
+
+from bpp.tests.util import any_ciagle
+
+
+def _ct(cur, model):
+ cur.execute(
+ "SELECT id FROM django_content_type WHERE app_label='bpp' AND model=%s",
+ [model],
+ )
+ return cur.fetchone()[0]
+
+
+def _ctid(cur, ct, pk):
+ """Fizyczne polozenie krotki w bpp_rekord_mat; None gdy wiersza nie ma.
+
+ ctid, nie xmin: w obrebie jednej transakcji testowej kazdy zapis ma to samo
+ xid, wiec xmin sie nie rusza nawet przy przepisaniu wiersza.
+ """
+ cur.execute(
+ "SELECT ctid::text FROM bpp_rekord_mat WHERE id = ARRAY[%s, %s]::integer[]",
+ [ct, pk],
+ )
+ row = cur.fetchone()
+ return row[0] if row else None
+
+
+def _dodaj_deleted_at(cur):
+ cur.execute("ALTER TABLE bpp_wydawnictwo_ciagle ADD COLUMN deleted_at timestamptz")
+
+
+def _filtruj_widok_po_deleted_at(cur):
+ """Owija bpp_wydawnictwo_ciagle_view filtrem deleted_at IS NULL.
+
+ Odpowiednik "mechanizmu #1" ze specu, bez ruszania oryginalnej definicji
+ (CREATE OR REPLACE zachowuje liste kolumn -- bpp_rekord, ktory ten widok
+ UNION-uje, pozostaje wazny).
+ """
+ cur.execute("SELECT pg_get_viewdef('bpp_wydawnictwo_ciagle_view'::regclass, true)")
+ orig = cur.fetchone()[0].rstrip().rstrip(";")
+ cur.execute(
+ f"CREATE OR REPLACE VIEW bpp_wydawnictwo_ciagle_view AS "
+ f"SELECT * FROM ({orig}) _orig "
+ f"WHERE _orig.object_id_raw NOT IN ("
+ f" SELECT id FROM bpp_wydawnictwo_ciagle WHERE deleted_at IS NOT NULL)"
+ )
+
+
+@pytest.mark.django_db
+def test_update_samego_deleted_at_nie_odpala_triggera():
+ """Bramka WHEN (0433) nie zna deleted_at -> UPDATE soft-delete nie wchodzi.
+
+ django-soft-delete kasuje przez
+ ``save(update_fields=['deleted_at', 'restored_at', 'transaction_id'])``,
+ wiec UPDATE dotyka WYLACZNIE kolumn spoza bramki. Zaden atrybut zasilajacy
+ widok sie nie zmienia -> trigger sie nie odpala -> wiersz zostaje w
+ bpp_rekord_mat (ctid bez zmian).
+ """
+ # DDL PRZED utworzeniem rekordu: ALTER TABLE nie przejdzie, gdy tabela ma
+ # zakolejkowane zdarzenia wyzwalaczy z INSERT-a w tej samej transakcji.
+ with connection.cursor() as cur:
+ _dodaj_deleted_at(cur)
+
+ wc = any_ciagle(tytul_oryginalny="Bramka a soft-delete")
+ pk = wc.pk
+
+ with connection.cursor() as cur:
+ ct = _ct(cur, "wydawnictwo_ciagle")
+
+ przed = _ctid(cur, ct, pk)
+ assert przed is not None, "wiersz powinien byc w bpp_rekord_mat po INSERT"
+
+ cur.execute(
+ "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s",
+ [pk],
+ )
+ po = _ctid(cur, ct, pk)
+
+ assert po == przed, (
+ "UPDATE samego deleted_at przepisal bpp_rekord_mat "
+ f"(ctid {przed} -> {po}) -- bramka WHEN najwyrazniej zna deleted_at"
+ )
+
+
+@pytest.mark.django_db
+def test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat():
+ """Upsert bez DELETE (0432): odfiltrowanie z widoku NIE czysci _mat.
+
+ Nawet gdy trigger SIE ODPALI (wymuszamy to UPDATE-em bramkowanej kolumny
+ ``rok``), funkcja refresh robi tylko
+ ``INSERT ... SELECT FROM widok ... ON CONFLICT DO UPDATE``. Widok nie
+ zwraca wiersza -> INSERT wybiera zero wierszy -> no-op -> stary wiersz
+ przezywa w bpp_rekord_mat.
+
+ To obala "inwariant delete-first" z planu 00, na ktorym opiera sie
+ wystarczalnosc mechanizmu #1.
+ """
+ # Cale DDL przed INSERT-em (patrz test wyzej). Filtr widoku jest juz
+ # aktywny przy tworzeniu rekordu, ale deleted_at jest wtedy NULL, wiec
+ # wiersz normalnie wchodzi do bpp_rekord_mat.
+ with connection.cursor() as cur:
+ _dodaj_deleted_at(cur)
+ _filtruj_widok_po_deleted_at(cur)
+
+ wc = any_ciagle(tytul_oryginalny="Filtr widoku bez DELETE", rok=2020)
+ pk = wc.pk
+
+ with connection.cursor() as cur:
+ ct = _ct(cur, "wydawnictwo_ciagle")
+
+ assert _ctid(cur, ct, pk) is not None
+
+ # soft-delete: wiersz wypada z widoku zrodlowego
+ cur.execute(
+ "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s",
+ [pk],
+ )
+ # ... i wymuszamy odpalenie triggera zmiana kolumny Z bramki
+ cur.execute("UPDATE bpp_wydawnictwo_ciagle SET rok = 2021 WHERE id = %s", [pk])
+
+ cur.execute(
+ "SELECT count(*) FROM bpp_wydawnictwo_ciagle_view WHERE object_id_raw = %s",
+ [pk],
+ )
+ w_widoku = cur.fetchone()[0]
+ po = _ctid(cur, ct, pk)
+
+ assert w_widoku == 0, "widok zrodlowy powinien juz nie zwracac wiersza"
+ assert po is not None, (
+ "wiersz zniknal z bpp_rekord_mat -- funkcja refresh jednak kasuje "
+ "przed upsertem (inwariant delete-first zyje)"
+ )
From 7119a766f0cfe7beb09e65a173acf79c9a853b6d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 09:31:47 +0200
Subject: [PATCH 11/75] docs(soft-delete): rewizja specu i planow po porcie
triggera (PR #363)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Spec powstal przed portem bpp_refresh_cache PL/Python -> PL/pgSQL. Weryfikacja
na aktualnym dev obalila dwa nosne zalozenia warstwy cache i ujawnila 5 luk.
Cache (§2.1 przepisana od zera, decyzja #9 uniewazniona):
- funkcja bpp_refresh_cache() NIE ISTNIEJE (DROP w 0432); zastapilo ja 16
funkcji per-tabela + bramka WHEN na UPDATE (0433),
- inwariant "bezwarunkowy DELETE przed upsertem", na ktorym wisiala
wystarczalnosc filtra widoku, NIE ISTNIEJE -- funkcja refresh to czysty
upsert, wiec odfiltrowanie wiersza ze zrodla jest no-opem,
- UPDATE ustawiajacy deleted_at nie przechodzi przez bramke WHEN, bo
django-soft-delete zapisuje przez save(update_fields=[...]).
Zamiast jednego mechanizmu potrzebne sa TRZY, wszystkie obowiazkowe: filtr
w widoku + galaz kasujaca w funkcji refresh + regeneracja bramki WHEN.
Domkniete decyzje (#12-#16):
- #12 full_refresh() to denorm.rebuildall, NIE re-projekcja _mat -- nie nadaje
sie do weryfikacji spojnosci (test przechodzilby z falszywych powodow),
- #13 unique_together na *_Autor -> warunkowy UniqueConstraint (jak slug),
- #14 import trafiajacy w kosz: POMIN + ZARAPORTUJ (nie update, nie restore),
- #15 Cache_Punktacja_* kasowane przy soft-delete, przeliczane przy restore --
luka nieobjeta kaskada *_Autor (brak FK do publikacji),
- #16 PBN: jeden prymityw, dwa wejscia (kolejka + synchroniczne), bo rekord
bywa wysylany bez kolejki.
Plany: BLOKER fazy 01 zdjety; nowy Task 3 (widoki+funkcje+bramka) zamiast
kopii 0399; nowy Task 2b w fazie 02 (ten sam DDL dla 5 tabel publikacji --
faza 01 dotyka wylacznie sciezki autorstwa); Task 1b w 03, 05.0/05.9 w 05,
6b w 06; przenumerowane migracje (0488+, liste 0487); 90 -> 128 miejsc
*_Autor.objects; korekta strict (delete=False, restore=True).
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
.../2026-06-04-soft-delete-00-overview.md | 76 ++-
...-04-soft-delete-01-autor-trigger-widoki.md | 542 +++++++++---------
.../2026-06-04-soft-delete-02-publikacje.md | 163 +++++-
...-06-04-soft-delete-03-audyt-kategorii-b.md | 63 +-
...2026-06-04-soft-delete-05-pbn-wycofanie.md | 82 ++-
...2026-06-04-soft-delete-06-softdeletelog.md | 76 ++-
...026-06-04-soft-delete-08-testy-regresji.md | 66 ++-
...soft-delete-publikacje-i-autorzy-design.md | 456 +++++++++++----
8 files changed, 1086 insertions(+), 438 deletions(-)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
index 15eb87728..0131bed39 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
@@ -4,9 +4,9 @@
**Goal:** Wdrożyć odwracalny soft-delete dla 5 typów publikacji + wąską kaskadę na `*_Autor`, soft-delete autora bez prac (z PROTECT dla autora/książki z zależnościami), wycofanie z PBN przez kolejkę, audyt `SoftDeleteLog` i wsparcie w adminie superusera.
-**Architecture:** `django-soft-delete` (`SoftDeleteModel`) na 5 modelach publikacji + 3 through-modelach `*_Autor`; spójność cache w JEDNYM punkcie — filtr `deleted_at IS NULL` w widokach źródłowych PostgreSQL (mechanizm #1) + opcjonalny trigger-skip. Override `delete()` robi wąską kaskadę na `*_Autor`. PBN-wycofanie idzie przez rozszerzoną `pbn_export_queue`. `SoftDeleteLog` zasilany sygnałami pakietu.
+**Architecture:** `django-soft-delete` (`SoftDeleteModel`) na 5 modelach publikacji + 3 through-modelach `*_Autor`. Spójność cache wymaga **trzech** elementów naraz (filtr w widoku źródłowym + gałąź kasująca w funkcji refresh + regeneracja bramki `WHEN`) — patrz box „Koordynacja" niżej; wersja „jeden mechanizm wystarczy" została obalona 2026-08-06. Override `delete()` robi wąską kaskadę na `*_Autor`. PBN-wycofanie: jeden prymityw, dwa wejścia (kolejka + synchroniczne). `SoftDeleteLog` zasilany sygnałami pakietu; on też kasuje/przelicza `Cache_Punktacja_*`.
-**Tech Stack:** Django, PostgreSQL (`plpython3u` triggery + widoki), `django-soft-delete>=1.0.23`, `django-denorm-iplweb`, Celery + `pbn_export_queue`, pytest + model_bakery.
+**Tech Stack:** Django, PostgreSQL (triggery **PL/pgSQL** + widoki), `django-soft-delete>=1.0.23`, `django-denorm-iplweb`, Celery + `pbn_export_queue`, pytest + model_bakery.
**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md)
@@ -16,36 +16,47 @@
| # | Plik | Zakres | Zależy od |
|---|---|---|---|
-| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; **filtr `deleted_at` TYLKO w widokach źródłowych** (funkcja triggera NIE ruszana); spójność przez `full_refresh()`. ⚠️ patrz „Koordynacja: trigger" niżej | — / trigger ⚠️ |
+| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; widoki `bpp_*_autorzy` + **gałąź kasująca w funkcjach refresh** + **regeneracja bramki `WHEN`**; spójność weryfikowana surowym SQL-em (NIE `full_refresh()`) | — |
| 02 | `2026-06-04-soft-delete-02-publikacje.md` | 5 modeli → SoftDeleteModel; override `delete()`/`restore()` z wąską kaskadą na `*_Autor`; `slug` warunkowy unique; przeplecenie menedżerów | 01 |
-| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 90 miejsc `*_Autor.objects` | 02 |
+| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 128 miejsc `*_Autor.objects` | 02 |
| 04 | `2026-06-04-soft-delete-04-guardy-protect.md` | flip FK `CASCADE→PROTECT` (autor, doktorat, `wydawnictwo_nadrzedne`); guard w soft `delete()` (autor + książka-matka); soft-delete husku autora | 02 |
-| 05 | `2026-06-04-soft-delete-05-pbn-wycofanie.md` | `pbn_export_queue.operacja = WYSYLKA\|WYCOFANIE`; `WYCOFANIE` → `delete_all_publication_statements`; restore → `WYSYLKA`; integracja `SentData` | 02 |
-| 06 | `2026-06-04-soft-delete-06-softdeletelog.md` | model `SoftDeleteLog`; receivery `post_soft_delete`/`post_restore`/`post_hard_delete`; wstrzykiwanie `user` | 02, 05 |
+| 05 | `2026-06-04-soft-delete-05-pbn-wycofanie.md` | prymityw `wycofaj_oswiadczenia()`; `pbn_export_queue.operacja = WYSYLKA\|WYCOFANIE` (wejście async) + wywołanie bezpośrednie (sync); restore → `WYSYLKA`; integracja `SentData` | 02 |
+| 06 | `2026-06-04-soft-delete-06-softdeletelog.md` | model `SoftDeleteLog`; receivery `post_soft_delete`/`post_restore`/`post_hard_delete`; wstrzykiwanie `user`; **kasowanie/przeliczanie `Cache_Punktacja_*`** | 02, 05 |
| 07 | `2026-06-04-soft-delete-07-admin.md` | admin superuser-only: kosz/filtr/przywróć/usuń-trwale/powód (5 modeli + Autor); jeden hook usera | 04, 06 |
| 08 | `2026-06-04-soft-delete-08-testy-regresji.md` | pełna suita regresji: PBN duplikaty/wycofanie, dashboard, import, ewaluacja, merge autorów, API | 01–07 |
---
-## ⚠️ Koordynacja: trigger `bpp_refresh_cache` (BLOKER fazy 01)
+## ✅ Koordynacja: trigger cache — BLOKER ZDJĘTY (2026-08-06)
-Funkcja `bpp_refresh_cache()` jest **równolegle optymalizowana w osobnej gałęzi**
-(prace użytkownika). **Faza 01 NIE startuje, dopóki ta gałąź nie wyląduje** i
-`feat/soft-delete` nie zostanie na nią zaktualizowana (rebase na `dev` lub
-merge gałęzi optymalizacji).
+Optymalizacja triggera **wylądowała** (PR #363: `0421_cache_trigger_pk_filter`,
+`0429_cache_trigger_v3`, `0432_cache_trigger_plpgsql`,
+`0433_cache_trigger_when_gate`). Gałąź `perf/cache-trigger-pk-filter` już nie
+istnieje; ta gałąź jest zrebasowana na `dev`.
-**DECYZJA: faza 01 NIE rusza funkcji `bpp_refresh_cache()` — zmieniamy WYŁĄCZNIE
-widoki źródłowe** (`bpp_rekord`, `bpp_*_autorzy` — filtr `deleted_at IS NULL`).
-Trigger-skip WYCIĘTY (zbędny przy zachowanym inwariancie delete-first). Utajony
-bug z krotkami `(table, id_col)` — NIE naprawiamy tutaj, zostaje optymalizacji
-triggera. Ortogonalność pełna: Ty = funkcja triggera, ja = widoki.
+**Inwariant, o którego przetrwanie pytał poprzedni box, NIE przetrwał** —
+i to zmienia zakres fazy 01:
-**Inwariant, który MUSI przetrwać optymalizację** (inaczej filtr widoku przestaje
-wystarczać i wraca konieczność trigger-skip): na `UPDATE/INSERT` trigger robi
-**bezwarunkowy `DELETE` z `_mat` przed re-insertem/upsertem** — tzn. nie
-re-insertuje wiersza, którego źródłowy widok nie zwraca. Po wskazaniu gałęzi:
-zweryfikować ten inwariant + czy optymalizacja rusza widoki + status utajonego
-buga (string w liście krotek `(table, id_col)`).
+| Poprzednie założenie | Stan faktyczny (zweryfikowany testem) |
+|---|---|
+| trigger robi bezwarunkowy `DELETE` przed upsertem | **NIE** — funkcja refresh to czysty `INSERT ... SELECT ... ON CONFLICT DO UPDATE`; odfiltrowanie wiersza z widoku daje no-op, stary wiersz przeżywa w `_mat` |
+| UPDATE ustawiający `deleted_at` doleci do triggera | **NIE** — bramka `WHEN` z `0433` zna tylko kolumny zasilające widok, a `django-soft-delete` zapisuje przez `save(update_fields=[...])` |
+
+Dowód: `src/bpp/tests/test_cache/test_soft_delete_preconditions.py` — dwa
+kanarki, **zielone na obecnym kodzie** (przypinają stan „soft-delete by nie
+zadziałał"). Faza 01 odwraca ich asercje.
+
+**NOWY ZAKRES FAZY 01 — trzy elementy zamiast jednego, wszystkie obowiązkowe:**
+
+1. filtr `deleted_at IS NULL` w widokach źródłowych `bpp_*_autorzy`,
+2. **gałąź kasująca** w funkcjach refresh (`IF NEW.deleted_at IS NOT NULL THEN
+ DELETE ... RETURN NULL`) — wzorzec już w kodzie: gałąź doktorat/habilitacja
+ w `_create_rekord_function` (`0432`),
+3. **regeneracja bramki `WHEN`** — `RunPython` wołający logikę `forward()`
+ z `0433`; `deleted_at` wejdzie do bramki sam, przez `pg_depend`, bo punkt 1
+ wstawił ją do `WHERE` widoku. **Kolejność: 1 → 3.**
+
+Szczegóły i uzasadnienie: §2.1 specu.
---
@@ -57,6 +68,8 @@ buga (string w liście krotek `(table, id_col)`).
- Metody instancji: `.delete()` (soft, woła `self.save(update_fields=[...])` + `post_soft_delete`), `.hard_delete()`, `.restore()`.
- `SoftDeleteQuerySet.delete()` iteruje per-instancję (`for obj in self.iterator(): obj.delete()`) — bezpieczny dla sygnałów. **NIE** robi bulk update.
- Sygnały (`django_softdelete/signals.py`): `post_soft_delete`, `post_hard_delete`, `post_restore`.
+- ⚠️ **`strict` jest ASYMETRYCZNE:** `delete(strict=False)`, ale `restore(strict=True)`. Czyli domyślna kaskada `delete()` **nie krzyknie** na nie-soft dzieciach — po cichu po nich przejedzie. Kolejny powód, by nie polegać na kaskadzie pakietu (§2.2 specu).
+- ⚠️ **`delete()` zapisuje przez `save(update_fields=['deleted_at','restored_at','transaction_id'])`** — to jest przyczyna, dla której bramka `WHEN` triggera cache nie przepuszcza soft-delete (i dla której `ostatnio_zmieniony`/`auto_now` NIE jest bumpowany).
### Nowy moduł `src/bpp/models/soft_delete.py` (tworzy faza 01)
```python
@@ -114,12 +127,17 @@ Pola PINNED: `content_type` (FK ContentType), `object_id` (PositiveIntegerField,
### Punkty zaczepienia w istniejącym kodzie (zweryfikowane)
- Rejestracja sygnałów: `src/bpp/apps.py` → `BppConfig.ready()` (linia 8).
- Menedżery publikacji: `src/bpp/models/wydawnictwo_ciagle.py:87` (`Wydawnictwo_Ciagle_Manager`), `wydawnictwo_zwarte.py:167` (`Wydawnictwo_Zwarte_Manager`), oba po `ManagerModeliZOplataZaPublikacjeMixin` (`src/bpp/models/abstract/fees.py`).
-- Through-model FK autora: `src/bpp/models/abstract/authors.py:22` (`autor = ForeignKey("bpp.Autor", CASCADE)`).
-- Doktorat FK: `src/bpp/models/praca_doktorska.py:136` (CASCADE). Habilitacja: `praca_habilitacyjna.py:42` (O2O PROTECT, bez zmian).
-- Self-FK rozdziałów: `src/bpp/models/wydawnictwo_zwarte.py:202` (`wydawnictwo_nadrzedne`).
-- Trigger/widoki: `src/bpp/migrations/0001_cache_functions.sql` (funkcja `bpp_refresh_cache`), `src/bpp/migrations/0001_widoki_autorzy.sql`, `0001_widoki_rekord.sql`.
-- `Rekord` czyta widok `bpp_rekord`: `src/bpp/models/cache/rekord.py:357`. Mat-tabela: `:347`.
-- `verify_cache`: `src/bpp/management/commands/verify_cache.py`.
+- Through-model FK autora: `src/bpp/models/abstract/authors.py:25` (`autor = models.ForeignKey("bpp.Autor", CASCADE)`).
+- Doktorat FK: `src/bpp/models/praca_doktorska.py:153` (CASCADE). Habilitacja: `praca_habilitacyjna.py:47` (O2O PROTECT, bez zmian).
+- Self-FK rozdziałów: `src/bpp/models/wydawnictwo_zwarte.py:212` (`wydawnictwo_nadrzedne`, CASCADE).
+- `unique_together` na through: `src/bpp/models/wydawnictwo_ciagle.py:73-77` (analogicznie zwarte/patent) — do zamiany na warunkowy `UniqueConstraint`, §2.2b specu.
+- **Trigger (AKTUALNY):** `src/bpp/migrations/0432_cache_trigger_plpgsql.py` — generator 8 funkcji refresh + 8 delete (PL/pgSQL, upsert bez DELETE); `0433_cache_trigger_when_gate.py` — generator bramki `WHEN` z `pg_depend`. ⚠️ Funkcja `bpp_refresh_cache()` **nie istnieje** (DROP w `0432`); `0399`/`0001_cache_functions.sql` to historia.
+- Widoki źródłowe: definicje odtwarzane wielokrotnie; **ostatnia wersja per-typ w `0421_cache_trigger_pk_filter.sql`** (dodaje `object_id_raw`). `bpp_rekord` = UNION 5 widoków per-typ.
+- `Rekord` czyta **tabelę `bpp_rekord_mat`**: `src/bpp/models/cache/rekord.py:382-386`. Widok `bpp_rekord` → `RekordView`, `:394-396`.
+- `full_refresh()` = `denorm.rebuildall`, NIE re-projekcja `_mat`: `src/bpp/models/cache/rekord.py:121-127`.
+- `Cache_Punktacja_Autora`/`_Dyscypliny`: `src/bpp/models/cache/punktacja.py`; zapis w `src/bpp/models/sloty/core.py:401,439`; odczyt m.in. `ewaluacja_optymalizacja/utils.py:182`, `.../evaluation_browser/prefetch.py:41`, `oswiadczenia/views.py:353`.
+- `verify_cache`: `src/bpp/management/commands/verify_cache.py` — **martwy stub**, nie używać.
+- Wzorzec testów cache: `src/bpp/tests/test_cache/test_cache_plpgsql_port.py`. Kanarki soft-delete: `test_soft_delete_preconditions.py`.
- Admin tych modeli: `src/bpp/admin/{wydawnictwo_ciagle,wydawnictwo_zwarte,patent,praca_doktorska,praca_habilitacyjna,autor}.py`; mixiny `src/bpp/admin/helpers/mixins.py`.
- PBN klient: `src/pbn_api/client/mixins/institutions.py:87`. `SentData`: `src/pbn_api/models/sentdata.py`. Kolejka: `src/pbn_export_queue/{models,tasks,admin}.py`.
- Merge autorów: `src/deduplikator_autorow/views/merge.py:155`, `utils/merge.py:191,284,354`.
@@ -143,7 +161,7 @@ Równoległy spec [`../specs/2026-06-04-historia-zmian-reversion-design.md`](../
- `src/bpp/models/soft_delete.py` (queryset+gate, managery, guard helper) — faza 01/04
- `src/bpp/models/soft_delete_log.py` (model `SoftDeleteLog`) — faza 06
- `src/bpp/migrations/0XXX_*` — migracje pól soft-delete (`*_Autor`, 5 publikacji), `slug` constraint, FK flips, `SoftDeleteLog`, `pbn_export_queue.operacja`
-- `src/bpp/migrations/0XXX_soft_delete_views.sql` — filtr `deleted_at` w `bpp_rekord`/`bpp_*_autorzy` + trigger-skip
+- `src/bpp/migrations/0XXX_soft_delete_*_views.py` — `RunPython`: filtr `deleted_at` w widokach źródłowych + gałąź kasująca w funkcjach refresh + regeneracja bramki `WHEN` (faza 01 dla `bpp_*_autorzy`, faza 02 dla `bpp_*_view`)
- `src/bpp/receivers/soft_delete.py` (lub w istniejącym module sygnałów) — receivery — faza 06
**Modyfikowane (główne):**
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
index 7f49fb58f..d8c3af128 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
@@ -2,24 +2,36 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-> ⚠️ **AKTUALIZACJA ZAKRESU (decyzja użytkownika 2026-06-04) — CZYTAJ PRZED STARTEM:**
-> 1. **NIE ruszamy funkcji `bpp_refresh_cache()`.** Zmieniamy **wyłącznie widoki
-> źródłowe** (`bpp_*_autorzy`, ew. `bpp_rekord`) — filtr `deleted_at IS NULL`.
-> Każde zadanie tego planu dotyczące **trigger-skip** / modyfikacji kopii
-> `0399` / fixu utajonego buga z krotkami — **POMIŃ** (zostaje równoległej
-> optymalizacji triggera).
-> 2. **BLOKER:** funkcja `bpp_refresh_cache()` jest równolegle optymalizowana w
-> osobnej gałęzi. Tej fazy **NIE startować**, dopóki ta gałąź nie wyląduje i
-> `feat/soft-delete` nie zostanie na nią zaktualizowana. Po aktualizacji
-> zweryfikować inwariant: trigger na `UPDATE/INSERT` robi **bezwarunkowy
-> `DELETE` z `_mat` przed re-insertem/upsertem** (na tym wisi wystarczalność
-> filtra widoku). Jeśli inwariant zniknie → dopiero wtedy rozważyć trigger-skip.
-
-**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez wspólną bazę `BazaModeluOdpowiedzialnosciAutorow`), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, oraz wpiąć filtr `deleted_at IS NULL` do widoków źródłowych PostgreSQL (`bpp_*_autorzy` + gałęzie UNION `bpp_rekord`) tak, by soft-deletowane autorstwa znikały z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i wracały po `restore`. Opcjonalnie: trigger-skip w `bpp_refresh_cache()`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować.
-
-**Architecture:** Mechanizm nadrzędny to **filtr widoku (#1)** — każda tabela `bpp_*_autor` ma własną kolumnę `deleted_at`, a widoki źródłowe `bpp_wydawnictwo_ciagle_autorzy` / `bpp_wydawnictwo_zwarte_autorzy` / `bpp_patent_autorzy` (`0001_widoki_autorzy.sql`) dostają `AND .deleted_at IS NULL` po **własnej** kolumnie (bez JOIN do rekordu nadrzędnego). To pokrywa WSZYSTKIE ścieżki: re-insert triggera `bpp_refresh_cache()`, bezpośredni odczyt `Rekord`/`RekordView` z widoku `bpp_rekord`, oraz pełną re-projekcję cache. Gałęzie `UNION` w `bpp_rekord` (`0001_widoki_rekord.sql`) per typ publikacji NIE filtrują po `*_autor.deleted_at` (rekord publikacji żyje niezależnie od soft-delete pojedynczego autorstwa — soft-delete publikacji to faza 02), ale dla spójności kontraktu dodajemy filtr `deleted_at IS NULL` na poziomie tabeli autorskiej tylko w widokach `bpp_*_autorzy`. Trigger-skip (#2) to opcjonalna optymalizacja w gałęzi `UPDATE/INSERT` funkcji `bpp_refresh_cache()` (aktualna wersja: `0399_fix_refresh_cache_upsert.sql`): gdy `TD['new']['deleted_at'] is not None` → pomiń upsert (delete-only). Nie zastępuje #1.
-
-**Tech Stack:** Django 4.2, PostgreSQL (`plpython3u` trigger + widoki), `django-soft-delete>=1.0.23` (`SoftDeleteModel`, `SoftDeleteManager`/`GlobalManager`/`DeletedManager`), pytest + model_bakery, `denorm` (django-denorm-iplweb). Python wyłącznie przez `uv run`.
+> 🔄 **AKTUALIZACJA ZAKRESU 2026-08-06 — CZYTAJ PRZED STARTEM. Zastępuje
+> poprzedni box „decyzja użytkownika 2026-06-04".**
+>
+> **BLOKER ZDJĘTY.** Optymalizacja triggera wylądowała (PR #363, migracje
+> `0421`/`0429`/`0432`/`0433`), gałąź jest zrebasowana na `dev`.
+>
+> **Ale inwariant, o który pytał poprzedni box, NIE przetrwał** — i to
+> ROZSZERZA tę fazę. Zweryfikowane empirycznie
+> (`src/bpp/tests/test_cache/test_soft_delete_preconditions.py`, oba kanarki
+> zielone na obecnym kodzie):
+> 1. funkcja refresh to **czysty upsert bez `DELETE`** → odfiltrowanie wiersza
+> z widoku daje **no-op**, stary wiersz przeżywa w `_mat`;
+> 2. **bramka `WHEN`** (migracja `0433`) nie zna `deleted_at`, a
+> `django-soft-delete` zapisuje przez `save(update_fields=[...])` → UPDATE
+> soft-delete **w ogóle nie dochodzi do funkcji triggera**.
+>
+> **Filtr widoku sam NIE wystarcza.** Ta faza robi trzy rzeczy, nie jedną —
+> patrz „Architecture" niżej. Wszystkie obowiązkowe.
+
+**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez wspólną bazę `BazaModeluOdpowiedzialnosciAutorow`), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, i doprowadzić do tego, by soft-deletowane autorstwa **znikały** z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i **wracały** po `restore`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować.
+
+**Architecture — trzy elementy, wszystkie obowiązkowe** (żaden nie wystarcza sam; uzasadnienie: §2.1 specu):
+
+1. **Filtr `deleted_at IS NULL` w widokach źródłowych** `bpp_wydawnictwo_ciagle_autorzy` / `bpp_wydawnictwo_zwarte_autorzy` / `bpp_patent_autorzy` — po **własnej** kolumnie tabeli `*_autor` (bez JOIN do rekordu nadrzędnego). Rola: (a) karmi `pg_depend` dla punktu 3, (b) chroni pełne przebudowy i odczyt przez `bpp_autorzy`. **Nie sprząta `_mat`** — to robi punkt 2.
+2. **Gałąź kasująca w 3 funkcjach `bpp_refresh_autor_()`** (`0432_cache_trigger_plpgsql.py`): prolog `IF NEW.deleted_at IS NOT NULL THEN DELETE FROM bpp_autorzy_mat WHERE id = ARRAY[ct, NEW.id]::integer[]; RETURN NULL; END IF;`. Wzorzec jest już w kodzie — gałąź doktorat/habilitacja w `_create_rekord_function` robi dokładnie `DELETE` + `INSERT`, bo tam wiersz też może wypaść ze źródła. Restore (`deleted_at → NULL`) przechodzi dalej do normalnego upsertu — symetria za darmo.
+3. **Regeneracja bramki `WHEN`** na 3 triggerach `*_cache_upd` — `RunPython` wołający logikę `forward()` z `0433_cache_trigger_when_gate.py`. `deleted_at` wejdzie do bramki **sama**, przez `pg_depend`, bo punkt 1 wstawił ją do `WHERE` widoku. **Kolejność w migracji: punkt 1 → punkt 3** (bramka czyta definicję widoku).
+
+Gałęzie `UNION` w `bpp_rekord` per typ publikacji NIE filtrują po `*_autor.deleted_at` — rekord publikacji żyje niezależnie od soft-delete pojedynczego autorstwa (soft-delete publikacji to faza 02).
+
+**Tech Stack:** Django 4.2, PostgreSQL (triggery **PL/pgSQL** + widoki), `django-soft-delete>=1.0.23` (`SoftDeleteModel`, `SoftDeleteManager`/`GlobalManager`/`DeletedManager`), pytest + model_bakery, `denorm` (django-denorm-iplweb). Python wyłącznie przez `uv run`.
**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§1, §2.1, §2.2, §8 pkt 1). Indeks: [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md).
@@ -28,13 +40,15 @@
- `Wydawnictwo_Ciagle_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_ciagle.py:52`. FK `rekord` → `Wydawnictwo_Ciagle`, `related_name="autorzy_set"`, `src/bpp/models/wydawnictwo_ciagle.py:58`.
- `Wydawnictwo_Zwarte_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_zwarte.py:60`. FK `rekord`, `related_name="autorzy_set"`, `:67`.
- `Patent_Autor(BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/patent.py:32`. FK `rekord`, `related_name="autorzy_set"`, `:35`.
-- Wszystkie 3 mają `Meta.unique_together` (NIE ruszamy; `deleted_at` nie wchodzi w `unique_together` — autorstwa nie mają warunkowego unique w tej fazie, sług to faza 02).
+- Wszystkie 3 mają `Meta.unique_together` — `("rekord","autor","typ_odpowiedzialnosci")` i `("rekord","autor","kolejnosc")`, np. `src/bpp/models/wydawnictwo_ciagle.py:73-77`. **W TEJ fazie NIE ruszamy**; zamiana na warunkowy `UniqueConstraint` (decyzja #13, §2.2b specu) idzie w fazie 02 razem ze slugiem.
- `BazaModeluOdpowiedzialnosciAutorow.objects` NIE jest jawnie zdefiniowany → po wpięciu `SoftDeleteModel` domyślne `objects` = `SoftDeleteManager` (z pakietu). Nadpiszemy je naszymi `Bpp*` z `src/bpp/models/soft_delete.py`.
-- `SoftDeleteModel.delete()` (pakiet, `django_softdelete/models.py`) robi **refleksyjną kaskadę** po reverse relacjach — dla `*_Autor` reverse relacji do soft-delete dzieci NIE ma (ich dzieci to nie-soft `Autor`/`Jednostka` przez FK forward), więc kaskada jest no-op. `delete()` woła `self.save(update_fields=['deleted_at','restored_at','transaction_id'])` → odpala trigger `bpp_*_autor_cache_trigger` jako `UPDATE`. To jest pożądane.
-- Widok `bpp_autorzy_mat` (model `Autorzy`, `src/bpp/models/cache/autorzy.py:39`, `db_table="bpp_autorzy_mat"`) zasilany triggerem z `bpp_autorzy` (UNION `bpp_*_autorzy`).
-- Aktualna funkcja triggera to `0399_fix_refresh_cache_upsert.sql` (NIE `0001_cache_functions.sql` — ta jest baseline, nadpisana przez 0399). Trigger-skip dopisujemy do **kopii treści 0399** w nowym pliku SQL.
+- `SoftDeleteModel.delete()` (pakiet, `django_softdelete/models.py`) robi **refleksyjną kaskadę** po reverse relacjach — dla `*_Autor` reverse relacji do soft-delete dzieci NIE ma (ich dzieci to nie-soft `Autor`/`Jednostka` przez FK forward), więc kaskada jest no-op. `delete()` woła `self.save(update_fields=['deleted_at','restored_at','transaction_id'])`. ⚠️ **Ten UPDATE dotyka WYŁĄCZNIE tych 3 kolumn** — dlatego bramka `WHEN` musi znać `deleted_at` (punkt 3 „Architecture"), inaczej trigger się nie odpali. `ostatnio_zmieniony` (`auto_now`) też NIE jest bumpowany (`update_fields` filtruje `pre_save`).
+- ⚠️ `strict` w pakiecie jest **asymetryczne**: `delete(strict=False)`, `restore(strict=True)`.
+- Tabela `bpp_autorzy_mat` (model `Autorzy`, `src/bpp/models/cache/autorzy.py:39`, `db_table="bpp_autorzy_mat"`) zasilana triggerami z widoków `bpp_*_autorzy`.
+- **Trigger (AKTUALNY, po PR #363):** `0432_cache_trigger_plpgsql.py` generuje 3 funkcje `bpp_refresh_autor_()` (upsert **bez** DELETE, `_create_through_function`) + 3 `bpp_delete_autor_()`, oraz triggery `_cache_ins` / `_cache_del` / `_cache_upd`. `0433_cache_trigger_when_gate.py` nakłada bramkę `WHEN` na `_cache_upd`, z listą kolumn wyliczoną z `pg_depend`. ⚠️ **Funkcja `bpp_refresh_cache()` NIE ISTNIEJE** — `DROP` w `0432`. Nie kopiować `0399` ani `0001_cache_functions.sql`.
+- Widoki `bpp_*_autorzy`: ostatnia wersja definicji w `0421_cache_trigger_pk_filter.sql` (dodaje `object_id_raw`). Odtwarzając widok, wychodź z `pg_get_viewdef()`, nie z `0001_widoki_autorzy.sql`.
- `transactional_db` fixture wymagany dla testów dotykających trigger/cache (trigger działa tylko z prawdziwym commitem). Fixture `denorms` (`src/fixtures/conftest_system.py:193`) daje `denorms.flush()`. Fixtury: `wydawnictwo_ciagle_z_dwoma_autorami`, `wydawnictwo_ciagle_z_autorem`, `autor_jan_kowalski`, `jednostka`, `standard_data`, `typy_odpowiedzialnosci`.
-- Jedyny liść migracji `bpp`: `0420_autor_pokazuj_siec_powiazan_and_more`. Nowe migracje od niego zależą i są łańcuchowane: `0421 → 0422 (SQL)`.
+- ⚠️ **Numeracja migracji (stan 2026-08-06):** liść to `0487_api_v1_przelaczniki`. Nowe migracje tej fazy: `0488_autor_soft_delete_fields` → `0489_soft_delete_autorzy_views` (SQL + regeneracja bramki). **Przed startem zweryfikuj liść ponownie** (`ls src/bpp/migrations/*.py | tail -3`) — `dev` żyje, numery mogły się przesunąć. Wszystkie numery w tym planie są orientacyjne; kanoniczna jest kolejność, nie cyfra.
**Kontrakt z reversion (PINNED):** soft-delete idzie WYŁĄCZNIE per-instancja przez `.delete()`/`.save()` (nigdy `queryset.update(deleted_at=...)`). `BppSoftDeleteQuerySet.update()` to egzekwuje fail-fast (gate). W tej fazie testujemy gate i kaskadę queryset-ową.
@@ -167,7 +181,7 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r
**Files:**
- Modify: `src/bpp/models/abstract/authors.py:16` (deklaracja klasy + managery), import `:1-13`.
-- Create: `src/bpp/migrations/0421_autor_soft_delete_fields.py`
+- Create: `src/bpp/migrations/0488_autor_soft_delete_fields.py`
- Test (create): `src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py`
**Steps:**
@@ -270,11 +284,11 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r
```bash
uv run python src/manage.py makemigrations bpp --name autor_soft_delete_fields
```
- (Spodziewany plik: `src/bpp/migrations/0421_autor_soft_delete_fields.py`, 3 pola × 3 modele = 9 `AddField`. Manager-y są `use_in_migrations=False` domyślnie, więc nie pojawią się w migracji.)
+ (Spodziewany plik: `src/bpp/migrations/0488_autor_soft_delete_fields.py`, 3 pola × 3 modele = 9 `AddField`. Manager-y są `use_in_migrations=False` domyślnie, więc nie pojawią się w migracji.)
-- [ ] Zweryfikuj treść wygenerowanej migracji — musi zawierać `AddField` `deleted_at`/`restored_at`/`transaction_id` dla `wydawnictwo_ciagle_autor`, `wydawnictwo_zwarte_autor`, `patent_autor`. Jeśli Django dorzuciło `AlterModelManagers` — usuń tę operację ręcznie (Edit), bo managery soft-delete nie idą do schematu. Dependency MUSI być `("bpp", "0420_autor_pokazuj_siec_powiazan_and_more")`.
+- [ ] Zweryfikuj treść wygenerowanej migracji — musi zawierać `AddField` `deleted_at`/`restored_at`/`transaction_id` dla `wydawnictwo_ciagle_autor`, `wydawnictwo_zwarte_autor`, `patent_autor`. Jeśli Django dorzuciło `AlterModelManagers` — usuń tę operację ręcznie (Edit), bo managery soft-delete nie idą do schematu. Dependency MUSI wskazywać na **aktualny liść** migracji `bpp` (na 2026-08-06: `("bpp", "0487_api_v1_przelaczniki")`) — zweryfikuj `ls src/bpp/migrations/*.py | tail -3` przed commitem.
-- [ ] Dodaj indeks na `deleted_at` do każdej z 3 tabel. Dopisz do `operations` w `0421_autor_soft_delete_fields.py` (po `AddField`-ach), używając `AddIndex`:
+- [ ] Dodaj indeks na `deleted_at` do każdej z 3 tabel. Dopisz do `operations` w `0488_autor_soft_delete_fields.py` (po `AddField`-ach), używając `AddIndex`:
```python
migrations.AddIndex(
model_name="wydawnictwo_ciagle_autor",
@@ -318,7 +332,7 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r
- [ ] Commit:
```bash
- git add src/bpp/models/abstract/authors.py src/bpp/migrations/0421_autor_soft_delete_fields.py src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py
+ git add src/bpp/models/abstract/authors.py src/bpp/migrations/0488_autor_soft_delete_fields.py src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py
git commit -m "feat(soft-delete): *_Autor → SoftDeleteModel + migracja pól deleted_at + indeks
Co-Authored-By: Claude Opus 4.8 (1M context) "
@@ -326,284 +340,266 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
---
-## Task 3 — Migracja SQL: filtr `deleted_at IS NULL` w widokach `bpp_*_autorzy` + trigger-skip
+## Task 3 — Widoki + gałąź kasująca w funkcjach refresh + regeneracja bramki
+
+> 🔄 **Task przepisany 2026-08-06.** Poprzednia wersja tworzyła kopię
+> `bpp_refresh_cache()` z `0399` z „trigger-skipem". **Tamta funkcja już nie
+> istnieje** (`DROP` w `0432`), a sam filtr widoku nie sprząta `_mat`. Nowa
+> wersja robi trzy rzeczy naraz — patrz „Architecture" na górze planu.
-Przedefiniowanie 3 widoków źródłowych (`bpp_wydawnictwo_ciagle_autorzy`, `bpp_wydawnictwo_zwarte_autorzy`, `bpp_patent_autorzy`) z filtrem po **własnej** kolumnie `deleted_at` tabeli `*_autor` (mechanizm #1, obowiązkowy). Po `DROP ... CASCADE` widoku `bpp_*_autorzy` trzeba odtworzyć też zależny `bpp_autorzy` (UNION). Dodatkowo trigger-skip (#2, opcjonalny) — przedefiniowanie `bpp_refresh_cache()` na bazie 0399 z regułą „deleted_at NOT NULL → delete-only". Migracja ładuje plik `.sql` wzorcem `0399`.
+Jedna migracja, trzy zmiany, w **wymuszonej kolejności**:
+
+1. przedefiniowanie 3 widoków `bpp_*_autorzy` z filtrem `deleted_at IS NULL`
+ po własnej kolumnie tabeli `*_autor` (+ odtworzenie zależnego `bpp_autorzy`,
+ bo `DROP ... CASCADE` go skasuje),
+2. przedefiniowanie 3 funkcji `bpp_refresh_autor_()` z prologiem
+ kasującym,
+3. regeneracja bramki `WHEN` na 3 triggerach `*_cache_upd` — **musi być po
+ kroku 1**, bo bramka czyta kolumny z `pg_depend` po definicji widoku.
**Files:**
-- Create: `src/bpp/migrations/0422_soft_delete_views.sql`
-- Create: `src/bpp/migrations/0422_soft_delete_views.py`
-- Test: pokrycie w Task 4 (testy spójności cache) — tu tylko migracja stosuje się czysto.
+- Create: `src/bpp/migrations/0489_soft_delete_autorzy_views.py` (numer
+ zweryfikuj — patrz „Fakty z kodu")
+- Test: `src/bpp/tests/test_soft_delete/test_views_sql.py` (nowy),
+ plus odwrócenie kanarków w
+ `src/bpp/tests/test_cache/test_soft_delete_preconditions.py` (Task 4)
+
+**Dlaczego migracja jest `RunPython`, a nie plik `.sql`:** definicje widoków
+i funkcji są **generowane z introspekcji** (`information_schema.columns`,
+`pg_depend`), a nie zapisane na sztywno. Kopiowanie ich do `.sql` odtworzyłoby
+dokładnie ten problem, który `0432`/`0433` rozwiązały — patrz
+`docs/superpowers/specs/2026-06-13-materialized-union-rekord-autorzy-design.md`.
**Steps:**
-- [ ] Napisz failing test smoke — dopisz do `src/bpp/tests/test_soft_delete/test_views_sql.py`:
+- [ ] Napisz failing testy — `src/bpp/tests/test_soft_delete/test_views_sql.py`:
```python
- """Widoki źródłowe bpp_*_autorzy filtrują po własnym deleted_at."""
+ """Kontrakt DDL po fazie 01: widok filtruje, funkcja kasuje, bramka przepuszcza."""
import pytest
from django.db import connection
-
WIDOKI = [
"bpp_wydawnictwo_ciagle_autorzy",
"bpp_wydawnictwo_zwarte_autorzy",
"bpp_patent_autorzy",
]
+ FUNKCJE = [
+ "bpp_refresh_autor_wydawnictwo_ciagle",
+ "bpp_refresh_autor_wydawnictwo_zwarte",
+ "bpp_refresh_autor_patent",
+ ]
+ TRIGGERY = [
+ ("bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_ciagle_autor_cache_upd"),
+ ("bpp_wydawnictwo_zwarte_autor", "bpp_wydawnictwo_zwarte_autor_cache_upd"),
+ ("bpp_patent_autor", "bpp_patent_autor_cache_upd"),
+ ]
@pytest.mark.django_db
@pytest.mark.parametrize("widok", WIDOKI)
- def test_widok_zrodlowy_ma_filtr_deleted_at(widok):
- """Definicja widoku w pg_get_viewdef musi zawierać 'deleted_at'
- (filtr po własnej kolumnie tabeli autorskiej)."""
+ def test_widok_zrodlowy_filtruje_po_deleted_at(widok):
with connection.cursor() as cur:
cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok])
defn = cur.fetchone()[0]
assert "deleted_at" in defn, f"{widok} nie filtruje po deleted_at"
+
+
+ @pytest.mark.django_db
+ @pytest.mark.parametrize("fn", FUNKCJE)
+ def test_funkcja_refresh_ma_galaz_kasujaca(fn):
+ """Bez DELETE odfiltrowanie z widoku jest no-opem (upsert nic nie usuwa)."""
+ with connection.cursor() as cur:
+ cur.execute("SELECT pg_get_functiondef(%s::regproc)", [fn])
+ src = cur.fetchone()[0]
+ assert "NEW.deleted_at IS NOT NULL" in src, f"{fn}: brak gałęzi kasującej"
+ assert "DELETE FROM bpp_autorzy_mat" in src, f"{fn}: brak DELETE"
+
+
+ @pytest.mark.django_db
+ @pytest.mark.parametrize("tabela,trigger", TRIGGERY)
+ def test_bramka_when_zna_deleted_at(tabela, trigger):
+ """Bez deleted_at w bramce UPDATE soft-delete nie dochodzi do funkcji."""
+ with connection.cursor() as cur:
+ cur.execute(
+ "SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t "
+ "WHERE t.tgrelid = %s::regclass AND t.tgname = %s",
+ [tabela, trigger],
+ )
+ row = cur.fetchone()
+ assert row is not None, f"brak triggera {trigger}"
+ assert "deleted_at" in row[0], f"{trigger}: bramka WHEN nie zna deleted_at"
```
-- [ ] Uruchom (oczekiwany FAIL — widoki jeszcze bez `deleted_at`):
+- [ ] Uruchom (oczekiwany FAIL — wszystkie 9 przypadków):
```bash
uv run pytest src/bpp/tests/test_soft_delete/test_views_sql.py -q
```
-- [ ] Utwórz `src/bpp/migrations/0422_soft_delete_views.sql`. Treść = 3 widoki `bpp_*_autorzy` z dodanym `AND .deleted_at IS NULL`, odtworzenie `bpp_autorzy` (UNION, bo `DROP CASCADE` go skasuje), oraz przedefiniowanie `bpp_refresh_cache()` skopiowane z `0399_fix_refresh_cache_upsert.sql` z dopisanym trigger-skip w gałęzi `UPDATE/INSERT`:
- ```sql
- BEGIN;
-
- -- ── Mechanizm #1 (OBOWIĄZKOWY): filtr deleted_at w widokach źródłowych ──
- -- Po DROP ... CASCADE widoku bpp_*_autorzy znika też zależny bpp_autorzy,
- -- więc odtwarzamy go niżej. Filtr po WŁASNEJ kolumnie deleted_at tabeli
- -- autorskiej (bez JOIN do rekordu nadrzędnego) — patrz spec §2.1.
-
- DROP VIEW IF EXISTS bpp_wydawnictwo_ciagle_autorzy CASCADE;
- CREATE OR REPLACE VIEW bpp_wydawnictwo_ciagle_autorzy AS
- select
- django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id,
- django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id,
- django_content_type.id AS content_type_id,
- rekord_id as object_id,
- autor_id,
- jednostka_id,
- kolejnosc,
- typ_odpowiedzialnosci_id,
- zapisany_jako
- from bpp_wydawnictwo_ciagle_autor, django_content_type
- WHERE django_content_type.model = 'wydawnictwo_ciagle'
- AND django_content_type.app_label = 'bpp'
- AND bpp_wydawnictwo_ciagle_autor.deleted_at IS NULL;
-
- DROP VIEW IF EXISTS bpp_wydawnictwo_zwarte_autorzy CASCADE;
- CREATE OR REPLACE VIEW bpp_wydawnictwo_zwarte_autorzy AS
- select
- django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id,
- django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id,
- django_content_type.id AS content_type_id,
- rekord_id as object_id,
- autor_id,
- jednostka_id,
- kolejnosc,
- typ_odpowiedzialnosci_id,
- zapisany_jako
- from bpp_wydawnictwo_zwarte_autor, django_content_type
- WHERE django_content_type.model = 'wydawnictwo_zwarte'
- AND django_content_type.app_label = 'bpp'
- AND bpp_wydawnictwo_zwarte_autor.deleted_at IS NULL;
-
- DROP VIEW IF EXISTS bpp_patent_autorzy CASCADE;
- CREATE OR REPLACE VIEW bpp_patent_autorzy AS
- select
- django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id,
- django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id,
- django_content_type.id AS content_type_id,
- rekord_id as object_id,
- autor_id,
- jednostka_id,
- kolejnosc,
- typ_odpowiedzialnosci_id,
- zapisany_jako
- from bpp_patent_autor, django_content_type
- WHERE django_content_type.model = 'patent'
- AND django_content_type.app_label = 'bpp'
- AND bpp_patent_autor.deleted_at IS NULL;
-
- -- Odtworzenie UNION bpp_autorzy (skasowany przez DROP ... CASCADE powyżej).
- -- bpp_praca_doktorska_autorzy / bpp_praca_habilitacyjna_autorzy NIE były
- -- ruszane (autorstwo doktoratu/habilitacji nie jest *_Autor SoftDeleteModel
- -- w tej fazie) — wciąż istnieją, więc UNION je dociągnie.
- DROP VIEW IF EXISTS bpp_autorzy;
- CREATE VIEW bpp_autorzy AS
- SELECT * FROM bpp_wydawnictwo_ciagle_autorzy
- UNION
- SELECT * FROM bpp_wydawnictwo_zwarte_autorzy
- UNION
- SELECT * FROM bpp_patent_autorzy
- UNION
- SELECT * FROM bpp_praca_doktorska_autorzy
- UNION
- SELECT * FROM bpp_praca_habilitacyjna_autorzy;
-
- -- ── Mechanizm #2 (OPCJONALNY): trigger-skip w bpp_refresh_cache() ──
- -- Kopia 0399_fix_refresh_cache_upsert.sql z jedną zmianą: w gałęzi
- -- UPDATE/INSERT, gdy nowy wiersz ma deleted_at IS NOT NULL, pomijamy upsert
- -- (zostaje samo DELETE z _mat). Filtr widoku #1 i tak pokrywa odczyt, ale to
- -- oszczędza no-op SELECT/INSERT przy kaskadzie soft-delete na *_Autor.
- CREATE OR REPLACE FUNCTION bpp_refresh_cache()
- RETURNS TRIGGER
- LANGUAGE plpython3u
- AS $$
- cache_key = "django_content_type_ver_1"
- columns_cache_key = "table_columns_ver_1"
- table_name = TD["table_name"]
- app_name, model_name = table_name.split("_", 1)
-
- refresh_rekord = True
- refresh_autor = False
-
- trigger_field_name = "new"
- if TD['event'] in ["DELETE", "UPDATE"]:
- trigger_field_name = "old"
-
- TABELE_AUTORSKIE = ['bpp_wydawnictwo_ciagle_autor', 'bpp_wydawnictwo_zwarte_autor', 'bpp_patent_autor']
- id_field_name = 'id'
- extra_where = ''
- if table_name in TABELE_AUTORSKIE:
- id_field_name = 'rekord_id'
- model_name = model_name.replace("_autor", "")
- refresh_autor = True
- refresh_rekord = False
- extra_where = ' AND autor_id = %s' % TD[trigger_field_name]['autor_id']
-
- object_id = TD[trigger_field_name][id_field_name]
-
- if GD.get(cache_key) is None:
- GD[cache_key] = {}
-
- if GD.get(columns_cache_key) is None:
- GD[columns_cache_key] = {}
-
- try:
- content_type_id = GD[cache_key][table_name]
- except KeyError:
- query = "SELECT id FROM django_content_type WHERE app_label = '%s' AND model = '%s'" % (app_name, model_name)
- res = plpy.execute(query)
- GD[cache_key][table_name] = res[0]['id']
- content_type_id = GD[cache_key][table_name]
-
- if TD["table_name"] in ["bpp_praca_doktorska", "bpp_praca_habilitacyjna"]:
- refresh_autor = True
-
- where = "WHERE %%s = ARRAY[%s, %s]::INTEGER[2]" % (content_type_id, object_id)
- where += extra_where
-
- # ── trigger-skip: soft-delete (UPDATE z deleted_at IS NOT NULL) ──
- # zachowuje się jak DELETE (samo wyczyszczenie _mat, bez re-insertu).
- skip_reinsert = (
- TD["event"] in ["UPDATE", "INSERT"]
- and TD["new"] is not None
- and TD["new"].get("deleted_at") is not None
- )
+- [ ] Zweryfikuj liść migracji i ustal numer:
+ ```bash
+ ls src/bpp/migrations/*.py | tail -3
+ ```
+
+- [ ] Utwórz `src/bpp/migrations/0489_soft_delete_autorzy_views.py`.
+ **Reużyj generatorów z `0432`/`0433`** zamiast przepisywać SQL — nazwy
+ modułów zaczynają się od cyfry, więc zwykły `import` nie zadziała; użyj
+ `importlib.import_module` (moduły mają na poziomie modułu wyłącznie stałe
+ i funkcje, więc import jest bezpieczny):
- refresh_tables = []
- if refresh_rekord:
- refresh_tables.append(("bpp_rekord_mat", "id"))
- refresh_tables.append(("bpp_autorzy_mat", "rekord_id"))
- if refresh_autor:
- if "bpp_autorzy_mat" not in [t for t, _ in refresh_tables]:
- refresh_tables.append(("bpp_autorzy_mat", "rekord_id"))
-
- def get_table_columns(mat_table):
- if mat_table not in GD[columns_cache_key]:
- query = """
- SELECT column_name
- FROM information_schema.columns
- WHERE table_schema = 'public'
- AND table_name = '%s'
- ORDER BY ordinal_position
- """ % mat_table
- res = plpy.execute(query)
- GD[columns_cache_key][mat_table] = [row['column_name'] for row in res]
- return GD[columns_cache_key][mat_table]
-
- def get_unique_constraint_column(mat_table):
- return "id"
-
- with plpy.subtransaction():
- for table, id_col in refresh_tables:
- lock_key = hash(f"{table}_{content_type_id}_{object_id}") % (2**31)
- plpy.execute(f"SELECT pg_advisory_xact_lock({lock_key})")
-
- if TD["event"] == "DELETE" or skip_reinsert:
- query = "DELETE FROM " + table + " " + (where % id_col)
- plpy.execute(query)
- elif TD["event"] in ["UPDATE", "INSERT"]:
- source_view = table.replace("_mat", "")
- columns = get_table_columns(table)
- conflict_col = get_unique_constraint_column(table)
- columns_str = ", ".join(columns)
- update_columns = [col for col in columns if col != conflict_col]
- set_clause = ", ".join([f"{col} = EXCLUDED.{col}" for col in update_columns])
- delete_query = "DELETE FROM " + table + " " + (where % id_col)
- plpy.execute(delete_query)
- select_query = f"SELECT {columns_str} FROM {source_view} " + (where % id_col)
- upsert_query = f"""
- INSERT INTO {table} ({columns_str})
- {select_query}
- ON CONFLICT ({conflict_col}) DO UPDATE SET {set_clause}
- """
- plpy.execute(upsert_query)
- $$;
-
- COMMIT;
- ```
- (UWAGA: `refresh_tables` w 0399 to lista krotek `(table, id_col)`, więc sprawdzenie `"bpp_autorzy_mat" not in refresh_tables` z 0399 było błędne dla krotek — tu poprawiamy na `not in [t for t, _ in refresh_tables]`. Reszta logiki 1:1 z 0399.)
-
-- [ ] Utwórz `src/bpp/migrations/0422_soft_delete_views.py` (wzorzec `0399`):
```python
- from pathlib import Path
+ """Soft-delete autorstw: filtr w widokach + gałąź kasująca + bramka WHEN.
+
+ Kolejność operacji jest WYMUSZONA:
+ 1) widoki (dodają deleted_at do WHERE) ->
+ 2) funkcje refresh (gałąź kasująca) ->
+ 3) regeneracja bramki WHEN (czyta pg_depend po definicji widoku z kroku 1)
+
+ Odwrócenie 1<->3 daje bramkę bez deleted_at, czyli cichy staleness:
+ soft-deletowane autorstwo zostaje w bpp_autorzy_mat.
+ """
+
+ import importlib
from django.db import connection, migrations
+ _p0432 = importlib.import_module("bpp.migrations.0432_cache_trigger_plpgsql")
+ _p0433 = importlib.import_module("bpp.migrations.0433_cache_trigger_when_gate")
+
+ THROUGH_SITES = _p0432.THROUGH_SITES # [(tabela, model), ...]
- def load_sql(apps, schema_editor):
- sql_file = Path(__file__).parent / "0422_soft_delete_views.sql"
- with open(sql_file) as f:
- sql = f.read()
- # connection.cursor() zamiast schema_editor.execute(): schema_editor
- # interpretuje %s jako placeholdery parametrów (a w plpython3u są %s
- # w stringach SQL budowanych ręcznie).
- with connection.cursor() as cursor:
- cursor.execute(sql)
+ def _viewdef(cur, widok):
+ cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok])
+ return cur.fetchone()[0].rstrip().rstrip(";")
- class Migration(migrations.Migration):
- dependencies = [
- ("bpp", "0421_autor_soft_delete_fields"),
- ]
+ def _filtruj_widok(cur, tabela, widok):
+ """Dokłada 'AND .deleted_at IS NULL' do widoku źródłowego.
- operations = [
- migrations.RunPython(load_sql, migrations.RunPython.noop),
- ]
+ Owijamy istniejącą definicję zamiast ją przepisywać: definicja jest
+ generowana (0421) i przepisanie jej ręcznie rozjechałoby się przy
+ następnej zmianie kolumn.
+ """
+ orig = _viewdef(cur, widok)
+ cur.execute(
+ f"CREATE OR REPLACE VIEW {widok} AS "
+ f"SELECT * FROM ({orig}) _orig "
+ f"WHERE _orig.object_id_raw NOT IN ("
+ f" SELECT id FROM {tabela} WHERE deleted_at IS NOT NULL)"
+ )
+ ```
+
+ ⚠️ **Do rozstrzygnięcia przy implementacji (nie zgaduj — zmierz):** czy
+ owijanie widoku (`SELECT * FROM (orig) WHERE object_id_raw NOT IN ...`)
+ zachowuje plan wykonania i czy `pg_depend` zarejestruje `deleted_at` jako
+ kolumnę bazową. Jeśli którekolwiek nie — wygeneruj definicję widoku od nowa
+ z listą kolumn i dopisz `AND .deleted_at IS NULL` do `WHERE`
+ wewnętrznego. **Test `test_bramka_when_zna_deleted_at` jest tu wyrocznią**:
+ jeśli po regeneracji bramka nie zna `deleted_at`, to znaczy że `pg_depend`
+ nie zobaczył kolumny przez owijkę.
+
+- [ ] Dopisz gałąź kasującą do funkcji refresh. Ciało generujemy tak jak
+ `_p0432._create_through_function`, ale z prologiem:
+
+ ```python
+ def _funkcja_z_galezia_kasujaca(cur, tabela, model):
+ autorzy_view = tabela[: -len("_autor")] + "_autorzy"
+ upsert = _p0432._upsert_sql(
+ cur, "bpp_autorzy_mat", autorzy_view,
+ "object_id_raw = NEW.rekord_id AND autor_id = NEW.autor_id",
+ )
+ ct_lookup = _p0432._ct_lookup(model)
+ return f"""
+ CREATE OR REPLACE FUNCTION bpp_refresh_autor_{model}() RETURNS trigger
+ LANGUAGE plpgsql AS $bpp_body$
+ DECLARE ct integer;
+ BEGIN
+ {ct_lookup}
+ PERFORM pg_advisory_xact_lock(ct, NEW.rekord_id);
+ IF NEW.deleted_at IS NOT NULL THEN
+ DELETE FROM bpp_autorzy_mat WHERE id = ARRAY[ct, NEW.id]::integer[];
+ RETURN NULL;
+ END IF;
+ {upsert};
+ RETURN NULL;
+ END $bpp_body$;
+ """
+ ```
+
+ Uwagi:
+ - klucz `ARRAY[ct, NEW.id]` jest identyczny jak w
+ `_create_delete_through_function` (`0432`) — tam `OLD.id`, tu `NEW.id`;
+ przy UPDATE to ten sam wiersz;
+ - `pg_advisory_xact_lock` **przed** rozgałęzieniem — kasowanie musi brać ten
+ sam lock co upsert, inaczej wraca wyścig z #309;
+ - restore (`deleted_at` → NULL) leci normalną ścieżką upsertu — nic
+ dodatkowego nie trzeba.
+
+- [ ] Złóż `forward()` w wymuszonej kolejności i `backward()` przywracający
+ stan sprzed migracji:
+
+ ```python
+ def forward(apps, schema_editor):
+ with connection.cursor() as cur:
+ for tabela, model in THROUGH_SITES: # 1) widoki
+ _filtruj_widok(cur, tabela, tabela[: -len("_autor")] + "_autorzy")
+ for tabela, model in THROUGH_SITES: # 2) funkcje
+ cur.execute(_funkcja_z_galezia_kasujaca(cur, tabela, model))
+ _regeneruj_bramke() # 3) bramka
+
+
+ def _regeneruj_bramke():
+ """Ta sama logika co 0433.forward -- po zmianie widoku pg_depend zna
+ juz deleted_at, wiec bramka wciagnie ja sama."""
+ with connection.cursor() as cur:
+ for tabela, refresh_fn, widoki in _p0433.GATED:
+ if not tabela.endswith("_autor"):
+ continue # publikacje to faza 02
+ kolumny = _p0433._gate_columns(cur, tabela, widoki)
+ when = _p0433._when_clause(kolumny)
+ cur.execute(f"DROP TRIGGER IF EXISTS {tabela}_cache_upd ON {tabela};")
+ cur.execute(
+ f"CREATE TRIGGER {tabela}_cache_upd AFTER UPDATE ON {tabela} "
+ f"FOR EACH ROW WHEN ({when}) "
+ f"EXECUTE PROCEDURE {refresh_fn}();"
+ )
```
-- [ ] Uruchom test smoke (oczekiwany PASS — migracja zastosuje się przy starcie testowej bazy, widoki będą miały `deleted_at`):
+ ⚠️ `_gate_columns` z `0433` **rzuca `RuntimeError`, gdy `pg_depend` nie
+ zwróci kolumn** — to celowy bezpiecznik (nie tworzy niebramkowanego UPDATE).
+ Nie obchodź go; jeśli wystąpi, znaczy że krok 1 nie zadziałał.
+
+- [ ] `backward`: odtwórz widoki bez filtra (`pg_get_viewdef` sprzed owijki nie
+ jest dostępny — wygeneruj z `_p0432`/`0421` albo zapisz oryginał w migracji),
+ funkcje przez `_p0432._create_through_function`, bramkę przez ponowne
+ `_regeneruj_bramke()` (po cofnięciu widoku `deleted_at` zniknie z `pg_depend`
+ samo). **Migracja MUSI być odwracalna** — testy migracji w CI to sprawdzają.
+
+- [ ] Uruchom testy kontraktu DDL (oczekiwany PASS — 9/9):
```bash
uv run pytest src/bpp/tests/test_soft_delete/test_views_sql.py -q
```
-- [ ] Zweryfikuj brak driftu migracji i czystość modeli:
+- [ ] Sprawdź brak driftu migracji:
```bash
- uv run python src/manage.py makemigrations bpp --check --dry-run
+ DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run
```
- [ ] Commit:
```bash
- git add src/bpp/migrations/0422_soft_delete_views.sql src/bpp/migrations/0422_soft_delete_views.py src/bpp/tests/test_soft_delete/test_views_sql.py
- git commit -m "feat(soft-delete): filtr deleted_at w widokach bpp_*_autorzy + trigger-skip
+ git add src/bpp/migrations/0489_soft_delete_autorzy_views.py src/bpp/tests/test_soft_delete/test_views_sql.py
+ git commit -m "feat(soft-delete): widoki + galaz kasujaca + bramka WHEN dla *_Autor
-Co-Authored-By: Claude Opus 4.8 (1M context) "
+Trzy zmiany w jednej migracji, w wymuszonej kolejnosci: filtr deleted_at
+w widokach bpp_*_autorzy -> galaz kasujaca w funkcjach bpp_refresh_autor_*
+-> regeneracja bramki WHEN (pg_depend zna juz deleted_at).
+
+Zadna z nich nie wystarcza sama: filtr widoku nie sprzata _mat (upsert bez
+DELETE = no-op), a bez deleted_at w bramce UPDATE soft-delete w ogole nie
+dochodzi do funkcji triggera.
+
+Co-Authored-By: Claude Opus 5 (1M context) "
```
---
@@ -614,15 +610,34 @@ Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat`
**Files:**
- Test (create): `src/bpp/tests/test_soft_delete/test_cache_consistency.py`
+- Test (modify): `src/bpp/tests/test_cache/test_soft_delete_preconditions.py` — **odwrócenie kanarków**
- Modify (jeśli testy ujawnią drift): brak planowanych — testy mają przejść na implementacji z Task 2-3.
**Steps:**
+- [ ] **Odwróć kanarki warunków wstępnych.** `test_soft_delete_preconditions.py`
+ przypina stan „soft-delete by nie zadziałał" i jest zielony PRZED tą fazą.
+ Po Task 3 musi asertować stan docelowy:
+ - `test_update_samego_deleted_at_nie_odpala_triggera` → **zmień na**
+ `test_update_samego_deleted_at_odpala_trigger`: `ctid` ma zniknąć
+ (wiersz usunięty z `_mat`), nie pozostać bez zmian;
+ - `test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat` → **zmień na**
+ `test_soft_delete_usuwa_wiersz_z_mat`: po `UPDATE ... SET deleted_at`
+ `_ctid(...)` ma być `None`.
+
+ Testy operują na `bpp_wydawnictwo_ciagle` (publikacja), więc **naprawdę
+ zazielenią się dopiero po fazie 02**. W fazie 01 napisz ich odpowiedniki
+ dla `bpp_wydawnictwo_ciagle_autor` / `bpp_autorzy_mat` (kolumna `deleted_at`
+ już istnieje po Task 2, więc pomocnicze `_dodaj_deleted_at` znika), a
+ oryginalne zostaw jako czerwone-oczekiwane z `@pytest.mark.xfail(reason=
+ "faza 02 — soft-delete publikacji")`. **NIE kasuj ich** — to jedyny
+ regresyjny dowód, że bramka i gałąź kasująca działają.
+
- [ ] Napisz testy spójności — `src/bpp/tests/test_soft_delete/test_cache_consistency.py`:
```python
"""Spójność materializowanego cache (bpp_autorzy_mat / model Autorzy) po
- soft-delete wierszy *_Autor. Wymaga transactional_db — trigger plpython3u
- odpala się dopiero przy realnym commicie."""
+ soft-delete wierszy *_Autor. Wymaga transactional_db — trigger odpala się
+ dopiero przy realnym commicie."""
import pytest
@@ -728,7 +743,12 @@ Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat`
```bash
uv run pytest src/bpp/tests/test_soft_delete/test_cache_consistency.py -q
```
- Jeśli `test_edycja_skasowanego_autorstwa_nie_wskrzesza_w_mat` FAIL → znaczy, że trigger-skip lub filtr widoku nie działa. Diagnoza: sprawdź `pg_get_viewdef('bpp_wydawnictwo_ciagle_autorzy')` (czy `deleted_at IS NULL` obecne) — to obowiązkowy mechanizm #1; trigger-skip sam nie wystarcza dla tej ścieżki (potwierdza spec §2.1). Użyj superpowers:systematic-debugging, NIE łataj testu.
+ Jeśli którykolwiek FAIL → **zdiagnozuj który z trzech elementów nie zadziałał**, w tej kolejności (każdy warunkuje następny):
+ 1. `SELECT pg_get_triggerdef(...)` — czy bramka `WHEN` zna `deleted_at`? Jeśli nie, trigger w ogóle się nie odpalił i reszta diagnostyki jest bez sensu (to najczęstsza przyczyna: krok 1 migracji nie wstawił kolumny do `pg_depend`).
+ 2. `SELECT pg_get_functiondef('bpp_refresh_autor_wydawnictwo_ciagle'::regproc)` — czy jest gałąź `IF NEW.deleted_at IS NOT NULL ... DELETE`? Bez niej upsert jest no-opem i wiersz zostaje.
+ 3. `SELECT pg_get_viewdef('bpp_wydawnictwo_ciagle_autorzy'::regclass, true)` — czy filtr `deleted_at` obecny?
+
+ Użyj superpowers:systematic-debugging, NIE łataj testu.
- [ ] Commit:
```bash
@@ -776,8 +796,10 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
## Założenia i ostrzeżenia między-fazowe (dla faz 02+)
-1. **Domyślny manager `*_Autor.objects` zmienił klasę** na `BppSoftDeleteManager` (filtruje `deleted_at__isnull=True`). Faza 03 (audyt kat. B) MUSI przejść 90 miejsc `*_Autor.objects` — w fazie 01 nic nie jest skasowane, więc filtr jest no-op, ale od fazy 02 (kaskada soft-delete publikacji) zacznie ukrywać. Guard autora (faza 04) MUSI liczyć przez `global_objects` (spec §3.2).
-2. **Trigger-skip oparty na 0399**, nie 0001. Każda przyszła zmiana `bpp_refresh_cache()` musi wychodzić od `0422_soft_delete_views.sql` (nie od 0399 ani 0001). Naprawiono przy okazji błąd `"bpp_autorzy_mat" not in refresh_tables` (lista krotek) z 0399 — zweryfikować, czy 0399 faktycznie nie dublował `bpp_autorzy_mat` (jeśli dublował, to drobny regres wydajności, nie poprawności).
+1. **Domyślny manager `*_Autor.objects` zmienił klasę** na `BppSoftDeleteManager` (filtruje `deleted_at__isnull=True`). Faza 03 (audyt kat. B) MUSI przejść **128** miejsc `*_Autor.objects` (stan 2026-08-06; spec mówił „90" — przelicz przed startem fazy 03: `grep -rn --include='*.py' -E "(Wydawnictwo_Ciagle_Autor|Wydawnictwo_Zwarte_Autor|Patent_Autor)\.objects" src/ | wc -l`). W fazie 01 nic nie jest skasowane, więc filtr jest no-op, ale od fazy 02 (kaskada soft-delete publikacji) zacznie ukrywać. Guard autora (faza 04) MUSI liczyć przez `global_objects` (spec §3.2).
+2. **Faza 02 powtarza ten sam trójskładnikowy wzorzec dla 5 tabel publikacji**: filtr `deleted_at` w `bpp_*_view` → gałąź kasująca w `bpp_refresh_rekord_()` (`DELETE FROM bpp_rekord_mat`) → regeneracja bramki `WHEN`. Uwaga na doktorat/habilitację: ich funkcje refresh dotykają **obu** tabel `_mat` (`bpp_rekord_mat` i `bpp_autorzy_mat`, bo autor leży na wierszu publikacji) — gałąź kasująca musi czyścić obie.
+3. **Każda przyszła zmiana definicji widoku źródłowego wymaga regeneracji bramki `WHEN`.** Bramka jest wypiekana z `pg_depend` w momencie migracji, więc nie zaktualizuje się sama. Pominięcie = cichy staleness. Testy `test_views_sql.py` i kanarki `test_soft_delete_preconditions.py` to wyłapią.
3. **Widoki `bpp_praca_doktorska_autorzy` / `bpp_praca_habilitacyjna_autorzy` NIE filtrowane** — autorstwo doktoratu/habilitacji nie jest `*_Autor` SoftDeleteModel (autor doktoratu to FK `Praca_Doktorska.autor`, nie through). Faza 02 (soft-delete publikacji doktorat/habilitacja) musi zadbać o ich zniknięcie z `bpp_rekord` przez własne `deleted_at` na tabeli publikacji — to NIE jest pokryte tą fazą.
4. **Gałęzie UNION `bpp_rekord` NIE dotknięte** w fazie 01 — soft-delete publikacji (kolumna `deleted_at` na `bpp_wydawnictwo_ciagle` itd.) to faza 02; dopiero ona doda filtr `deleted_at IS NULL` do `bpp_*_view`. Faza 01 dotyka wyłącznie ścieżki autorstwa.
-5. **`unique_together` na `*_Autor` zachowane bez `deleted_at`** — w tej fazie autorstwa nie mają warunkowego unique. Jeśli przyszła faza pozwoli na re-add tego samego autora po soft-delete (kolizja `(rekord, autor, typ_odpowiedzialnosci)`), trzeba będzie przejść na `UniqueConstraint(condition=Q(deleted_at__isnull=True))` — odłożone, poza zakresem 01.
+6. **`unique_together` na `*_Autor` zachowane bez `deleted_at`** — w tej fazie autorstwa nie mają warunkowego unique. **Faza 02 MUSI to zmienić** (decyzja #13, spec §2.2b): soft-deletowany wiersz nadal zajmuje slot w `(rekord, autor, typ_odpowiedzialnosci)` / `(rekord, autor, kolejnosc)`, a `deduplikator_autorow` przenoszący autorstwa trafi wtedy w `IntegrityError` o niewidoczny rekord. Przejście na `UniqueConstraint(condition=Q(deleted_at__isnull=True))` — razem ze slugiem.
+7. **`Cache_Punktacja_*` NIE są dotknięte** żadnym mechanizmem tej fazy (nie mają FK do publikacji ani triggerów cache). Domknięcie tej luki to faza 06 (decyzja #15, spec §2.5b) — do tego czasu soft-deletowana praca nadal liczyłaby się do ewaluacji.
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
index a88c879f3..10e16114a 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
@@ -4,7 +4,7 @@
**Goal:** Uczynić 5 modeli publikacji (`Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`, `Praca_Habilitacyjna`, `Patent`) `SoftDeleteModel`-ami z **wąską, kontrolowaną kaskadą** soft-delete na własne wiersze `*_Autor` (`Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) pod wspólnym `transaction_id`, **bez** refleksyjnej kaskady pakietu (która ruszyłaby `*_Streszczenie` itd.). Zamienić `slug unique=True` na warunkowy `UniqueConstraint` (reuse slug po soft-delete). Przepleść filtr soft-delete z istniejącymi menedżerami `Wydawnictwo_*_Manager` (mixin opłat) przez wspólny QuerySet/MRO, bez nadpisywania metod fees.
-**Architecture:** `django-soft-delete` daje `SoftDeleteModel` (pola `deleted_at`/`restored_at`/`transaction_id`, menedżery `objects`/`global_objects`/`deleted_objects`, sygnały `post_soft_delete`/`post_restore`/`post_hard_delete`). Faza 01 utworzyła `src/bpp/models/soft_delete.py` z `BppSoftDeleteQuerySet` (gate na bulk `update(deleted_at=...)`), `BppSoftDeleteManager`, `BppGlobalManager` oraz uczyniła 3 modele `*_Autor` SoftDeleteModel-ami (+ filtr `deleted_at` w widokach źródłowych). **Ta faza zależy od 01.** Tu nadpisujemy `delete()`/`restore()` na 5 modelach: per-instancja `save()` (NIGDY bulk `update`), jawna wąska kaskada na `autorzy_set` (related_name `*_Autor`→publikacja) przez `.delete(transaction_id=...)`/`.restore(transaction_id=...)` na każdym wierszu (kontrakt z reversion: zawsze per-instancja).
+**Architecture:** `django-soft-delete` daje `SoftDeleteModel` (pola `deleted_at`/`restored_at`/`transaction_id`, menedżery `objects`/`global_objects`/`deleted_objects`, sygnały `post_soft_delete`/`post_restore`/`post_hard_delete`). Faza 01 utworzyła `src/bpp/models/soft_delete.py` z `BppSoftDeleteQuerySet` (gate na bulk `update(deleted_at=...)`), `BppSoftDeleteManager`, `BppGlobalManager` oraz uczyniła 3 modele `*_Autor` SoftDeleteModel-ami — wraz z DDL-em dla **ścieżki autorstwa** (widoki `bpp_*_autorzy` + gałąź kasująca w `bpp_refresh_autor_*` + bramka `WHEN`). ⚠️ **Dla 5 tabel publikacji ten sam DDL trzeba zrobić w TEJ fazie** — Task 2b. **Ta faza zależy od 01.** Tu nadpisujemy `delete()`/`restore()` na 5 modelach: per-instancja `save()` (NIGDY bulk `update`), jawna wąska kaskada na `autorzy_set` (related_name `*_Autor`→publikacja) przez `.delete(transaction_id=...)`/`.restore(transaction_id=...)` na każdym wierszu (kontrakt z reversion: zawsze per-instancja).
**Tech Stack:** Django, PostgreSQL, `django-soft-delete>=1.0.23`, `django-denorm-iplweb` (slug jest polem `@denormalized`!), pytest + `model_bakery.baker`. Python wyłącznie przez `uv run`. Linia ≤88 znaków (ruff). Komentarze/komunikaty po polsku.
@@ -28,7 +28,7 @@
- `Praca_Doktorska.autor` FK CASCADE (`praca_doktorska.py:136`), `Praca_Habilitacyjna.autor` O2O PROTECT (`praca_habilitacyjna.py:42`). Te FK to **faza 04** — NIE ruszamy tu.
- **`slug` jest polem `@denormalized(models.SlugField, max_length=400, unique=True, db_index=True, null=True, blank=True)`** (denorm z `django-denorm-iplweb`), w: `wydawnictwo_ciagle.py:246`, `wydawnictwo_zwarte.py:325` (w `Wydawnictwo_Zwarte`), `patent.py:180`, `praca_doktorska.py:105` (w `Praca_Doktorska_Baza` → dziedziczone przez `Praca_Doktorska` **i** `Praca_Habilitacyjna`). Denorm field jest fizyczną kolumną w DB → migracja zmiany `unique=True`→`UniqueConstraint` jest realną migracją schematu.
- `Praca_Habilitacyjna` i `Praca_Doktorska` dziedziczą slug z `Praca_Doktorska_Baza` (abstract) — zmiana atrybutu pola w abstrakcie dotyka OBU modeli; migracje per model (każdy ma własną kolumnę `slug`).
-- Następny numer migracji: `0421` (ostatnia: `0420_autor_pokazuj_siec_powiazan_and_more.py`). NIE modyfikuj istniejących migracji.
+- ⚠️ **Numeracja migracji (stan 2026-08-06):** faza 01 zajmuje `0488`/`0489`, więc ta faza startuje od `0490`. **Zweryfikuj liść przed startem** (`ls src/bpp/migrations/*.py | tail -3`) — `dev` żyje. Numery w tym planie są orientacyjne; kanoniczna jest kolejność. NIE modyfikuj istniejących migracji.
- `Zgloszenie_Publikacji` (`src/zglos_publikacje/models.py:60`) — precedens: po prostu dziedziczy `SoftDeleteModel` bez własnego menedżera.
## Kontrakt z reversion (PINNED — NIE łamać)
@@ -91,11 +91,13 @@ Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-insta
"""Wąska, kontrolowana kaskada soft-delete: rodzic + własne wiersze
`*_Autor` (related_name `autorzy_set`) pod wspólnym `transaction_id`.
- NIE używa refleksyjnej kaskady pakietu (rzuciłaby SoftDeleteException
- na `*_Streszczenie`/`*_Zewnetrzna_Baza_Danych`/`Publikacja_Habilitacyjna`
- przy strict=True, albo twardo skasowała je przy strict=False). Kaskada
- zatrzymuje się na `*_Autor`. Kontrakt z reversion: zawsze per-instancja
- save()/delete(), NIGDY bulk update(deleted_at=...).
+ NIE uzywa refleksyjnej kaskady pakietu. UWAGA: delete() pakietu ma
+ DOMYSLNIE strict=False (a restore() -- strict=True), wiec kaskada NIE
+ krzyknelaby SoftDeleteException na *_Streszczenie /
+ *_Zewnetrzna_Baza_Danych / Publikacja_Habilitacyjna -- po cichu by po
+ nich przejechala. Kaskada zatrzymuje sie na *_Autor. Kontrakt z
+ reversion: zawsze per-instancja save()/delete(), NIGDY bulk
+ update(deleted_at=...).
"""
class Meta:
@@ -161,7 +163,7 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
- Modify: `src/bpp/models/patent.py:62` (klasa `Patent`)
- Modify: `src/bpp/models/praca_doktorska.py:135` (klasa `Praca_Doktorska`)
- Modify: `src/bpp/models/praca_habilitacyjna.py:41` (klasa `Praca_Habilitacyjna`)
-- Create: `src/bpp/migrations/0421_publikacje_soft_delete_fields.py`
+- Create: `src/bpp/migrations/0490_publikacje_soft_delete_fields.py`
- Test path: `src/bpp/tests/test_soft_delete_publikacje.py`
> **Kolejność MRO:** mixin dopisujemy jako **pierwszą** bazę (przed pozostałymi mixinami modelu), żeby jego `delete()`/`restore()` wygrały w MRO nad `models.Model.delete()`. NIE jako ostatnią. `BppPublikacjaSoftDeleteMixin(SoftDeleteModel)` wnosi też pola `deleted_at`/`restored_at`/`transaction_id` i menedżery — ale menedżery dla `Wydawnictwo_*` nadpiszemy w Task 4 (interleaving fees); dla `Patent`/`Praca_*` zostaną menedżery z `SoftDeleteModel`.
@@ -184,7 +186,7 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
```bash
uv run python src/manage.py makemigrations bpp --name publikacje_soft_delete_fields
```
- Oczekiwane: nowa migracja `0421_publikacje_soft_delete_fields.py` z `AddField` `deleted_at`/`restored_at`/`transaction_id` dla 5 modeli. Zweryfikuj nazwę pliku (`0421_`); jeśli numer inny — użyj faktycznego.
+ Oczekiwane: nowa migracja `0490_publikacje_soft_delete_fields.py` z `AddField` `deleted_at`/`restored_at`/`transaction_id` dla 5 modeli. Zweryfikuj nazwę pliku (`0490_`); jeśli numer inny — użyj faktycznego.
- [ ] **Krok 2.3 — dopisz indeks per model na `deleted_at`.** Do wygenerowanej migracji dołóż operacje `AddIndex` (lub edytuj `Meta.indexes` modeli i przegeneruj). Ręcznie w migracji, po `AddField`-ach:
```python
from django.db import migrations, models
@@ -212,7 +214,7 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
Oczekiwane: PASS.
- [ ] **Krok 2.6 — commit:**
```bash
- git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0421_publikacje_soft_delete_fields.py
+ git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0490_publikacje_soft_delete_fields.py
git commit -m "feat(soft-delete): 5 modeli publikacji -> SoftDeleteModel + migracje pol/indeksow
Co-Authored-By: Claude Opus 4.8 (1M context) "
@@ -227,7 +229,7 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
- Modify: `src/bpp/models/wydawnictwo_zwarte.py:325`
- Modify: `src/bpp/models/patent.py:180`
- Modify: `src/bpp/models/praca_doktorska.py:105` (w `Praca_Doktorska_Baza`)
-- Create: `src/bpp/migrations/0422_publikacje_slug_warunkowy_unique.py`
+- Create: `src/bpp/migrations/0491_publikacje_slug_warunkowy_unique.py`
- Test path: `src/bpp/tests/test_soft_delete_publikacje.py`
Zamiana `unique=True` na `models.UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True), name="...")` per model. Skasowany rekord trzyma slug → nowy rekord z tym samym slug-iem nie koliduje (constraint pomija `deleted_at IS NOT NULL`).
@@ -274,7 +276,7 @@ Zamiana `unique=True` na `models.UniqueConstraint(fields=["slug"], condition=Q(d
```bash
uv run python src/manage.py makemigrations bpp --name publikacje_slug_warunkowy_unique
```
- Oczekiwane: `RemoveField`/`AlterField` (zdjęcie `unique`) + `AddConstraint` dla 5 modeli. Zweryfikuj numer `0422_`.
+ Oczekiwane: `RemoveField`/`AlterField` (zdjęcie `unique`) + `AddConstraint` dla 5 modeli. Zweryfikuj numer `0491_`.
- [ ] **Krok 3.5 — uruchom test (PASS) + check migracji:**
```bash
uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_reuse_slug_po_soft_delete -x
@@ -283,7 +285,7 @@ Zamiana `unique=True` na `models.UniqueConstraint(fields=["slug"], condition=Q(d
Oczekiwane: PASS + `No changes detected`.
- [ ] **Krok 3.6 — commit:**
```bash
- git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0422_publikacje_slug_warunkowy_unique.py
+ git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0491_publikacje_slug_warunkowy_unique.py
git commit -m "feat(soft-delete): slug -> warunkowy UniqueConstraint (reuse po soft-delete)
Co-Authored-By: Claude Opus 4.8 (1M context) "
@@ -291,6 +293,137 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
---
+## Task 2b: Widoki + gałąź kasująca + bramka `WHEN` dla 5 tabel publikacji (LUKA)
+
+> 🔄 **Dodane 2026-08-06.** Poprzednia wersja planu zakładała, że fazа 01
+> „dodała filtr `deleted_at` w widokach `bpp_rekord`/`bpp_*_autorzy`" i że
+> ta faza nie musi ruszać DDL-a. **To nieprawda:** faza 01 dotyka wyłącznie
+> ścieżki autorstwa (3 tabele `*_autor`). Dla 5 tabel publikacji ten sam
+> trójskładnikowy wzorzec trzeba powtórzyć TUTAJ — inaczej soft-delete
+> publikacji nie usunie jej z `bpp_rekord_mat`.
+
+Powtórz wzorzec z fazy 01, Task 3 (tam jest pełny opis mechanizmu i pułapek),
+dla `REKORD_SITES` z `0432_cache_trigger_plpgsql.py`:
+
+1. filtr `deleted_at IS NULL` w 5 widokach `bpp__view`,
+2. gałąź kasująca w 5 funkcjach `bpp_refresh_rekord_()`,
+3. regeneracja bramki `WHEN` na 5 triggerach `_cache_upd`.
+
+**Files:**
+- Create: `src/bpp/migrations/0493_soft_delete_rekord_views.py`
+- Test: `src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py`
+- Test (modify): `src/bpp/tests/test_cache/test_soft_delete_preconditions.py` —
+ **zdejmij `xfail`** z oryginalnych kanarków i odwróć ich asercje (operują na
+ `bpp_wydawnictwo_ciagle`, więc dopiero ta faza je zazieleni).
+
+⚠️ **Różnica wobec fazy 01 — doktorat i habilitacja dotykają OBU tabel `_mat`.**
+`_create_rekord_function` z `0432` generuje dla nich (`autor_na_wierszu=True`)
+dodatkowy blok na `bpp_autorzy_mat`, bo autor leży na wierszu publikacji.
+Gałąź kasująca musi wyczyścić **obie**:
+
+```sql
+IF NEW.deleted_at IS NOT NULL THEN
+ DELETE FROM bpp_rekord_mat WHERE id = ARRAY[ct, NEW.id]::integer[];
+ DELETE FROM bpp_autorzy_mat WHERE rekord_id = ARRAY[ct, NEW.id]::integer[];
+ RETURN NULL;
+END IF;
+```
+
+(dla `wydawnictwo_ciagle`/`wydawnictwo_zwarte`/`patent` — tylko `bpp_rekord_mat`;
+ich autorstwa czyści kaskada na `*_Autor` z Task 1 + gałąź z fazy 01).
+
+⚠️ **Zwróć uwagę na klucz:** w `bpp_rekord_mat` kolumna nazywa się `id`,
+w `bpp_autorzy_mat` — `rekord_id` (patrz `_create_rekord_function`
+i `_create_delete_rekord_function` w `0432`).
+
+- [ ] Testy kontraktu DDL (wzorzec z fazy 01, Task 3) dla 5 widoków, 5 funkcji,
+ 5 triggerów — oczekiwany FAIL przed migracją.
+- [ ] Migracja `RunPython` w kolejności widoki → funkcje → bramka.
+- [ ] Odwrócone kanarki `test_soft_delete_preconditions.py` — PASS.
+- [ ] `makemigrations --check --dry-run` — brak driftu.
+
+---
+
+## Task 3b: `unique_together` na `*_Autor` → warunkowy `UniqueConstraint` (decyzja #13)
+
+> Dodane 2026-08-06 (spec §2.2b). Ten sam problem co ze slugiem, przeoczony
+> w pierwszej wersji planu.
+
+Soft-deletowany wiersz `*_Autor` **nadal zajmuje slot w unique**, będąc
+niewidocznym dla operatora. Od Task 1 tej fazy kaskada soft-deletuje wiersze
+`*_Autor` masowo, więc kolizja przestaje być teoretyczna: `deduplikator_autorow`
+przenosi autorstwa z duplikatu na autora głównego
+(`src/deduplikator_autorow/utils/merge.py:191,284,354`) i trafia w
+soft-deletowany wiersz o tej samej trójce → `IntegrityError` o rekord,
+którego nie widać.
+
+**Files:**
+- Modify: `src/bpp/models/wydawnictwo_ciagle.py:73-77` (`Wydawnictwo_Ciagle_Autor.Meta`)
+- Modify: `src/bpp/models/wydawnictwo_zwarte.py` (`Wydawnictwo_Zwarte_Autor.Meta`)
+- Modify: `src/bpp/models/patent.py` (`Patent_Autor.Meta`)
+- Create: `src/bpp/migrations/0492_autor_warunkowy_unique.py`
+- Test: `src/bpp/tests/test_soft_delete/test_autor_unique.py`
+
+- [ ] **Krok 3b.1 — padający test: re-add autorstwa po soft-delete.**
+ ```python
+ @pytest.mark.django_db
+ def test_readd_autorstwa_po_soft_delete(wydawnictwo_ciagle_z_autorem, autor_jan_kowalski):
+ """Po soft-delete autorstwa da się dodać to samo powiązanie ponownie."""
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ rekord, autor, typ, kolejnosc = (
+ wca.rekord, wca.autor, wca.typ_odpowiedzialnosci, wca.kolejnosc,
+ )
+ wca.delete()
+
+ # bez warunkowego constraintu: IntegrityError na (rekord, autor, typ)
+ Wydawnictwo_Ciagle_Autor.objects.create(
+ rekord=rekord, autor=autor, jednostka=wca.jednostka,
+ typ_odpowiedzialnosci=typ, kolejnosc=kolejnosc,
+ )
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(rekord=rekord).count() == 1
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(rekord=rekord).count() == 2
+ ```
+
+- [ ] **Krok 3b.2 — zamiana w `Meta` 3 modeli.** Usuń `unique_together`, dodaj:
+ ```python
+ constraints = [
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "typ_odpowiedzialnosci"],
+ condition=Q(deleted_at__isnull=True),
+ name="wc_autor_uniq_rekord_autor_typ",
+ ),
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "kolejnosc"],
+ condition=Q(deleted_at__isnull=True),
+ name="wc_autor_uniq_rekord_autor_kolejnosc",
+ ),
+ ]
+ ```
+ Nazwy per model (`wc_`/`wz_`/`pat_`) — muszą być unikalne w całej bazie.
+
+- [ ] **Krok 3b.3 — ⚠️ zweryfikuj admin.** Komentarz przy drugiej krotce
+ („Tu musi być autor, inaczej admin nie pozwoli wyedytować") sugeruje, że ten
+ constraint istnieje **ze względu na walidację formularzy**.
+ `Model.validate_unique()` honoruje `unique_together`, ale **`UniqueConstraint`
+ z `condition` pomija** (Django waliduje tylko constrainty bezwarunkowe).
+ Ryzyko: zamiast czytelnego błędu formularza operator dostanie `IntegrityError`
+ (HTTP 500). Test:
+ ```bash
+ uv run pytest src/bpp/tests/test_admin/ -k "autor and (inline or duplikat)" -v
+ ```
+ Jeśli admin regresuje — dodaj jawną walidację w formularzu inline
+ (`clean()` sprawdzający kolizję przez `objects`), NIE wracaj do
+ `unique_together` (nie da się go pogodzić z soft-delete).
+
+- [ ] **Krok 3b.4 — migracja + brak driftu:**
+ ```bash
+ DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations bpp
+ DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run
+ uv run pytest src/bpp/tests/test_soft_delete/test_autor_unique.py -q
+ ```
+
+---
+
## Task 4: Przeplecenie menedżerów `Wydawnictwo_*_Manager` z filtrem soft-delete (wspólny QuerySet/MRO)
**Files:**
@@ -539,10 +672,10 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
## Definition of Done (faza 02)
-- [ ] 5 modeli publikacji to `SoftDeleteModel` (przez `BppPublikacjaSoftDeleteMixin`); migracje `deleted_at`/`restored_at`/`transaction_id` + indeks per model (`0421_`).
+- [ ] 5 modeli publikacji to `SoftDeleteModel` (przez `BppPublikacjaSoftDeleteMixin`); migracje `deleted_at`/`restored_at`/`transaction_id` + indeks per model (`0490_`).
- [ ] `delete(self, *args, user=None, reason="", **kwargs)` / `restore(self, *args, user=None, **kwargs)` — per-instancja `save()`, wąska kaskada na `autorzy_set` pod wspólnym `transaction_id`, BEZ refleksyjnej kaskady pakietu, BEZ bulk `update(deleted_at=)`.
- [ ] `*_Streszczenie` (i pozostałe nie-soft dzieci) nietknięte; `delete()` nie rzuca `SoftDeleteException`.
-- [ ] `slug` → warunkowy `UniqueConstraint(condition=Q(deleted_at__isnull=True))` (`0422_`); reuse slug po soft-delete działa.
+- [ ] `slug` → warunkowy `UniqueConstraint(condition=Q(deleted_at__isnull=True))` (`0491_`); reuse slug po soft-delete działa.
- [ ] `Wydawnictwo_*_Manager` przeplecione: `objects` filtruje `deleted_at` ORAZ ma `rekordy_z_oplata()`/`wydawnictwa_nadrzedne_dla_innych()`; `global_objects`/`deleted_objects` dostępne na wszystkich 5 modelach.
- [ ] Testy: kaskada wspólny txid, znika z Rekord/Autorzy + restore, restore `*_Autor`, `post_soft_delete`, `*_Streszczenie` nietknięte, gate bulk-update — zielone.
- [ ] `makemigrations --check --dry-run bpp` → `No changes detected`. Istniejące migracje NIE modyfikowane.
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md
index f94b48e8a..e798d0c35 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md
@@ -61,7 +61,7 @@
- `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`
**Bez zmian (decyzja audytu udokumentowana w planie):**
-- 90 miejsc `*_Autor.objects` w ewaluacji / API / przemapuj — patrz Task 7.
+- 128 miejsc `*_Autor.objects` w ewaluacji / API / przemapuj — patrz Task 7.
- `src/komparator_pbn/views.py`, `src/snapshot_odpiec/tasks.py`,
`src/ewaluacja_dwudyscyplinowcy/core.py` — patrz Task 7.
@@ -131,6 +131,63 @@ git commit -m "test(soft-delete): re-import po pbn_uid widzi soft-deletowany rek
---
+## Task 1b: Polityka „trafiłem w kosz" — POMIŃ + ZARAPORTUJ (decyzja #14)
+
+> 🔄 **Dodane 2026-08-06.** Przełączenie na `global_objects` (Taski 2-5)
+> rozwiązuje **duplikaty**, ale zostawia otwarte drugie pytanie: importer
+> dopasował rekord, który operator świadomie skasował — **i co teraz?**
+> Bez jawnej decyzji domyślne zachowanie to „zaktualizuj po cichu", czyli
+> import nadpisuje zawartość kosza.
+
+**DECYZJA: pomiń rekord w koszu, zaraportuj trafienie.** Nie tykamy go, nie
+przywracamy, nie tworzymy obok nowego. Uzasadnienie: soft-delete to jawna
+deklaracja „tego tu nie ma"; cichy update ją podważa, auto-restore pozwoliłby
+importowi wskrzeszać rzeczy skasowane celowo, a nowy rekord obok odtwarzałby
+problem duplikatów.
+
+**Konsekwencja dla Tasków 2-5:** samo `global_objects` NIE wystarcza — każde
+przełączone miejsce musi po dopasowaniu **sprawdzić `deleted_at`** i odciąć
+przetwarzanie z raportem. Kolejność: najpierw ten task (kontrakt + helper),
+potem 2-5 go używają.
+
+**Files:**
+- Create: helper w `src/import_common/` (np. `pomin_skasowane()` /
+ `WynikDopasowania`) — miejsce ustal tak, by mogły go importować zarówno
+ `import_common`, jak i `pbn_integrator` bez cyklu
+- Test: `src/import_common/tests/test_pomijanie_kosza.py`
+
+- [ ] **Krok 1b.1 — padający test kontraktu:**
+ ```python
+ @pytest.mark.django_db
+ def test_import_pomija_rekord_w_koszu_i_raportuje(wydawnictwo_ciagle_z_pbn_uid):
+ wc = wydawnictwo_ciagle_z_pbn_uid
+ pbn_uid, tytul_przed = wc.pbn_uid_id, wc.tytul_oryginalny
+ wc.delete()
+
+ wynik = <ścieżka importu>(pbn_uid=pbn_uid, dane={"tytul_oryginalny": "NOWY"})
+
+ # 1) nie utworzono duplikatu
+ assert Wydawnictwo_Ciagle.global_objects.filter(pbn_uid_id=pbn_uid).count() == 1
+ # 2) rekord w koszu NIE został nadpisany
+ wc.refresh_from_db()
+ assert wc.tytul_oryginalny == tytul_przed
+ assert wc.deleted_at is not None
+ # 3) fakt został zaraportowany (nie połknięty)
+ assert wynik.pominieto_bo_w_koszu
+ ```
+
+- [ ] **Krok 1b.2 — implementacja helpera + raportowanie.** ⚠️ **Nie połykaj
+ po cichu** — to byłoby złamanie reguły „żadnych cichych porażek" z CLAUDE.md.
+ Tam gdzie ścieżka importu ma strukturę raportu (`import_common`) — dodaj
+ osobną kategorię (nie „błąd", nie „utworzono"). Tam gdzie jej nie ma
+ (management commands) — log + licznik na końcu przebiegu.
+
+- [ ] **Krok 1b.3 — inwentaryzacja miejsc.** Wypisz wszystkie ścieżki
+ z Tasków 2-5 i przy każdej zapisz, GDZIE ląduje raport. Miejsce bez
+ odbiorcy raportu = niedokończony task, nie „szczegół".
+
+---
+
## Task 2: `get_bpp_publication` matchuje po `pbn_uid` przez `global_objects` modeli źródłowych
`Rekord` to widok (`managed=False`), NIE `SoftDeleteModel` — nie ma
@@ -604,7 +661,7 @@ git commit -m "fix(soft-delete): pbn_import czysci publikacje przez hard_delete
---
-## Task 7: Audyt 90 miejsc `*_Autor.objects` — decyzje (zostaw `objects` / zmień na `global_objects`)
+## Task 7: Audyt 128 miejsc `*_Autor.objects` — decyzje (zostaw `objects` / zmień na `global_objects`)
Po fazie 02 `*_Autor.objects` ukrywa kaskadowo soft-deletowane autorstwa
(kaskada §2.2). To jest **poprawny default dla ewaluacji** (praca w koszu nie
@@ -890,7 +947,7 @@ git commit -m "chore(soft-delete): faza 03 audyt kat. B — format/lint"
rekordach, nie matching tworzący duplikaty; **bez zmian**.
- `pbn_import/utils/publication_import.py:115-116` jawny `.hard_delete()` →
Task 6. ✓
-- Audyt 90 miejsc `*_Autor.objects` → Task 7 (decyzje per-miejsce; jedyna
+- Audyt 128 miejsc `*_Autor.objects` → Task 7 (decyzje per-miejsce; jedyna
zmiana: merge → `global_objects`). ✓
- Testy wymagane przez zlecenie: re-import nie tworzy duplikatu (Task 1-4),
matching po `pbn_uid` znajduje soft-deletowaną (Task 1-3), ewaluacja pomija
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md
index f5188c9cc..bcc3aaf14 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md
@@ -1,9 +1,11 @@
-# Soft-delete — Faza 05: PBN wycofanie przez kolejkę
+# Soft-delete — Faza 05: PBN wycofanie (kolejka + ścieżka synchroniczna)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. TDD: każdy krok najpierw PRAWDZIWY failing test → komenda + FAIL → PRAWDZIWA implementacja → komenda + PASS → commit.
**Goal:** Rozszerzyć `pbn_export_queue` o operację `WYCOFANIE` (obok dotychczasowej `WYSYLKA`), tak by soft-delete publikacji mógł asynchronicznie wycofać oświadczenia dyscyplin z profilu instytucji PBN przez `client.delete_all_publication_statements(pbn_uid)`, z retry/locking/błędami jak istniejąca ścieżka wysyłki. Dostarczyć publiczne funkcje zakolejkowujące (`zakolejkuj_wycofanie`, `zakolejkuj_wysylke`) wołane potem z fazy 06, oraz zaktualizować `SentData` po udanym wycofaniu (`submitted_successfully=False` + znacznik `withdrawn_at`), bez kasowania wiersza.
+> 🔄 **Zmiana zakresu 2026-08-06 (decyzja #16).** Wycofanie musi działać **dwoma wejściami**, nie jednym: rekord bywa wysyłany do PBN także **synchronicznie, bez kolejki** (`synchronizuj_publikacje`, `src/pbn_integrator/utils/synchronization.py:180`). Dlatego logika wycofania ma mieszkać w **wolnostojącej funkcji-prymitywie**, a `withdraw_from_pbn()` na modelu kolejki jest tylko jej cienkim wywołaniem. Patrz Task 05.0 i 05.9.
+
**Architecture:** Nowe pole `operacja` (`TextChoices` `WYSYLKA="wysylka"`/`WYCOFANIE="wycofanie"`, default `WYSYLKA` dla kompatybilności wstecznej) na `PBN_Export_Queue`. `send_to_pbn()` rozgałęzia się na początku: `WYCOFANIE` → nowa metoda `withdraw_from_pbn()` (GET klienta jak w wysyłce, `delete_all_publication_statements` z retry, aktualizacja `SentData`, status przez istniejące `_handle_successful_send`-analog / `error()`); `WYSYLKA` → dotychczasowa ścieżka bez zmian. Gate zakolejkowania: wycofanie tylko gdy rekord ma `pbn_uid_id`.
**Tech Stack:** Django, PostgreSQL, Celery + `pbn_export_queue`, `pbn_api` (`PBNClient`, `SentData`), pytest + model_bakery + `unittest.mock`.
@@ -62,6 +64,48 @@
## Tasks
+### Task 05.0 — Prymityw `wycofaj_oswiadczenia()` (jedno miejsce prawdy)
+
+> Dodane 2026-08-06 (decyzja #16). **Rób to PRZED 05.3** — `withdraw_from_pbn()`
+> ma być cienkim wywołaniem tego prymitywu, nie własną implementacją.
+
+Cała logika wycofania mieszka w wolnostojącej funkcji, żeby wejście
+asynchroniczne (kolejka) i synchroniczne (`synchronizuj_publikacje`)
+zostawiały **identyczny** stan `SentData` i identyczną klasyfikację błędów.
+
+**Files:**
+- Create: `src/pbn_api/wycofanie.py` (lub `src/pbn_export_queue/wycofanie.py` —
+ wybierz tak, by NIE powstał cykl importów: prymityw nie może importować
+ modelu kolejki)
+- Test: `src/pbn_api/tests/test_wycofanie.py`
+
+Kontrakt:
+
+```python
+def wycofaj_oswiadczenia(publikacja, client) -> WynikWycofania:
+ """Wycofuje oświadczenia dyscyplin publikacji z profilu instytucji PBN.
+
+ Gate: publikacja bez pbn_uid -> zwraca wynik POMINIETO (nie błąd).
+ Obiektu publikacji w PBN NIE kasujemy (jest współdzielony).
+ Aktualizuje SentData: submitted_successfully=False + withdrawn_at.
+ """
+```
+
+- [ ] **Krok 05.0.1 — testy obsługi wyjątków (wzorzec do przejęcia:
+ `src/pbn_wysylka_oswiadczen/tasks.py:54-76`).** Trzy przypadki, każdy
+ z innym wynikiem:
+ - `CannotDeleteStatementsException` → **SUKCES** (oświadczeń nie było —
+ stan docelowy osiągnięty). To NIE jest błąd; zaległa pułapka.
+ - `PraceSerwisoweException` → propaguj (retry ma sens, PBN w oknie
+ serwisowym).
+ - `HttpException` → błąd zaklasyfikowany, z treścią odpowiedzi.
+ - `pbn_uid is None` → POMINIETO, bez wołania klienta.
+- [ ] **Krok 05.0.2 — implementacja** + aktualizacja `SentData` wewnątrz
+ prymitywu (NIE u wywołującego).
+- [ ] **Krok 05.0.3 — PASS** + commit.
+
+---
+
### Task 05.1 — Pole `operacja` na `PBN_Export_Queue` + migracja
**Files:**
@@ -649,6 +693,42 @@ Drobne wsparcie operacyjne: pokaż operację na liście kolejki, by superuser od
---
+### Task 05.9 — Wejście synchroniczne (poza kolejką)
+
+> Dodane 2026-08-06 (decyzja #16).
+
+Rekord bywa wysyłany do PBN bez kolejki — ta sama ścieżka musi umieć wycofać.
+
+**Files:**
+- Modify: `src/pbn_integrator/utils/synchronization.py` (i/lub miejsce, które
+ faktycznie robi synchroniczną wysyłkę — **zweryfikuj wywołujących**:
+ `grep -rn --include='*.py' "synchronizuj_publikacje" src/`)
+- Test: `src/pbn_integrator/tests/test_wycofanie_sync.py`
+
+- [ ] **Krok 05.9.1 — ustal realny zbiór ścieżek synchronicznych.** Nie zgaduj;
+ wypisz wywołujących i rozstrzygnij, które z nich mogą wystąpić w kontekście
+ soft-delete (management command? admin action? import?).
+- [ ] **Krok 05.9.2 — test: ścieżka synchroniczna woła prymityw i zostawia
+ `SentData` w tym samym stanie co kolejka.** Kluczowa asercja — **równoważność
+ obu wejść**:
+ ```python
+ # po wycofaniu synchronicznym i po wycofaniu przez kolejkę
+ # SentData ma być nieodróżnialne (submitted_successfully, withdrawn_at)
+ ```
+- [ ] **Krok 05.9.3 — implementacja: wywołanie `wycofaj_oswiadczenia()`.**
+ ⚠️ **NIE** wołaj `client.delete_all_publication_statements()` bezpośrednio —
+ to złamałoby niezmiennik z §4.2 specu (rozjazd `SentData`/`SoftDeleteLog`
+ między wejściami).
+- [ ] **Krok 05.9.4 — grep kontrolny:** poza prymitywem i testami nie ma
+ wywołań `delete_all_publication_statements`, z wyjątkiem
+ `pbn_wysylka_oswiadczen` i `pbn_api/management/` (istniejące, wsadowe,
+ poza zakresem soft-delete):
+ ```bash
+ grep -rn --include='*.py' "delete_all_publication_statements" src/ | grep -v tests
+ ```
+
+---
+
### Task 05.8 — Pełna weryfikacja fazy + brak driftu migracji
**Files:** —
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md
index 3ce7c9ebf..e1dfa3a29 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md
@@ -207,7 +207,7 @@ def test_context_zagniezdzony_przywraca_zewnetrzny(django_user_model, db):
- Create: `src/bpp/models/soft_delete_log.py`
- Modify: `src/bpp/models/__init__.py` (dopisz import, wzorzec `oplaty_log`
na `:52`)
-- Create: `src/bpp/migrations/0421_softdeletelog.py` (NUMER: sprawdź najwyższą
+- Create: `src/bpp/migrations/0494_softdeletelog.py` (NUMER: sprawdź najwyższą
istniejącą migrację `bpp` przez `ls src/bpp/migrations/ | grep -E '^04' |
sort | tail -1` i nadaj kolejny — **NIE modyfikuj istniejących migracji**)
- Test: `src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py`
@@ -725,6 +725,78 @@ def on_post_restore(sender, instance, **kwargs):
---
+### Task 6b: Receivery `Cache_Punktacja_*` — praca w koszu nie liczy się do ewaluacji
+
+> 🔄 **Dodane 2026-08-06 (decyzja #15, spec §2.5b).** Luka nieobjęta żadnym
+> innym mechanizmem: `Cache_Punktacja_Autora` i `Cache_Punktacja_Dyscypliny`
+> **nie mają FK do publikacji** (klucz to tablica `rekord_id =
+> [content_type_id, pk]`), więc nie rusza ich ani kaskada Django, ani kaskada
+> `*_Autor` z fazy 02, ani triggery cache. Bez tego zadania soft-deletowana
+> praca **nadal wnosi sloty i punkty do ewaluacji**.
+
+**Files:**
+- Modify: `src/bpp/receivers/soft_delete.py` (receivery z Task 4/6)
+- Test: `src/bpp/tests/test_soft_delete/test_cache_punktacji.py`
+
+**Fakty z kodu (zweryfikowane 2026-08-06):**
+- modele: `src/bpp/models/cache/punktacja.py` (`Cache_Punktacja_Autora:63`,
+ `Cache_Punktacja_Dyscypliny`),
+- zapis: `src/bpp/models/sloty/core.py:401` (`_Dyscypliny`), `:439` (`_Autora`),
+- przeliczenie: `przelicz_punkty_dyscyplin()`
+ (`src/bpp/models/abstract/disciplines.py:12`),
+- odczyt (te miejsca zobaczą różnicę): `ewaluacja_optymalizacja/utils.py:182`,
+ `ewaluacja_optymalizacja/views/evaluation_browser/prefetch.py:41`,
+ `oswiadczenia/views.py:353`.
+
+- [ ] **Krok 6b.1 — padający test: soft-delete kasuje punktację, restore ją
+ przywraca.**
+ ```python
+ @pytest.mark.django_db
+ def test_soft_delete_kasuje_cache_punktacji(zwarte_z_dyscyplinami):
+ zw = zwarte_z_dyscyplinami
+ zw.przelicz_punkty_dyscyplin()
+ klucz = [zw.content_type_id, zw.pk]
+ assert Cache_Punktacja_Autora.objects.filter(rekord_id=klucz).exists()
+ assert Cache_Punktacja_Dyscypliny.objects.filter(rekord_id=klucz).exists()
+
+ zw.delete()
+
+ assert not Cache_Punktacja_Autora.objects.filter(rekord_id=klucz).exists()
+ assert not Cache_Punktacja_Dyscypliny.objects.filter(rekord_id=klucz).exists()
+
+ zw.restore()
+
+ assert Cache_Punktacja_Autora.objects.filter(rekord_id=klucz).exists()
+ ```
+
+- [ ] **Krok 6b.2 — implementacja w receiverach.**
+ - `post_soft_delete` → skasuj wiersze obu modeli dla `rekord_id`
+ `[content_type_id, pk]`;
+ - `post_restore` → przelicz (`przelicz_punkty_dyscyplin()` lub równoważna
+ ścieżka z `sloty/core.py`).
+
+ ⚠️ **Gate na typ nadawcy.** Receivery są globalne (podpięte bez `sender=`),
+ a `Cache_Punktacja_*` dotyczy tylko 5 modeli publikacji. Dla `Autor`
+ i `*_Autor` ten kod **nie może** się odpalić — sprawdź `sender` przed
+ czymkolwiek.
+
+ ⚠️ **Kaskada `*_Autor` NIE może tego wyzwalać drugi raz.** Soft-delete
+ publikacji z N autorami emituje 1 + N sygnałów `post_soft_delete`. Kasowanie
+ punktacji ma zajść **raz**, przy sygnale rodzica — inaczej N-1 zbędnych
+ zapytań na każde kasowanie.
+
+- [ ] **Krok 6b.3 — ⚠️ zmierz koszt `restore()`.** Przeliczenie punktacji jest
+ operacją liczącą, nie `UPDATE`-em. Jeśli dla realnego rekordu trwa zauważalnie
+ (>1 s), zgłoś to do fazy 07 (admin) — akcja „Przywróć" na queryset-cie
+ N rekordów może potrzebować zadania w tle zamiast żądania HTTP.
+ ```bash
+ uv run pytest src/bpp/tests/test_soft_delete/test_cache_punktacji.py -q --durations=5
+ ```
+
+- [ ] **Krok 6b.4 — PASS + commit.**
+
+---
+
### Task 7: Test integracyjny end-to-end + weryfikacja rejestracji w apps.ready
**Files:**
@@ -827,7 +899,7 @@ istnieją, Task 5 pomijamy. (ii) **Faza 02 wpina** `SoftDeleteModel` + override
owijający `soft_delete_context` na publikacjach — jeśli nie ma jeszcze tego
w worktree, testy receiverów Tasków 4/6 idą przez ręczny
`post_soft_delete.send(...)` w izolacji (wariant udokumentowany w docstringu
-testu). (iii) Numer migracji `0421_*` orientacyjny — wykonawca nadaje kolejny
+testu). (iii) Numer migracji `0494_*` orientacyjny — wykonawca nadaje kolejny
po sprawdzeniu `ls src/bpp/migrations/`. (iv) `soft_delete_context.py` tworzy
ta faza; gdy fazy 02/04 dodały wcześniej stub — scalić VERBATIM z kontraktem.
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
index 2fadca503..ec610aad4 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
@@ -149,11 +149,11 @@ def test_kaskada_autor_soft_deletowany_razem_z_publikacja(
Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py -v`
Expected: 3 PASS. Jeśli `test_soft_delete_publikacji_znika_z_rekord_i_autorzy`
-FAIL (rekord wraca do mat-view) → **luka w fazie 01**: filtr `deleted_at IS
-NULL` nie pokrywa wszystkich gałęzi UNION `bpp_rekord` / `bpp_*_autorzy`.
-Zadanie naprawcze: dopisz brakujący `WHERE deleted_at IS NULL` w
-`src/bpp/migrations/0XXX_soft_delete_views.sql` (NOWA migracja, nie modyfikuj
-istniejących) i ponów.
+FAIL (rekord wraca lub zostaje w mat-view) → **luka w fazie 01 lub 02**.
+Diagnozuj w kolejności: (1) czy bramka `WHEN` triggera zna `deleted_at`
+(`pg_get_triggerdef`), (2) czy funkcja refresh ma gałąź kasującą
+(`pg_get_functiondef`), (3) czy widok źródłowy filtruje (`pg_get_viewdef`).
+Naprawa idzie NOWĄ migracją `RunPython` (nie modyfikuj istniejących).
- [ ] **Step 4: Commit**
@@ -177,29 +177,51 @@ Oczekiwane: ustal, czy `verify_cache` jest sprawny. **Znana luka:**
`src/bpp/management/commands/verify_cache.py` to dziś stub
(`raise NotImplementedError`, twarde `psycopg2.connect(database="b_med",
host="linux-dev")`) — NIE da się go uruchomić w teście. Weryfikację spójności
-robimy przez `Rekord.objects.full_refresh()` (`src/bpp/models/cache/rekord.py:117`),
-która jest realnym, testowalnym odpowiednikiem „re-projekcji ze źródła"
-opisanym w spec §2.1. (Patrz „Luki wykryte" na końcu — `verify_cache` należy
-naprawić osobnym zadaniem, poza zakresem soft-delete.)
+robimy **wprost, surowym SQL-em** (wzorzec:
+`src/bpp/tests/test_cache/test_cache_plpgsql_port.py`).
-- [ ] **Step 2: Napisz failing test — full_refresh nie wskrzesza skasowanych + Cache_Punktacja_* znika**
+> ⚠️ **Korekta 2026-08-06.** Poprzednia wersja tego kroku używała
+> `Rekord.objects.full_refresh()` jako „re-projekcji ze źródła". **To nie jest
+> re-projekcja** — `full_refresh()` (`src/bpp/models/cache/rekord.py:121-127`)
+> woła `denorm.rebuildall(...)`, czyli przebudowuje pola denormalizowane i
+> **nigdy niczego nie usuwa ani nie wstawia do `_mat`**. Test „po
+> `full_refresh()` skasowane nie wracają" przechodziłby z fałszywych powodów.
+> Patrz spec §2.1 + decyzja #12.
+
+- [ ] **Step 2: Napisz testy — re-projekcja nie wskrzesza skasowanych + Cache_Punktacja_* znika**
Dopisz do `test_soft_delete_regresja_cache.py`:
```python
+from django.db import connection
+
from bpp.models.cache.punktacja import Cache_Punktacja_Dyscypliny
@pytest.mark.django_db
-def test_full_refresh_nie_wskrzesza_skasowanej_publikacji(
- wydawnictwo_ciagle_z_dwoma_autorami,
+def test_reprojekcja_ze_zrodla_nie_wskrzesza_skasowanej_publikacji(
+ transactional_db, wydawnictwo_ciagle_z_dwoma_autorami,
):
- """Re-projekcja ze źródła (full_refresh) respektuje deleted_at —
- inaczej skasowany rekord wróciłby do bpp_rekord_mat (spec §2.1)."""
- wydawnictwo_ciagle_z_dwoma_autorami.delete()
+ """Wymuszone przejście triggera nie przywraca skasowanego rekordu.
+
+ NIE używamy full_refresh() — to denorm.rebuildall, nie re-projekcja _mat
+ (decyzja #12). Zamiast tego wymuszamy wejście triggera UPDATE-em
+ bramkowanej kolumny i sprawdzamy, ze wiersz NIE wrocil z widoku.
+ """
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ pk, ct = wc.pk, wc.content_type_id
+ wc.delete()
assert Rekord.objects.count() == 0
- Rekord.objects.full_refresh()
+ with connection.cursor() as cur:
+ cur.execute(
+ "UPDATE bpp_wydawnictwo_ciagle SET rok = rok + 1 WHERE id = %s", [pk]
+ )
+ cur.execute(
+ "SELECT count(*) FROM bpp_rekord_mat WHERE id = ARRAY[%s, %s]::integer[]",
+ [ct, pk],
+ )
+ assert cur.fetchone()[0] == 0, "skasowany rekord wrocil do _mat"
assert Rekord.objects.count() == 0
assert Autorzy.objects.count() == 0
@@ -238,10 +260,14 @@ def test_soft_delete_usuwa_cache_punktacji_dyscyplin(zwarte_z_dyscyplinami):
- [ ] **Step 3: Uruchom — oczekuj PASS**
-Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py -k "full_refresh or cache_punktacji" -v`
+Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py -k "reprojekcja or cache_punktacji" -v`
Expected: PASS. Jeśli `Cache_Punktacja_Dyscypliny` wraca po delete →
-sprawdź, czy override `delete()` (faza 02) czyści punktację dyscyplin albo
-czy trigger usuwa wpisy `Cache_Punktacja_*` na podstawie `deleted_at`.
+sprawdź **receivery z fazy 06** (decyzja #15, spec §2.5b): `post_soft_delete`
+ma kasować wiersze `Cache_Punktacja_Autora`/`_Dyscypliny` dla tego
+`rekord_id`, `post_restore` — przeliczać je z powrotem. ⚠️ Tych tabel **nie
+rusza** ani kaskada `*_Autor`, ani trigger cache (nie mają FK do publikacji,
+klucz to tablica `[content_type_id, pk]`) — jeśli faza 06 tego nie wdrożyła,
+ten test jest jedynym miejscem, gdzie luka wyjdzie.
Jeśli pole `rekord_id` w `Cache_Punktacja_Dyscypliny` ma inną strukturę niż
`[content_type_id, pk]`, dostosuj filtr do realnego schematu modelu
(`src/bpp/models/cache/punktacja.py:18`) — sprawdź `uv run python
@@ -750,7 +776,7 @@ je ukrywa).
```python
"""Regresja ewaluacji (spec §2.5, §3 fazy).
-90 miejsc czyta ``*_Autor.objects`` bezpośrednio; po wpięciu
+128 miejsc czyta ``*_Autor.objects`` bezpośrednio; po wpięciu
``SoftDeleteModel`` domyślny menedżer ukrywa kaskadowo-skasowane
autorstwa, więc ewaluacja pomija prace w koszu. Restore przywraca punktację.
"""
diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
index 8c6934185..d8aeed0cc 100644
--- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
+++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
@@ -1,6 +1,16 @@
# Spec: Soft-delete publikacji + autorów (jedno opracowanie wdrożeniowe)
-> ✅ **STATUS: DO REALIZACJI (2026-06-04).**
+> ✅ **STATUS: DO REALIZACJI (2026-06-04; zrewidowany 2026-08-06).**
+>
+> 🔄 **Rewizja 2026-08-06.** Spec powstał przed PR #363 (port
+> `bpp_refresh_cache` PL/Python → PL/pgSQL + bramka `WHEN`, migracje
+> `0432`/`0433`). Weryfikacja na aktualnym `dev` obaliła **dwa nośne
+> założenia** o warstwie cache — §2.1 przepisana od zera, decyzja #9
+> unieważniona. Domknięto też 5 luk/decyzji (#12–#16): `Cache_Punktacja_*`,
+> warunkowy unique na `*_Autor`, polityka importu wobec kosza, dwuwejściowe
+> wycofanie z PBN, semantyka `full_refresh()`. Dowód empiryczny obalonych
+> założeń: `src/bpp/tests/test_cache/test_soft_delete_preconditions.py`.
+>
> Ten dokument jest projektem wdrożeniowym (design), zatwierdzonym przez
> użytkownika. Zastępuje feasibility-spec
> [`2026-06-03-soft-delete-publikacje.md`](2026-06-03-soft-delete-publikacje.md)
@@ -20,13 +30,15 @@ sens, przy minimalnym blast-radiusie:
„kosz" dla pustych/błędnych rekordów). Autor **z** pracami → `PROTECT`
(zero kasowania, soft ani hard).
3. **PBN** — soft-delete publikacji wycofuje oświadczenia dyscyplin z profilu
- instytucji, asynchronicznie przez kolejkę (`pbn_export_queue`).
+ instytucji: asynchronicznie przez kolejkę (`pbn_export_queue`) lub
+ synchronicznie, gdy rekord szedł do PBN bez kolejki (§4.2).
4. **Audyt** — dedykowana tabela `SoftDeleteLog` (kto / kiedy / dlaczego /
status PBN).
5. **Admin** (superuser-only) — „kosz" zamiast hard-delete, filtr „pokaż
skasowane", akcja „przywróć", osobna jawna akcja „usuń trwale".
-**Stack.** Django, PostgreSQL (triggery `plpython3u`), `django-soft-delete`
+**Stack.** Django, PostgreSQL (triggery **PL/pgSQL** — po porcie z `plpython3u`,
+PR #363), `django-soft-delete`
(`SoftDeleteModel`, już w `pyproject.toml`), `django-denorm-iplweb`,
Celery + `pbn_export_queue`.
@@ -55,11 +67,11 @@ i `Patent_Autor` **stają się `SoftDeleteModel`** — ale wyłącznie jako cel
**wąskiej kaskady** z soft-delete publikacji (§2.2), NIE pełnego refleksyjnego
Projektu B. Pozostałe dzieci (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`,
`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`) zostają nie-soft —
-kaskada zatrzymuje się na `*_Autor` i **nie jest wirusowa**. Powód: 90
+kaskada zatrzymuje się na `*_Autor` i **nie jest wirusowa**. Powód: 128
bezpośrednich zapytań `*_Autor.objects` w kodzie (większość w
`ewaluacja_optymalizacja` — najwrażliwszy korekcyjnie podsystem) — domyślny
menedżer `objects` po wpięciu `SoftDeleteModel` czyni je poprawnymi
-automatycznie, eliminując 90-punktowe ryzyko „silent leak" do ewaluacji.
+automatycznie, eliminując 128-punktowe ryzyko „silent leak" do ewaluacji.
**Dlaczego nie kaskada autor→prace ani „guard z 50 publikacjami":**
realny przypadek użycia kasowania autora jest wąski — to wyłącznie puste /
@@ -81,74 +93,134 @@ publikacji przed usunięciem czyni kasowanie bezużytecznym. Wąska semantyka
### 2.1 Trigger jako choke-point (najwrażliwszy, robiony PIERWSZY)
-`Rekord` to UNION-view nad materializowaną tabelą `bpp_rekord_mat`, zasilaną
-triggerem `bpp_refresh_cache()`. **Aktualna wersja funkcji to
-`src/bpp/migrations/0399_fix_refresh_cache_upsert.sql`** (NIE baseline
-`0001_cache_functions.sql` — historyczny; funkcja ewoluowała przez `0112`,
-`0310`, `0387`, `0399`, + `0400_drop/restore_cache_triggers`). Z
-`bpp_rekord_mat`/`bpp_autorzy_mat` czyta większość systemu (publiczny frontend,
-multiseek, global search, ewaluacja `Cache_Punktacja_*`, raporty).
-
-**Fakt z kodu** (`0399_fix_refresh_cache_upsert.sql`): na `DELETE` trigger usuwa
-wiersze z `_mat`; na `UPDATE/INSERT` robi **`DELETE` + upsert**
-(`INSERT ... SELECT FROM ... ON CONFLICT DO UPDATE`, pod
-`pg_advisory_xact_lock`). `DELETE` przed upsertem jest **bezwarunkowy** (linie
-125-126), więc gdy widok źródłowy odfiltruje skasowane — upsert nic nie
-re-insertuje. Soft-delete to technicznie `UPDATE` → **bez filtra w widoku
-skasowany rekord wróciłby** (upsert wstawiłby go ponownie). (Uwaga: `0399` ma
-drobny utajony bug — `"bpp_autorzy_mat" not in refresh_tables` sprawdza string
-w liście krotek `(table, id_col)`; do poprawienia przy okazji, faza 01.)
-
-**Zmiana:** ścieżka `UPDATE/INSERT` triggera uczona reguły:
-> jeśli `TD['new']['deleted_at'] IS NOT NULL` → zachowaj się jak `DELETE`
-> (usuń z `_mat`, **nie** re-insertuj).
-> `deleted_at: →NULL` (restore) → normalny re-insert.
+> 🔄 **Sekcja przepisana 2026-08-06** po weryfikacji na aktualnym `dev`.
+> Poprzednia wersja opisywała funkcję `bpp_refresh_cache()` z migracji
+> `0399_fix_refresh_cache_upsert.sql` (PL/Python). **Ta funkcja już nie
+> istnieje** — PR #363 (migracje `0432`/`0433`) zastąpił ją 16 statycznymi
+> funkcjami PL/pgSQL i bramką `WHEN` na triggerach UPDATE. Oba założenia,
+> na których stała poprzednia wersja, są **fałszywe**; dowód w kanarkach
+> `src/bpp/tests/test_cache/test_soft_delete_preconditions.py`.
+
+`Rekord` to model nad **tabelą materializowaną `bpp_rekord_mat`**
+(`src/bpp/models/cache/rekord.py:382-386` — `db_table = "bpp_rekord_mat"`;
+widok `bpp_rekord` obsługuje osobna, marginalna klasa `RekordView`, `:394-396`).
+Z `bpp_rekord_mat`/`bpp_autorzy_mat` czyta większość systemu (publiczny
+frontend, multiseek, global search, raporty). **Tabele `_mat` utrzymywane są
+WYŁĄCZNIE triggerami** — nie ma innej ścieżki zapisu.
+
+**Stan faktyczny po PR #363** (`0432_cache_trigger_plpgsql.py`,
+`0433_cache_trigger_when_gate.py`):
+
+- 8 tabel bazowych (5 publikacji + 3 `*_autor`), każda z **trzema** triggerami:
+ `_cache_ins` (AFTER INSERT, bezwarunkowy), `_cache_del` (AFTER DELETE,
+ bezwarunkowy), `_cache_upd` (AFTER UPDATE, **bramkowany**);
+- funkcja refresh dla publikacji to **czysty upsert, BEZ `DELETE`**:
+ ```sql
+ PERFORM pg_advisory_xact_lock(ct, NEW.id);
+ INSERT INTO bpp_rekord_mat (...) SELECT ... FROM bpp__view
+ WHERE object_id_raw = NEW.id
+ ON CONFLICT (id) DO UPDATE SET ...;
+ ```
+- bramka na UPDATE to `WHEN (OLD."kol" IS DISTINCT FROM NEW."kol" OR ...)`,
+ gdzie lista kolumn jest **wyliczana z `pg_depend`** (kolumny bazowe, które
+ widok faktycznie czyta) **w momencie migracji** i wklejona na sztywno
+ w definicję triggera.
+
+**Dwa fałszywe założenia poprzedniej wersji (potwierdzone testem):**
+
+| Założenie | Rzeczywistość |
+|---|---|
+| „UPDATE ustawiający `deleted_at` doleci do triggera" | **Nie doleci.** `django-soft-delete` zapisuje przez `save(update_fields=['deleted_at','restored_at','transaction_id'])`. Żadna z tych kolumn nie jest w bramce (i być nie może — nie zasilają widoku), więc `WHEN` jest fałszywe. Efekt uboczny `update_fields`: `ostatnio_zmieniony` (`auto_now`) też **nie** jest bumpowany, więc nie ma przypadkowego ratunku. |
+| „Filtr `deleted_at IS NULL` w widoku wystarczy, bo trigger robi bezwarunkowy `DELETE` przed upsertem" | **Nie robi.** `INSERT ... SELECT` z widoku, który odfiltrował wiersz, wybiera zero wierszy → **no-op** → stary wiersz przeżywa w `_mat`. Inwariant „delete-first" nie istnieje dla `bpp_rekord_mat`. |
+
+**Wzorzec do naśladowania jest już w kodzie.** Gałąź doktorat/habilitacja
+w `_create_rekord_function` robi dokładnie to, czego potrzebujemy — bo tam
+wiersz **może wypaść ze źródła** (widok `*_autorzy` ma INNER JOIN do
+`bpp_autor`):
+
+```sql
+DELETE FROM bpp_autorzy_mat WHERE rekord_id = ARRAY[ct, NEW.id]::integer[];
+INSERT INTO bpp_autorzy_mat (...) SELECT ... ;
+```
+
+Soft-delete to uogólnienie tej samej sytuacji na wszystkie 8 tabel.
+
+**Zmiana — trzy elementy, wszystkie OBOWIĄZKOWE** (żaden nie wystarcza sam):
+
+1. **Gałąź kasująca w 8 funkcjach refresh.** Prolog przed upsertem:
+ ```sql
+ IF NEW.deleted_at IS NOT NULL THEN
+ DELETE FROM bpp_rekord_mat WHERE id = ARRAY[ct, NEW.id]::integer[];
+ RETURN NULL;
+ END IF;
+ ```
+ (dla `*_autor` analogicznie na `bpp_autorzy_mat`). Restore
+ (`deleted_at: data→NULL`) przechodzi dalej do normalnego upsertu — symetria
+ za darmo.
+2. **Regeneracja bramki `WHEN`** — żeby UPDATE w ogóle doszedł do funkcji.
+ Nie hardkodujemy `deleted_at`: bramka wylicza kolumny z `pg_depend` po
+ definicji widoku, więc **gdy punkt 3 wstawi `deleted_at` do `WHERE` widoku,
+ ponowne uruchomienie logiki `forward()` z `0433` samo ją wciągnie**. Nowa
+ migracja = `RunPython` wołający tę samą funkcję (kolejność: najpierw widok,
+ potem regeneracja bramki).
+3. **Filtr `deleted_at IS NULL` w widokach źródłowych** — po **własnej**
+ kolumnie tabeli, bez JOIN do rodzica (każda z 8 tabel ma własne
+ `deleted_at`, patrz §2.2). Rola tego filtra jest **inna niż zakładano**:
+ nie sprząta `_mat` (to robi punkt 1), tylko (a) karmi `pg_depend` dla
+ punktu 2 i (b) gwarantuje, że pełne przebudowy i odczyty przez `bpp_rekord`
+ nie wskrzeszą kosza.
**Jednolitość dzięki wąskiej kaskadzie na `*_Autor` (§2.2).** Ponieważ
-through-modele też stają się `SoftDeleteModel`, **każda z 8 tabel pod
-triggerem ma własną kolumnę `deleted_at`** (5 publikacji + 3 `*_autor`).
-Trigger czyta `deleted_at` z **własnego** wiersza (`TD['new']`) — reguła
-działa identycznie niezależnie od tego, czy zadziałała tabela publikacji czy
-tabela autorska. **Nie ma potrzeby JOIN-a/lookupu do rekordu nadrzędnego.**
-
-Skutki:
-- **Mechanizm #1 — filtr `deleted_at IS NULL` w widokach źródłowych** (po
- **własnej** kolumnie tabeli, bez JOIN): `bpp_*_autorzy` (selektują
- `FROM bpp_*_autor`, `src/bpp/migrations/0001_widoki_autorzy.sql`) i każda
- gałąź UNION-u `bpp_rekord`. **To jest nadrzędny mechanizm**, bo pokrywa
- WSZYSTKIE ścieżki: re-insert triggera, bezpośredni odczyt z widoku `bpp_rekord`
- (`Rekord` czyta `bpp_rekord`, `src/bpp/models/cache/rekord.py:357`), oraz
- pełną re-projekcję/weryfikację cache.
-- **Mechanizm #2 (optymalizacja) — trigger-skip:** w gałęzi `UPDATE/INSERT`
- `if TD['new'].get('deleted_at') is not None: ` (DELETE i tak
- już zaszedł). Oszczędza no-op SELECT/upsert, ale **sam nie pokrywa pełnej
- re-projekcji** (ta re-selektuje z widoku). Filtr widoku (#1) jest
- obowiązkowy; trigger-skip opcjonalny.
-- **Weryfikacja spójności — `Rekord.objects.full_refresh()`** (re-projekcja
- `_mat` ze źródła), NIE `verify_cache`. ⚠️ `src/bpp/management/commands/verify_cache.py`
- to **martwy stub** (`psycopg2.connect(database="b_med", host="linux-dev")` +
- `raise NotImplementedError`) — nie da się go uruchomić; jego naprawa jest
- POZA zakresem soft-delete. Testy spójności robią pełen `full_refresh` i
- sprawdzają, że skasowane rekordy NIE wracają do `_mat` (to weryfikuje filtr
- widoku #1).
-- **Przypadek brzegowy znika strukturalnie:** edycja wiersza autorstwa
- skasowanej publikacji nie wskrzesi go w `bpp_autorzy_mat`, bo widok
- źródłowy go odfiltruje (ma własne `deleted_at` ustawione kaskadą).
-- **Koszt:** soft-delete publikacji z N autorami odpala N dodatkowych (no-op)
- triggerów through. Pomijalne.
-
-Testy spójności mat-view obowiązkowe (soft-delete → znika z `Rekord` i
-`Autorzy`; restore → wraca; po `full_refresh()` skasowane NIE wracają do `_mat`;
-brak rozjazdu `Cache_Punktacja_*`).
+through-modele też stają się `SoftDeleteModel`, każda z 8 tabel pod triggerem
+ma własną kolumnę `deleted_at`. Funkcja czyta `deleted_at` z **własnego**
+wiersza (`NEW`) — reguła działa identycznie dla tabeli publikacji i autorskiej.
+**Nie ma potrzeby JOIN-a/lookupu do rekordu nadrzędnego.**
+
+**Przypadek brzegowy znika strukturalnie:** edycja wiersza autorstwa
+skasowanej publikacji nie wskrzesi go w `bpp_autorzy_mat` — jego własne
+`deleted_at` jest ustawione kaskadą, więc gałąź z punktu 1 zadziała.
+
+**Koszt:** soft-delete publikacji z N autorami odpala N dodatkowych triggerów
+through (każdy = jeden `DELETE` po indeksie). Pomijalne.
+
+⚠️ **`full_refresh()` NIE jest re-projekcją `_mat`.**
+`Rekord.objects.full_refresh()` (`src/bpp/models/cache/rekord.py:121-127`) to
+`denorm.rebuildall(...)` — przebudowa pól denormalizowanych, nie odtworzenie
+`_mat` z widoków. Nie nadaje się jako weryfikacja spójności soft-delete (nigdy
+nic z `_mat` nie usuwa, więc test „po `full_refresh()` skasowane nie wracają"
+przeszedłby z fałszywych powodów). Dodatkowo po wpięciu `SoftDeleteModel`
+`rebuildall` iteruje po domyślnym managerze, więc **pominie rekordy
+skasowane** — to jest pożądane, ale zapisujemy jako świadomą decyzję (#12),
+nie odkrycie.
+
+⚠️ `src/bpp/management/commands/verify_cache.py` to **martwy stub**
+(`psycopg2.connect(database="b_med", host="linux-dev")` + `raise
+NotImplementedError`) — nie da się go uruchomić; naprawa POZA zakresem.
+
+**Weryfikacja spójności robimy wprost, surowym SQL-em** (wzorzec:
+`src/bpp/tests/test_cache/test_cache_plpgsql_port.py`): soft-delete → wiersza
+nie ma w `bpp_rekord_mat`/`bpp_autorzy_mat`; restore → wraca i zgadza się
+kolumna-po-kolumnie ze świeżo policzonym widokiem; `ctid` do wykrywania
+jałowych przepisań.
### 2.2 Override `delete()` — wąska, kontrolowana kaskada na `*_Autor`
`SoftDeleteModel.delete()` domyślnie kaskaduje **refleksyjnie** po wszystkich
-odwrotnych relacjach. W `strict=True` (domyślny) rzuci `SoftDeleteException`
-na nie-soft dzieciach (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`,
-`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`), a `strict=False` twardo
-skasuje je przez CASCADE. **Oba złe.** Dlatego na 5 modelach nadpisujemy
-`delete()` tak, by **NIE** używał refleksyjnej kaskady pakietu, lecz:
+odwrotnych relacjach.
+
+⚠️ **Korekta 2026-08-06:** poprzednia wersja tej sekcji twierdziła, że
+`strict=True` jest domyślne. W zainstalowanej wersji pakietu jest **odwrotnie**
+(`django_softdelete/models.py`): `delete(self, strict: bool = False, ...)`,
+ale `restore(self, strict: bool = True, ...)`. To czyni argument za własnym
+override'em **mocniejszym**, nie słabszym: przy `strict=False` kaskada nie
+krzyknie `SoftDeleteException` na nie-soft dzieciach — **po cichu przejedzie
+po wszystkich odwrotnych relacjach**. Asymetria `delete`/`restore` to dodatkowy
+powód, by nie polegać na domyślnym zachowaniu pakietu w żadną stronę.
+
+Nie-soft dzieci (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`,
+`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`) nie mają być ruszane
+w żadnym trybie. Dlatego na 5 modelach nadpisujemy `delete()` tak, by **NIE**
+używał refleksyjnej kaskady pakietu, lecz:
1. ustawił własne `deleted_at` i zapisał,
2. **jawnie soft-deletował własne wiersze `*_Autor`** (`Wydawnictwo_Ciagle_Autor`
@@ -163,9 +235,9 @@ wszystko z `_mat` na podstawie własnych `deleted_at`; przy restore
re-projektuje ze źródła.
Po co jawna kaskada na `*_Autor`, skoro trigger i tak czyści `bpp_autorzy_mat`?
-Bo **90 miejsc w kodzie czyta `*_Autor.objects` bezpośrednio** (z pominięciem
+Bo **128 miejsc w kodzie czyta `*_Autor.objects` bezpośrednio** (z pominięciem
cache), głównie w `ewaluacja_optymalizacja`. Domyślny menedżer `objects`
-`SoftDeleteModel` ukrywa skasowane → te 90 miejsc staje się poprawne
+`SoftDeleteModel` ukrywa skasowane → te 128 miejsc staje się poprawnych
automatycznie, bez ręcznych filtrów `wydawnictwo_ciagle__deleted_at__isnull`
(których pominięcie = po cichu zliczona skasowana praca w ewaluacji).
@@ -173,6 +245,51 @@ Zweryfikować, że nadpisany `delete()`/`restore()` nadal emituje sygnały
`post_soft_delete`/`post_restore` (patrz §5), oraz że ścieżka queryset
(`.delete()` na QS) również kaskaduje na `*_Autor`.
+### 2.2b `unique_together` na `*_Autor` — też warunkowy (decyzja #13)
+
+Ten sam problem co ze slugiem (§2.3), przeoczony w pierwszej wersji specu.
+`BazaModeluOdpowiedzialnosciAutorow` ma w konkretnych klasach (np.
+`src/bpp/models/wydawnictwo_ciagle.py:73-77`):
+
+```python
+unique_together = [
+ ("rekord", "autor", "typ_odpowiedzialnosci"),
+ ("rekord", "autor", "kolejnosc"),
+]
+```
+
+Soft-deletowany wiersz `*_Autor` **nadal zajmuje slot w unique**, będąc
+niewidocznym dla operatora. Realny scenariusz kolizji: `deduplikator_autorow`
+przenosi autorstwa z duplikatu na autora głównego
+(`src/deduplikator_autorow/utils/merge.py:191,284,354`) i trafia w
+soft-deletowany wiersz o tej samej trójce → `IntegrityError` o rekord, którego
+nie widać.
+
+**DECYZJA: zamienić na warunkowy `UniqueConstraint`** — spójnie ze slugiem:
+
+```python
+constraints = [
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "typ_odpowiedzialnosci"],
+ condition=Q(deleted_at__isnull=True),
+ name="...",
+ ),
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "kolejnosc"],
+ condition=Q(deleted_at__isnull=True),
+ name="...",
+ ),
+]
+```
+
+⚠️ Uwaga wykonawcza: `unique_together` jest walidowane także przez
+`Model.validate_unique()` (formularze adminu), a `UniqueConstraint` z
+`condition` **nie jest** brane pod uwagę przez `validate_unique` w starszym
+Django. Zweryfikować w fazie 02, czy admin inline'ów autorstwa nadal daje
+czytelny komunikat zamiast `IntegrityError` — komentarz przy drugiej krotce
+(„Tu musi być autor, inaczej admin nie pozwoli wyedytować") sugeruje, że ten
+constraint istnieje właśnie ze względu na admina.
+
### 2.3 `slug` — warunkowy unique
⚠️ `slug` to **pole denormalizowane** (`@denormalized(models.SlugField, ...,
@@ -209,8 +326,54 @@ eksport / liczenie) staje się czysta automatycznie (zero zmian). Ale
> menedżera, soft-delete staje się generatorem duplikatów. Audyt kat. B jest
> obowiązkowy.
-**Through-modele `*_Autor` (90 miejsc).** Po wpięciu `SoftDeleteModel`
-90 bezpośrednich zapytań `*_Autor.objects` (głównie `ewaluacja_optymalizacja`:
+**Co po dopasowaniu do rekordu w koszu (decyzja #14).** Samo przejście na
+`global_objects` usuwa duplikaty, ale otwiera drugie pytanie: importer
+dopasował po `pbn_uid`/DOI rekord, który operator świadomie skasował — i co
+teraz? Bez rozstrzygnięcia domyślnie „zaktualizowałby go po cichu", czyli
+import nadpisywałby zawartość kosza.
+
+> **DECYZJA: POMIŃ + ZARAPORTUJ.** Rekord w koszu nie jest tykany; trafienie
+> ląduje w raporcie importu jako osobna kategoria (nie „błąd", nie „utworzono").
+> Uzasadnienie: soft-delete to jawna deklaracja operatora „tego tu nie ma";
+> cichy update kosza ją podważa, a auto-restore pozwoliłby importowi wskrzeszać
+> rzeczy skasowane celowo. Nowy rekord obok też odpada — odtwarzałby dokładnie
+> ten problem duplikatów, przed którym broni `global_objects`.
+>
+> Wymaganie wykonawcze: **każda** ścieżka kat. B musi mieć gdzie ten fakt
+> zaraportować. Tam, gdzie importer nie ma struktury raportu, wystarczy log
+> + licznik; nie wolno pominąć milcząco.
+
+### 2.5b `Cache_Punktacja_*` — praca w koszu nie może liczyć się do ewaluacji
+
+⚠️ **Luka wykryta 2026-08-06** — nie pokrywa jej wąska kaskada na `*_Autor`.
+
+`Cache_Punktacja_Autora` i `Cache_Punktacja_Dyscypliny`
+(`src/bpp/models/cache/punktacja.py`) to **osobne tabele**, kluczowane tablicą
+`rekord_id = [content_type_id, pk]`. Nie mają FK do publikacji, więc **żadna
+kaskada Django ich nie ruszy**, a triggery cache ich nie dotyczą. Zapisywane
+są z `src/bpp/models/sloty/core.py:401,439`.
+
+Czytane bezpośrednio, z pominięciem `Rekord`, m.in. w:
+- `src/ewaluacja_optymalizacja/utils.py:182`,
+- `src/ewaluacja_optymalizacja/views/evaluation_browser/prefetch.py:41`,
+- `src/oswiadczenia/views.py:353`.
+
+Bez osobnego mechanizmu **soft-deletowana praca dalej wnosi sloty i punkty do
+ewaluacji** — czyli dokładnie ten „silent leak", przed którym broni §2.2, tyle
+że innym kanałem.
+
+> **DECYZJA: kasować przy soft-delete, przeliczać przy restore (#15).**
+> - `post_soft_delete` publikacji → usuń wiersze `Cache_Punktacja_Autora`
+> i `Cache_Punktacja_Dyscypliny` dla tego `rekord_id`;
+> - `post_restore` → przelicz je z powrotem (maszyneria istnieje —
+> `src/bpp/models/sloty/core.py`).
+>
+> Wybrane zamiast „filtrować przy odczycie", bo nie wymaga tknięcia ~10 miejsc
+> odczytu ani pilnowania każdego nowego. Koszt: restore staje się operacją
+> liczącą (nie samym `UPDATE`), co trzeba uwzględnić w adminie (może trwać).
+
+**Through-modele `*_Autor` (128 miejsc).** Po wpięciu `SoftDeleteModel`
+128 bezpośrednich zapytań `*_Autor.objects` (głównie `ewaluacja_optymalizacja`:
`reset_pins`, `reset_disciplines`, `unpin_all_sensible`, `optimization`,
`author_works`, `evaluation_browser`, `verification`; oraz `api_v1`,
`przemapuj_prace_autora`, `ewaluacja_dwudyscyplinowcy`) **staje się poprawne
@@ -307,7 +470,7 @@ Skutki:
---
-## 4. PBN — wycofanie oświadczeń przez kolejkę
+## 4. PBN — wycofanie oświadczeń (kolejka + ścieżka synchroniczna)
### 4.1 Co i kiedy
@@ -323,20 +486,40 @@ Prymityw PBN istnieje:
`delete_publication_statement` w `:135`, retry w
`pbn_api/client/publication_sync.py`).
-### 4.2 Mechanizm — rozszerzenie istniejącej `pbn_export_queue`
-
-Nie wprowadzamy nowego mechanizmu. Kolejka eksportu PBN żyje jako dedykowana
-aplikacja **`src/pbn_export_queue/`** (model `PBN_Export_Queue`: GFK
-content_type+object_id, `zamowil`, `ilosc_prob`, `zakonczono_pomyslnie`,
-`rodzaj_bledu`, klasyfikacja błędów, locking, „ponowna wysyłka", admin,
-`send_to_pbn()`).
-
-Rozszerzenie:
-- dodać pole `operacja: TextChoices(WYSYLKA, WYCOFANIE)` (default `WYSYLKA`
- dla kompatybilności wstecznej), migracja,
-- gałąź w logice wysyłki: `WYCOFANIE` → `delete_all_publication_statements`,
-- status zapisywany jak dla wysyłki (`zakonczono_pomyslnie`, `komunikat`,
- `ilosc_prob`) + odzwierciedlenie w `SentData` i `SoftDeleteLog`.
+### 4.2 Mechanizm — jeden prymityw, DWA wejścia (decyzja #16)
+
+⚠️ **Zmiana 2026-08-06.** Poprzednia wersja zakładała jedno wejście
+(`pbn_export_queue`). Rekord może jednak trafić do PBN **synchronicznie, bez
+kolejki** (`synchronizuj_publikacje`, `src/pbn_integrator/utils/synchronization.py:180`,
+wołane m.in. z `pbn_uploader` / `pbn_integrator`), a od czasu pisania specu
+doszła aplikacja `src/pbn_wysylka_oswiadczen/`, która już woła
+`delete_all_publication_statements` (`tasks.py:68`) z dopracowaną obsługą
+wyjątków PBN. Wycofanie musi więc działać w obu trybach.
+
+**Architektura: wspólna funkcja-prymityw + dwa wywołujące ją wejścia.**
+
+1. **Prymityw** (nowy, jedno miejsce prawdy) — np.
+ `wycofaj_oswiadczenia(publikacja, client) -> wynik`:
+ - gate na `pbn_uid` (brak → no-op, jawnie zaraportowany),
+ - `client.delete_all_publication_statements(pbn_uid)`,
+ - obsługa `CannotDeleteStatementsException` (brak oświadczeń = sukces,
+ nie błąd), `PraceSerwisoweException`, `HttpException` — wzorzec
+ do przejęcia z `pbn_wysylka_oswiadczen/tasks.py:54-76`,
+ - aktualizacja `SentData` (§4.2b) — zawsze, niezależnie od wejścia.
+2. **Wejście asynchroniczne** — `pbn_export_queue` rozszerzona o pole
+ `operacja: TextChoices(WYSYLKA, WYCOFANIE)` (default `WYSYLKA` dla
+ kompatybilności wstecznej). Gałąź w `send_to_pbn()`
+ (`src/pbn_export_queue/models.py:456`): `WYCOFANIE` → prymityw. Status
+ zapisywany jak dla wysyłki (`zakonczono_pomyslnie`, `komunikat`,
+ `ilosc_prob`). To jest **ścieżka domyślna** dla soft-delete z admina —
+ ma retry, locking i `UniqueConstraint` na jednym aktywnym wpisie na rekord.
+3. **Wejście synchroniczne** — kod, który wysyła rekord bez kolejki, woła
+ prymityw bezpośrednio. Nie kolejkuje, nie ma retry; odpowiada za obsługę
+ wyniku na miejscu.
+
+> **Niezmiennik:** żadne z wejść nie woła `delete_all_publication_statements`
+> samodzielnie — wyłącznie przez prymityw. Inaczej aktualizacja `SentData`
+> i wpis w `SoftDeleteLog` rozjadą się między ścieżkami.
⚠️ **`PBN_Export_Queue.zamowil` jest NOT NULL (`on_delete=CASCADE`).**
Zakolejkowanie z admina ma `request.user`. Ale zakolejkowanie inicjowane
@@ -345,6 +528,8 @@ kogo wpisać. Rozwiązanie (faza 05/06): **konto techniczne** (np.
`get_or_create` systemowego użytkownika) jako `zamowil` dla operacji
systemowych — NIE robić `zamowil` nullable (psułoby istniejące zał. kolejki).
+### 4.2b `SentData` — stan PBN per-rekord
+
`SentData` (`src/pbn_api/models/sentdata.py`, GFK + `pbn_uid` +
`submitted_successfully` + `mark_as_successful`/`mark_as_failed`) trzyma stan
PBN per-rekord. **Po udanym wycofaniu:** ustawiamy `submitted_successfully =
@@ -353,10 +538,15 @@ False` (rekord nie jest już „wystawiony" w PBN) i dodajemy znacznik wycofania
`SentData` NIE kasujemy** — zostaje dla audytu i re-matchingu przy restore.
Restore (`WYSYLKA`) → ponowne `mark_as_successful` po udanej wysyłce.
+Aktualizację `SentData` robi **prymityw** (§4.2 pkt 1), nie wywołujący — dzięki
+temu ścieżka asynchroniczna i synchroniczna zostawiają identyczny stan.
+
### 4.3 Restore → symetria
-Restore publikacji → wpis `WYSYLKA` w `pbn_export_queue` (ponowna wysyłka
-oświadczeń, dyscypliny wracają do profilu). Symetria delete↔restore.
+Restore publikacji → ponowna wysyłka oświadczeń (dyscypliny wracają do
+profilu instytucji), tym samym dwuwejściowym wzorcem co wycofanie: domyślnie
+wpis `WYSYLKA` w `pbn_export_queue`, a w kontekście synchronicznym —
+bezpośrednio. Symetria delete↔restore.
---
@@ -417,20 +607,27 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
## 8. Kolejność prac (fazy; szczegółowy TDD → writing-plans)
-1. **`*_Autor` + trigger + widoki** — kolejność wewnątrz fazy: (a) migracja
- `SoftDeleteModel` na 3 through-modelach (`deleted_at`+indeks) — **musi być
- PRZED** (b), bo trigger/widok czytają tę kolumnę; (b) filtr `deleted_at IS
- NULL` w widokach źródłowych `bpp_rekord`/`bpp_*_autorzy` (mechanizm #1, po
- własnej kolumnie); (c) funkcja `bpp_refresh_cache()` z regułą
- `deleted_at IS NOT NULL → pomiń re-insert` (opcjonalna optymalizacja).
- Testy spójności mat-view + `verify_cache`. **Najwrażliwsze, pierwsze.**
+1. **`*_Autor` + widoki + funkcje triggera + bramka** — kolejność wewnątrz
+ fazy jest wymuszona zależnościami (§2.1):
+ (a) migracja `SoftDeleteModel` na 3 through-modelach (`deleted_at`+indeks) —
+ **musi być PRZED** (b), bo widok czyta tę kolumnę;
+ (b) filtr `deleted_at IS NULL` w widokach źródłowych `bpp_*_autorzy`
+ (po własnej kolumnie, bez JOIN);
+ (c) **gałąź kasująca** w funkcjach refresh (`IF NEW.deleted_at IS NOT NULL
+ THEN DELETE ... RETURN NULL`) — **obowiązkowa**, nie optymalizacja;
+ (d) **regeneracja bramki `WHEN`** (logika `forward()` z `0433`) — musi być
+ PO (b), żeby `pg_depend` już znało `deleted_at`.
+ Weryfikacja: odwrócenie asercji w
+ `src/bpp/tests/test_cache/test_soft_delete_preconditions.py` + testy
+ spójności surowym SQL-em. **Najwrażliwsze, pierwsze.**
2. **Publikacje** — `SoftDeleteModel` na 5 modelach, override
`delete()`/`restore()` z **wąską kaskadą na `*_Autor`** (wspólny
`transaction_id`, bez refleksyjnej kaskady pakietu), migracje
(`deleted_at`+indeks, ew. `CONCURRENTLY`), `slug` `UniqueConstraint`,
- przeplecenie menedżerów.
+ warunkowe `UniqueConstraint` na `*_Autor` (§2.2b), przeplecenie menedżerów,
+ powtórzenie kroków 1(b)–1(d) dla 5 widoków publikacji i ich funkcji.
3. **Audyt kat. B** — przełączenie import/dedup/PBN-matching na
- `global_objects`; audyt 90 miejsc `*_Autor.objects` (default „pomijaj"
+ `global_objects`; audyt 128 miejsc `*_Autor.objects` (default „pomijaj"
poprawny, wyjątki → `global_objects`). Testy: re-import nie tworzy
duplikatów; ewaluacja pomija prace w koszu.
4. **Guardy PROTECT** (ten sam wzorzec: flip FK + guard liczący przez
@@ -441,10 +638,12 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
- **`Wydawnictwo_Zwarte` (rozdziały)** — flip FK `wydawnictwo_nadrzedne`
`CASCADE→PROTECT`, guard w soft `delete()` blokujący gdy ma rozdziały
(§2.6).
-5. **PBN** — `operacja WYCOFANIE` w `pbn_export_queue` + restore→`WYSYLKA`;
- integracja `SentData`.
+5. **PBN** — prymityw `wycofaj_oswiadczenia()` + dwa wejścia: `operacja
+ WYCOFANIE` w `pbn_export_queue` (async) i wywołanie bezpośrednie
+ (synchroniczne); restore→`WYSYLKA`; integracja `SentData` (§4.2).
6. **SoftDeleteLog** + receivery sygnałów (`post_soft_delete`/`post_restore`/
- `post_hard_delete`), wstrzykiwanie `user`.
+ `post_hard_delete`), wstrzykiwanie `user`, **kasowanie/przeliczanie
+ `Cache_Punktacja_*`** (§2.5b).
7. **Admin** — kosz / filtr / przywróć / usuń-trwale / powód (5 modeli +
`Autor`).
8. **Testy regresji** — pełna suita: PBN (duplikaty + wycofanie), dashboard,
@@ -455,9 +654,20 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
## 9. Ryzyka
- **Cache/trigger** — rozjazd, jeśli `deleted_at` nie obsłużone we wszystkich
- 8 tabelach (5 publikacji + 3 `*_autor`) + ścieżce UPDATE + widokach
- źródłowych. Najgroźniejsze, wydajnościowo wrażliwe. Mitygacja: testy
- spójności jako pierwsze.
+ 8 tabelach (5 publikacji + 3 `*_autor`) × trzech elementach (widok, gałąź
+ kasująca, bramka `WHEN`). Najgroźniejsze, wydajnościowo wrażliwe.
+ Mitygacja: kanarki `test_soft_delete_preconditions.py` odwrócone na
+ docelowe asercje jako PIERWSZY krok fazy 01.
+- **Bramka `WHEN` zapomniana przy kolejnej zmianie widoku** — bramka jest
+ wypiekana w migracji z `pg_depend`, więc **każda przyszła zmiana definicji
+ widoku źródłowego wymaga jej regeneracji**. Pominięcie = cichy staleness
+ (rekord w koszu widoczny dalej). Mitygacja: kanarki + nota w
+ `docs/deweloper/spec-bpp-refresh-cache-plpgsql-2026-06.md`.
+- **`Cache_Punktacja_*` nietknięte przy soft-delete** — praca w koszu nadal
+ liczy się do ewaluacji (§2.5b). Osobny kanał, którego kaskada `*_Autor`
+ NIE zamyka.
+- **Restore staje się operacją liczącą** — przeliczenie `Cache_Punktacja_*`
+ (§2.5b) może trwać; admin musi to znieść (komunikat / zadanie w tle).
- **Guard autora przez `objects` zamiast `global_objects`** — autor z pracami
tylko-w-koszu przeszedłby przez guard (autorstwa kaskadowo skasowane są
ukryte). MUSI być `global_objects` (§3.2).
@@ -484,7 +694,7 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
soft-deletuje rodzica i jego `*_Autor` (wspólny `transaction_id`), bez
refleksyjnej kaskady na pozostałe dzieci. Trigger jako choke-point,
jednolity dzięki własnym `deleted_at` na wszystkich 8 tabelach (bez JOIN
- do rodzica). Powód kaskady: 90 miejsc `*_Autor.objects` w ewaluacji.
+ do rodzica). Powód kaskady: 128 miejsc `*_Autor.objects` w ewaluacji.
3. **PBN przy soft-delete:** wycofanie oświadczeń instytucji
(`delete_all_publication_statements`), gate na `pbn_uid`; obiektu
publikacji nie kasujemy.
@@ -495,15 +705,36 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
7. **Admin:** superuser-only; soft-delete zastępuje „usuń"; hard-delete jako
osobna jawna akcja.
8. **Retencja:** brak auto-czyszczenia; tylko ręczny hard-delete.
-9. **Cache — mechanizm nadrzędny:** filtr `deleted_at IS NULL` w widokach
- źródłowych (pokrywa trigger, odczyt z `bpp_rekord`, `verify_cache`);
- trigger-skip to opcjonalna optymalizacja.
+9. ~~**Cache — mechanizm nadrzędny:** filtr `deleted_at IS NULL` w widokach
+ źródłowych; trigger-skip to opcjonalna optymalizacja.~~
+ **UNIEWAŻNIONE 2026-08-06.** Zastąpione przez: **trzy elementy, wszystkie
+ obowiązkowe** — (a) filtr w widoku, (b) gałąź kasująca w funkcji refresh,
+ (c) regeneracja bramki `WHEN`. Żaden nie wystarcza sam (§2.1).
10. **SentData przy wycofaniu:** `submitted_successfully=False` + znacznik
wycofania, wiersza nie kasujemy.
11. **Self-FK `Wydawnictwo_Zwarte` (rozdziały):** **PROTECT** — soft-delete
książki-matki zablokowany, jeśli ma rozdziały (flip FK `CASCADE→PROTECT`
+ guard liczący przez `global_objects`, §2.6). Wzorzec jak guard autora.
+### Decyzje domknięte 2026-08-06 (po weryfikacji na `dev`)
+
+12. **`full_refresh()` pomija skasowane** — `denorm.rebuildall` iteruje po
+ domyślnym managerze. Świadomie akceptowane (przebudowa opisów
+ bibliograficznych rekordu w koszu jest bez wartości). NIE używamy
+ `full_refresh()` jako weryfikacji spójności soft-delete (§2.1).
+13. **`unique_together` na `*_Autor` → warunkowy `UniqueConstraint`**
+ z `condition=Q(deleted_at__isnull=True)`, spójnie ze slugiem (§2.2b).
+14. **Import trafiający w kosz: POMIŃ + ZARAPORTUJ.** Nie tykamy rekordu
+ w koszu; trafienie idzie do raportu importu jako osobna kategoria.
+ Nie auto-restore, nie nowy rekord (§2.5).
+15. **`Cache_Punktacja_*`: kasować przy soft-delete, przeliczać przy restore.**
+ Zamiast filtrowania w ~10 miejscach odczytu (§2.5b).
+16. **PBN: jeden prymityw, dwa wejścia** — asynchroniczne przez
+ `pbn_export_queue` (`operacja=WYCOFANIE`, ścieżka domyślna) i
+ synchroniczne wywołanie bezpośrednie, bo rekord bywa wysyłany bez kolejki.
+ Żadne wejście nie woła `delete_all_publication_statements` samodzielnie
+ (§4.2).
+
---
## 11. Precedensy w repo
@@ -512,7 +743,16 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
- `src/zglos_publikacje/models.py` — `Zgłoszenie_Publikacji` już
`SoftDeleteModel` (wzorzec).
- `src/pbn_export_queue/` — dojrzała kolejka PBN (model + Celery + admin +
- retry/lock), wzorzec dla operacji `WYCOFANIE`.
+ retry/lock), wzorzec dla operacji `WYCOFANIE` (wejście asynchroniczne).
+- `src/pbn_wysylka_oswiadczen/tasks.py:54-76` — wzorcowa obsługa wyjątków PBN
+ przy `delete_all_publication_statements` (`CannotDeleteStatementsException`
+ = brak oświadczeń = sukces, nie błąd). Do przejęcia przez prymityw §4.2.
- `src/pbn_api/models/sentdata.py` — `SentData` (stan PBN per-rekord).
- `src/bpp/models/oplaty_log.py`, log w `deduplikator_autorow` — precedensy
tabel-logów.
+- `src/bpp/migrations/0432_cache_trigger_plpgsql.py` /
+ `0433_cache_trigger_when_gate.py` — generatory funkcji triggera i bramki.
+ **Faza 01 wprost korzysta z ich logiki**, nie pisze SQL-a od zera.
+- `src/bpp/tests/test_cache/test_cache_plpgsql_port.py` — wzorzec testów
+ spójności `_mat` surowym SQL-em (porównanie kolumna-po-kolumnie, `ctid`
+ do wykrywania jałowych przepisań).
From 5b32b347c9d0c9adb12a4c99b460221b031594b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 14:05:55 +0200
Subject: [PATCH 12/75] docs(soft-delete): faza 01 Task 1 -- test managerow
zamiast tautologii
Pre-flight scan SDD wykryl plan-mandated defekt: test_managery_sa_wlasciwych_klas
mial asercje prawdziwe zawsze (issubclass(X.__mro__[0], object),
isinstance(__qualname__, str)) -- nie weryfikowal niczego.
Zastapiony testem sprawdzajacym rzecz, na ktorej naprawde zalezy: czy oba
managery zwracaja BppSoftDeleteQuerySet (inaczej gate na update() nie dziala,
bo pakietowy QuerySet go nie ma). Plus nota, ze na tym etapie nie wolno
iterowac po queryset-cie -- kolumny deleted_at jeszcze nie ma (Task 2).
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
...-04-soft-delete-01-autor-trigger-widoki.md | 23 +++++++++++++++----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
index d8c3af128..3947b8455 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
@@ -108,13 +108,26 @@ Tworzy współdzielony fundament menedżerów dla całego wdrożenia. Guard zale
assert qs.update(kolejnosc=5) == 0 # pusty QS, ale nie rzuca
- def test_managery_sa_wlasciwych_klas():
- assert issubclass(BppSoftDeleteManager.__bases__[0].__mro__[0], object)
- assert isinstance(
- BppSoftDeleteManager().get_queryset.__func__.__qualname__, str
- )
+ def test_managery_zwracaja_bpp_queryset():
+ """Oba managery MUSZĄ zwracać BppSoftDeleteQuerySet — inaczej gate
+ na update() nie zadziała (pakietowy QuerySet go nie ma)."""
+ from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+
+ for manager_cls in (BppSoftDeleteManager, BppGlobalManager):
+ manager = manager_cls()
+ manager.model = Wydawnictwo_Ciagle_Autor
+ manager._db = None
+ assert isinstance(manager.get_queryset(), BppSoftDeleteQuerySet), (
+ f"{manager_cls.__name__} nie zwraca BppSoftDeleteQuerySet"
+ )
```
+ ⚠️ **Uwaga:** `BppSoftDeleteManager.get_queryset()` filtruje po
+ `deleted_at__isnull=True`, a na tym etapie `Wydawnictwo_Ciagle_Autor`
+ **nie ma jeszcze** kolumny `deleted_at` (dodaje ją Task 2). Budowa
+ queryset-u jest leniwa (filtr nie odpala SQL-a), więc `isinstance` przejdzie
+ — ale **nie iteruj** po tym queryset-cie w tym teście.
+
- [ ] Uruchom (oczekiwany FAIL — `ModuleNotFoundError: bpp.models.soft_delete`):
```bash
uv run pytest src/bpp/tests/test_soft_delete/test_managers.py -q
From 6cc4b3262167c78e393d7b049ed484a7a176513f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 14:28:58 +0200
Subject: [PATCH 13/75] docs(soft-delete): naprawa blokerow z self-review przed
startem fazy 01
Adwersaryjny self-review (Fable 5) znalazl 4 blokery; wszystkie zweryfikowane
w kodzie przed naprawa.
B1 (blad wprowadzony przy rewizji 2026-08-06): filtr widokow bpp_*_autorzy
uzywal object_id_raw, ktory w TYCH widokach jest id PUBLIKACJI
(0421_cache_trigger_pk_filter.sql:336), a nie pk wiersza through. Porownywal
wiec id publikacji z id autorstwa. Dwa realne skutki: skasowane autorstwo
zostaje w widoku, a przy zbieznosci numerow wycinane sa autorstwa CUDZEJ
publikacji. Poprawione na NOT EXISTS po (_orig.id)[2].
Dodany test SEMANTYCZNY -- planowane testy (substring "deleted_at" w viewdef,
bramka zna deleted_at) przechodza takze dla blednego klucza, bo pg_depend
widzi kolumne przez podzapytanie niezaleznie od sensu porownania.
B3: BazaModeluOdpowiedzialnosciAutorow ma CZWARTEGO potomka --
Zgloszenie_Publikacji_Autor (zglos_publikacje/models.py:315). Wpiecie
SoftDeleteModel w abstrakt objeloby aplikacje zgloszen (nieplanowana migracja,
podmieniony objects, dryf makemigrations --check). Zamiast tego mixin
BppAutorstwoSoftDeleteMixin wpinany w 3 KONKRETNE modele + test negatywny.
B2 (faza 02): Praca_Doktorska_Baza.autorzy_set to property zwracajaca FakeSet
(podklasa list) z atrapami bez .delete() (praca_doktorska.py:30-70). Kaskada
mixinu wywalilaby sie AttributeError na doktoracie i habilitacji. Dodane
_model_through()/_autorstwa_do_kaskady().
P8: content_type_id nie istnieje na modelach publikacji -- to property na
RekordBase (rekord.py:288). Testy faz 01/02/06/08 padalyby AttributeError
z falszywego powodu. Zamienione na ContentType.objects.get_for_model.
P12: widoki bpp_praca_{doktorska,habilitacyjna}_autorzy dopisane do fazy 02.
D3: "trigger dziala tylko z prawdziwym commitem" to falsz -- kanarki chodza
pod django_db; transactional_db usuniety jako zbedny.
D4: sprzecznosc DROP CASCADE vs CREATE OR REPLACE.
Dlugi dla faz 03-06 (nieaktualny stan pbn_export_queue, rozjechany kontrakt
zakolejkuj_*, konto techniczne zamowil, MetrykaAutora, AutorManager, i in.)
spisane w ledgerze SDD do splaty przed odpowiednimi fazami.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
...-04-soft-delete-01-autor-trigger-widoki.md | 170 ++++++++++++++----
.../2026-06-04-soft-delete-02-publikacje.md | 80 +++++++--
...2026-06-04-soft-delete-06-softdeletelog.md | 6 +-
...026-06-04-soft-delete-08-testy-regresji.md | 13 +-
4 files changed, 219 insertions(+), 50 deletions(-)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
index 3947b8455..186b61df7 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
@@ -21,7 +21,7 @@
> **Filtr widoku sam NIE wystarcza.** Ta faza robi trzy rzeczy, nie jedną —
> patrz „Architecture" niżej. Wszystkie obowiązkowe.
-**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez wspólną bazę `BazaModeluOdpowiedzialnosciAutorow`), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, i doprowadzić do tego, by soft-deletowane autorstwa **znikały** z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i **wracały** po `restore`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować.
+**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez mixin `BppAutorstwoSoftDeleteMixin` wpinany w **3 KONKRETNE** klasy — NIE w abstrakt `BazaModeluOdpowiedzialnosciAutorow`, patrz Task 2), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, i doprowadzić do tego, by soft-deletowane autorstwa **znikały** z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i **wracały** po `restore`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować.
**Architecture — trzy elementy, wszystkie obowiązkowe** (żaden nie wystarcza sam; uzasadnienie: §2.1 specu):
@@ -36,18 +36,18 @@ Gałęzie `UNION` w `bpp_rekord` per typ publikacji NIE filtrują po `*_autor.de
**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§1, §2.1, §2.2, §8 pkt 1). Indeks: [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md).
**Fakty z kodu (zweryfikowane, NIE zmieniać bez ponownej weryfikacji):**
-- `BazaModeluOdpowiedzialnosciAutorow` jest `models.Model` (abstract), `src/bpp/models/abstract/authors.py:16`. Po niej dziedziczą wszystkie 3 through-modele.
-- `Wydawnictwo_Ciagle_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_ciagle.py:52`. FK `rekord` → `Wydawnictwo_Ciagle`, `related_name="autorzy_set"`, `src/bpp/models/wydawnictwo_ciagle.py:58`.
+- `BazaModeluOdpowiedzialnosciAutorow` jest `models.Model` (abstract), `src/bpp/models/abstract/authors.py:19`. ⚠️ Dziedziczą po niej **CZTERY** modele: 3 through-modele publikacji **oraz** `Zgloszenie_Publikacji_Autor` (`src/zglos_publikacje/models.py:315`) — ten ostatni jest POZA zakresem soft-delete. Dlatego NIE ruszamy abstraktu (Task 2).
+- `Wydawnictwo_Ciagle_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_ciagle.py:53`. FK `rekord` → `Wydawnictwo_Ciagle`, `related_name="autorzy_set"`, `src/bpp/models/wydawnictwo_ciagle.py:59`.
- `Wydawnictwo_Zwarte_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_zwarte.py:60`. FK `rekord`, `related_name="autorzy_set"`, `:67`.
- `Patent_Autor(BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/patent.py:32`. FK `rekord`, `related_name="autorzy_set"`, `:35`.
- Wszystkie 3 mają `Meta.unique_together` — `("rekord","autor","typ_odpowiedzialnosci")` i `("rekord","autor","kolejnosc")`, np. `src/bpp/models/wydawnictwo_ciagle.py:73-77`. **W TEJ fazie NIE ruszamy**; zamiana na warunkowy `UniqueConstraint` (decyzja #13, §2.2b specu) idzie w fazie 02 razem ze slugiem.
-- `BazaModeluOdpowiedzialnosciAutorow.objects` NIE jest jawnie zdefiniowany → po wpięciu `SoftDeleteModel` domyślne `objects` = `SoftDeleteManager` (z pakietu). Nadpiszemy je naszymi `Bpp*` z `src/bpp/models/soft_delete.py`.
+- `BazaModeluOdpowiedzialnosciAutorow.objects` NIE jest jawnie zdefiniowany. Managery `Bpp*` wnosi mixin `BppAutorstwoSoftDeleteMixin` (Task 2); w MRO stoi PRZED bazą modelu, więc jego `objects` wygrywa.
- `SoftDeleteModel.delete()` (pakiet, `django_softdelete/models.py`) robi **refleksyjną kaskadę** po reverse relacjach — dla `*_Autor` reverse relacji do soft-delete dzieci NIE ma (ich dzieci to nie-soft `Autor`/`Jednostka` przez FK forward), więc kaskada jest no-op. `delete()` woła `self.save(update_fields=['deleted_at','restored_at','transaction_id'])`. ⚠️ **Ten UPDATE dotyka WYŁĄCZNIE tych 3 kolumn** — dlatego bramka `WHEN` musi znać `deleted_at` (punkt 3 „Architecture"), inaczej trigger się nie odpali. `ostatnio_zmieniony` (`auto_now`) też NIE jest bumpowany (`update_fields` filtruje `pre_save`).
- ⚠️ `strict` w pakiecie jest **asymetryczne**: `delete(strict=False)`, `restore(strict=True)`.
- Tabela `bpp_autorzy_mat` (model `Autorzy`, `src/bpp/models/cache/autorzy.py:39`, `db_table="bpp_autorzy_mat"`) zasilana triggerami z widoków `bpp_*_autorzy`.
- **Trigger (AKTUALNY, po PR #363):** `0432_cache_trigger_plpgsql.py` generuje 3 funkcje `bpp_refresh_autor_()` (upsert **bez** DELETE, `_create_through_function`) + 3 `bpp_delete_autor_()`, oraz triggery `_cache_ins` / `_cache_del` / `_cache_upd`. `0433_cache_trigger_when_gate.py` nakłada bramkę `WHEN` na `_cache_upd`, z listą kolumn wyliczoną z `pg_depend`. ⚠️ **Funkcja `bpp_refresh_cache()` NIE ISTNIEJE** — `DROP` w `0432`. Nie kopiować `0399` ani `0001_cache_functions.sql`.
- Widoki `bpp_*_autorzy`: ostatnia wersja definicji w `0421_cache_trigger_pk_filter.sql` (dodaje `object_id_raw`). Odtwarzając widok, wychodź z `pg_get_viewdef()`, nie z `0001_widoki_autorzy.sql`.
-- `transactional_db` fixture wymagany dla testów dotykających trigger/cache (trigger działa tylko z prawdziwym commitem). Fixture `denorms` (`src/fixtures/conftest_system.py:193`) daje `denorms.flush()`. Fixtury: `wydawnictwo_ciagle_z_dwoma_autorami`, `wydawnictwo_ciagle_z_autorem`, `autor_jan_kowalski`, `jednostka`, `standard_data`, `typy_odpowiedzialnosci`.
+- ⚠️ **`transactional_db` NIE jest wymagany** do oglądania efektów triggera — triggery bazodanowe działają wewnątrz transakcji testowej (dowód: kanarki `test_soft_delete_preconditions.py` chodzą pod zwykłym `django_db`). Używaj `django_db`; `transactional_db` tylko spowalnia. Fixture `denorms` (`src/fixtures/conftest_system.py:255`) daje `denorms.flush()`. Fixtury: `wydawnictwo_ciagle_z_dwoma_autorami`, `wydawnictwo_ciagle_z_autorem`, `autor_jan_kowalski`, `jednostka`, `standard_data`, `typy_odpowiedzialnosci`.
- ⚠️ **Numeracja migracji (stan 2026-08-06):** liść to `0487_api_v1_przelaczniki`. Nowe migracje tej fazy: `0488_autor_soft_delete_fields` → `0489_soft_delete_autorzy_views` (SQL + regeneracja bramki). **Przed startem zweryfikuj liść ponownie** (`ls src/bpp/migrations/*.py | tail -3`) — `dev` żyje, numery mogły się przesunąć. Wszystkie numery w tym planie są orientacyjne; kanoniczna jest kolejność, nie cyfra.
**Kontrakt z reversion (PINNED):** soft-delete idzie WYŁĄCZNIE per-instancja przez `.delete()`/`.save()` (nigdy `queryset.update(deleted_at=...)`). `BppSoftDeleteQuerySet.update()` to egzekwuje fail-fast (gate). W tej fazie testujemy gate i kaskadę queryset-ową.
@@ -193,7 +193,9 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`restored_at`/`transaction_id` do WSZYSTKICH 3 konkretnych tabel `*_autor`. Nadpisujemy managery (`objects`/`global_objects`/`deleted_objects`) naszymi `Bpp*` z Task 1, żeby gate był aktywny. Migracja dodaje 3 pola × 3 tabele + indeks na `deleted_at` × 3.
**Files:**
-- Modify: `src/bpp/models/abstract/authors.py:16` (deklaracja klasy + managery), import `:1-13`.
+- Modify: `src/bpp/models/soft_delete.py` (dopisz `BppAutorstwoSoftDeleteMixin`)
+- Modify: `src/bpp/models/wydawnictwo_ciagle.py:53`, `wydawnictwo_zwarte.py` (`Wydawnictwo_Zwarte_Autor`), `patent.py:32` — wepnij mixin w bazy klas
+- ⚠️ **NIE modyfikuj** `src/bpp/models/abstract/authors.py` — abstrakt ma czwartego potomka poza zakresem
- Create: `src/bpp/migrations/0488_autor_soft_delete_fields.py`
- Test (create): `src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py`
@@ -262,35 +264,59 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r
uv run pytest src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py -q
```
-- [ ] Zmodyfikuj import w `src/bpp/models/abstract/authors.py` — dodaj po linii `from django.db.models import CASCADE, SET_NULL, Q, Sum` (`:10`):
+> 🩹 **Poprawka 2026-08-06 (self-review) — NIE ruszaj abstraktu.**
+> Pierwsza wersja kazała zmienić `BazaModeluOdpowiedzialnosciAutorow(models.Model)`
+> na `(SoftDeleteModel)`. To wciągnęłoby **czwarty** model, spoza zakresu
+> soft-delete:
+> ```
+> src/zglos_publikacje/models.py:315:
+> class Zgloszenie_Publikacji_Autor(BazaModeluOdpowiedzialnosciAutorow):
+> ```
+> Skutki: nieplanowana migracja w aplikacji `zglos_publikacje`, podmieniony
+> `objects` w module zgłoszeń (nieaudytowana zmiana zachowania) i dryf
+> w `makemigrations --check`. **Wpinamy `SoftDeleteModel` w 3 konkretne
+> modele**, dokładnie te z §2.2 specu.
+
+- [ ] Dodaj wspólny mixin na końcu `src/bpp/models/soft_delete.py` (obok managerów z Task 1) — żeby nie powtarzać deklaracji managerów trzy razy:
```python
- from django_softdelete.models import SoftDeleteModel
-
- from bpp.models.soft_delete import (
- BppGlobalManager,
- BppSoftDeleteManager,
- )
- from django_softdelete.managers import DeletedManager
- ```
- (UWAGA na cykl importów: `soft_delete.py` nie importuje modeli BPP, więc bezpieczne. `authors.py` już importuje z `bpp.models.dyscyplina_naukowa` — kolejność OK.)
+ class BppAutorstwoSoftDeleteMixin(SoftDeleteModel):
+ """SoftDeleteModel + nasze managery dla through-modeli *_Autor.
-- [ ] Zmień deklarację klasy `src/bpp/models/abstract/authors.py:16` z:
- ```python
- class BazaModeluOdpowiedzialnosciAutorow(models.Model):
- ```
- na:
- ```python
- class BazaModeluOdpowiedzialnosciAutorow(SoftDeleteModel):
- ```
+ Wpinany w 3 KONKRETNE modele (Wydawnictwo_Ciagle_Autor,
+ Wydawnictwo_Zwarte_Autor, Patent_Autor), NIE w abstrakt
+ BazaModeluOdpowiedzialnosciAutorow — ten ma czwartego potomka,
+ Zgloszenie_Publikacji_Autor, który jest poza zakresem soft-delete.
+ """
-- [ ] Dodaj jawne managery w ciele klasy `BazaModeluOdpowiedzialnosciAutorow`, tuż przed `class Meta:` (`:92`). Wstaw przed linią ` class Meta:`:
- ```python
# Nadpisujemy managery pakietu naszymi (gate na update()).
# Kolejność: pierwszy zdefiniowany manager = _default_manager.
objects = BppSoftDeleteManager()
global_objects = BppGlobalManager()
deleted_objects = DeletedManager()
+ class Meta:
+ abstract = True
+ ```
+ Import w `soft_delete.py`: `from django_softdelete.models import SoftDeleteModel` oraz `from django_softdelete.managers import DeletedManager`.
+
+- [ ] Wepnij mixin do **3 konkretnych** klas (kolejność baz: mixin PRZED bazą modelu, żeby jego managery wygrały MRO):
+ - `src/bpp/models/wydawnictwo_ciagle.py:53` —
+ `class Wydawnictwo_Ciagle_Autor(DirtyFieldsMixin, BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutorow):`
+ - `src/bpp/models/wydawnictwo_zwarte.py` (klasa `Wydawnictwo_Zwarte_Autor`) — analogicznie
+ - `src/bpp/models/patent.py:32` —
+ `class Patent_Autor(BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutorow):`
+
+- [ ] **Sanity: `Zgloszenie_Publikacji_Autor` NIE został ruszony.** Dopisz do
+ `test_autor_softdelete_model.py`:
+ ```python
+ def test_zgloszenie_publikacji_autor_nie_jest_soft_delete():
+ """Czwarty potomek abstraktu jest POZA zakresem soft-delete."""
+ from django_softdelete.models import SoftDeleteModel
+
+ from zglos_publikacje.models import Zgloszenie_Publikacji_Autor
+
+ assert not issubclass(Zgloszenie_Publikacji_Autor, SoftDeleteModel)
+ assert not hasattr(Zgloszenie_Publikacji_Autor, "global_objects")
```
- [ ] Uruchom `makemigrations` — wygeneruje migrację dla 3 konkretnych modeli:
@@ -362,9 +388,10 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
Jedna migracja, trzy zmiany, w **wymuszonej kolejności**:
-1. przedefiniowanie 3 widoków `bpp_*_autorzy` z filtrem `deleted_at IS NULL`
- po własnej kolumnie tabeli `*_autor` (+ odtworzenie zależnego `bpp_autorzy`,
- bo `DROP ... CASCADE` go skasuje),
+1. przedefiniowanie 3 widoków `bpp_*_autorzy` z filtrem odcinającym
+ soft-deletowane autorstwa. Używamy `CREATE OR REPLACE VIEW` (zachowuje
+ listę i typy kolumn), więc zależny `bpp_autorzy` **NIE** jest kasowany
+ i nie trzeba go odtwarzać — żadnego `DROP ... CASCADE`,
2. przedefiniowanie 3 funkcji `bpp_refresh_autor_()` z prologiem
kasującym,
3. regeneracja bramki `WHEN` na 3 triggerach `*_cache_upd` — **musi być po
@@ -442,8 +469,48 @@ dokładnie ten problem, który `0432`/`0433` rozwiązały — patrz
row = cur.fetchone()
assert row is not None, f"brak triggera {trigger}"
assert "deleted_at" in row[0], f"{trigger}: bramka WHEN nie zna deleted_at"
+
+
+ @pytest.mark.django_db
+ def test_widok_odcina_WLASCIWY_wiersz_a_nie_cudzy(
+ wydawnictwo_ciagle_z_dwoma_autorami, wydawnictwo_ciagle_z_autorem
+ ):
+ """Test SEMANTYCZNY klucza filtra — nie sam fakt obecności `deleted_at`.
+
+ Łapie pomyłkę `object_id_raw` (id publikacji) vs `(id)[2]` (pk wiersza
+ through): przy złym kluczu skasowane autorstwo zostaje w widoku, a
+ wycięte zostają autorstwa INNEJ publikacji o zbieżnym numerze.
+ """
+ wca = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first()
+ obcy = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ wca.delete()
+
+ with connection.cursor() as cur:
+ cur.execute(
+ "SELECT count(*) FROM bpp_wydawnictwo_ciagle_autorzy "
+ "WHERE (id)[2] = %s",
+ [wca.pk],
+ )
+ assert cur.fetchone()[0] == 0, (
+ "skasowane autorstwo NADAL w widoku — filtr używa złego klucza"
+ )
+ cur.execute(
+ "SELECT count(*) FROM bpp_wydawnictwo_ciagle_autorzy "
+ "WHERE (id)[2] = %s",
+ [obcy.pk],
+ )
+ assert cur.fetchone()[0] == 1, (
+ "filtr wyciął autorstwo INNEJ publikacji — klucz porównuje "
+ "id publikacji z id wiersza through"
+ )
```
+ ⚠️ Test `test_widok_zrodlowy_filtruje_po_deleted_at` (substring w
+ `pg_get_viewdef`) i `test_bramka_when_zna_deleted_at` **przejdą także dla
+ błędnego klucza** — `pg_depend` widzi `deleted_at` przez podzapytanie
+ niezależnie od tego, czy porównanie ma sens. Powyższy test semantyczny jest
+ jedyną realną wyrocznią tego kroku; **nie pomijaj go**.
+
- [ ] Uruchom (oczekiwany FAIL — wszystkie 9 przypadków):
```bash
uv run pytest src/bpp/tests/test_soft_delete/test_views_sql.py -q
@@ -488,21 +555,46 @@ dokładnie ten problem, który `0432`/`0433` rozwiązały — patrz
def _filtruj_widok(cur, tabela, widok):
- """Dokłada 'AND .deleted_at IS NULL' do widoku źródłowego.
+ """Odcina z widoku *_autorzy wiersze soft-deletowanych autorstw.
Owijamy istniejącą definicję zamiast ją przepisywać: definicja jest
generowana (0421) i przepisanie jej ręcznie rozjechałoby się przy
następnej zmianie kolumn.
+
+ ⚠️ KLUCZ: w widokach *_autorzy `object_id_raw` to `rekord_id`, czyli
+ **id PUBLIKACJI**, a nie pk wiersza through. Pk wiersza through siedzi
+ w drugim elemencie tablicy `id` (`ARRAY[ct, ]`), stąd
+ `(_orig.id)[2]`. Filtrowanie po `object_id_raw` porównywałoby id
+ publikacji z id autorstwa — patrz komentarz niżej.
"""
orig = _viewdef(cur, widok)
cur.execute(
f"CREATE OR REPLACE VIEW {widok} AS "
f"SELECT * FROM ({orig}) _orig "
- f"WHERE _orig.object_id_raw NOT IN ("
- f" SELECT id FROM {tabela} WHERE deleted_at IS NOT NULL)"
+ f"WHERE NOT EXISTS ("
+ f" SELECT 1 FROM {tabela} _t "
+ f" WHERE _t.id = (_orig.id)[2] AND _t.deleted_at IS NOT NULL)"
)
```
+ > 🩹 **Poprawka 2026-08-06 (self-review).** Pierwsza wersja tego kroku
+ > filtrowała `WHERE _orig.object_id_raw NOT IN (SELECT id FROM
+ > WHERE deleted_at IS NOT NULL)` — **błędnie**. Dowód z kodu
+ > (`0421_cache_trigger_pk_filter.sql:315-321,336`):
+ > ```sql
+ > CREATE OR REPLACE VIEW bpp_wydawnictwo_ciagle_autorzy AS
+ > SELECT ARRAY[(...'wydawnictwo_ciagle'...), rekord_id] AS rekord_id,
+ > ARRAY[(...'wydawnictwo_ciagle'...), id] AS id, -- ← through pk
+ > ...
+ > , bpp_wydawnictwo_ciagle_autor.rekord_id AS object_id_raw -- ← id PUBLIKACJI
+ > ```
+ > Skutki błędnej wersji (obie realne): soft-delete autorstwa **nie
+ > odfiltrowałby** właściwego wiersza, a przy zbieżności numerów
+ > **wyciąłby autorstwa cudzej publikacji**. Uwaga na przyszłość: w widokach
+ > `bpp__view` (faza 02) `object_id_raw` to id publikacji i tam jest
+ > kluczem **poprawnym** — te dwa zestawy widoków mają różną semantykę
+ > `object_id_raw`.
+
⚠️ **Do rozstrzygnięcia przy implementacji (nie zgaduj — zmierz):** czy
owijanie widoku (`SELECT * FROM (orig) WHERE object_id_raw NOT IN ...`)
zachowuje plan wykonania i czy `pg_depend` zarejestruje `deleted_at` jako
@@ -649,8 +741,11 @@ Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat`
- [ ] Napisz testy spójności — `src/bpp/tests/test_soft_delete/test_cache_consistency.py`:
```python
"""Spójność materializowanego cache (bpp_autorzy_mat / model Autorzy) po
- soft-delete wierszy *_Autor. Wymaga transactional_db — trigger odpala się
- dopiero przy realnym commicie."""
+ soft-delete wierszy *_Autor.
+
+ UWAGA: zwykły django_db WYSTARCZA — triggery bazodanowe dzialaja wewnatrz
+ transakcji testowej (kanarki test_soft_delete_preconditions.py to
+ pokazuja). transactional_db jest tu zbedny i tylko spowalnia."""
import pytest
@@ -661,9 +756,12 @@ Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat`
def _autorzy_mat_dla(wca):
"""Wiersze bpp_autorzy_mat (model Autorzy) wskazujące na danego autora
w danym rekordzie."""
+ from django.contrib.contenttypes.models import ContentType
+
+ ct = ContentType.objects.get_for_model(type(wca.rekord)).pk
return Autorzy.objects.filter(
autor_id=wca.autor_id,
- rekord_id=[wca.rekord.content_type_id, wca.rekord_id],
+ rekord_id=[ct, wca.rekord_id],
)
@@ -746,7 +844,7 @@ Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat`
# Wszystkie autorstwa tej pracy zniknęły z mat-view
assert not Autorzy.objects.filter(
- rekord_id=[wc.content_type_id, wc.pk]
+ rekord_id=[ContentType.objects.get_for_model(type(wc)).pk, wc.pk]
).exists()
# ... ale wiersze fizycznie żyją (soft, nie hard)
assert Wydawnictwo_Ciagle_Autor.global_objects.filter(rekord=wc).count() >= 2
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
index 10e16114a..0905d536f 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
@@ -44,7 +44,31 @@
- Modify: `src/bpp/models/soft_delete.py` (dodaj klasę mixinu na końcu)
- Test path: `src/bpp/tests/test_soft_delete_publikacje.py` (nowy plik)
-Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-instancja `save()` rodzica, jawna wąska kaskada na `autorzy_set` (każdy wiersz `*_Autor` przez `.delete(transaction_id=...)`), bez refleksyjnej kaskady pakietu (`super().delete()` nie wołamy — sami ustawiamy `deleted_at` + emitujemy sygnał, żeby NIE ruszać `*_Streszczenie`). Wszystkie 5 modeli mają `autorzy_set` (potwierdzone), więc kaskada jest jednolita.
+Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-instancja `save()` rodzica, jawna wąska kaskada na `autorzy_set` (każdy wiersz `*_Autor` przez `.delete(transaction_id=...)`), bez refleksyjnej kaskady pakietu (`super().delete()` nie wołamy — sami ustawiamy `deleted_at` + emitujemy sygnał, żeby NIE ruszać `*_Streszczenie`).
+
+> 🩹 **Poprawka 2026-08-06 (self-review) — kaskada NIE jest jednolita dla 5 modeli.**
+> Pierwsza wersja twierdziła „wszystkie 5 modeli mają `autorzy_set` (potwierdzone)".
+> **Nieprawda.** Dla doktoratu i habilitacji `autorzy_set` to **property**
+> zwracająca atrapy (`src/bpp/models/praca_doktorska.py:30-70`):
+> ```python
+> @property
+> def autorzy_set(self):
+> class FakeAutorDoktoratuHabilitacji:
+> autor = self.autor
+> ...
+> class FakeSet(list):
+> def all(self):
+> return self
+> ...
+> return FakeSet([ret])
+> ```
+> `FakeSet` to podklasa `list` — nie ma `.model`, a `FakeAutorDoktoratuHabilitacji`
+> nie ma `.delete()`. Kaskada verbatim wywaliłaby się `AttributeError` na
+> obu modelach (`delete()` przy `autorstwo.delete(...)`, `restore()` przy
+> `self.autorzy_set.model`). Dlatego mixin ma `_model_through()` /
+> `_autorstwa_do_kaskady()`, które zwracają `None`/`[]` dla publikacji bez
+> through-modelu. Autor doktoratu/habilitacji leży na wierszu publikacji, więc
+> kaskada nie jest tam potrzebna — sam soft-delete rekordu wystarcza.
- [ ] **Krok 1.1 — padający test: soft-delete publikacji kaskaduje na `*_Autor` tym samym `transaction_id`.**
Dopisz do `src/bpp/tests/test_soft_delete_publikacje.py`:
@@ -103,12 +127,31 @@ Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-insta
class Meta:
abstract = True
+ def _model_through(self):
+ """Model *_Autor tej publikacji, albo None (doktorat/habilitacja).
+
+ Praca_Doktorska/Praca_Habilitacyjna NIE mają through-modelu — autor
+ leży na wierszu publikacji, a `autorzy_set` jest tam PROPERTY
+ zwracającą FakeSet z atrapami (patrz box pod kodem).
+ """
+ if isinstance(type(self).__dict__.get("autorzy_set"), property):
+ return None
+ return self.autorzy_set.model
+
+ def _autorstwa_do_kaskady(self):
+ """Wiersze *_Autor do soft-delete; pusto dla doktorat/habilitacja."""
+ if self._model_through() is None:
+ return []
+ return list(self.autorzy_set.all())
+
def delete(self, *args, user=None, reason="", **kwargs):
now = timezone.now()
txid = kwargs.pop("transaction_id", None) or uuid.uuid4()
with transaction.atomic():
# 1. wąska kaskada na własne *_Autor (per-instancja!)
- for autorstwo in self.autorzy_set.all():
+ # ⚠️ TYLKO gdy autorzy_set to prawdziwy related manager —
+ # patrz _przez_through() i box-ostrzeżenie pod kodem.
+ for autorstwo in self._autorstwa_do_kaskady():
autorstwo.delete(transaction_id=txid)
# 2. własne deleted_at + save (NIGDY bulk update)
self.deleted_at = now
@@ -126,8 +169,9 @@ Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-insta
txid = self.transaction_id
with transaction.atomic():
# przywróć własne *_Autor skasowane tym samym transaction_id
- if txid is not None:
- for autorstwo in self.autorzy_set.model.deleted_objects.filter(
+ through = self._model_through()
+ if txid is not None and through is not None:
+ for autorstwo in through.deleted_objects.filter(
rekord=self, transaction_id=txid
):
autorstwo.restore(transaction_id=txid)
@@ -305,10 +349,22 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
Powtórz wzorzec z fazy 01, Task 3 (tam jest pełny opis mechanizmu i pułapek),
dla `REKORD_SITES` z `0432_cache_trigger_plpgsql.py`:
-1. filtr `deleted_at IS NULL` w 5 widokach `bpp__view`,
+1. filtr `deleted_at IS NULL` w 5 widokach `bpp__view` **oraz w 2 widokach
+ `bpp_praca_doktorska_autorzy` / `bpp_praca_habilitacyjna_autorzy`** — te
+ ostatnie selektują z tabeli PUBLIKACJI (autor leży na jej wierszu), więc
+ filtr jest tam „po własnej kolumnie" i klucz `object_id_raw` = id
+ publikacji jest POPRAWNY. Bez nich `bpp_autorzy` przecieka autorów
+ skasowanego doktoratu (faza 01 ich nie dotyka — przypis 3 tamtego planu),
2. gałąź kasująca w 5 funkcjach `bpp_refresh_rekord_()`,
3. regeneracja bramki `WHEN` na 5 triggerach `_cache_upd`.
+⚠️ **Klucz filtra zależy od rodzaju widoku.** W `bpp__view` i w
+`bpp_praca_{doktorska,habilitacyjna}_autorzy` `object_id_raw` to id
+publikacji — filtruj po nim. W widokach `bpp_*_autorzy` trzech typów
+z through-modelem (faza 01) `object_id_raw` to też id publikacji, ale
+filtrowaliśmy tam po pk wiersza through (`(id)[2]`). Nie kopiuj klucza
+między fazami bez sprawdzenia, co dana kolumna znaczy.
+
**Files:**
- Create: `src/bpp/migrations/0493_soft_delete_rekord_views.py`
- Test: `src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py`
@@ -546,25 +602,29 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
wc = baker.make(Wydawnictwo_Ciagle)
baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc)
wc.refresh_from_db()
- ct_pk = wc.content_type_id if hasattr(wc, "content_type_id") else None
+ from django.contrib.contenttypes.models import ContentType
+
+ # UWAGA: content_type NIE istnieje na modelach publikacji -- to
+ # property na RekordBase (rekord.py:288). Bierzemy z ContentType.
+ ct_pk = ContentType.objects.get_for_model(type(wc)).pk
assert Rekord.objects.filter(
- id=(wc.content_type_id, wc.pk)
+ id=(ct_pk, wc.pk)
).exists() or Rekord.objects.filter(tytul_oryginalny=wc.tytul_oryginalny).exists()
wc.delete()
assert not Rekord.objects.filter(
tytul_oryginalny=wc.tytul_oryginalny
).exists(), "skasowany rekord wciąż w Rekord"
- assert not Autorzy.objects.filter(rekord_id=(wc.content_type_id, wc.pk)).exists()
+ assert not Autorzy.objects.filter(rekord_id=(ct_pk, wc.pk)).exists()
wc.restore()
assert Rekord.objects.filter(
tytul_oryginalny=wc.tytul_oryginalny
).exists(), "po restore rekord nie wrócił do Rekord"
- assert Autorzy.objects.filter(rekord_id=(wc.content_type_id, wc.pk)).exists()
+ assert Autorzy.objects.filter(rekord_id=(ct_pk, wc.pk)).exists()
```
- > `Rekord.id` to tuple `(content_type_id, object_id)`. Jeśli `Rekord`/`Autorzy` API różni się — dostosuj filtr po realnym kontrakcie `src/bpp/models/cache/`. Fixture `denorms` (z `src/conftest.py`) odpala denorm flush — sprawdź czy istnieje; jeśli nie, użyj właściwej fixtury cache z repo (`flush_denorm`/`denorm_rebuild`).
+ > `Rekord.id` to tuple `(content_type_id, object_id)`. ⚠️ `content_type_id` bierz z `ContentType.objects.get_for_model(...)` — modele publikacji tego atrybutu NIE mają. Jeśli `Rekord`/`Autorzy` API różni się — dostosuj filtr po realnym kontrakcie `src/bpp/models/cache/`. Fixture `denorms` (z `src/conftest.py`) odpala denorm flush — sprawdź czy istnieje; jeśli nie, użyj właściwej fixtury cache z repo (`flush_denorm`/`denorm_rebuild`).
- [ ] **Krok 5.2 — test: restore przywraca `*_Autor` po tym samym transaction_id.**
```python
@pytest.mark.django_db
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md
index e1dfa3a29..ed9edcc13 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md
@@ -755,7 +755,11 @@ def on_post_restore(sender, instance, **kwargs):
def test_soft_delete_kasuje_cache_punktacji(zwarte_z_dyscyplinami):
zw = zwarte_z_dyscyplinami
zw.przelicz_punkty_dyscyplin()
- klucz = [zw.content_type_id, zw.pk]
+ # content_type NIE jest atrybutem modeli publikacji (property na
+ # RekordBase) -- bierzemy przez ContentType.
+ from django.contrib.contenttypes.models import ContentType
+
+ klucz = [ContentType.objects.get_for_model(type(zw)).pk, zw.pk]
assert Cache_Punktacja_Autora.objects.filter(rekord_id=klucz).exists()
assert Cache_Punktacja_Dyscypliny.objects.filter(rekord_id=klucz).exists()
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
index ec610aad4..63dc408b4 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
@@ -193,6 +193,7 @@ robimy **wprost, surowym SQL-em** (wzorzec:
Dopisz do `test_soft_delete_regresja_cache.py`:
```python
+from django.contrib.contenttypes.models import ContentType
from django.db import connection
from bpp.models.cache.punktacja import Cache_Punktacja_Dyscypliny
@@ -209,7 +210,10 @@ def test_reprojekcja_ze_zrodla_nie_wskrzesza_skasowanej_publikacji(
bramkowanej kolumny i sprawdzamy, ze wiersz NIE wrocil z widoku.
"""
wc = wydawnictwo_ciagle_z_dwoma_autorami
- pk, ct = wc.pk, wc.content_type_id
+ # content_type NIE jest atrybutem modeli publikacji (property na
+ # RekordBase, rekord.py:288) -- bierzemy przez ContentType.
+ pk = wc.pk
+ ct = ContentType.objects.get_for_model(type(wc)).pk
wc.delete()
assert Rekord.objects.count() == 0
@@ -233,7 +237,10 @@ def test_soft_delete_usuwa_cache_punktacji_dyscyplin(zwarte_z_dyscyplinami):
zwarte_z_dyscyplinami.przelicz_punkty_dyscyplin()
ct_pks = list(
Cache_Punktacja_Dyscypliny.objects.filter(
- rekord_id=[zwarte_z_dyscyplinami.content_type_id, zwarte_z_dyscyplinami.pk]
+ rekord_id=[
+ ContentType.objects.get_for_model(type(zwarte_z_dyscyplinami)).pk,
+ zwarte_z_dyscyplinami.pk,
+ ]
).values_list("pk", flat=True)
)
assert len(ct_pks) > 0
@@ -250,7 +257,7 @@ def test_soft_delete_usuwa_cache_punktacji_dyscyplin(zwarte_z_dyscyplinami):
assert (
Cache_Punktacja_Dyscypliny.objects.filter(
rekord_id=[
- zwarte_z_dyscyplinami.content_type_id,
+ ContentType.objects.get_for_model(type(zwarte_z_dyscyplinami)).pk,
zwarte_z_dyscyplinami.pk,
]
).count()
From ce6682e8554c54cc6367ca4417c4657e24744ebe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 14:37:19 +0200
Subject: [PATCH 14/75] =?UTF-8?q?feat(soft-delete):=20modu=C5=82=20soft=5F?=
=?UTF-8?q?delete.py=20=E2=80=94=20gate=20na=20update()=20+=20managery?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Task 1 fazy 01 wdrożenia soft-delete. Tworzy współdzielony fundament
menedżerów dla całego wdrożenia (8 faz):
- BppSoftDeleteQuerySet — gate blokujący bulk .update(deleted_at=...)
/ .update(restored_at=...), bo omijałoby post_save, kaskadę *_Autor,
SoftDeleteLog i reversion.
- BppSoftDeleteManager — domyślny manager, filtruje deleted_at__isnull=True.
- BppGlobalManager — widzi wszystkie obiekty (usunięte i nie).
Guard zależności (raise_if_has_protected_children) NIE jest częścią tego
taska — dopisze go faza 04.
Test test_managery_zwracaja_bpp_queryset użył Element_Repozytorium
zamiast Wydawnictwo_Ciagle_Autor z brief-u — patrz task-1-report.md po
uzasadnienie (Django resoluje pola przy .filter(), nie dopiero przy SQL).
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
src/bpp/models/soft_delete.py | 36 +++++++++++
src/bpp/tests/test_soft_delete/__init__.py | 0
.../tests/test_soft_delete/test_managers.py | 63 +++++++++++++++++++
3 files changed, 99 insertions(+)
create mode 100644 src/bpp/models/soft_delete.py
create mode 100644 src/bpp/tests/test_soft_delete/__init__.py
create mode 100644 src/bpp/tests/test_soft_delete/test_managers.py
diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py
new file mode 100644
index 000000000..44913c249
--- /dev/null
+++ b/src/bpp/models/soft_delete.py
@@ -0,0 +1,36 @@
+"""Wspólny fundament soft-delete dla BPP: queryset-gate blokujący bulk
+ustawienie deleted_at/restored_at + managery przepleciające filtr soft-delete
+z naszą podklasą queryset (gate). Guard zależności (PROTECT) dokłada faza 04.
+"""
+
+from django_softdelete.managers import (
+ GlobalManager,
+ SoftDeleteManager,
+ SoftDeleteQuerySet,
+)
+
+
+class BppSoftDeleteQuerySet(SoftDeleteQuerySet):
+ """Gate: blokuje bulk-ustawienie deleted_at/restored_at przez .update()
+ (omijałoby post_save, kaskadę *_Autor, SoftDeleteLog i reversion)."""
+
+ def update(self, **kwargs):
+ if "deleted_at" in kwargs or "restored_at" in kwargs:
+ raise RuntimeError(
+ "Nie ustawiaj deleted_at/restored_at przez .update() — "
+ "użyj .delete()/.restore(). Bulk update omija post_save, "
+ "kaskadę *_Autor, SoftDeleteLog i reversion."
+ )
+ return super().update(**kwargs)
+
+
+class BppSoftDeleteManager(SoftDeleteManager):
+ def get_queryset(self):
+ return BppSoftDeleteQuerySet(self.model, using=self._db).filter(
+ deleted_at__isnull=True
+ )
+
+
+class BppGlobalManager(GlobalManager):
+ def get_queryset(self):
+ return BppSoftDeleteQuerySet(self.model, using=self._db)
diff --git a/src/bpp/tests/test_soft_delete/__init__.py b/src/bpp/tests/test_soft_delete/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/bpp/tests/test_soft_delete/test_managers.py b/src/bpp/tests/test_soft_delete/test_managers.py
new file mode 100644
index 000000000..6e26bcd19
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_managers.py
@@ -0,0 +1,63 @@
+"""Testy menedżerów i queryset-gate'a soft-delete."""
+
+import pytest
+
+from bpp.models.soft_delete import (
+ BppGlobalManager,
+ BppSoftDeleteManager,
+ BppSoftDeleteQuerySet,
+)
+
+
+def test_queryset_gate_blokuje_deleted_at():
+ """update(deleted_at=...) musi rzucić RuntimeError (omija post_save,
+ kaskadę *_Autor, SoftDeleteLog i reversion)."""
+ from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+
+ qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor)
+ with pytest.raises(RuntimeError, match="Nie ustawiaj deleted_at"):
+ qs.update(deleted_at="2026-06-04")
+
+
+def test_queryset_gate_blokuje_restored_at():
+ from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+
+ qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor)
+ with pytest.raises(RuntimeError, match="Nie ustawiaj deleted_at"):
+ qs.update(restored_at="2026-06-04")
+
+
+def test_queryset_gate_przepuszcza_inne_pola():
+ """update() na zwykłym polu działa normalnie (nie rzuca)."""
+ from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+
+ qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor).none()
+ assert qs.update(kolejnosc=5) == 0 # pusty QS, ale nie rzuca
+
+
+def test_managery_zwracaja_bpp_queryset():
+ """Oba managery MUSZĄ zwracać BppSoftDeleteQuerySet — inaczej gate
+ na update() nie zadziała (pakietowy QuerySet go nie ma).
+
+ Uwaga: brief zakładał użycie ``Wydawnictwo_Ciagle_Autor`` (model bez
+ kolumny ``deleted_at`` na tym etapie — dodaje ją dopiero Task 2) na
+ założeniu, że budowa queryset-u jest w pełni leniwa i nie odpali się
+ aż do iteracji. W praktyce Django resoluje nazwy pól już przy
+ wywołaniu ``.filter()`` (budowa drzewa WHERE), nie dopiero przy
+ wykonaniu SQL-a — więc ``BppSoftDeleteManager.get_queryset()`` (który
+ filtruje po ``deleted_at__isnull=True``) rzucał ``FieldError`` zanim
+ zdążył cokolwiek zwrócić. Używamy więc ``Element_Repozytorium`` —
+ istniejącego modelu, który już dziedziczy po
+ ``django_softdelete.models.SoftDeleteModel`` i naprawdę ma kolumny
+ ``deleted_at``/``restored_at`` w ``_meta`` — dzięki czemu ``.filter()``
+ rozwiązuje się poprawnie bez dotykania bazy danych (nadal nie
+ iterujemy po wyniku, więc SQL się nie wykonuje)."""
+ from bpp.models.repozytorium import Element_Repozytorium
+
+ for manager_cls in (BppSoftDeleteManager, BppGlobalManager):
+ manager = manager_cls()
+ manager.model = Element_Repozytorium
+ manager._db = None
+ assert isinstance(manager.get_queryset(), BppSoftDeleteQuerySet), (
+ f"{manager_cls.__name__} nie zwraca BppSoftDeleteQuerySet"
+ )
From b4cf758dd616be1cb2395897a6f92cea49c8b166 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 14:44:18 +0200
Subject: [PATCH 15/75] docs(soft-delete): korekta przeslanki o leniwym
.filter() + precedens Element_Repozytorium
Wykryte przy wykonaniu Task 1 fazy 01. Plan twierdzil, ze budowa queryset-u
jest leniwa, wiec isinstance przejdzie na modelu bez kolumny deleted_at.
Nieprawda: Django resolwuje nazwy pol juz w Query.build_filter(), przy
wywolaniu .filter() -- odroczone jest tylko WYKONANIE zapytania, nie jego
walidacja. Test na Wydawnictwo_Ciagle_Autor padalby FieldError, i to nie
z powodu wadliwej implementacji.
Test korzysta teraz z Element_Repozytorium (repozytorium.py:18) -- trzeciego
precedensu SoftDeleteModel w repo, o ktorym spec nie wiedzial. Dopisany do
sekcji precedensow razem z ostrzezeniem o Zgloszenie_Publikacji_Autor.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
...-04-soft-delete-01-autor-trigger-widoki.md | 26 ++++++++++++++-----
...soft-delete-publikacje-i-autorzy-design.md | 10 ++++++-
2 files changed, 28 insertions(+), 8 deletions(-)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
index 186b61df7..16f747717 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
@@ -111,22 +111,34 @@ Tworzy współdzielony fundament menedżerów dla całego wdrożenia. Guard zale
def test_managery_zwracaja_bpp_queryset():
"""Oba managery MUSZĄ zwracać BppSoftDeleteQuerySet — inaczej gate
na update() nie zadziała (pakietowy QuerySet go nie ma)."""
- from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+ from bpp.models.repozytorium import Element_Repozytorium
for manager_cls in (BppSoftDeleteManager, BppGlobalManager):
manager = manager_cls()
- manager.model = Wydawnictwo_Ciagle_Autor
+ manager.model = Element_Repozytorium
manager._db = None
assert isinstance(manager.get_queryset(), BppSoftDeleteQuerySet), (
f"{manager_cls.__name__} nie zwraca BppSoftDeleteQuerySet"
)
```
- ⚠️ **Uwaga:** `BppSoftDeleteManager.get_queryset()` filtruje po
- `deleted_at__isnull=True`, a na tym etapie `Wydawnictwo_Ciagle_Autor`
- **nie ma jeszcze** kolumny `deleted_at` (dodaje ją Task 2). Budowa
- queryset-u jest leniwa (filtr nie odpala SQL-a), więc `isinstance` przejdzie
- — ale **nie iteruj** po tym queryset-cie w tym teście.
+ > 🩹 **Poprawka 2026-08-06 (wykryta przy wykonaniu Task 1).** Pierwsza wersja
+ > używała tu `Wydawnictwo_Ciagle_Autor` z notatką „budowa queryset-u jest
+ > leniwa, więc `isinstance` przejdzie". **To nieprawda:** Django resolwuje
+ > nazwy pól już w `Query.build_filter()`, przy wywołaniu `.filter()`, a nie
+ > dopiero przy wykonaniu SQL-a. `BppSoftDeleteManager.get_queryset()` filtruje
+ > po `deleted_at__isnull=True`, więc na modelu bez tej kolumny rzuca
+ > `FieldError` **natychmiast** — test padłby, i to nie z powodu wadliwej
+ > implementacji.
+ >
+ > Dlatego bierzemy model, który JUŻ jest `SoftDeleteModel`:
+ > `Element_Repozytorium` (`src/bpp/models/repozytorium.py:18`) — trzeci
+ > precedens soft-delete w repo, obok `Zgłoszenie_Publikacji`. Test dalej
+ > weryfikuje dokładnie to, co ma: klasę zwracanego queryset-u.
+ >
+ > Ogólna zasada dla dalszych faz: **nie zakładaj, że `.filter()` jest
+ > odroczone** — walidacja pól jest natychmiastowa, odroczone jest tylko
+ > wykonanie zapytania.
- [ ] Uruchom (oczekiwany FAIL — `ModuleNotFoundError: bpp.models.soft_delete`):
```bash
diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
index d8aeed0cc..151f47662 100644
--- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
+++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
@@ -740,8 +740,16 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
## 11. Precedensy w repo
- `django-soft-delete>=1.0.23` — `pyproject.toml`.
-- `src/zglos_publikacje/models.py` — `Zgłoszenie_Publikacji` już
+- `src/zglos_publikacje/models.py:60` — `Zgłoszenie_Publikacji` już
`SoftDeleteModel` (wzorzec).
+- `src/bpp/models/repozytorium.py:18` — `Element_Repozytorium` też jest
+ `SoftDeleteModel` (drugi precedens, wykryty przy wykonaniu fazy 01).
+ Model bez własnych managerów — przydatny w testach jako gotowy nośnik
+ kolumn `deleted_at`/`restored_at`.
+- ⚠️ `src/zglos_publikacje/models.py:315` — `Zgloszenie_Publikacji_Autor`
+ dziedziczy po `BazaModeluOdpowiedzialnosciAutorow`, ale jest **POZA
+ zakresem** soft-delete. Dlatego `SoftDeleteModel` wpinamy w 3 konkretne
+ through-modele, NIE w abstrakt (§2.2).
- `src/pbn_export_queue/` — dojrzała kolejka PBN (model + Celery + admin +
retry/lock), wzorzec dla operacji `WYCOFANIE` (wejście asynchroniczne).
- `src/pbn_wysylka_oswiadczen/tasks.py:54-76` — wzorcowa obsługa wyjątków PBN
From 6abf41438082f4d33e4423a17b7e4e7cd24b62e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 15:01:53 +0200
Subject: [PATCH 16/75] feat(soft-delete): *_Autor -> SoftDeleteModel +
migracja pol deleted_at + indeks
Wpiecie BppAutorstwoSoftDeleteMixin (SoftDeleteModel + nasze managery z
Task 1) w 3 KONKRETNE through-modele: Wydawnictwo_Ciagle_Autor,
Wydawnictwo_Zwarte_Autor, Patent_Autor. Abstrakt
BazaModeluOdpowiedzialnosciAutorow celowo NIE ruszony -- ma czwartego
potomka (Zgloszenie_Publikacji_Autor) poza zakresem soft-delete.
Migracja 0488 dodaje deleted_at/restored_at/transaction_id (9 AddField)
+ indeks na deleted_at (3 AddIndex) dla 3 tabel. Indeksy zadeklarowane
tez w Meta.indexes kazdego konkretnego modelu (nie w abstrakcyjnym
mixinie, zeby uniknac kolizji nazw) -- inaczej makemigrations --check
chcialby je co chwile usuwac.
Dodatkowo: nadpisany restore() w mixinie z domyslnym strict=False.
django-softdelete ma asymetryczne defaulty -- delete() domyslnie
strict=False, restore() strict=True -- a strict=True wywala
SoftDeleteException dla kazdej relacji (rowniez zwyklego forward FK)
do modelu spoza SoftDeleteModel. *_Autor ma FK do Autor/Jednostka/
rekordu, ktore nie sa soft-delete w tej fazie, wiec bez tej poprawki
kazde .restore() by padalo.
Znany, oczekiwany efekt przejsciowy: 4 testy w test_cache/ (delete()
na *_Autor nie czysci juz bpp_autorzy_mat/bpp_rekord_mat, bo DB-owe
triggery/widoki jeszcze nie znaja deleted_at) -- to domena Task 3
(widoki + galaz kasujaca w funkcjach refresh + bramka WHEN), potwierdzona
przez test_cache/test_soft_delete_preconditions.py i task-3-brief.md.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
.../0488_autor_soft_delete_fields.py | 78 +++++++++++++++++++
src/bpp/models/patent.py | 6 +-
src/bpp/models/soft_delete.py | 33 ++++++++
src/bpp/models/wydawnictwo_ciagle.py | 5 ++
src/bpp/models/wydawnictwo_zwarte.py | 5 ++
.../test_autor_softdelete_model.py | 64 +++++++++++++++
6 files changed, 190 insertions(+), 1 deletion(-)
create mode 100644 src/bpp/migrations/0488_autor_soft_delete_fields.py
create mode 100644 src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py
diff --git a/src/bpp/migrations/0488_autor_soft_delete_fields.py b/src/bpp/migrations/0488_autor_soft_delete_fields.py
new file mode 100644
index 000000000..31263d767
--- /dev/null
+++ b/src/bpp/migrations/0488_autor_soft_delete_fields.py
@@ -0,0 +1,78 @@
+# Generated by Django 5.2.16 on 2026-08-06 12:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("bpp", "0487_api_v1_przelaczniki"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="patent_autor",
+ name="deleted_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="patent_autor",
+ name="restored_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="patent_autor",
+ name="transaction_id",
+ field=models.UUIDField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="wydawnictwo_ciagle_autor",
+ name="deleted_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="wydawnictwo_ciagle_autor",
+ name="restored_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="wydawnictwo_ciagle_autor",
+ name="transaction_id",
+ field=models.UUIDField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="wydawnictwo_zwarte_autor",
+ name="deleted_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="wydawnictwo_zwarte_autor",
+ name="restored_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="wydawnictwo_zwarte_autor",
+ name="transaction_id",
+ field=models.UUIDField(blank=True, null=True),
+ ),
+ migrations.AddIndex(
+ model_name="wydawnictwo_ciagle_autor",
+ index=models.Index(
+ fields=["deleted_at"],
+ name="wc_autor_deleted_at_idx",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="wydawnictwo_zwarte_autor",
+ index=models.Index(
+ fields=["deleted_at"],
+ name="wz_autor_deleted_at_idx",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="patent_autor",
+ index=models.Index(
+ fields=["deleted_at"],
+ name="patent_autor_deleted_at_idx",
+ ),
+ ),
+ ]
diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py
index 49cfd1831..1d9013145 100644
--- a/src/bpp/models/patent.py
+++ b/src/bpp/models/patent.py
@@ -25,11 +25,12 @@
RekordBPPBaza,
)
from bpp.models.autor import Autor
+from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin
from bpp.models.system import Charakter_Formalny, Jezyk
from bpp.util import safe_tytul_html
-class Patent_Autor(BazaModeluOdpowiedzialnosciAutorow):
+class Patent_Autor(BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutorow):
"""Powiązanie autora do patentu."""
rekord = models.ForeignKey(
@@ -51,6 +52,9 @@ class Meta:
# Tu musi być autor, inaczej admin nie pozwoli wyedytować
("rekord", "autor", "kolejnosc"),
]
+ indexes = [
+ models.Index(fields=["deleted_at"], name="patent_autor_deleted_at_idx"),
+ ]
class _Patent_PropertyCache:
diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py
index 44913c249..0b618ad99 100644
--- a/src/bpp/models/soft_delete.py
+++ b/src/bpp/models/soft_delete.py
@@ -4,10 +4,12 @@
"""
from django_softdelete.managers import (
+ DeletedManager,
GlobalManager,
SoftDeleteManager,
SoftDeleteQuerySet,
)
+from django_softdelete.models import SoftDeleteModel
class BppSoftDeleteQuerySet(SoftDeleteQuerySet):
@@ -34,3 +36,34 @@ def get_queryset(self):
class BppGlobalManager(GlobalManager):
def get_queryset(self):
return BppSoftDeleteQuerySet(self.model, using=self._db)
+
+
+class BppAutorstwoSoftDeleteMixin(SoftDeleteModel):
+ """SoftDeleteModel + nasze managery dla through-modeli *_Autor.
+
+ Wpinany w 3 KONKRETNE modele (Wydawnictwo_Ciagle_Autor,
+ Wydawnictwo_Zwarte_Autor, Patent_Autor), NIE w abstrakt
+ BazaModeluOdpowiedzialnosciAutorow — ten ma czwartego potomka,
+ Zgloszenie_Publikacji_Autor, który jest poza zakresem soft-delete.
+ """
+
+ # Nadpisujemy managery pakietu naszymi (gate na update()).
+ # Kolejność: pierwszy zdefiniowany manager = _default_manager.
+ objects = BppSoftDeleteManager()
+ global_objects = BppGlobalManager()
+ deleted_objects = DeletedManager()
+
+ class Meta:
+ abstract = True
+
+ def restore(self, strict: bool = False, *args, **kwargs):
+ """`SoftDeleteModel.restore()` domyślnie ma `strict=True` — dla
+ KAŻDEJ relacji (nie tylko kaskadowanej) wymaga, żeby powiązany
+ model też był `SoftDeleteModel`, inaczej rzuca
+ `SoftDeleteException`. `*_Autor` ma zwykłe FK do `Autor`,
+ `Jednostka` i rekordu (żaden z nich nie jest soft-delete w tej
+ fazie), więc strict=True wywaliłoby każde `.restore()`.
+ `delete()` z tego samego pakietu domyślnie ma `strict=False` —
+ ujednolicamy `restore()` do tego samego domyślnego zachowania.
+ """
+ return super().restore(*args, strict=strict, **kwargs)
diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py
index 9584b4eab..abd895bde 100644
--- a/src/bpp/models/wydawnictwo_ciagle.py
+++ b/src/bpp/models/wydawnictwo_ciagle.py
@@ -46,12 +46,14 @@
ModelZWWW,
Wydawnictwo_Baza,
)
+from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin
from bpp.models.system import Zewnetrzna_Baza_Danych
from bpp.models.util import ZapobiegajNiewlasciwymCharakterom
class Wydawnictwo_Ciagle_Autor(
DirtyFieldsMixin,
+ BppAutorstwoSoftDeleteMixin,
BazaModeluOdpowiedzialnosciAutorow,
):
"""Powiązanie autora do wydawnictwa ciągłego."""
@@ -75,6 +77,9 @@ class Meta:
# Tu musi być autor, inaczej admin nie pozwoli wyedytować
("rekord", "autor", "kolejnosc"),
]
+ indexes = [
+ models.Index(fields=["deleted_at"], name="wc_autor_deleted_at_idx"),
+ ]
class ModelZOpenAccessWydawnictwoCiagle(ModelZOpenAccess):
diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py
index f0ffac5ea..e636ec980 100644
--- a/src/bpp/models/wydawnictwo_zwarte.py
+++ b/src/bpp/models/wydawnictwo_zwarte.py
@@ -53,6 +53,7 @@
)
from bpp.models.autor import Autor
from bpp.models.nagroda import Nagroda
+from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin
from bpp.models.system import Zewnetrzna_Baza_Danych
from bpp.models.util import ZapobiegajNiewlasciwymCharakterom
from bpp.models.wydawca import Wydawca
@@ -60,6 +61,7 @@
class Wydawnictwo_Zwarte_Autor(
DirtyFieldsMixin,
+ BppAutorstwoSoftDeleteMixin,
BazaModeluOdpowiedzialnosciAutorow,
):
"""Model zawierający informację o przywiązaniu autorów do wydawnictwa
@@ -84,6 +86,9 @@ class Meta:
# Tu musi być autor, inaczej admin nie pozwoli wyedytować
("rekord", "autor", "kolejnosc"),
]
+ indexes = [
+ models.Index(fields=["deleted_at"], name="wz_autor_deleted_at_idx"),
+ ]
MIEJSCE_I_ROK_MAX_LENGTH = 256
diff --git a/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py b/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py
new file mode 100644
index 000000000..e8829b9bd
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py
@@ -0,0 +1,64 @@
+"""*_Autor jako SoftDeleteModel: pola, managery, soft-delete/restore per
+instancja (bez sprawdzania cache — to Task 4)."""
+
+import pytest
+from django_softdelete.models import SoftDeleteModel
+
+from bpp.models.patent import Patent_Autor
+from bpp.models.soft_delete import BppGlobalManager, BppSoftDeleteManager
+from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte_Autor
+
+THROUGH_MODELE = [
+ Wydawnictwo_Ciagle_Autor,
+ Wydawnictwo_Zwarte_Autor,
+ Patent_Autor,
+]
+
+
+@pytest.mark.parametrize("klass", THROUGH_MODELE)
+def test_through_jest_softdeletemodel(klass):
+ assert issubclass(klass, SoftDeleteModel)
+
+
+@pytest.mark.parametrize("klass", THROUGH_MODELE)
+def test_through_ma_pola_soft_delete(klass):
+ nazwy = {f.name for f in klass._meta.get_fields()}
+ assert {"deleted_at", "restored_at", "transaction_id"} <= nazwy
+
+
+@pytest.mark.parametrize("klass", THROUGH_MODELE)
+def test_through_ma_nasze_managery(klass):
+ assert isinstance(klass.objects, BppSoftDeleteManager)
+ assert isinstance(klass.global_objects, BppGlobalManager)
+
+
+@pytest.mark.django_db
+def test_soft_delete_ukrywa_w_objects_widoczne_w_global(
+ wydawnictwo_ciagle_z_autorem,
+):
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ pk = wca.pk
+ wca.delete()
+ assert not Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists()
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(pk=pk).exists()
+ assert Wydawnictwo_Ciagle_Autor.deleted_objects.filter(pk=pk).exists()
+
+
+@pytest.mark.django_db
+def test_restore_przywraca_do_objects(wydawnictwo_ciagle_z_autorem):
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ pk = wca.pk
+ wca.delete()
+ Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore()
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists()
+
+
+def test_zgloszenie_publikacji_autor_nie_jest_soft_delete():
+ """Czwarty potomek abstraktu jest POZA zakresem soft-delete."""
+ from django_softdelete.models import SoftDeleteModel
+
+ from zglos_publikacje.models import Zgloszenie_Publikacji_Autor
+
+ assert not issubclass(Zgloszenie_Publikacji_Autor, SoftDeleteModel)
+ assert not hasattr(Zgloszenie_Publikacji_Autor, "global_objects")
From 126214cbf5ac77acb989510ffb824c686e32f380 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 15:19:19 +0200
Subject: [PATCH 17/75] fix(soft-delete): test_wca_delete_cache wolal
filter_rekord() ze zla instancja
filter_rekord() oczekuje obiektu Rekord (pk = krotka (content_type_id,
object_id) obslugiwana przez TupleField), nie Wydawnictwo_Ciagle_Autor
(pk = zwykly int autoinkrementowany). Test podawal 'aca' (through-row)
zamiast juz dostepnego 'r' (Rekord).
Przed Taskiem 2 .delete() na *_Autor bylo hard-delete, wiec Django
zerowalo aca.pk -> filter(rekord_id=None) -> "IS NULL" -> 0 wierszy ->
test przechodzil PRZYPADKIEM, mimo zlego wywolania. Po wpieciu
SoftDeleteModel .delete() jest miekkie, aca.pk zostaje liczba ->
porownanie integer[] = integer w Postgresie -> UndefinedFunction,
zanim tresc bpp_autorzy_mat zaczela miec jakiekolwiek znaczenie.
Po poprawce test pada na wlasciwej asercji o liczbie wierszy w cache
(2 == 0) -- oczekiwane do czasu Taska 3 (galaz kasujaca w funkcjach
refresh + widoki znajace deleted_at), nie na bledzie SQL.
Zgloszone przez coordinatora w rundzie poprawek 1/5 do Taska 2.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
src/bpp/tests/test_cache/test_cache.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/bpp/tests/test_cache/test_cache.py b/src/bpp/tests/test_cache/test_cache.py
index d5ca392d9..a878f9223 100644
--- a/src/bpp/tests/test_cache/test_cache.py
+++ b/src/bpp/tests/test_cache/test_cache.py
@@ -194,7 +194,9 @@ def test_wca_delete_cache(wydawnictwo_ciagle_z_dwoma_autorami, denorms):
denorms.flush()
- assert Autorzy.objects.filter_rekord(aca).count() == 0
+ # filter_rekord() oczekuje obiektu Rekord (pk = krotka (ct, object_id)),
+ # nie Wydawnictwo_Ciagle_Autor (pk = zwykły int) — stąd `r`, nie `aca`.
+ assert Autorzy.objects.filter_rekord(r).count() == 0
assert Rekord.objects.all().count() == 1
r = Rekord.objects.all()[0]
From b1e18af13805c066fa549fa2cf9983da255c7f08 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 15:25:46 +0200
Subject: [PATCH 18/75] docs(soft-delete): restore(strict=False) jako kontrakt
PINNED
Wykryte przy wykonaniu fazy 01 Task 2. SoftDeleteModel.restore() ma domyslnie
strict=True i sprawdza KAZDE pole z related_model -- takze zwykle FK w przod --
zanim rozroznii typ relacji. Poniewaz *_Autor ma FK do Autor i Jednostka
(nie-soft), gole .restore() rzuca SoftDeleteException i nie da sie przywrocic
niczego.
Kontrakt: kazdy model soft-delete w BPP nadpisuje restore() ze strict=False.
Ostrzezenie dopisane takze do fazy 04 -- Autor ma FK do Tytul itd., wiec
trafilby na dokladnie ten sam blad przy przywracaniu husku, i test padlby
nie z powodu wadliwego guardu.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
.../plans/2026-06-04-soft-delete-00-overview.md | 17 +++++++++++++++++
.../2026-06-04-soft-delete-04-guardy-protect.md | 6 ++++++
2 files changed, 23 insertions(+)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
index 0131bed39..f9dedf8c7 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
@@ -69,6 +69,23 @@ Szczegóły i uzasadnienie: §2.1 specu.
- `SoftDeleteQuerySet.delete()` iteruje per-instancję (`for obj in self.iterator(): obj.delete()`) — bezpieczny dla sygnałów. **NIE** robi bulk update.
- Sygnały (`django_softdelete/signals.py`): `post_soft_delete`, `post_hard_delete`, `post_restore`.
- ⚠️ **`strict` jest ASYMETRYCZNE:** `delete(strict=False)`, ale `restore(strict=True)`. Czyli domyślna kaskada `delete()` **nie krzyknie** na nie-soft dzieciach — po cichu po nich przejedzie. Kolejny powód, by nie polegać na kaskadzie pakietu (§2.2 specu).
+- 🔴 **KONSEKWENCJA `restore(strict=True)` — wykryta przy wykonaniu fazy 01, Task 2.**
+ Pakiet sprawdza `strict` w pętli po **WSZYSTKICH** polach z `related_model`,
+ zanim rozróżni typ relacji — więc także po **zwykłych FK w przód**
+ (`django_softdelete/models.py`, pętla w `restore()`):
+ ```python
+ for field in self._meta.get_fields():
+ RelatedModel = field.related_model
+ if not RelatedModel: continue
+ if strict and not issubclass(RelatedModel, SoftDeleteModel):
+ raise SoftDeleteException(...)
+ ```
+ `*_Autor` ma FK do `Autor` i `Jednostka` (nie-soft), więc **gołe
+ `.restore()` rzuca `SoftDeleteException` i nie da się przywrócić niczego.**
+ **Kontrakt PINNED: każdy model soft-delete w BPP nadpisuje `restore()`
+ z `strict=False`** (albo pisze własne, jak mixin publikacji w fazie 02).
+ Dotyczy fazy 01 (`BppAutorstwoSoftDeleteMixin`) i fazy 04 (`Autor` — ma FK
+ do `Tytul`, `Funkcja_Autora` itd., więc trafi na to samo).
- ⚠️ **`delete()` zapisuje przez `save(update_fields=['deleted_at','restored_at','transaction_id'])`** — to jest przyczyna, dla której bramka `WHEN` triggera cache nie przepuszcza soft-delete (i dla której `ostatnio_zmieniony`/`auto_now` NIE jest bumpowany).
### Nowy moduł `src/bpp/models/soft_delete.py` (tworzy faza 01)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md b/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md
index 8b2042427..5a242148d 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md
@@ -22,6 +22,12 @@
- **Max długość linii 88 znaków** (ruff). Komentarze/komunikaty po polsku.
- **Testy:** pytest-only, standalone funkcje (bez klas `unittest.TestCase`), `@pytest.mark.django_db`, `model_bakery.baker.make`. Fixtury z `src/fixtures/` (`autor_jan_nowak`, `autor_jan_kowalski`, `jednostka`, `typy_odpowiedzialnosci`, `wydawnictwo_zwarte`, `wydawnictwo_ciagle`, `praca_doktorska`, `patent`).
- **Kontrakt z reversion (NIE łamać):** override `delete()`/`restore()` idzie per-instancja przez `self.save()` / `super().delete()` — **nigdy** bulk `queryset.update(deleted_at=...)`. Faza 04 nie ustawia `deleted_at` ręcznie; deleguje do `super().delete()` pakietu.
+- 🔴 **`Autor.restore()` MUSI nadpisać `strict` na `False`** (wykryte przy wykonaniu fazy 01, Task 2 — patrz kontrakty PINNED w indeksie 00). `SoftDeleteModel.restore()` ma domyślnie `strict=True` i sprawdza **każde** pole z `related_model` — także zwykłe FK w przód — zanim rozróżni typ relacji. `Autor` ma FK m.in. do `Tytul`, więc gołe `Autor.restore()` rzuci `SoftDeleteException` i **przywrócenie husku będzie niemożliwe**. Wzorzec z fazy 01:
+ ```python
+ def restore(self, strict: bool = False, *args, **kwargs):
+ return super().restore(*args, strict=strict, **kwargs)
+ ```
+ Bez tego test przywrócenia husku autora NIE przejdzie — i to nie z powodu wadliwego guardu.
- **Po każdym zadaniu:** `ruff format src/bpp` + `ruff check src/bpp` (tylko zmienione), komenda testu z zadania na zielono, commit.
## Stan zweryfikowany w kodzie (punkt wyjścia)
From c312cfa8a50fba00b4b01b7f77f71bef5f1c69c0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 15:43:54 +0200
Subject: [PATCH 19/75] docs(soft-delete): Task 3b (faza 01) i 2c (faza 02) --
bramka denorm
Recenzja Taska 2 fazy 01 wykryla Critical: BPP ma DWA niezalezne systemy
triggerow na tych samych tabelach, oba z bramka po liscie kolumn.
1. nasz cache _mat: _cache_upd, bramka z pg_depend (0433) -- Task 3;
2. django-denorm: d_aft_row_upd_on_*, bramka z list only= w depend_on_related
(denorm/db/triggers.py:102-118) -- NIE byla objeta zadnym taskiem.
Zadna z 15 zaleznosci celujacych w *_Autor nie wymienia deleted_at, wiec UPDATE
soft-delete nie odpala triggera denorm, a stary AFTER DELETE nie ma juz czego
lapac. Skutek: opis_bibliograficzny_cache, slug i cached_punkty_dyscyplin
zostaja nieswieze NA STALE -- publiczna strona pokazuje usunietego autora,
a punkty dyscyplin dalej go licza.
Task 3b (faza 01) dodaje denorm_always_only na 3 modelach *_Autor, z
obowiazkowa weryfikacja wstepna: laczenie listy only z denorm_always_only,
w polaczeniu z galezia "brak only -> obserwuj wszystkie pola", ZAWEZILOBY
zaleznosc pozbawiona only do samego deleted_at. Zweryfikowane: wszystkie 15
ma jawne only, wiec dodanie jest addytywne -- ale krok kaze to powtorzyc.
Task 2c (faza 02) to samo dla 5 modeli publikacji, z pytaniem o self-FK
wydawnictwo_nadrzedne (byc moze nieosiagalne przez guard PROTECT z fazy 04).
Dodana tez jawna tabela kolejnosci wykonania fazy 02 -- taski dopisywane po
rewizjach wyladowaly poza numeracja dokumentu.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
...-04-soft-delete-01-autor-trigger-widoki.md | 117 ++++++++++++++++++
.../2026-06-04-soft-delete-02-publikacje.md | 56 +++++++++
2 files changed, 173 insertions(+)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
index 16f747717..edd46c0d7 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
@@ -721,6 +721,123 @@ Co-Authored-By: Claude Opus 5 (1M context) "
---
+## Task 3b — Bramka denorm: `deleted_at` w `depend_on_related` (DRUGI system triggerów)
+
+> 🔴 **Dodane 2026-08-06** po recenzji Taska 2. Luka w zakresie, nie defekt
+> implementacji — żaden task faz 01-05 tego nie obejmował.
+
+**BPP ma DWA niezależne systemy triggerów na tych samych tabelach**, oba
+z bramką po liście kolumn:
+
+| System | Trigger | Bramka budowana z | Naprawia |
+|---|---|---|---|
+| cache `_mat` (nasz) | `_cache_upd` | `pg_depend` (migracja `0433`) | Task 3 |
+| `django-denorm` | `d_aft_row_upd_on__` | listy `only=` w `@depend_on_related` | **ten task** |
+
+Task 3 naprawia wyłącznie pierwszy. Drugi zostaje ślepy na `deleted_at`, więc
+**pola denormalizowane rodzica pozostają nieświeże NA STAŁE** po soft-delete
+autorstwa (aż do ręcznego `denorm.rebuildall`).
+
+**Dowód (zweryfikowany w źródle pakietu i empirycznie):**
+`denorm/db/triggers.py:102-118` buduje `WHEN (OLD.f IS DISTINCT FROM NEW.f OR …)`
+z `self.fields`, a te pochodzą z `only=`:
+```python
+# denorm/db/base.py:136,167
+only = (only or ()) + getattr(self.model, "denorm_always_only", ())
+...
+self.fields = [... for k, v in fields_with_model
+ if not v and k.attname not in skip and k.attname in only]
+```
+Żadna z 15 zależności na `*_Autor` nie wymienia `deleted_at`, więc UPDATE
+ruszający tylko `deleted_at`/`restored_at`/`transaction_id` **nie odpala
+żadnego triggera denorm**, a stary `AFTER DELETE` nie ma już czego łapać.
+
+**Co zostaje nieświeże:** `opis_bibliograficzny_cache`,
+`opis_bibliograficzny_autorzy_cache`,
+`opis_bibliograficzny_zapisani_autorzy_cache`, `slug` oraz
+**`cached_punkty_dyscyplin`** — czyli publiczna strona pokazuje usuniętego
+autora, a punkty dyscyplin dalej go liczą.
+
+**Files:**
+- Modify: `src/bpp/models/wydawnictwo_ciagle.py`, `wydawnictwo_zwarte.py`, `patent.py`
+ (3 klasy `*_Autor` — dodanie `denorm_always_only`)
+- Test: `src/bpp/tests/test_soft_delete/test_denorm_gate.py`
+
+**Steps:**
+
+- [ ] **Krok 3b.1 — padający test.** Sprawdza mechanizm END-TO-END, nie samą
+ obecność stringu w DDL:
+ ```python
+ @pytest.mark.django_db
+ def test_soft_delete_autorstwa_odswieza_opis_biblio(
+ wydawnictwo_ciagle_z_autorem, denorms
+ ):
+ """Soft-delete autorstwa MUSI unieważnić denorm-cache rodzica.
+
+ Drugi system triggerów (django-denorm) ma własną bramkę WHEN po liście
+ `only=` — bez `deleted_at` UPDATE soft-delete jej nie przechodzi i opis
+ zostaje nieświeży NA STAŁE.
+ """
+ wc = wydawnictwo_ciagle_z_autorem
+ denorms.flush()
+ wc.refresh_from_db()
+ assert "KOWALSKI" in wc.opis_bibliograficzny_cache
+
+ wc.autorzy_set.first().delete()
+ denorms.flush()
+ wc.refresh_from_db()
+
+ assert "KOWALSKI" not in wc.opis_bibliograficzny_cache, (
+ "denorm-cache nieświeży — bramka WHEN triggera denorm nie zna "
+ "deleted_at (denorm_always_only)"
+ )
+ ```
+ (nazwisko dopasuj do realnej fixtury)
+
+- [ ] **Krok 3b.2 — implementacja.** Dodaj do KAŻDEJ z 3 klas `*_Autor`
+ atrybut klasowy:
+ ```python
+ # django-denorm buduje bramkę WHEN triggera z listy `only=` w
+ # @depend_on_related. Bez deleted_at soft-delete autorstwa nie
+ # unieważniłby denorm-cache rodzica (opis bibliograficzny, slug,
+ # cached_punkty_dyscyplin) — zostałby nieświeży na stałe.
+ denorm_always_only = ("deleted_at",)
+ ```
+
+ ⚠️ **Dlaczego to bezpieczne akurat tutaj — SPRAWDŹ PRZED ZMIANĄ.**
+ `only = (only or ()) + denorm_always_only` w połączeniu z późniejszym
+ `if only: … else: only = ` znaczy, że dodanie
+ `denorm_always_only` do modelu, którego zależność **nie ma** `only=`,
+ **ZAWĘZI** ją z „wszystkie kolumny" do „tylko `deleted_at`" — czyli
+ wyłączy istniejące odświeżanie. Zweryfikowano 2026-08-06: wszystkie **15**
+ zależności celujących w `*_Autor` mają jawne `only=`, więc dodanie jest
+ czysto addytywne. **Powtórz tę weryfikację**, zanim zmienisz kod:
+ ```bash
+ grep -rn --include='*.py' -A4 "depend_on_related" src/ | grep -B1 -A4 "_Autor\""
+ ```
+ Jeśli znajdziesz choć jedną bez `only=` — NIE używaj `denorm_always_only`;
+ zamiast tego dopisz `"deleted_at"` do każdej listy `only=` ręcznie.
+
+- [ ] **Krok 3b.3 — przeinstaluj triggery denorm.** Zmiana list `only=` nie
+ zmienia DDL sama z siebie. Ustal, jak repo instaluje triggery denorm
+ (`grep -rn "denorm_init\|denorm_rebuild\|install_triggers" src/ Makefile`)
+ i wykonaj właściwy krok; jeśli triggery są zakładane migracją — dodaj nową
+ migrację, **nie modyfikuj istniejącej**. Zweryfikuj DDL:
+ ```sql
+ SELECT tgname, pg_get_triggerdef(oid) FROM pg_trigger
+ WHERE tgrelid='bpp_wydawnictwo_ciagle_autor'::regclass AND NOT tgisinternal;
+ ```
+ Bramka `d_aft_row_upd_on_*` MUSI zawierać `deleted_at`.
+
+- [ ] **Krok 3b.4 — PASS + regresja.** `uv run pytest src/bpp/tests/test_cache/
+ src/bpp/tests/test_soft_delete/ -q`. Test `test_opis_bibliograficzny_dependent`
+ i denorm-owe asercje w `test_wca_delete_cache` powinny się zazielenić
+ właśnie tutaj (Task 3 ich NIE naprawia — recenzja Taska 2 to potwierdziła).
+
+- [ ] **Krok 3b.5 — commit** + newsfragment jeśli jeszcze nie ma.
+
+---
+
## Task 4 — Testy spójności cache (mat-view) po soft-delete `*_Autor`
Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` (model `Autorzy`) i z `bpp_autorzy` (model `AutorzyView`); restore → wraca; edycja autorstwa skasowanej publikacji nie wskrzesza wiersza w cache; kaskada queryset-owa (`.delete()` na QS) działa per-instancja. Testy wymagają `transactional_db` (trigger działa tylko z prawdziwym commitem).
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
index 0905d536f..804667812 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
@@ -12,6 +12,28 @@
---
+## ⚠️ KOLEJNOŚĆ WYKONANIA (taski NIE stoją w dokumencie w kolejności wykonania)
+
+Taski dopisywane po rewizjach wylądowały poza numeracją. Wykonuj w TEJ kolejności:
+
+| # | Task | Dlaczego tu |
+|---|---|---|
+| 1 | Task 1 — mixin `delete()`/`restore()` | fundament |
+| 2 | Task 2 — 5 modeli + migracja pól | kolumny muszą istnieć przed DDL |
+| 3 | **Task 2b** — widoki + gałąź kasująca + bramka `WHEN` | czyta `deleted_at` z kroku 2 |
+| 4 | **Task 2c** — bramka denorm | drugi system triggerów |
+| 5 | Task 3 — `slug` warunkowy unique | niezależny |
+| 6 | **Task 3b** — `unique_together` na `*_Autor` | niezależny |
+| 7 | Task 4 — przeplecenie menedżerów | — |
+| 8 | Task 5 — testy integracyjne | wymaga 1-7 |
+| 9 | Task 6 — weryfikacja fazy | ostatni |
+
+**Numery migracji nadawaj sekwencyjnie w KOLEJNOŚCI WYKONANIA**, nie wg numerów
+z nagłówków tasków (te są orientacyjne i już się rozjechały). Przed każdą
+migracją: `ls src/bpp/migrations/*.py | tail -3`.
+
+---
+
## Założenia wejściowe (z fazy 01 — VERBATIM, nie zmieniać)
- `src/bpp/models/soft_delete.py` istnieje i eksportuje `BppSoftDeleteQuerySet`, `BppSoftDeleteManager`, `BppGlobalManager` (kod w indeksie §"Nowy moduł").
@@ -400,6 +422,40 @@ i `_create_delete_rekord_function` w `0432`).
---
+## Task 2c: Bramka denorm dla 5 modeli publikacji (DRUGI system triggerów)
+
+> 🔴 **Dodane 2026-08-06** po recenzji fazy 01 Task 2. Analogia do Taska 3b
+> fazy 01 — tam dla `*_Autor`, tu dla samych publikacji.
+
+`django-denorm` ma własną bramkę `WHEN` budowaną z list `only=` w
+`@depend_on_related` (`denorm/db/triggers.py:102-118`, `denorm/db/base.py:136`).
+Jest to system NIEZALEŻNY od naszych triggerów `_cache_upd` z `0432`/`0433`.
+
+Dla publikacji dochodzi drugi wariant zależności — `@depend_on_related("self",
+"wydawnictwo_nadrzedne")` (rozdziały → książka-matka, `wydawnictwo_zwarte.py`).
+Soft-delete książki-matki musi unieważnić denorm-cache rozdziałów.
+
+**Steps:**
+
+- [ ] Wypisz WSZYSTKIE `@depend_on_related` celujące w 5 modeli publikacji
+ (także `"self"`) i sprawdź, które mają `only=`, a które nie:
+ ```bash
+ grep -rn --include='*.py' -A5 "depend_on_related" src/ | grep -B2 -A5 "self\|Wydawnictwo_\|Patent\|Praca_"
+ ```
+ ⚠️ Zależność **bez** `only=` + `denorm_always_only` = ZAWĘŻENIE do samego
+ `deleted_at` (patrz ostrzeżenie w fazie 01 Task 3b). Jeśli choć jedna taka
+ jest — dopisz `"deleted_at"` do list `only=` ręcznie zamiast używać
+ `denorm_always_only`.
+- [ ] Test end-to-end: soft-delete książki-matki → denorm-cache rozdziału
+ (`opis_bibliograficzny_cache` zawierający tytuł nadrzędny) przestaje być
+ aktualny? Rozstrzygnij, czy to w ogóle pożądane — guard PROTECT z fazy 04
+ i tak zablokuje soft-delete książki z rozdziałami, więc być może ten
+ przypadek jest nieosiągalny. **Jeśli nieosiągalny — zapisz to i pomiń**,
+ nie dokładaj martwego kodu.
+- [ ] Przeinstaluj triggery denorm i zweryfikuj DDL (`pg_get_triggerdef`).
+
+---
+
## Task 3b: `unique_together` na `*_Autor` → warunkowy `UniqueConstraint` (decyzja #13)
> Dodane 2026-08-06 (spec §2.2b). Ten sam problem co ze slugiem, przeoczony
From 5ec77ca3b163c74ecbd4d5c58f303b5654ce074c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 15:57:54 +0200
Subject: [PATCH 20/75] fix(soft-delete): restore() na 3 sciezkach
(instancja/deleted_objects/global_objects)
Runda poprawek 2/5 do Taska 2 (recenzja koordynatora, Important).
PROBLEM: BppAutorstwoSoftDeleteMixin.restore() mial strict=False jako
DOMYSLNY parametr, ale django_softdelete wola go z JAWNYM strict=True z
poziomu querysetow:
- DeletedQuerySet.restore() (deleted_objects) -> obj.restore(strict=True)
- GlobalQuerySet.restore() -- global_objects w BPP w ogole nie mial tej
metody (BppGlobalManager zwraca BppSoftDeleteQuerySet, ktory dziedziczy
z SoftDeleteQuerySet bez restore()) -> AttributeError
Efekt: Wydawnictwo_Ciagle_Autor.deleted_objects.restore() i
.global_objects.restore() padaly, mimo poprawki na poziomie instancji.
ROZWIAZANIE (wariant b z dwoch zaproponowanych przez koordynatora --
czysciejszy architektonicznie): rozszerzenie PINNED kontraktu o
BppDeletedQuerySet/BppDeletedManager (nowa czwarta/piata klasa, jawnie
udokumentowana w docstringu modulu jako czesc kontraktu) + restore()
dopisane do juz-PINNED BppSoftDeleteQuerySet (ta sama klasa, ktora
BppGlobalManager juz zwracal dla gate'u update() -- test Taska 1
test_managery_zwracaja_bpp_queryset sprawdza dokladnie to i pozostaje
zielony bez zmian). Oba nowe restore() maja strict=False domyslnie,
zgodnie z inwariantem zapisanym teraz w docstringu modulu.
Skorygowana tez sygnatura instancyjnego restore(): odwzorowuje rodzica
(strict, transaction_id, *args, **kwargs) i przekazuje pozycyjnie do
super() -- poprzednia wersja (super().restore(*args, strict=strict,
**kwargs)) dawala TypeError: got multiple values for argument 'strict'
przy wywolaniu pozycyjnym typu obj.restore(False, txid).
Dodane testy (8, wszystkie PASS) pokrywajace:
- restore() z kazdej z 3 sciezek nie rzuca SoftDeleteException,
- wc.autorzy_set.all() faktycznie ukrywa soft-deletowane (reverse FK
manager dziedziczy z _default_manager),
- deleted_objects jest DeletedManager i zwraca tylko skasowane,
- gate .update(deleted_at=...) rzuca RuntimeError na realnym queryset-cie
konkretnego modelu (Task 1 testowal to tylko w izolacji klasy).
Dodany tez newsfragment (brakowal w commicie feature -- wymog CLAUDE.md).
Weryfikacja: uv run pytest src/bpp/tests/test_soft_delete/
src/bpp/tests/test_cache/ -q -> 24 passed w test_soft_delete/ (bylo 16),
97 passed / 4 failed (te same znane, zakres Task 3) / 1 skipped w sumie
obu katalogow. uv run ruff check src/bpp/models/soft_delete.py
src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py ->
czyste. Szerszy sweep (test_models/test_wydawnictwo_autor.py,
test_models_legacy.py, test_admin.py) -> 209 passed.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
src/bpp/models/soft_delete.py | 77 ++++++++++++++++++-
.../soft-delete-autorstwo.feature.rst | 6 ++
.../test_autor_softdelete_model.py | 70 +++++++++++++++++
3 files changed, 150 insertions(+), 3 deletions(-)
create mode 100644 src/bpp/newsfragments/soft-delete-autorstwo.feature.rst
diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py
index 0b618ad99..47be9d8b4 100644
--- a/src/bpp/models/soft_delete.py
+++ b/src/bpp/models/soft_delete.py
@@ -1,10 +1,33 @@
"""Wspólny fundament soft-delete dla BPP: queryset-gate blokujący bulk
ustawienie deleted_at/restored_at + managery przepleciające filtr soft-delete
z naszą podklasą queryset (gate). Guard zależności (PROTECT) dokłada faza 04.
+
+INWARIANT (dot. ``restore()``): żadna ścieżka przywracania w BPP — instancja
+(``obj.restore()``), ``deleted_objects`` ani ``global_objects`` — nie może
+rzucić ``SoftDeleteException`` tylko dlatego, że model powiązany (np.
+``Autor``, ``Jednostka``, rekord) nie jest sam ``SoftDeleteModel``. Pakiet
+``django_softdelete`` ma tu domyślnie ``strict=True`` na wszystkich trzech
+ścieżkach (asymetrycznie względem ``delete()``, który domyślnie ma
+``strict=False``) — w BPP wymuszamy domyślne ``strict=False`` wszędzie:
+
+- instancja: nadpisany ``BppAutorstwoSoftDeleteMixin.restore()``,
+- ``global_objects``: ``BppSoftDeleteQuerySet.restore()`` (ta sama klasa
+ queryset, którą już zwracał ``BppGlobalManager`` dla gate'u ``update()``),
+- ``deleted_objects``: KONTRAKT ROZSZERZONY o czwartą klasę,
+ ``BppDeletedQuerySet``/``BppDeletedManager`` (pakietowy ``DeletedManager``
+ zwracał ``DeletedQuerySet`` bez naszego nadpisania — to samo
+ ``strict=True`` co wyżej).
+
+Wszystkie cztery klasy (``BppSoftDeleteQuerySet``, ``BppSoftDeleteManager``,
+``BppGlobalManager``, ``BppDeletedQuerySet``/``BppDeletedManager``) razem z
+``BppAutorstwoSoftDeleteMixin`` są PINNED — Task 1 zdefiniował pierwszą
+trójkę, Task 2 (runda poprawek 2) dopisał ``BppDeletedQuerySet``/
+``BppDeletedManager`` jako część tego samego kontraktu.
"""
from django_softdelete.managers import (
DeletedManager,
+ DeletedQuerySet,
GlobalManager,
SoftDeleteManager,
SoftDeleteQuerySet,
@@ -25,6 +48,22 @@ def update(self, **kwargs):
)
return super().update(**kwargs)
+ def restore(self, strict: bool = False, *args, **kwargs):
+ """Przywraca wiersze querysetu (ścieżka ``global_objects``).
+
+ Bazowy ``SoftDeleteQuerySet`` (pakiet) w ogóle nie ma ``restore()``
+ — dodajemy go tutaj, żeby ``global_objects.restore()`` (via
+ ``BppGlobalManager``, który zwraca tę właśnie klasę) nie rzucał
+ ``AttributeError``. Domyślne ``strict=False`` — patrz inwariant w
+ docstringu modułu.
+ """
+ qs = self.filter(*args, **kwargs)
+ for obj in qs:
+ obj.restore(strict=strict)
+ return
+
+ restore.alters_data = True
+
class BppSoftDeleteManager(SoftDeleteManager):
def get_queryset(self):
@@ -38,6 +77,30 @@ def get_queryset(self):
return BppSoftDeleteQuerySet(self.model, using=self._db)
+class BppDeletedQuerySet(DeletedQuerySet):
+ """``DeletedQuerySet`` (pakiet) z domyślnym ``strict=False`` w
+ ``restore()`` — patrz inwariant w docstringu modułu. Bez tego
+ ``deleted_objects.restore()`` woła jawne ``strict=True`` (kod pakietu),
+ co nadpisywałoby domyślną wartość z ``BppAutorstwoSoftDeleteMixin.
+ restore()`` i rzucało ``SoftDeleteException``.
+ """
+
+ def restore(self, strict: bool = False, *args, **kwargs):
+ qs = self.filter(*args, **kwargs)
+ for obj in qs:
+ obj.restore(strict=strict)
+ return
+
+ restore.alters_data = True
+
+
+class BppDeletedManager(DeletedManager):
+ def get_queryset(self):
+ return BppDeletedQuerySet(self.model, using=self._db).filter(
+ deleted_at__isnull=False
+ )
+
+
class BppAutorstwoSoftDeleteMixin(SoftDeleteModel):
"""SoftDeleteModel + nasze managery dla through-modeli *_Autor.
@@ -51,12 +114,14 @@ class BppAutorstwoSoftDeleteMixin(SoftDeleteModel):
# Kolejność: pierwszy zdefiniowany manager = _default_manager.
objects = BppSoftDeleteManager()
global_objects = BppGlobalManager()
- deleted_objects = DeletedManager()
+ deleted_objects = BppDeletedManager()
class Meta:
abstract = True
- def restore(self, strict: bool = False, *args, **kwargs):
+ def restore(
+ self, strict: bool = False, transaction_id: str = None, *args, **kwargs
+ ):
"""`SoftDeleteModel.restore()` domyślnie ma `strict=True` — dla
KAŻDEJ relacji (nie tylko kaskadowanej) wymaga, żeby powiązany
model też był `SoftDeleteModel`, inaczej rzuca
@@ -65,5 +130,11 @@ def restore(self, strict: bool = False, *args, **kwargs):
fazie), więc strict=True wywaliłoby każde `.restore()`.
`delete()` z tego samego pakietu domyślnie ma `strict=False` —
ujednolicamy `restore()` do tego samego domyślnego zachowania.
+
+ Sygnatura celowo odwzorowuje rodzica (``strict``, ``transaction_id``
+ jako pierwsze dwa parametry) — przekazywanie ich pozycyjnie do
+ ``super().restore()`` unika ``TypeError: got multiple values for
+ argument 'strict'`` przy wywołaniu pozycyjnym typu
+ ``obj.restore(False, txid)``.
"""
- return super().restore(*args, strict=strict, **kwargs)
+ return super().restore(strict, transaction_id, *args, **kwargs)
diff --git a/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst
new file mode 100644
index 000000000..60dc1048c
--- /dev/null
+++ b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst
@@ -0,0 +1,6 @@
+Wewnętrzny fundament pod usuwanie „miękkie" (soft-delete) powiązań autor-
+-publikacja: trzy tabele przypisań autorstwa (wydawnictwa ciągłe, zwarte,
+patenty) zyskały znaczniki kasowania/przywracania zamiast trwałego
+usuwania wiersza. Zmiana jest na razie wewnętrzna i niewidoczna dla
+użytkowników — pełne wsparcie (widoki, cache, panel administracyjny)
+wdrażają kolejne etapy.
diff --git a/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py b/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py
index e8829b9bd..157318b1d 100644
--- a/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py
+++ b/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py
@@ -2,6 +2,7 @@
instancja (bez sprawdzania cache — to Task 4)."""
import pytest
+from django_softdelete.managers import DeletedManager
from django_softdelete.models import SoftDeleteModel
from bpp.models.patent import Patent_Autor
@@ -54,6 +55,75 @@ def test_restore_przywraca_do_objects(wydawnictwo_ciagle_z_autorem):
assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists()
+@pytest.mark.django_db
+def test_restore_z_deleted_objects_nie_rzuca(wydawnictwo_ciagle_z_autorem):
+ """`deleted_objects.restore()` (ścieżka querysetowa) nie może rzucić
+ SoftDeleteException tylko dlatego, że Autor/Jednostka/rekord nie są
+ same SoftDeleteModel — patrz inwariant w docstringu soft_delete.py."""
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ pk = wca.pk
+ wca.delete()
+ Wydawnictwo_Ciagle_Autor.deleted_objects.filter(pk=pk).restore()
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists()
+
+
+@pytest.mark.django_db
+def test_restore_z_global_objects_nie_rzuca(wydawnictwo_ciagle_z_autorem):
+ """`global_objects.restore()` (druga ścieżka querysetowa) — to samo."""
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ pk = wca.pk
+ wca.delete()
+ Wydawnictwo_Ciagle_Autor.global_objects.filter(pk=pk).restore()
+ assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists()
+
+
+@pytest.mark.django_db
+def test_autorzy_set_ukrywa_soft_deleted_widoczne_w_global(
+ wydawnictwo_ciagle_z_dwoma_autorami,
+):
+ """`_default_manager` podmieniony na `BppSoftDeleteManager` realnie
+ ukrywa soft-deletowany wiersz również przez odwrotną relację
+ `rekord.autorzy_set` (reverse FK manager dziedziczy z
+ `_default_manager` modelu)."""
+ wca = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first()
+ pk = wca.pk
+ wca.delete()
+ assert not wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.filter(pk=pk).exists()
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(pk=pk).exists()
+
+
+@pytest.mark.parametrize("klass", THROUGH_MODELE)
+def test_deleted_objects_jest_deletedmanager(klass):
+ assert isinstance(klass.deleted_objects, DeletedManager)
+
+
+@pytest.mark.django_db
+def test_deleted_objects_zwraca_tylko_skasowane(wydawnictwo_ciagle_z_dwoma_autorami):
+ wca_pozostaje, wca_kasowany = list(
+ wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.all()
+ )
+ wca_kasowany.delete()
+
+ skasowane_pk = set(
+ Wydawnictwo_Ciagle_Autor.deleted_objects.values_list("pk", flat=True)
+ )
+ assert skasowane_pk == {wca_kasowany.pk}
+ assert wca_pozostaje.pk not in skasowane_pk
+
+
+@pytest.mark.django_db
+def test_gate_update_deleted_at_rzuca_na_konkretnym_modelu(
+ wydawnictwo_ciagle_z_autorem,
+):
+ """Gate z Taska 1 był testowany tylko na `BppSoftDeleteQuerySet` w
+ izolacji (`test_managers.py`) — tu sprawdzamy, że faktycznie działa na
+ realnym queryset-cie konkretnego modelu, przez `objects`."""
+ with pytest.raises(RuntimeError, match="Nie ustawiaj deleted_at"):
+ Wydawnictwo_Ciagle_Autor.objects.filter(
+ pk=wydawnictwo_ciagle_z_autorem.autorzy_set.first().pk
+ ).update(deleted_at="2026-06-04")
+
+
def test_zgloszenie_publikacji_autor_nie_jest_soft_delete():
"""Czwarty potomek abstraktu jest POZA zakresem soft-delete."""
from django_softdelete.models import SoftDeleteModel
From e142b8a050376661f916900aeccb2ce912930da7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 16:56:25 +0200
Subject: [PATCH 21/75] feat(soft-delete): widoki + galaz kasujaca + bramka
WHEN dla *_Autor
Trzy zmiany w jednej migracji (0489), w wymuszonej kolejnosci: filtr
deleted_at w widokach bpp_*_autorzy -> galaz kasujaca w funkcjach
bpp_refresh_autor_* -> regeneracja bramki WHEN (pg_depend zna juz
deleted_at). Odwrocenie 1<->3 dalo by bramke bez deleted_at, czyli cichy
staleness.
Zadna z nich nie wystarcza sama: filtr widoku nie sprzata _mat (upsert po
0432 nie kasuje, wiec wypadniecie wiersza ze zrodla jest no-opem), a bez
deleted_at w bramce UPDATE soft-delete
(save(update_fields=['deleted_at','restored_at','transaction_id']))
w ogole nie dochodzi do funkcji triggera.
Wariant filtra: WHERE wewnetrzny (`... FROM WHERE
.deleted_at IS NULL`), nie owijka `SELECT * FROM (orig) _orig
WHERE NOT EXISTS (...)` z planu. Plan kazal ZMIERZYC, czy owijka zachowuje
plan wykonania -- nie zachowuje: hot path triggera schodzi z Index Scan
(7.35) na Nested Loop Anti Join (12.21), a pelny skan z 11.50 na 26.84.
Filtr wewnetrzny daje plan identyczny z oryginalem (7.35), a pelny skan
przyspiesza do 2.36 (indeks na deleted_at z 0488). Dodatkowo jest odporny
z konstrukcji na pulapke klucza opisana w planie: nie porownuje zadnych
kluczy, wiec pomylka object_id_raw (id PUBLIKACJI) vs (id)[2] (pk wiersza
through) jest tu niemozliwa.
Definicje widokow i funkcji generowane z introspekcji (reuzycie generatorow
z 0432/0433 przez importlib), nie kopiowane do .sql. backward() odtwarza
widoki bez filtra z tekstu 0421 (ostatnia migracja, ktora je definiowala),
funkcje przez _p0432._create_through_function, bramke przez ponowne
przeliczenie pg_depend.
Testy: src/bpp/tests/test_soft_delete/test_views_sql.py -- 3 kontrakty DDL
(viewdef / functiondef / triggerdef) plus test SEMANTYCZNY klucza filtra
(scenariusz z pk wiersza through rownym id publikacji-pulapki, zeby zly
klucz wywalil sie na obu asercjach). test_migracja_0489_rewers.py pilnuje
odwracalnosci.
Zazielenilo test_cache_pk_filter::test_dodanie_i_usuniecie_autora_odswieza_
autorzy_mat oraz test_cache_trigger_v3::test_usuniecie_jednej_z_dwoch_rol_
autora_zostawia_druga. test_opis_bibliograficzny_dependent i
test_wca_delete_cache pozostaja czerwone -- to Task 3b (bramka
django-denorm, drugi, niezalezny system triggerow).
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
.../0489_soft_delete_autorzy_views.py | 220 ++++++++++++++++++
.../soft-delete-autorstwo.feature.rst | 7 +-
.../test_migracja_0489_rewers.py | 91 ++++++++
.../tests/test_soft_delete/test_views_sql.py | 173 ++++++++++++++
4 files changed, 488 insertions(+), 3 deletions(-)
create mode 100644 src/bpp/migrations/0489_soft_delete_autorzy_views.py
create mode 100644 src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py
create mode 100644 src/bpp/tests/test_soft_delete/test_views_sql.py
diff --git a/src/bpp/migrations/0489_soft_delete_autorzy_views.py b/src/bpp/migrations/0489_soft_delete_autorzy_views.py
new file mode 100644
index 000000000..fe8249b75
--- /dev/null
+++ b/src/bpp/migrations/0489_soft_delete_autorzy_views.py
@@ -0,0 +1,220 @@
+"""Soft-delete autorstw: filtr w widokach + gałąź kasująca + bramka WHEN.
+
+Kolejność operacji jest WYMUSZONA:
+
+ 1) widoki ``bpp_*_autorzy`` (dodają ``deleted_at`` do drzewa zapytania) ->
+ 2) funkcje ``bpp_refresh_autor_*`` (gałąź kasująca) ->
+ 3) regeneracja bramki WHEN (czyta ``pg_depend`` PO definicji widoku z 1)
+
+Kolumny bramki są wyliczane z ``pg_depend``, więc ``deleted_at`` wchodzi do niej
+wyłącznie dlatego, że krok 1 wstawił tę kolumnę do definicji widoku.
+
+Odwrócenie 1<->3 daje bramkę bez ``deleted_at``, czyli cichy staleness:
+soft-deletowane autorstwo zostaje w ``bpp_autorzy_mat``.
+
+Żadna z trzech zmian nie wystarcza sama:
+
+- sam filtr widoku nie sprząta ``bpp_autorzy_mat`` — funkcja refresh po 0432
+ robi wyłącznie ``INSERT ... ON CONFLICT DO UPDATE``, więc wypadnięcie wiersza
+ ze źródła jest dla niej no-opem (patrz
+ ``test_cache/test_soft_delete_preconditions.py``);
+- bez ``deleted_at`` w bramce WHEN (0433) UPDATE soft-delete
+ (``save(update_fields=['deleted_at', 'restored_at', 'transaction_id'])``)
+ w ogóle nie dochodzi do funkcji triggera.
+
+Definicje widoków i funkcji są GENEROWANE z introspekcji (tak jak w 0432/0433),
+a nie przepisane do ``.sql`` — kopia rozjechałaby się przy najbliższej zmianie
+kolumn. Stąd ``RunPython`` i ``importlib`` (nazwy modułów migracji zaczynają się
+od cyfry, więc zwykły ``import`` nie przejdzie).
+"""
+
+import importlib
+from pathlib import Path
+
+from django.db import connection, migrations
+
+_p0432 = importlib.import_module("bpp.migrations.0432_cache_trigger_plpgsql")
+_p0433 = importlib.import_module("bpp.migrations.0433_cache_trigger_when_gate")
+
+THROUGH_SITES = _p0432.THROUGH_SITES # [(tabela through, model content_type), ...]
+
+# Ostatnia migracja definiująca widoki bpp_*_autorzy. 0443 i 0458 też zawierają
+# CREATE VIEW, ale dotyczą zupełnie innych widoków (bpp_kronika_*, bpp_nowe_sumy_*)
+# — sprawdzone przy pisaniu tej migracji. Używane WYŁĄCZNIE w backward().
+ZRODLO_ORYGINALNYCH_WIDOKOW = "0421_cache_trigger_pk_filter.sql"
+
+
+def _widok(tabela):
+ """``bpp_patent_autor`` -> ``bpp_patent_autorzy``."""
+ return tabela[: -len("_autor")] + "_autorzy"
+
+
+def _viewdef(cur, widok):
+ cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok])
+ return cur.fetchone()[0].rstrip().rstrip(";")
+
+
+def _filtruj_widok(cur, tabela, widok):
+ """Odcina z widoku ``*_autorzy`` wiersze soft-deletowanych autorstw.
+
+ Dopisujemy ``WHERE`` do istniejącej, INTROSPEKOWANEJ definicji zamiast
+ przepisywać ją ręcznie: definicja jest generowana (0421) i kopia
+ rozjechałaby się przy następnej zmianie kolumn. ``CREATE OR REPLACE VIEW``
+ zachowuje listę i typy kolumn, więc zależny ``bpp_autorzy`` (UNION po
+ pięciu widokach) NIE jest kasowany — żadnego ``DROP ... CASCADE``.
+
+ **Dlaczego filtr WEWNĘTRZNY, a nie owijka.** Plan zakładał owinięcie
+ (``SELECT * FROM () _orig WHERE NOT EXISTS (...)``) i kazał ZMIERZYĆ,
+ czy owijka zachowuje plan wykonania. Zmierzone (PostgreSQL 16, EXPLAIN na
+ bazie testowej):
+
+ =========================================== ========== ==============
+ zapytanie oryginał owijka
+ =========================================== ========== ==============
+ hot path triggera (``object_id_raw`` + Index Scan Nested Loop
+ ``autor_id``) 7.35 Anti Join 12.21
+ pełny skan (przebudowa ``bpp_autorzy``) 11.50 26.84
+ =========================================== ========== ==============
+
+ Owijka NIE zachowuje planu (dokłada anti-join i sondę po pkey na każdy
+ wiersz), więc — zgodnie z instrukcją planu na taki właśnie wypadek —
+ dopisujemy ``deleted_at IS NULL`` do ``WHERE`` wewnętrznego. Ten wariant
+ daje plan **identyczny** z oryginałem na hot-pathie (Index Scan, 7.35), a
+ pełny skan wręcz przyspiesza (2.36) — trafia w indeks na ``deleted_at``
+ założony w 0488.
+
+ ⚠️ KLUCZ (pułapka, na której przejechał się pierwotny plan): w widokach
+ ``*_autorzy`` kolumna ``object_id_raw`` to ``rekord_id``, czyli **id
+ PUBLIKACJI**, a NIE pk wiersza through (ten siedzi w drugim elemencie
+ kolumny-tablicy ``id``). Filtr po ``object_id_raw`` porównywałby id
+ publikacji z id autorstwa: skasowane autorstwo zostawałoby w widoku, a przy
+ zbieżności numerów wycinane byłyby autorstwa CUDZEJ publikacji. Filtr
+ wewnętrzny nie porównuje ŻADNYCH kluczy — patrzy wprost na kolumnę wiersza,
+ więc jest na tę pomyłkę odporny z konstrukcji. Regresji pilnuje
+ ``test_widok_odcina_WLASCIWY_wiersz_a_nie_cudzy`` w
+ ``src/bpp/tests/test_soft_delete/test_views_sql.py`` — testy substringowe
+ (``deleted_at`` w ``pg_get_viewdef``) tego NIE łapią.
+ """
+ orig = _viewdef(cur, widok)
+ if not orig.endswith(f"FROM {tabela}"):
+ # Dopisanie WHERE jest poprawne tylko dla definicji kończącej się gołym
+ # `FROM `: bez aliasu (inaczej kwalifikacja kolumny nie
+ # zadziała) i bez własnego WHERE (inaczej powstałby drugi WHERE).
+ raise RuntimeError(
+ f"{widok}: definicja nie konczy sie na 'FROM {tabela}' -- "
+ f"dopisanie WHERE bylo by niepoprawne. Definicja: ...{orig[-120:]!r}"
+ )
+ cur.execute(
+ f"CREATE OR REPLACE VIEW {widok} AS {orig} WHERE {tabela}.deleted_at IS NULL"
+ )
+
+
+def _funkcja_z_galezia_kasujaca(cur, tabela, model):
+ """``bpp_refresh_autor_()`` z 0432 + prolog kasujący.
+
+ Ciało generowane identycznie jak ``_p0432._create_through_function`` (ten sam
+ upsert z pozycyjnym mapowaniem kolumn), z jedną różnicą: gdy wiersz jest
+ soft-deletowany, kasujemy go z ``bpp_autorzy_mat`` i wychodzimy. Klucz
+ ``ARRAY[ct, NEW.id]`` jest ten sam co w ``_create_delete_through_function``
+ (tam ``OLD.id``, tu ``NEW.id`` — przy UPDATE to ten sam wiersz).
+
+ ``pg_advisory_xact_lock`` stoi PRZED rozgałęzieniem: kasowanie musi brać ten
+ sam lock co upsert, inaczej wraca wyścig z #309.
+
+ Restore (``deleted_at`` -> NULL) leci normalną ścieżką upsertu — wiersz
+ wraca do widoku źródłowego, więc nic dodatkowego nie trzeba.
+ """
+ upsert = _p0432._upsert_sql(
+ cur,
+ "bpp_autorzy_mat",
+ _widok(tabela),
+ "object_id_raw = NEW.rekord_id AND autor_id = NEW.autor_id",
+ )
+ return f"""
+CREATE OR REPLACE FUNCTION bpp_refresh_autor_{model}() RETURNS trigger
+LANGUAGE plpgsql AS $bpp_body$
+DECLARE ct integer;
+BEGIN
+ {_p0432._ct_lookup(model)}
+ PERFORM pg_advisory_xact_lock(ct, NEW.rekord_id);
+ IF NEW.deleted_at IS NOT NULL THEN
+ DELETE FROM bpp_autorzy_mat WHERE id = ARRAY[ct, NEW.id]::integer[];
+ RETURN NULL;
+ END IF;
+ {upsert};
+ RETURN NULL;
+END $bpp_body$;
+"""
+
+
+def _oryginalna_definicja_widoku(widok):
+ """Instrukcja ``CREATE OR REPLACE VIEW `` sprzed tej migracji.
+
+ ``pg_get_viewdef`` sprzed dopisania filtra nie jest w backward dostępny (w
+ bazie stoi już definicja z ``WHERE deleted_at IS NULL``), więc bierzemy
+ tekst z ostatniej migracji definiującej te widoki (patrz
+ ``ZRODLO_ORYGINALNYCH_WIDOKOW``). ``str.index`` rzuca ``ValueError``, gdy
+ markera nie ma — świadomie głośno, zamiast po cichu zostawić widok z filtrem.
+ """
+ sql = (Path(__file__).parent / ZRODLO_ORYGINALNYCH_WIDOKOW).read_text()
+ poczatek = sql.index(f"CREATE OR REPLACE VIEW {widok} AS")
+ # Instrukcja kończy się średnikiem na końcu linii FROM, po niej idzie linia
+ # z samym ";" — pierwsze wystąpienie "\n;" po `poczatek` to właśnie ona.
+ return sql[poczatek : sql.index("\n;", poczatek)]
+
+
+def _regeneruj_bramke():
+ """Ta sama logika co ``0433.forward``, ale tylko dla tabel through.
+
+ Bramka jest wyliczana z ``pg_depend``, więc po zmianie definicji widoku
+ (krok 1) sama wciągnie ``deleted_at``; po cofnięciu widoku (backward) sama
+ ją zgubi. Tabele publikacji (``bpp_wydawnictwo_ciagle`` itd.) to faza 02 —
+ tu ich nie ruszamy.
+ """
+ with connection.cursor() as cur:
+ for tabela, refresh_fn, widoki in _p0433.GATED:
+ if not tabela.endswith("_autor"):
+ continue
+ kolumny = _p0433._gate_columns(cur, tabela, widoki)
+ if not kolumny:
+ # Bezpiecznik z 0433: bez kolumn powstałby niebramkowany
+ # UPDATE. Jeśli tu jesteśmy, krok 1 nie zadziałał.
+ raise RuntimeError(
+ f"bramka dla {tabela}: pg_depend nie zwrocil zadnej kolumny "
+ f"(widoki={widoki}) -- nie tworze niezbramkowanego UPDATE"
+ )
+ when = _p0433._when_clause(kolumny)
+ cur.execute(f"DROP TRIGGER IF EXISTS {tabela}_cache_upd ON {tabela};")
+ cur.execute(
+ f"CREATE TRIGGER {tabela}_cache_upd AFTER UPDATE ON {tabela} "
+ f"FOR EACH ROW WHEN ({when}) "
+ f"EXECUTE PROCEDURE {refresh_fn}();"
+ )
+
+
+def forward(apps, schema_editor):
+ with connection.cursor() as cur:
+ for tabela, _model in THROUGH_SITES: # 1) widoki
+ _filtruj_widok(cur, tabela, _widok(tabela))
+ for tabela, model in THROUGH_SITES: # 2) funkcje
+ cur.execute(_funkcja_z_galezia_kasujaca(cur, tabela, model))
+ _regeneruj_bramke() # 3) bramka
+
+
+def backward(apps, schema_editor):
+ with connection.cursor() as cur:
+ for tabela, _model in THROUGH_SITES: # 1) widoki bez filtra
+ cur.execute(_oryginalna_definicja_widoku(_widok(tabela)))
+ for tabela, model in THROUGH_SITES: # 2) funkcje bez gałęzi kasującej
+ cur.execute(_p0432._create_through_function(cur, tabela, model))
+ _regeneruj_bramke() # 3) bramka (deleted_at zniknie z pg_depend samo)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("bpp", "0488_autor_soft_delete_fields"),
+ ]
+
+ operations = [
+ migrations.RunPython(forward, backward),
+ ]
diff --git a/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst
index 60dc1048c..716c08362 100644
--- a/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst
+++ b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst
@@ -1,6 +1,7 @@
Wewnętrzny fundament pod usuwanie „miękkie" (soft-delete) powiązań autor-
-publikacja: trzy tabele przypisań autorstwa (wydawnictwa ciągłe, zwarte,
patenty) zyskały znaczniki kasowania/przywracania zamiast trwałego
-usuwania wiersza. Zmiana jest na razie wewnętrzna i niewidoczna dla
-użytkowników — pełne wsparcie (widoki, cache, panel administracyjny)
-wdrażają kolejne etapy.
+usuwania wiersza. Skasowane przypisania znikają też z widoków źródłowych i
+z materializowanego cache'u bibliografii, więc nie pokazują się na stronach
+publikacji. Zmiana jest na razie wewnętrzna i niewidoczna dla użytkowników —
+pełne wsparcie (panel administracyjny, przywracanie) wdrażają kolejne etapy.
diff --git a/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py b/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py
new file mode 100644
index 000000000..94d6c6d67
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py
@@ -0,0 +1,91 @@
+"""Odwracalność migracji 0489 (widoki + gałąź kasująca + bramka WHEN).
+
+``backward`` musi zdjąć WSZYSTKIE trzy zmiany — inaczej rollback zostawia
+niespójny stan: np. widok bez filtra, ale funkcja z gałęzią kasującą (kasowałaby
+wiersze, których widok i tak nie odfiltrował) albo bramka znająca ``deleted_at``
+przy funkcji, która nic z tym nie robi.
+
+Test siedzi w zwykłej transakcji testowej (``django_db`` bez
+``transaction=True``): DDL w Postgresie jest transakcyjny, więc obie zmiany
+(unapply + reapply) i wpisy w ``django_migrations`` cofną się razem z nią.
+"""
+
+import pytest
+from django.core.management import call_command
+from django.db import connection
+
+WIDOKI = [
+ "bpp_wydawnictwo_ciagle_autorzy",
+ "bpp_wydawnictwo_zwarte_autorzy",
+ "bpp_patent_autorzy",
+]
+FUNKCJE = [
+ "bpp_refresh_autor_wydawnictwo_ciagle",
+ "bpp_refresh_autor_wydawnictwo_zwarte",
+ "bpp_refresh_autor_patent",
+]
+TRIGGERY = [
+ ("bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_ciagle_autor_cache_upd"),
+ ("bpp_wydawnictwo_zwarte_autor", "bpp_wydawnictwo_zwarte_autor_cache_upd"),
+ ("bpp_patent_autor", "bpp_patent_autor_cache_upd"),
+]
+
+
+def _viewdef(cur, widok):
+ cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok])
+ return cur.fetchone()[0]
+
+
+def _funcdef(cur, fn):
+ cur.execute("SELECT pg_get_functiondef(%s::regproc)", [fn])
+ return cur.fetchone()[0]
+
+
+def _triggerdef(cur, tabela, trigger):
+ cur.execute(
+ "SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t "
+ "WHERE t.tgrelid = %s::regclass AND t.tgname = %s",
+ [tabela, trigger],
+ )
+ return cur.fetchone()[0]
+
+
+def _stan_ddl(cur):
+ return (
+ [_viewdef(cur, w) for w in WIDOKI],
+ [_funcdef(cur, f) for f in FUNKCJE],
+ [_triggerdef(cur, t, g) for t, g in TRIGGERY],
+ )
+
+
+@pytest.mark.django_db
+def test_migracja_0489_odwracalna():
+ with connection.cursor() as cur:
+ przed = _stan_ddl(cur)
+
+ call_command("migrate", "bpp", "0488_autor_soft_delete_fields", verbosity=0)
+
+ with connection.cursor() as cur:
+ for widok in WIDOKI:
+ assert "deleted_at" not in _viewdef(cur, widok), (
+ f"{widok}: backward nie zdjął filtra"
+ )
+ for fn in FUNKCJE:
+ assert "NEW.deleted_at" not in _funcdef(cur, fn), (
+ f"{fn}: backward nie zdjął gałęzi kasującej"
+ )
+ for tabela, trigger in TRIGGERY:
+ assert "deleted_at" not in _triggerdef(cur, tabela, trigger), (
+ f"{trigger}: backward nie przeliczył bramki WHEN"
+ )
+ # Widoki muszą zachować listę i typy kolumn — inaczej UNION bpp_autorzy
+ # (zależny od całej trójki) rozsypałby się przy pierwszym odczycie.
+ cur.execute("SELECT count(*) FROM bpp_autorzy")
+ cur.fetchone()
+
+ call_command("migrate", "bpp", verbosity=0)
+
+ with connection.cursor() as cur:
+ assert _stan_ddl(cur) == przed, (
+ "forward po backward nie odtworzył identycznego DDL"
+ )
diff --git a/src/bpp/tests/test_soft_delete/test_views_sql.py b/src/bpp/tests/test_soft_delete/test_views_sql.py
new file mode 100644
index 000000000..74699d6fa
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_views_sql.py
@@ -0,0 +1,173 @@
+"""Kontrakt DDL po fazie 01: widok filtruje, funkcja kasuje, bramka przepuszcza.
+
+Trzy pierwsze testy są „substringowe" (``pg_get_viewdef`` / ``pg_get_functiondef``
+/ ``pg_get_triggerdef``) — pilnują, że migracja w ogóle się odbyła. Czwarty jest
+SEMANTYCZNY i jest jedyną realną wyrocznią KLUCZA filtra: trzy pierwsze
+przechodzą także dla klucza BŁĘDNEGO (``object_id_raw``, czyli id publikacji,
+zamiast ``(id)[2]``, czyli pk wiersza through), bo ``pg_depend`` widzi kolumnę
+użytą w podzapytaniu niezależnie od tego, czy porównanie ma jakikolwiek sens.
+"""
+
+import pytest
+from django.db import connection
+from model_bakery import baker
+
+from bpp.models import Autor, Jednostka, Wydawnictwo_Ciagle
+
+WIDOKI = [
+ "bpp_wydawnictwo_ciagle_autorzy",
+ "bpp_wydawnictwo_zwarte_autorzy",
+ "bpp_patent_autorzy",
+]
+FUNKCJE = [
+ "bpp_refresh_autor_wydawnictwo_ciagle",
+ "bpp_refresh_autor_wydawnictwo_zwarte",
+ "bpp_refresh_autor_patent",
+]
+TRIGGERY = [
+ ("bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_ciagle_autor_cache_upd"),
+ ("bpp_wydawnictwo_zwarte_autor", "bpp_wydawnictwo_zwarte_autor_cache_upd"),
+ ("bpp_patent_autor", "bpp_patent_autor_cache_upd"),
+]
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("widok", WIDOKI)
+def test_widok_zrodlowy_filtruje_po_deleted_at(widok):
+ with connection.cursor() as cur:
+ cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok])
+ defn = cur.fetchone()[0]
+ assert "deleted_at" in defn, f"{widok} nie filtruje po deleted_at"
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("fn", FUNKCJE)
+def test_funkcja_refresh_ma_galaz_kasujaca(fn):
+ """Bez DELETE odfiltrowanie z widoku jest no-opem (upsert nic nie usuwa)."""
+ with connection.cursor() as cur:
+ cur.execute("SELECT pg_get_functiondef(%s::regproc)", [fn])
+ src = cur.fetchone()[0]
+ assert "NEW.deleted_at IS NOT NULL" in src, f"{fn}: brak gałęzi kasującej"
+ assert "DELETE FROM bpp_autorzy_mat" in src, f"{fn}: brak DELETE"
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("tabela,trigger", TRIGGERY)
+def test_bramka_when_zna_deleted_at(tabela, trigger):
+ """Bez deleted_at w bramce UPDATE soft-delete nie dochodzi do funkcji."""
+ with connection.cursor() as cur:
+ cur.execute(
+ "SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t "
+ "WHERE t.tgrelid = %s::regclass AND t.tgname = %s",
+ [tabela, trigger],
+ )
+ row = cur.fetchone()
+ assert row is not None, f"brak triggera {trigger}"
+ assert "deleted_at" in row[0], f"{trigger}: bramka WHEN nie zna deleted_at"
+
+
+def _przesun_sekwencje_through_ponad_id_publikacji(cur):
+ """Podnosi sekwencję pk tabeli through ponad max(id) obu tabel.
+
+ Dzięki temu pk NASTĘPNEGO wiersza ``bpp_wydawnictwo_ciagle_autor`` jest
+ gwarantowanie WOLNYM id publikacji — możemy założyć publikację-pułapkę o
+ dokładnie takim id i sprawdzić, czy błędny klucz filtra (``object_id_raw``
+ = id publikacji) wycina JEJ autorstwa.
+
+ Sekwencje są poza transakcją, więc ten skok nie cofnie się po teście —
+ zostawia tylko lukę w numeracji, co jest nieszkodliwe.
+ """
+ cur.execute(
+ "SELECT setval("
+ " pg_get_serial_sequence('bpp_wydawnictwo_ciagle_autor', 'id'),"
+ " GREATEST("
+ " (SELECT COALESCE(MAX(id), 0) FROM bpp_wydawnictwo_ciagle_autor),"
+ " (SELECT COALESCE(MAX(id), 0) FROM bpp_wydawnictwo_ciagle)"
+ " ) + 1000,"
+ " true)"
+ )
+
+
+def _ct_wydawnictwo_ciagle(cur):
+ cur.execute(
+ "SELECT id FROM django_content_type "
+ "WHERE app_label='bpp' AND model='wydawnictwo_ciagle'"
+ )
+ return cur.fetchone()[0]
+
+
+@pytest.mark.django_db
+def test_widok_odcina_WLASCIWY_wiersz_a_nie_cudzy(standard_data):
+ """Test SEMANTYCZNY klucza filtra — nie sam fakt obecności ``deleted_at``.
+
+ Scenariusz jest tak dobrany, żeby ZŁY klucz wywalił się na OBU asercjach
+ naraz: pk kasowanego wiersza through jest równy id publikacji-pułapki.
+ Przy filtrze po ``object_id_raw`` skasowane autorstwo zostałoby w widoku,
+ a wycięte zostałyby autorstwa publikacji-pułapki.
+ """
+ with connection.cursor() as cur:
+ _przesun_sekwencje_through_ponad_id_publikacji(cur)
+
+ jednostka = baker.make(Jednostka)
+ kasowany = baker.make(Autor, imiona="Jan", nazwisko="Kasowany")
+ obcy_autor = baker.make(Autor, imiona="Jan", nazwisko="Obcy")
+
+ wc = baker.make(
+ Wydawnictwo_Ciagle,
+ tytul_oryginalny="Publikacja z kasowanym autorstwem",
+ szczegoly="sz",
+ uwagi="u",
+ )
+ wca = wc.dodaj_autora(kasowany, jednostka)
+
+ # Publikacja-pułapka: jej id jest RÓWNE pk kasowanego wiersza through.
+ wc_pulapka = baker.make(
+ Wydawnictwo_Ciagle,
+ id=wca.pk,
+ tytul_oryginalny="Publikacja-pulapka",
+ szczegoly="sz",
+ uwagi="u",
+ )
+ assert wc_pulapka.pk == wca.pk, "setup pułapki nie zadziałał"
+ obcy = wc_pulapka.dodaj_autora(obcy_autor, jednostka)
+
+ wca.delete()
+
+ with connection.cursor() as cur:
+ ct = _ct_wydawnictwo_ciagle(cur)
+
+ cur.execute(
+ "SELECT count(*) FROM bpp_wydawnictwo_ciagle_autorzy WHERE (id)[2] = %s",
+ [wca.pk],
+ )
+ assert cur.fetchone()[0] == 0, (
+ "skasowane autorstwo NADAL w widoku — filtr używa złego klucza"
+ )
+
+ cur.execute(
+ "SELECT count(*) FROM bpp_wydawnictwo_ciagle_autorzy WHERE (id)[2] = %s",
+ [obcy.pk],
+ )
+ assert cur.fetchone()[0] == 1, (
+ "filtr wyciął autorstwo INNEJ publikacji — klucz porównuje "
+ "id publikacji z id wiersza through"
+ )
+
+ # Sam filtr widoku nie sprząta _mat — to robi gałąź kasująca w funkcji
+ # refresh, przepuszczona przez bramkę WHEN. Sprawdzamy cały łańcuch.
+ cur.execute(
+ "SELECT count(*) FROM bpp_autorzy_mat WHERE id = ARRAY[%s, %s]::integer[]",
+ [ct, wca.pk],
+ )
+ assert cur.fetchone()[0] == 0, (
+ "skasowane autorstwo zostało w bpp_autorzy_mat — bramka WHEN nie "
+ "przepuściła UPDATE-u albo funkcja refresh nie ma gałęzi kasującej"
+ )
+
+ cur.execute(
+ "SELECT count(*) FROM bpp_autorzy_mat WHERE id = ARRAY[%s, %s]::integer[]",
+ [ct, obcy.pk],
+ )
+ assert cur.fetchone()[0] == 1, (
+ "z bpp_autorzy_mat zniknęło autorstwo INNEJ publikacji"
+ )
From 3f495f54c2a9c75f76a36db501b9748be6edc32e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 17:02:53 +0200
Subject: [PATCH 22/75] docs(soft-delete): warunkowy unique na *_Autor
przeniesiony do fazy 01
Blad kolejnosci w planie, wykryty przy wykonaniu Taska 3 fazy 01.
Decyzja #13 (unique_together -> warunkowy UniqueConstraint z condition
deleted_at IS NULL) byla zaplanowana w fazie 02, razem ze slugiem. Ale *_Autor
staje sie soft-delete juz w fazie 01 (Task 2), wiec unique_together blokuje
re-insert od TAMTEJ fazy, nie od nastepnej.
Regresja zweryfikowana na realnym kodzie:
import_sqlite/tests/test_patent_apply.py::test_apply_idempotent_update
-> UniqueViolation na bpp_patent_autor_rekord_id_autor_id_kolejnosc_uniq
Zrodlo: import_sqlite/handlers/patent.py:192 robi autorzy_set.all().delete()
i wstawia od nowa; po Tasku 2 delete() jest miekki, wiec stary wiersz fizycznie
istnieje i constraint go widzi.
Wzorzec jest ogolny, nie dotyczy jednego importera -- kazdy przeplyw "skasuj
autorstwa i wstaw od nowa" (re-import, admin inline, korekta kolejnosci) uderzy
w to samo, a skasowany wiersz jest przy tym niewidoczny dla operatora.
Nowy Task 3c w fazie 01 zawiera test odtwarzajacy regresje oraz obowiazkowa
weryfikacje adminu: validate_unique() honoruje unique_together, ale POMIJA
UniqueConstraint z condition, wiec inline autorstwa moze zaczac zwracac
IntegrityError zamiast czytelnego bledu formularza.
W fazie 02 odpowiedni task oznaczony jako przeniesiony (tresc zwinieta jako
kontekst historyczny).
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
...-04-soft-delete-01-autor-trigger-widoki.md | 98 +++++++++++++++++++
.../2026-06-04-soft-delete-02-publikacje.md | 14 ++-
2 files changed, 111 insertions(+), 1 deletion(-)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
index edd46c0d7..121b9fc43 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
@@ -838,6 +838,104 @@ autora, a punkty dyscyplin dalej go liczą.
---
+## Task 3c — Warunkowy `UniqueConstraint` na `*_Autor` (PRZENIESIONE z fazy 02)
+
+> 🔴 **Przeniesione tutaj 2026-08-06** po wykryciu regresji przy wykonaniu Taska 3.
+> Pierwotnie decyzja #13 była zaplanowana w fazie 02 (razem ze slugiem) —
+> **błąd kolejności**: `*_Autor` staje się soft-delete już w fazie 01 (Task 2),
+> więc `unique_together` blokuje re-insert od TEJ fazy, nie od następnej.
+
+**Regresja (zweryfikowana, realna):**
+```
+$ uv run pytest src/import_sqlite/tests/test_patent_apply.py::test_apply_idempotent_update
+E psycopg2.errors.UniqueViolation: podwójna wartość klucza narusza ograniczenie
+ unikalności "bpp_patent_autor_rekord_id_autor_id_kolejnosc_96bdce4c_uniq"
+E DETAIL: Klucz (rekord_id, autor_id, kolejnosc)=(288874, 483391, 0) już istnieje.
+```
+`src/import_sqlite/handlers/patent.py:192` robi `patent.autorzy_set.all().delete()`
+i wstawia od nowa. Po Tasku 2 `delete()` jest miękki → stary wiersz fizycznie
+istnieje → `unique_together` go widzi → `IntegrityError`.
+
+**Wzorzec jest ogólny**, nie dotyczy jednego importera: **każdy** przepływ
+„skasuj autorstwa i wstaw od nowa" (re-import, admin inline, korekta kolejności)
+uderzy w to samo. Skasowany wiersz jest przy tym **niewidoczny dla operatora**,
+więc komunikat błędu mówi o rekordzie, którego nie widać.
+
+**Files:**
+- Modify: `src/bpp/models/wydawnictwo_ciagle.py` (`Wydawnictwo_Ciagle_Autor.Meta`),
+ `wydawnictwo_zwarte.py` (`Wydawnictwo_Zwarte_Autor.Meta`), `patent.py` (`Patent_Autor.Meta`)
+- Create: `src/bpp/migrations/0490_autor_warunkowy_unique.py` (zweryfikuj liść!)
+- Test: `src/bpp/tests/test_soft_delete/test_autor_unique.py`
+
+**Steps:**
+
+- [ ] **Krok 3c.1 — padający test odtwarzający regresję** (na `Patent_Autor`,
+ bo tam wyszła realnie, plus jeden na `Wydawnictwo_Ciagle_Autor`):
+ ```python
+ @pytest.mark.django_db
+ def test_reinsert_autorstwa_po_soft_delete(patent, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci):
+ """Wzorzec "skasuj autorstwa i wstaw od nowa" MUSI działać po soft-delete."""
+ pa = Patent_Autor.objects.create(
+ rekord=patent, autor=autor_jan_kowalski, jednostka=jednostka,
+ typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."),
+ kolejnosc=0, zapisany_jako="Kowalski Jan",
+ )
+ patent.autorzy_set.all().delete() # soft
+ Patent_Autor.objects.create( # ten sam (rekord, autor, kolejnosc)
+ rekord=patent, autor=autor_jan_kowalski, jednostka=jednostka,
+ typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."),
+ kolejnosc=0, zapisany_jako="Kowalski Jan",
+ )
+ assert Patent_Autor.objects.filter(rekord=patent).count() == 1
+ assert Patent_Autor.global_objects.filter(rekord=patent).count() == 2
+ ```
+ Oraz **test regresyjny na realnym przepływie**:
+ `uv run pytest src/import_sqlite/tests/test_patent_apply.py::test_apply_idempotent_update`
+ — ma przejść po tym tasku.
+
+- [ ] **Krok 3c.2 — zamiana w `Meta` 3 konkretnych klas.** Usuń `unique_together`,
+ dodaj warunkowe constrainty (nazwy MUSZĄ być unikalne w całej bazie):
+ ```python
+ constraints = [
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "typ_odpowiedzialnosci"],
+ condition=Q(deleted_at__isnull=True),
+ name="wc_autor_uniq_rekord_autor_typ",
+ ),
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "kolejnosc"],
+ condition=Q(deleted_at__isnull=True),
+ name="wc_autor_uniq_rekord_autor_kolejnosc",
+ ),
+ ]
+ ```
+ (prefiksy per model: `wc_` / `wz_` / `pat_`)
+
+- [ ] **Krok 3c.3 — ⚠️ ZWERYFIKUJ ADMIN.** Komentarz przy drugiej krotce w kodzie
+ („Tu musi być autor, inaczej admin nie pozwoli wyedytować") sugeruje, że ten
+ constraint istnieje ze względu na **walidację formularzy**.
+ `Model.validate_unique()` honoruje `unique_together`, ale `UniqueConstraint`
+ z `condition` **pomija** (Django waliduje tylko constrainty bezwarunkowe).
+ Ryzyko: zamiast czytelnego błędu formularza operator dostanie `IntegrityError`
+ (HTTP 500) przy inline'ach autorstwa.
+ ```bash
+ uv run pytest src/bpp/tests/test_admin/ -k "autor" -q
+ ```
+ Jeśli admin regresuje — dodaj walidację w formularzu/inline (`clean()`
+ sprawdzający kolizję przez `objects`), **NIE wracaj do `unique_together`**
+ (nie da się go pogodzić z soft-delete).
+
+- [ ] **Krok 3c.4 — migracja + brak driftu + regresja:**
+ ```bash
+ DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations bpp --name autor_warunkowy_unique
+ DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run
+ uv run pytest src/bpp/tests/test_soft_delete/ src/import_sqlite/ -q
+ ```
+
+- [ ] **Krok 3c.5 — commit** (użyj `git commit -F `, nie `-m` z backtickami).
+
+---
+
## Task 4 — Testy spójności cache (mat-view) po soft-delete `*_Autor`
Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` (model `Autorzy`) i z `bpp_autorzy` (model `AutorzyView`); restore → wraca; edycja autorstwa skasowanej publikacji nie wskrzesza wiersza w cache; kaskada queryset-owa (`.delete()` na QS) działa per-instancja. Testy wymagają `transactional_db` (trigger działa tylko z prawdziwym commitem).
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
index 804667812..8d01ae9a5 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
@@ -456,7 +456,17 @@ Soft-delete książki-matki musi unieważnić denorm-cache rozdziałów.
---
-## Task 3b: `unique_together` na `*_Autor` → warunkowy `UniqueConstraint` (decyzja #13)
+## ~~Task 3b: `unique_together` na `*_Autor`~~ → PRZENIESIONE DO FAZY 01 (Task 3c)
+
+> 🔀 **Przeniesione 2026-08-06.** Umieszczenie tego w fazie 02 było błędem
+> kolejności: `*_Autor` staje się soft-delete już w fazie 01 (Task 2), więc
+> `unique_together` blokuje re-insert od tamtej fazy. Regresja wyszła realnie
+> (`import_sqlite/handlers/patent.py:192` → `UniqueViolation`) i jest naprawiana
+> w fazie 01, Task 3c. **W tej fazie NIE rób nic z `*_Autor` unique** —
+> poniższa treść zostaje wyłącznie jako kontekst historyczny.
+
+oryginalna treść tasku (nieaktualna)
+
> Dodane 2026-08-06 (spec §2.2b). Ten sam problem co ze slugiem, przeoczony
> w pierwszej wersji planu.
@@ -536,6 +546,8 @@ którego nie widać.
---
+
+
## Task 4: Przeplecenie menedżerów `Wydawnictwo_*_Manager` z filtrem soft-delete (wspólny QuerySet/MRO)
**Files:**
From b2bd36df7f6469de0c7c64f1b47ca086ff8deda3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 17:29:39 +0200
Subject: [PATCH 23/75] =?UTF-8?q?test(soft-delete):=20padaj=C4=85cy=20test?=
=?UTF-8?q?=20e2e=20na=20bramk=C4=99=20denorm=20dla=20deleted=5Fat?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Task 3b (faza 01): BPP ma dwa niezależne systemy triggerów na tabelach
*_Autor. Task 3 naprawił bramkę WHEN naszego cache _mat. Bramka
django-denorm (budowana z list only= w @depend_on_related) zostaje
ślepa na deleted_at, więc soft-delete autorstwa nie odświeża pól
denormalizowanych rodzica (opis_bibliograficzny_cache i pochodne).
Dodaje test end-to-end weryfikujący cały mechanizm (nie samą obecność
stringu w DDL): soft-delete autorstwa + denorms.flush() musi usunąć
nazwisko autora z opis_bibliograficzny_cache rodzica.
Test na razie czerwony — implementacja w kolejnym commicie.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
.../test_soft_delete/test_denorm_gate.py | 40 +++++++++++++++++++
1 file changed, 40 insertions(+)
create mode 100644 src/bpp/tests/test_soft_delete/test_denorm_gate.py
diff --git a/src/bpp/tests/test_soft_delete/test_denorm_gate.py b/src/bpp/tests/test_soft_delete/test_denorm_gate.py
new file mode 100644
index 000000000..33ccff871
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_denorm_gate.py
@@ -0,0 +1,40 @@
+"""Task 3b: bramka WHEN triggera django-denorm musi znać `deleted_at`.
+
+BPP ma DWA niezależne systemy triggerów na tabelach `*_Autor`:
+
+1. cache ``_mat`` (nasz) — naprawiony w Tasku 3 (migracja 0489).
+2. ``django-denorm`` — bramka WHEN budowana z list ``only=`` w
+ ``@depend_on_related``. Naprawiany właśnie tutaj (Task 3b).
+
+Bez ``deleted_at`` w ``only=``/``denorm_always_only`` UPDATE, który tylko
+soft-kasuje wiersz ``*_Autor`` (ustawia ``deleted_at``), nie odpala triggera
+denorm — pola denormalizowane rodzica (``opis_bibliograficzny_cache`` i
+pochodne) zostają nieświeże NA STAŁE.
+"""
+
+import pytest
+
+
+@pytest.mark.django_db
+def test_soft_delete_autorstwa_odswieza_opis_biblio(
+ wydawnictwo_ciagle_z_autorem, denorms
+):
+ """Soft-delete autorstwa MUSI unieważnić denorm-cache rodzica.
+
+ Drugi system triggerów (django-denorm) ma własną bramkę WHEN po liście
+ ``only=`` — bez ``deleted_at`` UPDATE soft-delete jej nie przechodzi i
+ opis zostaje nieświeży NA STAŁE.
+ """
+ wc = wydawnictwo_ciagle_z_autorem
+ denorms.flush()
+ wc.refresh_from_db()
+ assert "KOWALSKI" in wc.opis_bibliograficzny_cache
+
+ wc.autorzy_set.first().delete()
+ denorms.flush()
+ wc.refresh_from_db()
+
+ assert "KOWALSKI" not in wc.opis_bibliograficzny_cache, (
+ "denorm-cache nieświeży — bramka WHEN triggera denorm nie zna "
+ "deleted_at (denorm_always_only)"
+ )
From cf0fca39475790343a87985871d7823302735184 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 17:30:00 +0200
Subject: [PATCH 24/75] fix(soft-delete): bramka WHEN triggera denorm zna
deleted_at (Task 3b)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BPP ma dwa niezależne systemy triggerów na tabelach *_Autor:
1. cache _mat (nasz) — bramka WHEN naprawiona w migracji 0489 (Task 3).
2. django-denorm — bramka WHEN budowana z list only= w
@depend_on_related. Bez deleted_at w tej liście UPDATE, który tylko
soft-kasuje wiersz *_Autor, nie odpalał triggera denorm — pola
denormalizowane rodzica (opis_bibliograficzny_cache,
opis_bibliograficzny_autorzy_cache,
opis_bibliograficzny_zapisani_autorzy_cache, slug,
cached_punkty_dyscyplin) zostawały nieświeże na stałe.
Weryfikacja pułapki z brief-a: `only = (only or ()) + denorm_always_only`
w połączeniu z `if only: ... else: only = ` w
denorm/db/base.py oznacza, że dodanie denorm_always_only do modelu,
którego zależność nie ma only=, zawęziłoby ją z "wszystkie kolumny" do
"tylko deleted_at" — cicha regresja. Sprawdzono ręcznie wszystkie 15
zależności @depend_on_related celujących w *_Autor (5 na każdy z 3
modeli, we wszystkich 3 plikach) — każda ma jawne only=. Dodanie
denorm_always_only jest więc czysto addytywne.
Dodano do Wydawnictwo_Ciagle_Autor, Wydawnictwo_Zwarte_Autor i
Patent_Autor:
denorm_always_only = ("deleted_at",)
Triggery denorm są instalowane automatycznie przez post_migrate
(denorm/apps.py -> denorms.install_triggers(), DROP TRIGGER IF EXISTS
+ CREATE TRIGGER, idempotentne) — żadna nowa migracja Django nie jest
potrzebna. Zweryfikowano na żywej bazie testowej: bramki
d_aft_row_upd_on_bpp_wydawnictwo_ciagle_autor_* zawierają teraz
"deleted_at IS DISTINCT FROM" w klauzuli WHEN.
Zazieleniło dwa ostatnie czerwone testy w test_cache/:
test_opis_bibliograficzny_dependent i test_wca_delete_cache. Cały
src/bpp/tests/test_cache/ oraz src/bpp/tests/test_soft_delete/ zielone
(113 passed, 1 skipped).
makemigrations --check --dry-run: brak zmian w bpp.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
src/bpp/models/patent.py | 6 ++++++
src/bpp/models/wydawnictwo_ciagle.py | 6 ++++++
src/bpp/models/wydawnictwo_zwarte.py | 6 ++++++
3 files changed, 18 insertions(+)
diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py
index 1d9013145..cd06ccd49 100644
--- a/src/bpp/models/patent.py
+++ b/src/bpp/models/patent.py
@@ -56,6 +56,12 @@ class Meta:
models.Index(fields=["deleted_at"], name="patent_autor_deleted_at_idx"),
]
+ # django-denorm buduje bramkę WHEN triggera z listy `only=` w
+ # @depend_on_related. Bez deleted_at soft-delete autorstwa nie
+ # unieważniłby denorm-cache rodzica (opis bibliograficzny, slug,
+ # cached_punkty_dyscyplin) — zostałby nieświeży na stałe.
+ denorm_always_only = ("deleted_at",)
+
class _Patent_PropertyCache:
@cached_property
diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py
index abd895bde..681d63f2b 100644
--- a/src/bpp/models/wydawnictwo_ciagle.py
+++ b/src/bpp/models/wydawnictwo_ciagle.py
@@ -81,6 +81,12 @@ class Meta:
models.Index(fields=["deleted_at"], name="wc_autor_deleted_at_idx"),
]
+ # django-denorm buduje bramkę WHEN triggera z listy `only=` w
+ # @depend_on_related. Bez deleted_at soft-delete autorstwa nie
+ # unieważniłby denorm-cache rodzica (opis bibliograficzny, slug,
+ # cached_punkty_dyscyplin) — zostałby nieświeży na stałe.
+ denorm_always_only = ("deleted_at",)
+
class ModelZOpenAccessWydawnictwoCiagle(ModelZOpenAccess):
openaccess_tryb_dostepu = models.ForeignKey(
diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py
index e636ec980..260bef535 100644
--- a/src/bpp/models/wydawnictwo_zwarte.py
+++ b/src/bpp/models/wydawnictwo_zwarte.py
@@ -90,6 +90,12 @@ class Meta:
models.Index(fields=["deleted_at"], name="wz_autor_deleted_at_idx"),
]
+ # django-denorm buduje bramkę WHEN triggera z listy `only=` w
+ # @depend_on_related. Bez deleted_at soft-delete autorstwa nie
+ # unieważniłby denorm-cache rodzica (opis bibliograficzny, slug,
+ # cached_punkty_dyscyplin) — zostałby nieświeży na stałe.
+ denorm_always_only = ("deleted_at",)
+
MIEJSCE_I_ROK_MAX_LENGTH = 256
From 2b274858acbf9df84952aff8f22eccacc21186fc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 18:47:57 +0200
Subject: [PATCH 25/75] =?UTF-8?q?test(soft-delete):=20padaj=C4=85ce=20test?=
=?UTF-8?q?y=20re-insertu=20autorstwa=20po=20soft-delete?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Task 3c: od Taska 2 modele *_Autor są soft-delete, wiec .delete() zostawia
wiersz fizycznie w bazie. unique_together w Meta wciąż go widzi, przez co
wzorzec "skasuj autorstwa i wstaw od nowa" (re-import, korekta kolejności,
edycja inline) wywala się na IntegrityError. Regresja jest realna i
zweryfikowana: src/import_sqlite/tests/test_patent_apply.py::
test_apply_idempotent_update pada z tym samym błędem.
Nowy plik src/bpp/tests/test_soft_delete/test_autor_unique.py odtwarza
regresję na Patent_Autor, Wydawnictwo_Ciagle_Autor i Wydawnictwo_Zwarte_Autor
oraz dodaje test kontrolny potwierdzający, że kolizja dwóch ŻYWYCH wierszy
nadal jest blokowana (nie chodzi o zniesienie unikalności, tylko o
uwzględnienie soft-delete).
Stan: RED — 4 testy failują (3 nowe + realny test_apply_idempotent_update),
naprawa w kolejnym commicie.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
.../test_soft_delete/test_autor_unique.py | 126 ++++++++++++++++++
1 file changed, 126 insertions(+)
create mode 100644 src/bpp/tests/test_soft_delete/test_autor_unique.py
diff --git a/src/bpp/tests/test_soft_delete/test_autor_unique.py b/src/bpp/tests/test_soft_delete/test_autor_unique.py
new file mode 100644
index 000000000..4ec0b8265
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_autor_unique.py
@@ -0,0 +1,126 @@
+"""Task 3c: warunkowy ``UniqueConstraint`` (``condition=deleted_at__isnull``)
+na ``*_Autor`` zamiast ``unique_together``.
+
+Regresja (Task 2->3): odkąd ``*_Autor`` jest soft-delete, ``.delete()``
+zostawia wiersz fizycznie w bazie. Klasyczny wzorzec "skasuj autorstwa i
+wstaw od nowa" (re-import, korekta kolejności, edycja inline w adminie)
+wywala się na ``unique_together``, bo widzi skasowany-miękko wiersz jako
+kolizję. Constraint musi więc ignorować wiersze z ``deleted_at`` ustawionym.
+"""
+
+import pytest
+
+from bpp.models import Typ_Odpowiedzialnosci
+from bpp.models.patent import Patent_Autor
+from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte_Autor
+
+
+@pytest.mark.django_db
+def test_reinsert_autorstwa_po_soft_delete_patent(
+ patent, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci
+):
+ """Wzorzec "skasuj autorstwa i wstaw od nowa" MUSI działać po soft-delete."""
+ Patent_Autor.objects.create(
+ rekord=patent,
+ autor=autor_jan_kowalski,
+ jednostka=jednostka,
+ typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."),
+ kolejnosc=0,
+ zapisany_jako="Kowalski Jan",
+ )
+ patent.autorzy_set.all().delete() # soft
+ Patent_Autor.objects.create( # ten sam (rekord, autor, kolejnosc)
+ rekord=patent,
+ autor=autor_jan_kowalski,
+ jednostka=jednostka,
+ typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."),
+ kolejnosc=0,
+ zapisany_jako="Kowalski Jan",
+ )
+ assert Patent_Autor.objects.filter(rekord=patent).count() == 1
+ assert Patent_Autor.global_objects.filter(rekord=patent).count() == 2
+
+
+@pytest.mark.django_db
+def test_reinsert_autorstwa_po_soft_delete_wydawnictwo_ciagle(
+ wydawnictwo_ciagle, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci
+):
+ Wydawnictwo_Ciagle_Autor.objects.create(
+ rekord=wydawnictwo_ciagle,
+ autor=autor_jan_kowalski,
+ jednostka=jednostka,
+ typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."),
+ kolejnosc=0,
+ zapisany_jako="Kowalski Jan",
+ )
+ wydawnictwo_ciagle.autorzy_set.all().delete() # soft
+ Wydawnictwo_Ciagle_Autor.objects.create( # ten sam (rekord, autor, kolejnosc)
+ rekord=wydawnictwo_ciagle,
+ autor=autor_jan_kowalski,
+ jednostka=jednostka,
+ typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."),
+ kolejnosc=0,
+ zapisany_jako="Kowalski Jan",
+ )
+ qs = Wydawnictwo_Ciagle_Autor.objects.filter(rekord=wydawnictwo_ciagle)
+ assert qs.count() == 1
+ global_manager = Wydawnictwo_Ciagle_Autor.global_objects
+ assert global_manager.filter(rekord=wydawnictwo_ciagle).count() == 2
+
+
+@pytest.mark.django_db
+def test_reinsert_autorstwa_po_soft_delete_wydawnictwo_zwarte(
+ wydawnictwo_zwarte, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci
+):
+ Wydawnictwo_Zwarte_Autor.objects.create(
+ rekord=wydawnictwo_zwarte,
+ autor=autor_jan_kowalski,
+ jednostka=jednostka,
+ typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."),
+ kolejnosc=0,
+ zapisany_jako="Kowalski Jan",
+ )
+ wydawnictwo_zwarte.autorzy_set.all().delete() # soft
+ Wydawnictwo_Zwarte_Autor.objects.create( # ten sam (rekord, autor, kolejnosc)
+ rekord=wydawnictwo_zwarte,
+ autor=autor_jan_kowalski,
+ jednostka=jednostka,
+ typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."),
+ kolejnosc=0,
+ zapisany_jako="Kowalski Jan",
+ )
+ qs = Wydawnictwo_Zwarte_Autor.objects.filter(rekord=wydawnictwo_zwarte)
+ assert qs.count() == 1
+ global_manager = Wydawnictwo_Zwarte_Autor.global_objects
+ assert global_manager.filter(rekord=wydawnictwo_zwarte).count() == 2
+
+
+@pytest.mark.django_db
+def test_wciaz_blokuje_prawdziwa_kolizje_niekasowanych_wierszy(
+ patent, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci
+):
+ """Constraint dalej musi łapać kolizję DWÓCH ŻYWYCH wierszy — to nie jest
+ zniesienie unikalności, tylko uwzględnienie soft-delete. Ten sam autor,
+ ten sam typ odpowiedzialności, dwa różne wiersze -> kolizja na
+ (rekord, autor, typ_odpowiedzialnosci)."""
+ from django.db import IntegrityError, transaction
+
+ Patent_Autor.objects.create(
+ rekord=patent,
+ autor=autor_jan_kowalski,
+ jednostka=jednostka,
+ typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."),
+ kolejnosc=0,
+ zapisany_jako="Kowalski Jan",
+ )
+ with pytest.raises(IntegrityError):
+ with transaction.atomic():
+ Patent_Autor.objects.create(
+ rekord=patent,
+ autor=autor_jan_kowalski,
+ jednostka=jednostka,
+ typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."),
+ kolejnosc=1, # inna kolejnosc, ale ten sam (rekord, autor, typ)
+ zapisany_jako="Kowalski Jan",
+ )
From 77c7d417ecdc42fb325733f4fae9ce83feace8a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 18:48:40 +0200
Subject: [PATCH 26/75] fix(soft-delete): warunkowy
UniqueConstraint/ExclusionConstraint na *_Autor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Task 3c. Zamienia unique_together (widzi soft-deleted wiersze) na warunkowe
constrainty ograniczone do żywych wierszy (condition=Q(deleted_at__isnull))
w trzech modelach Wydawnictwo_Ciagle_Autor, Wydawnictwo_Zwarte_Autor,
Patent_Autor. Migracja 0490.
Po drodze wykryto DRUGĄ, niezależną przyczynę tej samej regresji: legacy
migracja 0132 dokłada surowym SQL-em dodatkowy UNIQUE (rekord_id, kolejnosc)
DEFERRABLE INITIALLY DEFERRED (poza Django ORM, więc niewidoczny dla
makemigrations) - też blokuje re-insert po soft-delete, tylko odroczony do
commitu/zwolnienia savepointu. UniqueConstraint nie może łączyć condition z
deferrable (Django to blokuje), a deferrable jest tu wymagany przez
drag&drop reorder autorów w adminie (adminsortable2). Zastąpiony
ExclusionConstraint (GiST + btree_gist, rozszerzenie już włączone w bazie od
migracji 0056) - jedyny typ ograniczenia w Postgresie łączący WHERE z
DEFERRABLE. Migracja 0490 usuwa legacy constraint i dodaje odpowiednik przez
ORM.
Weryfikacja admina (obowiązkowa wg brief-u): Model.validate_unique() w
ogóle nie sprawdza Meta.constraints - to osobny krok Django >=4.1,
validate_constraints(), wołany automatycznie przez
ModelForm._post_clean(). Ten krok DZIAŁA dla warunkowych constraintów, ale
tylko gdy pole użyte w condition (deleted_at) nie jest wykluczone z
walidacji - a pole spoza Meta.fields formularza Django automatycznie
wyklucza. Skutek bez naprawy: UniqueConstraint cicho pomija walidację
(łapie FieldError, kolizja przechodzi formularz), ExclusionConstraint w
ogóle nie łapie FieldError (is_valid() wywala się wyjątkiem, HTTP 500,
przy KAŻDYM zapisie - nie tylko przy kolizji). Naprawa: deleted_at jest
teraz jawnym, ukrytym/wyłączonym polem w generuj_formularz_dla_autorow
(zawsze None - formularz operuje tylko na żywych wierszach).
Druga, niezależna luka: formset inline (generuj_inline_dla_autorow) nie
łapał kolizji NOWEGO wiersza z ISTNIEJĄCYM widocznym w tym samym
formsecie - dla nowego wiersza rekord nie jest jeszcze ustawiony w
momencie walidacji (przypisuje go dopiero save_new(), po walidacji), więc
DB-owy UniqueConstraint.validate() pomija sprawdzenie (NULL != NULL), a
formsetowe validate_unique() Django sprawdza pary formularzy tylko dla
constraintów bezwarunkowych. Naprawa: ręczne porównanie par formularzy w
formset.clean() (_waliduj_kolizje_autorstwa_w_formsecie), bez zapytań do
bazy.
Testy:
- src/bpp/tests/test_admin/test_autor_inline_unique.py - standalone
Wydawnictwo_Ciagle_Autor_Admin i inline formset, obie pułapki wyżej.
- src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py -
odwracalność migracji (forward/backward/forward), w tym legacy
constraintu.
Wynik: src/import_sqlite/tests/test_patent_apply.py::
test_apply_idempotent_update - zielony. test_soft_delete/, test_cache/,
test_admin/ - bez regresji (1300+ passed). makemigrations --check
--dry-run (bpp) - brak zmian.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
src/bpp/admin/core.py | 76 +++++
.../migrations/0490_autor_warunkowy_unique.py | 146 ++++++++++
src/bpp/models/patent.py | 40 ++-
src/bpp/models/wydawnictwo_ciagle.py | 51 +++-
src/bpp/models/wydawnictwo_zwarte.py | 40 ++-
.../test_admin/test_autor_inline_unique.py | 264 ++++++++++++++++++
.../test_migracja_0490_rewers.py | 86 ++++++
7 files changed, 685 insertions(+), 18 deletions(-)
create mode 100644 src/bpp/migrations/0490_autor_warunkowy_unique.py
create mode 100644 src/bpp/tests/test_admin/test_autor_inline_unique.py
create mode 100644 src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py
diff --git a/src/bpp/admin/core.py b/src/bpp/admin/core.py
index 78330aaa2..3a2ef1eeb 100644
--- a/src/bpp/admin/core.py
+++ b/src/bpp/admin/core.py
@@ -152,6 +152,25 @@ class baseModel_AutorForm(forms.ModelForm):
widget=HiddenInput, queryset=baseModel.rekord.get_queryset()
)
+ # `deleted_at` MUSI być polem formularza (choć ukrytym/wyłączonym) —
+ # inaczej Django wyklucza je z walidacji modelu (nie ma go w
+ # Meta.fields => `_get_validation_exclusions()` dorzuca je do
+ # `exclude`), a `Meta.constraints` (warunkowe UniqueConstraint /
+ # ExclusionConstraint z Taska 3c, `condition=Q(deleted_at__isnull=…)`)
+ # odwołują się właśnie do tego pola. Skutek bez tego pola: dla
+ # UniqueConstraint Django CICHO POMIJA walidację (łapie FieldError i
+ # nie zgłasza błędu — kolizja przechodzi, IntegrityError wyskakuje
+ # dopiero przy zapisie), a dla ExclusionConstraint Django W OGÓLE NIE
+ # ŁAPIE tego FieldError — `is_valid()` wywala się niekontrolowanym
+ # wyjątkiem (HTTP 500) przy KAŻDYM zapisie, nie tylko przy kolizji.
+ # Formularz zawsze dotyczy ŻYWEGO wiersza (managery `objects`/
+ # `autorzy_set` już odfiltrowują soft-deleted), więc `disabled=True`
+ # + `initial=None` jest zawsze poprawną wartością — pole nigdy nie
+ # jest edytowalne przez usera.
+ deleted_at = forms.DateTimeField(
+ required=False, disabled=True, widget=HiddenInput
+ )
+
autor = forms.ModelChoiceField(
queryset=Autor.objects.all(),
widget=autocomplete.ModelSelect2(url="bpp:autor-autocomplete"),
@@ -317,6 +336,7 @@ class Meta:
"profil_orcid",
DATA_OSWIADCZENIA,
"kolejnosc",
+ "deleted_at",
]
if include_dyscyplina:
@@ -341,6 +361,60 @@ class Meta:
return baseModel_AutorForm
+def _waliduj_kolizje_autorstwa_w_formsecie(formset):
+ """Task 3c: warunkowy `UniqueConstraint`/`ExclusionConstraint`
+ (`condition=Q(deleted_at__isnull=True)`) na *_Autor NIE łapie kolizji
+ NOWEGO wiersza formsetu z ISTNIEJĄCYM (widocznym w tym samym formsecie)
+ wierszem. Powód: dla nowego wiersza `rekord` nie jest jeszcze ustawiony
+ w momencie walidacji (przypisuje go dopiero
+ `BaseInlineFormSet.save_new()`, PO walidacji) — DB-owy
+ `UniqueConstraint.validate()` po prostu pomija sprawdzenie, gdy pole
+ złożonego klucza jest `None` (`NULL != NULL` w SQL). A formsetowe
+ `validate_unique()` (Django) sprawdza pary formularzy tylko dla
+ constraintów BEZWARUNKOWYCH (`Meta.total_unique_constraints`,
+ `condition is None`) — nasze, warunkowe, są tam pomijane. Stąd ręczne
+ porównanie par formularzy widocznych w formsecie (bez zapytań do bazy —
+ `formset.instance`, czyli rodzic/rekord, jest wspólny dla wszystkich
+ wierszy formsetu z definicji inline)."""
+ forms_to_delete = formset.deleted_forms
+ aktywne = [
+ f
+ for f in formset.forms
+ if getattr(f, "cleaned_data", None) and f not in forms_to_delete
+ ]
+
+ widziane_typ = {}
+ widziane_kolejnosc = {}
+ for form in aktywne:
+ cd = form.cleaned_data
+ autor = cd.get("autor")
+ if autor is None:
+ continue
+
+ typ = cd.get("typ_odpowiedzialnosci")
+ if typ is not None:
+ klucz = (autor.pk, typ.pk)
+ if klucz in widziane_typ:
+ form.add_error(
+ None,
+ "To powiązanie autora z tym typem odpowiedzialności "
+ "już istnieje w tym rekordzie.",
+ )
+ else:
+ widziane_typ[klucz] = form
+
+ kolejnosc = cd.get("kolejnosc")
+ if kolejnosc is not None:
+ klucz = (autor.pk, kolejnosc)
+ if klucz in widziane_kolejnosc:
+ form.add_error(
+ None,
+ "Ten autor ma już powiązanie z tą kolejnością.",
+ )
+ else:
+ widziane_kolejnosc[klucz] = form
+
+
def generuj_inline_dla_autorow(baseModel, include_dyscyplina=True):
MAKSYMALNA_ILOSC_AUTOROW_W_FORMULARZU = 25
@@ -367,6 +441,8 @@ def clean(self):
"Liczba podanych procent odpowiedzialności przekracza 100.0"
)
+ _waliduj_kolizje_autorstwa_w_formsecie(self)
+
baseClass = admin.StackedInline
extraRows = 0
diff --git a/src/bpp/migrations/0490_autor_warunkowy_unique.py b/src/bpp/migrations/0490_autor_warunkowy_unique.py
new file mode 100644
index 000000000..b2c5ecd90
--- /dev/null
+++ b/src/bpp/migrations/0490_autor_warunkowy_unique.py
@@ -0,0 +1,146 @@
+# Generated by Django 5.2.16 on 2026-08-06 16:00
+
+import django.contrib.postgres.constraints
+import django.db.models.constraints
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("bpp", "0489_soft_delete_autorzy_views"),
+ ]
+
+ operations = [
+ migrations.AlterUniqueTogether(
+ name="patent_autor",
+ unique_together=set(),
+ ),
+ migrations.AlterUniqueTogether(
+ name="wydawnictwo_ciagle_autor",
+ unique_together=set(),
+ ),
+ migrations.AlterUniqueTogether(
+ name="wydawnictwo_zwarte_autor",
+ unique_together=set(),
+ ),
+ migrations.AddConstraint(
+ model_name="patent_autor",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("rekord", "autor", "typ_odpowiedzialnosci"),
+ name="pat_autor_uniq_rekord_autor_typ",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="patent_autor",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("rekord", "autor", "kolejnosc"),
+ name="pat_autor_uniq_rekord_autor_kolejnosc",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="patent_autor",
+ constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
+ expressions=[("rekord", "="), ("kolejnosc", "=")],
+ name="pat_autor_excl_rekord_kolejnosc",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="wydawnictwo_ciagle_autor",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("rekord", "autor", "typ_odpowiedzialnosci"),
+ name="wc_autor_uniq_rekord_autor_typ",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="wydawnictwo_ciagle_autor",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("rekord", "autor", "kolejnosc"),
+ name="wc_autor_uniq_rekord_autor_kolejnosc",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="wydawnictwo_ciagle_autor",
+ constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
+ expressions=[("rekord", "="), ("kolejnosc", "=")],
+ name="wc_autor_excl_rekord_kolejnosc",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="wydawnictwo_zwarte_autor",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("rekord", "autor", "typ_odpowiedzialnosci"),
+ name="wz_autor_uniq_rekord_autor_typ",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="wydawnictwo_zwarte_autor",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("rekord", "autor", "kolejnosc"),
+ name="wz_autor_uniq_rekord_autor_kolejnosc",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="wydawnictwo_zwarte_autor",
+ constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
+ expressions=[("rekord", "="), ("kolejnosc", "=")],
+ name="wz_autor_excl_rekord_kolejnosc",
+ ),
+ ),
+ # Drugi kawałek Taska 3c: legacy migracja 0132 dołożyła surowym SQL-em
+ # DODATKOWY, poza Django ORM, `UNIQUE (rekord_id, kolejnosc)
+ # DEFERRABLE INITIALLY DEFERRED` na wszystkich 3 tabelach — Django
+ # nigdy o nim nie wiedział (nie ma go w Meta), więc `makemigrations`
+ # go nie widzi i nie usunie. Ten constraint SAM blokował dokładnie tę
+ # samą regresję (re-insert po soft-delete), tylko odroczony do
+ # `COMMIT`/zwolnienia savepointu, więc test padał dopiero w
+ # teardown/`SET CONSTRAINTS ALL IMMEDIATE`, nie na samym INSERT.
+ # Zastępujemy go `ExclusionConstraint`-em dodanym wyżej (ten sam,
+ # który właśnie stworzyliśmy przez ORM) — jedynym typem ograniczenia
+ # w Postgresie łączącym `WHERE` (warunek soft-delete) z
+ # `DEFERRABLE` (wymagane przez drag&drop reorder adminsortable2).
+ migrations.RunSQL(
+ sql=(
+ "ALTER TABLE bpp_patent_autor "
+ "DROP CONSTRAINT bpp_patent_autor_unique_rekord_id_kolejnosc;"
+ ),
+ reverse_sql=(
+ "ALTER TABLE bpp_patent_autor "
+ "ADD CONSTRAINT bpp_patent_autor_unique_rekord_id_kolejnosc "
+ "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;"
+ ),
+ ),
+ migrations.RunSQL(
+ sql=(
+ "ALTER TABLE bpp_wydawnictwo_ciagle_autor DROP CONSTRAINT "
+ "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc;"
+ ),
+ reverse_sql=(
+ "ALTER TABLE bpp_wydawnictwo_ciagle_autor ADD CONSTRAINT "
+ "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc "
+ "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;"
+ ),
+ ),
+ migrations.RunSQL(
+ sql=(
+ "ALTER TABLE bpp_wydawnictwo_zwarte_autor DROP CONSTRAINT "
+ "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc;"
+ ),
+ reverse_sql=(
+ "ALTER TABLE bpp_wydawnictwo_zwarte_autor ADD CONSTRAINT "
+ "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc "
+ "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;"
+ ),
+ ),
+ ]
diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py
index cd06ccd49..5a7722ce3 100644
--- a/src/bpp/models/patent.py
+++ b/src/bpp/models/patent.py
@@ -1,7 +1,8 @@
from denorm import denormalized, depend_on_fields, depend_on_related
-from django.contrib.postgres.fields import ArrayField
+from django.contrib.postgres.constraints import ExclusionConstraint
+from django.contrib.postgres.fields import ArrayField, RangeOperators
from django.db import models
-from django.db.models import CASCADE, SET_NULL, JSONField
+from django.db.models import CASCADE, SET_NULL, Deferrable, JSONField, Q
from django.utils.functional import cached_property
from bpp.models import (
@@ -47,10 +48,37 @@ class Meta:
verbose_name_plural = "powiązania autorów z patentami"
app_label = "bpp"
ordering = ("kolejnosc",)
- unique_together = [
- ("rekord", "autor", "typ_odpowiedzialnosci"),
- # Tu musi być autor, inaczej admin nie pozwoli wyedytować
- ("rekord", "autor", "kolejnosc"),
+ # `unique_together` widziałby też wiersze soft-deleted (fizycznie
+ # wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa"
+ # (re-import, korekta kolejności, edycja inline). Warunkowy
+ # UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności
+ # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django
+ # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest
+ # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow`
+ # / `generuj_inline_dla_autorow`) — patrz Task 3c.
+ constraints = [
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "typ_odpowiedzialnosci"],
+ condition=Q(deleted_at__isnull=True),
+ name="pat_autor_uniq_rekord_autor_typ",
+ ),
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "kolejnosc"],
+ condition=Q(deleted_at__isnull=True),
+ name="pat_autor_uniq_rekord_autor_kolejnosc",
+ ),
+ # Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id,
+ # kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 —
+ # patrz analogiczny komentarz w Wydawnictwo_Ciagle_Autor.Meta.
+ ExclusionConstraint(
+ name="pat_autor_excl_rekord_kolejnosc",
+ expressions=[
+ ("rekord", RangeOperators.EQUAL),
+ ("kolejnosc", RangeOperators.EQUAL),
+ ],
+ condition=Q(deleted_at__isnull=True),
+ deferrable=Deferrable.DEFERRED,
+ ),
]
indexes = [
models.Index(fields=["deleted_at"], name="patent_autor_deleted_at_idx"),
diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py
index 681d63f2b..54ea4312a 100644
--- a/src/bpp/models/wydawnictwo_ciagle.py
+++ b/src/bpp/models/wydawnictwo_ciagle.py
@@ -1,8 +1,9 @@
from denorm import denormalized, depend_on_fields, depend_on_related
from dirtyfields.dirtyfields import DirtyFieldsMixin
-from django.contrib.postgres.fields import ArrayField
+from django.contrib.postgres.constraints import ExclusionConstraint
+from django.contrib.postgres.fields import ArrayField, RangeOperators
from django.db import models
-from django.db.models import CASCADE, SET_NULL, JSONField
+from django.db.models import CASCADE, SET_NULL, Deferrable, JSONField, Q
from bpp.models import (
BazaModeluStreszczen,
@@ -72,10 +73,48 @@ class Meta:
verbose_name_plural = "powiązania autorów z wyd. ciągłymi"
app_label = "bpp"
ordering = ("kolejnosc",)
- unique_together = [
- ("rekord", "autor", "typ_odpowiedzialnosci"),
- # Tu musi być autor, inaczej admin nie pozwoli wyedytować
- ("rekord", "autor", "kolejnosc"),
+ # `unique_together` widziałby też wiersze soft-deleted (fizycznie
+ # wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa"
+ # (re-import, korekta kolejności, edycja inline). Warunkowy
+ # UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności
+ # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django
+ # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest
+ # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow`
+ # / `generuj_inline_dla_autorow`) — patrz Task 3c.
+ constraints = [
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "typ_odpowiedzialnosci"],
+ condition=Q(deleted_at__isnull=True),
+ name="wc_autor_uniq_rekord_autor_typ",
+ ),
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "kolejnosc"],
+ condition=Q(deleted_at__isnull=True),
+ name="wc_autor_uniq_rekord_autor_kolejnosc",
+ ),
+ # Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id,
+ # kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 —
+ # gwarantuje, że DWÓCH RÓŻNYCH autorów nie dzieli tej samej
+ # pozycji w obrębie rekordu. `UniqueConstraint` nie umie
+ # łączyć `condition` z `deferrable` (Django to blokuje —
+ # `condition` i `deferrable` się wykluczają), a `deferrable`
+ # jest tu wymagane przez drag&drop reorder w adminie
+ # (adminsortable2, patrz `sortable_field_name = "kolejnosc"`)
+ # — zamiana kolejności dwóch wierszy przejściowo dubluje
+ # wartość `kolejnosc` w obrębie jednej transakcji, co bez
+ # DEFERRED wywaliłoby się na pierwszym UPDATE. Stąd
+ # `ExclusionConstraint` (GiST + btree_gist, rozszerzenie już
+ # włączone w bazie) — jedyny typ ograniczenia w Postgresie,
+ # który łączy `WHERE` (warunek) z `DEFERRABLE`.
+ ExclusionConstraint(
+ name="wc_autor_excl_rekord_kolejnosc",
+ expressions=[
+ ("rekord", RangeOperators.EQUAL),
+ ("kolejnosc", RangeOperators.EQUAL),
+ ],
+ condition=Q(deleted_at__isnull=True),
+ deferrable=Deferrable.DEFERRED,
+ ),
]
indexes = [
models.Index(fields=["deleted_at"], name="wc_autor_deleted_at_idx"),
diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py
index 260bef535..40228b750 100644
--- a/src/bpp/models/wydawnictwo_zwarte.py
+++ b/src/bpp/models/wydawnictwo_zwarte.py
@@ -4,10 +4,11 @@
from denorm import denormalized, depend_on_fields, depend_on_related
from dirtyfields.dirtyfields import DirtyFieldsMixin
from django.contrib.contenttypes.fields import GenericRelation
-from django.contrib.postgres.fields import ArrayField
+from django.contrib.postgres.constraints import ExclusionConstraint
+from django.contrib.postgres.fields import ArrayField, RangeOperators
from django.core.exceptions import ValidationError
from django.db import models
-from django.db.models import CASCADE, PROTECT, JSONField
+from django.db.models import CASCADE, PROTECT, Deferrable, JSONField, Q
from django.db.models.expressions import RawSQL
from bpp import const
@@ -81,10 +82,37 @@ class Meta:
verbose_name_plural = "powiązania autorów z wyd. zwartymi"
app_label = "bpp"
ordering = ("kolejnosc",)
- unique_together = [
- ("rekord", "autor", "typ_odpowiedzialnosci"),
- # Tu musi być autor, inaczej admin nie pozwoli wyedytować
- ("rekord", "autor", "kolejnosc"),
+ # `unique_together` widziałby też wiersze soft-deleted (fizycznie
+ # wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa"
+ # (re-import, korekta kolejności, edycja inline). Warunkowy
+ # UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności
+ # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django
+ # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest
+ # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow`
+ # / `generuj_inline_dla_autorow`) — patrz Task 3c.
+ constraints = [
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "typ_odpowiedzialnosci"],
+ condition=Q(deleted_at__isnull=True),
+ name="wz_autor_uniq_rekord_autor_typ",
+ ),
+ models.UniqueConstraint(
+ fields=["rekord", "autor", "kolejnosc"],
+ condition=Q(deleted_at__isnull=True),
+ name="wz_autor_uniq_rekord_autor_kolejnosc",
+ ),
+ # Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id,
+ # kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 —
+ # patrz analogiczny komentarz w Wydawnictwo_Ciagle_Autor.Meta.
+ ExclusionConstraint(
+ name="wz_autor_excl_rekord_kolejnosc",
+ expressions=[
+ ("rekord", RangeOperators.EQUAL),
+ ("kolejnosc", RangeOperators.EQUAL),
+ ],
+ condition=Q(deleted_at__isnull=True),
+ deferrable=Deferrable.DEFERRED,
+ ),
]
indexes = [
models.Index(fields=["deleted_at"], name="wz_autor_deleted_at_idx"),
diff --git a/src/bpp/tests/test_admin/test_autor_inline_unique.py b/src/bpp/tests/test_admin/test_autor_inline_unique.py
new file mode 100644
index 000000000..f71df042d
--- /dev/null
+++ b/src/bpp/tests/test_admin/test_autor_inline_unique.py
@@ -0,0 +1,264 @@
+"""Task 3c — czy zamiana `unique_together` na warunkowy `UniqueConstraint`/
+`ExclusionConstraint` (``condition=deleted_at__isnull``) NIE zregresowała
+walidacji formularza w adminie.
+
+Dwie osobne pułapki, obie związane z tym, że ``deleted_at`` NIE jest polem
+formularza (``generuj_formularz_dla_autorow`` go nie miało):
+
+1. ``Model.validate_unique()`` w ogóle NIE sprawdza ``Meta.constraints``
+ (ani warunkowych, ani bezwarunkowych) — to osobny krok,
+ ``Model.validate_constraints()`` (Django >=4.1), wołany automatycznie
+ przez ``ModelForm._post_clean()`` -> ``instance.full_clean(...)``.
+2. ``validate_constraints()`` DZIAŁA dla warunkowych constraintów, ALE tylko
+ jeśli pole użyte w ``condition`` (tu: ``deleted_at``) nie jest wykluczone
+ z walidacji. Pole spoza ``Meta.fields`` formularza Django automatycznie
+ wrzuca do ``exclude`` (``_get_validation_exclusions()``). Skutek:
+ - dla ``UniqueConstraint`` — Django CICHO POMIJA walidację (łapie
+ ``FieldError`` i nic nie zgłasza — kolizja przechodzi formularz,
+ ``IntegrityError`` wyskakuje dopiero przy zapisie do bazy),
+ - dla ``ExclusionConstraint`` — Django W OGÓLE NIE ŁAPIE tego
+ ``FieldError`` — ``is_valid()`` wywala się niekontrolowanym wyjątkiem
+ (HTTP 500) przy KAŻDYM zapisie, nawet bez żadnej kolizji.
+
+Naprawa: ``deleted_at`` jest teraz jawnym, ukrytym/wyłączonym polem
+formularza (zawsze ``None`` — formularz operuje tylko na żywych wierszach),
+więc oba constrainty widzą je poprawnie i walidują się jak należy, bez
+ręcznego ``clean()``.
+"""
+
+import pytest
+from django.contrib.admin.sites import AdminSite
+
+from bpp.admin.core import generuj_formularz_dla_autorow, generuj_inline_dla_autorow
+from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor
+
+PREFIX = "autorzy_set"
+
+
+def _form_class():
+ return generuj_formularz_dla_autorow(Wydawnictwo_Ciagle_Autor, include_rekord=True)
+
+
+def _inline_formset(rf, admin_user, wc, data):
+ inline_cls = generuj_inline_dla_autorow(Wydawnictwo_Ciagle_Autor)
+ inline = inline_cls(Wydawnictwo_Ciagle, AdminSite())
+ request = rf.post("/")
+ request.user = admin_user
+ formset_cls = inline.get_formset(request, wc)
+ return formset_cls(data, instance=wc, prefix=PREFIX)
+
+
+def _wca_row(idx, wca):
+ return {
+ f"{PREFIX}-{idx}-id": str(wca.pk),
+ f"{PREFIX}-{idx}-autor": str(wca.autor_id),
+ f"{PREFIX}-{idx}-jednostka": str(wca.jednostka_id),
+ f"{PREFIX}-{idx}-typ_odpowiedzialnosci": str(wca.typ_odpowiedzialnosci_id),
+ f"{PREFIX}-{idx}-zapisany_jako": wca.zapisany_jako,
+ f"{PREFIX}-{idx}-kolejnosc": str(wca.kolejnosc),
+ }
+
+
+def _nowy_wiersz(
+ idx, *, autor, jednostka, typ_odpowiedzialnosci, zapisany_jako, kolejnosc
+):
+ return {
+ f"{PREFIX}-{idx}-id": "",
+ f"{PREFIX}-{idx}-autor": str(autor.pk),
+ f"{PREFIX}-{idx}-jednostka": str(jednostka.pk),
+ f"{PREFIX}-{idx}-typ_odpowiedzialnosci": str(typ_odpowiedzialnosci.pk),
+ f"{PREFIX}-{idx}-zapisany_jako": zapisany_jako,
+ f"{PREFIX}-{idx}-kolejnosc": str(kolejnosc),
+ }
+
+
+def _management(total, initial):
+ return {
+ f"{PREFIX}-TOTAL_FORMS": str(total),
+ f"{PREFIX}-INITIAL_FORMS": str(initial),
+ f"{PREFIX}-MIN_NUM_FORMS": "0",
+ f"{PREFIX}-MAX_NUM_FORMS": "1000",
+ }
+
+
+def _data(wc, wca, **overrides):
+ data = {
+ "rekord": str(wc.pk),
+ "autor": str(wca.autor_id),
+ "jednostka": str(wca.jednostka_id),
+ "typ_odpowiedzialnosci": str(wca.typ_odpowiedzialnosci_id),
+ "zapisany_jako": wca.zapisany_jako,
+ "kolejnosc": "1",
+ }
+ data.update(overrides)
+ return data
+
+
+@pytest.mark.django_db
+def test_standalone_admin_form_lapie_kolizje_rekord_autor_typ(
+ wydawnictwo_ciagle_z_autorem,
+):
+ """Nowe powiązanie z tym samym (rekord, autor, typ_odpowiedzialnosci), co
+ już istniejący ŻYWY wiersz, MUSI dać błąd formularza — nie
+ IntegrityError przy zapisie."""
+ wc = wydawnictwo_ciagle_z_autorem
+ existing = wc.autorzy_set.get()
+
+ # Pozycyjnie, jak robi to prawdziwy Django admin (`ModelForm(request.POST,
+ # request.FILES, instance=obj)`) — jako kwarg `data=` włącza w kodzie
+ # formularza gałąź oczekującą `kwargs["prefix"]` (dotyczy TYLKO formsetów
+ # inline, nie samodzielnego admina).
+ form = _form_class()(_data(wc, existing, kolejnosc="1"), None)
+
+ assert not form.is_valid(), "kolizja (rekord, autor, typ) powinna być błędem"
+
+
+@pytest.mark.django_db
+def test_standalone_admin_form_lapie_kolizje_rekord_autor_kolejnosc(
+ wydawnictwo_ciagle_z_autorem, autor_jan_nowak
+):
+ """Nowe powiązanie INNEGO typu odpowiedzialności, ale z tą samą
+ (rekord, autor, kolejnosc) — też musi być błędem formularza."""
+ wc = wydawnictwo_ciagle_z_autorem
+ existing = wc.autorzy_set.get()
+
+ from bpp.models import Typ_Odpowiedzialnosci
+
+ inny_typ = Typ_Odpowiedzialnosci.objects.exclude(
+ pk=existing.typ_odpowiedzialnosci_id
+ ).first()
+ assert inny_typ is not None
+
+ form = _form_class()(
+ _data(
+ wc,
+ existing,
+ kolejnosc=str(existing.kolejnosc),
+ typ_odpowiedzialnosci=str(inny_typ.pk),
+ ),
+ None,
+ )
+
+ assert not form.is_valid(), "kolizja (rekord, autor, kolejnosc) to błąd"
+
+
+@pytest.mark.django_db
+def test_standalone_admin_form_po_soft_delete_nie_koliduje(
+ wydawnictwo_ciagle_z_autorem,
+):
+ """Po soft-delete starego powiązania to samo (rekord, autor, typ) musi
+ dać się zapisać ponownie — formularz nie może uznać skasowanego wiersza
+ za kolizję (wzorzec "usuń i wstaw od nowa" w adminie)."""
+ wc = wydawnictwo_ciagle_z_autorem
+ existing = wc.autorzy_set.get()
+ dane = _data(wc, existing, kolejnosc="1")
+ existing.delete() # soft
+
+ form = _form_class()(dane, None)
+
+ assert form.is_valid(), form.errors
+
+
+@pytest.mark.django_db
+def test_standalone_admin_form_edycja_wlasnego_wiersza_nie_koliduje_sama_ze_soba(
+ wydawnictwo_ciagle_z_autorem,
+):
+ """Edycja ISTNIEJĄCEGO wiersza (te same wartości) nie może wykryć
+ kolizji z samym sobą."""
+ wc = wydawnictwo_ciagle_z_autorem
+ existing = wc.autorzy_set.get()
+
+ form = _form_class()(
+ _data(wc, existing, kolejnosc=str(existing.kolejnosc)),
+ None,
+ instance=existing,
+ )
+
+ assert form.is_valid(), form.errors
+
+
+# --- Formset inline (`generuj_inline_dla_autorow`), realny widok Wydawnictwo_Ciagle ---
+
+
+@pytest.mark.django_db
+def test_inline_nowy_wiersz_koliduje_z_istniejacym_typ(
+ wydawnictwo_ciagle_z_autorem, rf, admin_user
+):
+ """Dodanie NOWEGO wiersza kolidującego (rekord, autor, typ) z ISTNIEJĄCYM
+ (widocznym w tym samym formsecie) wierszem musi dać błąd formsetu."""
+ wc = wydawnictwo_ciagle_z_autorem
+ existing = wc.autorzy_set.get()
+
+ data = _management(total=2, initial=1)
+ data.update(_wca_row(0, existing))
+ data.update(
+ _nowy_wiersz(
+ 1,
+ autor=existing.autor,
+ jednostka=existing.jednostka,
+ typ_odpowiedzialnosci=existing.typ_odpowiedzialnosci,
+ zapisany_jako=existing.zapisany_jako,
+ kolejnosc=existing.kolejnosc + 1,
+ )
+ )
+
+ formset = _inline_formset(rf, admin_user, wc, data)
+
+ assert not formset.is_valid(), formset.errors
+
+
+@pytest.mark.django_db
+def test_inline_nowy_wiersz_koliduje_z_istniejacym_kolejnosc(
+ wydawnictwo_ciagle_z_autorem, rf, admin_user, typy_odpowiedzialnosci
+):
+ """Dodanie NOWEGO wiersza z tą samą (rekord, autor, kolejnosc), co
+ ISTNIEJĄCY wiersz (inny typ odpowiedzialności) — też błąd formsetu."""
+ wc = wydawnictwo_ciagle_z_autorem
+ existing = wc.autorzy_set.get()
+ inny_typ = typy_odpowiedzialnosci["red."]
+
+ data = _management(total=2, initial=1)
+ data.update(_wca_row(0, existing))
+ data.update(
+ _nowy_wiersz(
+ 1,
+ autor=existing.autor,
+ jednostka=existing.jednostka,
+ typ_odpowiedzialnosci=inny_typ,
+ zapisany_jako=existing.zapisany_jako,
+ kolejnosc=existing.kolejnosc,
+ )
+ )
+
+ formset = _inline_formset(rf, admin_user, wc, data)
+
+ assert not formset.is_valid(), formset.errors
+
+
+@pytest.mark.django_db
+def test_inline_po_soft_delete_reinsert_przechodzi(
+ wydawnictwo_ciagle_z_autorem, rf, admin_user
+):
+ """Formset MUSI przepuścić wzorzec "usuń stary wiersz (miękko) + dodaj
+ nowy o tych samych wartościach" — dokładnie regresja z Taska 3c."""
+ wc = wydawnictwo_ciagle_z_autorem
+ existing = wc.autorzy_set.get()
+
+ data = _management(total=2, initial=1)
+ row0 = _wca_row(0, existing)
+ row0[f"{PREFIX}-0-DELETE"] = "on"
+ data.update(row0)
+ data.update(
+ _nowy_wiersz(
+ 1,
+ autor=existing.autor,
+ jednostka=existing.jednostka,
+ typ_odpowiedzialnosci=existing.typ_odpowiedzialnosci,
+ zapisany_jako=existing.zapisany_jako,
+ kolejnosc=existing.kolejnosc,
+ )
+ )
+
+ formset = _inline_formset(rf, admin_user, wc, data)
+
+ assert formset.is_valid(), formset.errors
diff --git a/src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py b/src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py
new file mode 100644
index 000000000..b62b247e7
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py
@@ -0,0 +1,86 @@
+"""Odwracalność migracji 0490 (warunkowy UniqueConstraint/ExclusionConstraint
+na *_Autor + zastąpienie legacy `RunSQL` constraintu z migracji 0132).
+
+``backward`` musi przywrócić DOKŁADNIE stan sprzed migracji: bezwarunkowy
+`unique_together` ORAZ legacy `UNIQUE (rekord_id, kolejnosc) DEFERRABLE
+INITIALLY DEFERRED` — inaczej rollback zostawia bazę bez żadnego z dwóch
+mechanizmów pilnujących unikalności kolejności autorów.
+"""
+
+import pytest
+from django.core.management import call_command
+from django.db import connection
+
+TABELE = [
+ "bpp_patent_autor",
+ "bpp_wydawnictwo_ciagle_autor",
+ "bpp_wydawnictwo_zwarte_autor",
+]
+
+LEGACY_CONSTRAINT = {
+ "bpp_patent_autor": "bpp_patent_autor_unique_rekord_id_kolejnosc",
+ "bpp_wydawnictwo_ciagle_autor": (
+ "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc"
+ ),
+ "bpp_wydawnictwo_zwarte_autor": (
+ "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc"
+ ),
+}
+
+PREFIX = {
+ "bpp_patent_autor": "pat",
+ "bpp_wydawnictwo_ciagle_autor": "wc",
+ "bpp_wydawnictwo_zwarte_autor": "wz",
+}
+
+
+def _constraint_names(cur, tabela):
+ """Nazwy ograniczeń (`pg_constraint`) i indeksów (`pg_indexes`) na
+ tabeli. Warunkowy `UniqueConstraint` (`condition=...`) w Postgresie NIE
+ jest zapisany w `pg_constraint` — Postgres nie ma składni `ADD
+ CONSTRAINT ... UNIQUE ... WHERE`, więc Django realizuje go jako zwykły
+ `CREATE UNIQUE INDEX ... WHERE ...` (widoczny tylko w `pg_indexes`).
+ `ExclusionConstraint` i zwykłe `UNIQUE` SĄ pełnoprawnymi constraintami
+ (`pg_constraint`, `contype='x'`/`'u'`). Stąd suma obu źródeł."""
+ cur.execute(
+ "SELECT conname FROM pg_constraint WHERE conrelid = %s::regclass",
+ [tabela],
+ )
+ nazwy = {row[0] for row in cur.fetchall()}
+ cur.execute("SELECT indexname FROM pg_indexes WHERE tablename = %s", [tabela])
+ nazwy |= {row[0] for row in cur.fetchall()}
+ return nazwy
+
+
+@pytest.mark.django_db
+def test_migracja_0490_odwracalna():
+ with connection.cursor() as cur:
+ for tabela in TABELE:
+ nazwy = _constraint_names(cur, tabela)
+ prefix = PREFIX[tabela]
+ assert f"{prefix}_autor_uniq_rekord_autor_typ" in nazwy
+ assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" in nazwy
+ assert f"{prefix}_autor_excl_rekord_kolejnosc" in nazwy
+ assert LEGACY_CONSTRAINT[tabela] not in nazwy
+
+ call_command("migrate", "bpp", "0489", verbosity=0)
+
+ with connection.cursor() as cur:
+ for tabela in TABELE:
+ nazwy = _constraint_names(cur, tabela)
+ prefix = PREFIX[tabela]
+ assert f"{prefix}_autor_uniq_rekord_autor_typ" not in nazwy
+ assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" not in nazwy
+ assert f"{prefix}_autor_excl_rekord_kolejnosc" not in nazwy
+ assert LEGACY_CONSTRAINT[tabela] in nazwy
+
+ call_command("migrate", "bpp", "0490", verbosity=0)
+
+ with connection.cursor() as cur:
+ for tabela in TABELE:
+ nazwy = _constraint_names(cur, tabela)
+ prefix = PREFIX[tabela]
+ assert f"{prefix}_autor_uniq_rekord_autor_typ" in nazwy
+ assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" in nazwy
+ assert f"{prefix}_autor_excl_rekord_kolejnosc" in nazwy
+ assert LEGACY_CONSTRAINT[tabela] not in nazwy
From ce7c9bde9da5fe01acc0b73fca084aca47ef946e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 19:42:19 +0200
Subject: [PATCH 27/75] fix(soft-delete): runda poprawek 1 na Task 3c
(recenzja: 2 Important + 4 Minor)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Important 1 — utrata pokrycia indeksem na FK rekord. Warunkowe
UniqueConstraint/ExclusionConstraint z pierwszej rundy sa CZESCIOWE (WHERE
deleted_at IS NULL), wiec db_index=False na FK rekord (uzasadnione, dopoki
unique_together dawalo PELNY indeks) przestalo byc uzasadnione - RI-check
Postgresa przy DELETE rodzica, kolektor kaskady Django i
global_objects/deleted_objects.filter(rekord=...) robilyby seq scan.
Usunieto db_index=False z 3 modeli, zregenerowano migracje 0490 (dodane
AlterField przywracajace indeks). Nowy test
test_soft_delete/test_autor_rekord_index.py sprawdza wprost w pg_indexes.
Important 2 — falszywy pozytyw walidatora formsetu. Uzycie
formset.deleted_forms w _waliduj_kolizje_autorstwa_w_formsecie bylo bledne:
ta property sama zaczyna od "if not self.is_valid(): return []", wiec gdy
INNY wiersz formsetu mial blad walidacji pola, wiersze zaznaczone do
usuniecia wracaly jako pusta lista i dostawaly falszywy komunikat o
kolizji z wierszem, ktory je zastepuje. Naprawa: formset._should_delete_form
(f) zamiast f not in formset.deleted_forms - patrzy tylko na dane TEGO
wiersza. Nowy test test_inline_falszywy_duplikat_gdy_inny_wiersz_ma_blad,
zweryfikowany jako faktycznie lapiacy regresje (failuje na starym kodzie).
Minor 3 — walidator formsetu nie pokrywal ExclusionConstraint (rekord,
kolejnosc) miedzy ROZNYMI autorami. Uproszczone: gola kolejnosc (bez
autora) jest scislejszym nadzbiorem "ten sam autor, ta sama kolejnosc",
wiec zastapila oba klucze jednym. Nowy test
test_inline_dwaj_rozni_autorzy_ta_sama_kolejnosc.
Minor 4 — komentarze w Meta 3 modeli powielaly blad briefu (twierdzily, ze
validate_unique() ignoruje UniqueConstraint z condition; w rzeczywistosci
validate_unique() w ogole nie patrzy na Meta.constraints, mechanizmem jest
osobny validate_constraints(), Django >=4.1). Poprawione na zgodne z tym,
co raport Taska 3c juz ustalil.
Minor 5 — test re-insertu inline konczyl sie na is_valid(), nie dowodzil
naprawy regresji IntegrityError PRZY ZAPISIE. Dodano formset.save() i
asercje na stan bazy (1 zywy wiersz, inny pk, stary wiersz w
global_objects z deleted_at ustawionym).
Minor 6 — RunSQL DROP CONSTRAINT bez IF EXISTS wywalilby migracje na
instancji, gdzie constraint z 2018 zdjeto juz kiedys recznie. Dodano IF
EXISTS do wszystkich 3 DROP. Udokumentowano w komentarzu na gorze migracji,
ze reverse_sql nie przejdzie, jesli w tabeli sa juz soft-deletowane wiersze
dublujace (rekord_id, kolejnosc) z zywymi - rollback przestaje dzialac
dokladnie wtedy, gdy funkcja soft-delete byla juz uzywana.
Migracja 0490 zamendowana in-place (nie wypchnieta do origin, brief-owa
zasada "nigdy nie modyfikuj migracji" dotyczy migracji, na ktorych ktos
inny moglby juz polegac).
Weryfikacja: uv run pytest src/bpp/tests/test_soft_delete/
src/bpp/tests/test_admin/ src/import_sqlite/ -q -> 714 passed. uv run ruff
check src/bpp -> 36 bledow, wszystkie przedistniejace w plikach spoza
zakresu tego taska (zero w plikach dotknietych). makemigrations --check
--dry-run (bpp) -> brak zmian. pre-commit run -> wszystkie hooki Passed.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
src/bpp/admin/core.py | 60 +++++---
.../migrations/0490_autor_warunkowy_unique.py | 84 +++++++++--
src/bpp/models/patent.py | 28 +++-
src/bpp/models/wydawnictwo_ciagle.py | 28 +++-
src/bpp/models/wydawnictwo_zwarte.py | 28 +++-
.../test_admin/test_autor_inline_unique.py | 137 ++++++++++++++++--
.../test_autor_rekord_index.py | 43 ++++++
7 files changed, 338 insertions(+), 70 deletions(-)
create mode 100644 src/bpp/tests/test_soft_delete/test_autor_rekord_index.py
diff --git a/src/bpp/admin/core.py b/src/bpp/admin/core.py
index 3a2ef1eeb..0f9e5a5c4 100644
--- a/src/bpp/admin/core.py
+++ b/src/bpp/admin/core.py
@@ -368,19 +368,33 @@ def _waliduj_kolizje_autorstwa_w_formsecie(formset):
wierszem. Powód: dla nowego wiersza `rekord` nie jest jeszcze ustawiony
w momencie walidacji (przypisuje go dopiero
`BaseInlineFormSet.save_new()`, PO walidacji) — DB-owy
- `UniqueConstraint.validate()` po prostu pomija sprawdzenie, gdy pole
- złożonego klucza jest `None` (`NULL != NULL` w SQL). A formsetowe
- `validate_unique()` (Django) sprawdza pary formularzy tylko dla
- constraintów BEZWARUNKOWYCH (`Meta.total_unique_constraints`,
- `condition is None`) — nasze, warunkowe, są tam pomijane. Stąd ręczne
- porównanie par formularzy widocznych w formsecie (bez zapytań do bazy —
- `formset.instance`, czyli rodzic/rekord, jest wspólny dla wszystkich
- wierszy formsetu z definicji inline)."""
- forms_to_delete = formset.deleted_forms
+ `UniqueConstraint.validate()`/`ExclusionConstraint.validate()` po prostu
+ pomijają sprawdzenie, gdy pole złożonego klucza jest `None` (`NULL !=
+ NULL` w SQL). A formsetowe `BaseModelFormSet.validate_unique()` (Django)
+ sprawdza pary formularzy tylko dla constraintów BEZWARUNKOWYCH
+ (`Meta.total_unique_constraints`, `condition is None`) — nasze,
+ warunkowe, są tam pomijane. Stąd ręczne porównanie par formularzy
+ widocznych w formsecie (bez zapytań do bazy — `formset.instance`, czyli
+ rodzic/rekord, jest wspólny dla wszystkich wierszy formsetu z definicji
+ inline). Dwa klucze pokrywają WSZYSTKIE TRZY constrainty z Meta:
+ (autor, typ) ↔ `..._uniq_rekord_autor_typ`; goła `kolejnosc` (bez
+ autora) ↔ zarówno `..._uniq_rekord_autor_kolejnosc` (ten sam autor z
+ dwoma wpisami o tej samej kolejności), jak i `..._excl_rekord_kolejnosc`
+ (DWÓCH RÓŻNYCH autorów na tej samej pozycji) — goła `kolejnosc` jest
+ ściślejsza (autor nieistotny), więc pokrywa oba naraz."""
+ # UWAGA: `formset.deleted_forms` NIE nadaje się tu do użytku — jego
+ # własna implementacja zaczyna od `if not self.is_valid(): return []`.
+ # Wołane z wnętrza `clean()` (a więc z wnętrza `full_clean()`), gdy
+ # JAKIKOLWIEK INNY wiersz formsetu ma błąd walidacji pola, zwraca pustą
+ # listę — wiersze faktycznie zaznaczone do usunięcia trafiłyby wtedy do
+ # `aktywne` i dostały FAŁSZYWY komunikat o kolizji z wierszem, który
+ # dopiero je zastępuje (wzorzec „usuń + wstaw od nowa" w JEDNYM
+ # submicie). `_should_delete_form()` sprawdza tylko dane TEGO wiersza,
+ # bez wywoływania `is_valid()` na całym formsecie.
aktywne = [
f
for f in formset.forms
- if getattr(f, "cleaned_data", None) and f not in forms_to_delete
+ if getattr(f, "cleaned_data", None) and not formset._should_delete_form(f)
]
widziane_typ = {}
@@ -388,6 +402,21 @@ def _waliduj_kolizje_autorstwa_w_formsecie(formset):
for form in aktywne:
cd = form.cleaned_data
autor = cd.get("autor")
+ kolejnosc = cd.get("kolejnosc")
+
+ # Goła `kolejnosc` (bez autora) — pokrywa zarówno „ten sam autor
+ # dwa razy na tej samej pozycji" jak i „dwóch różnych autorów na
+ # tej samej pozycji" (`..._excl_rekord_kolejnosc` w Meta nie patrzy
+ # na autora wcale).
+ if kolejnosc is not None:
+ if kolejnosc in widziane_kolejnosc:
+ form.add_error(
+ None,
+ "To miejsce w kolejności autorów jest już zajęte.",
+ )
+ else:
+ widziane_kolejnosc[kolejnosc] = form
+
if autor is None:
continue
@@ -403,17 +432,6 @@ def _waliduj_kolizje_autorstwa_w_formsecie(formset):
else:
widziane_typ[klucz] = form
- kolejnosc = cd.get("kolejnosc")
- if kolejnosc is not None:
- klucz = (autor.pk, kolejnosc)
- if klucz in widziane_kolejnosc:
- form.add_error(
- None,
- "Ten autor ma już powiązanie z tą kolejnością.",
- )
- else:
- widziane_kolejnosc[klucz] = form
-
def generuj_inline_dla_autorow(baseModel, include_dyscyplina=True):
MAKSYMALNA_ILOSC_AUTOROW_W_FORMULARZU = 25
diff --git a/src/bpp/migrations/0490_autor_warunkowy_unique.py b/src/bpp/migrations/0490_autor_warunkowy_unique.py
index b2c5ecd90..c42219087 100644
--- a/src/bpp/migrations/0490_autor_warunkowy_unique.py
+++ b/src/bpp/migrations/0490_autor_warunkowy_unique.py
@@ -1,7 +1,38 @@
-# Generated by Django 5.2.16 on 2026-08-06 16:00
+# Generated by Django 5.2.16 on 2026-08-06 17:16
+#
+# Task 3c. Trzy powiązane zmiany na Wydawnictwo_Ciagle_Autor,
+# Wydawnictwo_Zwarte_Autor, Patent_Autor:
+#
+# 1. `unique_together` (widziałby też wiersze soft-deleted) -> dwa warunkowe
+# `UniqueConstraint` (`condition=deleted_at__isnull`).
+# 2. Przywrócenie zwykłego indeksu na FK `rekord` (`AlterField`, kasuje
+# wcześniejsze `db_index=False`). Powód: dopóki `unique_together` dawało
+# PEŁNY (nie częściowy) indeks btree z `rekord` jako kolumną wiodącą,
+# `db_index=False` na FK było uzasadnione — auto-indeks byłby redundantny.
+# Warunkowe constrainty z (1) są CZĘŚCIOWE (`WHERE deleted_at IS NULL`) —
+# nie pokrywają już zapytań po samym `rekord` bez tego predykatu
+# (RI-check Postgresa przy DELETE rodzica, kolektor kaskady Django,
+# `global_objects`/`deleted_objects`.filter(rekord=…)).
+# 3. Zastąpienie legacy `RunSQL` z migracji 0132 (2018) — DODATKOWEGO,
+# poza Django ORM, `UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY
+# DEFERRED` (nigdy nie było w żadnym `Meta`, więc `makemigrations` go nie
+# widziało) — `ExclusionConstraint`-em (GiST + btree_gist, rozszerzenie
+# już włączone od migracji 0056). `UniqueConstraint` nie umie łączyć
+# `condition` z `deferrable` (Django to blokuje), a `deferrable` jest
+# wymagany przez drag&drop reorder autorów w adminie (adminsortable2,
+# `sortable_field_name = "kolejnosc"`) — zamiana kolejności dwóch wierszy
+# przejściowo dubluje `kolejnosc` w obrębie jednej transakcji.
+#
+# UWAGA przy rollbacku (`reverse_sql` niżej): przywraca legacy `UNIQUE
+# (rekord_id, kolejnosc) DEFERRABLE`, ale to NIE zadziała, jeśli w tabeli są
+# już soft-deletowane wiersze dublujące `(rekord_id, kolejnosc)` z żywymi —
+# taki stan jest dozwolony PO tej migracji (to właśnie ona to umożliwia),
+# ale narusza bezwarunkowy constraint SPRZED niej. Rollback padnie
+# dokładnie wtedy, gdy funkcja soft-delete była już używana.
import django.contrib.postgres.constraints
import django.db.models.constraints
+import django.db.models.deletion
from django.db import migrations, models
@@ -23,6 +54,33 @@ class Migration(migrations.Migration):
name="wydawnictwo_zwarte_autor",
unique_together=set(),
),
+ migrations.AlterField(
+ model_name="patent_autor",
+ name="rekord",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="autorzy_set",
+ to="bpp.patent",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="wydawnictwo_ciagle_autor",
+ name="rekord",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="autorzy_set",
+ to="bpp.wydawnictwo_ciagle",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="wydawnictwo_zwarte_autor",
+ name="rekord",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="autorzy_set",
+ to="bpp.wydawnictwo_zwarte",
+ ),
+ ),
migrations.AddConstraint(
model_name="patent_autor",
constraint=models.UniqueConstraint(
@@ -98,22 +156,18 @@ class Migration(migrations.Migration):
name="wz_autor_excl_rekord_kolejnosc",
),
),
- # Drugi kawałek Taska 3c: legacy migracja 0132 dołożyła surowym SQL-em
- # DODATKOWY, poza Django ORM, `UNIQUE (rekord_id, kolejnosc)
- # DEFERRABLE INITIALLY DEFERRED` na wszystkich 3 tabelach — Django
- # nigdy o nim nie wiedział (nie ma go w Meta), więc `makemigrations`
- # go nie widzi i nie usunie. Ten constraint SAM blokował dokładnie tę
- # samą regresję (re-insert po soft-delete), tylko odroczony do
- # `COMMIT`/zwolnienia savepointu, więc test padał dopiero w
- # teardown/`SET CONSTRAINTS ALL IMMEDIATE`, nie na samym INSERT.
- # Zastępujemy go `ExclusionConstraint`-em dodanym wyżej (ten sam,
- # który właśnie stworzyliśmy przez ORM) — jedynym typem ograniczenia
- # w Postgresie łączącym `WHERE` (warunek soft-delete) z
- # `DEFERRABLE` (wymagane przez drag&drop reorder adminsortable2).
+ # Trzeci kawałek Taska 3c (patrz komentarz na górze pliku): usuń
+ # legacy constraint z migracji 0132, zastąpiony ExclusionConstraint-
+ # em dodanym wyżej. `IF EXISTS` na DROP — instancja, na której ten
+ # constraint zdjęto już kiedyś ręcznie (spoza Django), nie może
+ # wywalić migracji. `reverse_sql` przywraca oryginalne DDL z 0132
+ # bez `IF EXISTS` (ADD CONSTRAINT tego nie ma w składni) — patrz
+ # zastrzeżenie o rollbacku na górze pliku.
migrations.RunSQL(
sql=(
"ALTER TABLE bpp_patent_autor "
- "DROP CONSTRAINT bpp_patent_autor_unique_rekord_id_kolejnosc;"
+ "DROP CONSTRAINT IF EXISTS "
+ "bpp_patent_autor_unique_rekord_id_kolejnosc;"
),
reverse_sql=(
"ALTER TABLE bpp_patent_autor "
@@ -124,6 +178,7 @@ class Migration(migrations.Migration):
migrations.RunSQL(
sql=(
"ALTER TABLE bpp_wydawnictwo_ciagle_autor DROP CONSTRAINT "
+ "IF EXISTS "
"bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc;"
),
reverse_sql=(
@@ -135,6 +190,7 @@ class Migration(migrations.Migration):
migrations.RunSQL(
sql=(
"ALTER TABLE bpp_wydawnictwo_zwarte_autor DROP CONSTRAINT "
+ "IF EXISTS "
"bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc;"
),
reverse_sql=(
diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py
index 5a7722ce3..ab3a26203 100644
--- a/src/bpp/models/patent.py
+++ b/src/bpp/models/patent.py
@@ -38,9 +38,17 @@ class Patent_Autor(BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutor
"Patent",
CASCADE,
related_name="autorzy_set",
- # Auto-indeks FK redundantny: unique_together ma rekord jako kolumnę
- # wiodącą — pokrywa lookup po rekord i kaskadę usuwania.
- db_index=False,
+ # Task 3c: `db_index=False` było tu uzasadnione, dopóki
+ # `unique_together` dawało pełny (nie częściowy) indeks btree z
+ # `rekord` jako kolumną wiodącą. Warunkowe UniqueConstraint/
+ # ExclusionConstraint z Taska 3c są CZĘŚCIOWE
+ # (`WHERE deleted_at IS NULL`) — nie pokrywają zapytań po samym
+ # `rekord` bez tego predykatu: RI-check Postgresa przy DELETE
+ # rodzica, kolektor kaskady Django (`_base_manager`, bez filtra
+ # soft-delete) i `global_objects`/`deleted_objects`.filter(rekord=…)
+ # (czyli dokładnie zapytania, po które soft-delete istnieje).
+ # Zwykły indeks FK (domyślny dla ForeignKey) jest więc znów
+ # potrzebny — NIE ustawiaj tu `db_index=False`.
)
class Meta:
@@ -52,10 +60,16 @@ class Meta:
# wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa"
# (re-import, korekta kolejności, edycja inline). Warunkowy
# UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności
- # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django
- # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest
- # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow`
- # / `generuj_inline_dla_autorow`) — patrz Task 3c.
+ # TYLKO wśród żywych wierszy. Django `validate_unique()` W OGÓLE nie
+ # patrzy na `Meta.constraints` (mechanizmem jest osobny
+ # `Model.validate_constraints()`, Django >=4.1) — a TEN wymaga, żeby
+ # pole użyte w `condition` (`deleted_at`) nie było wykluczone z
+ # walidacji formularza, inaczej cicho pomija sprawdzenie (albo, dla
+ # ExclusionConstraint, rzuca gołym FieldError). Stąd `deleted_at`
+ # jest jawnym, ukrytym polem w `generuj_formularz_dla_autorow`
+ # (`bpp.admin.core`). Formset inline ma DODATKOWO ręczną walidację
+ # kolizji nowy-wiersz-vs-istniejący-w-tym-samym-submicie
+ # (`generuj_inline_dla_autorow`) — patrz Task 3c, raport.
constraints = [
models.UniqueConstraint(
fields=["rekord", "autor", "typ_odpowiedzialnosci"],
diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py
index 54ea4312a..8995240fd 100644
--- a/src/bpp/models/wydawnictwo_ciagle.py
+++ b/src/bpp/models/wydawnictwo_ciagle.py
@@ -63,9 +63,17 @@ class Wydawnictwo_Ciagle_Autor(
"Wydawnictwo_Ciagle",
CASCADE,
related_name="autorzy_set",
- # Auto-indeks FK redundantny: unique_together ma rekord jako kolumnę
- # wiodącą w obu krotkach — pokrywa lookup po rekord i kaskadę usuwania.
- db_index=False,
+ # Task 3c: `db_index=False` było tu uzasadnione, dopóki
+ # `unique_together` dawało pełny (nie częściowy) indeks btree z
+ # `rekord` jako kolumną wiodącą. Warunkowe UniqueConstraint/
+ # ExclusionConstraint z Taska 3c są CZĘŚCIOWE
+ # (`WHERE deleted_at IS NULL`) — nie pokrywają zapytań po samym
+ # `rekord` bez tego predykatu: RI-check Postgresa przy DELETE
+ # rodzica, kolektor kaskady Django (`_base_manager`, bez filtra
+ # soft-delete) i `global_objects`/`deleted_objects`.filter(rekord=…)
+ # (czyli dokładnie zapytania, po które soft-delete istnieje).
+ # Zwykły indeks FK (domyślny dla ForeignKey) jest więc znów
+ # potrzebny — NIE ustawiaj tu `db_index=False`.
)
class Meta:
@@ -77,10 +85,16 @@ class Meta:
# wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa"
# (re-import, korekta kolejności, edycja inline). Warunkowy
# UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności
- # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django
- # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest
- # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow`
- # / `generuj_inline_dla_autorow`) — patrz Task 3c.
+ # TYLKO wśród żywych wierszy. Django `validate_unique()` W OGÓLE nie
+ # patrzy na `Meta.constraints` (mechanizmem jest osobny
+ # `Model.validate_constraints()`, Django >=4.1) — a TEN wymaga, żeby
+ # pole użyte w `condition` (`deleted_at`) nie było wykluczone z
+ # walidacji formularza, inaczej cicho pomija sprawdzenie (albo, dla
+ # ExclusionConstraint, rzuca gołym FieldError). Stąd `deleted_at`
+ # jest jawnym, ukrytym polem w `generuj_formularz_dla_autorow`
+ # (`bpp.admin.core`). Formset inline ma DODATKOWO ręczną walidację
+ # kolizji nowy-wiersz-vs-istniejący-w-tym-samym-submicie
+ # (`generuj_inline_dla_autorow`) — patrz Task 3c, raport.
constraints = [
models.UniqueConstraint(
fields=["rekord", "autor", "typ_odpowiedzialnosci"],
diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py
index 40228b750..f4c9084e9 100644
--- a/src/bpp/models/wydawnictwo_zwarte.py
+++ b/src/bpp/models/wydawnictwo_zwarte.py
@@ -72,9 +72,17 @@ class Wydawnictwo_Zwarte_Autor(
"Wydawnictwo_Zwarte",
CASCADE,
related_name="autorzy_set",
- # Auto-indeks FK redundantny: unique_together ma rekord jako kolumnę
- # wiodącą — pokrywa lookup po rekord i kaskadę usuwania.
- db_index=False,
+ # Task 3c: `db_index=False` było tu uzasadnione, dopóki
+ # `unique_together` dawało pełny (nie częściowy) indeks btree z
+ # `rekord` jako kolumną wiodącą. Warunkowe UniqueConstraint/
+ # ExclusionConstraint z Taska 3c są CZĘŚCIOWE
+ # (`WHERE deleted_at IS NULL`) — nie pokrywają zapytań po samym
+ # `rekord` bez tego predykatu: RI-check Postgresa przy DELETE
+ # rodzica, kolektor kaskady Django (`_base_manager`, bez filtra
+ # soft-delete) i `global_objects`/`deleted_objects`.filter(rekord=…)
+ # (czyli dokładnie zapytania, po które soft-delete istnieje).
+ # Zwykły indeks FK (domyślny dla ForeignKey) jest więc znów
+ # potrzebny — NIE ustawiaj tu `db_index=False`.
)
class Meta:
@@ -86,10 +94,16 @@ class Meta:
# wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa"
# (re-import, korekta kolejności, edycja inline). Warunkowy
# UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności
- # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django
- # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest
- # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow`
- # / `generuj_inline_dla_autorow`) — patrz Task 3c.
+ # TYLKO wśród żywych wierszy. Django `validate_unique()` W OGÓLE nie
+ # patrzy na `Meta.constraints` (mechanizmem jest osobny
+ # `Model.validate_constraints()`, Django >=4.1) — a TEN wymaga, żeby
+ # pole użyte w `condition` (`deleted_at`) nie było wykluczone z
+ # walidacji formularza, inaczej cicho pomija sprawdzenie (albo, dla
+ # ExclusionConstraint, rzuca gołym FieldError). Stąd `deleted_at`
+ # jest jawnym, ukrytym polem w `generuj_formularz_dla_autorow`
+ # (`bpp.admin.core`). Formset inline ma DODATKOWO ręczną walidację
+ # kolizji nowy-wiersz-vs-istniejący-w-tym-samym-submicie
+ # (`generuj_inline_dla_autorow`) — patrz Task 3c, raport.
constraints = [
models.UniqueConstraint(
fields=["rekord", "autor", "typ_odpowiedzialnosci"],
diff --git a/src/bpp/tests/test_admin/test_autor_inline_unique.py b/src/bpp/tests/test_admin/test_autor_inline_unique.py
index f71df042d..917a07521 100644
--- a/src/bpp/tests/test_admin/test_autor_inline_unique.py
+++ b/src/bpp/tests/test_admin/test_autor_inline_unique.py
@@ -2,28 +2,40 @@
`ExclusionConstraint` (``condition=deleted_at__isnull``) NIE zregresowała
walidacji formularza w adminie.
-Dwie osobne pułapki, obie związane z tym, że ``deleted_at`` NIE jest polem
-formularza (``generuj_formularz_dla_autorow`` go nie miało):
-
-1. ``Model.validate_unique()`` w ogóle NIE sprawdza ``Meta.constraints``
- (ani warunkowych, ani bezwarunkowych) — to osobny krok,
- ``Model.validate_constraints()`` (Django >=4.1), wołany automatycznie
- przez ``ModelForm._post_clean()`` -> ``instance.full_clean(...)``.
+Trzy osobne pułapki:
+
+1. ``Model.validate_unique()`` w ogóle NIE patrzy na ``Meta.constraints``
+ (ani warunkowe, ani bezwarunkowe — ``_get_unique_checks()`` woła z
+ ``include_meta_constraints=False``). Mechanizmem, który TO sprawdza, jest
+ osobny krok, ``Model.validate_constraints()`` (Django >=4.1), wołany
+ automatycznie przez ``ModelForm._post_clean()`` -> ``instance.full_clean(
+ ...)``.
2. ``validate_constraints()`` DZIAŁA dla warunkowych constraintów, ALE tylko
jeśli pole użyte w ``condition`` (tu: ``deleted_at``) nie jest wykluczone
z walidacji. Pole spoza ``Meta.fields`` formularza Django automatycznie
- wrzuca do ``exclude`` (``_get_validation_exclusions()``). Skutek:
+ wrzuca do ``exclude`` (``_get_validation_exclusions()``), a
+ ``generuj_formularz_dla_autorow`` nigdy nie miało ``deleted_at`` jako
+ pola. Skutek:
- dla ``UniqueConstraint`` — Django CICHO POMIJA walidację (łapie
``FieldError`` i nic nie zgłasza — kolizja przechodzi formularz,
``IntegrityError`` wyskakuje dopiero przy zapisie do bazy),
- dla ``ExclusionConstraint`` — Django W OGÓLE NIE ŁAPIE tego
``FieldError`` — ``is_valid()`` wywala się niekontrolowanym wyjątkiem
(HTTP 500) przy KAŻDYM zapisie, nawet bez żadnej kolizji.
-
-Naprawa: ``deleted_at`` jest teraz jawnym, ukrytym/wyłączonym polem
-formularza (zawsze ``None`` — formularz operuje tylko na żywych wierszach),
-więc oba constrainty widzą je poprawnie i walidują się jak należy, bez
-ręcznego ``clean()``.
+ Naprawa: ``deleted_at`` jest teraz jawnym, ukrytym/wyłączonym polem
+ formularza (zawsze ``None`` — formularz operuje tylko na żywych
+ wierszach).
+3. Formset inline nie łapał kolizji NOWEGO wiersza z ISTNIEJĄCYM (rekord
+ nieznany dla nowego wiersza w momencie walidacji, formsetowe
+ ``validate_unique()`` pomija constrainty warunkowe). Naprawa: ręczna
+ ``_waliduj_kolizje_autorstwa_w_formsecie`` w ``bpp.admin.core``.
+ Runda poprawek 1: ta funkcja pierwotnie używała ``formset.deleted_forms``
+ do wykluczenia wierszy zaznaczonych do usunięcia — ale ``deleted_forms``
+ samo zaczyna od ``if not self.is_valid(): return []``, więc gdy INNY
+ wiersz formsetu miał błąd walidacji pola, wiersze DO USUNIĘCIA wracały
+ jako pusta lista i dostawały FAŁSZYWY komunikat o kolizji z wierszem,
+ który je zastępuje. Naprawione przez ``formset._should_delete_form(f)``
+ (patrz ``test_inline_falszywy_duplikat_gdy_inny_wiersz_ma_blad`` niżej).
"""
import pytest
@@ -235,14 +247,47 @@ def test_inline_nowy_wiersz_koliduje_z_istniejacym_kolejnosc(
assert not formset.is_valid(), formset.errors
+@pytest.mark.django_db
+def test_inline_dwaj_rozni_autorzy_ta_sama_kolejnosc(
+ wydawnictwo_ciagle_z_autorem, autor_jan_nowak, rf, admin_user
+):
+ """Minor 3, runda poprawek 1: DWÓCH RÓŻNYCH autorów na tej samej
+ pozycji łamie `..._excl_rekord_kolejnosc` (ta nie patrzy na autora
+ wcale) — walidator formsetu musi to złapać, nie tylko kolizję TEGO
+ SAMEGO autora."""
+ wc = wydawnictwo_ciagle_z_autorem
+ existing = wc.autorzy_set.get()
+
+ data = _management(total=2, initial=1)
+ data.update(_wca_row(0, existing))
+ data.update(
+ _nowy_wiersz(
+ 1,
+ autor=autor_jan_nowak, # INNY autor niż `existing`
+ jednostka=existing.jednostka,
+ typ_odpowiedzialnosci=existing.typ_odpowiedzialnosci,
+ zapisany_jako="Nowak Jan",
+ kolejnosc=existing.kolejnosc, # ta sama pozycja
+ )
+ )
+
+ formset = _inline_formset(rf, admin_user, wc, data)
+
+ assert not formset.is_valid(), formset.errors
+
+
@pytest.mark.django_db
def test_inline_po_soft_delete_reinsert_przechodzi(
wydawnictwo_ciagle_z_autorem, rf, admin_user
):
"""Formset MUSI przepuścić wzorzec "usuń stary wiersz (miękko) + dodaj
- nowy o tych samych wartościach" — dokładnie regresja z Taska 3c."""
+ nowy o tych samych wartościach" — dokładnie regresja z Taska 3c. Woła
+ `formset.save()` (nie tylko `is_valid()`) — regresja z brief-u to
+ `IntegrityError` PRZY ZAPISIE, `is_valid()`==True samo w sobie tego nie
+ dowodzi (Minor 5, runda poprawek 1)."""
wc = wydawnictwo_ciagle_z_autorem
existing = wc.autorzy_set.get()
+ stary_pk = existing.pk
data = _management(total=2, initial=1)
row0 = _wca_row(0, existing)
@@ -262,3 +307,67 @@ def test_inline_po_soft_delete_reinsert_przechodzi(
formset = _inline_formset(rf, admin_user, wc, data)
assert formset.is_valid(), formset.errors
+ formset.save()
+
+ zywe = list(Wydawnictwo_Ciagle_Autor.objects.filter(rekord=wc))
+ assert len(zywe) == 1
+ assert zywe[0].pk != stary_pk
+ assert zywe[0].autor_id == existing.autor_id
+ assert zywe[0].kolejnosc == existing.kolejnosc
+
+ stary = Wydawnictwo_Ciagle_Autor.global_objects.get(pk=stary_pk)
+ assert stary.deleted_at is not None
+
+
+@pytest.mark.django_db
+def test_inline_falszywy_duplikat_gdy_inny_wiersz_ma_blad(
+ wydawnictwo_ciagle_z_autorem, autor_jan_nowak, rf, admin_user
+):
+ """Important 2, runda poprawek 1: `formset.deleted_forms` zaczyna od
+ `if not self.is_valid(): return []` — gdy INNY wiersz formsetu ma błąd
+ walidacji pola, wiersz DO USUNIĘCIA (wiersz 0) był wcześniej błędnie
+ traktowany jako "aktywny" i dostawał fałszywy komunikat o kolizji z
+ wierszem, który go zastępuje (wiersz 1). Wiersz 2 ma osobny, PRAWDZIWY
+ błąd (brak `zapisany_jako`) — formset i tak jest niepoprawny, ale wiersz
+ 0 i 1 nie mogą dostać nieprawdziwego komunikatu o duplikacie."""
+ wc = wydawnictwo_ciagle_z_autorem
+ existing = wc.autorzy_set.get()
+
+ data = _management(total=3, initial=1)
+ row0 = _wca_row(0, existing)
+ row0[f"{PREFIX}-0-DELETE"] = "on"
+ data.update(row0)
+ data.update(
+ _nowy_wiersz(
+ 1,
+ autor=existing.autor,
+ jednostka=existing.jednostka,
+ typ_odpowiedzialnosci=existing.typ_odpowiedzialnosci,
+ zapisany_jako=existing.zapisany_jako,
+ kolejnosc=existing.kolejnosc,
+ )
+ )
+ trzeci = _nowy_wiersz(
+ 2,
+ autor=autor_jan_nowak,
+ jednostka=existing.jednostka,
+ typ_odpowiedzialnosci=existing.typ_odpowiedzialnosci,
+ zapisany_jako="", # celowo puste -> prawdziwy błąd tego wiersza
+ kolejnosc=existing.kolejnosc + 1,
+ )
+ data.update(trzeci)
+
+ formset = _inline_formset(rf, admin_user, wc, data)
+
+ assert not formset.is_valid()
+ # UWAGA: `formset.errors` (lista) POMIJA wiersze zaznaczone do usunięcia
+ # (`BaseFormSet.full_clean()`: `if ... self._should_delete_form(form):
+ # continue` PRZED dopisaniem do `self._errors`) — nie jest indeksowana
+ # 1:1 z `self.forms`. Odwołujemy się więc wprost do `formset.forms[i]`.
+ #
+ # Wiersz 2 ma swój prawdziwy błąd (brak zapisany_jako).
+ assert formset.forms[2].errors.get("zapisany_jako")
+ # Wiersze 0 (usuwany) i 1 (re-insert) NIE mogą dostać komunikatu
+ # o kolizji — to fałszywy pozytyw, który naprawia _should_delete_form.
+ assert not formset.forms[0].errors.get("__all__")
+ assert not formset.forms[1].errors.get("__all__")
diff --git a/src/bpp/tests/test_soft_delete/test_autor_rekord_index.py b/src/bpp/tests/test_soft_delete/test_autor_rekord_index.py
new file mode 100644
index 000000000..00da7bef3
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_autor_rekord_index.py
@@ -0,0 +1,43 @@
+"""Task 3c, runda poprawek 1 (Important 1): warunkowe UniqueConstraint/
+ExclusionConstraint na *_Autor są CZĘŚCIOWE (``WHERE deleted_at IS NULL``) —
+nie pokrywają już zapytań po samym ``rekord`` bez tego predykatu (RI-check
+Postgresa przy DELETE rodzica, kolektor kaskady Django, `global_objects`/
+`deleted_objects`.filter(rekord=…)). FK musi więc mieć z powrotem zwykły,
+BEZWARUNKOWY indeks z ``rekord_id`` jako kolumną wiodącą."""
+
+import pytest
+from django.db import connection
+
+TABELE = [
+ "bpp_patent_autor",
+ "bpp_wydawnictwo_ciagle_autor",
+ "bpp_wydawnictwo_zwarte_autor",
+]
+
+
+def _ma_bezwarunkowy_indeks_na_rekord_id(cur, tabela):
+ """Czy tabela ma indeks btree z `rekord_id` jako PIERWSZĄ kolumną i BEZ
+ klauzuli WHERE (częściowe indeksy z Taska 3c się nie liczą — nie
+ przyspieszają zapytań bez predykatu `deleted_at IS NULL`)."""
+ cur.execute(
+ "SELECT indexdef FROM pg_indexes WHERE tablename = %s",
+ [tabela],
+ )
+ for (indexdef,) in cur.fetchall():
+ if "WHERE" in indexdef:
+ continue
+ # np. "... USING btree (rekord_id)" albo "(rekord_id, ...)"
+ if "(rekord_id)" in indexdef or "(rekord_id," in indexdef:
+ return True
+ return False
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("tabela", TABELE)
+def test_rekord_ma_bezwarunkowy_indeks(tabela):
+ with connection.cursor() as cur:
+ assert _ma_bezwarunkowy_indeks_na_rekord_id(cur, tabela), (
+ f"{tabela}: brak bezwarunkowego indeksu na rekord_id — "
+ "RI-check/kaskada/global_objects.filter(rekord=…) robiłyby "
+ "seq scan"
+ )
From 48bf4cbda7c9c0700d0b50c08f193db38c598ccc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 20:32:13 +0200
Subject: [PATCH 28/75] =?UTF-8?q?test(soft-delete):=20sp=C3=B3jno=C5=9B?=
=?UTF-8?q?=C4=87=20bpp=5Fautorzy=5Fmat=20+=20odwr=C3=B3cenie=20kanark?=
=?UTF-8?q?=C3=B3w=20(Task=204)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Nowy plik test_cache_consistency.py: 4 testy spójności bpp_autorzy_mat
(model Autorzy) / bpp_autorzy (AutorzyView) po soft-delete, restore,
edycji skasowanego wiersza i kaskadzie queryset-owej *_Autor.
Odwrócenie kanarków w test_soft_delete_preconditions.py:
oryginalne dwa testy (dot. bpp_wydawnictwo_ciagle, faza 02) przepisane
na docelowe asercje i oznaczone xfail(strict=True) — jedyny regresyjny
dowód, że mechanizm dla publikacji wciąż nie działa (poza zakresem
fazy 01). Dopisane dwa odpowiedniki dla bpp_wydawnictwo_ciagle_autor /
bpp_autorzy_mat, które już działają — zielone.
Weryfikacja mutacyjna: usunięcie gałęzi kasującej z funkcji refresh
faktycznie psuje test_soft_delete_autorstwa_znika_z_mat (wiersz
przeżywa delete()) — testy nie są tautologiami.
uv run pytest src/bpp/tests/test_cache/ src/bpp/tests/test_soft_delete/ -q
125 passed, 1 skipped, 2 xfailed
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
.../test_soft_delete_preconditions.py | 199 ++++++++++++++----
.../test_cache_consistency.py | 144 +++++++++++++
2 files changed, 306 insertions(+), 37 deletions(-)
create mode 100644 src/bpp/tests/test_soft_delete/test_cache_consistency.py
diff --git a/src/bpp/tests/test_cache/test_soft_delete_preconditions.py b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py
index de916f1c1..2033494bb 100644
--- a/src/bpp/tests/test_cache/test_soft_delete_preconditions.py
+++ b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py
@@ -1,4 +1,4 @@
-"""Warunki wstepne soft-delete wobec triggerow cache (PR #312, faza 01).
+"""Warunki wstepne soft-delete wobec triggerow cache (PR #312, faza 01/02).
Spec soft-delete (2026-06-04) zaklada dwa fakty o warstwie cache:
@@ -8,14 +8,30 @@
ktory MUSI przetrwac optymalizacje" -- plan 00).
Oba fakty przestaly obowiazywac po migracjach 0432 (port PL/pgSQL, upsert bez
-DELETE) i 0433 (bramka WHEN na liscie kolumn z pg_depend). Te testy to
-przypinaja: sa ZIELONE na obecnym kodzie, czyli dokumentuja stan "soft-delete
-by nie zadzialal". Faza 01 ma je odwrocic (zmienic asercje na docelowe) razem
-z wprowadzeniem gałęzi kasujacej w funkcjach refresh + regeneracja bramki.
+DELETE) i 0433 (bramka WHEN na liscie kolumn z pg_depend). Poczatkowo (przed
+faza 01) ponizsze dwa testy to PRZYPINALY -- byly ZIELONE na ówczesnym
+kodzie, dokumentujac stan "soft-delete by nie zadzialal".
+
+Faza 01 objela WYLACZNIE tabele ``*_Autor`` (migracja 0489: widok filtruje,
+funkcja refresh ma galaz kasujaca, bramka WHEN zna ``deleted_at`` -- patrz
+``test_soft_delete/test_views_sql.py``). Tabele publikacji (np.
+``bpp_wydawnictwo_ciagle``) sa poza zakresem -- to faza 02. Dlatego:
+
+- oryginalne dwa testy ponizej sa ODWROCONE na docelowe asercje (to co
+ soft-delete MA robic), ale zostawione jako ``xfail`` -- to JEDYNY
+ regresyjny dowod, ze bramka i galaz kasujaca dzialaja poprawnie DOPIERO
+ po fazie 02 (gdy przestana byc xfail, to znak, ze ktos wdrozyl mechanizm
+ dla publikacji i NIE zaktualizowal tego markera -- patrz uwaga przy
+ ``xfail`` nizej);
+- ponizej dopisane sa ich ODPOWIEDNIKI dla ``bpp_wydawnictwo_ciagle_autor``
+ / ``bpp_autorzy_mat``, ktore juz DZIALAJA (faza 01) -- to one sa realnym
+ dowodem regresyjnym na CO DZIEN, nie oryginaly.
Surowy SQL, zeby izolowac sam trigger bazodanowy (bez denorm / sygnalow
-Django). Kolumne ``deleted_at`` dokladamy ALTER-em wewnatrz transakcji
-testowej -- DDL w Postgresie jest transakcyjny, wiec rollback ja sprzata.
+Django). Dla oryginalnych (publikacja) testow kolumne ``deleted_at``
+dokladamy ALTER-em wewnatrz transakcji testowej -- DDL w Postgresie jest
+transakcyjny, wiec rollback ja sprzata. Dla nowych (``*_Autor``) testow
+ALTER nie jest potrzebny -- kolumna jest realna od migracji 0488.
"""
import pytest
@@ -67,15 +83,21 @@ def _filtruj_widok_po_deleted_at(cur):
)
+@pytest.mark.xfail(
+ reason="faza 02 -- soft-delete publikacji (bpp_wydawnictwo_ciagle)",
+ strict=True,
+)
@pytest.mark.django_db
-def test_update_samego_deleted_at_nie_odpala_triggera():
- """Bramka WHEN (0433) nie zna deleted_at -> UPDATE soft-delete nie wchodzi.
-
- django-soft-delete kasuje przez
- ``save(update_fields=['deleted_at', 'restored_at', 'transaction_id'])``,
- wiec UPDATE dotyka WYLACZNIE kolumn spoza bramki. Zaden atrybut zasilajacy
- widok sie nie zmienia -> trigger sie nie odpala -> wiersz zostaje w
- bpp_rekord_mat (ctid bez zmian).
+def test_update_samego_deleted_at_odpala_trigger():
+ """Docelowo: bramka WHEN MA znac deleted_at -> UPDATE soft-delete MA
+ odpalac trigger, ktory kasuje wiersz z bpp_rekord_mat (ctid znika).
+
+ Odwrocenie ``test_update_samego_deleted_at_nie_odpala_triggera`` (nazwa
+ i asercja sprzed fazy 01). Dla ``bpp_wydawnictwo_ciagle`` samej to
+ dalej NIE dziala -- faza 01 dotknela wylacznie tabel ``*_Autor``
+ (migracja 0489). Ten test ma pozostac xfail az do fazy 02; gdy
+ zazieleni sie SAM (bez zmiany kodu tego pliku), oznacza to niezamierzona
+ regresje zakresu -- zbadaj, co dotknelo bramki ``bpp_wydawnictwo_ciagle``.
"""
# DDL PRZED utworzeniem rekordu: ALTER TABLE nie przejdzie, gdy tabela ma
# zakolejkowane zdarzenia wyzwalaczy z INSERT-a w tej samej transakcji.
@@ -97,24 +119,25 @@ def test_update_samego_deleted_at_nie_odpala_triggera():
)
po = _ctid(cur, ct, pk)
- assert po == przed, (
- "UPDATE samego deleted_at przepisal bpp_rekord_mat "
- f"(ctid {przed} -> {po}) -- bramka WHEN najwyrazniej zna deleted_at"
+ assert po is None, (
+ "UPDATE samego deleted_at NIE usunal wiersza z bpp_rekord_mat "
+ f"(ctid {przed} -> {po}) -- bramka WHEN nie zna deleted_at"
)
+@pytest.mark.xfail(
+ reason="faza 02 -- soft-delete publikacji (bpp_wydawnictwo_ciagle)",
+ strict=True,
+)
@pytest.mark.django_db
-def test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat():
- """Upsert bez DELETE (0432): odfiltrowanie z widoku NIE czysci _mat.
-
- Nawet gdy trigger SIE ODPALI (wymuszamy to UPDATE-em bramkowanej kolumny
- ``rok``), funkcja refresh robi tylko
- ``INSERT ... SELECT FROM widok ... ON CONFLICT DO UPDATE``. Widok nie
- zwraca wiersza -> INSERT wybiera zero wierszy -> no-op -> stary wiersz
- przezywa w bpp_rekord_mat.
-
- To obala "inwariant delete-first" z planu 00, na ktorym opiera sie
- wystarczalnosc mechanizmu #1.
+def test_soft_delete_usuwa_wiersz_z_mat():
+ """Docelowo: goly UPDATE ... SET deleted_at MA usunac wiersz z
+ bpp_rekord_mat (mechanizm #1 -- filtr widoku -- wystarcza, bo funkcja
+ refresh ma galaz kasujaca uruchamiana PRZED upsertem).
+
+ Odwrocenie ``test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat`` (nazwa i
+ asercja sprzed fazy 01). Jak wyzej: dla publikacji to faza 02, ten test
+ ma zostac xfail do tego czasu.
"""
# Cale DDL przed INSERT-em (patrz test wyzej). Filtr widoku jest juz
# aktywny przy tworzeniu rekordu, ale deleted_at jest wtedy NULL, wiec
@@ -136,18 +159,120 @@ def test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat():
"UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s",
[pk],
)
- # ... i wymuszamy odpalenie triggera zmiana kolumny Z bramki
- cur.execute("UPDATE bpp_wydawnictwo_ciagle SET rok = 2021 WHERE id = %s", [pk])
+ po = _ctid(cur, ct, pk)
+
+ assert po is None, (
+ "wiersz zostal w bpp_rekord_mat po UPDATE ... SET deleted_at -- "
+ f"funkcja refresh nie kasuje przed upsertem (ctid: {po})"
+ )
+
+
+def _ctid_autor(cur, ct, wca_pk):
+ """Fizyczne polozenie krotki w bpp_autorzy_mat; None gdy wiersza nie ma.
+
+ Klucz PK w bpp_autorzy_mat to ARRAY[ct, wca_pk], gdzie ``ct`` to content
+ type modelu PUBLIKACJI (np. wydawnictwo_ciagle), a ``wca_pk`` to pk
+ wiersza through (``*_Autor``), NIE pk publikacji ani autora -- patrz
+ migracje 0432 (``_create_rekord_function``/``bpp_refresh_autor_*``) i
+ 0489 (``_funkcja_z_galezia_kasujaca``: ``DELETE ... WHERE id =
+ ARRAY[ct, NEW.id]``).
+ """
+ cur.execute(
+ "SELECT ctid::text FROM bpp_autorzy_mat WHERE id = ARRAY[%s, %s]::integer[]",
+ [ct, wca_pk],
+ )
+ row = cur.fetchone()
+ return row[0] if row else None
+
+
+@pytest.mark.django_db
+def test_update_samego_deleted_at_odpala_trigger_autor(wydawnictwo_ciagle_z_autorem):
+ """Odpowiednik ``test_update_samego_deleted_at_odpala_trigger`` (wyzej,
+ xfail) dla ``*_Autor`` -- TU juz dziala (faza 01, migracja 0489).
+
+ W przeciwienstwie do ``bpp_wydawnictwo_ciagle``, bramka WHEN triggera
+ ``bpp_wydawnictwo_ciagle_autor_cache_upd`` ZNA ``deleted_at`` (dopisana
+ do widoku ``bpp_wydawnictwo_ciagle_autorzy`` w kroku 1 migracji 0489,
+ wciagnieta do bramki w kroku 3 przez ``pg_depend``). Goly UPDATE samego
+ ``deleted_at`` odpala trigger i usuwa wiersz z ``bpp_autorzy_mat``.
+
+ Wyrocznia: gdyby bramka regenerowana w 0489 nie objela ``deleted_at``
+ (np. ktos odwrocil krok 3 migracji), UPDATE ponizej w ogole nie
+ dotarlby do funkcji triggera -- ``po`` zostaloby rowne ``przed``.
+ """
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ pk = wca.pk
+
+ with connection.cursor() as cur:
+ ct = _ct(cur, "wydawnictwo_ciagle")
+
+ przed = _ctid_autor(cur, ct, pk)
+ assert przed is not None, "wiersz powinien byc w bpp_autorzy_mat po INSERT"
+
+ cur.execute(
+ "UPDATE bpp_wydawnictwo_ciagle_autor SET deleted_at = now() WHERE id = %s",
+ [pk],
+ )
+ po = _ctid_autor(cur, ct, pk)
+
+ assert po is None, (
+ "UPDATE samego deleted_at NIE usunal wiersza z bpp_autorzy_mat "
+ f"(ctid {przed} -> {po}) -- bramka WHEN nie zna deleted_at dla *_Autor"
+ )
+
+
+@pytest.mark.django_db
+def test_soft_delete_autor_usuwa_wiersz_z_mat_mimo_reedycji(
+ wydawnictwo_ciagle_z_autorem,
+):
+ """Odpowiednik ``test_soft_delete_usuwa_wiersz_z_mat`` (wyzej, xfail)
+ dla ``*_Autor`` -- TU juz dziala (faza 01, migracja 0489).
+
+ Rozszerzony o krok, ktorego oryginal (dla publikacji) nie mial szans
+ wykonac: PO soft-delete wymuszamy PONOWNE odpalenie triggera zmiana
+ INNEJ, bramkowanej kolumny (``kolejnosc``). Galaz kasujaca w funkcji
+ ``bpp_refresh_autor_wydawnictwo_ciagle`` (0489) jest bezwarunkowa --
+ ``IF NEW.deleted_at IS NOT NULL THEN DELETE ...; RETURN NULL; END IF;``
+ -- wiec kazde kolejne odpalenie triggera na juz skasowanym wierszu
+ tylko PONAWIA DELETE (no-op), nigdy nie dochodzi do upsertu.
+
+ Wyrocznia: gdyby galaz kasujaca nie konczyla sie ``RETURN NULL`` (czyli
+ leciala dalej do upsertu tak jak w mutancie sprzed 0489), drugi UPDATE
+ ponizej przywrocilby wiersz do ``bpp_autorzy_mat`` -- to dokladnie
+ "inwariant delete-first", ktorego brak dla publikacji dokumentuje
+ xfail-owany test wyzej.
+ """
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ pk = wca.pk
+
+ with connection.cursor() as cur:
+ ct = _ct(cur, "wydawnictwo_ciagle")
+ assert _ctid_autor(cur, ct, pk) is not None
+
+ cur.execute(
+ "UPDATE bpp_wydawnictwo_ciagle_autor SET deleted_at = now() WHERE id = %s",
+ [pk],
+ )
+ assert _ctid_autor(cur, ct, pk) is None, "goly UPDATE deleted_at nie skasowal"
+
+ # Wymuszamy PONOWNE odpalenie triggera zmiana INNEJ, bramkowanej
+ # kolumny -- deleted_at nadal NOT NULL.
cur.execute(
- "SELECT count(*) FROM bpp_wydawnictwo_ciagle_view WHERE object_id_raw = %s",
+ "UPDATE bpp_wydawnictwo_ciagle_autor SET kolejnosc = kolejnosc + 100 "
+ "WHERE id = %s",
+ [pk],
+ )
+
+ cur.execute(
+ "SELECT count(*) FROM bpp_wydawnictwo_ciagle_autorzy WHERE (id)[2] = %s",
[pk],
)
w_widoku = cur.fetchone()[0]
- po = _ctid(cur, ct, pk)
+ po = _ctid_autor(cur, ct, pk)
- assert w_widoku == 0, "widok zrodlowy powinien juz nie zwracac wiersza"
- assert po is not None, (
- "wiersz zniknal z bpp_rekord_mat -- funkcja refresh jednak kasuje "
- "przed upsertem (inwariant delete-first zyje)"
+ assert w_widoku == 0, "widok zrodlowy nie powinien zwracac skasowanego wiersza"
+ assert po is None, (
+ "skasowany wiersz WROCIL do bpp_autorzy_mat po edycji innej kolumny "
+ f"-- galaz kasujaca nie jest bezwarunkowa (ctid: {po})"
)
diff --git a/src/bpp/tests/test_soft_delete/test_cache_consistency.py b/src/bpp/tests/test_soft_delete/test_cache_consistency.py
new file mode 100644
index 000000000..ba097956c
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_cache_consistency.py
@@ -0,0 +1,144 @@
+"""Spójność materializowanego cache (bpp_autorzy_mat / model Autorzy) po
+soft-delete wierszy *_Autor (Task 4, faza 01).
+
+UWAGA: zwykły ``django_db`` WYSTARCZA — triggery bazodanowe działają
+wewnątrz transakcji testowej (kanarki ``test_soft_delete_preconditions.py``
+to pokazują: chodzą pod zwykłym ``django_db``, nie ``transactional_db``).
+``transactional_db`` jest tu zbędny i tylko spowalnia (osobny kontener/
+commit na test zamiast rollbacku transakcji).
+"""
+
+import pytest
+from django.contrib.contenttypes.models import ContentType
+
+from bpp.models.cache import Autorzy, AutorzyView
+from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+
+
+def _autorzy_mat_dla(wca):
+ """Wiersze bpp_autorzy_mat (model Autorzy) wskazujące na danego autora
+ w danym rekordzie (kolumna rekord_id = [content_type publikacji, pk
+ publikacji] -- patrz AutorzyManager.filter_rekord i migracja 0432)."""
+ ct = ContentType.objects.get_for_model(type(wca.rekord)).pk
+ return Autorzy.objects.filter(
+ autor_id=wca.autor_id,
+ rekord_id=[ct, wca.rekord_id],
+ )
+
+
+@pytest.mark.django_db
+def test_soft_delete_autorstwa_znika_z_mat(
+ denorms, wydawnictwo_ciagle_z_dwoma_autorami
+):
+ """Soft-delete jednego autorstwa: znika WYŁĄCZNIE ten wiersz, z OBU
+ warstw (widok źródłowy i mat-view), drugi autor pracy zostaje.
+
+ Wyrocznia: gdyby gałąź kasująca w bpp_refresh_autor_wydawnictwo_ciagle
+ zniknęła (albo bramka WHEN przestała znać deleted_at), pierwsza asercja
+ po delete() by padła. Gdyby filtr widoku źródłowego zniknął, druga by
+ padła. Gdyby kaskada usuwała OBA wiersze zamiast jednego (zły klucz),
+ trzecia by padła.
+ """
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ denorms.flush()
+ wca = wc.autorzy_set.first()
+ autor_id = wca.autor_id
+
+ # Przed: autor jest w bpp_autorzy_mat ...
+ assert _autorzy_mat_dla(wca).exists()
+ # ... i w bpp_autorzy (widok źródłowy)
+ assert AutorzyView.objects.filter(autor_id=autor_id).exists()
+
+ drugi_autor_id = wc.autorzy_set.exclude(pk=wca.pk).first().autor_id
+
+ wca.delete() # soft-delete per instancja
+
+ # Po: znika z mat-view (trigger + filtr widoku) ...
+ assert not _autorzy_mat_dla(wca).exists()
+ # ... i z widoku źródłowego (mechanizm #1)
+ assert not AutorzyView.objects.filter(autor_id=autor_id).exists()
+ # Drugi autor pracy NIE zniknął (kaskada jest per-instancja, nie per-rekord)
+ assert Autorzy.objects.filter(autor_id=drugi_autor_id).exists()
+
+
+@pytest.mark.django_db
+def test_restore_autorstwa_wraca_do_mat(denorms, wydawnictwo_ciagle_z_autorem):
+ """restore() na skasowanym autorstwie musi ponownie wstawić wiersz do
+ mat-view. Wyrocznia: gdyby restore() nie odpalał tego samego triggera
+ UPDATE (np. hipotetyczny bulk .update() z pominięciem save()), druga
+ para asercji by padła mimo że deleted_at wróciło do NULL w bazie."""
+ wc = wydawnictwo_ciagle_z_autorem
+ denorms.flush()
+ wca = wc.autorzy_set.first()
+ autor_id = wca.autor_id
+ pk = wca.pk
+
+ wca.delete()
+ assert not Autorzy.objects.filter(autor_id=autor_id).exists()
+
+ Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore()
+
+ # Restore → re-insert do mat-view
+ assert Autorzy.objects.filter(autor_id=autor_id).exists()
+ assert AutorzyView.objects.filter(autor_id=autor_id).exists()
+
+
+@pytest.mark.django_db
+def test_edycja_skasowanego_autorstwa_nie_wskrzesza_w_mat(
+ denorms, wydawnictwo_ciagle_z_autorem
+):
+ """Zapis skasowanego wiersza *_Autor (np. zmiana kolejności) NIE wraca
+ do bpp_autorzy_mat -- widok źródłowy go odfiltrowuje po własnym
+ deleted_at (mechanizm #1), niezależnie od tego, KTÓRA kolumna
+ bramkowana zmieniła się przy tym UPDATE-cie.
+
+ Wyrocznia: gdyby gałąź kasująca w funkcji refresh nie kończyła się
+ ``RETURN NULL`` (czyli leciała dalej do upsertu), ten test by padł --
+ to dokładnie inwariant "delete-first", którego brak dokumentują
+ kanarki w test_soft_delete_preconditions.py dla warstwy publikacji.
+ """
+ wc = wydawnictwo_ciagle_z_autorem
+ denorms.flush()
+ wca = wc.autorzy_set.first()
+ autor_id = wca.autor_id
+ pk = wca.pk
+
+ wca.delete()
+ assert not Autorzy.objects.filter(autor_id=autor_id).exists()
+
+ # Edycja skasowanego wiersza (przez global_objects, bo objects ukrywa)
+ skasowany = Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk)
+ skasowany.kolejnosc = 99
+ skasowany.save() # odpala trigger jako UPDATE z deleted_at NOT NULL
+
+ # Nadal nie ma go w mat-view (kluczowy przypadek brzegowy ze spec §2.1)
+ assert not Autorzy.objects.filter(autor_id=autor_id).exists()
+
+
+@pytest.mark.django_db
+def test_queryset_delete_kaskaduje_per_instancja(
+ denorms, wydawnictwo_ciagle_z_dwoma_autorami
+):
+ """.delete() na QuerySet soft-deletuje per instancję (iterator) --
+ wszystkie wiersze znikają z mat-view, gate na .update() nie blokuje
+ QuerySet.delete() (django-softdelete: SoftDeleteQuerySet.delete()
+ woła obj.delete() w pętli, nie bulk .update()).
+
+ Wyrocznia: gdyby .delete() na queryset trafiał w nasz gate z
+ soft_delete.py (blokadę bulk .update() na deleted_at/restored_at),
+ ten test rzuciłby RuntimeError zamiast przejść. Gdyby kaskada NIE
+ była per-instancja (np. bulk UPDATE z pominięciem triggera), pierwsza
+ asercja po delete() by padła, a druga (fizyczne przetrwanie wierszy)
+ odróżnia to od hard-delete.
+ """
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ denorms.flush()
+ ct = ContentType.objects.get_for_model(type(wc)).pk
+ assert Autorzy.objects.filter(rekord_id=[ct, wc.pk]).count() == 2
+
+ Wydawnictwo_Ciagle_Autor.objects.filter(rekord=wc).delete()
+
+ # Wszystkie autorstwa tej pracy zniknęły z mat-view
+ assert not Autorzy.objects.filter(rekord_id=[ct, wc.pk]).exists()
+ # ... ale wiersze fizycznie żyją (soft, nie hard)
+ assert Wydawnictwo_Ciagle_Autor.global_objects.filter(rekord=wc).count() == 2
From 13622945b8a4eacd33e728a2640e535b32a44c8c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 21:10:44 +0200
Subject: [PATCH 29/75] docs(soft-delete): 89 miejsc produkcyjnych zamiast 128
lacznie
Weryfikacja fazy 01 (Task 5) pokazala 146 wystapien *_Autor.objects, ale
57 z nich jest w testach -- w tym w testach dolozonych przez sama faze 01.
Realny cel audytu fazy 03 to 89 miejsc w kodzie PRODUKCYJNYM. Poprzednia
liczba (najpierw "90" ze specu, potem "128" po moim przeliczeniu) mieszala
kod produkcyjny z testowym, wiec audytor dostalby zawyzony zakres i mogl
zmarnowac czas na pliki testowe.
Dopisana komenda kontrolna z filtrem na testy, zeby liczba dala sie
odtworzyc w momencie startu fazy 03 (bedzie rosla wraz z kolejnymi fazami,
ktore dokladaja wlasne testy).
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
.../2026-06-04-soft-delete-00-overview.md | 2 +-
...6-04-soft-delete-01-autor-trigger-widoki.md | 6 +++++-
...6-06-04-soft-delete-03-audyt-kategorii-b.md | 6 +++---
...2026-06-04-soft-delete-08-testy-regresji.md | 3 ++-
...-soft-delete-publikacje-i-autorzy-design.md | 18 +++++++++---------
5 files changed, 20 insertions(+), 15 deletions(-)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
index f9dedf8c7..76468a60f 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md
@@ -18,7 +18,7 @@
|---|---|---|---|
| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; widoki `bpp_*_autorzy` + **gałąź kasująca w funkcjach refresh** + **regeneracja bramki `WHEN`**; spójność weryfikowana surowym SQL-em (NIE `full_refresh()`) | — |
| 02 | `2026-06-04-soft-delete-02-publikacje.md` | 5 modeli → SoftDeleteModel; override `delete()`/`restore()` z wąską kaskadą na `*_Autor`; `slug` warunkowy unique; przeplecenie menedżerów | 01 |
-| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 128 miejsc `*_Autor.objects` | 02 |
+| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 89 miejsc produkcyjnych `*_Autor.objects` (146 wystąpień łącznie, 57 w testach) | 02 |
| 04 | `2026-06-04-soft-delete-04-guardy-protect.md` | flip FK `CASCADE→PROTECT` (autor, doktorat, `wydawnictwo_nadrzedne`); guard w soft `delete()` (autor + książka-matka); soft-delete husku autora | 02 |
| 05 | `2026-06-04-soft-delete-05-pbn-wycofanie.md` | prymityw `wycofaj_oswiadczenia()`; `pbn_export_queue.operacja = WYSYLKA\|WYCOFANIE` (wejście async) + wywołanie bezpośrednie (sync); restore → `WYSYLKA`; integracja `SentData` | 02 |
| 06 | `2026-06-04-soft-delete-06-softdeletelog.md` | model `SoftDeleteLog`; receivery `post_soft_delete`/`post_restore`/`post_hard_delete`; wstrzykiwanie `user`; **kasowanie/przeliczanie `Cache_Punktacja_*`** | 02, 05 |
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
index 121b9fc43..c510d7d25 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
@@ -1134,7 +1134,11 @@ Co-Authored-By: Claude Opus 4.8 (1M context) "
## Założenia i ostrzeżenia między-fazowe (dla faz 02+)
-1. **Domyślny manager `*_Autor.objects` zmienił klasę** na `BppSoftDeleteManager` (filtruje `deleted_at__isnull=True`). Faza 03 (audyt kat. B) MUSI przejść **128** miejsc `*_Autor.objects` (stan 2026-08-06; spec mówił „90" — przelicz przed startem fazy 03: `grep -rn --include='*.py' -E "(Wydawnictwo_Ciagle_Autor|Wydawnictwo_Zwarte_Autor|Patent_Autor)\.objects" src/ | wc -l`). W fazie 01 nic nie jest skasowane, więc filtr jest no-op, ale od fazy 02 (kaskada soft-delete publikacji) zacznie ukrywać. Guard autora (faza 04) MUSI liczyć przez `global_objects` (spec §3.2).
+1. **Domyślny manager `*_Autor.objects` zmienił klasę** na `BppSoftDeleteManager` (filtruje `deleted_at__isnull=True`). Faza 03 (audyt kat. B) MUSI przejść **89 miejsc w kodzie produkcyjnym** czyta `*_Autor.objects` bezpośrednio (stan 2026-08-06; łącznie 146 wystąpień, z czego 57 w testach — audyt fazy 03 dotyczy wyłącznie kodu produkcyjnego). Komenda kontrolna:
+```bash
+P='(Wydawnictwo_Ciagle_Autor|Wydawnictwo_Zwarte_Autor|Patent_Autor)\.objects'
+grep -rEn --include='*.py' "$P" src/ | grep -vcE '/tests?/|test_'
+```. W fazie 01 nic nie jest skasowane, więc filtr jest no-op, ale od fazy 02 (kaskada soft-delete publikacji) zacznie ukrywać. Guard autora (faza 04) MUSI liczyć przez `global_objects` (spec §3.2).
2. **Faza 02 powtarza ten sam trójskładnikowy wzorzec dla 5 tabel publikacji**: filtr `deleted_at` w `bpp_*_view` → gałąź kasująca w `bpp_refresh_rekord_()` (`DELETE FROM bpp_rekord_mat`) → regeneracja bramki `WHEN`. Uwaga na doktorat/habilitację: ich funkcje refresh dotykają **obu** tabel `_mat` (`bpp_rekord_mat` i `bpp_autorzy_mat`, bo autor leży na wierszu publikacji) — gałąź kasująca musi czyścić obie.
3. **Każda przyszła zmiana definicji widoku źródłowego wymaga regeneracji bramki `WHEN`.** Bramka jest wypiekana z `pg_depend` w momencie migracji, więc nie zaktualizuje się sama. Pominięcie = cichy staleness. Testy `test_views_sql.py` i kanarki `test_soft_delete_preconditions.py` to wyłapią.
3. **Widoki `bpp_praca_doktorska_autorzy` / `bpp_praca_habilitacyjna_autorzy` NIE filtrowane** — autorstwo doktoratu/habilitacji nie jest `*_Autor` SoftDeleteModel (autor doktoratu to FK `Praca_Doktorska.autor`, nie through). Faza 02 (soft-delete publikacji doktorat/habilitacja) musi zadbać o ich zniknięcie z `bpp_rekord` przez własne `deleted_at` na tabeli publikacji — to NIE jest pokryte tą fazą.
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md
index e798d0c35..44549aa97 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md
@@ -61,7 +61,7 @@
- `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`
**Bez zmian (decyzja audytu udokumentowana w planie):**
-- 128 miejsc `*_Autor.objects` w ewaluacji / API / przemapuj — patrz Task 7.
+- 89 miejsc produkcyjnych `*_Autor.objects` w ewaluacji / API / przemapuj — patrz Task 7.
- `src/komparator_pbn/views.py`, `src/snapshot_odpiec/tasks.py`,
`src/ewaluacja_dwudyscyplinowcy/core.py` — patrz Task 7.
@@ -661,7 +661,7 @@ git commit -m "fix(soft-delete): pbn_import czysci publikacje przez hard_delete
---
-## Task 7: Audyt 128 miejsc `*_Autor.objects` — decyzje (zostaw `objects` / zmień na `global_objects`)
+## Task 7: Audyt 89 miejsc produkcyjnych `*_Autor.objects` — decyzje (zostaw `objects` / zmień na `global_objects`)
Po fazie 02 `*_Autor.objects` ukrywa kaskadowo soft-deletowane autorstwa
(kaskada §2.2). To jest **poprawny default dla ewaluacji** (praca w koszu nie
@@ -947,7 +947,7 @@ git commit -m "chore(soft-delete): faza 03 audyt kat. B — format/lint"
rekordach, nie matching tworzący duplikaty; **bez zmian**.
- `pbn_import/utils/publication_import.py:115-116` jawny `.hard_delete()` →
Task 6. ✓
-- Audyt 128 miejsc `*_Autor.objects` → Task 7 (decyzje per-miejsce; jedyna
+- Audyt 89 miejsc produkcyjnych `*_Autor.objects` → Task 7 (decyzje per-miejsce; jedyna
zmiana: merge → `global_objects`). ✓
- Testy wymagane przez zlecenie: re-import nie tworzy duplikatu (Task 1-4),
matching po `pbn_uid` znajduje soft-deletowaną (Task 1-3), ewaluacja pomija
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
index 63dc408b4..491bd2d55 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md
@@ -783,7 +783,8 @@ je ukrywa).
```python
"""Regresja ewaluacji (spec §2.5, §3 fazy).
-128 miejsc czyta ``*_Autor.objects`` bezpośrednio; po wpięciu
+89 miejsc w kodzie produkcyjnym czyta ``*_Autor.objects``
+bezpośrednio (146 wystąpień łącznie, 57 w testach); po wpięciu
``SoftDeleteModel`` domyślny menedżer ukrywa kaskadowo-skasowane
autorstwa, więc ewaluacja pomija prace w koszu. Restore przywraca punktację.
"""
diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
index 151f47662..1061bf2d6 100644
--- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
+++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
@@ -67,11 +67,11 @@ i `Patent_Autor` **stają się `SoftDeleteModel`** — ale wyłącznie jako cel
**wąskiej kaskady** z soft-delete publikacji (§2.2), NIE pełnego refleksyjnego
Projektu B. Pozostałe dzieci (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`,
`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`) zostają nie-soft —
-kaskada zatrzymuje się na `*_Autor` i **nie jest wirusowa**. Powód: 128
-bezpośrednich zapytań `*_Autor.objects` w kodzie (większość w
+kaskada zatrzymuje się na `*_Autor` i **nie jest wirusowa**. Powód: 89
+bezpośrednich, produkcyjnych zapytań `*_Autor.objects` w kodzie (większość w
`ewaluacja_optymalizacja` — najwrażliwszy korekcyjnie podsystem) — domyślny
menedżer `objects` po wpięciu `SoftDeleteModel` czyni je poprawnymi
-automatycznie, eliminując 128-punktowe ryzyko „silent leak" do ewaluacji.
+automatycznie, eliminując 89-punktowe ryzyko „silent leak" do ewaluacji.
**Dlaczego nie kaskada autor→prace ani „guard z 50 publikacjami":**
realny przypadek użycia kasowania autora jest wąski — to wyłącznie puste /
@@ -235,9 +235,9 @@ wszystko z `_mat` na podstawie własnych `deleted_at`; przy restore
re-projektuje ze źródła.
Po co jawna kaskada na `*_Autor`, skoro trigger i tak czyści `bpp_autorzy_mat`?
-Bo **128 miejsc w kodzie czyta `*_Autor.objects` bezpośrednio** (z pominięciem
+Bo **89 miejsc w kodzie produkcyjnym czyta `*_Autor.objects` bezpośrednio** (z pominięciem
cache), głównie w `ewaluacja_optymalizacja`. Domyślny menedżer `objects`
-`SoftDeleteModel` ukrywa skasowane → te 128 miejsc staje się poprawnych
+`SoftDeleteModel` ukrywa skasowane → te 89 miejsc staje się poprawnych
automatycznie, bez ręcznych filtrów `wydawnictwo_ciagle__deleted_at__isnull`
(których pominięcie = po cichu zliczona skasowana praca w ewaluacji).
@@ -372,8 +372,8 @@ ewaluacji** — czyli dokładnie ten „silent leak", przed którym broni §2.2,
> odczytu ani pilnowania każdego nowego. Koszt: restore staje się operacją
> liczącą (nie samym `UPDATE`), co trzeba uwzględnić w adminie (może trwać).
-**Through-modele `*_Autor` (128 miejsc).** Po wpięciu `SoftDeleteModel`
-128 bezpośrednich zapytań `*_Autor.objects` (głównie `ewaluacja_optymalizacja`:
+**Through-modele `*_Autor` (89 miejsc produkcyjnych).** Po wpięciu `SoftDeleteModel`
+89 bezpośrednich, produkcyjnych zapytań `*_Autor.objects` (głównie `ewaluacja_optymalizacja`:
`reset_pins`, `reset_disciplines`, `unpin_all_sensible`, `optimization`,
`author_works`, `evaluation_browser`, `verification`; oraz `api_v1`,
`przemapuj_prace_autora`, `ewaluacja_dwudyscyplinowcy`) **staje się poprawne
@@ -627,7 +627,7 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
warunkowe `UniqueConstraint` na `*_Autor` (§2.2b), przeplecenie menedżerów,
powtórzenie kroków 1(b)–1(d) dla 5 widoków publikacji i ich funkcji.
3. **Audyt kat. B** — przełączenie import/dedup/PBN-matching na
- `global_objects`; audyt 128 miejsc `*_Autor.objects` (default „pomijaj"
+ `global_objects`; audyt 89 miejsc produkcyjnych `*_Autor.objects` (146 wystąpień łącznie, 57 w testach) (default „pomijaj"
poprawny, wyjątki → `global_objects`). Testy: re-import nie tworzy
duplikatów; ewaluacja pomija prace w koszu.
4. **Guardy PROTECT** (ten sam wzorzec: flip FK + guard liczący przez
@@ -694,7 +694,7 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
soft-deletuje rodzica i jego `*_Autor` (wspólny `transaction_id`), bez
refleksyjnej kaskady na pozostałe dzieci. Trigger jako choke-point,
jednolity dzięki własnym `deleted_at` na wszystkich 8 tabelach (bez JOIN
- do rodzica). Powód kaskady: 128 miejsc `*_Autor.objects` w ewaluacji.
+ do rodzica). Powód kaskady: 89 miejsc produkcyjnych `*_Autor.objects` w ewaluacji.
3. **PBN przy soft-delete:** wycofanie oświadczeń instytucji
(`delete_all_publication_statements`), gate na `pbn_uid`; obiektu
publikacji nie kasujemy.
From 00e8173c886476ce415427ccdd170969b7f4c115 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 21:59:20 +0200
Subject: [PATCH 30/75] test(soft-delete): padajace testy 3 blokerow widokow
pochodnych
Recenzja finalna galezi fazy 01 znalazla trzy rodziny widokow SQL, ktore
po 0489 wciaz czytaja SUROWA tabele *_autor (bez `deleted_at IS NULL`).
`delete()` jest miekki od pierwszej sekundy po wdrozeniu -- nie ma feature
flagi -- wiec kazdy z tych wyciekow jest aktywny od dnia 1.
Testy (na razie CZERWONE, poprawki w kolejnych commitach):
1. `bpp__view` (0421) -- `count(*_autor.autor_id) AS liczba_autorow`
po surowej tabeli. `Rekord.liczba_autorow` = 2 przy jednym zywym
autorstwie. Skutek: multiseek "Ostatnie nazwisko i imie" filtruje
`kolejnosc IN [liczba_autorow-1, liczba_autorow)` -- przy zawyzonej
wartosci celuje w pozycje, na ktorej nikogo nie ma i cicho zwraca pustke.
2. `bpp_nowe_sumy_*_view` (0458) -- zrodlo rankingu autorow. Skasowane
autorstwo dalej wnosi punkty; deduplikator autorow (kasuje zrodlowe
autorstwa) zostawialby duplikat z punktami w rankingu na zawsze.
3. `rozbieznosci_dyscyplin_rozbieznoscizrodelview` (0017, przeladowany
przez 0018/0019/0020) -- raport pokazuje rozbieznosci dla autorstw,
ktorych nie ma juz w interfejsie (nie da sie ich "naprawic").
---
src/bpp/tests/test_audyt_konsumentow.py | 23 +++++
.../test_soft_delete/test_widoki_pochodne.py | 94 +++++++++++++++++++
.../tests/test_soft_delete.py | 49 ++++++++++
3 files changed, 166 insertions(+)
create mode 100644 src/bpp/tests/test_audyt_konsumentow.py
create mode 100644 src/bpp/tests/test_soft_delete/test_widoki_pochodne.py
create mode 100644 src/rozbieznosci_dyscyplin/tests/test_soft_delete.py
diff --git a/src/bpp/tests/test_audyt_konsumentow.py b/src/bpp/tests/test_audyt_konsumentow.py
new file mode 100644
index 000000000..026dae7cc
--- /dev/null
+++ b/src/bpp/tests/test_audyt_konsumentow.py
@@ -0,0 +1,23 @@
+import re
+import pytest
+from django.db import connection
+
+TABELE = ("bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_zwarte_autor", "bpp_patent_autor")
+
+
+def _czyta(t):
+ return any(re.search(rf"\b{x}\b(?!zy)", t) for x in TABELE)
+
+
+@pytest.mark.django_db
+def test_audyt():
+ with connection.cursor() as cur:
+ cur.execute("SELECT viewname, definition FROM pg_views WHERE schemaname='public'")
+ widoki = cur.fetchall()
+ brudne = [(n, "deleted_at" in d) for n, d in widoki if _czyta(d)]
+ bez = [n for n, f in brudne if not f]
+ linie = [f"WIDOKOW OGOLEM: {len(widoki)}", f"CZYTA SUROWA *_autor: {len(brudne)}", f"BEZ FILTRA: {len(bez)}"]
+ linie += [f" BRAK: {n}" for n in sorted(bez)]
+ linie += [f" ok: {n}" for n, f in sorted(brudne) if f]
+ open("/tmp/audyt.txt", "w").write("\n".join(linie))
+ assert True
diff --git a/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py b/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py
new file mode 100644
index 000000000..a2764f40a
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py
@@ -0,0 +1,94 @@
+"""Widoki POCHODNE (spoza rodziny ``bpp_*_autorzy``) po soft-delete autorstwa.
+
+Faza 01 przefiltrowała widoki ``bpp_*_autorzy`` (migracja 0489) — ale to nie
+jedyne miejsce, gdzie SQL czyta SUROWĄ tabelę ``*_autor``. Ponieważ
+``delete()`` jest miękki od pierwszej sekundy po wdrożeniu (żadnej feature
+flagi), każdy taki widok wycieka skasowane autorstwa od dnia 1.
+
+Ten moduł pilnuje dwóch takich rodzin widoków:
+
+1. ``bpp__view`` (migracja 0421) — ``count(*_autor.autor_id) AS
+ liczba_autorow``; karmi ``bpp_rekord_mat`` (model ``Rekord``). Zawyżona
+ ``liczba_autorow`` cicho psuje multiseek: kryterium „Ostatnie nazwisko i
+ imię" filtruje ``kolejnosc ∈ [liczba_autorow-1, liczba_autorow)``, więc
+ przy zawyżonej wartości celuje w pozycję, na której nikogo nie ma
+ (``bpp/multiseek_registry/fields/author_fields.py``).
+2. ``bpp_nowe_sumy_*_view`` (migracja 0458) — sumy punktów per autor,
+ źródło rankingu autorów (``ranking_autorow.views``). Wyciek = zdublowany
+ (i skasowany) autor trzyma punkty w rankingu na zawsze.
+"""
+
+import pytest
+
+from bpp.models.cache import Rekord
+from bpp.models.sumy_views import Sumy
+
+
+@pytest.mark.django_db
+def test_liczba_autorow_nie_liczy_soft_deletowanych(
+ denorms, wydawnictwo_ciagle_z_dwoma_autorami
+):
+ """``Rekord.liczba_autorow`` po soft-delete = liczba ŻYWYCH autorstw.
+
+ Wyrocznia: gdyby ``bpp_wydawnictwo_ciagle_view`` liczyło
+ ``count(bpp_wydawnictwo_ciagle_autor.autor_id)`` po surowej tabeli (stan
+ sprzed poprawki), ostatnia asercja zwróciłaby 2 przy jednym żywym
+ autorstwie.
+
+ ``denorms.flush()`` po ``delete()`` jest KONIECZNY: trigger na tabeli
+ ``*_autor`` odświeża tylko ``bpp_autorzy_mat``; ``liczba_autorow`` w
+ ``bpp_rekord_mat`` przelicza się dopiero, gdy flush denorm dotknie
+ wiersza publikacji (patrz komentarz przy polu ``Rekord.liczba_autorow``).
+ Tak samo działa to przy TWARDYM kasowaniu autorstwa — to nie jest
+ regresja soft-delete.
+ """
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ denorms.flush()
+
+ assert wc.autorzy_set.count() == 2
+ assert Rekord.objects.get_for_model(wc).liczba_autorow == 2
+
+ wc.autorzy_set.first().delete() # soft-delete
+ denorms.flush()
+
+ assert wc.autorzy_set.count() == 1
+ assert Rekord.objects.get_for_model(wc).liczba_autorow == 1
+
+
+@pytest.mark.django_db
+def test_sumy_rankingu_nie_licza_soft_deletowanych(wydawnictwo_ciagle_z_autorem):
+ """``Sumy`` (``bpp_nowe_sumy_view``) nie widzą skasowanego autorstwa.
+
+ Wyrocznia: gdyby ``bpp_nowe_sumy_wydawnictwo_ciagle_view`` joinowało
+ surową ``bpp_wydawnictwo_ciagle_autor`` (stan sprzed poprawki), druga
+ asercja by padła — a to dokładnie ten wiersz, po którym ranking autorów
+ sumuje punkty. Deduplikator autorów przenosi autorstwa i kasuje źródłowe
+ (od fazy 01 — miękko), więc bez tego filtra zduplikowany autor trzymałby
+ punkty w rankingu bezterminowo.
+ """
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ autor_id = wca.autor_id
+
+ assert Sumy.objects.filter(autor_id=autor_id).exists()
+
+ wca.delete() # soft-delete
+
+ assert not Sumy.objects.filter(autor_id=autor_id).exists()
+
+
+@pytest.mark.django_db
+def test_sumy_rankingu_wracaja_po_restore(wydawnictwo_ciagle_z_autorem):
+ """Filtr w ``bpp_nowe_sumy_*`` jest odwracalny: ``restore()`` przywraca
+ punkty do rankingu. Wyrocznia dla poprawki nadgorliwej (np. filtr po
+ ``restored_at`` zamiast ``deleted_at``)."""
+ from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor
+
+ wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first()
+ autor_id, pk = wca.autor_id, wca.pk
+
+ wca.delete()
+ assert not Sumy.objects.filter(autor_id=autor_id).exists()
+
+ Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore()
+
+ assert Sumy.objects.filter(autor_id=autor_id).exists()
diff --git a/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py b/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py
new file mode 100644
index 000000000..5854c9f0f
--- /dev/null
+++ b/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py
@@ -0,0 +1,49 @@
+"""Raport rozbieżności dyscyplin źródeł a soft-delete autorstw.
+
+``rozbieznosci_dyscyplin_rozbieznoscizrodelview`` (migracja 0017, przeładowana
+przez 0018/0019/0020) czyta SUROWĄ tabelę ``bpp_wydawnictwo_ciagle_autor``.
+Bez filtra ``deleted_at IS NULL`` raport pokazuje rozbieżności dyscyplin dla
+autorstw, których w systemie już nie ma — a użytkownik nie ma jak ich
+„naprawić", bo w interfejsie nie istnieją.
+"""
+
+import pytest
+from model_bakery import baker
+
+from bpp.models import Dyscyplina_Zrodla, Wydawnictwo_Ciagle
+from rozbieznosci_dyscyplin.models import RozbieznosciZrodelView
+
+
+@pytest.fixture
+def rozbieznosc_zrodla(
+ autor_z_dyscyplina,
+ rok,
+ zrodlo,
+ dyscyplina1,
+ dyscyplina2,
+ jednostka,
+ typy_odpowiedzialnosci,
+):
+ """Autorstwo z dyscypliną SPOZA listy dyscyplin źródła — czyli dokładnie
+ to, co raportuje ``RozbieznosciZrodelView``."""
+ Dyscyplina_Zrodla.objects.create(rok=rok, zrodlo=zrodlo, dyscyplina=dyscyplina2)
+ wc = baker.make(Wydawnictwo_Ciagle, rok=rok, zrodlo=zrodlo)
+ return wc.dodaj_autora(
+ autor_z_dyscyplina.autor, jednostka, dyscyplina_naukowa=dyscyplina1
+ )
+
+
+@pytest.mark.django_db
+def test_rozbieznosci_zrodel_pomijaja_soft_deletowane(rozbieznosc_zrodla):
+ """Wyrocznia: bez ``deleted_at IS NULL`` w definicji widoku druga
+ asercja by padła — skasowane autorstwo dalej generowałoby wiersz
+ raportu."""
+ assert RozbieznosciZrodelView.objects.filter(
+ autor_id=rozbieznosc_zrodla.autor_id
+ ).exists()
+
+ rozbieznosc_zrodla.delete() # soft-delete
+
+ assert not RozbieznosciZrodelView.objects.filter(
+ autor_id=rozbieznosc_zrodla.autor_id
+ ).exists()
From 0e8bff7bc1ab21d1e46ecd21307a0f30a0de17cb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 22:05:00 +0200
Subject: [PATCH 31/75] chore: usun plik tymczasowy audytu, ktory wpadl do
commita
test_audyt_konsumentow.py byl jednorazowa sonda kontrolera (inwentaryzacja
widokow czytajacych surowe tabele *_autor), tworzona w tym samym worktree,
w ktorym rownolegle pracowal agent naprawczy. Agent zgarnal go swoim
git add do commita 00e8173c8.
Sonda nie jest testem regresyjnym i nie ma czego pilnowac -- jej wynik jest
zapisany w ledgerze SDD. Usuwam.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
src/bpp/tests/test_audyt_konsumentow.py | 23 -----------------------
1 file changed, 23 deletions(-)
delete mode 100644 src/bpp/tests/test_audyt_konsumentow.py
diff --git a/src/bpp/tests/test_audyt_konsumentow.py b/src/bpp/tests/test_audyt_konsumentow.py
deleted file mode 100644
index 026dae7cc..000000000
--- a/src/bpp/tests/test_audyt_konsumentow.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import re
-import pytest
-from django.db import connection
-
-TABELE = ("bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_zwarte_autor", "bpp_patent_autor")
-
-
-def _czyta(t):
- return any(re.search(rf"\b{x}\b(?!zy)", t) for x in TABELE)
-
-
-@pytest.mark.django_db
-def test_audyt():
- with connection.cursor() as cur:
- cur.execute("SELECT viewname, definition FROM pg_views WHERE schemaname='public'")
- widoki = cur.fetchall()
- brudne = [(n, "deleted_at" in d) for n, d in widoki if _czyta(d)]
- bez = [n for n, f in brudne if not f]
- linie = [f"WIDOKOW OGOLEM: {len(widoki)}", f"CZYTA SUROWA *_autor: {len(brudne)}", f"BEZ FILTRA: {len(bez)}"]
- linie += [f" BRAK: {n}" for n in sorted(bez)]
- linie += [f" ok: {n}" for n, f in sorted(brudne) if f]
- open("/tmp/audyt.txt", "w").write("\n".join(linie))
- assert True
From 686ccf5aed38ab4c42da31c60e1b50d2aefaaf18 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 22:11:27 +0200
Subject: [PATCH 32/75] refactor(soft-delete): czesciowy indeks, redundantny
unique, rozbicie 0490
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Trzy poprawki "przy okazji" z recenzji finalnej. Wszystkie dotykaja migracji
0488/0490, ktore sa NIEWYPCHNIETE (remote stoi na 7119a766f) -- wolno je
poprawiac w miejscu.
1. Indeks `deleted_at` -> CZESCIOWY (`WHERE deleted_at IS NOT NULL`).
Pelny btree byl martwym kosztem: predykat `deleted_at IS NULL` pasuje do
~100% wierszy, wiec planner i tak nigdy by go nie wybral, a indeks
zostawal narzutem przy kazdym INSERT/UPDATE autorstwa. Selektywne jest
zapytanie ODWROTNE (`deleted_objects`) -- i to ono dostaje mikroskopijny
indeks. Poprawiony tez mylacy fragment docstringa 0489 ("pelny skan
przyspiesza dzieki indeksowi" -- artefakt pomiaru na pustej bazie).
2. Usuniety `*_uniq_rekord_autor_kolejnosc` (3 modele + migracja).
`ExclusionConstraint(rekord, kolejnosc)` nie patrzy na autora, wiec jest
sciśle silniejszy -- unique po (rekord, autor, kolejnosc) byl w 100%
redundantny: trzeci build indeksu w oknie serwisowym i staly narzut na
najgoretszej sciezce zapisu. Zaktualizowany docstring walidacji formsetu
w `bpp/admin/core.py` (pokrywa juz DWA, nie trzy constrainty).
3. Migracja 0490 rozbita na 0490-0493, kazda z `atomic = False`.
`ADD CONSTRAINT ... EXCLUDE USING GIST` bierze ACCESS EXCLUSIVE (blokuje
takze ODCZYTY) i nie ma wariantu wspolbieznego; w jednej transakcji
blokady z trzech tabel wisialyby az do COMMIT. Kolejnosc krokow dobrana
tak, ze NIE MA okna bez ochrony unikalnosci: najpierw wszystko dodajace
(indeks FK / unique po typie / exclusion po kolejnosci), na koncu
zdjecie starego `unique_together` i legacy constraintu z 0132.
Test odwracalnosci przemianowany na `test_migracje_0490_0493_rewers.py`;
wraca teraz do NAJNOWSZEJ migracji `bpp` zamiast do konkretnego numeru
(baza testowa jest wspoldzielona przez caly przebieg -- `--reuse-db`).
---
src/bpp/admin/core.py | 11 +-
.../0488_autor_soft_delete_fields.py | 12 ++
.../0489_soft_delete_autorzy_views.py | 13 +-
.../migrations/0490_autor_indeks_fk_rekord.py | 78 +++++++
.../migrations/0490_autor_warunkowy_unique.py | 202 ------------------
.../0491_autor_unique_rekord_autor_typ.py | 54 +++++
.../0492_autor_excl_rekord_kolejnosc.py | 62 ++++++
.../0493_autor_zdjecie_starych_unique.py | 87 ++++++++
src/bpp/models/patent.py | 17 +-
src/bpp/models/wydawnictwo_ciagle.py | 27 ++-
src/bpp/models/wydawnictwo_zwarte.py | 17 +-
...s.py => test_migracje_0490_0493_rewers.py} | 53 +++--
12 files changed, 381 insertions(+), 252 deletions(-)
create mode 100644 src/bpp/migrations/0490_autor_indeks_fk_rekord.py
delete mode 100644 src/bpp/migrations/0490_autor_warunkowy_unique.py
create mode 100644 src/bpp/migrations/0491_autor_unique_rekord_autor_typ.py
create mode 100644 src/bpp/migrations/0492_autor_excl_rekord_kolejnosc.py
create mode 100644 src/bpp/migrations/0493_autor_zdjecie_starych_unique.py
rename src/bpp/tests/test_soft_delete/{test_migracja_0490_rewers.py => test_migracje_0490_0493_rewers.py} (56%)
diff --git a/src/bpp/admin/core.py b/src/bpp/admin/core.py
index 0f9e5a5c4..a59ed1990 100644
--- a/src/bpp/admin/core.py
+++ b/src/bpp/admin/core.py
@@ -376,12 +376,11 @@ def _waliduj_kolizje_autorstwa_w_formsecie(formset):
warunkowe, są tam pomijane. Stąd ręczne porównanie par formularzy
widocznych w formsecie (bez zapytań do bazy — `formset.instance`, czyli
rodzic/rekord, jest wspólny dla wszystkich wierszy formsetu z definicji
- inline). Dwa klucze pokrywają WSZYSTKIE TRZY constrainty z Meta:
- (autor, typ) ↔ `..._uniq_rekord_autor_typ`; goła `kolejnosc` (bez
- autora) ↔ zarówno `..._uniq_rekord_autor_kolejnosc` (ten sam autor z
- dwoma wpisami o tej samej kolejności), jak i `..._excl_rekord_kolejnosc`
- (DWÓCH RÓŻNYCH autorów na tej samej pozycji) — goła `kolejnosc` jest
- ściślejsza (autor nieistotny), więc pokrywa oba naraz."""
+ inline). Dwa klucze pokrywają OBA constrainty z Meta: (autor, typ) ↔
+ `..._uniq_rekord_autor_typ`; goła `kolejnosc` (bez autora) ↔
+ `..._excl_rekord_kolejnosc`, który tak samo nie patrzy na autora — łapie
+ więc zarówno „ten sam autor dwa razy na tej samej pozycji", jak i „dwóch
+ RÓŻNYCH autorów na tej samej pozycji"."""
# UWAGA: `formset.deleted_forms` NIE nadaje się tu do użytku — jego
# własna implementacja zaczyna od `if not self.is_valid(): return []`.
# Wołane z wnętrza `clean()` (a więc z wnętrza `full_clean()`), gdy
diff --git a/src/bpp/migrations/0488_autor_soft_delete_fields.py b/src/bpp/migrations/0488_autor_soft_delete_fields.py
index 31263d767..3a1cfc20d 100644
--- a/src/bpp/migrations/0488_autor_soft_delete_fields.py
+++ b/src/bpp/migrations/0488_autor_soft_delete_fields.py
@@ -1,6 +1,15 @@
# Generated by Django 5.2.16 on 2026-08-06 12:48
+#
+# Indeksy na `deleted_at` są CZĘŚCIOWE (`WHERE deleted_at IS NOT NULL`).
+# Pełny btree byłby tu martwym kosztem: `deleted_at IS NULL` pasuje do ~100%
+# wierszy, więc planner pod ten predykat i tak wybierze seq scan, a indeks
+# zostawałby wyłącznie narzutem (rozmiar rzędu tabeli + wpis przy każdym
+# INSERT/UPDATE autorstwa). Selektywne jest zapytanie ODWROTNE —
+# `deleted_objects` (`deleted_at IS NOT NULL`) — i to jemu służy ten
+# mikroskopijny indeks. Uzasadnienie w `Meta.indexes` modeli.
from django.db import migrations, models
+from django.db.models import Q
class Migration(migrations.Migration):
@@ -57,6 +66,7 @@ class Migration(migrations.Migration):
migrations.AddIndex(
model_name="wydawnictwo_ciagle_autor",
index=models.Index(
+ condition=Q(("deleted_at__isnull", False)),
fields=["deleted_at"],
name="wc_autor_deleted_at_idx",
),
@@ -64,6 +74,7 @@ class Migration(migrations.Migration):
migrations.AddIndex(
model_name="wydawnictwo_zwarte_autor",
index=models.Index(
+ condition=Q(("deleted_at__isnull", False)),
fields=["deleted_at"],
name="wz_autor_deleted_at_idx",
),
@@ -71,6 +82,7 @@ class Migration(migrations.Migration):
migrations.AddIndex(
model_name="patent_autor",
index=models.Index(
+ condition=Q(("deleted_at__isnull", False)),
fields=["deleted_at"],
name="patent_autor_deleted_at_idx",
),
diff --git a/src/bpp/migrations/0489_soft_delete_autorzy_views.py b/src/bpp/migrations/0489_soft_delete_autorzy_views.py
index fe8249b75..b0f0ae286 100644
--- a/src/bpp/migrations/0489_soft_delete_autorzy_views.py
+++ b/src/bpp/migrations/0489_soft_delete_autorzy_views.py
@@ -79,9 +79,16 @@ def _filtruj_widok(cur, tabela, widok):
Owijka NIE zachowuje planu (dokłada anti-join i sondę po pkey na każdy
wiersz), więc — zgodnie z instrukcją planu na taki właśnie wypadek —
dopisujemy ``deleted_at IS NULL`` do ``WHERE`` wewnętrznego. Ten wariant
- daje plan **identyczny** z oryginałem na hot-pathie (Index Scan, 7.35), a
- pełny skan wręcz przyspiesza (2.36) — trafia w indeks na ``deleted_at``
- założony w 0488.
+ daje plan **identyczny** z oryginałem na hot-pathie (Index Scan, 7.35).
+
+ ⚠️ Pomiar pełnego skanu z tabeli wyżej został zebrany na PUSTEJ bazie
+ testowej i nie uogólnia się: pierwotnie zanotowano tam „2.36 — trafia w
+ indeks na ``deleted_at``". Na produkcji tak NIE będzie. ``deleted_at IS
+ NULL`` pasuje do ~100% wierszy, więc planner pod ten predykat wybierze
+ seq scan, a nie indeks — i właśnie dlatego indeks z 0488 jest CZĘŚCIOWY
+ (``WHERE deleted_at IS NOT NULL``, pod ``deleted_objects``). Filtr
+ wewnętrzny wybieramy z powodu hot-pathu triggera, nie z powodu pełnego
+ skanu.
⚠️ KLUCZ (pułapka, na której przejechał się pierwotny plan): w widokach
``*_autorzy`` kolumna ``object_id_raw`` to ``rekord_id``, czyli **id
diff --git a/src/bpp/migrations/0490_autor_indeks_fk_rekord.py b/src/bpp/migrations/0490_autor_indeks_fk_rekord.py
new file mode 100644
index 000000000..f6fe648a6
--- /dev/null
+++ b/src/bpp/migrations/0490_autor_indeks_fk_rekord.py
@@ -0,0 +1,78 @@
+"""Task 3c, krok 1/4: zwykły indeks na FK ``rekord`` w trzech ``*_Autor``.
+
+Task 3c był pierwotnie JEDNĄ migracją (0490). Rozbity jest na cztery
+(0490-0493), a każda ma ``atomic = False``. Powód:
+
+- ``ADD CONSTRAINT ... EXCLUDE USING GIST`` bierze ``ACCESS EXCLUSIVE`` na
+ tabeli (blokuje także ODCZYTY) i nie ma wariantu współbieżnego. W jednej
+ transakcji WSZYSTKIE takie blokady — z trzech największych tabel through —
+ wisiałyby aż do ``COMMIT``, czyli przez czas budowy wszystkich dziewięciu
+ indeksów naraz. ``atomic = False`` sprawia, że każde ``ALTER TABLE``
+ commituje się osobno i natychmiast oddaje blokadę.
+- Rozbicie na pliki daje dodatkowo granulację odzyskiwania: przerwana
+ migracja zostawia zapisane w ``django_migrations`` te kroki, które się
+ udały.
+
+KOLEJNOŚĆ jest dobrana tak, żeby NIE BYŁO OKNA bez ochrony unikalności:
+najpierw wszystko, co DODAJE (0490 indeks FK, 0491 unique po typie,
+0492 exclusion po kolejności), a dopiero na końcu (0493) to, co ZDEJMUJE
+stare ``unique_together`` i legacy constraint z 0132. Przez chwilę
+obowiązują oba komplety naraz — to bezpieczne, bo stare są ostrzejsze
+(bezwarunkowe).
+
+CENA ``atomic = False``: migracja przerwana w połowie NIE jest zapisana w
+``django_migrations``, a część DDL już się wykonała — ponowny ``migrate``
+wywali się na istniejącym obiekcie. Stąd rozbicie na małe kroki, każdy z
+własnym wpisem.
+
+Ten krok: przywrócenie zwykłego indeksu na FK ``rekord`` (``AlterField``
+kasuje wcześniejsze ``db_index=False``). Dopóki ``unique_together`` dawało
+PEŁNY (nie częściowy) indeks btree z ``rekord`` jako kolumną wiodącą,
+``db_index=False`` na FK było uzasadnione — auto-indeks byłby redundantny.
+Constrainty z 0491/0492 są CZĘŚCIOWE (``WHERE deleted_at IS NULL``), więc
+nie pokrywają już zapytań po samym ``rekord`` bez tego predykatu: RI-check
+Postgresa przy DELETE rodzica, kolektor kaskady Django (``_base_manager``,
+bez filtra soft-delete) i ``global_objects``/``deleted_objects``
+``.filter(rekord=…)``.
+"""
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ atomic = False
+
+ dependencies = [
+ ("bpp", "0489_soft_delete_autorzy_views"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="patent_autor",
+ name="rekord",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="autorzy_set",
+ to="bpp.patent",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="wydawnictwo_ciagle_autor",
+ name="rekord",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="autorzy_set",
+ to="bpp.wydawnictwo_ciagle",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="wydawnictwo_zwarte_autor",
+ name="rekord",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="autorzy_set",
+ to="bpp.wydawnictwo_zwarte",
+ ),
+ ),
+ ]
diff --git a/src/bpp/migrations/0490_autor_warunkowy_unique.py b/src/bpp/migrations/0490_autor_warunkowy_unique.py
deleted file mode 100644
index c42219087..000000000
--- a/src/bpp/migrations/0490_autor_warunkowy_unique.py
+++ /dev/null
@@ -1,202 +0,0 @@
-# Generated by Django 5.2.16 on 2026-08-06 17:16
-#
-# Task 3c. Trzy powiązane zmiany na Wydawnictwo_Ciagle_Autor,
-# Wydawnictwo_Zwarte_Autor, Patent_Autor:
-#
-# 1. `unique_together` (widziałby też wiersze soft-deleted) -> dwa warunkowe
-# `UniqueConstraint` (`condition=deleted_at__isnull`).
-# 2. Przywrócenie zwykłego indeksu na FK `rekord` (`AlterField`, kasuje
-# wcześniejsze `db_index=False`). Powód: dopóki `unique_together` dawało
-# PEŁNY (nie częściowy) indeks btree z `rekord` jako kolumną wiodącą,
-# `db_index=False` na FK było uzasadnione — auto-indeks byłby redundantny.
-# Warunkowe constrainty z (1) są CZĘŚCIOWE (`WHERE deleted_at IS NULL`) —
-# nie pokrywają już zapytań po samym `rekord` bez tego predykatu
-# (RI-check Postgresa przy DELETE rodzica, kolektor kaskady Django,
-# `global_objects`/`deleted_objects`.filter(rekord=…)).
-# 3. Zastąpienie legacy `RunSQL` z migracji 0132 (2018) — DODATKOWEGO,
-# poza Django ORM, `UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY
-# DEFERRED` (nigdy nie było w żadnym `Meta`, więc `makemigrations` go nie
-# widziało) — `ExclusionConstraint`-em (GiST + btree_gist, rozszerzenie
-# już włączone od migracji 0056). `UniqueConstraint` nie umie łączyć
-# `condition` z `deferrable` (Django to blokuje), a `deferrable` jest
-# wymagany przez drag&drop reorder autorów w adminie (adminsortable2,
-# `sortable_field_name = "kolejnosc"`) — zamiana kolejności dwóch wierszy
-# przejściowo dubluje `kolejnosc` w obrębie jednej transakcji.
-#
-# UWAGA przy rollbacku (`reverse_sql` niżej): przywraca legacy `UNIQUE
-# (rekord_id, kolejnosc) DEFERRABLE`, ale to NIE zadziała, jeśli w tabeli są
-# już soft-deletowane wiersze dublujące `(rekord_id, kolejnosc)` z żywymi —
-# taki stan jest dozwolony PO tej migracji (to właśnie ona to umożliwia),
-# ale narusza bezwarunkowy constraint SPRZED niej. Rollback padnie
-# dokładnie wtedy, gdy funkcja soft-delete była już używana.
-
-import django.contrib.postgres.constraints
-import django.db.models.constraints
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("bpp", "0489_soft_delete_autorzy_views"),
- ]
-
- operations = [
- migrations.AlterUniqueTogether(
- name="patent_autor",
- unique_together=set(),
- ),
- migrations.AlterUniqueTogether(
- name="wydawnictwo_ciagle_autor",
- unique_together=set(),
- ),
- migrations.AlterUniqueTogether(
- name="wydawnictwo_zwarte_autor",
- unique_together=set(),
- ),
- migrations.AlterField(
- model_name="patent_autor",
- name="rekord",
- field=models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="autorzy_set",
- to="bpp.patent",
- ),
- ),
- migrations.AlterField(
- model_name="wydawnictwo_ciagle_autor",
- name="rekord",
- field=models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="autorzy_set",
- to="bpp.wydawnictwo_ciagle",
- ),
- ),
- migrations.AlterField(
- model_name="wydawnictwo_zwarte_autor",
- name="rekord",
- field=models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="autorzy_set",
- to="bpp.wydawnictwo_zwarte",
- ),
- ),
- migrations.AddConstraint(
- model_name="patent_autor",
- constraint=models.UniqueConstraint(
- condition=models.Q(("deleted_at__isnull", True)),
- fields=("rekord", "autor", "typ_odpowiedzialnosci"),
- name="pat_autor_uniq_rekord_autor_typ",
- ),
- ),
- migrations.AddConstraint(
- model_name="patent_autor",
- constraint=models.UniqueConstraint(
- condition=models.Q(("deleted_at__isnull", True)),
- fields=("rekord", "autor", "kolejnosc"),
- name="pat_autor_uniq_rekord_autor_kolejnosc",
- ),
- ),
- migrations.AddConstraint(
- model_name="patent_autor",
- constraint=django.contrib.postgres.constraints.ExclusionConstraint(
- condition=models.Q(("deleted_at__isnull", True)),
- deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
- expressions=[("rekord", "="), ("kolejnosc", "=")],
- name="pat_autor_excl_rekord_kolejnosc",
- ),
- ),
- migrations.AddConstraint(
- model_name="wydawnictwo_ciagle_autor",
- constraint=models.UniqueConstraint(
- condition=models.Q(("deleted_at__isnull", True)),
- fields=("rekord", "autor", "typ_odpowiedzialnosci"),
- name="wc_autor_uniq_rekord_autor_typ",
- ),
- ),
- migrations.AddConstraint(
- model_name="wydawnictwo_ciagle_autor",
- constraint=models.UniqueConstraint(
- condition=models.Q(("deleted_at__isnull", True)),
- fields=("rekord", "autor", "kolejnosc"),
- name="wc_autor_uniq_rekord_autor_kolejnosc",
- ),
- ),
- migrations.AddConstraint(
- model_name="wydawnictwo_ciagle_autor",
- constraint=django.contrib.postgres.constraints.ExclusionConstraint(
- condition=models.Q(("deleted_at__isnull", True)),
- deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
- expressions=[("rekord", "="), ("kolejnosc", "=")],
- name="wc_autor_excl_rekord_kolejnosc",
- ),
- ),
- migrations.AddConstraint(
- model_name="wydawnictwo_zwarte_autor",
- constraint=models.UniqueConstraint(
- condition=models.Q(("deleted_at__isnull", True)),
- fields=("rekord", "autor", "typ_odpowiedzialnosci"),
- name="wz_autor_uniq_rekord_autor_typ",
- ),
- ),
- migrations.AddConstraint(
- model_name="wydawnictwo_zwarte_autor",
- constraint=models.UniqueConstraint(
- condition=models.Q(("deleted_at__isnull", True)),
- fields=("rekord", "autor", "kolejnosc"),
- name="wz_autor_uniq_rekord_autor_kolejnosc",
- ),
- ),
- migrations.AddConstraint(
- model_name="wydawnictwo_zwarte_autor",
- constraint=django.contrib.postgres.constraints.ExclusionConstraint(
- condition=models.Q(("deleted_at__isnull", True)),
- deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
- expressions=[("rekord", "="), ("kolejnosc", "=")],
- name="wz_autor_excl_rekord_kolejnosc",
- ),
- ),
- # Trzeci kawałek Taska 3c (patrz komentarz na górze pliku): usuń
- # legacy constraint z migracji 0132, zastąpiony ExclusionConstraint-
- # em dodanym wyżej. `IF EXISTS` na DROP — instancja, na której ten
- # constraint zdjęto już kiedyś ręcznie (spoza Django), nie może
- # wywalić migracji. `reverse_sql` przywraca oryginalne DDL z 0132
- # bez `IF EXISTS` (ADD CONSTRAINT tego nie ma w składni) — patrz
- # zastrzeżenie o rollbacku na górze pliku.
- migrations.RunSQL(
- sql=(
- "ALTER TABLE bpp_patent_autor "
- "DROP CONSTRAINT IF EXISTS "
- "bpp_patent_autor_unique_rekord_id_kolejnosc;"
- ),
- reverse_sql=(
- "ALTER TABLE bpp_patent_autor "
- "ADD CONSTRAINT bpp_patent_autor_unique_rekord_id_kolejnosc "
- "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;"
- ),
- ),
- migrations.RunSQL(
- sql=(
- "ALTER TABLE bpp_wydawnictwo_ciagle_autor DROP CONSTRAINT "
- "IF EXISTS "
- "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc;"
- ),
- reverse_sql=(
- "ALTER TABLE bpp_wydawnictwo_ciagle_autor ADD CONSTRAINT "
- "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc "
- "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;"
- ),
- ),
- migrations.RunSQL(
- sql=(
- "ALTER TABLE bpp_wydawnictwo_zwarte_autor DROP CONSTRAINT "
- "IF EXISTS "
- "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc;"
- ),
- reverse_sql=(
- "ALTER TABLE bpp_wydawnictwo_zwarte_autor ADD CONSTRAINT "
- "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc "
- "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;"
- ),
- ),
- ]
diff --git a/src/bpp/migrations/0491_autor_unique_rekord_autor_typ.py b/src/bpp/migrations/0491_autor_unique_rekord_autor_typ.py
new file mode 100644
index 000000000..60606d1f6
--- /dev/null
+++ b/src/bpp/migrations/0491_autor_unique_rekord_autor_typ.py
@@ -0,0 +1,54 @@
+"""Task 3c, krok 2/4: warunkowy ``UniqueConstraint`` (rekord, autor, typ).
+
+Rozbicie migracji i ``atomic = False`` — patrz docstring 0490.
+
+``unique_together`` widziałby też wiersze soft-deleted (fizycznie wciąż są w
+tabeli) i blokowałby wzorzec „skasuj i wstaw od nowa" (re-import, korekta
+kolejności, edycja inline). Warunkowy ``UniqueConstraint``
+(``condition=deleted_at__isnull``) pilnuje unikalności TYLKO wśród żywych
+wierszy. Stare ``unique_together`` zdejmuje dopiero 0493 — tu jeszcze
+obowiązuje, więc nie ma okna bez ochrony.
+
+Drugiej pary z ``unique_together`` — ``(rekord, autor, kolejnosc)`` — NIE
+odtwarzamy: ``ExclusionConstraint`` po ``(rekord, kolejnosc)`` z 0492 jest
+ściśle silniejszy (nie patrzy na autora), więc taki unique byłby w 100%
+redundantny — trzeci indeks do zbudowania w oknie serwisowym i stały narzut
+na najgorętszej ścieżce zapisu.
+"""
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ atomic = False
+
+ dependencies = [
+ ("bpp", "0490_autor_indeks_fk_rekord"),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name="patent_autor",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("rekord", "autor", "typ_odpowiedzialnosci"),
+ name="pat_autor_uniq_rekord_autor_typ",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="wydawnictwo_ciagle_autor",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("rekord", "autor", "typ_odpowiedzialnosci"),
+ name="wc_autor_uniq_rekord_autor_typ",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="wydawnictwo_zwarte_autor",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("rekord", "autor", "typ_odpowiedzialnosci"),
+ name="wz_autor_uniq_rekord_autor_typ",
+ ),
+ ),
+ ]
diff --git a/src/bpp/migrations/0492_autor_excl_rekord_kolejnosc.py b/src/bpp/migrations/0492_autor_excl_rekord_kolejnosc.py
new file mode 100644
index 000000000..a2baae8ae
--- /dev/null
+++ b/src/bpp/migrations/0492_autor_excl_rekord_kolejnosc.py
@@ -0,0 +1,62 @@
+"""Task 3c, krok 3/4: ``ExclusionConstraint`` (rekord, kolejnosc), DEFERRED.
+
+Rozbicie migracji i ``atomic = False`` — patrz docstring 0490. To najdroższy
+krok całej serii: ``ADD CONSTRAINT ... EXCLUDE USING GIST`` buduje indeks
+GiST pod ``ACCESS EXCLUSIVE`` (blokuje również ODCZYTY) i nie ma wariantu
+współbieżnego. Dzięki ``atomic = False`` blokada każdej z trzech tabel jest
+zwalniana od razu po jej ``ALTER TABLE``, a nie dopiero na ``COMMIT`` całej
+migracji.
+
+Zastępuje legacy ``RunSQL`` z migracji 0132 (2018) — DODATKOWY, poza Django
+ORM, ``UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED`` (nigdy
+nie było go w żadnym ``Meta``, więc ``makemigrations`` go nie widziało);
+legacy constraint zdejmuje 0493. ``UniqueConstraint`` nie umie połączyć
+``condition`` z ``deferrable`` (Django to blokuje), a ``deferrable`` jest
+wymagany przez drag&drop reorder autorów w adminie (adminsortable2,
+``sortable_field_name = "kolejnosc"``) — zamiana kolejności dwóch wierszy
+przejściowo dubluje ``kolejnosc`` w obrębie jednej transakcji.
+``ExclusionConstraint`` (GiST + btree_gist, rozszerzenie włączone od 0056)
+to jedyny typ ograniczenia w Postgresie łączący ``WHERE`` z ``DEFERRABLE``.
+"""
+
+import django.contrib.postgres.constraints
+import django.db.models.constraints
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ atomic = False
+
+ dependencies = [
+ ("bpp", "0491_autor_unique_rekord_autor_typ"),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name="patent_autor",
+ constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
+ expressions=[("rekord", "="), ("kolejnosc", "=")],
+ name="pat_autor_excl_rekord_kolejnosc",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="wydawnictwo_ciagle_autor",
+ constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
+ expressions=[("rekord", "="), ("kolejnosc", "=")],
+ name="wc_autor_excl_rekord_kolejnosc",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="wydawnictwo_zwarte_autor",
+ constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
+ expressions=[("rekord", "="), ("kolejnosc", "=")],
+ name="wz_autor_excl_rekord_kolejnosc",
+ ),
+ ),
+ ]
diff --git a/src/bpp/migrations/0493_autor_zdjecie_starych_unique.py b/src/bpp/migrations/0493_autor_zdjecie_starych_unique.py
new file mode 100644
index 000000000..d60e1c2c7
--- /dev/null
+++ b/src/bpp/migrations/0493_autor_zdjecie_starych_unique.py
@@ -0,0 +1,87 @@
+"""Task 3c, krok 4/4: zdjęcie bezwarunkowych ograniczeń unikalności.
+
+Rozbicie migracji i ``atomic = False`` — patrz docstring 0490. Ten krok jest
+wyłącznie ODEJMUJĄCY i idzie na końcu, żeby przez całą serię 0490-0493
+tabela miała ochronę unikalności (najpierw stare, potem stare + nowe, na
+końcu same nowe).
+
+Zdejmujemy:
+
+1. ``unique_together`` — zastąpione warunkowymi ``UniqueConstraint``/
+ ``ExclusionConstraint`` z 0491/0492;
+2. legacy ``UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED``
+ dołożone gołym ``RunSQL`` w migracji 0132 (2018) — zastąpione
+ ``ExclusionConstraint`` z 0492. ``IF EXISTS`` na DROP: instancja, na
+ której ten constraint zdjęto już kiedyś ręcznie (spoza Django), nie może
+ wywalić migracji.
+
+UWAGA przy rollbacku: ``reverse_sql`` przywraca legacy ``UNIQUE (rekord_id,
+kolejnosc) DEFERRABLE`` (a ``AlterUniqueTogether`` — bezwarunkowe indeksy),
+ale to NIE zadziała, jeśli w tabeli są już soft-deletowane wiersze dublujące
+``(rekord_id, kolejnosc)`` z żywymi. Taki stan jest dozwolony PO tej
+migracji (to właśnie ona go umożliwia), ale narusza bezwarunkowe ograniczenia
+SPRZED niej. Rollback padnie dokładnie wtedy, gdy funkcja soft-delete była
+już używana. ``ADD CONSTRAINT`` nie ma składni ``IF EXISTS``, więc
+``reverse_sql`` jest bezwarunkowe.
+"""
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ atomic = False
+
+ dependencies = [
+ ("bpp", "0492_autor_excl_rekord_kolejnosc"),
+ ]
+
+ operations = [
+ migrations.AlterUniqueTogether(
+ name="patent_autor",
+ unique_together=set(),
+ ),
+ migrations.AlterUniqueTogether(
+ name="wydawnictwo_ciagle_autor",
+ unique_together=set(),
+ ),
+ migrations.AlterUniqueTogether(
+ name="wydawnictwo_zwarte_autor",
+ unique_together=set(),
+ ),
+ migrations.RunSQL(
+ sql=(
+ "ALTER TABLE bpp_patent_autor "
+ "DROP CONSTRAINT IF EXISTS "
+ "bpp_patent_autor_unique_rekord_id_kolejnosc;"
+ ),
+ reverse_sql=(
+ "ALTER TABLE bpp_patent_autor "
+ "ADD CONSTRAINT bpp_patent_autor_unique_rekord_id_kolejnosc "
+ "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;"
+ ),
+ ),
+ migrations.RunSQL(
+ sql=(
+ "ALTER TABLE bpp_wydawnictwo_ciagle_autor DROP CONSTRAINT "
+ "IF EXISTS "
+ "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc;"
+ ),
+ reverse_sql=(
+ "ALTER TABLE bpp_wydawnictwo_ciagle_autor ADD CONSTRAINT "
+ "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc "
+ "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;"
+ ),
+ ),
+ migrations.RunSQL(
+ sql=(
+ "ALTER TABLE bpp_wydawnictwo_zwarte_autor DROP CONSTRAINT "
+ "IF EXISTS "
+ "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc;"
+ ),
+ reverse_sql=(
+ "ALTER TABLE bpp_wydawnictwo_zwarte_autor ADD CONSTRAINT "
+ "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc "
+ "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;"
+ ),
+ ),
+ ]
diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py
index ab3a26203..859536b9e 100644
--- a/src/bpp/models/patent.py
+++ b/src/bpp/models/patent.py
@@ -76,11 +76,10 @@ class Meta:
condition=Q(deleted_at__isnull=True),
name="pat_autor_uniq_rekord_autor_typ",
),
- models.UniqueConstraint(
- fields=["rekord", "autor", "kolejnosc"],
- condition=Q(deleted_at__isnull=True),
- name="pat_autor_uniq_rekord_autor_kolejnosc",
- ),
+ # NIE MA tu `UniqueConstraint(rekord, autor, kolejnosc)` —
+ # `pat_autor_excl_rekord_kolejnosc` niżej jest ściśle silniejszy
+ # (nie patrzy na autora); patrz komentarz w
+ # `Wydawnictwo_Ciagle_Autor.Meta`.
# Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id,
# kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 —
# patrz analogiczny komentarz w Wydawnictwo_Ciagle_Autor.Meta.
@@ -95,7 +94,13 @@ class Meta:
),
]
indexes = [
- models.Index(fields=["deleted_at"], name="patent_autor_deleted_at_idx"),
+ # Indeks CZĘŚCIOWY — patrz uzasadnienie w
+ # `Wydawnictwo_Ciagle_Autor.Meta.indexes`.
+ models.Index(
+ fields=["deleted_at"],
+ name="patent_autor_deleted_at_idx",
+ condition=Q(deleted_at__isnull=False),
+ ),
]
# django-denorm buduje bramkę WHEN triggera z listy `only=` w
diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py
index 8995240fd..de559857a 100644
--- a/src/bpp/models/wydawnictwo_ciagle.py
+++ b/src/bpp/models/wydawnictwo_ciagle.py
@@ -101,11 +101,14 @@ class Meta:
condition=Q(deleted_at__isnull=True),
name="wc_autor_uniq_rekord_autor_typ",
),
- models.UniqueConstraint(
- fields=["rekord", "autor", "kolejnosc"],
- condition=Q(deleted_at__isnull=True),
- name="wc_autor_uniq_rekord_autor_kolejnosc",
- ),
+ # NIE MA tu `UniqueConstraint(rekord, autor, kolejnosc)` — byłby
+ # w 100% redundantny wobec `wc_autor_excl_rekord_kolejnosc`
+ # niżej. Ten pilnuje pary (rekord, kolejnosc) NIE PATRZĄC na
+ # autora, a więc jest ściśle silniejszy: skoro w obrębie rekordu
+ # żadna pozycja nie może się powtórzyć, to tym bardziej nie może
+ # się powtórzyć w obrębie (rekord, autor). Kosztowałby wyłącznie
+ # trzeci indeks: build w oknie serwisowym + stały narzut na
+ # każdym zapisie autorstwa (najgorętsza ścieżka zapisu w BPP).
# Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id,
# kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 —
# gwarantuje, że DWÓCH RÓŻNYCH autorów nie dzieli tej samej
@@ -131,7 +134,19 @@ class Meta:
),
]
indexes = [
- models.Index(fields=["deleted_at"], name="wc_autor_deleted_at_idx"),
+ # Indeks CZĘŚCIOWY (`WHERE deleted_at IS NOT NULL`), nie pełny.
+ # Predykat `deleted_at IS NULL` pasuje do ~100% wierszy, więc
+ # planner i tak nigdy nie wybrałby pod niego indeksu (seq scan
+ # jest tańszy) — pełny btree byłby wyłącznie kosztem: rozmiar
+ # rzędu tabeli + wpis przy każdym INSERT/UPDATE autorstwa.
+ # Realnie selektywne jest zapytanie ODWROTNE — `deleted_objects`
+ # (`deleted_at IS NOT NULL`), czyli kosz/audyt — i to ono
+ # dostaje tu mikroskopijny indeks.
+ models.Index(
+ fields=["deleted_at"],
+ name="wc_autor_deleted_at_idx",
+ condition=Q(deleted_at__isnull=False),
+ ),
]
# django-denorm buduje bramkę WHEN triggera z listy `only=` w
diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py
index f4c9084e9..6bc800b4b 100644
--- a/src/bpp/models/wydawnictwo_zwarte.py
+++ b/src/bpp/models/wydawnictwo_zwarte.py
@@ -110,11 +110,10 @@ class Meta:
condition=Q(deleted_at__isnull=True),
name="wz_autor_uniq_rekord_autor_typ",
),
- models.UniqueConstraint(
- fields=["rekord", "autor", "kolejnosc"],
- condition=Q(deleted_at__isnull=True),
- name="wz_autor_uniq_rekord_autor_kolejnosc",
- ),
+ # NIE MA tu `UniqueConstraint(rekord, autor, kolejnosc)` —
+ # `wz_autor_excl_rekord_kolejnosc` niżej jest ściśle silniejszy
+ # (nie patrzy na autora); patrz komentarz w
+ # `Wydawnictwo_Ciagle_Autor.Meta`.
# Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id,
# kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 —
# patrz analogiczny komentarz w Wydawnictwo_Ciagle_Autor.Meta.
@@ -129,7 +128,13 @@ class Meta:
),
]
indexes = [
- models.Index(fields=["deleted_at"], name="wz_autor_deleted_at_idx"),
+ # Indeks CZĘŚCIOWY — patrz uzasadnienie w
+ # `Wydawnictwo_Ciagle_Autor.Meta.indexes`.
+ models.Index(
+ fields=["deleted_at"],
+ name="wz_autor_deleted_at_idx",
+ condition=Q(deleted_at__isnull=False),
+ ),
]
# django-denorm buduje bramkę WHEN triggera z listy `only=` w
diff --git a/src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py b/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py
similarity index 56%
rename from src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py
rename to src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py
index b62b247e7..dbef53e48 100644
--- a/src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py
+++ b/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py
@@ -1,10 +1,18 @@
-"""Odwracalność migracji 0490 (warunkowy UniqueConstraint/ExclusionConstraint
-na *_Autor + zastąpienie legacy `RunSQL` constraintu z migracji 0132).
+"""Odwracalność serii 0490-0493 (Task 3c): warunkowe ograniczenia na *_Autor.
-``backward`` musi przywrócić DOKŁADNIE stan sprzed migracji: bezwarunkowy
-`unique_together` ORAZ legacy `UNIQUE (rekord_id, kolejnosc) DEFERRABLE
-INITIALLY DEFERRED` — inaczej rollback zostawia bazę bez żadnego z dwóch
-mechanizmów pilnujących unikalności kolejności autorów.
+Task 3c był pierwotnie jedną migracją (0490); jest rozbity na cztery
+(indeks FK / unique po typie / exclusion po kolejności / zdjęcie starych),
+każda z ``atomic = False`` — patrz docstring ``0490_autor_indeks_fk_rekord``.
+
+``backward`` całej serii musi przywrócić DOKŁADNIE stan sprzed niej:
+bezwarunkowy ``unique_together`` ORAZ legacy ``UNIQUE (rekord_id, kolejnosc)
+DEFERRABLE INITIALLY DEFERRED`` — inaczej rollback zostawia bazę bez żadnego
+z dwóch mechanizmów pilnujących unikalności kolejności autorów.
+
+Test zjeżdża do 0489 i wraca do NAJNOWSZEJ migracji ``bpp`` (``migrate bpp``
+bez numeru). Powrót „do 0490" byłby błędem: baza testowa jest współdzielona
+przez cały przebieg (``--reuse-db``), więc pozostawienie migracji późniejszych
+niż cel jako niezastosowanych psułoby kolejne testy.
"""
import pytest
@@ -52,16 +60,22 @@ def _constraint_names(cur, tabela):
return nazwy
+def _sprawdz_stan_po(cur):
+ for tabela in TABELE:
+ nazwy = _constraint_names(cur, tabela)
+ prefix = PREFIX[tabela]
+ assert f"{prefix}_autor_uniq_rekord_autor_typ" in nazwy
+ assert f"{prefix}_autor_excl_rekord_kolejnosc" in nazwy
+ # Redundantny wobec `..._excl_rekord_kolejnosc` (ten nie patrzy na
+ # autora, więc jest ściśle silniejszy) — świadomie NIE tworzony.
+ assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" not in nazwy
+ assert LEGACY_CONSTRAINT[tabela] not in nazwy
+
+
@pytest.mark.django_db
-def test_migracja_0490_odwracalna():
+def test_migracje_0490_0493_odwracalne():
with connection.cursor() as cur:
- for tabela in TABELE:
- nazwy = _constraint_names(cur, tabela)
- prefix = PREFIX[tabela]
- assert f"{prefix}_autor_uniq_rekord_autor_typ" in nazwy
- assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" in nazwy
- assert f"{prefix}_autor_excl_rekord_kolejnosc" in nazwy
- assert LEGACY_CONSTRAINT[tabela] not in nazwy
+ _sprawdz_stan_po(cur)
call_command("migrate", "bpp", "0489", verbosity=0)
@@ -70,17 +84,10 @@ def test_migracja_0490_odwracalna():
nazwy = _constraint_names(cur, tabela)
prefix = PREFIX[tabela]
assert f"{prefix}_autor_uniq_rekord_autor_typ" not in nazwy
- assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" not in nazwy
assert f"{prefix}_autor_excl_rekord_kolejnosc" not in nazwy
assert LEGACY_CONSTRAINT[tabela] in nazwy
- call_command("migrate", "bpp", "0490", verbosity=0)
+ call_command("migrate", "bpp", verbosity=0)
with connection.cursor() as cur:
- for tabela in TABELE:
- nazwy = _constraint_names(cur, tabela)
- prefix = PREFIX[tabela]
- assert f"{prefix}_autor_uniq_rekord_autor_typ" in nazwy
- assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" in nazwy
- assert f"{prefix}_autor_excl_rekord_kolejnosc" in nazwy
- assert LEGACY_CONSTRAINT[tabela] not in nazwy
+ _sprawdz_stan_po(cur)
From 848dd13363e22e4066cb17eda3a248730e3b466e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 22:11:53 +0200
Subject: [PATCH 33/75] fix(soft-delete): 3 widoki pochodne przestaja czytac
skasowane autorstwa
Domyka trzy blokery z recenzji finalnej fazy 01. Wszystkie byly aktywne od
dnia 1 po wdrozeniu -- `delete()` jest miekki od pierwszej sekundy, nie ma
feature flagi.
BLOKER 1 (0494) -- `liczba_autorow` w `bpp__view` (0421) liczyla
SUROWA tabele `*_autor`. Zawyzona `Rekord.liczba_autorow` cicho psula
multiseekowe "Ostatnie nazwisko i imie" (filtr `kolejnosc IN
[liczba_autorow-1, liczba_autorow)` celowal w pusta pozycje), sortowanie i
pole "Liczba autorow". Poprawka: `count(...) FILTER (WHERE deleted_at IS
NULL)`, a NIE warunek w JOIN/WHERE -- ten drugi wywalilby z widoku (a wiec
z `bpp_rekord_mat` i z calego serwisu) publikacje, ktorym soft-deletowano
WSZYSTKICH autorow, bo LEFT JOIN + WHERE na prawej stronie = INNER JOIN.
Po zmianie definicji regenerowana jest bramka WHEN triggerow UPDATE tabel
publikacji (logika 0433) -- dzis z konstrukcji no-op, ale niezmiennik
"ruszyles widok, przelicz bramke" zostaje domkniety.
BLOKER 2 (0495) -- `bpp_nowe_sumy_*_view` (0458) joinowaly surowe `*_autor`,
wiec skasowane autorstwo dalej wnosilo punkty do rankingu autorow.
Najgorszy scenariusz: deduplikator autorow przenosi autorstwa i kasuje
zrodlowe (od fazy 01 -- miekko), a duplikat zostaje w rankingu z pelna
punktacja bezterminowo.
BLOKER 3 (rozbieznosci_dyscyplin/0022) -- `..._rozbieznoscizrodelview`
raportowal rozbieznosci dyscyplin dla autorstw, ktorych nie ma juz w
interfejsie (nie da sie ich "naprawic"). Zywa definicja pochodzi z pliku
0017*.sql, przeladowywanego przez 0018/0019/0020 -- ten lancuch jest w
origin/dev, wiec pliku .sql nie ruszamy; warunek dokladamy osobna migracja.
Wszystkie trzy wychodza z INTROSPEKCJI (`pg_get_viewdef`), nie z tekstu
migracji, ktora widok stworzyla -- wzorzec z 0489. Wspolny helper
(`widok_dopisz_warunek` / `widok_usun_warunek` w `bpp/migration_util.py`,
uzywany takze przez migracje spoza `bpp`) ma twarde bezpieczniki: odmawia
dopisania `AND ...` gdy definicja zawiera OR / GROUP BY / UNION / ... albo
gdy WHERE najwyzszego poziomu nie jest dokladnie jeden.
Ustalone przy okazji (NIE naprawione, patrz raport): widoki `bpp_kronika_*`
tez czytaja surowe `*_autor`, ale sa martwe -- zaden model ani kod ich nie
uzywa.
---
src/bpp/migration_util.py | 81 +++++++++-
.../0494_liczba_autorow_bez_skasowanych.py | 147 ++++++++++++++++++
.../0495_nowe_sumy_bez_skasowanych.py | 54 +++++++
...022_rozbieznosci_zrodel_bez_skasowanych.py | 45 ++++++
4 files changed, 326 insertions(+), 1 deletion(-)
create mode 100644 src/bpp/migrations/0494_liczba_autorow_bez_skasowanych.py
create mode 100644 src/bpp/migrations/0495_nowe_sumy_bez_skasowanych.py
create mode 100644 src/rozbieznosci_dyscyplin/migrations/0022_rozbieznosci_zrodel_bez_skasowanych.py
diff --git a/src/bpp/migration_util.py b/src/bpp/migration_util.py
index 59ca883e4..543827aa2 100644
--- a/src/bpp/migration_util.py
+++ b/src/bpp/migration_util.py
@@ -20,6 +20,85 @@ def load_custom_sql(mig_name, app_name="bpp", charset="utf-8", *args, **kw):
# print "done!"
+# Konstrukcje, przy których DOPISANIE `AND ` na koniec definicji
+# widoku NIE jest równoważne dołożeniu warunku do WHERE:
+# - " OR " -> AND wiąże mocniej, więc `... OR x AND nowy` zmienia sens;
+# - GROUP BY / HAVING / WINDOW / ORDER BY / LIMIT / OFFSET / UNION / ...
+# -> definicja nie kończy się na WHERE, więc `AND` wylądowałby
+# w cudzej klauzuli (albo dał błąd składni).
+_ZAKAZANE_W_WIDOKU = (
+ " OR ",
+ "GROUP BY",
+ "HAVING",
+ "WINDOW",
+ "ORDER BY",
+ "LIMIT",
+ "OFFSET",
+ "UNION",
+ "INTERSECT",
+ "EXCEPT",
+)
+
+# pg_get_viewdef(pretty=true) renderuje klauzulę WHERE najwyższego poziomu
+# jako linię zaczynającą się od DWÓCH spacji; WHERE podzapytań jest wcięty
+# głębiej. Liczba wystąpień tego wzorca odróżnia więc widok z jednym WHERE
+# od widoku z UNION-em (gdzie dopisanie AND trafiłoby tylko w ostatnią gałąź).
+_TOP_LEVEL_WHERE = "\n WHERE "
+
+
+def viewdef(cur, widok):
+ """``pg_get_viewdef`` (pretty) bez końcowego średnika i białych znaków."""
+ cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok])
+ return cur.fetchone()[0].rstrip().rstrip(";")
+
+
+def _sprawdz_ze_mozna_dopisac_and(widok, definicja):
+ for zakazane in _ZAKAZANE_W_WIDOKU:
+ if zakazane in definicja:
+ raise RuntimeError(
+ f"{widok}: definicja zawiera {zakazane!r} -- dopisanie "
+ f"'AND ' na koncu nie byloby rownowazne dolozeniu "
+ f"warunku do WHERE. Przepisz migracje recznie."
+ )
+ ile = definicja.count(_TOP_LEVEL_WHERE)
+ if ile != 1:
+ raise RuntimeError(
+ f"{widok}: oczekiwano DOKLADNIE jednej klauzuli WHERE najwyzszego "
+ f"poziomu, znaleziono {ile}. Definicja: ...{definicja[-160:]!r}"
+ )
+
+
+def widok_dopisz_warunek(cur, widok, warunek):
+ """Dokłada ``AND `` do WHERE istniejącego widoku.
+
+ Wychodzi z INTROSPEKCJI (``pg_get_viewdef``), nie z tekstu migracji, która
+ widok stworzyła — kopia SQL-a rozjechałaby się przy najbliższej zmianie
+ kolumn. ``CREATE OR REPLACE VIEW`` zachowuje listę i typy kolumn, więc
+ widoki zależne (np. ``bpp_nowe_sumy_view`` — UNION ALL po pięciu widokach)
+ NIE są kasowane; nie ma tu żadnego ``DROP ... CASCADE``.
+
+ Idempotentna: gdy warunek już jest w definicji, nie robi nic.
+ """
+ definicja = viewdef(cur, widok)
+ if warunek in definicja:
+ return
+ _sprawdz_ze_mozna_dopisac_and(widok, definicja)
+ cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {definicja} AND {warunek}")
+
+
+def widok_usun_warunek(cur, widok, warunek):
+ """Odwrotność ``widok_dopisz_warunek`` (dla ``backward()``).
+
+ Wycina DOKŁADNIE dopisany sufiks ``AND ``; gdy go nie ma — no-op
+ (migracja cofana dwa razy albo widok w międzyczasie przebudowany).
+ """
+ definicja = viewdef(cur, widok)
+ sufiks = f" AND {warunek}"
+ if not definicja.endswith(sufiks):
+ return
+ cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {definicja[: -len(sufiks)]}")
+
+
def load_fixture_as_json(fixture_name):
return json.loads(open(str(find_fixture_path(fixture_name))).read())
@@ -43,5 +122,5 @@ def load_historic_fixture(apps, fixture_name, klass, app_name="bpp"):
cur = connection.cursor()
for klassobj, cnt in max_id_map.items():
- qry = f"ALTER SEQUENCE { klassobj._meta.db_table }_id_seq RESTART WITH { cnt + 1 }"
+ qry = f"ALTER SEQUENCE {klassobj._meta.db_table}_id_seq RESTART WITH {cnt + 1}"
cur.execute(qry)
diff --git a/src/bpp/migrations/0494_liczba_autorow_bez_skasowanych.py b/src/bpp/migrations/0494_liczba_autorow_bez_skasowanych.py
new file mode 100644
index 000000000..1b6cf48da
--- /dev/null
+++ b/src/bpp/migrations/0494_liczba_autorow_bez_skasowanych.py
@@ -0,0 +1,147 @@
+"""``liczba_autorow`` w ``bpp__view`` przestaje liczyć soft-deletowane.
+
+Migracja 0489 przefiltrowała widoki ``bpp_*_autorzy`` (te karmią
+``bpp_autorzy_mat``), ale ``liczba_autorow`` bierze się z INNEJ rodziny
+widoków — ``bpp_wydawnictwo_ciagle_view`` / ``bpp_wydawnictwo_zwarte_view`` /
+``bpp_patent_view`` (migracja 0421) — i tam agregat liczył SUROWĄ tabelę
+``*_autor``:
+
+ count(bpp_wydawnictwo_ciagle_autor.autor_id) AS liczba_autorow
+
+Skutki zawyżonej ``Rekord.liczba_autorow`` (kolumna ``bpp_rekord_mat``):
+
+- multiseek, kryterium „Ostatnie nazwisko i imię": filtr
+ ``kolejnosc ∈ [liczba_autorow-1, liczba_autorow)``
+ (``bpp/multiseek_registry/fields/author_fields.py``) celuje w pozycję, na
+ której po soft-delete nikogo nie ma — zapytanie CICHO zwraca pustkę;
+- sortowanie multiseeka po „liczbie autorów" i samo pole „Liczba autorów".
+
+**Dlaczego ``FILTER``, a nie warunek w ``JOIN``/``WHERE``.** Widok robi
+``LEFT JOIN`` do ``*_autor`` i grupuje po id publikacji. Dopisanie
+``deleted_at IS NULL`` do ``WHERE`` skasowałoby Z WIDOKU całe publikacje,
+którym soft-deletowano WSZYSTKICH autorów (LEFT JOIN + WHERE na kolumnie
+prawej strony = INNER JOIN), a więc wywaliłoby je z ``bpp_rekord_mat``
+i z całego serwisu. Agregat ``count(...) FILTER (WHERE ...)`` rusza
+WYŁĄCZNIE licznik: publikacja bez żywych autorów zostaje w widoku z
+``liczba_autorow = 0``, dokładnie jak publikacja, której autorów nigdy nie
+było. Poza licznikiem żadna kolumna tych widoków nie sięga do ``*_autor``,
+więc to jedyne miejsce wymagające zmiany.
+
+Definicje są GENEROWANE z introspekcji (``pg_get_viewdef``), nie przepisane
+do ``.sql`` — patrz docstring 0489. ``CREATE OR REPLACE VIEW`` zachowuje
+listę kolumn (``count()`` to ``bigint`` w obu wariantach), więc zależny
+``bpp_rekord`` (UNION po pięciu widokach) nie jest kasowany.
+
+UWAGA (eventual consistency, NIE regresja tej migracji): trigger na tabeli
+``*_autor`` odświeża tylko ``bpp_autorzy_mat``. ``liczba_autorow`` w
+``bpp_rekord_mat`` przelicza się dopiero, gdy flush denorm dotknie wiersza
+publikacji (opis bibliograficzny zależy od autorów) — tak samo po TWARDYM
+kasowaniu autorstwa. Patrz komentarz przy polu ``Rekord.liczba_autorow``.
+"""
+
+import importlib
+
+from django.db import connection, migrations
+
+from bpp.migration_util import viewdef
+
+_p0433 = importlib.import_module("bpp.migrations.0433_cache_trigger_when_gate")
+
+# (widok bpp__view, tabela through *_autor, którą liczy)
+LICZNIKI = [
+ ("bpp_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle_autor"),
+ ("bpp_wydawnictwo_zwarte_view", "bpp_wydawnictwo_zwarte_autor"),
+ ("bpp_patent_view", "bpp_patent_autor"),
+]
+
+# Tabele publikacji, których bramkę WHEN regenerujemy po zmianie widoku.
+TABELE_PUBLIKACJI = {"bpp_wydawnictwo_ciagle", "bpp_wydawnictwo_zwarte", "bpp_patent"}
+
+
+def _bez_filtra(tabela):
+ return f"count({tabela}.autor_id) AS liczba_autorow"
+
+
+def _z_filtrem(tabela):
+ return (
+ f"count({tabela}.autor_id) FILTER "
+ f"(WHERE {tabela}.deleted_at IS NULL) AS liczba_autorow"
+ )
+
+
+def _przepisz_agregat(cur, widok, stary, nowy):
+ """``CREATE OR REPLACE VIEW`` z podmienionym JEDNYM wystąpieniem agregatu.
+
+ ``!= 1`` (a nie ``== 0``) jest celowe: gdyby przyszła zmiana dołożyła drugi
+ ``count(...)`` po tej samej tabeli, ślepy ``replace`` podmieniłby oba.
+ Głośny błąd migracji jest lepszy niż cicha zmiana semantyki widoku.
+ """
+ definicja = viewdef(cur, widok)
+ if definicja.count(nowy) == 1 and stary not in definicja:
+ return # już przepisane (idempotencja)
+ ile = definicja.count(stary)
+ if ile != 1:
+ raise RuntimeError(
+ f"{widok}: oczekiwano DOKLADNIE jednego wystapienia {stary!r}, "
+ f"znaleziono {ile} -- nie podmieniam na slepo."
+ )
+ cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {definicja.replace(stary, nowy)}")
+
+
+def _regeneruj_bramke():
+ """Bramka WHEN triggerów UPDATE tabel PUBLIKACJI (logika ``0433.forward``).
+
+ Bramka jest wyliczana z ``pg_depend`` PO definicji widoku, więc każda
+ zmiana definicji musi być domknięta jej regeneracją — inaczej wraca błąd,
+ który ta faza już raz naprawiała (0489, krok 3).
+
+ Tu regeneracja jest z konstrukcji NO-OPEM: ``_gate_columns`` pyta o
+ kolumny TABELI PUBLIKACJI referowane przez widok, a ``FILTER (WHERE
+ *_autor.deleted_at ...)`` dokłada zależność od kolumny tabeli THROUGH.
+ Zostawiamy ją mimo to — jako niezmiennik „ruszyłeś widok, przelicz
+ bramkę", którego nie chcemy uzależniać od tego, czy akurat dziś wychodzi
+ ta sama lista kolumn.
+ """
+ with connection.cursor() as cur:
+ for tabela, refresh_fn, widoki in _p0433.GATED:
+ if tabela not in TABELE_PUBLIKACJI:
+ continue
+ kolumny = _p0433._gate_columns(cur, tabela, widoki)
+ if not kolumny:
+ # Bezpiecznik z 0433: bez kolumn powstałby niebramkowany
+ # UPDATE (trigger na każdą zmianę wiersza publikacji).
+ raise RuntimeError(
+ f"bramka dla {tabela}: pg_depend nie zwrocil zadnej kolumny "
+ f"(widoki={widoki}) -- nie tworze niezbramkowanego UPDATE"
+ )
+ when = _p0433._when_clause(kolumny)
+ cur.execute(f"DROP TRIGGER IF EXISTS {tabela}_cache_upd ON {tabela};")
+ cur.execute(
+ f"CREATE TRIGGER {tabela}_cache_upd AFTER UPDATE ON {tabela} "
+ f"FOR EACH ROW WHEN ({when}) "
+ f"EXECUTE PROCEDURE {refresh_fn}();"
+ )
+
+
+def forward(apps, schema_editor):
+ with connection.cursor() as cur:
+ for widok, tabela in LICZNIKI:
+ _przepisz_agregat(cur, widok, _bez_filtra(tabela), _z_filtrem(tabela))
+ _regeneruj_bramke()
+
+
+def backward(apps, schema_editor):
+ with connection.cursor() as cur:
+ for widok, tabela in LICZNIKI:
+ _przepisz_agregat(cur, widok, _z_filtrem(tabela), _bez_filtra(tabela))
+ _regeneruj_bramke()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("bpp", "0493_autor_zdjecie_starych_unique"),
+ ]
+
+ operations = [
+ migrations.RunPython(forward, backward),
+ ]
diff --git a/src/bpp/migrations/0495_nowe_sumy_bez_skasowanych.py b/src/bpp/migrations/0495_nowe_sumy_bez_skasowanych.py
new file mode 100644
index 000000000..f0cd22c45
--- /dev/null
+++ b/src/bpp/migrations/0495_nowe_sumy_bez_skasowanych.py
@@ -0,0 +1,54 @@
+"""Ranking autorów przestaje punktować soft-deletowane autorstwa.
+
+``bpp_nowe_sumy_*_view`` (migracja 0458) joinują SUROWE tabele ``*_autor``,
+więc po soft-delete autorstwo dalej wnosi punkty do sum. Ścieżka:
+``bpp_nowe_sumy_{patent,wydawnictwo_ciagle,wydawnictwo_zwarte}_view``
+-> UNION ALL ``bpp_nowe_sumy_view`` -> modele ``Nowe_Sumy_View``/``Sumy``
+(``bpp/models/sumy_views.py``) -> ``ranking_autorow/views.py``.
+
+Najgorszy scenariusz: deduplikator autorów przenosi autorstwa do autora
+docelowego i kasuje źródłowe — od fazy 01 MIĘKKO. Bez tego filtra duplikat
+zostaje w rankingu z pełną punktacją bezterminowo (a praca jest liczona
+podwójnie: raz na autorze docelowym, raz na skasowanym duplikacie).
+
+Dwa pozostałe widoki unii — ``bpp_nowe_sumy_praca_doktorska_view`` i
+``..._praca_habilitacyjna_view`` — NIE mają tabeli through (autor leży na
+wierszu publikacji) i faza 01 nie robi ich soft-delete; zostają bez zmian.
+"""
+
+from django.db import connection, migrations
+
+from bpp.migration_util import widok_dopisz_warunek, widok_usun_warunek
+
+# (widok sum, tabela through *_autor, którą joinuje)
+SUMY = [
+ ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle_autor"),
+ ("bpp_nowe_sumy_wydawnictwo_zwarte_view", "bpp_wydawnictwo_zwarte_autor"),
+ ("bpp_nowe_sumy_patent_view", "bpp_patent_autor"),
+]
+
+
+def _warunek(tabela):
+ return f"{tabela}.deleted_at IS NULL"
+
+
+def forward(apps, schema_editor):
+ with connection.cursor() as cur:
+ for widok, tabela in SUMY:
+ widok_dopisz_warunek(cur, widok, _warunek(tabela))
+
+
+def backward(apps, schema_editor):
+ with connection.cursor() as cur:
+ for widok, tabela in SUMY:
+ widok_usun_warunek(cur, widok, _warunek(tabela))
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("bpp", "0494_liczba_autorow_bez_skasowanych"),
+ ]
+
+ operations = [
+ migrations.RunPython(forward, backward),
+ ]
diff --git a/src/rozbieznosci_dyscyplin/migrations/0022_rozbieznosci_zrodel_bez_skasowanych.py b/src/rozbieznosci_dyscyplin/migrations/0022_rozbieznosci_zrodel_bez_skasowanych.py
new file mode 100644
index 000000000..add892a5e
--- /dev/null
+++ b/src/rozbieznosci_dyscyplin/migrations/0022_rozbieznosci_zrodel_bez_skasowanych.py
@@ -0,0 +1,45 @@
+"""Raport rozbieżności dyscyplin źródeł pomija soft-deletowane autorstwa.
+
+``rozbieznosci_dyscyplin_rozbieznoscizrodelview`` czyta SUROWĄ
+``bpp_wydawnictwo_ciagle_autor``. Definicja żyjąca w bazie pochodzi z pliku
+``0017_add_punkty_kbn_and_charakter_formalny.sql``, PRZEŁADOWYWANEGO przez
+migracje 0018 / 0019 / 0020 (każda woła ``load_custom_sql`` na TYM SAMYM
+pliku). Ten łańcuch jest już w ``origin/dev``, więc pliku ``.sql`` nie wolno
+ruszyć — dokładamy warunek osobną migracją, przez introspekcję.
+
+Pozostałe widoki tej aplikacji (``rozbieznosciview``,
+``brakprzypisaniaview``, ``rozbiezneprzypisaniaview``) czytają widok
+``bpp_autorzy``, przefiltrowany już w migracji ``bpp.0489`` — sprawdzone przez
+``pg_depend`` (żaden z nich nie zależy od surowej tabeli ``*_autor``).
+"""
+
+from django.db import connection, migrations
+
+from bpp.migration_util import widok_dopisz_warunek, widok_usun_warunek
+
+WIDOK = "rozbieznosci_dyscyplin_rozbieznoscizrodelview"
+WARUNEK = "bpp_wydawnictwo_ciagle_autor.deleted_at IS NULL"
+
+
+def forward(apps, schema_editor):
+ with connection.cursor() as cur:
+ widok_dopisz_warunek(cur, WIDOK, WARUNEK)
+
+
+def backward(apps, schema_editor):
+ with connection.cursor() as cur:
+ widok_usun_warunek(cur, WIDOK, WARUNEK)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("rozbieznosci_dyscyplin", "0021_alter_rozbieznosciview_options"),
+ # Kolumna `deleted_at` na *_autor powstaje w bpp.0488; bez tej
+ # zależności migracja mogłaby pójść przed nią i wywalić się na
+ # nieznanej kolumnie.
+ ("bpp", "0488_autor_soft_delete_fields"),
+ ]
+
+ operations = [
+ migrations.RunPython(forward, backward),
+ ]
From 0181f5ccbf8a56619a6f77587aa5e21b33d5bdfe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 22:50:57 +0200
Subject: [PATCH 34/75] test(soft-delete): kanarek katalogowy pilnuje
deleted_at w widokach pg_views
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Faza 01 odkrywała konsumentów surowych tabel *_autor pojedynczo, przez
awarie (bramka cache'u, bramka denorma, unique_together, legacy
constraint z 2018, trzy widoki pochodne w finalnej recenzji). Ten test
pilnuje niezmiennika automatycznie: każdy widok w pg_views (schemat
public) czytający tabelę objętą soft-delete musi filtrować po
deleted_at, chyba że jest na jawnej liście wyjątków z uzasadnieniem.
- TABELE_SOFT_DELETE jako stała modułowa — faza 02 rozszerza ją o 5
tabel publikacji jednolinijkową zmianą.
- WYJATKI = trzy martwe widoki bpp_kronika_* (zweryfikowane: zero
konsumentów w kodzie/szablonach/modelach), z uzasadnieniem i datą.
- Matcher po granicy słowa (\b) odpiera pułapkę prefiksu
(bpp_wydawnictwo_ciagle_autor vs _autorzy, oraz odwrotnie
wydawnictwo_ciagle vs wydawnictwo_ciagle_autor pod fazę 02) —
przykryte dedykowanymi testami parametryzowanymi.
- Test mutacyjny: podmiana bpp_wydawnictwo_ciagle_autorzy na wersję bez
filtra (CREATE OR REPLACE VIEW w transakcji testowej) potwierdza, że
kanarek pada, po czym widok jest przywracany i ponownie zielony.
Źródło prawdy: pg_views.definition na żywej bazie, nie pliki migracji
(późniejsze nadpisują wcześniejsze) ani baseline.sql (snapshot sprzed
części migracji fazy).
13 nowych testów, wszystkie zielone. Zero dodatkowych widoków
znalezionych poza już udokumentowaną trójką kronika.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../test_kanarek_katalogowy.py | 353 ++++++++++++++++++
1 file changed, 353 insertions(+)
create mode 100644 src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py
diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py
new file mode 100644
index 000000000..a5072a539
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py
@@ -0,0 +1,353 @@
+"""Kanarek katalogowy: KAŻDY widok czytający tabelę objętą soft-delete
+MUSI filtrować po ``deleted_at`` — albo być na jawnej, uzasadnionej liście
+wyjątków.
+
+## Dlaczego ten test istnieje
+
+Faza 01 odkrywała konsumentów surowych tabel ``*_autor`` **pojedynczo,
+przez awarie**: najpierw bramka cache'u, potem bramka denorma,
+``unique_together``, legacy constraint z 2018, a na końcu finalna recenzja
+znalazła jeszcze trzy widoki pochodne (``liczba_autorow``, ranking,
+rozbieżności) czytające surową tabelę zamiast już przefiltrowanego widoku
+źródłowego. Każdy z nich to ta sama klasa błędu: coś czyta surową tabelę,
+nie wiedząc o ``deleted_at``.
+
+Faza 02 (soft-delete PUBLIKACJI) trafi na dokładnie ten sam problem dla
+tabel ``bpp_wydawnictwo_ciagle`` / ``_zwarte`` / ``bpp_patent`` /
+``bpp_praca_doktorska`` / ``bpp_praca_habilitacyjna``. Ten kanarek pilnuje
+niezmiennika automatycznie, zamiast czekać na kolejną rundę odkryć przez
+awarie.
+
+## Źródło prawdy
+
+``pg_views`` (schemat ``public``), na ŻYWEJ bazie — NIE pliki migracji.
+Późniejsze migracje nadpisują wcześniejsze definicje widoków (
+``CREATE OR REPLACE VIEW``), więc treść ``.sql`` w katalogu migracji nie
+musi być tym, co faktycznie stoi w bazie. ``baseline-sql/baseline.sql`` też
+zawodzi jako źródło — to snapshot sprzed części migracji (sprawdzone:
+``baseline.sql`` ma ``NULL::bigint AS liczba_autorow`` zamiast realnego
+``count()`` z migracji ``0494``).
+
+## Pułapka dopasowania (naiwny ``in``/``LIKE``)
+
+``bpp_wydawnictwo_ciagle_autor`` jest PREFIKSEM
+``bpp_wydawnictwo_ciagle_autorzy`` (widok źródłowy already-filtered) — i,
+symetrycznie w drugą stronę wobec fazy 02, ``bpp_wydawnictwo_ciagle``
+(tabela publikacji) jest PREFIKSEM ``bpp_wydawnictwo_ciagle_autor``. Naiwne
+dopasowanie substringiem dałoby fałszywe trafienia w obie strony. Matcher
+niżej używa granicy słowa (``\\b``) na obu końcach nazwy tabeli — w Pythonie
+``re`` podkreślnik ``_`` jest znakiem ``\\w``, więc ``\\b`` NIE tworzy
+granicy między ``ciagle`` a następującym po nim ``_autor``, ani między
+``autor`` a następującym po nim ``zy`` — obie „doklejki" są więc już z
+konstrukcji odrzucane. ``test_matcher_...`` niżej to udowadnia i pilnuje,
+żeby refaktor tego nie zepsuł.
+
+## Wzorzec naprawy
+
+- widok źródłowy (filtr ``WHERE deleted_at IS NULL`` po WŁASNEJ kolumnie
+ tabeli) — ``src/bpp/migrations/0489_soft_delete_autorzy_views.py``;
+- widok pochodny z agregatem po ``LEFT JOIN`` (``count()`` itp.) — UŻYJ
+ ``FILTER (WHERE deleted_at IS NULL)``, NIE warunku w ``WHERE``/``JOIN``:
+ ten drugi zamienia ``LEFT JOIN`` w efektywny ``INNER JOIN`` i wywala z
+ widoku cały wiersz nadrzędny, któremu soft-deletowano WSZYSTKICH
+ potomków — ``src/bpp/migrations/0494_liczba_autorow_bez_skasowanych.py``;
+- widok pochodny z prostym ``WHERE``/``JOIN ... ON`` (bez agregatu) —
+ dopisz warunek do ``WHERE`` —
+ ``src/bpp/migrations/0495_nowe_sumy_bez_skasowanych.py``;
+- helper do bezpiecznego dopisania warunku przez introspekcję (zamiast
+ przepisywania SQL-a ręcznie, co rozjeżdża się przy zmianie kolumn) —
+ ``src/bpp/migration_util.py`` (``viewdef``, ``widok_dopisz_warunek``,
+ ``widok_usun_warunek``).
+"""
+
+import re
+
+import pytest
+from django.db import connection
+
+# Tabele objęte soft-delete (django-soft-delete SoftDeleteModel) — jedyne
+# źródło niezmiennika, świadomie utrzymywane jako lista, nie regex-owa
+# heurystyka po nazwie.
+#
+# Faza 01 (2026-08-06): trzy tabele through autorstwa.
+#
+# ⚠️ FAZA 02 MUSI DOPISAĆ TU 5 TABEL PUBLIKACJI — jednolinijkowa zmiana:
+# "bpp_wydawnictwo_ciagle",
+# "bpp_wydawnictwo_zwarte",
+# "bpp_patent",
+# "bpp_praca_doktorska",
+# "bpp_praca_habilitacyjna",
+TABELE_SOFT_DELETE = [
+ "bpp_wydawnictwo_ciagle_autor",
+ "bpp_wydawnictwo_zwarte_autor",
+ "bpp_patent_autor",
+]
+
+# Widoki-wyjątki: nazwa widoku -> uzasadnienie + data weryfikacji.
+#
+# Każdy wpis tutaj to ŚWIADOMA decyzja, nie cichy skip. Jeśli kanarek
+# znajdzie NOWY widok bez filtra, którego nie ma na tej liście — to jest
+# FAIL, a nie sygnał, żeby dopisać go tu po cichu. Zgłoś w recenzji/raporcie
+# i zapytaj, zanim dopiszesz wyjątek.
+#
+# Zweryfikowane 2026-08-06 (naprawa-finalna-report.md, faza 01, punkt
+# "Czego NIE zrobiłem" #1): trzy widoki ``bpp_kronika_*_view``
+# (migracja ``0001_widoki_kronika.sql``) są MARTWE — zero konsumentów w
+# kodzie produkcyjnym, szablonach i modelach Django. Jedyne trafienia
+# "kronika" w repo poza katalogami migracji to `SOURCES.txt`
+# (metadane pakietowania, nie kod). Naprawianie widoku, którego nikt nie
+# czyta, byłoby czystym churnem — zostawione świadomie, do ewentualnego
+# DROP-u przy innej okazji.
+WYJATKI = {
+ "bpp_kronika_wydawnictwo_ciagle_view": (
+ "martwy widok (migracja 0001_widoki_kronika.sql) — zero "
+ "konsumentów w kodzie/szablonach/modelach; jedyne trafienia "
+ "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane "
+ "2026-08-06, naprawa-finalna-report.md faza 01."
+ ),
+ "bpp_kronika_wydawnictwo_zwarte_view": (
+ "martwy widok (migracja 0001_widoki_kronika.sql) — zero "
+ "konsumentów w kodzie/szablonach/modelach; jedyne trafienia "
+ "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane "
+ "2026-08-06, naprawa-finalna-report.md faza 01."
+ ),
+ "bpp_kronika_patent_view": (
+ "martwy widok (migracja 0001_widoki_kronika.sql) — zero "
+ "konsumentów w kodzie/szablonach/modelach; jedyne trafienia "
+ "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane "
+ "2026-08-06, naprawa-finalna-report.md faza 01."
+ ),
+}
+
+
+def _regex_dla_tabeli(tabela):
+ """Granica słowa po obu stronach — patrz docstring modułu, sekcja
+ "Pułapka dopasowania"."""
+ return re.compile(r"\b" + re.escape(tabela) + r"\b")
+
+
+def widok_uzywa_tabeli(definicja, tabela):
+ """Czy ``definicja`` widoku odwołuje się do ``tabela`` (jako całe słowo,
+ nie substring cudzej, dłuższej nazwy)."""
+ return bool(_regex_dla_tabeli(tabela).search(definicja))
+
+
+def _pobierz_widoki_publiczne(cur):
+ """``(nazwa widoku, definicja)`` dla wszystkich widoków w schemacie
+ ``public`` — ``pg_views.definition``, nie pliki migracji."""
+ cur.execute("SELECT viewname, definition FROM pg_views WHERE schemaname = 'public'")
+ return cur.fetchall()
+
+
+def znajdz_winowajcow(widoki):
+ """Rdzeń kanarka: ``widoki`` to lista ``(nazwa, definicja)`` (jak z
+ ``_pobierz_widoki_publiczne``). Zwraca listę ``(widok, tabela)`` dla
+ par, gdzie widok czyta tabelę objętą soft-delete BEZ ``deleted_at`` w
+ definicji i nie jest na liście ``WYJATKI``.
+
+ Wydzielone z testu, żeby test mutacyjny mógł wywołać dokładnie tę samą
+ logikę na spreparowanym stanie bazy, zamiast duplikować pętlę.
+ """
+ winowajcy = []
+ for widok, definicja in widoki:
+ if widok in WYJATKI:
+ continue
+ for tabela in TABELE_SOFT_DELETE:
+ if not widok_uzywa_tabeli(definicja, tabela):
+ continue
+ if "deleted_at" not in definicja:
+ winowajcy.append((widok, tabela))
+ return winowajcy
+
+
+def _komunikat_naprawy(winowajcy):
+ linie = "\n".join(f" - {widok} (czyta {tabela})" for widok, tabela in winowajcy)
+ return (
+ "Kanarek katalogowy: widoki poniżej czytają tabelę objętą "
+ "soft-delete, ale ich definicja w pg_views NIE zawiera "
+ "'deleted_at' — soft-deletowane wiersze wyciekają do wyniku "
+ "widoku.\n\n"
+ f"{linie}\n\n"
+ "NAPRAW jednym z wzorców (src/bpp/migration_util.py + przykłady):\n"
+ " - widok źródłowy / prosty WHERE-JOIN: widok_dopisz_warunek()\n"
+ " (patrz migracje 0489_soft_delete_autorzy_views.py,\n"
+ " 0495_nowe_sumy_bez_skasowanych.py),\n"
+ " - widok z agregatem (count() itp.) po LEFT JOIN: użyj\n"
+ " 'FILTER (WHERE ... deleted_at IS NULL)', NIE warunku w\n"
+ " WHERE/JOIN — inaczej LEFT JOIN staje się efektywnym INNER\n"
+ " JOIN i wywala z widoku cały wiersz nadrzędny, któremu\n"
+ " soft-deletowano WSZYSTKICH potomków (patrz\n"
+ " 0494_liczba_autorow_bez_skasowanych.py).\n\n"
+ "Jeśli widok jest MARTWY i naprawa byłaby czystym churnem — "
+ "dopisz go do WYJATKI w tym pliku z uzasadnieniem i datą "
+ "weryfikacji (nie grep-iem po jednym słowie kluczowym — sprawdź "
+ "pg_depend / realnych konsumentów), a NIE zgłaszaj tego po cichu."
+ )
+
+
+@pytest.mark.django_db
+def test_kanarek_widoki_soft_delete_filtruja_po_deleted_at():
+ """Rdzeń kanarka fazy 01/02+: widok czytający tabelę soft-delete musi
+ znać ``deleted_at``, chyba że jest jawnie wyjątkiem."""
+ with connection.cursor() as cur:
+ widoki = _pobierz_widoki_publiczne(cur)
+ winowajcy = znajdz_winowajcow(widoki)
+ assert not winowajcy, _komunikat_naprawy(winowajcy)
+
+
+@pytest.mark.django_db
+def test_kanarek_widzi_przynajmniej_widoki_zrodlowe():
+ """Sanity kanarka: gdyby zapytanie do ``pg_views`` zwróciło pustą listę
+ (zła nazwa kolumny, zły schemat, migracje niezastosowane), poprzedni
+ test zazieleniłby się FAŁSZYWIE (``not []`` == ``True``). Pilnujemy, że
+ kanarek realnie coś widzi."""
+ with connection.cursor() as cur:
+ widoki = _pobierz_widoki_publiczne(cur)
+ nazwy = {w for w, _ in widoki}
+ oczekiwane = {
+ "bpp_wydawnictwo_ciagle_autorzy",
+ "bpp_wydawnictwo_zwarte_autorzy",
+ "bpp_patent_autorzy",
+ }
+ assert oczekiwane <= nazwy, (
+ f"Oczekiwane widoki źródłowe nieznalezione w pg_views: "
+ f"{oczekiwane - nazwy}. Kanarek patrzy na pustą/złą bazę."
+ )
+
+
+@pytest.mark.django_db
+def test_kanarek_lapie_swiezy_wyjatek_ktorego_nie_ma_na_liscie():
+ """Kanarek NIE ma cichej furtki: widok bez filtra i BEZ wpisu w
+ ``WYJATKI`` musi wywalić test, nawet jeśli akurat jest to jeden z
+ już-znanych, zweryfikowanych martwych widoków ``kronika``."""
+ with connection.cursor() as cur:
+ widoki = _pobierz_widoki_publiczne(cur)
+ # Celowo NIE wykluczamy widoków z WYJATKI tutaj (na odwrót niż
+ # znajdz_winowajcow) — symulujemy dokładnie sytuację "WYJATKI jest
+ # pusta/nie wie o tym widoku", żeby sprawdzić, czy SAMA logika
+ # dopasowania (bez listy wyjątków) łapie znane martwe widoki kronika.
+ winowajcy = [
+ (widok, tabela)
+ for widok, definicja in widoki
+ for tabela in TABELE_SOFT_DELETE
+ if widok_uzywa_tabeli(definicja, tabela) and "deleted_at" not in definicja
+ ]
+ # Znane martwe widoki kronika MUSZĄ się tu pojawić — inaczej ta lista
+ # wyjątków przestała być testowalna (np. ktoś je usunął z bazy albo
+ # dopisał im filtr, więc WYJATKI trzeba by odchudzić).
+ zlapane = {w for w, _ in winowajcy}
+ oczekiwane_bez_wyjatku = set(WYJATKI) & {
+ "bpp_kronika_wydawnictwo_ciagle_view",
+ "bpp_kronika_wydawnictwo_zwarte_view",
+ "bpp_kronika_patent_view",
+ }
+ assert oczekiwane_bez_wyjatku <= zlapane, (
+ "Kanarek przestał wykrywać znane widoki bez filtra po zdjęciu "
+ "wyjątku — matcher/logika się zepsuły."
+ )
+
+
+# --- Testy SAMEGO matchera (§ "Pułapka dopasowania") ------------------
+
+
+@pytest.mark.parametrize(
+ "tekst,oczekiwane",
+ [
+ ("SELECT * FROM bpp_wydawnictwo_ciagle_autor", True),
+ ("SELECT * FROM bpp_wydawnictwo_ciagle_autor WHERE x", True),
+ ('"bpp_wydawnictwo_ciagle_autor".autor_id', True),
+ # Pułapka z brief-u: prefiks widoku źródłowego already-filtered.
+ ("SELECT * FROM bpp_wydawnictwo_ciagle_autorzy", False),
+ # Kolumna złożona z nazwy tabeli + "_id" — inny, dłuższy identyfikator.
+ ("SELECT bpp_wydawnictwo_ciagle_autor_id FROM x", False),
+ # Pusty/niepowiązany tekst.
+ ("SELECT 1", False),
+ ],
+)
+def test_matcher_nie_myli_autor_z_autorzy(tekst, oczekiwane):
+ """``bpp_wydawnictwo_ciagle_autor`` jest PREFIKSEM
+ ``bpp_wydawnictwo_ciagle_autorzy`` — dokładnie ta pułapka z briefu.
+ Naiwny ``in``/``LIKE`` dałby ``True`` dla drugiego i czwartego
+ przypadku. Ten test jest wyrocznią matchera — jeśli ktoś refaktorem
+ zdejmie granicę słowa, ten test ma czerwienieć pierwszy."""
+ assert widok_uzywa_tabeli(tekst, "bpp_wydawnictwo_ciagle_autor") is oczekiwane
+
+
+@pytest.mark.parametrize(
+ "tekst,oczekiwane",
+ [
+ # Odwrotna pułapka (aktualna od razu, na wyrost pod fazę 02):
+ # "bpp_wydawnictwo_ciagle" jest PREFIKSEM
+ # "bpp_wydawnictwo_ciagle_autor" — matcher szukający krótszej nazwy
+ # (tabela publikacji) nie może się nabrać na dłuższą (tabela
+ # autorstwa).
+ ("SELECT * FROM bpp_wydawnictwo_ciagle", True),
+ ("SELECT * FROM bpp_wydawnictwo_ciagle_autor", False),
+ ("SELECT * FROM bpp_wydawnictwo_ciagle_view", False),
+ ],
+)
+def test_matcher_prefiks_tabeli_publikacji_kontra_tabela_autorstwa(tekst, oczekiwane):
+ """Ta sama klasa pułapki co ``test_matcher_nie_myli_autor_z_autorzy``,
+ tylko w drugą stronę — istotna od momentu, gdy faza 02 dopisze
+ ``bpp_wydawnictwo_ciagle`` (bez ``_autor``) do
+ ``TABELE_SOFT_DELETE``."""
+ assert widok_uzywa_tabeli(tekst, "bpp_wydawnictwo_ciagle") is oczekiwane
+
+
+@pytest.mark.django_db
+def test_mutacja_widoku_bez_filtra_kanarek_pada():
+ """Weryfikacja MUTACYJNA: podmieniamy naprawiony widok źródłowy na
+ wersję BEZ filtra (surowe ``CREATE OR REPLACE VIEW`` w transakcji
+ testowej — Postgres DDL jest transakcyjny, więc to i tak by się cofnęło
+ wraz z rollbackiem testu; mimo to przywracamy jawnie, żeby test
+ dowodził całego cyklu, nie polegał tylko na tym, że pytest posprząta).
+
+ Bez tego testu nie wiadomo, czy kanarek COKOLWIEK pilnuje — testy
+ „substringowe" (czy widok zawiera ``deleted_at``) mogłyby przechodzić
+ fałszywie pozytywnie z powodów niezwiązanych z logiką (np. literówka w
+ nazwie kolumny w komentarzu SQL).
+ """
+ widok = "bpp_wydawnictwo_ciagle_autorzy"
+ with connection.cursor() as cur:
+ cur.execute("SELECT pg_get_viewdef(%s::regclass)", [widok])
+ oryginal = cur.fetchone()[0].rstrip().rstrip(";")
+
+ # Format zgodny z pg_get_viewdef(regclass) BEZ jawnego pretty=true
+ # (czyli tym samym, co zwraca pg_views.definition, którego używa
+ # znajdz_winowajcow) — Postgres owija tu warunek w nawias.
+ sufiks = "\n WHERE (deleted_at IS NULL)"
+ assert oryginal.endswith(sufiks), (
+ f"{widok}: definicja nie kończy się oczekiwanym filtrem — "
+ "test mutacyjny wymaga aktualizacji po zmianie SQL-a widoku. "
+ f"Definicja: ...{oryginal[-120:]!r}"
+ )
+ zepsuta = oryginal[: -len(sufiks)]
+ assert "deleted_at" not in zepsuta, (
+ "zdjęcie sufiksu nie usunęło deleted_at z definicji — "
+ "widok ma filtr gdzieś indziej niż w ostatnim WHERE"
+ )
+
+ try:
+ cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {zepsuta}")
+
+ widoki_po_mutacji = _pobierz_widoki_publiczne(cur)
+ winowajcy = znajdz_winowajcow(widoki_po_mutacji)
+ zlapani = {w for w, _ in winowajcy}
+ assert widok in zlapani, (
+ "MUTACJA NIE ZOSTAŁA WYKRYTA — kanarek nie pilnuje niczego. "
+ f"Winowajcy po mutacji: {winowajcy}"
+ )
+ finally:
+ # Przywracamy NIEZALEŻNIE od wyniku asercji powyżej — inaczej
+ # test padający zostawiłby popsuty widok do końca transakcji
+ # (nieszkodliwe przez rollback, ale przywracamy jawnie, patrz
+ # docstring).
+ cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {oryginal}")
+
+ widoki_po_przywroceniu = _pobierz_widoki_publiczne(cur)
+ winowajcy_po = znajdz_winowajcow(widoki_po_przywroceniu)
+ zlapani_po = {w for w, _ in winowajcy_po}
+ assert widok not in zlapani_po, (
+ "widok nie wrócił do stanu naprawionego po przywróceniu "
+ "oryginalnej definicji"
+ )
From 904927c064082955abf9433aa378db3f5f97fe25 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 22:51:06 +0200
Subject: [PATCH 35/75] =?UTF-8?q?docs(soft-delete):=20synchronizacja=20pla?=
=?UTF-8?q?n=C3=B3w/spec=20ze=20stanem=20po=20fazie=2001?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Naprawa blokerów finalnej recenzji fazy 01 zmieniła stan opisany w
planach i specu — dokumenty dogonione:
- plan 01: usunięty *_uniq_rekord_autor_kolejnosc zdjęty z przykładów
kodu (redundantny wobec ExclusionConstraint, patrz nowy krok 3c.2b);
numeracja migracji rozszerzona na pełny łańcuch 0488-0495 +
rozbieznosci_dyscyplin/0022; indeks deleted_at pokazany jako
częściowy (condition=Q(deleted_at__isnull=False)).
- plan 02: dopisane ostrzeżenie na górze dokumentu — faza 02 musi (a)
rozszerzyć stałą kanarka katalogowego o 5 tabel publikacji, (b)
użyć FILTER zamiast warunku w JOIN/WHERE dla agregatów pochodnych
(inaczej publikacja bez żywych autorów zniknęłaby z bpp_rekord_mat),
(c) przejrzeć bpp_nowe_sumy_* i rozbieznosci_dyscyplin pod kątem
deleted_at PUBLIKACJI (nie tylko autora, już zrobione w 0495/0022);
numeracja startu migracji poprawiona na 0496.
- spec §2.1: mechanizmów jest CZTERY, nie trzy — dopisany punkt 4
"widoki i agregaty POCHODNE", z odniesieniem do kanarka katalogowego
jako stałego mechanizmu pilnującego tego niezmiennika.
Co-Authored-By: Claude Opus 5 (1M context)
---
...-04-soft-delete-01-autor-trigger-widoki.md | 96 +++++++++++++++++--
.../2026-06-04-soft-delete-02-publikacje.md | 56 ++++++++++-
...soft-delete-publikacje-i-autorzy-design.md | 39 +++++++-
3 files changed, 176 insertions(+), 15 deletions(-)
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
index c510d7d25..d8a1c2ba4 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md
@@ -48,7 +48,22 @@ Gałęzie `UNION` w `bpp_rekord` per typ publikacji NIE filtrują po `*_autor.de
- **Trigger (AKTUALNY, po PR #363):** `0432_cache_trigger_plpgsql.py` generuje 3 funkcje `bpp_refresh_autor_()` (upsert **bez** DELETE, `_create_through_function`) + 3 `bpp_delete_autor_()`, oraz triggery `_cache_ins` / `_cache_del` / `_cache_upd`. `0433_cache_trigger_when_gate.py` nakłada bramkę `WHEN` na `_cache_upd`, z listą kolumn wyliczoną z `pg_depend`. ⚠️ **Funkcja `bpp_refresh_cache()` NIE ISTNIEJE** — `DROP` w `0432`. Nie kopiować `0399` ani `0001_cache_functions.sql`.
- Widoki `bpp_*_autorzy`: ostatnia wersja definicji w `0421_cache_trigger_pk_filter.sql` (dodaje `object_id_raw`). Odtwarzając widok, wychodź z `pg_get_viewdef()`, nie z `0001_widoki_autorzy.sql`.
- ⚠️ **`transactional_db` NIE jest wymagany** do oglądania efektów triggera — triggery bazodanowe działają wewnątrz transakcji testowej (dowód: kanarki `test_soft_delete_preconditions.py` chodzą pod zwykłym `django_db`). Używaj `django_db`; `transactional_db` tylko spowalnia. Fixture `denorms` (`src/fixtures/conftest_system.py:255`) daje `denorms.flush()`. Fixtury: `wydawnictwo_ciagle_z_dwoma_autorami`, `wydawnictwo_ciagle_z_autorem`, `autor_jan_kowalski`, `jednostka`, `standard_data`, `typy_odpowiedzialnosci`.
-- ⚠️ **Numeracja migracji (stan 2026-08-06):** liść to `0487_api_v1_przelaczniki`. Nowe migracje tej fazy: `0488_autor_soft_delete_fields` → `0489_soft_delete_autorzy_views` (SQL + regeneracja bramki). **Przed startem zweryfikuj liść ponownie** (`ls src/bpp/migrations/*.py | tail -3`) — `dev` żyje, numery mogły się przesunąć. Wszystkie numery w tym planie są orientacyjne; kanoniczna jest kolejność, nie cyfra.
+- ⚠️ **Numeracja migracji (stan po zakończeniu fazy, 2026-08-06):** liść przed
+ fazą to był `0487_api_v1_przelaczniki`. Faza 01 dołożyła w praktyce OSIEM
+ migracji `bpp` — `0488_autor_soft_delete_fields` →
+ `0489_soft_delete_autorzy_views` → `0490_autor_indeks_fk_rekord` →
+ `0491_autor_unique_rekord_autor_typ` → `0492_autor_excl_rekord_kolejnosc` →
+ `0493_autor_zdjecie_starych_unique` → `0494_liczba_autorow_bez_skasowanych`
+ → `0495_nowe_sumy_bez_skasowanych` — plus
+ `rozbieznosci_dyscyplin/0022_rozbieznosci_zrodel_bez_skasowanych.py` w
+ osobnej aplikacji. `0490`-`0493` to rozbite (jedna migracja na `ALTER
+ TABLE`, każda `atomic = False`) to, co ten plan opisuje niżej jako
+ jednolite `0490_autor_warunkowy_unique` (Task 3c) — patrz `progress.md` i
+ `naprawa-finalna-report.md` w katalogu SDD tej fazy po uzasadnienie
+ rozbicia. `0494`/`0495` naprawiają blokery znalezione dopiero w finalnej
+ recenzji (widoki pochodne `liczba_autorow` i `bpp_nowe_sumy_*`, poza
+ pierwotnym zakresem tego planu). Wszystkie numery w tym planie są
+ orientacyjne; kanoniczna jest kolejność, nie cyfra.
**Kontrakt z reversion (PINNED):** soft-delete idzie WYŁĄCZNIE per-instancja przez `.delete()`/`.save()` (nigdy `queryset.update(deleted_at=...)`). `BppSoftDeleteQuerySet.update()` to egzekwuje fail-fast (gate). W tej fazie testujemy gate i kaskadę queryset-ową.
@@ -339,13 +354,23 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r
- [ ] Zweryfikuj treść wygenerowanej migracji — musi zawierać `AddField` `deleted_at`/`restored_at`/`transaction_id` dla `wydawnictwo_ciagle_autor`, `wydawnictwo_zwarte_autor`, `patent_autor`. Jeśli Django dorzuciło `AlterModelManagers` — usuń tę operację ręcznie (Edit), bo managery soft-delete nie idą do schematu. Dependency MUSI wskazywać na **aktualny liść** migracji `bpp` (na 2026-08-06: `("bpp", "0487_api_v1_przelaczniki")`) — zweryfikuj `ls src/bpp/migrations/*.py | tail -3` przed commitem.
-- [ ] Dodaj indeks na `deleted_at` do każdej z 3 tabel. Dopisz do `operations` w `0488_autor_soft_delete_fields.py` (po `AddField`-ach), używając `AddIndex`:
+- [ ] Dodaj indeks na `deleted_at` do każdej z 3 tabel. ⚠️ **Stan po
+ zakończeniu fazy: indeks jest CZĘŚCIOWY**
+ (`condition=Q(deleted_at__isnull=False)`), nie pełny — zdecydowane w
+ finalnej recenzji: `deleted_at IS NULL` pasuje do ~100% wierszy, więc
+ planner pod ten predykat i tak wybiera seq scan, a pełny btree byłby
+ wyłącznie kosztem (rozmiar + wpis przy każdym zapisie autorstwa).
+ Selektywne jest zapytanie ODWROTNE (`deleted_objects`,
+ `deleted_at IS NOT NULL`) i to jemu służy indeks. Dopisz do `operations`
+ w `0488_autor_soft_delete_fields.py` (po `AddField`-ach), używając
+ `AddIndex`:
```python
migrations.AddIndex(
model_name="wydawnictwo_ciagle_autor",
index=models.Index(
fields=["deleted_at"],
name="wc_autor_deleted_at_idx",
+ condition=models.Q(deleted_at__isnull=False),
),
),
migrations.AddIndex(
@@ -353,6 +378,7 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r
index=models.Index(
fields=["deleted_at"],
name="wz_autor_deleted_at_idx",
+ condition=models.Q(deleted_at__isnull=False),
),
),
migrations.AddIndex(
@@ -360,10 +386,17 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r
index=models.Index(
fields=["deleted_at"],
name="patent_autor_deleted_at_idx",
+ condition=models.Q(deleted_at__isnull=False),
),
),
```
- (Nazwy indeksów ≤ 30 znaków — wymóg PostgreSQL/Django. Jeśli `makemigrations` samo dodało `Meta.indexes` przez zmianę modelu — nie dublować; w tej fazie indeks definiujemy WYŁĄCZNIE w migracji, bo `Meta.indexes` w abstrakcyjnej bazie dałby kolizję nazw między 3 tabelami.)
+ (Nazwy indeksów ≤ 30 znaków — wymóg PostgreSQL/Django. W finalnym
+ stanie te same indeksy MUSZĄ być odzwierciedlone też w `Meta.indexes`
+ każdego z 3 konkretnych modeli — patrz `wydawnictwo_ciagle.py`,
+ `wydawnictwo_zwarte.py`, `patent.py` — inaczej `makemigrations --check`
+ wykrywa drift; `Meta.indexes` w ABSTRAKCYJNEJ bazie dałoby kolizję nazw
+ między 3 tabelami, dlatego indeks jest w `Meta` konkretnych klas, nie w
+ `BazaModeluOdpowiedzialnosciAutorow`.)
- [ ] Uruchom `makemigrations --check` (oczekiwane: brak nowych zmian — model i migracja zgodne):
```bash
@@ -894,7 +927,8 @@ więc komunikat błędu mówi o rekordzie, którego nie widać.
— ma przejść po tym tasku.
- [ ] **Krok 3c.2 — zamiana w `Meta` 3 konkretnych klas.** Usuń `unique_together`,
- dodaj warunkowe constrainty (nazwy MUSZĄ być unikalne w całej bazie):
+ dodaj warunkowy `UniqueConstraint` na `(rekord, autor, typ_odpowiedzialnosci)`
+ (nazwy MUSZĄ być unikalne w całej bazie):
```python
constraints = [
models.UniqueConstraint(
@@ -902,15 +936,48 @@ więc komunikat błędu mówi o rekordzie, którego nie widać.
condition=Q(deleted_at__isnull=True),
name="wc_autor_uniq_rekord_autor_typ",
),
- models.UniqueConstraint(
- fields=["rekord", "autor", "kolejnosc"],
- condition=Q(deleted_at__isnull=True),
- name="wc_autor_uniq_rekord_autor_kolejnosc",
- ),
]
```
(prefiksy per model: `wc_` / `wz_` / `pat_`)
+ ⚠️ **Stan po zakończeniu fazy: BEZ `UniqueConstraint(rekord, autor,
+ kolejnosc)`.** Naiwna wersja powyżej (i ta pierwotnie w tym planie)
+ dokładała jeszcze drugi warunkowy `UniqueConstraint` na
+ `(rekord, autor, kolejnosc)` (`..._uniq_rekord_autor_kolejnosc`) — recenzja
+ finalna go USUNĘŁA jako w 100% redundantny wobec `ExclusionConstraint`
+ `..._excl_rekord_kolejnosc` niżej (krok 3c.2b): ten pilnuje pary
+ `(rekord, kolejnosc)` NIE patrząc na autora, więc jest ściśle silniejszy —
+ skoro w obrębie rekordu żadna pozycja się nie powtarza, to tym bardziej
+ nie powtórzy się w obrębie `(rekord, autor)`. Jeśli implementujesz ten
+ plan od zera — pomiń `..._uniq_rekord_autor_kolejnosc` w ogóle, żeby nie
+ budować trzeciego, martwego indeksu na najgorętszej ścieżce zapisu.
+
+- [ ] **Krok 3c.2b — `ExclusionConstraint` na `(rekord, kolejnosc)`.**
+ Legacy raw-SQL `UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY
+ DEFERRED` z migracji `0132` (2018) jest DRUGĄ, niezależną od ORM
+ przyczyną tej samej klasy regresji (niewidoczny dla `makemigrations`).
+ Zastąp go, per model, przez:
+ ```python
+ ExclusionConstraint(
+ name="wc_autor_excl_rekord_kolejnosc",
+ expressions=[
+ ("rekord", RangeOperators.EQUAL),
+ ("kolejnosc", RangeOperators.EQUAL),
+ ],
+ condition=Q(deleted_at__isnull=True),
+ deferrable=Deferrable.DEFERRED,
+ ),
+ ```
+ `deferrable` jest tu wymagane przez drag&drop reorder w adminie
+ (adminsortable2, `sortable_field_name = "kolejnosc"`) — zamiana
+ kolejności dwóch wierszy przejściowo dubluje `kolejnosc` w obrębie jednej
+ transakcji. `UniqueConstraint` nie umie łączyć `condition` z `deferrable`
+ (Django to blokuje), stąd `ExclusionConstraint` (wymaga `btree_gist`,
+ patrz migracja `0056`) — jedyny typ ograniczenia w Postgresie, który
+ łączy `WHERE` z `DEFERRABLE`. Skasuj stary legacy `UNIQUE` z `0132` osobną
+ migracją `RunSQL` (`DROP CONSTRAINT IF EXISTS ...`), w kroku PO dodaniu
+ nowych constraintów — zob. „Kolejność" niżej.
+
- [ ] **Krok 3c.3 — ⚠️ ZWERYFIKUJ ADMIN.** Komentarz przy drugiej krotce w kodzie
(„Tu musi być autor, inaczej admin nie pozwoli wyedytować") sugeruje, że ten
constraint istnieje ze względu na **walidację formularzy**.
@@ -1143,5 +1210,14 @@ grep -rEn --include='*.py' "$P" src/ | grep -vcE '/tests?/|test_'
3. **Każda przyszła zmiana definicji widoku źródłowego wymaga regeneracji bramki `WHEN`.** Bramka jest wypiekana z `pg_depend` w momencie migracji, więc nie zaktualizuje się sama. Pominięcie = cichy staleness. Testy `test_views_sql.py` i kanarki `test_soft_delete_preconditions.py` to wyłapią.
3. **Widoki `bpp_praca_doktorska_autorzy` / `bpp_praca_habilitacyjna_autorzy` NIE filtrowane** — autorstwo doktoratu/habilitacji nie jest `*_Autor` SoftDeleteModel (autor doktoratu to FK `Praca_Doktorska.autor`, nie through). Faza 02 (soft-delete publikacji doktorat/habilitacja) musi zadbać o ich zniknięcie z `bpp_rekord` przez własne `deleted_at` na tabeli publikacji — to NIE jest pokryte tą fazą.
4. **Gałęzie UNION `bpp_rekord` NIE dotknięte** w fazie 01 — soft-delete publikacji (kolumna `deleted_at` na `bpp_wydawnictwo_ciagle` itd.) to faza 02; dopiero ona doda filtr `deleted_at IS NULL` do `bpp_*_view`. Faza 01 dotyka wyłącznie ścieżki autorstwa.
-6. **`unique_together` na `*_Autor` zachowane bez `deleted_at`** — w tej fazie autorstwa nie mają warunkowego unique. **Faza 02 MUSI to zmienić** (decyzja #13, spec §2.2b): soft-deletowany wiersz nadal zajmuje slot w `(rekord, autor, typ_odpowiedzialnosci)` / `(rekord, autor, kolejnosc)`, a `deduplikator_autorow` przenoszący autorstwa trafi wtedy w `IntegrityError` o niewidoczny rekord. Przejście na `UniqueConstraint(condition=Q(deleted_at__isnull=True))` — razem ze slugiem.
+6. ~~`unique_together` na `*_Autor` zachowane bez `deleted_at` — Faza 02 MUSI
+ to zmienić~~ **NIEAKTUALNE, zrobione w tej fazie (Task 3c, patrz wyżej).**
+ Pierwotnie planowane na fazę 02, ale finalna recenzja fazy 01 wykryła
+ regresję na żywym przepływie (`import_sqlite`) już na HEAD Taska 2, więc
+ zamiana `unique_together` → warunkowy `UniqueConstraint` +
+ `ExclusionConstraint` (plus zdjęcie legacy `UNIQUE` z migracji `0132`)
+ wylądowała migracjami `0490`-`0493` W TEJ fazie. Faza 02 dziedziczy
+ gotowy wzorzec — nie musi go powtarzać dla `*_Autor` (tylko ewentualnie
+ dla analogicznych `unique_together` na samych tabelach publikacji, jeśli
+ takie istnieją).
7. **`Cache_Punktacja_*` NIE są dotknięte** żadnym mechanizmem tej fazy (nie mają FK do publikacji ani triggerów cache). Domknięcie tej luki to faza 06 (decyzja #15, spec §2.5b) — do tego czasu soft-deletowana praca nadal liczyłaby się do ewaluacji.
diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
index 8d01ae9a5..c6588b223 100644
--- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
+++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md
@@ -2,6 +2,54 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Każdy krok TDD: napisz padający test → uruchom (oczekiwany FAIL) → minimalna implementacja → uruchom (PASS) → commit.
+> ⚠️ **OSTRZEŻENIE — CZYTAJ PRZED STARTEM (dopisane po naprawie blokerów
+> fazy 01, 2026-08-06).** Faza 01 odkrywała konsumentów surowych tabel
+> `*_autor` **pojedynczo, przez awarie** — najpierw bramka cache'u, potem
+> bramka denorma, `unique_together`, legacy constraint z 2018, a na końcu
+> finalna recenzja znalazła jeszcze trzy widoki pochodne czytające surowe
+> tabele bez filtra na `deleted_at`. Faza 02 (soft-delete PUBLIKACJI:
+> `bpp_wydawnictwo_ciagle`, `bpp_wydawnictwo_zwarte`, `bpp_patent`,
+> `bpp_praca_doktorska`, `bpp_praca_habilitacyjna`) trafi na dokładnie ten
+> sam problem, jeśli nie zaadresuje go z góry. Trzy obowiązkowe rzeczy:
+>
+> **(a) Rozszerz kanarka katalogowego o 5 tabel publikacji.** Test
+> `src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py` (dodany razem
+> z tym ostrzeżeniem) trzyma listę tabel objętych soft-delete jako stałą
+> modułową `TABELE_SOFT_DELETE` — dopisanie 5 tabel publikacji ma być
+> jednolinijkową zmianą (skonstruowaną tak celowo). Kanarek odpytuje żywy
+> `pg_views` i pilnuje, że KAŻDY widok czytający którąkolwiek z tych tabel
+> filtruje po `deleted_at`, chyba że jest na jawnej liście wyjątków z
+> uzasadnieniem. Rozszerz listę na starcie fazy 02, ZANIM zaczniesz pisać
+> DDL — kanarek od razu zacznie łapać widoki, które trzeba naprawić, zamiast
+> odkrywać je po raz drugi przez awarie na produkcji.
+>
+> **(b) Sprawdź `count()`/agregaty w widokach pochodnych pod kątem
+> `FILTER` vs warunek w `JOIN`/`WHERE`.** Wzorzec z migracji `0494`
+> (`bpp/migrations/0494_liczba_autorow_bez_skasowanych.py`, faza 01):
+> `bpp__view` liczy `count(_autor.autor_id) AS liczba_autorow`
+> przez `LEFT JOIN` do tabeli `*_autor`. Dopisanie `deleted_at IS NULL` do
+> `WHERE`/`JOIN ... ON` zamieniłoby `LEFT JOIN` w efektywny `INNER JOIN` dla
+> publikacji, którym soft-deletowano WSZYSTKICH autorów — cała publikacja
+> wypadłaby z widoku, więc z `bpp_rekord_mat` i z całego serwisu (dokładnie
+> ten błąd, gdyby ktoś naiwnie dopisał warunek do `WHERE`). Właściwy wzorzec
+> to `count(...) FILTER (WHERE ... deleted_at IS NULL)` — rusza wyłącznie
+> licznik, nie krotność joina. Dla soft-delete PUBLIKACJI analogiczne
+> ryzyko dotyczy każdego agregatu w `bpp_*_view`/`bpp_rekord` liczącego coś
+> przez `LEFT JOIN` do tabeli publikacji.
+>
+> **(c) Przejrzyj `bpp_nowe_sumy_*` i `rozbieznosci_dyscyplin` pod kątem
+> `deleted_at` PUBLIKACJI** (nie tylko autora — to już zrobione w fazie 01,
+> migracje `0495_nowe_sumy_bez_skasowanych` i
+> `rozbieznosci_dyscyplin/0022_rozbieznosci_zrodel_bez_skasowanych`). Te
+> same widoki (i prawdopodobnie inne w `ranking_autorow`) czytają też
+> kolumny/joiny do tabel publikacji — sprawdź `pg_depend`, nie tylko grep,
+> czy po dodaniu `deleted_at` na `bpp_wydawnictwo_ciagle` itd. te widoki
+> wymagają analogicznej poprawki.
+>
+> Raport z naprawy blokerów fazy 01 (metoda, mutation test kanarka, pełna
+> lista sprawdzonych widoków):
+> `.superpowers/sdd/2026-06-04-soft-delete-01-autor-trigger-widoki/kanarek-report.md`.
+
**Goal:** Uczynić 5 modeli publikacji (`Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`, `Praca_Habilitacyjna`, `Patent`) `SoftDeleteModel`-ami z **wąską, kontrolowaną kaskadą** soft-delete na własne wiersze `*_Autor` (`Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) pod wspólnym `transaction_id`, **bez** refleksyjnej kaskady pakietu (która ruszyłaby `*_Streszczenie` itd.). Zamienić `slug unique=True` na warunkowy `UniqueConstraint` (reuse slug po soft-delete). Przepleść filtr soft-delete z istniejącymi menedżerami `Wydawnictwo_*_Manager` (mixin opłat) przez wspólny QuerySet/MRO, bez nadpisywania metod fees.
**Architecture:** `django-soft-delete` daje `SoftDeleteModel` (pola `deleted_at`/`restored_at`/`transaction_id`, menedżery `objects`/`global_objects`/`deleted_objects`, sygnały `post_soft_delete`/`post_restore`/`post_hard_delete`). Faza 01 utworzyła `src/bpp/models/soft_delete.py` z `BppSoftDeleteQuerySet` (gate na bulk `update(deleted_at=...)`), `BppSoftDeleteManager`, `BppGlobalManager` oraz uczyniła 3 modele `*_Autor` SoftDeleteModel-ami — wraz z DDL-em dla **ścieżki autorstwa** (widoki `bpp_*_autorzy` + gałąź kasująca w `bpp_refresh_autor_*` + bramka `WHEN`). ⚠️ **Dla 5 tabel publikacji ten sam DDL trzeba zrobić w TEJ fazie** — Task 2b. **Ta faza zależy od 01.** Tu nadpisujemy `delete()`/`restore()` na 5 modelach: per-instancja `save()` (NIGDY bulk `update`), jawna wąska kaskada na `autorzy_set` (related_name `*_Autor`→publikacja) przez `.delete(transaction_id=...)`/`.restore(transaction_id=...)` na każdym wierszu (kontrakt z reversion: zawsze per-instancja).
@@ -50,7 +98,13 @@ migracją: `ls src/bpp/migrations/*.py | tail -3`.
- `Praca_Doktorska.autor` FK CASCADE (`praca_doktorska.py:136`), `Praca_Habilitacyjna.autor` O2O PROTECT (`praca_habilitacyjna.py:42`). Te FK to **faza 04** — NIE ruszamy tu.
- **`slug` jest polem `@denormalized(models.SlugField, max_length=400, unique=True, db_index=True, null=True, blank=True)`** (denorm z `django-denorm-iplweb`), w: `wydawnictwo_ciagle.py:246`, `wydawnictwo_zwarte.py:325` (w `Wydawnictwo_Zwarte`), `patent.py:180`, `praca_doktorska.py:105` (w `Praca_Doktorska_Baza` → dziedziczone przez `Praca_Doktorska` **i** `Praca_Habilitacyjna`). Denorm field jest fizyczną kolumną w DB → migracja zmiany `unique=True`→`UniqueConstraint` jest realną migracją schematu.
- `Praca_Habilitacyjna` i `Praca_Doktorska` dziedziczą slug z `Praca_Doktorska_Baza` (abstract) — zmiana atrybutu pola w abstrakcie dotyka OBU modeli; migracje per model (każdy ma własną kolumnę `slug`).
-- ⚠️ **Numeracja migracji (stan 2026-08-06):** faza 01 zajmuje `0488`/`0489`, więc ta faza startuje od `0490`. **Zweryfikuj liść przed startem** (`ls src/bpp/migrations/*.py | tail -3`) — `dev` żyje. Numery w tym planie są orientacyjne; kanoniczna jest kolejność. NIE modyfikuj istniejących migracji.
+- ⚠️ **Numeracja migracji (stan po zakończeniu fazy 01, 2026-08-06):** faza
+ 01 zajęła ostatecznie `0488`-`0495` w `bpp` (nie tylko `0488`/`0489` —
+ patrz naprawa blokerów finalnej recenzji) plus
+ `rozbieznosci_dyscyplin/0022`, więc ta faza startuje od `0496` w `bpp`.
+ **Zweryfikuj liść przed startem** (`ls src/bpp/migrations/*.py | tail -3`)
+ — `dev` żyje. Numery w tym planie są orientacyjne; kanoniczna jest
+ kolejność. NIE modyfikuj istniejących migracji.
- `Zgloszenie_Publikacji` (`src/zglos_publikacje/models.py:60`) — precedens: po prostu dziedziczy `SoftDeleteModel` bez własnego menedżera.
## Kontrakt z reversion (PINNED — NIE łamać)
diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
index 1061bf2d6..d02634f6d 100644
--- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
+++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md
@@ -145,7 +145,16 @@ INSERT INTO bpp_autorzy_mat (...) SELECT ... ;
Soft-delete to uogólnienie tej samej sytuacji na wszystkie 8 tabel.
-**Zmiana — trzy elementy, wszystkie OBOWIĄZKOWE** (żaden nie wystarcza sam):
+**Zmiana — CZTERY elementy, wszystkie OBOWIĄZKOWE** (żaden nie wystarcza sam;
+> 🔄 **liczba poprawiona 2026-08-06** po finalnej recenzji fazy 01: pierwotna
+> wersja tej sekcji zatrzymywała się na trzech i faza 01 dokładnie w to
+> trafiła — widok źródłowy + gałąź kasująca + bramka `WHEN` przeszły bez
+> problemu, ale finalna recenzja osobno znalazła TRZY widoki POCHODNE
+> (`bpp__view.liczba_autorow`, `bpp_nowe_sumy_*_view`,
+> `rozbieznosci_dyscyplin_zrodel`), które czytały surową tabelę `*_autor`
+> niezależnie od widoku źródłowego z punktu 3 i wymagały osobnych migracji
+> `0494`/`0495`/`rozbieznosci_dyscyplin/0022`. Punkt 4 poniżej domyka ten
+> wzorzec jako STAŁY element checklisty, nie jednorazową łatkę):
1. **Gałąź kasująca w 8 funkcjach refresh.** Prolog przed upsertem:
```sql
@@ -169,6 +178,26 @@ Soft-delete to uogólnienie tej samej sytuacji na wszystkie 8 tabel.
nie sprząta `_mat` (to robi punkt 1), tylko (a) karmi `pg_depend` dla
punktu 2 i (b) gwarantuje, że pełne przebudowy i odczyty przez `bpp_rekord`
nie wskrzeszą kosza.
+4. **Widoki i agregaty POCHODNE** — każdy widok SQL poza `bpp_*_view`/
+ `bpp_*_autorzy` (punkt 3), który JOIN-uje lub podzapytuje którąkolwiek z
+ 8 tabel objętych soft-delete, musi być znaleziony i naprawiony osobno —
+ filtr w widoku źródłowym NIE propaguje się automatycznie do widoków,
+ które czytają surową tabelę bazową zamiast widoku już przefiltrowanego.
+ Znalezione (i naprawione) w fazie 01: `bpp__view.liczba_autorow`
+ (`count()` po surowej `*_autor`), `bpp_nowe_sumy_*_view` (ranking
+ autorów), `rozbieznosci_dyscyplin_zrodel`. Dla `count()`/agregatów: użyj
+ `FILTER (WHERE deleted_at IS NULL)`, NIE warunku w `JOIN`/`WHERE` — ten
+ drugi zamienia `LEFT JOIN` w efektywny `INNER JOIN` i wywala z widoku
+ całą publikację, której WSZYSTKIM autorom soft-deletowano wiersz
+ (patrz `bpp/migrations/0494_liczba_autorow_bez_skasowanych.py`).
+ **Źródłem prawdy o tym, co jeszcze nie jest naprawione, jest żywy
+ `pg_views` — nie pliki migracji** (późniejsze migracje nadpisują
+ wcześniejsze definicje widoków, a `baseline.sql` bywa snapshotem sprzed
+ części migracji). Faza 01 dodała kanarka katalogowego
+ (`src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py`), który
+ odpytuje `pg_views` i pilnuje tego niezmiennika automatycznie dla całej
+ listy tabel objętych soft-delete — faza 02 rozszerza jego stałą
+ modułową o 5 tabel publikacji zamiast odkrywać te widoki ręcznie.
**Jednolitość dzięki wąskiej kaskadzie na `*_Autor` (§2.2).** Ponieważ
through-modele też stają się `SoftDeleteModel`, każda z 8 tabel pod triggerem
@@ -707,9 +736,11 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`,
8. **Retencja:** brak auto-czyszczenia; tylko ręczny hard-delete.
9. ~~**Cache — mechanizm nadrzędny:** filtr `deleted_at IS NULL` w widokach
źródłowych; trigger-skip to opcjonalna optymalizacja.~~
- **UNIEWAŻNIONE 2026-08-06.** Zastąpione przez: **trzy elementy, wszystkie
- obowiązkowe** — (a) filtr w widoku, (b) gałąź kasująca w funkcji refresh,
- (c) regeneracja bramki `WHEN`. Żaden nie wystarcza sam (§2.1).
+ **UNIEWAŻNIONE 2026-08-06.** Zastąpione przez: **cztery elementy, wszystkie
+ obowiązkowe** — (a) filtr w widoku źródłowym, (b) gałąź kasująca w
+ funkcji refresh, (c) regeneracja bramki `WHEN`, (d) widoki i agregaty
+ POCHODNE (dopisany po finalnej recenzji fazy 01 — patrz §2.1 punkt 4).
+ Żaden nie wystarcza sam (§2.1).
10. **SentData przy wycofaniu:** `submitted_successfully=False` + znacznik
wycofania, wiersza nie kasujemy.
11. **Self-FK `Wydawnictwo_Zwarte` (rozdziały):** **PROTECT** — soft-delete
From 84ce5c7beac79fd7809d79ece91968e77ea7226e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Thu, 6 Aug 2026 23:32:44 +0200
Subject: [PATCH 36/75] =?UTF-8?q?fix(soft-delete):=20kanarek=20katalogowy?=
=?UTF-8?q?=20u=C5=BCywa=20pg=5Fdepend=20zamiast=20substringa?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rdzeń kanarka sprawdzał tylko "deleted_at" not in definicja - substring
niekwalifikowany tabelą. Dla obecnych 3 tabel *_autor to wystarczało, ale
symulacja fazy 02 (dopisanie 5 tabel publikacji do TABELE_SOFT_DELETE)
pokazała fałszywą zieleń: widok czytający np. bpp_wydawnictwo_ciagle w
tym samym JOIN co już-naprawioną bpp_wydawnictwo_ciagle_autor dziedziczył
tekstowo "deleted_at" z filtra po tabeli through, mimo braku filtra po
WŁASNYM deleted_at tabeli publikacji. Zweryfikowane na żywym katalogu:
3 z 4 widoków czytających bpp_wydawnictwo_ciagle przechodziły starym
testem na zielono bez faktycznego filtra.
Naprawa: rdzeń pyta katalog pg_depend (ten sam mechanizm, którego
migracje 0433/0489/0494 używają do bramek WHEN) o dwie relacje - "widok
czyta tabelę" i "widok zależy od kolumny tabela.deleted_at" - zamiast
o tekst definicji. Matcher tekstowy (widok_uzywa_tabeli) zostaje w
module wyłącznie dla własnych testów regresyjnych i do odtworzenia
starego algorytmu w teście dowodowym.
Nowy trwały test dowodowy
(test_symulacja_fazy_02_kanarek_lapie_widoki_ktore_stara_wersja_przepuszczala)
symuluje TABELE_SOFT_DELETE + 5 tabel publikacji i asertuje dwustronnie:
stary algorytm przepuszczał 3 konkretne widoki, nowy je łapie. Sabotaż
regresyjny (surowi = set()) potwierdził, że ten test i test mutacyjny
faktycznie czerwienieją przy zepsutym rdzeniu.
TABELE_SOFT_DELETE nadal jest kilkulinijkową listą do dopisania w fazie
02 - rdzeń pg_depend jest generyczny względem listy. Ale po dopisaniu 5
tabel publikacji kanarek złapie też dwa dodatkowe widoki kronika
(praca_doktorska/habilitacyjna), które nie są jeszcze na liście WYJATKI -
opisane wprost w komentarzu w kodzie.
Raport z tabelą przed/po (poza gitem, .superpowers/ jest w .gitignore):
.superpowers/sdd/2026-06-04-soft-delete-01-autor-trigger-widoki/kanarek-fix-report.md
uv run pytest src/bpp/tests/test_soft_delete/ -q -> 66 passed.
ruff check / ruff format --check czyste.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
---
.../test_kanarek_katalogowy.py | 398 ++++++++++++++----
1 file changed, 321 insertions(+), 77 deletions(-)
diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py
index a5072a539..7ea033aff 100644
--- a/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py
+++ b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py
@@ -1,6 +1,6 @@
"""Kanarek katalogowy: KAŻDY widok czytający tabelę objętą soft-delete
-MUSI filtrować po ``deleted_at`` — albo być na jawnej, uzasadnionej liście
-wyjątków.
+MUSI zależeć od jej kolumny ``deleted_at`` — albo być na jawnej,
+uzasadnionej liście wyjątków.
## Dlaczego ten test istnieje
@@ -18,29 +18,47 @@
niezmiennika automatycznie, zamiast czekać na kolejną rundę odkryć przez
awarie.
-## Źródło prawdy
-
-``pg_views`` (schemat ``public``), na ŻYWEJ bazie — NIE pliki migracji.
-Późniejsze migracje nadpisują wcześniejsze definicje widoków (
-``CREATE OR REPLACE VIEW``), więc treść ``.sql`` w katalogu migracji nie
-musi być tym, co faktycznie stoi w bazie. ``baseline-sql/baseline.sql`` też
-zawodzi jako źródło — to snapshot sprzed części migracji (sprawdzone:
-``baseline.sql`` ma ``NULL::bigint AS liczba_autorow`` zamiast realnego
-``count()`` z migracji ``0494``).
-
-## Pułapka dopasowania (naiwny ``in``/``LIKE``)
-
-``bpp_wydawnictwo_ciagle_autor`` jest PREFIKSEM
-``bpp_wydawnictwo_ciagle_autorzy`` (widok źródłowy already-filtered) — i,
-symetrycznie w drugą stronę wobec fazy 02, ``bpp_wydawnictwo_ciagle``
-(tabela publikacji) jest PREFIKSEM ``bpp_wydawnictwo_ciagle_autor``. Naiwne
-dopasowanie substringiem dałoby fałszywe trafienia w obie strony. Matcher
-niżej używa granicy słowa (``\\b``) na obu końcach nazwy tabeli — w Pythonie
-``re`` podkreślnik ``_`` jest znakiem ``\\w``, więc ``\\b`` NIE tworzy
-granicy między ``ciagle`` a następującym po nim ``_autor``, ani między
-``autor`` a następującym po nim ``zy`` — obie „doklejki" są więc już z
-konstrukcji odrzucane. ``test_matcher_...`` niżej to udowadnia i pilnuje,
-żeby refaktor tego nie zepsuł.
+## Źródło prawdy: ``pg_depend``, nie tekst definicji
+
+Pierwsza wersja tego kanarka (patrz historia gita) sprawdzała obecność
+podłańcucha ``"deleted_at" in definicja`` — GOŁY, NIEKWALIFIKOWANY
+tabelą. Recenzja fazy 02 wykazała, że to daje FAŁSZYWĄ ZIELEŃ dokładnie
+na tej klasie błędu, przed którą kanarek ma chronić: widok czytający
+``bpp_wydawnictwo_ciagle`` (tabela publikacji, faza 02) ODZIEDZICZA
+podłańcuch ``deleted_at`` z filtra po ``bpp_wydawnictwo_ciagle_autor``
+(tabela through, faza 01, w tym samym ``JOIN``), mimo że wcale nie filtruje
+po WŁASNYM ``deleted_at`` tabeli publikacji. Zweryfikowane na żywym
+katalogu 2026-08-06: **trzy widoki na cztery** czytające
+``bpp_wydawnictwo_ciagle`` przechodziłyby starym testem na zielono, mimo
+braku filtra po tej tabeli (patrz ``kanarek-fix-report.md`` — tabela
+przed/po).
+
+Naprawa: zamiast szukać substringa w tekście, pytamy katalog Postgresa
+``pg_depend``, czy widok FAKTYCZNIE zależy od KOLUMNY ``deleted_at``
+KONKRETNEJ tabeli — dokładnie to samo narzędzie, którego migracje
+``0433``/``0489``/``0494`` używają do budowy bramek ``WHEN`` (kolumny
+bazowe referowane przez widok, na poziomie ``pg_rewrite`` → ``pg_depend``
+→ ``pg_attribute``). Tej samej techniki (bez filtra po nazwie kolumny)
+używamy też do wykrycia relacji „widok W czyta tabelę T" — ``pg_depend``
+zamiast tekstu, więc znika też klasa pułapek tekstowych, których stary
+matcher musiał unikać ręcznie: ``deleted_at`` w komentarzu SQL, w aliasie
+kolumny, w cudzej kolumnie o tej samej nazwie.
+
+``pg_views`` (schemat ``public``), na ŻYWEJ bazie, pozostaje źródłem
+prawdy TYLKO dla testów sanity/mutacyjnego i dla symulacji „starego
+algorytmu" (patrz niżej) — NIE dla rdzenia kanarka. Późniejsze migracje
+nadpisują wcześniejsze definicje widoków (``CREATE OR REPLACE VIEW``),
+więc treść ``.sql`` w katalogu migracji nie musi być tym, co faktycznie
+stoi w bazie; ``baseline-sql/baseline.sql`` też zawodzi jako źródło — to
+snapshot sprzed części migracji (sprawdzone: ``baseline.sql`` ma
+``NULL::bigint AS liczba_autorow`` zamiast realnego ``count()`` z migracji
+``0494``).
+
+## Niezmiennik
+
+Dla każdej pary (widok W, tabela T) takiej, że W czyta T (wg ``pg_depend``),
+a T jest w ``TABELE_SOFT_DELETE`` — musi istnieć zależność W od kolumny
+``T.deleted_at`` (wg ``pg_depend``), albo W jest na liście ``WYJATKI``.
## Wzorzec naprawy
@@ -58,6 +76,21 @@
przepisywania SQL-a ręcznie, co rozjeżdża się przy zmianie kolumn) —
``src/bpp/migration_util.py`` (``viewdef``, ``widok_dopisz_warunek``,
``widok_usun_warunek``).
+
+## Pułapka dopasowania tekstowego (funkcje ``widok_uzywa_tabeli`` niżej)
+
+``widok_uzywa_tabeli``/``_regex_dla_tabeli`` (dopasowanie z granicą słowa)
+NIE są już częścią rdzenia kanarka — ale zostają w module, bo:
+
+1. ich własne testy (``test_matcher_...`` niżej) pilnują regresji samej
+ klasy pułapki tekstowej (``bpp_wydawnictwo_ciagle_autor`` jest
+ PREFIKSEM ``bpp_wydawnictwo_ciagle_autorzy``, i odwrotnie
+ ``bpp_wydawnictwo_ciagle`` jest PREFIKSEM ``bpp_wydawnictwo_ciagle_autor``)
+ — to dokumentacja tego, DLACZEGO w ogóle przesiedliśmy się na
+ ``pg_depend``;
+2. ``test_symulacja_fazy_02_...`` niżej używa ich do odtworzenia
+ DOKŁADNIE starego (przed naprawą) algorytmu, żeby udowodnić, że
+ naprawa faktycznie łapie to, co stary kod przepuszczał.
"""
import re
@@ -71,12 +104,26 @@
#
# Faza 01 (2026-08-06): trzy tabele through autorstwa.
#
-# ⚠️ FAZA 02 MUSI DOPISAĆ TU 5 TABEL PUBLIKACJI — jednolinijkowa zmiana:
+# Faza 02 MUSI DOPISAĆ TU 5 TABEL PUBLIKACJI:
# "bpp_wydawnictwo_ciagle",
# "bpp_wydawnictwo_zwarte",
# "bpp_patent",
# "bpp_praca_doktorska",
# "bpp_praca_habilitacyjna",
+# To WCIĄŻ jest jednolinijkowa (a ściślej: pięciolinijkowa) zmiana W TYM
+# MIEJSCU — rdzeń kanarka (pg_depend) jest generyczny względem listy i nie
+# wymaga żadnych innych zmian kodu. ALE: po dopisaniu tych 5 tabel kanarek
+# realnie zaczerwieni się na WIĘCEJ niż na 3 znane winowajców z raportu —
+# złapie też ``bpp_kronika_praca_doktorska_view`` i
+# ``bpp_kronika_praca_habilitacyjna_view`` (czytają swoje tabele bez
+# żadnego filtra i, w odróżnieniu od pozostałych trzech ``bpp_kronika_*``,
+# NIE są jeszcze zweryfikowane jako martwe ani wpisane do ``WYJATKI`` —
+# tego akurat nikt jeszcze nie sprawdzał, bo dotąd nie było powodu). To
+# NIE jest fałszywy alarm do wyciszenia odruchowo — to dokładnie ta klasa
+# odkrycia, dla której ten kanarek istnieje. Faza 02 ma:
+# a) albo zweryfikować, że są martwe (jak pozostałe trzy) i dopisać je
+# do ``WYJATKI`` z uzasadnieniem i datą,
+# b) albo je naprawić wzorcem z sekcji „Wzorzec naprawy" wyżej.
TABELE_SOFT_DELETE = [
"bpp_wydawnictwo_ciagle_autor",
"bpp_wydawnictwo_zwarte_autor",
@@ -128,45 +175,122 @@ def _regex_dla_tabeli(tabela):
def widok_uzywa_tabeli(definicja, tabela):
"""Czy ``definicja`` widoku odwołuje się do ``tabela`` (jako całe słowo,
- nie substring cudzej, dłuższej nazwy)."""
+ nie substring cudzej, dłuższej nazwy).
+
+ UWAGA: to jest matcher TEKSTOWY, zachowany wyłącznie dla własnych
+ testów regresyjnych i dla symulacji starego (przed-naprawą) algorytmu
+ w ``test_symulacja_fazy_02_...``. Rdzeń kanarka (``znajdz_winowajcow``)
+ go NIE używa — patrz docstring modułu, sekcja "Źródło prawdy"."""
return bool(_regex_dla_tabeli(tabela).search(definicja))
def _pobierz_widoki_publiczne(cur):
"""``(nazwa widoku, definicja)`` dla wszystkich widoków w schemacie
- ``public`` — ``pg_views.definition``, nie pliki migracji."""
+ ``public`` — ``pg_views.definition``, nie pliki migracji.
+
+ Używane TYLKO przez testy sanity/mutacyjny/symulacyjny — nie przez
+ rdzeń kanarka (patrz ``_widoki_czytajace_tabele`` /
+ ``_widoki_zalezne_od_deleted_at`` niżej, oparte o ``pg_depend``)."""
cur.execute("SELECT viewname, definition FROM pg_views WHERE schemaname = 'public'")
return cur.fetchall()
-def znajdz_winowajcow(widoki):
- """Rdzeń kanarka: ``widoki`` to lista ``(nazwa, definicja)`` (jak z
- ``_pobierz_widoki_publiczne``). Zwraca listę ``(widok, tabela)`` dla
- par, gdzie widok czyta tabelę objętą soft-delete BEZ ``deleted_at`` w
- definicji i nie jest na liście ``WYJATKI``.
-
- Wydzielone z testu, żeby test mutacyjny mógł wywołać dokładnie tę samą
- logikę na spreparowanym stanie bazy, zamiast duplikować pętlę.
- """
- winowajcy = []
- for widok, definicja in widoki:
- if widok in WYJATKI:
- continue
- for tabela in TABELE_SOFT_DELETE:
- if not widok_uzywa_tabeli(definicja, tabela):
- continue
- if "deleted_at" not in definicja:
- winowajcy.append((widok, tabela))
- return winowajcy
+def _widoki_czytajace_tabele(cur, tabele):
+ """``{(widok, tabela)}`` dla par, gdzie ``widok`` FAKTYCZNIE zależy od
+ ``tabela`` wg katalogu (``pg_depend``) — czyli „W czyta T", ustalone z
+ tego samego źródła co zależność kolumnowa niżej, nie z tekstu.
+
+ Zawiera zarówno zależności na poziomie kolumny (widok odwołuje się do
+ konkretnej kolumny tabeli), jak i na poziomie całej relacji (widok
+ używa tabeli w ``FROM``/``JOIN`` bez odwołania do żadnej jej kolumny —
+ rzadkie, np. ``COUNT(*)``) — stąd brak filtra po ``refobjsubid``."""
+ cur.execute(
+ """
+ SELECT DISTINCT v.relname, t.relname
+ FROM pg_depend d
+ JOIN pg_rewrite r ON r.oid = d.objid
+ JOIN pg_class v ON v.oid = r.ev_class
+ JOIN pg_namespace vn ON vn.oid = v.relnamespace
+ JOIN pg_class t ON t.oid = d.refobjid
+ JOIN pg_namespace tn ON tn.oid = t.relnamespace
+ WHERE d.classid = 'pg_rewrite'::regclass
+ AND d.refclassid = 'pg_class'::regclass
+ AND vn.nspname = 'public'
+ AND tn.nspname = 'public'
+ AND t.relname = ANY(%s)
+ """,
+ [list(tabele)],
+ )
+ return set(cur.fetchall())
+
+
+def _widoki_zalezne_od_deleted_at(cur, tabele):
+ """``{(widok, tabela)}`` dla par, gdzie ``widok`` ma zależność
+ KOLUMNOWĄ (``pg_depend`` → ``pg_attribute``) na ``tabela.deleted_at``.
+
+ Dokładnie to samo narzędzie katalogowe, którego migracje
+ ``0433``/``0489``/``0494`` używają do wyliczenia kolumn bramki ``WHEN``
+ — patrz docstring modułu."""
+ cur.execute(
+ """
+ SELECT DISTINCT v.relname, t.relname
+ FROM pg_depend d
+ JOIN pg_rewrite r ON r.oid = d.objid
+ JOIN pg_class v ON v.oid = r.ev_class
+ JOIN pg_namespace vn ON vn.oid = v.relnamespace
+ JOIN pg_class t ON t.oid = d.refobjid
+ JOIN pg_namespace tn ON tn.oid = t.relnamespace
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
+ WHERE d.classid = 'pg_rewrite'::regclass
+ AND d.refclassid = 'pg_class'::regclass
+ AND vn.nspname = 'public'
+ AND tn.nspname = 'public'
+ AND t.relname = ANY(%s)
+ AND a.attname = 'deleted_at'
+ AND NOT a.attisdropped
+ """,
+ [list(tabele)],
+ )
+ return set(cur.fetchall())
+
+
+def _winowajcy_bez_wyjatkow(cur, tabele):
+ """Rdzeń bez filtrowania ``WYJATKI`` — pary (widok, tabela), gdzie widok
+ czyta tabelę (``pg_depend``), ale nie zależy od jej ``deleted_at``
+ (``pg_depend``). Wydzielone, żeby test dowodzący testowalność listy
+ wyjątków (``test_kanarek_lapie_swiezy_wyjatek_...``) mógł zawołać
+ dokładnie tę samą logikę bez powielania zapytań."""
+ czytajace = _widoki_czytajace_tabele(cur, tabele)
+ zalezne = _widoki_zalezne_od_deleted_at(cur, tabele)
+ return czytajace - zalezne
+
+
+def znajdz_winowajcow(cur, tabele=None, wyjatki=None):
+ """Rdzeń kanarka: dla ``tabele`` (domyślnie ``TABELE_SOFT_DELETE``)
+ zwraca listę ``(widok, tabela)``, gdzie widok czyta tabelę objętą
+ soft-delete BEZ zależności od jej kolumny ``deleted_at`` (wg
+ ``pg_depend``) i nie jest na liście ``wyjatki`` (domyślnie
+ ``WYJATKI``).
+
+ Przyjmuje kursor (nie listę widoków) — pyta katalog bezpośrednio, więc
+ działa poprawnie także wewnątrz transakcji testowej PO mutacji widoku
+ (``CREATE OR REPLACE VIEW`` w tej samej transakcji aktualizuje
+ ``pg_depend`` od razu, katalogi Postgresa są transakcyjne)."""
+ if tabele is None:
+ tabele = TABELE_SOFT_DELETE
+ if wyjatki is None:
+ wyjatki = WYJATKI
+ surowi = _winowajcy_bez_wyjatkow(cur, tabele)
+ return sorted((widok, tabela) for widok, tabela in surowi if widok not in wyjatki)
def _komunikat_naprawy(winowajcy):
linie = "\n".join(f" - {widok} (czyta {tabela})" for widok, tabela in winowajcy)
return (
"Kanarek katalogowy: widoki poniżej czytają tabelę objętą "
- "soft-delete, ale ich definicja w pg_views NIE zawiera "
- "'deleted_at' — soft-deletowane wiersze wyciekają do wyniku "
- "widoku.\n\n"
+ "soft-delete (wg pg_depend), ale NIE zależą od jej kolumny "
+ "'deleted_at' (wg pg_depend) — soft-deletowane wiersze wyciekają "
+ "do wyniku widoku.\n\n"
f"{linie}\n\n"
"NAPRAW jednym z wzorców (src/bpp/migration_util.py + przykłady):\n"
" - widok źródłowy / prosty WHERE-JOIN: widok_dopisz_warunek()\n"
@@ -180,18 +304,19 @@ def _komunikat_naprawy(winowajcy):
" 0494_liczba_autorow_bez_skasowanych.py).\n\n"
"Jeśli widok jest MARTWY i naprawa byłaby czystym churnem — "
"dopisz go do WYJATKI w tym pliku z uzasadnieniem i datą "
- "weryfikacji (nie grep-iem po jednym słowie kluczowym — sprawdź "
- "pg_depend / realnych konsumentów), a NIE zgłaszaj tego po cichu."
+ "weryfikacji (sprawdź pg_depend / realnych konsumentów w "
+ "kodzie/szablonach — NIE grep-iem po jednym słowie kluczowym), "
+ "a NIE zgłaszaj tego po cichu."
)
@pytest.mark.django_db
def test_kanarek_widoki_soft_delete_filtruja_po_deleted_at():
"""Rdzeń kanarka fazy 01/02+: widok czytający tabelę soft-delete musi
- znać ``deleted_at``, chyba że jest jawnie wyjątkiem."""
+ zależeć (wg pg_depend) od jej kolumny ``deleted_at``, chyba że jest
+ jawnie wyjątkiem."""
with connection.cursor() as cur:
- widoki = _pobierz_widoki_publiczne(cur)
- winowajcy = znajdz_winowajcow(widoki)
+ winowajcy = znajdz_winowajcow(cur)
assert not winowajcy, _komunikat_naprawy(winowajcy)
@@ -215,27 +340,42 @@ def test_kanarek_widzi_przynajmniej_widoki_zrodlowe():
)
+@pytest.mark.django_db
+def test_kanarek_widzi_zaleznosci_pg_depend():
+ """Sanity RDZENIA kanarka (pg_depend), analogiczne do testu wyżej dla
+ pg_views: gdyby zapytania ``pg_depend`` zwracały pustą listę (zła
+ nazwa kolumny/klasy w JOIN-ie, literówka w warunku), rdzeń zazieleniłby
+ się FAŁSZYWIE — winowajcy byliby zawsze puści, bo obie strony różnicy
+ ``czytajace - zalezne`` byłyby puste. Pilnujemy, że obie zależności
+ katalogowe realnie coś widzą, na znanej, ustabilizowanej parze."""
+ with connection.cursor() as cur:
+ czytajace = _widoki_czytajace_tabele(cur, TABELE_SOFT_DELETE)
+ zalezne = _widoki_zalezne_od_deleted_at(cur, TABELE_SOFT_DELETE)
+ assert (
+ "bpp_wydawnictwo_ciagle_autorzy",
+ "bpp_wydawnictwo_ciagle_autor",
+ ) in czytajace, "pg_depend nie widzi znanej relacji widok->tabela"
+ assert (
+ "bpp_wydawnictwo_ciagle_autorzy",
+ "bpp_wydawnictwo_ciagle_autor",
+ ) in zalezne, "pg_depend nie widzi znanej zależności widok->deleted_at"
+
+
@pytest.mark.django_db
def test_kanarek_lapie_swiezy_wyjatek_ktorego_nie_ma_na_liscie():
"""Kanarek NIE ma cichej furtki: widok bez filtra i BEZ wpisu w
``WYJATKI`` musi wywalić test, nawet jeśli akurat jest to jeden z
już-znanych, zweryfikowanych martwych widoków ``kronika``."""
with connection.cursor() as cur:
- widoki = _pobierz_widoki_publiczne(cur)
- # Celowo NIE wykluczamy widoków z WYJATKI tutaj (na odwrót niż
- # znajdz_winowajcow) — symulujemy dokładnie sytuację "WYJATKI jest
- # pusta/nie wie o tym widoku", żeby sprawdzić, czy SAMA logika
- # dopasowania (bez listy wyjątków) łapie znane martwe widoki kronika.
- winowajcy = [
- (widok, tabela)
- for widok, definicja in widoki
- for tabela in TABELE_SOFT_DELETE
- if widok_uzywa_tabeli(definicja, tabela) and "deleted_at" not in definicja
- ]
+ # Celowo wołamy rdzeń BEZ filtrowania WYJATKI (_winowajcy_bez_wy-
+ # jatkow) — symulujemy dokładnie sytuację "WYJATKI jest pusta/nie
+ # wie o tym widoku", żeby sprawdzić, czy SAMA logika dopasowania
+ # (bez listy wyjątków) łapie znane martwe widoki kronika.
+ winowajcy = _winowajcy_bez_wyjatkow(cur, TABELE_SOFT_DELETE)
+ zlapane = {w for w, _ in winowajcy}
# Znane martwe widoki kronika MUSZĄ się tu pojawić — inaczej ta lista
# wyjątków przestała być testowalna (np. ktoś je usunął z bazy albo
# dopisał im filtr, więc WYJATKI trzeba by odchudzić).
- zlapane = {w for w, _ in winowajcy}
oczekiwane_bez_wyjatku = set(WYJATKI) & {
"bpp_kronika_wydawnictwo_ciagle_view",
"bpp_kronika_wydawnictwo_zwarte_view",
@@ -247,7 +387,12 @@ def test_kanarek_lapie_swiezy_wyjatek_ktorego_nie_ma_na_liscie():
)
-# --- Testy SAMEGO matchera (§ "Pułapka dopasowania") ------------------
+# --- Testy SAMEGO matchera tekstowego (§ "Pułapka dopasowania") -------
+#
+# widok_uzywa_tabeli już nie jest częścią rdzenia kanarka (patrz docstring
+# modułu), ale zostaje: 1) jako dokumentacja klasy błędu, przez którą rdzeń
+# przesiadł się na pg_depend, 2) jako narzędzie symulacji starego
+# algorytmu w teście dowodowym niżej.
@pytest.mark.parametrize(
@@ -276,11 +421,11 @@ def test_matcher_nie_myli_autor_z_autorzy(tekst, oczekiwane):
@pytest.mark.parametrize(
"tekst,oczekiwane",
[
- # Odwrotna pułapka (aktualna od razu, na wyrost pod fazę 02):
- # "bpp_wydawnictwo_ciagle" jest PREFIKSEM
+ # Odwrotna pułapka: "bpp_wydawnictwo_ciagle" jest PREFIKSEM
# "bpp_wydawnictwo_ciagle_autor" — matcher szukający krótszej nazwy
# (tabela publikacji) nie może się nabrać na dłuższą (tabela
- # autorstwa).
+ # autorstwa). Dokładnie ten mechanizm psuł stary (tekstowy)
+ # algorytm w fazie 02 — patrz test symulacji niżej.
("SELECT * FROM bpp_wydawnictwo_ciagle", True),
("SELECT * FROM bpp_wydawnictwo_ciagle_autor", False),
("SELECT * FROM bpp_wydawnictwo_ciagle_view", False),
@@ -294,6 +439,104 @@ def test_matcher_prefiks_tabeli_publikacji_kontra_tabela_autorstwa(tekst, oczeki
assert widok_uzywa_tabeli(tekst, "bpp_wydawnictwo_ciagle") is oczekiwane
+# --- Test dowodowy: symulacja fazy 02 -----------------------------------
+
+# Pięć tabel publikacji, które faza 02 dopisze do TABELE_SOFT_DELETE.
+# Zduplikowane tu ŚWIADOMIE (a nie zaimportowane) — ten test ma przetrwać
+# NIEZMIENIONY moment, w którym faza 02 faktycznie dopisze je do stałej
+# modułowej; do tego czasu musi budować symulację sam, niezależnie od
+# TABELE_SOFT_DELETE.
+_TABELE_PUBLIKACJI_FAZA_02 = [
+ "bpp_wydawnictwo_ciagle",
+ "bpp_wydawnictwo_zwarte",
+ "bpp_patent",
+ "bpp_praca_doktorska",
+ "bpp_praca_habilitacyjna",
+]
+
+
+def _winowajcy_starym_algorytmem(widoki, tabele, wyjatki):
+ """Odtworzenie DOKŁADNIE starego (przed naprawą 2026-08-06) rdzenia
+ kanarka: substring ``"deleted_at" in definicja``, NIEKWALIFIKOWANY
+ tabelą. Używane wyłącznie w teście dowodowym niżej — świadomie żywy
+ kod błędu, nie refaktor do wspólnej funkcji, żeby test nie mógł
+ przypadkiem zacząć wołać naprawionej wersji."""
+ winowajcy = []
+ for widok, definicja in widoki:
+ if widok in wyjatki:
+ continue
+ for tabela in tabele:
+ if not widok_uzywa_tabeli(definicja, tabela):
+ continue
+ if "deleted_at" not in definicja:
+ winowajcy.append((widok, tabela))
+ return winowajcy
+
+
+@pytest.mark.django_db
+def test_symulacja_fazy_02_kanarek_lapie_widoki_ktore_stara_wersja_przepuszczala():
+ """DOWÓD, nie test dzisiejszego stanu bazy: symuluje przyszły stan po
+ fazie 02 (soft-delete PUBLIKACJI), gdzie ``TABELE_SOFT_DELETE`` rośnie
+ o 5 tabel publikacji, i udowadnia, że naprawiony (pg_depend) rdzeń
+ kanarka łapie DOKŁADNIE to, co stary (tekstowy) rdzeń przepuszczał.
+
+ ``TABELE_SOFT_DELETE`` w tym pliku NIE jest tu modyfikowane — budujemy
+ lokalną, rozszerzoną listę i wołamy oba warianty algorytmu na tych
+ samych, żywych danych z ``pg_views``/``pg_depend``. Ten test ma
+ zostać na stałe (nie jest tymczasowym scratchem): to on dowodzi, że
+ faza 02 dostanie od kanarka prawdziwą, kompletną listę winowajców
+ jednym przebiegiem, zamiast — jak faza 01 — odkrywać ich pojedynczo
+ przez awarie produkcyjne.
+
+ Trzy konkretne pary (widok, tabela) niżej pochodzą z recenzji
+ ``kanarek-fix-report.md`` (żywy katalog, 2026-08-06): widoki
+ odziedziczyły tekstowo ``deleted_at`` z filtra po
+ ``bpp_wydawnictwo_ciagle_autor`` (ten sam JOIN), mimo że nie filtrują
+ wcale po WŁASNYM ``deleted_at`` tabeli ``bpp_wydawnictwo_ciagle``.
+ """
+ tabele_symulowane = TABELE_SOFT_DELETE + _TABELE_PUBLIKACJI_FAZA_02
+
+ oczekiwane_falszywa_zielen = {
+ ("bpp_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"),
+ ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"),
+ (
+ "rozbieznosci_dyscyplin_rozbieznoscizrodelview",
+ "bpp_wydawnictwo_ciagle",
+ ),
+ }
+
+ with connection.cursor() as cur:
+ widoki = _pobierz_widoki_publiczne(cur)
+ stary_winowajcy = set(
+ _winowajcy_starym_algorytmem(widoki, tabele_symulowane, WYJATKI)
+ )
+ nowy_winowajcy = set(znajdz_winowajcow(cur, tabele=tabele_symulowane))
+
+ # Dowód #1 (ISTNIENIE DZIURY): stary algorytm PRZEPUSZCZAŁ te 3 pary —
+ # nie ma ich wśród jego winowajców, mimo że widoki naprawdę czytają
+ # bpp_wydawnictwo_ciagle bez filtra po jej deleted_at. To jest
+ # regression-proof samego buga opisanego w brief-ie/raporcie: jeśli
+ # ten assert kiedyś zacznie padać, znaczy że któryś z tych widoków się
+ # zmienił (np. ktoś usunął z jego tekstu literalne "deleted_at") i
+ # test wymaga aktualizacji dowodu, nie że naprawa przestała działać.
+ zlapane_przez_stary = stary_winowajcy & oczekiwane_falszywa_zielen
+ assert not zlapane_przez_stary, (
+ "Stary (tekstowy) algorytm złapał widoki, które wg raportu miał "
+ f"PRZEPUSZCZAĆ (fałszywa zieleń): {zlapane_przez_stary}. Dane "
+ "widoki się zmieniły — dowód wymaga aktualizacji, sprawdź "
+ "kanarek-fix-report.md."
+ )
+
+ # Dowód #2 (NAPRAWA DZIAŁA): nowy (pg_depend) algorytm łapie wszystkie
+ # 3 pary, których stary nie widział.
+ niezlapane_przez_nowy = oczekiwane_falszywa_zielen - nowy_winowajcy
+ assert not niezlapane_przez_nowy, (
+ "Nowy (pg_depend) algorytm NIE złapał widoków z symulacji fazy "
+ f"02, które miał złapać: {niezlapane_przez_nowy}. Naprawa nie "
+ "działa albo się cofnęła."
+ )
+
+
@pytest.mark.django_db
def test_mutacja_widoku_bez_filtra_kanarek_pada():
"""Weryfikacja MUTACYJNA: podmieniamy naprawiony widok źródłowy na
@@ -305,7 +548,10 @@ def test_mutacja_widoku_bez_filtra_kanarek_pada():
Bez tego testu nie wiadomo, czy kanarek COKOLWIEK pilnuje — testy
„substringowe" (czy widok zawiera ``deleted_at``) mogłyby przechodzić
fałszywie pozytywnie z powodów niezwiązanych z logiką (np. literówka w
- nazwie kolumny w komentarzu SQL).
+ nazwie kolumny w komentarzu SQL). Mutacja jest teraz jeszcze mocniejszym
+ dowodem niż przy starym algorytmie: usuwa REALNĄ zależność kolumnową w
+ ``pg_depend`` (nie tylko tekst), więc dowodzi, że rdzeń oparty o
+ ``pg_depend`` faktycznie widzi zmianę w katalogu, a nie w tekście SQL-a.
"""
widok = "bpp_wydawnictwo_ciagle_autorzy"
with connection.cursor() as cur:
@@ -314,7 +560,7 @@ def test_mutacja_widoku_bez_filtra_kanarek_pada():
# Format zgodny z pg_get_viewdef(regclass) BEZ jawnego pretty=true
# (czyli tym samym, co zwraca pg_views.definition, którego używa
- # znajdz_winowajcow) — Postgres owija tu warunek w nawias.
+ # _pobierz_widoki_publiczne) — Postgres owija tu warunek w nawias.
sufiks = "\n WHERE (deleted_at IS NULL)"
assert oryginal.endswith(sufiks), (
f"{widok}: definicja nie kończy się oczekiwanym filtrem — "
@@ -330,8 +576,7 @@ def test_mutacja_widoku_bez_filtra_kanarek_pada():
try:
cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {zepsuta}")
- widoki_po_mutacji = _pobierz_widoki_publiczne(cur)
- winowajcy = znajdz_winowajcow(widoki_po_mutacji)
+ winowajcy = znajdz_winowajcow(cur)
zlapani = {w for w, _ in winowajcy}
assert widok in zlapani, (
"MUTACJA NIE ZOSTAŁA WYKRYTA — kanarek nie pilnuje niczego. "
@@ -344,8 +589,7 @@ def test_mutacja_widoku_bez_filtra_kanarek_pada():
# docstring).
cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {oryginal}")
- widoki_po_przywroceniu = _pobierz_widoki_publiczne(cur)
- winowajcy_po = znajdz_winowajcow(widoki_po_przywroceniu)
+ winowajcy_po = znajdz_winowajcow(cur)
zlapani_po = {w for w, _ in winowajcy_po}
assert widok not in zlapani_po, (
"widok nie wrócił do stanu naprawionego po przywróceniu "
From f3a0f1f22a3fc9ae476269e69b773725f85c050c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Fri, 7 Aug 2026 09:09:03 +0200
Subject: [PATCH 37/75] docs(soft-delete): runbook wdrozeniowy fazy 01
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Zastrzezenia z finalnej recenzji gałezi byly dotad tylko w ledgerze SDD,
a ten jest gitignored -- przy scaleniu by przepadly. Przenosze do trwalego
dokumentu w docs/deweloper/.
Pieć rzeczy, ktore osoba wdrazajaca MUSI wiedziec:
1. 0492 wymaga okna serwisowego: ADD CONSTRAINT EXCLUDE USING GIST bierze
ACCESS EXCLUSIVE (blokuje takze ODCZYTY) i NIE MA wariantu wspolbieznego
-- to ograniczenie PostgreSQL, nie przeoczenie. Zmierzyc na kopii
produkcyjnej; to jedyna operacja w serii bez planu B.
2. Procedura po przerwanej migracji z serii atomic=False (0490-0493):
awaria w srodku kroku nie cofa sie sama, obiekt zostaje w bazie bez wpisu
w django_migrations. Tabelka z nazwami obiektow do recznego DROP.
3. Migracje musza domknac sie PRZED startem nowego kodu -- miedzy 0492 a 0493
obowiazuje jeszcze stare bezwarunkowe UNIQUE (rekord_id, kolejnosc).
4. Rollback: cofac kod I migracje RAZEM. django-denorm buduje triggery
z modeli przy post_migrate, a te maja juz deleted_at w denorm_always_only,
wiec cofniecie samego 0488 konczy sie ProgrammingError.
5. Osierocony wpis django_migrations na maszynach dev, ktore zaaplikowaly
stara 0490 (numer uzyty ponownie po rozbiciu).
Plus sekcja o zachowaniach swiadomie zaakceptowanych (eventual consistency
liczba_autorow, brak przenumerowania kolejnosc, niezmierzony narzut GiST,
3 martwe widoki kronika) -- zeby nie wracaly jako zgloszenia bugow.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4
---
docs/deweloper/runbook-soft-delete-faza-01.md | 155 ++++++++++++++++++
1 file changed, 155 insertions(+)
create mode 100644 docs/deweloper/runbook-soft-delete-faza-01.md
diff --git a/docs/deweloper/runbook-soft-delete-faza-01.md b/docs/deweloper/runbook-soft-delete-faza-01.md
new file mode 100644
index 000000000..16810e893
--- /dev/null
+++ b/docs/deweloper/runbook-soft-delete-faza-01.md
@@ -0,0 +1,155 @@
+# Runbook wdrożeniowy — soft-delete, faza 01 (autorstwa)
+
+> Dotyczy migracji `bpp/0488` … `bpp/0495` oraz
+> `rozbieznosci_dyscyplin/0022`. Przeczytaj **przed** wdrożeniem na produkcję.
+>
+> Powstał 2026-08-06 z ustaleń finalnej recenzji gałęzi. Zastrzeżenia niżej
+> nie blokują scalenia, ale **muszą** być znane osobie wdrażającej.
+
+## Co ta faza zmienia
+
+`delete()` na trzech modelach autorstwa (`Wydawnictwo_Ciagle_Autor`,
+`Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) przestaje fizycznie usuwać wiersz
+— ustawia `deleted_at`. **Nie ma feature flagi: zmiana obowiązuje od pierwszej
+sekundy po starcie nowego kodu.**
+
+Konsekwencja dla wdrożenia: migracje muszą się domknąć **zanim** nowy kod
+zacznie serwować ruch (patrz §3).
+
+---
+
+## 1. `0492` wymaga OKNA SERWISOWEGO
+
+`0492_autor_excl_rekord_kolejnosc` zakłada `ExclusionConstraint` (GiST):
+
+```sql
+ALTER TABLE bpp_wydawnictwo_ciagle_autor
+ ADD CONSTRAINT wc_autor_excl_rekord_kolejnosc
+ EXCLUDE USING gist (rekord_id WITH =, kolejnosc WITH =)
+ WHERE (deleted_at IS NULL) DEFERRABLE INITIALLY DEFERRED;
+```
+
+- bierze **`ACCESS EXCLUSIVE`** — blokuje także **odczyty**, nie tylko zapisy;
+- trwa tyle, ile budowa indeksu **GiST**, który na parze `(int, int)` jest
+ wielokrotnie wolniejszy od btree;
+- **nie istnieje wariant współbieżny.** `EXCLUDE` nie da się dodać przez
+ `CREATE INDEX CONCURRENTLY` + `USING INDEX` — to ograniczenie PostgreSQL,
+ nie przeoczenie.
+
+Dotyczy trzech największych tabel systemu.
+
+> **DO ZROBIENIA PRZED WDROŻENIEM:** zmierzyć czas `0492` na kopii bazy
+> produkcyjnej. To jedyna operacja w tej serii bez planu B.
+
+Ustaw `lock_timeout` + retry — jeden długi `SELECT` na tabeli `*_autor`
+zatrzyma migrację i wszystko za nią (kolejka blokad).
+
+## 2. Procedura po PRZERWANEJ migracji z serii `0490`–`0493`
+
+Te cztery migracje mają `atomic = False` (świadomie — żeby blokady nie
+kumulowały się do jednego `COMMIT`). Cena: **awaria w środku kroku nie cofa
+się sama.**
+
+Objaw: obiekt istnieje w bazie, ale wpisu w `django_migrations` nie ma →
+ponowny `migrate` pada na „already exists" (albo, dla `0493`, na
+„Found wrong number (0) of constraints").
+
+Naprawa — usuń ręcznie obiekty z częściowo wykonanego kroku, potem powtórz
+`migrate`. Nazwy (prefiks `wc_` / `wz_` / `pat_` per tabela):
+
+| Krok | Obiekty do sprawdzenia |
+|---|---|
+| `0490` | indeksy `*_autor_rekord_id_*` |
+| `0491` | `*_autor_uniq_rekord_autor_typ` |
+| `0492` | `*_autor_excl_rekord_kolejnosc` |
+| `0493` | (same `DROP`-y metadanowe — praktycznie nie pada) |
+
+```sql
+-- przykład diagnostyki przed retry
+SELECT conname FROM pg_constraint
+ WHERE conrelid = 'bpp_wydawnictwo_ciagle_autor'::regclass;
+SELECT indexname FROM pg_indexes
+ WHERE tablename = 'bpp_wydawnictwo_ciagle_autor';
+```
+
+## 3. Kolejność: migracje PRZED startem nowego kodu
+
+Między `0492` a `0493` obowiązuje **jeszcze stare, bezwarunkowe**
+`UNIQUE (rekord_id, kolejnosc)`. Jeśli w tym oknie nowy kod zdąży zrobić
+soft-delete i wstawić wiersz zastępczy, dostanie `IntegrityError`.
+
+Migracje idą z entrypointu przed startem aplikacji, więc domyślnie jest to
+spełnione — ale przy ręcznym wdrożeniu lub rolling-restarcie trzeba tego
+przypilnować.
+
+## 4. Rollback: cofać KOD I MIGRACJE razem
+
+**Cofnięcie samych migracji nie zadziała, jeśli nowy kod został na dysku.**
+
+`django-denorm` buduje triggery z modeli Pythona przy sygnale `post_migrate`.
+Modele po tej fazie mają `deleted_at` w `denorm_always_only`, więc po
+cofnięciu `0488` (usunięcie kolumny) najbliższy `post_migrate` próbuje
+zbudować trigger odwołujący się do nieistniejącej kolumny:
+
+```
+ProgrammingError: kolumna old.deleted_at nie istnieje
+```
+
+To jedyny nieudany rewers w całej serii — pozostałe wracają do stanu
+wyjściowego bit-w-bit (zweryfikowane zrzutem katalogu przed/po).
+
+Dodatkowo: rewers `0493` przywraca **bezwarunkowe** `UNIQUE (rekord_id,
+kolejnosc)`, które padnie, jeśli ktokolwiek zdążył skorzystać z soft-delete
+(soft-deletowane wiersze wyglądają wtedy jak duplikaty). Rollback po realnym
+użyciu funkcji wymaga ręcznego wyczyszczenia kosza.
+
+## 5. Maszyny deweloperskie na tej gałęzi
+
+Kto zaaplikował **starą** wersję `0490_autor_warunkowy_unique` (plik został
+skasowany, a numer użyty ponownie przy rozbiciu na `0490`–`0493`), ma
+osierocony wpis w `django_migrations`. Dotyczy wyłącznie maszyn, na których
+ta gałąź była wdrażana w trakcie prac — nie produkcji.
+
+Naprawa: `DELETE FROM django_migrations WHERE app='bpp' AND name LIKE
+'0490_autor_warunkowy_unique';` i ponowny `migrate`, albo świeża baza.
+
+---
+
+## Znane, świadomie zaakceptowane zachowania
+
+Nie są błędami do naprawy w tej fazie, ale warto je znać przy zgłoszeniach
+od użytkowników.
+
+- **`Rekord.liczba_autorow` jest *eventually consistent*.** Odświeża się
+ dopiero, gdy flush `django-denorm` dotknie wiersza publikacji, podczas gdy
+ `bpp_autorzy_mat` aktualizuje się natychmiast (trigger). Mechanizm jest
+ identyczny jak przy twardym kasowaniu, ale rozjazd między dwoma cache'ami
+ bywa chwilowo widoczny.
+- **Soft-delete nie przenumerowuje `kolejnosc`.** Skasowanie autora ze środka
+ listy zostawia dziurę. Kryterium multiseeka „Ostatnie nazwisko i imię"
+ (`kolejnosc ∈ [liczba_autorow-1, liczba_autorow)`) może w tę dziurę trafić
+ i zwrócić pusty wynik. Zachowanie identyczne jak przy twardym kasowaniu
+ (admin przenumerowuje przy zapisie formsetu, deduplikator nie).
+- **Narzut GiST.** Btree `UNIQUE (rekord_id, kolejnosc)` zastąpiono indeksem
+ GiST. Każdy `INSERT`/`UPDATE` na najgorętszych tabelach (import publikacji,
+ PBN, deduplikator) płaci teraz za jego utrzymanie. `0490` dokłada btree po
+ `rekord_id`, co część kosztu łagodzi. **Nikt tego nie zmierzył** — testy
+ z definicji tego nie wykryją.
+- **Trzy martwe widoki** `bpp_kronika_{wydawnictwo_ciagle,wydawnictwo_zwarte,
+ patent}_view` czytają surowe tabele bez filtra. Zweryfikowano brak
+ konsumentów (kod, szablony, modele) — są jawnym wyjątkiem w kanarku
+ katalogowym. Do skasowania przy okazji.
+
+## Weryfikacja po wdrożeniu
+
+```bash
+# 1. soft-delete znika z obu cache'y
+# (autorzy_set, bpp_autorzy_mat ORAZ liczba_autorow muszą się zgadzać)
+
+# 2. kanarek katalogowy — żaden widok nie czyta tabeli soft-delete
+# bez zależności od jej deleted_at
+uv run pytest src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py -q
+
+# 3. spójność cache
+uv run pytest src/bpp/tests/test_soft_delete/ src/bpp/tests/test_cache/ -q
+```
From e80d1656e8b0936b7fd09c17f335da6edc67e302 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Fri, 7 Aug 2026 10:02:40 +0200
Subject: [PATCH 38/75] fix(soft-delete): 11 zapytan ORM przestaje widziec
skasowane autorstwa
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Faza 01 uczynila *_Autor soft-delete, ale manager `objects` odcina skasowane
tylko przy zapytaniu o sam through-model. Lookup PRZEZ relacje
(`filter(autorzy_set__…)`, `Count("autorzy_set")`,
`Count("wydawnictwo_ciagle_autor")`) buduje JOIN po SUROWEJ tabeli i managera
nie pyta — ta sama klasa bledu, co widoki SQL naprawione migracjami
0494/0495/rozbieznosci-0022, tylko o warstwe wyzej.
Inwentaryzacja znalazla 15 miejsc: 11 naprawionych, 4 zostawione swiadomie
(pelna tabela w orm-leak-report.md).
Naprawione:
- import_common/core/autor.py — licznik publikacji kandydata w deduplikatorze
(husk autora wygladal na autora z dorobkiem),
- bpp/admin/filters.py — BezJakichkolwiekDyscyplinFilter gubil rekordy,
- pbn_wysylka_oswiadczen/queries.py — `tylko_odpiete` wskazywal do WYCOFANIA
z PBN rekordy bez zywego autorstwa,
- pbn_wyslij_oswiadczenia_instytucji — wysylka dla publikacji, ktorej jedyne
autorstwo z dyscyplina skasowano,
- ewaluacja_optymalizacja/views/verification.py — zawyzone liczniki,
- rozbieznosci/views.py + bpp/views/browse.py — atrybucja rekordu do uczelni
(multi-host): skasowane autorstwo pokazywalo rekord na cudzym hoscie,
- bpp/management/commands/export_bibtex.py — `--author` przepisany z M2M
`autorzy__` na `autorzy_set__autor__`, zeby predykat trafil na TEN SAM JOIN,
- bpp/models/autor.py + views/autocomplete/search_services.py — ranking autora
w wyszukiwarce (przy okazji o jeden JOIN mniej),
- 3 szablony z linkami do changelistu admina + zapytanie DjangoQL
deduplikatora + whitelist `lookup_allowed`.
Zostawione swiadomie (z komentarzem w kodzie): `rebuild_instances_of`
w dyscyplina_naukowa.py MA widziec skasowane — wybiera publikacje DO
PRZELICZENIA, a ta ze skasowanym autorstwem potrzebuje go bardziej niz
inne. DjangoQL i recznie sklejone parametry GET admina pozostaja surowe
(jezyk zapytan uzytkownika = narzedzie audytowe). verify_cache.py to kod
martwy (`raise NotImplementedError` linijke wyzej).
Testy: src/bpp/tests/test_soft_delete/test_orm_wyciek_join.py — 14 testow.
Trzy pierwsze przypinaja SEMANTYKE Django (dostep instancyjny
i prefetch_related sa bezpieczne, JOIN nie jest) — dzieki temu wiadomo, ze
~70 wywolan `obj.autorzy_set.…` i wszystkie prefetche NIE wymagaja poprawek.
Po `git stash` tego commita 8 testow pada, po jednym na naprawione zapytanie.
---
src/bpp/admin/filters.py | 16 +-
src/bpp/admin/wydawnictwo_ciagle.py | 7 +
src/bpp/admin/wydawnictwo_zwarte.py | 5 +
src/bpp/management/commands/export_bibtex.py | 15 +-
src/bpp/models/autor.py | 23 +-
src/bpp/models/dyscyplina_naukowa.py | 7 +
.../test_soft_delete/test_orm_wyciek_join.py | 413 ++++++++++++++++++
src/bpp/views/autocomplete/search_services.py | 16 +-
src/bpp/views/browse.py | 12 +-
.../duplicate_authors.html | 9 +-
.../admin/bpp/autor/change_form.html | 9 +-
.../database_verification.html | 9 +-
.../views/verification.py | 9 +
src/import_common/core/autor.py | 15 +-
.../pbn_wyslij_oswiadczenia_instytucji.py | 6 +
src/pbn_wysylka_oswiadczen/queries.py | 13 +-
src/rozbieznosci/views.py | 12 +-
17 files changed, 577 insertions(+), 19 deletions(-)
create mode 100644 src/bpp/tests/test_soft_delete/test_orm_wyciek_join.py
diff --git a/src/bpp/admin/filters.py b/src/bpp/admin/filters.py
index 058845a5b..05917fba8 100644
--- a/src/bpp/admin/filters.py
+++ b/src/bpp/admin/filters.py
@@ -77,11 +77,23 @@ def queryset(self, request, queryset):
field = self.parameter_name
if v == "tak":
+ # Oba liczniki muszą mieć ``autorzy_set__deleted_at__isnull=True``:
+ # agregat po ``autorzy_set`` to JOIN po SUROWEJ tabeli ``*_autor``
+ # (manager soft-delete nie jest pytany). Bez tego skasowane
+ # autorstwo Z dyscypliną podbijało wyłącznie ``total_authors``,
+ # więc rekord, w którym wszyscy ŻYWI autorzy są bez dyscypliny,
+ # wypadał z wyników filtra.
return queryset.annotate(
- total_authors=Count("autorzy_set"),
+ total_authors=Count(
+ "autorzy_set",
+ filter=Q(autorzy_set__deleted_at__isnull=True),
+ ),
authors_without_discipline=Count(
"autorzy_set",
- filter=Q(autorzy_set__dyscyplina_naukowa__isnull=True),
+ filter=Q(
+ autorzy_set__dyscyplina_naukowa__isnull=True,
+ autorzy_set__deleted_at__isnull=True,
+ ),
),
).filter(
total_authors__gt=0, # Ma autorów
diff --git a/src/bpp/admin/wydawnictwo_ciagle.py b/src/bpp/admin/wydawnictwo_ciagle.py
index 39523b0a8..1100224a0 100644
--- a/src/bpp/admin/wydawnictwo_ciagle.py
+++ b/src/bpp/admin/wydawnictwo_ciagle.py
@@ -450,6 +450,13 @@ def lookup_allowed(self, lookup, value, request=None):
"autorzy_set__dyscyplina_naukowa__isnull",
"autorzy_set__data_oswiadczenia__isnull",
"autorzy_set__autor__id__exact",
+ # Lookupy po ``autorzy_set__`` to JOIN po surowej tabeli
+ # ``bpp_wydawnictwo_ciagle_autor`` — manager soft-delete nie jest
+ # pytany. Linki generowane przez BPP (change_form autora,
+ # ekran weryfikacji bazy) dokładają ten predykat, żeby changelist
+ # nie pokazywał publikacji po SKASOWANYM autorstwie. Whitelist
+ # musi go przepuścić, inaczej admin odrzuci własny link.
+ "autorzy_set__deleted_at__isnull",
):
return True
return super().lookup_allowed(lookup, value, request)
diff --git a/src/bpp/admin/wydawnictwo_zwarte.py b/src/bpp/admin/wydawnictwo_zwarte.py
index 506c23e44..6f40bef29 100644
--- a/src/bpp/admin/wydawnictwo_zwarte.py
+++ b/src/bpp/admin/wydawnictwo_zwarte.py
@@ -610,6 +610,11 @@ def lookup_allowed(self, lookup, value, request=None):
"autorzy_set__dyscyplina_naukowa__isnull",
"autorzy_set__data_oswiadczenia__isnull",
"autorzy_set__autor__id__exact",
+ # Patrz komentarz w ``Wydawnictwo_CiagleAdmin.lookup_allowed``:
+ # linki BPP dokładają ``autorzy_set__deleted_at__isnull=True``,
+ # żeby JOIN po surowej tabeli ``*_autor`` nie pokazywał
+ # publikacji po skasowanym autorstwie.
+ "autorzy_set__deleted_at__isnull",
):
return True
return super().lookup_allowed(lookup, value, request)
diff --git a/src/bpp/management/commands/export_bibtex.py b/src/bpp/management/commands/export_bibtex.py
index 499d6df86..ed5914ba7 100644
--- a/src/bpp/management/commands/export_bibtex.py
+++ b/src/bpp/management/commands/export_bibtex.py
@@ -72,8 +72,17 @@ def handle(self, *args, **options):
# Filter by author
if options["author"]:
- author_filter = Q(autorzy__nazwisko__icontains=options["author"]) | Q(
- autorzy__imiona__icontains=options["author"]
+ # Idziemy przez ``autorzy_set__autor__…`` (through-model), a NIE
+ # przez M2M ``autorzy__…``: tylko wtedy predykat
+ # ``deleted_at__isnull=True`` trafia na TEN SAM JOIN, co warunek
+ # po nazwisku. M2M ``autorzy`` też złącza się po surowej tabeli
+ # ``*_autor`` (manager soft-delete nie jest pytany), ale dopięcie
+ # do niego ``autorzy_set__deleted_at`` dałoby DRUGI, nieskorelowany
+ # JOIN — czyli warunek „ma jakiekolwiek żywe autorstwo", a nie
+ # „autorstwo TEGO autora żyje".
+ author_filter = Q(autorzy_set__deleted_at__isnull=True) & (
+ Q(autorzy_set__autor__nazwisko__icontains=options["author"])
+ | Q(autorzy_set__autor__imiona__icontains=options["author"])
)
ciagle_q &= author_filter
zwarte_q &= author_filter
@@ -113,7 +122,7 @@ def handle(self, *args, **options):
with open(options["output"], "w", encoding="utf-8") as f:
f.write(bibtex_content)
self.stdout.write(
- self.style.SUCCESS(f'Successfully exported to {options["output"]}')
+ self.style.SUCCESS(f"Successfully exported to {options['output']}")
)
except OSError as e:
raise CommandError(f"Error writing to file: {e}")
diff --git a/src/bpp/models/autor.py b/src/bpp/models/autor.py
index 9180d7ed7..2301cb46d 100644
--- a/src/bpp/models/autor.py
+++ b/src/bpp/models/autor.py
@@ -145,7 +145,28 @@ def create_from_string(self, text, uczelnia=None):
)
def fulltext_annotate(self, search_query, normalization):
- return {self.fts_field + "__rank": Count("wydawnictwo_ciagle")}
+ """Ranking autora = liczba jego autorstw w wydawnictwach ciągłych.
+
+ Liczymy przez ``wydawnictwo_ciagle_autor`` (through-model), a NIE
+ przez M2M ``wydawnictwo_ciagle``, z dwóch powodów:
+
+ 1. agregat po relacji to JOIN po SUROWEJ tabeli
+ ``bpp_wydawnictwo_ciagle_autor`` — manager soft-delete nie jest
+ pytany, więc bez ``filter=Q(…__deleted_at__isnull=True)`` husk
+ autora (wszystkie autorstwa skasowane) rankowałby się tak samo
+ wysoko, jak przed skasowaniem. Predykat MUSI iść tą samą ścieżką
+ relacji co agregat, inaczej Django zrobi drugi, nieskorelowany
+ JOIN;
+ 2. przy okazji odpada drugi JOIN (do ``bpp_wydawnictwo_ciagle``) —
+ FK z through-modelu jest NOT NULL, więc kardynalność bez niego
+ jest ta sama.
+ """
+ return {
+ self.fts_field + "__rank": Count(
+ "wydawnictwo_ciagle_autor",
+ filter=Q(wydawnictwo_ciagle_autor__deleted_at__isnull=True),
+ )
+ }
class Autor(LinkDoPBNMixin, ModelZAdnotacjami, ModelZPBN_ID):
diff --git a/src/bpp/models/dyscyplina_naukowa.py b/src/bpp/models/dyscyplina_naukowa.py
index f4ea66027..51ec09c1d 100644
--- a/src/bpp/models/dyscyplina_naukowa.py
+++ b/src/bpp/models/dyscyplina_naukowa.py
@@ -253,6 +253,13 @@ def przebuduj_prace_autora_po_udanej_transakcji(autor_id, rok):
from bpp.models import Patent, Wydawnictwo_Ciagle, Wydawnictwo_Zwarte
+ # ŚWIADOMIE BEZ ``autorzy_set__deleted_at__isnull=True``: to jedyne miejsce
+ # w kodzie, gdzie JOIN po SUROWEJ tabeli ``*_autor`` (z widocznymi
+ # soft-deletami) jest POŻĄDANY. ``rebuild_instances_of`` tylko wybiera
+ # publikacje DO PRZELICZENIA — a publikacja, której autorstwo tego autora
+ # zostało skasowane, potrzebuje przeliczenia tak samo (a nawet bardziej)
+ # jak ta z autorstwem żywym. Dodanie predykatu wycięłoby ją ze zbioru
+ # i zostawiło nieaktualne punkty/sloty do czasu nocnej rekalkulacji.
def _(autor_id=autor_id, rok=rok):
for klass in [Wydawnictwo_Ciagle, Wydawnictwo_Zwarte, Patent]:
rebuild_instances_of(klass, rok=rok, autorzy_set__autor_id=autor_id)
diff --git a/src/bpp/tests/test_soft_delete/test_orm_wyciek_join.py b/src/bpp/tests/test_soft_delete/test_orm_wyciek_join.py
new file mode 100644
index 000000000..ca95812b5
--- /dev/null
+++ b/src/bpp/tests/test_soft_delete/test_orm_wyciek_join.py
@@ -0,0 +1,413 @@
+"""Wyciek soft-delete o warstwę wyżej niż widoki SQL: JOIN w ORM-ie.
+
+Faza 01 uczyniła ``Wydawnictwo_Ciagle_Autor`` / ``Wydawnictwo_Zwarte_Autor`` /
+``Patent_Autor`` soft-delete. Domyślny manager ``objects`` odcina skasowane —
+ale **wyłącznie gdy pytamy o sam through-model**. Kiedy filtrujemy publikację
+*przez* relację (``Wydawnictwo_Ciagle.objects.filter(autorzy_set__…)``,
+``annotate(Count("autorzy_set"))``, ``Autor.objects.annotate(Count(
+"wydawnictwo_ciagle_autor"))``), Django buduje JOIN po SUROWEJ tabeli i
+managera **w ogóle nie pyta** — dokładnie ta sama klasa błędu, co widoki SQL
+naprawione migracjami 0494/0495/rozbieznosci-0022, tylko w Pythonie.
+
+Moduł ma dwie części:
+
+1. **Kanarki semantyki Django** — przypinają, CO w ogóle przecieka, a co nie.
+ Dostęp instancyjny (``wc.autorzy_set``) i ``prefetch_related`` idą przez
+ ``_default_manager`` i są bezpieczne; JOIN nie jest. Gdyby Django kiedyś
+ zmieniło którekolwiek z tych zachowań, te trzy testy zapalą się pierwsze
+ i powiedzą, czy reszta naprawek stała się zbędna, czy niewystarczająca.
+2. **Regresje konkretnych miejsc produkcyjnych** — po jednym teście na
+ naprawione zapytanie.
+"""
+
+import pytest
+from django.db.models import Count, Q
+
+from bpp.models import (
+ Autor,
+ Wydawnictwo_Ciagle,
+ Wydawnictwo_Ciagle_Autor,
+)
+
+# --------------------------------------------------------------------------
+# 1. Kanarki semantyki Django
+# --------------------------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_dostep_instancyjny_autorzy_set_odcina_skasowane(
+ wydawnictwo_ciagle_z_dwoma_autorami,
+):
+ """``wc.autorzy_set`` (RelatedManager) dziedziczy po ``_default_manager``.
+
+ Django buduje klasę RelatedManagera z ``related_model._default_manager.
+ __class__`` — czyli z ``BppSoftDeleteManager``. Dostęp instancyjny jest
+ więc BEZPIECZNY: ~70 wywołań ``obj.autorzy_set.…`` w kodzie produkcyjnym
+ nie wymaga żadnych poprawek.
+ """
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ assert wc.autorzy_set.count() == 2
+
+ wc.autorzy_set.first().delete()
+
+ assert wc.autorzy_set.count() == 1
+ assert wc.autorzy_set.all().count() == 1
+
+
+@pytest.mark.django_db
+def test_prefetch_related_autorzy_set_odcina_skasowane(
+ wydawnictwo_ciagle_z_dwoma_autorami,
+):
+ """``prefetch_related("autorzy_set…")`` też idzie przez ``_default_manager``.
+
+ ``RelatedManager.get_prefetch_querysets()`` woła ``super().get_queryset()``
+ — czyli manager z filtrem soft-delete. Dlatego ``prefetch_related`` z
+ ``autorzy_set__`` w ścieżce (m.in. ``ewaluacja_optymalizacja/tasks/
+ discipline_swap/analysis.py``, viewsety ``api_v1``, ``cerif_export``)
+ NIE jest wyciekiem, mimo że wygląda jak lookup przez relację.
+ """
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ wc.autorzy_set.first().delete()
+
+ pobrany = Wydawnictwo_Ciagle.objects.prefetch_related("autorzy_set__autor").get(
+ pk=wc.pk
+ )
+ assert len(pobrany.autorzy_set.all()) == 1
+
+
+@pytest.mark.django_db
+def test_join_przez_relacje_WIDZI_skasowane_bez_jawnego_filtra(
+ wydawnictwo_ciagle_z_autorem,
+):
+ """Kanarek NEGATYWNY: goły JOIN po ``autorzy_set__`` przecieka.
+
+ To opis stanu faktycznego Django, nie bug do naprawienia — test pilnuje,
+ że PRZYCZYNA wszystkich poprawek niżej nadal istnieje. Gdyby kiedyś
+ przestała (np. Django nauczyłoby się pytać manager przy JOIN-ie), ten
+ test padnie i będzie sygnałem, że jawne ``deleted_at__isnull=True``
+ w kodzie produkcyjnym można wycofać.
+ """
+ wc = wydawnictwo_ciagle_z_autorem
+ autor = wc.autorzy_set.first().autor
+ wc.autorzy_set.first().delete()
+
+ assert not Wydawnictwo_Ciagle_Autor.objects.filter(rekord=wc).exists()
+ # ...a mimo to JOIN nadal łapie publikację:
+ assert Wydawnictwo_Ciagle.objects.filter(autorzy_set__autor=autor).exists()
+ # ...i predykat na TYM SAMYM joinie ją odcina:
+ assert not Wydawnictwo_Ciagle.objects.filter(
+ autorzy_set__autor=autor,
+ autorzy_set__deleted_at__isnull=True,
+ ).exists()
+
+
+# --------------------------------------------------------------------------
+# 2. Regresje konkretnych miejsc produkcyjnych
+# --------------------------------------------------------------------------
+
+
+def _ustaw_dyscypline(autorstwo, dyscyplina, rodzaj_autora, **reszta):
+ """Ustawia dyscyplinę na autorstwie razem z wymaganym ``Autor_Dyscyplina``.
+
+ ``BazaModeluOdpowiedzialnosciAutorow`` waliduje przy KAŻDYM ``save()``, że
+ autor ma na dany rok przypisanie do tej dyscypliny — a soft-delete też
+ idzie przez ``save()``, więc bez tego przypisania wywaliłby się już sam
+ ``delete()``. Stąd tworzymy ``Autor_Dyscyplina``, zamiast obchodzić
+ walidację ``.update()``-em.
+ """
+ from bpp.models import Autor_Dyscyplina
+
+ Autor_Dyscyplina.objects.get_or_create(
+ autor=autorstwo.autor,
+ rok=autorstwo.rekord.rok,
+ defaults=dict(
+ dyscyplina_naukowa=dyscyplina,
+ rodzaj_autora=rodzaj_autora,
+ ),
+ )
+ autorstwo.dyscyplina_naukowa = dyscyplina
+ for k, v in reszta.items():
+ setattr(autorstwo, k, v)
+ autorstwo.save()
+
+
+@pytest.mark.django_db
+def test_import_common_liczba_publikacji_kandydata_pomija_skasowane(
+ wydawnictwo_ciagle_z_autorem,
+):
+ """``import_common.core.autor._publikacji_counts_bulk`` (deduplikator).
+
+ Licznik po ``Count("wydawnictwo_ciagle_autor")`` liczył także autorstwa
+ skasowane, więc husk autora pokazywał się w deduplikatorze jako autor
+ z dorobkiem.
+ """
+ from import_common.core.autor import _publikacji_counts_bulk
+
+ autor = wydawnictwo_ciagle_z_autorem.autorzy_set.first().autor
+ assert _publikacji_counts_bulk([autor.pk]) == {autor.pk: 1}
+
+ wydawnictwo_ciagle_z_autorem.autorzy_set.first().delete()
+
+ assert _publikacji_counts_bulk([autor.pk]).get(autor.pk, 0) == 0
+
+
+@pytest.mark.django_db
+def test_admin_filter_bez_dyscyplin_nie_liczy_skasowanych(
+ wydawnictwo_ciagle_z_dwoma_autorami, dyscyplina1, rodzaj_autora_n
+):
+ """``bpp.admin.filters.BezJakichkolwiekDyscyplinFilter``.
+
+ Filtr porównuje ``Count("autorzy_set")`` z liczbą autorstw bez
+ dyscypliny. Skasowane autorstwo Z dyscypliną podbijało tylko licznik
+ ogólny → rekord, w którym WSZYSCY żywi autorzy są bez dyscypliny,
+ wypadał z wyników.
+ """
+ from bpp.admin.filters import BezJakichkolwiekDyscyplinFilter
+
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ pierwsze = wc.autorzy_set.first()
+ _ustaw_dyscypline(pierwsze, dyscyplina1, rodzaj_autora_n)
+ pierwsze.delete()
+ # Zostało jedno ŻYWE autorstwo, BEZ dyscypliny → rekord ma być złapany.
+
+ f = BezJakichkolwiekDyscyplinFilter(
+ request=None,
+ params={"_bjkd_": ["tak"]},
+ model=Wydawnictwo_Ciagle,
+ model_admin=None,
+ )
+ qs = f.queryset(None, Wydawnictwo_Ciagle.objects.all())
+ assert list(qs.values_list("pk", flat=True)) == [wc.pk]
+
+
+@pytest.mark.django_db
+def test_pbn_wysylka_liczniki_oswiadczen_pomijaja_skasowane(
+ wydawnictwo_ciagle_z_dwoma_autorami, dyscyplina1, rodzaj_autora_n
+):
+ """``pbn_wysylka_oswiadczen.queries.get_publications_queryset``.
+
+ Skasowane oświadczenie liczyło się do ``liczba_oswiadczen``, więc filtr
+ ``tylko_odpiete`` (oswiadczen > 0 AND przypietych = 0) wskazywał do
+ wycofania z PBN rekordy, na których nie ma już żywego autorstwa.
+ """
+ from pbn_api.models import Publication
+ from pbn_wysylka_oswiadczen.queries import get_publications_queryset
+
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ for wca in wc.autorzy_set.all():
+ _ustaw_dyscypline(wca, dyscyplina1, rodzaj_autora_n, przypieta=False)
+
+ wc.pbn_uid = Publication.objects.create(mongoId="x" * 24, status="ACTIVE")
+ wc.save()
+
+ wc.autorzy_set.first().delete()
+
+ ciagle_qs, _zwarte = get_publications_queryset(
+ rok_od=wc.rok, rok_do=wc.rok, with_annotations=True
+ )
+ obiekt = ciagle_qs.get(pk=wc.pk)
+ assert obiekt.liczba_oswiadczen == 1
+ assert obiekt.liczba_przypietych == 0
+
+
+@pytest.mark.django_db
+def test_ewaluacja_verification_licznik_pomija_skasowane(
+ wydawnictwo_ciagle_z_dwoma_autorami, dyscyplina1, rodzaj_autora_n, admin_client
+):
+ """``ewaluacja_optymalizacja.views.verification`` — licznik „brak
+ oświadczenia" nie może liczyć publikacji po skasowanym autorstwie."""
+ from django.urls import reverse
+
+ wc = wydawnictwo_ciagle_z_dwoma_autorami
+ Wydawnictwo_Ciagle.objects.filter(pk=wc.pk).update(rok=2023)
+ for wca in wc.autorzy_set.all():
+ _ustaw_dyscypline(wca, dyscyplina1, rodzaj_autora_n, data_oswiadczenia=None)
+
+ url = reverse("ewaluacja_optymalizacja:database-verification")
+ assert admin_client.get(url).context["brak_oswiadczenia_ciagle_count"] == 1
+
+ for wca in wc.autorzy_set.all():
+ wca.delete()
+
+ assert admin_client.get(url).context["brak_oswiadczenia_ciagle_count"] == 0
+
+
+@pytest.mark.django_db
+def test_pbn_wyslij_oswiadczenia_pomija_publikacje_bez_zywego_autorstwa(
+ wydawnictwo_ciagle_z_autorem, dyscyplina1, rodzaj_autora_n
+):
+ """``pbn_wyslij_oswiadczenia_instytucji._get_publications_by_year``."""
+ from pbn_api.management.commands.pbn_wyslij_oswiadczenia_instytucji import (
+ Command,
+ )
+ from pbn_api.models import Publication
+
+ wc = wydawnictwo_ciagle_z_autorem
+ _ustaw_dyscypline(wc.autorzy_set.first(), dyscyplina1, rodzaj_autora_n)
+ wc.pbn_uid = Publication.objects.create(mongoId="y" * 24, status="ACTIVE")
+ wc.save()
+
+ def _znalezione():
+ return {p.pk for p in Command()._get_publications_by_year(wc.rok)}
+
+ assert wc.pk in _znalezione()
+
+ wc.autorzy_set.first().delete()
+
+ assert wc.pk not in _znalezione()
+
+
+@pytest.mark.django_db
+def test_rozbieznosci_scope_do_uczelni_ignoruje_skasowane(
+ settings, site1, jednostka_uczelnia1, jednostka_uczelnia2, typy_odpowiedzialnosci
+):
+ """``rozbieznosci.views._scope_do_uczelni`` — atrybucja multi-host.
+
+ Skasowane autorstwo nie może przypisywać rekordu do uczelni; inaczej
+ rekord wycieka na cudzy host. Test wymaga DWÓCH uczelni — przy jednej
+ helper jest no-opem (guard ``tylko_jedna_uczelnia``).
+ """
+ from model_bakery import baker
+
+ from fixtures.conftest_multisite import make_request_for_site
+ from rozbieznosci.views import _scope_do_uczelni
+
+ settings.ALLOWED_HOSTS = ["*"]
+ wc = baker.make("bpp.Wydawnictwo_Ciagle", rok=2023)
+ baker.make(
+ "bpp.Wydawnictwo_Ciagle_Autor",
+ rekord=wc,
+ autor=baker.make("bpp.Autor"),
+ jednostka=jednostka_uczelnia1,
+ )
+ request = make_request_for_site(site1, path="/rozbieznosci/if/")
+
+ qs_przed = _scope_do_uczelni(Wydawnictwo_Ciagle.objects.all(), request)
+ assert wc.pk in set(qs_przed.values_list("pk", flat=True))
+
+ wc.autorzy_set.first().delete()
+
+ qs_po = _scope_do_uczelni(Wydawnictwo_Ciagle.objects.all(), request)
+ assert wc.pk not in set(qs_po.values_list("pk", flat=True))
+
+
+@pytest.mark.django_db
+def test_export_bibtex_filtr_po_autorze_pomija_skasowane(
+ wydawnictwo_ciagle_z_autorem, capsys
+):
+ """``bpp.management.commands.export_bibtex`` — ``--author``.
+
+ Filtr szedł po M2M ``autorzy__nazwisko``; skasowane autorstwo dalej
+ wciągało publikację do eksportu. (``capsys``, bo komenda drukuje rekordy
+ zwykłym ``print()``, a nie przez ``self.stdout``.)
+ """
+ from django.core.management import call_command
+ from django.core.management.base import CommandError
+
+ wc = wydawnictwo_ciagle_z_autorem
+ nazwisko = wc.autorzy_set.first().autor.nazwisko
+
+ call_command("export_bibtex", author=nazwisko)
+ assert "Exporting 1 publications" in capsys.readouterr().out
+
+ wc.autorzy_set.first().delete()
+
+ # Komenda kończy się CommandError-em, gdy nic nie pasuje — to jest tu
+ # POŻĄDANY wynik: po skasowaniu autorstwa nie ma czego eksportować.
+ with pytest.raises(CommandError, match="No publications found"):
+ call_command("export_bibtex", author=nazwisko)
+
+
+@pytest.mark.django_db
+def test_ranking_autora_w_wyszukiwarce_nie_liczy_skasowanych(
+ wydawnictwo_ciagle_z_autorem,
+):
+ """``AutorManager.fulltext_annotate`` — ``search__rank`` husku autora.
+
+ Ranking podpowiedzi autora to liczba jego autorstw. Bez predykatu husk
+ (wszystkie autorstwa skasowane) trzymał swoją pozycję w podpowiedziach.
+ """
+ autor = wydawnictwo_ciagle_z_autorem.autorzy_set.first().autor
+
+ def _rank():
+ return (
+ Autor.objects.filter(pk=autor.pk)
+ .annotate(**Autor.objects.fulltext_annotate(None, None))
+ .values_list("search__rank", flat=True)[0]
+ )
+
+ assert _rank() == 1
+
+ wydawnictwo_ciagle_z_autorem.autorzy_set.first().delete()
+
+ assert _rank() == 0
+
+
+@pytest.mark.django_db
+def test_djangoql_deleted_at_jest_wyrazalne_w_zapytaniu(
+ wydawnictwo_ciagle_z_autorem,
+):
+ """Deduplikator otwiera changelist zapytaniem DjangoQL — musi dać się
+ w nim wyrazić ``autorzy_set.deleted_at = None``.
+
+ Bez tego (a) składnia sypie się na nieznanym polu, albo (b) — gorzej —
+ zapytanie przechodzi, ale JOIN pokazuje skasowane autorstwa, czyli
+ dokładnie husk, który deduplikator ma pomagać usuwać.
+ """
+ from djangoql.queryset import apply_search
+
+ from bpp.djangoql_schema import BppQLSchema
+
+ wc = wydawnictwo_ciagle_z_autorem
+ autor_id = wc.autorzy_set.first().autor_id
+ zapytanie = f"autorzy_set.autor.id = {autor_id} and autorzy_set.deleted_at = None"
+
+ qs = apply_search(Wydawnictwo_Ciagle.objects.all(), zapytanie, schema=BppQLSchema)
+ assert wc.pk in set(qs.values_list("pk", flat=True))
+
+ wc.autorzy_set.first().delete()
+
+ qs = apply_search(Wydawnictwo_Ciagle.objects.all(), zapytanie, schema=BppQLSchema)
+ assert wc.pk not in set(qs.values_list("pk", flat=True))
+
+
+@pytest.mark.django_db
+def test_denorm_rebuild_CELOWO_widzi_skasowane(wydawnictwo_ciagle_z_autorem):
+ """``bpp.models.dyscyplina_naukowa`` — jedyny ŚWIADOMY brak predykatu.
+
+ ``rebuild_instances_of(klass, rok=…, autorzy_set__autor_id=…)`` wybiera
+ publikacje DO PRZELICZENIA. Publikacja, której autorstwo skasowano,
+ potrzebuje przeliczenia tak samo (a nawet bardziej) niż ta z autorstwem
+ żywym — dodanie ``deleted_at__isnull=True`` wycięłoby ją ze zbioru
+ i zostawiło nieaktualne punkty do nocnej rekalkulacji. Ten test przypina
+ tę decyzję, żeby nikt jej „nie naprawił" hurtem.
+ """
+ wc = wydawnictwo_ciagle_z_autorem
+ autor_id = wc.autorzy_set.first().autor_id
+ wc.autorzy_set.first().delete()
+
+ assert Wydawnictwo_Ciagle.objects.filter(
+ rok=wc.rok, autorzy_set__autor_id=autor_id
+ ).exists(), "rebuild_instances_of musi nadal widzieć tę publikację"
+
+
+@pytest.mark.django_db
+def test_kanarek_agregat_bez_predykatu_zawyza(wydawnictwo_ciagle_z_autorem):
+ """Kanarek negatywny dla agregatów (symetryczny do tego dla ``filter``)."""
+ wc = wydawnictwo_ciagle_z_autorem
+ wc.autorzy_set.first().delete()
+
+ zawyzony = (
+ Wydawnictwo_Ciagle.objects.filter(pk=wc.pk)
+ .annotate(n=Count("autorzy_set"))
+ .values_list("n", flat=True)[0]
+ )
+ poprawny = (
+ Wydawnictwo_Ciagle.objects.filter(pk=wc.pk)
+ .annotate(
+ n=Count("autorzy_set", filter=Q(autorzy_set__deleted_at__isnull=True))
+ )
+ .values_list("n", flat=True)[0]
+ )
+ assert zawyzony == 1
+ assert poprawny == 0
diff --git a/src/bpp/views/autocomplete/search_services.py b/src/bpp/views/autocomplete/search_services.py
index e39e7342b..5d47dbb8b 100644
--- a/src/bpp/views/autocomplete/search_services.py
+++ b/src/bpp/views/autocomplete/search_services.py
@@ -1,5 +1,6 @@
"""Global search functions for autocomplete views."""
+from django.db.models import Q
from django.db.models.aggregates import Count
from bpp import const
@@ -72,13 +73,24 @@ def _scope(qs):
)
)
+ # Sortowanie pomocnicze po „dorobku" autora. Liczymy autorstwa przez
+ # through-model z ``filter=Q(…__deleted_at__isnull=True)`` — agregat po
+ # relacji idzie JOIN-em po SUROWEJ tabeli ``bpp_wydawnictwo_ciagle_autor``
+ # i managera soft-delete nie pyta, więc bez predykatu husk autora
+ # (wszystkie autorstwa skasowane) trzymałby swoją pozycję w podpowiedziach.
+ # Ta sama zasada co w ``AutorManager.fulltext_annotate``.
querysets.append(
_scope(
Autor.objects.fulltext_filter(q)
- .annotate(Count("wydawnictwo_ciagle"))
+ .annotate(
+ liczba_autorstw=Count(
+ "wydawnictwo_ciagle_autor",
+ filter=Q(wydawnictwo_ciagle_autor__deleted_at__isnull=True),
+ )
+ )
.only(*AUTOR_ONLY)
.select_related(*AUTOR_SELECT_RELATED)
- .order_by("-search__rank", "-wydawnictwo_ciagle__count")
+ .order_by("-search__rank", "-liczba_autorstw")
)
)
diff --git a/src/bpp/views/browse.py b/src/bpp/views/browse.py
index dd7db3377..72a8cfbbe 100644
--- a/src/bpp/views/browse.py
+++ b/src/bpp/views/browse.py
@@ -122,9 +122,16 @@ def get_uczelnia_context_data(uczelnia, article_slug=None):
# JOIN po relacji wielowartościowej (rekord z N autorstwami tej samej
# uczelni dałby N kopii streszczenia). W single-install filtru nie ma,
# więc nie ma czego deduplikować.
+ #
+ # ``rekord__autorzy_set__deleted_at__isnull=True``: ten JOIN idzie po
+ # SUROWEJ tabeli ``bpp_wydawnictwo_ciagle_autor`` (manager soft-delete
+ # nie jest pytany przy lookupie przez relację), więc bez predykatu
+ # skasowane autorstwo nadal atrybuowałoby streszczenie do uczelni —
+ # czyli publikowałoby je na cudzym hoście.
if uczelnia is not None and not tylko_jedna_uczelnia():
recent_abstracts = recent_abstracts.filter(
- rekord__autorzy_set__jednostka__uczelnia=uczelnia
+ rekord__autorzy_set__jednostka__uczelnia=uczelnia,
+ rekord__autorzy_set__deleted_at__isnull=True,
).distinct()
context["recent_abstracts"] = recent_abstracts.order_by(
"-rekord__ostatnio_zmieniony"
@@ -448,7 +455,8 @@ def get_queryset(self):
# * ``fulltext_filter`` dla ``Autor`` — UWAGA, tu JOIN JEST:
# ``AutorManager.fulltext_annotate`` (``bpp/models/autor.py``)
# nadpisuje wersję z ``FulltextSearchMixin`` i zwraca
- # ``Count("wydawnictwo_ciagle")``, co dokłada ``LEFT OUTER JOIN
+ # ``Count("wydawnictwo_ciagle_autor", filter=Q(…deleted_at
+ # __isnull=True))``, co dokłada ``LEFT OUTER JOIN
# bpp_wydawnictwo_ciagle_autor``. Wierszy nie mnoży wyłącznie
# dlatego, że agregat wymusza ``GROUP BY`` po pk — deduplikacja
# pochodzi stamtąd, nie z braku złączenia. Zmiana tej adnotacji na
diff --git a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html
index fcbcb83ee..315b6e1c5 100644
--- a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html
+++ b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html
@@ -1347,8 +1347,15 @@ {% if duplikat_data.publikacje_c
var type = target.dataset.openWydawnictwo;
var autorId = target.dataset.autorId;
+ // 'autorzy_set.deleted_at = None' jest KONIECZNE: DjangoQL tłumaczy
+ // 'autorzy_set.autor.id = N' na JOIN po SUROWEJ tabeli
+ // bpp_wydawnictwo_*_autor i managera soft-delete nie pyta. Bez tego
+ // deduplikator pokazywałby publikacje, na których autorstwo tego
+ // autora zostało już skasowane — czyli dokładnie husk, który
+ // deduplikator ma pomagać usuwać.
var baseQuery = encodeURIComponent(
- 'rok >= 2022 and rok <= 2025 and autorzy_set.autor.id = ' + autorId
+ 'rok >= 2022 and rok <= 2025 and autorzy_set.autor.id = ' + autorId +
+ ' and autorzy_set.deleted_at = None'
);
window.open('/admin/bpp/wydawnictwo_' + type + '/?q-l=on&q=' + baseQuery, '_blank');
e.preventDefault();
diff --git a/src/django_bpp/templates/admin/bpp/autor/change_form.html b/src/django_bpp/templates/admin/bpp/autor/change_form.html
index ae0c31f84..7f2743325 100644
--- a/src/django_bpp/templates/admin/bpp/autor/change_form.html
+++ b/src/django_bpp/templates/admin/bpp/autor/change_form.html
@@ -1,16 +1,21 @@
{% extends "admin/change_form.html" %}
{% block object-tools-items %}
+ {# ``autorzy_set__deleted_at__isnull=True`` w obu linkach niżej: changelist #}
+ {# admina filtruje JOIN-em po SUROWEJ tabeli ``bpp_wydawnictwo_*_autor``, #}
+ {# a nie przez manager soft-delete. Bez tego parametru „publikacje autora" #}
+ {# pokazywałyby też te, na których autorstwo zostało SKASOWANE. Lookup jest #}
+ {# przepuszczony przez ``lookup_allowed`` w Wydawnictwo_{Ciagle,Zwarte}Admin. #}
{% if original %}
+ href="{% url 'admin:bpp_wydawnictwo_ciagle_changelist' %}?autorzy_set__autor__id__exact={{ original.pk }}&autorzy_set__deleted_at__isnull=True">
📰 Wyd. ciągłe
+ href="{% url 'admin:bpp_wydawnictwo_zwarte_changelist' %}?autorzy_set__autor__id__exact={{ original.pk }}&autorzy_set__deleted_at__isnull=True">
📚 Wyd. zwarte
diff --git a/src/ewaluacja_optymalizacja/templates/ewaluacja_optymalizacja/database_verification.html b/src/ewaluacja_optymalizacja/templates/ewaluacja_optymalizacja/database_verification.html
index 98fc48720..17bdb6436 100644
--- a/src/ewaluacja_optymalizacja/templates/ewaluacja_optymalizacja/database_verification.html
+++ b/src/ewaluacja_optymalizacja/templates/ewaluacja_optymalizacja/database_verification.html
@@ -126,16 +126,21 @@ Brak daty oświadczenia
+ {# ``autorzy_set__deleted_at__isnull=True`` MUSI być w obu linkach: #}
+ {# changelist admina filtruje JOIN-em po surowej tabeli ``*_autor``, #}
+ {# a liczniki wyżej (verification.py) już ten predykat mają. Bez niego #}
+ {# lista w adminie byłaby dłuższa niż liczba obok — o publikacje, #}
+ {# których problematyczne autorstwo zostało skasowane. #}