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..175719135 --- /dev/null +++ b/docs/deweloper/runbook-soft-delete-faza-01.md @@ -0,0 +1,203 @@ +# 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). + +> **Faza 02 rozszerza to na tabele publikacji.** Migracja `0496` dokłada +> `deleted_at` do pięciu tabel publikacji, więc **każdy** zjazd poniżej +> `0496` — nie tylko poniżej `0488` — wpada w ten sam mechanizm. Zasada +> pozostaje ta sama i nie zmienia procedury: rollback wycofuje KOD razem ze +> schematem, a stare modele nie deklarują `deleted_at`, więc `denorm` +> wygeneruje poprawne triggery. Kombinacja „nowy kod + stary schemat" +> powstaje wyłącznie sztucznie. +> +> Skutek uboczny w testach: testy odwracalności fazy 01 +> (`test_migracja_0489_rewers`, `test_migracje_0490_0493_rewers`) schodzą +> poniżej `0496`, trzymając kod na miejscu — czyli produkują dokładnie tę +> zakazaną kombinację. Dlatego korzystają z fixtury +> `bez_reinstalacji_denorma` (`src/bpp/tests/test_soft_delete/conftest.py`), +> która odpina handler `post_migrate` na czas testu i przebudowuje triggery +> ręcznie na końcu. To obejście **testowe** — nie zmienia niczego we +> wdrożeniu. + +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ą. +- **DjangoQL: kosz domyślnie niewidoczny (ZAMKNIĘTE 2026-08-07).** + Wcześniejszy zapis mówił, że język zapytań traktujemy jako narzędzie + audytowe, które *ma* widzieć kosz, a artefakty schematu dla LLM są + nieaktualne. **Oba punkty są nieaktualne.** Decyzją właściciela projektu: + + 1. **Zapytania DjangoQL domyślnie NIE zwracają rekordów skasowanych** — + w adminach, na `/zapytanie/` i w `/api/v1/zapytanie/*`. Realizuje to + `src/bpp/djangoql_soft_delete.py` (`WykluczSkasowaneMixin` wpięty w + `BppQLSchema` i `RekordLLMSchema`); zakres wykrywany introspekcją + `SoftDeleteModel`, więc faza 02 nic tu nie przepisuje. + 2. **Kosz jest nadal dostępny, ale na jawne żądanie** — wystarczy wymienić + `deleted_at` na danej ścieżce relacji: + `autorzy_set.deleted_at != None` (tylko kosz), + `autorzy_set.deleted_at = None` (to samo, co domyślnie). + Zdjęcie predykatu działa PER PREFIKS ścieżki, nie globalnie. + Świadome ślepe plamy (M2M przez through, `relacja != None`, operatory + negujące na ścieżce relacyjnej) — opisane w docstringu modułu. + 3. **Artefakty `src/bpp/data/*_djangoql_schema.compact.txt` zregenerowane** + — mają `deleted_at` oraz notę w nagłówku mówiącą konsumentowi, że + kosz jest domyślnie pomijany i jak go zobaczyć. + 4. **Jest test świeżości** — + `src/bpp/tests/test_djangoql_schema_swiezosc.py` pada, gdy artefakt + rozjedzie się z modelami. Regeneracja: + `uv run python src/manage.py opisz_schemat_djangoql_dla_llm --wszystkie-korzenie` + (przeciw bazie z `baseline-sql/baseline.sql`, żeby nie wciągnąć danych + instytucji). + + Otwarte zostaje wyłącznie pytanie o **ręcznie sklejone parametry GET + changelistu admina** (`?autorzy_set__…=`) — tam whitelist `lookup_allowed` + nadal przepuszcza surowy lookup. + +- **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 +``` diff --git a/docs/superpowers/HANDOFF-faza-03-domkniecie.md b/docs/superpowers/HANDOFF-faza-03-domkniecie.md new file mode 100644 index 000000000..360f93e2b --- /dev/null +++ b/docs/superpowers/HANDOFF-faza-03-domkniecie.md @@ -0,0 +1,200 @@ +# Handoff: domknięcie fazy 03 (PR #742 wymaga poprawek) + +> ## ✅ ZAMKNIĘTE 2026-08-08 — dokument HISTORYCZNY, nie lista zadań +> +> Wszystkie pięć pozycji (3.1–3.5) zostało zrobione. Nie działaj na podstawie +> tego pliku — aktualny stan i dług są w +> `docs/superpowers/HANDOFF-soft-delete-faza-04.md`. +> +> | Pozycja | Commit | +> |---|---| +> | 3.1 zaglądanie do kosza w gałęzi `None` | `b98aad5be` | +> | 3.2 warunkowy unique na habilitacji | `9c3ecb23e` | +> | 3.3 detekcja kolizji po `global_objects` | `369e6a5f6` | +> | 3.4 brak kolejkowania kosza do PBN | `369e6a5f6` | +> | 3.5 opis PR #742 | przepisany w GitHubie | +> +> Pozycje 9, 10, 13, 14 z §4 (niższy priorytet) NADAL otwarte — przeniesione +> do §6 handoffu fazy 04. Pułapki z §5 przeniesione do §7 tamże, wraz z trzema +> nowymi, które kosztowały tę sesję. +> +> --- + +> Stan na 2026-08-08. Sesja przerwana w połowie naprawiania po self-review. +> **PR #742 NIE nadaje się do scalenia w obecnym stanie.** + +--- + +## 1. Gdzie jesteś + +| | | +|---|---| +| Worktree | `~/Programowanie/bpp-soft-delete-03` | +| Gałąź | `feat/soft-delete-03`, baza `feat/soft-delete` | +| PR | [#742](https://github.com/iplweb/bpp/pull/742), OPEN | +| Ostatni commit | `eb046897a` — cofnięcie akcesorów | +| Drzewo | czyste, wszystko wypchnięte | + +Uruchom testy na start, żeby potwierdzić stan: +```bash +cd ~/Programowanie/bpp-soft-delete-03 +uv run pytest src/bpp/tests/test_soft_delete/ src/pbn_api/ -q +``` +Oczekiwane: zielono. + +--- + +## 2. Co się wydarzyło (skrót) + +Faza 03 miała sprawić, żeby re-import z PBN nie tworzył duplikatów rekordów +soft-skasowanych. Pierwsza implementacja poszła **złą drogą**: kazała +akcesorom (`rekord_w_bpp`, `get_bpp_publication`, `matchuj_publikacje`) +zaglądać do kosza i zwracać skasowane rekordy. + +Self-review (osobny agent) znalazł 3 blokery i kilka rzeczy ważnych. +**Blokery 1 i 2 są już naprawione** commitem `eb046897a`. + +### Decyzja właściciela, która ustawia resztę + +> **Soft-delete znaczy, że rekordu NIE MA.** `Rekord` (widok +> `bpp_rekord_mat`) jest odfiltrowany po `deleted_at` i tak ma zostać. +> Akcesor zwraca `Rekord` albo `None` — nigdy rzeczy z kosza. +> +> Zaglądanie do kosza jest decyzją **IMPORTERA**, nie akcesora. + +Oraz, na pytanie „co zrobić, gdy import trafi na rekord w koszu": + +> **Wariant A: PRZYWRÓĆ + ODNOTUJ.** PBN jest źródłem prawdy — skoro rekord +> tam jest, ma wrócić do BPP. Ślad w rejestrze +> `pbn_integrator.RekordPrzywroconyPrzezImport`. + +--- + +## 3. Do zrobienia — 5 pozycji, w tej kolejności + +### 3.1. Przenieść zaglądanie do kosza w gałąź `None` (wariant A) + +Dziś w `articles.py:70`, `books.py:68`, `chapters.py:113` jest: + +```python +ret = pbn_publication.rekord_w_bpp +if ret is not None and not force: + przywroc_jesli_w_koszu(ret, pbn_publication, "articles") + return ret +``` + +Po cofnięciu akcesorów `ret` to `Rekord` albo `None` — **nigdy** obiekt +z kosza. Czyli `przywroc_jesli_w_koszu(ret, ...)` jest teraz **no-opem** +(`Rekord` nie ma pola `deleted_at`). + +Docelowo: gdy `ret is None`, sprawdzić kosz JAWNIE po `pbn_uid` +(`Wydawnictwo_Ciagle.deleted_objects.filter(pbn_uid_id=...)` itd.), +przywrócić i wpisać do rejestru; dopiero potem tworzyć nowy rekord. + +⚠️ `Patent` NIE ma pola `pbn_uid` — `.filter(pbn_uid_id=…)` na nim wywala +`FieldError` (Django resolwuje nazwy pól natychmiast). + +`przywroc_jesli_w_koszu()` w `pbn_integrator/kosz.py` zostaje bez zmian — +jest dobry, ma testy, zweryfikowany mutacyjnie. + +### 3.2. BLOKER 3 — warunkowy unique na habilitacji + +`Praca_Habilitacyjna.autor` to `OneToOneField` → `UNIQUE (autor_id)`, które +**nie zna kosza**. Duplikat z habilitacją w koszu + główny autor z żywą → +`scal_autora` pada `IntegrityError` i **całe scalanie zwraca porażkę**. +Przed fazą 03 wiersz z kosza nie był przenoszony, więc scalanie się udawało. + +Naprawa (decyzja właściciela): zdjąć `unique` z pola, dodać warunkowy +`UniqueConstraint(fields=["autor"], condition=Q(deleted_at__isnull=True))` +w `Meta`. Ten sam wzorzec, co faza 01 zastosowała do `*_Autor`. + +Gdy OBIE habilitacje są żywe — to prawdziwy konflikt. Jawny komunikat: +„Nie można scalić autorów: obaj mają pracę habilitacyjną", zamiast +`IntegrityError`. + +Test najpierw — musi być czerwony przed zmianą constraintu. + +### 3.3. Detekcja kolizji w scalaniu — `merge.py:145` + +`existing = model.objects.filter(rekord=…, autor=glowny, typ=…)` — na +menedżerze ŻYWYCH. Gdy publikacja jest w koszu, oba autorstwa też są +w koszu → kolizja niewykryta → powstają DWA wiersze +`(rekord, glowny, typ)` w koszu. + +Skutek: `wc.restore()` wywala się `IntegrityError` na +`wc_autor_uniq_rekord_autor_typ`. **Kosz staje się drzwiami +jednokierunkowymi.** Dotyczy też `przywroc_jesli_w_koszu` w imporcie. + +Naprawa: detekcja kolizji ma iść po `global_objects`. + +### 3.4. Scalanie kolejkuje do PBN publikację z kosza + +`merge.py:214-222` — `PBN_Export_Queue.objects.create(...)` dla +`record.rekord`, także gdy ten jest soft-skasowany. Sprzeczne z kierunkiem +fazy 05 (soft-delete ma **wycofywać** oświadczenia, nie wysyłać). + +Naprawa: pominąć kolejkowanie, gdy `record.rekord.deleted_at` jest ustawione. + +### 3.5. Poprawić opis PR #742 + +Obecny opis twierdzi, że blocker duplikatów jest domknięty. **Nie jest.** + +- Ścieżka fuzzy (rekord BPP **bez** `pbn_uid`, czyli praca wprowadzona + ręcznie) idzie przez `Rekord`, więc kosza nie widzi → duplikat nadal + powstanie. Po cofnięciu akcesorów to zachowanie **zamierzone** (rekordu + nie ma), ale opis PR-a sugeruje co innego. +- Ścieżka po `pbn_uid`: unique na `pbn_uid_id` **nie jest** partial, więc + skasowany rekord i tak trzyma to pole. Przed fazą 03 dawało to + *wywalenie importu*, nie cichy duplikat. + +Zaktualizować też tabelkę „6 lookupów matchingu widzi kosz" — nieaktualna +po cofnięciu. + +--- + +## 4. Ustalenia recenzji, które ZOSTAJĄ otwarte (niższy priorytet) + +| # | Rzecz | Gdzie | +|---|---|---| +| 9 | Rejestr wskrzeszeń niewidoczny — `pbn_integrator/admin.py` to nadal `# Register your models here.` Brak też indeksu `(content_type, object_id)` pod pytanie „czy TEN rekord wrócił?" | `pbn_integrator/admin.py`, `models.py` | +| 10 | `force=True` omija wskrzeszenie (`if ret is not None and not force`), a `znajdz_ksiazke_nadrzedna` leży ZA tym guardem → książka-matka jest wskrzeszana przy `force`, sam rozdział nie. Niespójne | 3 importery | +| 12 | Brak newsfragmentu (CLAUDE.md wymaga) | `src/bpp/newsfragments/` | +| 13 | `Oswiadczenie_Instytucji.get_bpp_publication` iteruje po 4 modelach po `pbn_uid` przez `.objects` — nie ma go ani na liście zmienionych, ani „świadomie zostawionych" | `pbn_api/models/oswiadczenie_instytucji.py:51-74` | +| 14 | `hard_delete()` na querysecie NIE emituje `post_hard_delete` — najbardziej destrukcyjna operacja przejdzie niezauważona przez `SoftDeleteLog` z fazy 06 | do handoffu fazy 06 | + +--- + +## 5. Pułapki, które już kosztowały (nie powtarzaj) + +- **Lista wołaczy ≠ lista założeń wołaczy.** Sprawdziłem, kto woła + `rekord_w_bpp`, ale nie co robi z wynikiem. Admin woła `.original` — + atrybut istniejący WYŁĄCZNIE na `Rekord`. Efekt: `AttributeError` na całej + changeliście, zero pokrycia testami. Naprawione + dołożony + `src/pbn_api/tests/test_admin_rekord_w_bpp.py`. +- **Django połyka brak atrybutu w SZABLONACH.** Ten sam błąd w + `change_form.html` dawał pusty `href` zamiast wyjątku — objaw gorszy niż + crash. +- **`matchuj_publikacje` ma DWÓCH wołaczy o sprzecznych potrzebach**: + `pbn_api` (przekazuje `Rekord`) i `deduplikator_publikacji` + (`tasks.py:187`, przekazuje modele konkretne). Zmiana „na globalny + menedżer widzący" sprawiła, że dedup zaczął podsuwać kosz — wbrew decyzji + z Taska 8. Mój test tego nie złapał, bo pokrywał tylko połowę skanującą. +- **`make tests-without-playwright` zwraca EXIT 0 mimo porażek.** Czytaj + podsumowanie pytest, nie kod wyjścia. +- **`ruff format` na całym katalogu zagarnia cudze pliki.** Formatuj tylko + swoje. +- **Mutacja jest jedynym dowodem, że test coś pilnuje.** Czerwień z + `ImportError` nie dowodzi, że asercje działają. + +--- + +## 6. Poza tą gałęzią + +- **PR upstream** [soynatan/django-easy-audit#348](https://github.com/soynatan/django-easy-audit/pull/348) + — wystawiony z forka `mpasternak`. Po scaleniu skasować + `src/bpp/easyaudit_shim.py`, wywołanie `zainstaluj()` w `BppConfig.ready()` + i `test_easyaudit_shim.py`. Przypomni o tym + `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`. +- **Bramka wydania przesunięta na fazę 07** (decyzja 2026-08-08) — patrz + `HANDOFF-soft-delete-faza-04.md` §4b. +- **Nagrobki (OAI-PMH / CERIF / API) wciągnięte do fazy 05.** diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-02.md b/docs/superpowers/HANDOFF-soft-delete-faza-02.md new file mode 100644 index 000000000..00ecb388a --- /dev/null +++ b/docs/superpowers/HANDOFF-soft-delete-faza-02.md @@ -0,0 +1,210 @@ +# Handoff: soft-delete, start fazy 02 + +> Dokument przekazania po zamknięciu **fazy 01**. Napisany 2026-08-07, po +> sesji, która wykonała całą fazę 01 (41 commitów, 9 migracji, PR #312, CI 23/23). +> +> **Czytaj to zamiast odtwarzania historii z gita.** Zawiera rzeczy, których +> nie widać w diffie, a które w fazie 01 kosztowały rundy poprawek. + +--- + +## 1. Gdzie jesteśmy + +| | | +|---|---| +| Gałąź | `feat/soft-delete`, worktree `~/Programowanie/bpp-soft-delete` | +| PR | #312, **CI 23/23 SUCCESS** | +| Commity fazy 01 | 41 (od `7119a766f`) | +| Migracje | `bpp/0488`…`0496`, `rozbieznosci_dyscyplin/0022` | +| Testy | 9409 passed (+157 Playwright), 0 failed | + +**Faza 01 objęła TYLKO autorstwa** — 3 through-modele `Wydawnictwo_Ciagle_Autor`, +`Wydawnictwo_Zwarte_Autor`, `Patent_Autor`. Publikacje to faza 02. + +Dokumenty: +- spec: `docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md` +- plan fazy 02: `docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md` +- **runbook wdrożeniowy**: `docs/deweloper/runbook-soft-delete-faza-01.md` +- ledger fazy 01 (gitignored, tylko lokalnie): + `.superpowers/sdd/2026-06-04-soft-delete-01-autor-trigger-widoki/progress.md` + +--- + +## 2. Najważniejsza lekcja fazy 01 + +**Soft-delete nie jest „dodaniem kolumny".** To zmiana kontraktu `delete()` +z „wiersz przestaje istnieć" na „wiersz istnieje, ale się nie liczy". Każdy +mechanizm, który polegał na pierwszym znaczeniu, trzeba znaleźć i przekonfigurować. + +W fazie 01 takich mechanizmów było **siedem**, odkrywanych pojedynczo, przez awarie: + +| # | Mechanizm | Jak się objawiał | +|---|---|---| +| 1 | bramka `WHEN` triggerów cache (`0433`) | `save(update_fields=[…])` nie ruszał bramkowanej kolumny → trigger się nie odpalał | +| 2 | funkcje refresh (`0432`) | czysty upsert bez `DELETE` → odfiltrowanie z widoku było no-opem | +| 3 | bramka `django-denorm` | **drugi, niezależny** system triggerów z własną bramką z list `only=` | +| 4 | `unique_together` | blokował „skasuj i wstaw od nowa" (`UniqueViolation` w re-imporcie) | +| 5 | legacy raw-SQL `UNIQUE … DEFERRABLE` z `0132` (2018) | **niewidoczny dla ORM**; wybuchał dopiero przy `COMMIT` | +| 6 | `restore(strict=True)` | pakiet sprawdza `strict` dla *każdej* relacji → gołe `.restore()` rzucało wyjątek | +| 7 | widoki pochodne | `liczba_autorow`, ranking, raport rozbieżności czytały surową tabelę | + +**Faza 02 dostaje dwa strażniki, żeby nie powtarzać tego odkrywania:** + +- `src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py` — pyta `pg_depend` + o zależność na poziomie **kolumny** i pada, gdy widok czyta tabelę soft-delete + bez filtra po jej `deleted_at`; +- `src/bpp/tests/test_soft_delete/test_kanarek_orm.py` — AST, wykrywa zapytania + ORM z JOIN-em po relacji bez predykatu `deleted_at`. + +> ⚠️ **Rozszerz `TABELE_SOFT_DELETE` w kanarku katalogowym NA STARCIE fazy 02, +> a nie na końcu.** To jest cała jego wartość: dostajesz listę winowajców +> w pierwszej godzinie, zamiast odkrywać ich po jednym przez trzy tygodnie. +> Faza 01 zrobiła to odwrotnie i kosztowało to trzy dodatkowe zadania oraz +> werdykt „NIE SCALAĆ" na finalnej recenzji. + +--- + +## 3. Zakres fazy 02 — co MUSI się w niej znaleźć + +Plan (`…-02-publikacje.md`) ma jawną tabelę kolejności wykonania — taski +dopisywane po rewizjach wylądowały poza numeracją, więc **czytaj tabelę, +nie numery nagłówków**. + +Poza pierwotnym zakresem doszły cztery rzeczy: + +### 3.1 Nagrobki (decyzja właściciela, obowiązkowa) + +`ostatnio_zmieniony` **MUSI** być bumpowany przy kasowaniu — to już jest +w kontrakcie PINNED (faza 01, `BppAutorstwoSoftDeleteMixin.save()`), więc +publikacje dostaną to „z urodzenia". Ale trzeba dołożyć **widoczność usunięć +na zewnątrz**: + +- **OAI-PMH**: `
` w `src/cerif_export` (serwuje + `ListRecords`/`ListIdentifiers`/`resumptionToken`; **nie ma dziś żadnej + obsługi `deleted`**), respektujące `from`/`until`; +- **CERIF**: odpowiednik nagrobka w formacie rekordu; +- **API `/api/v1/`**: sposób odkrycia usuniętych (endpoint „usunięte od…" + albo parametr). + +Dzięki bumpowi lista nagrobków to po prostu +`Model.deleted_objects.filter(ostatnio_zmieniony__gte=X)` — bez potrzeby +`SoftDeleteLog` z fazy 06. + +**Traktuj to jako bramkę wydania**: faza 02 nie idzie na produkcję bez tego. + +### 3.2 Agregaty przez `FILTER`, nie przez `WHERE`/`JOIN` + +To jest pułapka, która **niszczy dane**, a nie tylko przecieka: + +```sql +-- ŹLE: warunek na prawej stronie LEFT JOIN degeneruje go do INNER JOIN +-- → publikacja, której WSZYSCY autorzy są w koszu, ZNIKA z bpp_rekord_mat +LEFT JOIN bpp_..._autor ON … WHERE bpp_..._autor.deleted_at IS NULL + +-- DOBRZE: liczy zero, ale zachowuje wiersz +count(bpp_..._autor.autor_id) FILTER (WHERE bpp_..._autor.deleted_at IS NULL) +``` + +Sprawdź **wszystkie** agregaty po tabelach objętych soft-delete +(`count`/`min`/`max`/`array_agg`/`string_agg`/`sum`/`bool_*`), nie tylko `count`. + +### 3.3 Sprzątanie `bpp_kronika_*` — siedem widoków, jedną migracją + +Cała rodzina jest martwa (zero konsumentów w kodzie, szablonach, `Meta.db_table`, +`flexible_reports`, surowym SQL-u). Nie da się skasować trzech osobno — zależą +od nich dwa widoki nadrzędne. Graf zależności i kolejność `DROP` są w planie 02. +Po zrobieniu: usuń wpisy z `WYJATKI` w kanarku katalogowym. + +### 3.4 Rozszerzenie obu kanarków + +`TABELE_SOFT_DELETE` += 5 tabel publikacji. Po tym kanarek złapie też +`bpp_kronika_praca_{doktorska,habilitacyjna}_view` — ich żywotności **nikt +jeszcze nie zweryfikował**. + +--- + +## 4. Fakty o kodzie, które w fazie 01 były źródłem błędów + +Wszystkie zweryfikowane empirycznie. Nie zakładaj, że któryś jest inny — +ale przy okazji fazy 02 warto potwierdzić, bo `dev` żyje. + +- **`.filter()` resolwuje nazwy pól NATYCHMIAST** (`Query.build_filter`), nie + przy iteracji. `FieldError` leci od razu na modelu bez kolumny. +- **`content_type_id` NIE istnieje** na modelach publikacji — to property na + `RekordBase` (`rekord.py:288`). Używaj `ContentType.objects.get_for_model()`. +- **`transactional_db` NIE jest potrzebny** — triggery działają w transakcji + testowej. Zwykły `django_db` wystarcza. +- **Fixtury `wydawnictwo_ciagle_z_dwoma_autorami` i `wydawnictwo_ciagle_z_autorem` + to TEN SAM obiekt** (`conftest_publications.py:193-215`, pierwsza zwraca drugą). +- **`Rekord` czyta tabelę `bpp_rekord_mat`**, nie widok `bpp_rekord` + (`rekord.py:382-386`); widok obsługuje marginalna klasa `RekordView`. +- **`full_refresh()` to `denorm.rebuildall`**, NIE re-projekcja `_mat` + (`rekord.py:121-127`). Nie nadaje się do weryfikacji spójności soft-delete. +- **`_upsert_sql` z `0432` mapuje kolumny POZYCYJNIE.** Zmiana definicji widoku, + która przestawi kolumny, wpisze dane do złych pól i nic nie krzyknie. +- **`Uczelnia.objects.get_default()` NIE ISTNIEJE** — usunięte, pilnowane + `src/bpp/tests/test_multihosted_get_default_guard.py`. +- **`SentData` jest scope'owane po uczelni** — `get_for_rec(rec, uczelnia=None)`; + przy ≥2 wierszach lookup bez uczelni rzuca `MultipleObjectsReturned`. +- **Klient PBN to pakiet zewnętrzny** (`pbn_client`), nie `src/pbn_api/client/mixins/`. +- **`DENORM_DISABLE_AUTOTIME_DURING_FLUSH = True`** (`settings/base.py:1222`) — + zapisy denorma nie bumpują `ostatnio_zmieniony`. Skutek: zmiana składu autorów + nie podnosi znacznika publikacji. **Zachowanie prekursorskie** (hard-delete + działa tak samo), opisane w runbooku jako obserwacja. + +--- + +## 5. Jak pracować (proces, który się sprawdził — i gdzie zawiódł) + +### Co działało + +- **SDD** (`superpowers:subagent-driven-development`): implementer → recenzja → + runda poprawek → re-recenzja. Recenzje znalazły rzeczy, których nie znalazł + ani self-review planów, ani implementer. +- **Mutation testy.** W fazie 01 **cztery** testy okazały się przechodzić + niezależnie od implementacji (tautologie, oparcie o efekt uboczny hard-delete, + fixtury będące tym samym obiektem, sprawdzanie substringu w DDL). Pytaj + o każdy nowy test: *co muszę zepsuć, żeby spadł na czerwono* — i sprawdź to. +- **Inwentaryzacja przed naprawą.** Przy wycieku ORM dała 15 znalezionych / + 11 naprawionych / 4 świadomie zostawione, plus listę kategorii z **zerem** + trafień — żeby nikt nie szukał drugi raz. + +### Czego NIE powtarzać + +- ⚠️ **Nie uruchamiaj równoległych agentów w jednym worktree.** Rozdzielenie + plików nie wystarcza: `reset`, `stash`, `amend` widzą całą gałąź. W tej sesji + doszło do trzech kolizji (plik roboczy w cudzym commicie, `git stash pop` + zjadający cudzy stash, `git reset --soft` omal nie kasujący cudzego commitu). + Równoległość → **osobne worktree**. +- ⚠️ **`PYTEST_TESTCONTAINERS_REUSE=1` daje fałszywe alarmy** — reużywany + kontener dryfuje (w jednym przebiegu zniknęła `Instytucja_Finansujaca`). + Przy dziwnych, niepowtarzalnych awariach: świeży kontener. +- ⚠️ **`make clean-testcontainers` na tym hoście ubija CUDZE kontenery.** + Celuj po nazwie/ID. +- ⚠️ **Pusty wynik ≠ potwierdzenie.** Pathspec `'src/*/migrations/'` w cudzysłowie + zwraca pustkę; `xfail` bez `strict=True` chowa `XPASS`; `git stash push` na + czystym drzewie nic nie tworzy, ale `pop` i tak coś zdejmie. Zanim uznasz brak + wyniku za dowód — sprawdź, czy narzędzie w ogóle potrafi coś pokazać. + +--- + +## 6. Otwarte decyzje + +| Sprawa | Stan | +|---|---| +| **Strategia wydania** | Rekomendacja: scalać fazami do `dev`, **wydać dopiero po 04**. Faza 01 sama nie daje wartości użytkownikowi (kosz bez UI — admin to faza 07), a kosztuje okno serwisowe i narzut GiST. Faza 03 jest obowiązkowa razem z 02 (bez niej re-import tworzy duplikaty). | +| Ręczne GET-y changelistu admina (`?autorzy_set__…=`) | `lookup_allowed` przepuszcza surowy lookup; decyzja produktowa | +| Pomiar `0492` na kopii produkcyjnej | **przed wdrożeniem**, patrz runbook §1 | +| Pomiar narzutu GiST | nikt nie zmierzył; testy tego nie wykryją | +| A2 — zmiana składu autorów nie podnosi znacznika publikacji | prekursorskie, w runbooku | + +## 7. Długi pozostałych faz (spłacić przy starcie danej fazy) + +- **03**: decyzja #14 („pomiń + zaraportuj" przy trafieniu w kosz) niewpięta + w taski 2-5; test ma literalny placeholder; `deduplikator_autorow/utils/merge.py` + zrefaktoryzowany (cytowane linie nieaktualne). +- **04**: nikt nie przeplata `AutorManager` (husk autora zostanie widoczny); + `Autor.restore()` **musi** nadpisać `strict=False`. +- **05**: ✅ spłacony 2026-08-07. +- **06**: `MetrykaAutora` trzyma listy ID prac w JSON-ach — zostanie stale; + Task 5 (shim `zakolejkuj_*`) jest **martwy** po ustaleniach fazy 05 — usunąć. diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-03.md b/docs/superpowers/HANDOFF-soft-delete-faza-03.md new file mode 100644 index 000000000..45bc5ee94 --- /dev/null +++ b/docs/superpowers/HANDOFF-soft-delete-faza-03.md @@ -0,0 +1,186 @@ +# Handoff: soft-delete, start fazy 03 + +> Dokument przekazania po zamknięciu **fazy 02** (publikacje), 2026-08-07. +> +> **Czytaj to zamiast odtwarzania historii z gita.** Zawiera rzeczy, których +> nie widać w diffie, a które w fazie 02 kosztowały rundy poprawek. + +--- + +## 1. Gdzie jesteśmy + +| | | +|---|---| +| Gałąź | `feat/soft-delete-02`, worktree `~/Programowanie/bpp-soft-delete-02` | +| Baza | `feat/soft-delete` (faza 01, PR #312) — PR fazy 02 jest **stackowany**, nie na `dev` | +| Migracje | `bpp/0496`…`0499`, `rozbieznosci_dyscyplin/0023` | +| Testy | 306 passed / 1 xfailed (soft-delete+cache+rozbieżności), 416 passed (regresja publikacji) | + +**Faza 02 objęła 5 modeli publikacji** — `Wydawnictwo_Ciagle`, +`Wydawnictwo_Zwarte`, `Patent`, `Praca_Doktorska`, `Praca_Habilitacyjna`. + +Dokumenty warte otwarcia zanim cokolwiek zaczniesz: + +- **inwentaryzacja ORM (wejście fazy 03)**: + `docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md` + — z self-review, który obala część własnych wniosków. Czytaj RAZEM z nim. +- inwentaryzacja widoków: `…/2026-08-07-faza-02-inwentaryzacja-widokow.md` +- zgłoszenie upstream do `django-easy-audit`: + `…/2026-08-07-easyaudit-upstream-zgloszenie.md` + +--- + +## 2. Najważniejsza lekcja fazy 02 + +Faza 01 zostawiła listę **siedmiu** mechanizmów zakładających, że skasowany +wiersz znika. Faza 02 dołożyła **dwa kolejne** — oba spoza kodu, który +zmienialiśmy: + +| # | Mechanizm | Jak się objawił | +|---|---|---| +| 8 | `django-easy-audit` | pobiera poprzedni wiersz przez `sender.objects` → `restore()` leci `DoesNotExist`. **Błąd ZASTANY** — `Zgloszenie_Publikacji` było nieprzywracalne na `dev` już przed fazą 02 | +| 9 | odwrotne `OneToOne` | `autor.praca_habilitacyjna` idzie przez `_base_manager` (nieprzefiltrowany) → skasowana habilitacja nadal osiągalna, widok zwracał 200 zamiast 404 | + +**Wniosek dla fazy 03:** szukaj konsumentów NIE tylko w kodzie, który +zmieniasz, i NIE tylko w tym repo. Kronikę zweryfikowaliśmy dopiero po +sprawdzeniu repozytoriów siostrzanych (`bpp-deploy` faktycznie ją +referencuje — na szczęście tylko w komentarzach i jednej kontrolce). + +--- + +## 3. Co faza 03 dziedziczy — i czego NIE ma dziedziczyć + +### 3.1 `xfail(strict=True)`, który sam się upomni + +`test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at` jest +`xfail(strict=True)` z powodem „faza 03". Gdy naprawisz wycieki, test +zacznie padać jako XPASS i **wymusi** zdjęcie markera. Nie „napraw testu" — +zdejmij marker. + +Analogicznie działa `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`: +padnie, gdy upstream easyauditu scali poprawkę, i każe skasować +`src/bpp/easyaudit_shim.py`. + +### 3.2 ⚠️ Tabelka 10 wycieków NIE jest gotową listą zadań + +To najważniejsze zastrzeżenie całego handoffu. Kanarek ORM dopasowuje +**NAZWY**, nie modele. W fazie 01 działał świetnie, bo nazwy były +dystynktywne (`autorzy_set`). W fazie 02 to zwykłe słowa (`patent`, +`rekord`, `wydawnictwo_ciagle`) i precyzja się załamuje: + +- lista 5 nazw daje 14 znalezisk, +- dołożenie `rekord` i `wydawnictwo_nadrzedne` daje **120**, +- z czego większość to fałszywe trafienia, bo `rekord` oznacza raz + publikację, raz widok `Rekord` (**już przefiltrowany** przez `0497`), + a raz parametr GET albo klucz formularza. + +**Nie rozszerzaj listy `RELACJE` i nie triażuj 120 pozycji ręcznie.** +Zbuduj narzędzie **model-aware**: rozwiązujące ścieżkę lookupu wobec +`_meta` Django i pytające, czy faktycznie dochodzi do tabeli soft-delete — +czyli robiące dla ORM to, co kanarek katalogowy robi na `pg_depend`. + +### 3.3 Klasa wycieku, której kanarek NIE MOŻE złapać + +Dostęp **atrybutowy** do relacji (`autor.praca_habilitacyjna`, +`*.wydawnictwo_nadrzedne`) omija soft-delete i nie jest wywołaniem ORM, więc +AST-owy skaner go nie widzi. Potrzebny osobny przegląd. Naprawa centralna +odpada: `Meta.base_manager_name` na menedżer filtrujący jest przez Django +jawnie odradzany (rozwaliłby deserializację i `refresh_from_db`). + +--- + +## 4. Fakty o kodzie, które w fazie 02 kosztowały rundę poprawek + +- **`_base_manager` jest NIEprzefiltrowany** i Django tworzy go sam, gdy + model nie ustawia `Meta.base_manager_name`. Ratuje to easyaudit i psuje + trawersowanie relacji — ta sama właściwość, dwa przeciwne skutki. +- **`slug` zawiera `pk`** (`models/util.py:246-254`: + `tytuł-źródło-autorzy--`). Kolizja slugów jest + konstrukcyjnie niemożliwa — dlatego Task 3 planu został świadomie + niewykonany (patrz box w planie fazy 02). +- **`slugify_function` BPP NIE obniża wielkości liter.** +- **Menedżer w ciele klasy przesłania ten z bazy abstrakcyjnej.** Dlatego + `Wydawnictwo_Ciagle.objects` pokazywało kosz aż do Taska 4, mimo że model + dziedziczył `BppPublikacjaSoftDeleteMixin`. +- **`ManagerModeliZOplataZaPublikacjeMixin` NIE jest menedżerem** — to czysty + mixin bez `get_queryset()`, więc kolejność baz w MRO menedżera NIE jest + nośna (sprawdzone mutacyjnie). Nośna jest druga baza. +- **`DJANGO_EASY_AUDIT_REGISTERED_CLASSES`** obejmuje 5 modeli publikacji + i `Zgloszenie_Publikacji`, ale NIE through-modele `*_Autor` — stąd faza 01 + nigdy nie trafiła na błąd easyauditu. +- **Denorm: tylko DWIE zależności celują w publikacje**, obie + `("self", "wydawnictwo_nadrzedne")` bez `only=`. Brak `only=` = wszystkie + kolumny w bramce, więc `deleted_at` wchodzi sam. Nie dokładaj + `denorm_always_only` — byłby martwym kodem. +- **Widoki publikacji mają TRZY różne kształty** (patrz docstring `0497`). + Wzorzec z `0489` pasuje tylko do dwóch z siedmiu. +- **Postgres normalizuje predykaty w definicjach widoków** — przy jednej + tabeli w zasięgu usuwa kwalifikację. Wywróciło to najpierw asercję testu, + potem funkcję `backward` migracji. + +--- + +## 5. Jak pracować (co się sprawdziło) + +### Mutacja jest tu jedynym dowodem, że test cokolwiek pilnuje + +W fazie 02 mutacje obaliły **cztery** rzeczy — w tym dwie moje własne: + +1. test kaskady przechodził częściowo przez efekt uboczny (odroczone + ograniczenie fazy 01 + `baker` nadający obu autorstwom `kolejnosc=0`), +2. test na pułapkę agregatu był bezwartościowy — odpytywał **cache**, którego + soft-delete autorstwa nie przelicza, więc nieświeży wiersz maskował + zdegenerowany widok. Wyrocznią musi być **widok**, +3. test wymiaru publikacji w sumach przechodził mimo wyłączenia OBU migracji, + bo kaskada `delete()` kasuje też autorstwa, a te są filtrowane od fazy 01. + Trzeba było testu **izolującego wymiar** (surowy UPDATE), +4. docstring o kolejności baz w MRO menedżera brzmiał wiarygodnie i był + nieprawdziwy. + +**Zasada:** przy każdym teście zapytaj, co musisz zepsuć, żeby spadł na +czerwono — i zepsuj to naprawdę. + +### Rozszerz kanarka NA STARCIE — to znowu się opłaciło + +Inwentaryzacja widoków na starcie fazy zajęła **19 sekund** i dała pełną +listę 15 widoków w 5 kategoriach, w tym **zadanie, którego nie było w planie** +(Task 2d: 6 widoków agregujących, w planie tylko jako ostrzeżenie „sprawdź"). +Kanarek fazy 01 dało się wywołać na rozszerzonej liście tabel BEZ zmiany +stałej modułowej — czyli bez commitowania czerwonego testu. + +### Czego nie powtarzać + +- ⚠️ **`ruff format` na całym katalogu** przeformatował 25 plików, których nie + dotykałem. Formatuj TYLKO swoje pliki, inaczej PR staje się nieczytelny. +- ⚠️ **`git stash` przy czystym drzewie to cichy no-op** — do baseline'owania + używaj `git checkout --detach `. +- ⚠️ **Zero zdarzeń audytu ≠ zepsuty kod.** Dwie niezależne bramki + (`dont_log_anonymous_crud_events` bez requestu, brak `settings.TEST` → + `on_commit` w rollbackowanej transakcji) dają ten sam objaw. +- ⚠️ Na tym hoście biegają cudze stacki. Kontenery ubijaj po ID, po + sprawdzeniu, czy ryuk jest martwy. + +--- + +## 6. Otwarte decyzje + +| Sprawa | Stan | +|---|---| +| **PR upstream do easyauditu** | gałąź gotowa w `~/Programowanie/django-easy-audit` (`fix/175-use-base-manager-in-pre-save`), przetestowana na Django 5.2 i 6.1. **Nie wypchnięta** — czeka na decyzję o koncie/forku. Tekst zgłoszenia gotowy | +| **Strategia wydania** | rekomendacja z fazy 01 bez zmian: scalać fazami, **wydać dopiero po 04**. Faza 03 jest obowiązkowa razem z 02 (bez niej re-import tworzy duplikaty) | +| Pomiar `0492` i narzutu GiST na kopii produkcyjnej | wciąż nikt nie zmierzył (dług fazy 01) | +| `bpp-deploy`: kontrolka „kronika views: N" | po `0499` wypisze 0 i może zmylić operatora. Nieblokujące, opisane w docstringu `0499` | + +## 7. Długi pozostałych faz + +- **03**: decyzja #14 („pomiń + zaraportuj") niewpięta w taski 2-5; test ma + literalny placeholder; `deduplikator_autorow/utils/merge.py` + zrefaktoryzowany. **Plus wszystko z §3 tego dokumentu.** +- **04**: nikt nie przeplata `AutorManager`; `Autor.restore()` musi nadpisać + `strict=False`; FK flips `CASCADE→PROTECT`. Uwaga: soft-skasowana + publikacja NADAL trzyma referencję O2O PROTECT do autora, więc autora nie + da się skasować — to zachowanie poprawne, ale faza 04 musi je obsłużyć w UI. +- **06**: `MetrykaAutora` trzyma listy ID prac w JSON-ach; Task 5 (shim + `zakolejkuj_*`) martwy po ustaleniach fazy 05 — usunąć. +- **07**: admin (kosz / przywróć / usuń trwale) — konsument `user`/`reason`, + które faza 02 już przepuszcza w sygnaturach. diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-04.md b/docs/superpowers/HANDOFF-soft-delete-faza-04.md new file mode 100644 index 000000000..3d2c68fab --- /dev/null +++ b/docs/superpowers/HANDOFF-soft-delete-faza-04.md @@ -0,0 +1,424 @@ +# Handoff: soft-delete, start fazy 04 + +> Po zamknięciu **fazy 03** (audyt kategorii B), 2026-08-08. +> Czytaj to zamiast odtwarzania historii z gita. + +--- + +## 1. Gdzie jesteśmy + +| | | +|---|---| +| Stan fazy 03 | ✅ **SCALONA** 2026-08-09, merge commit `774f1a72d`, PR #742 MERGED | +| Punkt startowy fazy 04 | gałąź `feat/soft-delete` (zawiera fazy 01, 02, 03) | +| Gałąź do założenia | `feat/soft-delete-04`, worktree `~/Programowanie/bpp-soft-delete-04` | +| Migracje fazy 03 | `pbn_integrator/0001` (rejestr wskrzeszeń), `pbn_integrator/0002` (indeks), `bpp/0500` (warunkowy unique na habilitacji) | +| Plan fazy 04 | `docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md` — ⚠️ **czytaj razem z §4c**, plan jest starszy niż fazy 02–03 | + +Faza 03 domknęła **blocker wydania**: re-import z PBN publikacji, której rekord +BPP siedzi w koszu, nie wywala się już `IntegrityError`-em na unikalności +`pbn_uid` — rekord wraca z kosza, ze śladem w rejestrze. ⚠️ Zakres jest węższy, +niż brzmiała pierwotna obietnica „re-import nie tworzy duplikatów" — patrz +§3, „Czego faza 03 NIE naprawia". + +⚠️ **Baseline (`baseline-sql/`) jest NIEŚWIEŻY** — stoi na `bpp/0487`, gałąź na +`bpp/0500`. Był taki już przed fazą 03 (fazy 01–02 dołożyły `0488`–`0499`). +Zgodnie z CLAUDE.md odświeżenie (`make baseline-update`) robi się **raz, przy +scalaniu**, a nie w równoległych feature-branchach. + +⚠️ **CI NIE URUCHAMIA SIĘ na PR-ach do `feat/soft-delete`.** Workflow `Tests` +ma `pull_request: branches: [dev]`, więc PR #742 (`feat/soft-delete-03` → +`feat/soft-delete`) dostaje wyłącznie GitGuardiana. Zielony check na takim +PR-ze **nie jest dowodem, że testy przeszły** — jedyną weryfikacją jest +przebieg lokalny. To samo będzie dotyczyć PR-ów faz 04–07, dopóki są stackowane +na `feat/soft-delete`. Odpalaj `make tests` lokalnie i czytaj podsumowanie +pytest (target zwraca EXIT 0 mimo porażek i przerywa się na pierwszym błędnym +kroku, więc `tests-only-playwright` i `js-tests` trzeba dokończyć osobno). + +Stan fazy 03 na `ac809b958`: 9522 + 157 + 81 passed, 0 failed. + +--- + +## 2. NAJWAŻNIEJSZE: zmieniona decyzja o polityce kosza + +Plan fazy 03 miał decyzję #14 „**POMIŃ + ZARAPORTUJ**”, argumentując, że +auto-restore pozwoliłby importowi wskrzeszać rzeczy skasowane celowo. + +**Właściciel systemu zmienił ją 2026-08-08 na „PRZYWRÓĆ + ODNOTUJ”.** +Uzasadnienie: PBN jest źródłem prawdy dla tych publikacji — skoro rekord tam +jest, ma wrócić także do BPP. + +Zastrzeżenie z pierwotnej decyzji nie zostało uznane za nieważne, tylko +przeniesione: z „nie róbmy tego” na „róbmy, ale zostawmy ślad”. Śladem jest +`pbn_integrator.RekordPrzywroconyPrzezImport` — rekord (generic FK), data, +publikacja PBN, ścieżka importu. + +⚠️ Wpis powstaje **wyłącznie przy realnym wskrzeszeniu**. Zwykły re-import +żywego rekordu nie zostawia nic — inaczej rejestr zapełniłby się szumem +i przestałby cokolwiek znaczyć. Przypięte osobnym testem. + +Punkt wejścia: `pbn_integrator.kosz.przywroc_jesli_w_koszu()`, wpięty +w `articles.py`, `books.py`, `chapters.py` (ta ostatnia w dwóch miejscach — +sam rozdział i jego książka-matka, rozróżnialne po `zrodlo_importu`). + +--- + +## 2a. Druga zmiana kierunku: AKCESORY NIE ZAGLĄDAJĄ DO KOSZA + +> Aktualizacja 2026-08-08, po self-review. Pierwsza implementacja fazy 03 +> poszła **złą drogą** i została cofnięta (`eb046897a`). + +Pierwotnie faza 03 kazała akcesorom (`rekord_w_bpp`, `get_bpp_publication`, +`matchuj_publikacje`) zaglądać do kosza i **zwracać rekordy skasowane**. +Właściciel rozstrzygnął inaczej: + +> **Soft-delete znaczy, że rekordu NIE MA.** `Rekord` (widok `bpp_rekord_mat`) +> jest odfiltrowany po `deleted_at` i tak ma zostać. Akcesor zwraca `Rekord` +> albo `None` — nigdy rzeczy z kosza. +> +> Zaglądanie do kosza jest decyzją **IMPORTERA**, nie akcesora. + +Dlaczego to ważne dla fazy 04: akcesory mają wołaczy, którzy zakładają KONKRETNY +typ zwracany. `PublicationAdmin` woła `.original` — atrybut istniejący +WYŁĄCZNIE na `Rekord`. Zwrócenie modelu konkretnego wywalało `AttributeError` +na całej changeliście, a w szablonie (`change_form.html`) to samo dawało pusty +`href` **bez żadnego wyjątku**. Pilnuje tego dziś +`src/pbn_api/tests/test_admin_rekord_w_bpp.py`. + +--- + +## 3. Co faza 03 zmieniła w kodzie + +| Miejsce | Zmiana | +|---|---| +| `pbn_integrator/kosz.py` | `przywroc_jesli_w_koszu()`, `wskrzes_z_kosza_po_pbn_uid()`, `znajdz_lub_wskrzes_rekord()` — całe zaglądanie do kosza w jednym module | +| `pbn_integrator/importer/{articles,books,chapters}.py` | wspólna preambuła `znajdz_lub_wskrzes_rekord()` w gałęzi `ret is None` | +| `pbn_integrator/importer/chapters.py` | `znajdz_ksiazke_nadrzedna()` — wydzielony, widzi kosz, wskrzesza | +| `pbn_import/utils/publication_import.py` | `global_objects` + `hard_delete()` przy czyszczeniu przed re-importem | +| `bpp/models/praca_habilitacyjna.py` | `autor`: `OneToOneField` → `ForeignKey` + warunkowy `phab_uniq_autor_zywy` (migracja `bpp/0500`) | +| `bpp/admin/praca_habilitacyjna.py` | `clean_autor()` — jawna walidacja „jeden autor, jedna żywa habilitacja" | +| `bpp/views/api/__init__.py` | `RokHabilitacjiView` pyta `Praca_Habilitacyjna.objects`, nie akcesor odwrotny | +| `bpp/templates/browse/autor.html` | pętla po `praca_habilitacyjna_set` (jak doktorat) | +| `deduplikator_autorow/utils/merge.py` | `wiersze_do_transferu()` widzi kosz; detekcja kolizji po `global_objects`; `KonfliktScalania`; guard `_w_koszu()` przed kolejką PBN | + +**NIE zmienione (cofnięte świadomie):** `pbn_api/models/publication.py` +i `import_common/core/publikacja.py` — patrz §2a. Helper `widzacy_manager()` +został usunięty razem z testami, które pilnowały złego zachowania. + +**ZOSTAWIONE świadomie na `objects`** (rejestr decyzji w +`src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`): ewaluacja, +snapshot odpięć, komparator PBN, REST API, skanowanie do dedupu publikacji. + +### Czego faza 03 NIE naprawia (świadomie) + +**Ścieżka fuzzy tworzy duplikat.** Rekord BPP **bez** `pbn_uid` (praca +wprowadzona ręcznie), skasowany miękko, nie zostanie znaleziony przez +`matchuj_publikacje` — bo ten idzie po `Rekord`, a `Rekord` kosza nie widzi. +Import utworzy nowy rekord. Po decyzji z §2a to zachowanie **zamierzone** +(rekordu nie ma), ale nie należy go opisywać jako „duplikaty domknięte". + +Domknięta jest ścieżka po `pbn_uid` — i tam problemem nie był cichy duplikat, +tylko **wywalenie importu**: `pbn_uid` to `OneToOneField(unique=True)` BEZ +warunku partial, więc rekord w koszu nadal trzyma to pole, a próba utworzenia +nowego kończyła się `IntegrityError`. + +--- + +## 4. Fakty, które kosztowały rundę poprawek + +- **`Patent` NIE ma pola `pbn_uid`.** Wpisanie go do listy modeli + matchowanych po `pbn_uid` wywala `FieldError` przy pierwszym `.filter()`. +- **`get_bpp_publication` i `rekord_w_bpp` zachowują się RÓŻNIE**: przy wielu + trafieniach pierwsza zwraca `None`, druga sklejone tytuły; przy braku + trafień druga spada do fuzzy matchingu. Różnica jest historyczna i celowo + zachowana — nie ujednolicaj jej „przy okazji”. +- **`rekord_w_bpp` bywa STRINGIEM.** Każdy helper na ścieżce importu musi to + przyjąć bez wyjątku (`getattr`, nie `isinstance`), inaczej wywróci cały + przebieg z powodu niezwiązanego z koszem. +- **`_try_match_pub_by_doi` mimo zawężenia po DOI przepuszcza kandydata przez + próg podobieństwa tytułu (0.80).** Test z celowo innym tytułem padnie — + i NIE będzie to dowód, że kod nie widzi kosza. +- **Linie cytowane w planie dla `merge.py` były nieaktualne** (plik + zrefaktoryzowany). Szukaj lookupów na nowo, nie po numerach. +- **`GlobalManager` pakietu nie ma metod domenowych.** `wydawnictwa_ + nadrzedne_dla_innych()` zostaje na `objects` — świadomie. +- **Lista wołaczy ≠ lista założeń wołaczy.** Sprawdzenie, KTO woła akcesor, + nie mówi nic o tym, CO robi z wynikiem. `PublicationAdmin` woła `.original` + — atrybut istniejący wyłącznie na `Rekord`. To samo pominięcie w szablonie + nie daje wyjątku, tylko pusty `href`. +- **`matchuj_publikacje` ma DWÓCH wołaczy o sprzecznych potrzebach**: + `pbn_api` (przekazuje `Rekord`) i `deduplikator_publikacji` + (`tasks.py:187`, przekazuje modele konkretne). Przełączenie go „na globalny + menedżer" sprawiło, że dedup zaczął podsuwać kosz — wbrew decyzji z Taska 8. + +--- + +## 4a. Mapa pozostałych faz — co dalej + +Kolejność nie jest dowolna: każda kolejna konsumuje coś, co dostarcza poprzednia. + +| Faza | Co robi | Zależy od | +|---|---|---| +| **04 — guardy PROTECT** | flip FK `CASCADE→PROTECT` na powiązaniach autora (`*_Autor.autor`, `Praca_Doktorska.autor`) i na self-FK rozdziałów (`Wydawnictwo_Zwarte.wydawnictwo_nadrzedne`) + guard aplikacyjny; `Autor` staje się `SoftDeleteModel` | — | +| **05 — wycofanie z PBN** | `pbn_export_queue` dostaje operację `WYCOFANIE` obok `WYSYLKA`; soft-delete publikacji asynchronicznie wycofuje oświadczenia dyscyplin z profilu instytucji w PBN | kolejka PBN | +| **06 — `SoftDeleteLog`** | model audytu (kto / kiedy / dlaczego / status PBN) zasilany receiverami `post_soft_delete` / `post_restore` / `post_hard_delete`; receiver DELETE kolejkuje wycofanie z fazy 05 | **05** | +| **07 — admin** | kosz w adminie dla 5 typów publikacji + `Autor`: „Usuń" = soft-delete z powodem, filtr „Pokaż skasowane", akcja „Przywróć", osobna „Usuń trwale" (superuser) | **06** (powód → log) | +| **08 — regresja E2E** | suita domykająca całość | wszystkie | + +### Co fazy 01–03 już pod nie podłożyły + +Trzy rzeczy są gotowe i kolejne fazy mają je po prostu skonsumować — nie +trzeba ich projektować od nowa: + +- **Sygnatury `delete(user=..., reason=...)` / `restore(user=...)`** istnieją + w mixinie publikacji od fazy 02 (puste, ale obecne — kontrakt PINNED). + Faza 06 wpina w nie `SoftDeleteLog`, faza 07 wstrzykuje `request.user`. + Sygnatur nie trzeba ruszać. +- **Sygnały `post_soft_delete` / `post_restore` są emitowane** i przypięte + testem (faza 02). Faza 06 podpina receivery pod gotowy mechanizm. +- **Kontrakt `ostatnio_zmieniony`** — soft-delete bumpuje znacznik, więc + nagrobki dla harvestu przyrostowego są odpytywalne przez + `deleted_objects.filter(ostatnio_zmieniony__gte=X)` już teraz, bez czekania + na `SoftDeleteLog` z fazy 06. + +### Nagrobki na zewnątrz — WCIĄGNIĘTE DO FAZY 05 (decyzja 2026-08-08) + +OAI-PMH `
` w `src/cerif_export` (dziś **zero** +obsługi `deleted`), odpowiednik w CERIF, sposób odkrycia usuniętych +w `/api/v1/`. Fundament gotowy — soft-delete bumpuje `ostatnio_zmieniony`, +więc `Model.deleted_objects.filter(ostatnio_zmieniony__gte=X)` działa już +teraz. Brakuje wyłącznie ekspozycji. + +**Dołączone do zakresu fazy 05**, bo to ten sam motyw co wycofanie oświadczeń +z PBN: *systemy zewnętrzne dowiadują się, że coś zniknęło* — inny odbiorca, +ta sama historia. Trzymane osobno przepadłyby między fazami, bo żaden plan +ich nie obejmował. + +⚠️ Muszą być gotowe **przed fazą 07**, nie przed 04. Dopóki kasowanie jest +rzadkie, luka w OAI-PMH jest teoretyczna; faza 07 czyni kasowanie rutynowym +i dopiero wtedy zaczyna realnie boleć. + +## 4b. Strategia wydania — bramka przesunięta na fazę 07 + +> 🔁 **DECYZJA 2026-08-08.** Poprzednia rekomendacja (fazy 01–03) brzmiała +> „wydać po fazie 04”. Zmieniona: **wydajemy dopiero po fazie 07**. + +Powód nie jest bezpieczeństwem — guardy z fazy 04 domykają je w porządku. +Powodem jest **spójność dla operatora**. + +Sprawdzone na kodzie (2026-08-08): `src/bpp/admin/` nie ma dziś ŻADNEGO +kosza — trafienia `deleted_at` to hydraulika (ukryte pole formularza dla +walidacji ograniczeń, filtry agregatów), a nie filtr „pokaż skasowane” ani +akcja „Przywróć”. Jednocześnie `SoftDeleteQuerySet.delete()` jest już miękki, +a `delete_selected` Django woła właśnie queryset. + +Czyli wydanie po fazie 04 dałoby operatorowi: + +- „Usuń” przestaje znaczyć „zniknęło” — rekord zostaje w bazie, +- **nie da się go przywrócić ani obejrzeć bez programisty**, +- nie da się go usunąć naprawdę. + +Operator traci jedną zdolność i nie dostaje w zamian żadnej, bo kosz +z przywracaniem przychodzi dopiero w fazie 07. To regresja UX, nie funkcja. + +**Fazy 01–06 scalamy dalej do `feat/soft-delete`, ale nie wydajemy.** + +Świadomie ODRZUCONE: wydanie faz 01+02 z tymczasowym „Usuń = twardo” +w adminie. Oznaczałoby utrzymywanie dwóch semantyk kasowania równolegle +i wyrzucenie tego kodu w fazie 07 — koszt bez odbiorcy. + +## 4c. ⚠️ PLAN FAZY 04 vs KOD — rozjazdy sprawdzone 2026-08-09 + +Plan `docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md` +powstał **przed fazami 02 i 03**. Poniższe zweryfikowane w kodzie, nie +zgadywane. **Przeczytaj to PRZED Zadaniem 1** — plan cytuje stan, którego +już nie ma. + +### R1. BLOKER: Zadanie 3 zepsuje scalanie autorów, a Zadanie 6 tego nie złapie + +To jest najważniejsza pozycja na tej liście. + +Plan (Zadanie 6) zakłada, że po transferze prac husk duplikatu jest pusty, +więc guard go przepuści. **To nieprawda dla przypadku „kolizja w koszu"**, +i to nie hipotetycznie — ten scenariusz ma już test: +`deduplikator_autorow/tests/test_scal_autora_soft_delete.py:: +test_kolizja_autorstw_w_koszu_nie_tworzy_dwoch_wierszy`. + +Mechanizm: + +1. Publikacja jest w koszu → oba autorstwa (głównego i duplikatu) też. +2. `_transfer_authorship_record` wykrywa kolizję (faza 03, po `global_objects`) + i **NIE przenosi** wiersza duplikatu — zostawia go przy duplikacie. +3. Dziś to bezpieczne, bo `autor_duplikat.delete()` jest TWARDY i kaskaduje po + FK — wiersz znika fizycznie. +4. **Zadanie 3 zmienia `Autor.delete()` na miękki.** Kaskada znika. Wiersz + zostaje przy duplikacie. +5. Guard z Zadania 3 liczy przez `global_objects`, więc **widzi ten wiersz + w koszu** → `ProtectedError` → `scal_autora` łapie to jako `Exception` + i zwraca `success=False`. **Całe scalanie pada.** + +Zadanie 6 tego nie wykryje, bo symuluje wyłącznie czysty transfer (autorstwo +żywe, przeniesione ręcznie). Test regresji z Zadania 6 +(`uv run pytest src/deduplikator_autorow/`) **wykryje** — i wtedy trzeba +wiedzieć, że to nie przypadek, tylko przewidziana konsekwencja. + +**Poprawka należy do gałęzi `if existing:` w `_transfer_authorship_record`** +(`deduplikator_autorow/utils/merge.py`): wiersz duplikatu trzeba przepiąć na +głównego autora ORAZ nadać mu WŁASNY `transaction_id`. To drugie jest +konieczne, bo inaczej `publikacja.restore()` wskrzesi dwa wiersze +`(rekord, glowny, typ)` naraz i wywali się na +`wc_autor_uniq_rekord_autor_typ`. + +Ta poprawka **była już raz napisana i usunięta** w fazie 03 — mutacja +pokazała, że przy twardej kaskadzie jest martwym kodem. Faza 04 ją ożywia. +Kolejność: napisz ją RAZEM z Zadaniem 3, nie po nim, bo inaczej między +commitami zostaje zepsuty merge. + +### R2. `Praca_Habilitacyjna.autor` NIE jest już `OneToOneField` + +Plan, sekcja „Stan zweryfikowany w kodzie": „`OneToOneField(Autor, PROTECT)` — +**już PROTECT, nie ruszamy**; reverse `autor.praca_habilitacyjna`". + +Po fazie 03 (migracja `bpp/0500`) to `ForeignKey(Autor, PROTECT)` + warunkowy +`phab_uniq_autor_zywy`. `PROTECT` się zgadza, więc **konkluzja planu („nie +ruszamy") zostaje słuszna** — ale dwie rzeczy w niej nie: + +- reverse to `autor.praca_habilitacyjna_set` (manager), nie obiekt; +- w tabeli mapowania relacji plan pisze „`autor` (O2O)". Helper i tak liczy + przez `Model.global_objects.filter(autor=…)`, więc kod działa bez zmian — + ale autor może teraz mieć **wiele** habilitacji (żywą + dowolnie wiele + w koszu). Nie zakładaj, że licznik zwróci 0 albo 1. + +### R3. Zadanie 5 wskazuje nieistniejący plik docelowy + +Plan: „w istniejącym override `delete()` (z fazy 02) w +`src/bpp/models/wydawnictwo_zwarte.py` dodaj guard na samym początku". + +**W `wydawnictwo_zwarte.py` NIE MA override `delete()`.** Faza 02 umieściła go +w `BppPublikacjaSoftDeleteMixin.delete()` (`bpp/models/soft_delete.py:335`), +dzielonym przez WSZYSTKIE pięć modeli publikacji. + +Wstawienie tam guarda na rozdziały byłoby błędem: dla `Wydawnictwo_Ciagle` +czy `Patent` helper odpytałby +`Wydawnictwo_Zwarte.global_objects.filter(wydawnictwo_nadrzedne=)` — zapytanie międzytypowe, w najlepszym razie zawsze puste, w gorszym +wyjątek. + +Guard musi trafić do **własnego** `delete()` w `Wydawnictwo_Zwarte`, wołającego +`super().delete(*args, **kwargs)`. Sygnatura musi przepuścić `user`/`reason` +(kontrakt PINNED fazy 06/07). + +### R4. `AutorManager` NIE jest przepleciony — to nie jest „warunek wstępny", to zakres + +Zadanie 3 pisze: „To zadanie traktuje przeplecenie menedżera jako warunek +wstępny; jeśli go brak — najpierw dorób". **Brak.** `Autor` ma +`objects = AutorManager()` (`autor.py:298`, NIE `:200`) i własny `save()`, +a `AutorManager` dziedziczy `FulltextSearchMixin` + +`models.Manager.from_queryset(AutorQuerySet)` — zero filtrowania po +`deleted_at`. + +Czyli po dodaniu `SoftDeleteModel` do bazy klasy **husk autora będzie widoczny +wszędzie**: w autocomplete, w wyszukiwarce pełnotekstowej, na listach. +To osobna, nietrywialna robota (MRO + FTS), a nie jednolinijkowiec — wyceń ją +jako część Zadania 3, nie jako przypis. + +### R5. Wszystkie numery linii w planie są nieaktualne + +Ta sama pułapka, co w fazie 03. Szukaj po nazwach, nie po numerach. + +| Plan mówi | Jest naprawdę | +|---|---| +| `abstract/authors.py:22` | `:25` (`autor = models.ForeignKey("bpp.Autor", CASCADE)`) | +| `praca_doktorska.py:136` | `:154` | +| `wydawnictwo_zwarte.py:202` | `:286` | +| `autor.py:81` (`class Autor`) | `:172` | +| `autor.py:200` (`objects = AutorManager()`) | `:298` | +| `merge.py:191/223/265/317/335/410/430` | plik zrefaktoryzowany w fazie 03 — wszystkie nieaktualne | + +### Co w planie jest nadal PRAWDZIWE (sprawdzone) + +- `Wydawnictwo_*_Autor.autor` i `Praca_Doktorska.autor` to nadal `CASCADE` — + flip do `PROTECT` (Zadanie 2) jest do zrobienia. +- `Wydawnictwo_Zwarte.wydawnictwo_nadrzedne` to nadal `CASCADE` + + `related_name="wydawnictwa_powiazane_set"`. +- `Autor` nie ma własnego `delete()` — dziś kasuje się twardo, z kaskadą FK. +- `raise_if_has_protected_children` nie istnieje nigdzie w `src/`. +- Katalog `src/bpp/tests/test_models/` istnieje, `test_wydawnictwo_zwarte.py` + też — ścieżki testów z planu są dobre. +- Migracja state-only (`SeparateDatabaseAndState`) jest właściwa: `on_delete` + żyje wyłącznie w ORM, DDL nie jest potrzebne. + +--- + +## 5. Co czeka fazę 04 (guardy PROTECT) + +- **Nikt nie przeplata `AutorManager`** — po uczynieniu `Autor` + `SoftDeleteModel` husk autora zostanie widoczny. +- **`Autor.restore()` musi nadpisać `strict=False`** (inwariant z docstringu + `bpp/models/soft_delete.py`). +- **Soft-skasowana publikacja NADAL trzyma referencję PROTECT do autora** + — `Praca_Habilitacyjna.autor`. Dziś oznacza to `ProtectedError` przy próbie + skasowania autora i jest to zachowanie poprawne (rekord istnieje, nie wolno + go osierocić), ale faza 04 musi to obsłużyć w UI, a nie zostawić jako gołe + 500. +- **`Praca_Habilitacyjna.autor` NIE jest już `OneToOneField`** (faza 03, + migracja `bpp/0500`). Akcesor odwrotny to `autor.praca_habilitacyjna_set`. + Ścieżka filtrowania w ORM/DjangoQL się nie zmieniła + (`related_query_name` = nazwa modelu), ale każdy kod sięgający po + `autor.praca_habilitacyjna` jako po OBIEKT jest zepsuty. Dwa znane miejsca + są już naprawione (`RokHabilitacjiView`, `browse/autor.html`) i oba miały + testy, które to złapały. +- ⚠️ **`scal_autora` polega dziś na TWARDEJ kaskadzie `autor_duplikat.delete()` + — Zadanie 3 to zepsuje.** Pełny mechanizm i miejsce poprawki: **§4c, R1** + (to jest bloker, nie przypis). + +## 6. Dług nadal otwarty + +| Sprawa | Stan | +|---|---| +| **Wycieki ORM (kanarek `xfail(strict=True)`)** | `test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at` dalej `xfail`. ⚠️ Tabelka 10 wycieków w `reviews/2026-08-07-faza-02-inwentaryzacja-orm.md` NIE jest listą zadań — patrz self-review w tym pliku. Potrzebne narzędzie model-aware (pytające `_meta`), nie rozszerzanie listy nazw | +| **PR upstream `django-easy-audit`** | **WYSTAWIONY 2026-08-08: [soynatan/django-easy-audit#348](https://github.com/soynatan/django-easy-audit/pull/348)** (fork `mpasternak`). Gdy zostanie scalony — skasować `src/bpp/easyaudit_shim.py`, wywołanie `zainstaluj()` w `BppConfig.ready()` i `test_easyaudit_shim.py`. Przypomni o tym test `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`, który wtedy zacznie padać | +| **`bpp-deploy`** | kontrolka „kronika views: N” po `bpp.0499` wypisze 0 i może zmylić operatora | +| Pomiar `0492` i narzutu GiST | wciąż nikt nie zmierzył (dług fazy 01) | +| **Rejestr wskrzeszeń niewidoczny w adminie** | `pbn_integrator/admin.py` to nadal `# Register your models here.`. Brak też indeksu `(content_type, object_id)` pod pytanie „czy TEN rekord wrócił?". Rejestr, do którego nie da się zajrzeć, nie spełnia swojej roli — a to ona uzasadniała zmianę decyzji #14 | +| **`force=True` omija wskrzeszenie** | Guard to `if not force`, więc `--force` idzie prosto do tworzenia nowego rekordu — i przy rekordzie w koszu wywali się na unikalności `pbn_uid` (tak samo jak przy rekordzie żywym; to nie jest regresja fazy 03). Niespójność: `znajdz_ksiazke_nadrzedna` leży ZA tym guardem, więc przy `force` książka-matka JEST wskrzeszana, a sam rozdział nie | +| **`Oswiadczenie_Instytucji.get_bpp_publication`** | `pbn_api/models/oswiadczenie_instytucji.py:51-74` iteruje po 4 modelach po `pbn_uid` przez `.objects`. Nie jest ani na liście zmienionych, ani „świadomie zostawionych" — po prostu nikt go nie rozpatrzył | +| **`hard_delete()` na querysecie nie emituje `post_hard_delete`** | Najbardziej destrukcyjna operacja przejdzie niezauważona przez `SoftDeleteLog` z fazy 06. Do handoffu fazy 06 | +| **Strategia wydania** | ⚠️ **ZMIENIONA 2026-08-08: bramka przesunięta z fazy 04 na fazę 07.** Uzasadnienie w sekcji 4b | + +## 7. Proces — co znowu się sprawdziło + +- **Mutacja, która PRZESZŁA, to informacja — nie powód, żeby dopisać asercję.** + Przy pozycji 3.3 dodałem gałąź odpinającą `transaction_id` dla autorstwa + już siedzącego w koszu. Mutacja (`pass`) przeszła. Zamiast wzmacniać test + sprawdziłem DLACZEGO — i okazało się, że `autor_duplikat.delete()` kaskaduje + twardo i fizycznie usuwa ten wiersz. Cała gałąź była martwym kodem opartym + na moim wyobrażeniu, nie na tym, co robi baza. Reguła: mutacja, która + przeszła, znaczy **albo słaby test, albo zbędny kod** — rozstrzygnij który, + ZANIM cokolwiek dopiszesz. (Konsekwencja dla fazy 04: §5, ostatni punkt.) +- **Mutacja jest jedynym dowodem, że test coś pilnuje.** W fazie 03 pierwsza + czerwień testu rejestru była `ImportError` — a to nie dowodzi, że asercje + działają. Dopiero wyłączenie warunku na `deleted_at` pokazało, że tak. +- **Dane referencyjne z `baseline.sql` NIE są w testach gwarantowane.** Testy + transakcyjne z tego samego przebiegu flushują bazę i przywracają tylko + część danych — a przy `PYTEST_TESTCONTAINERS_REUSE=1` zanieczyszczenie + przechodzi między przebiegami. Objaw: `Typ_Odpowiedzialnosci / + Rodzaj_Zrodla / Jezyk DoesNotExist` w teście, który „przecież przechodził". + Bierz słowniki z fixture'ów (`jezyki`, `charaktery_formalne`, `typy_kbn`, + `statusy_korekt`, `typy_odpowiedzialnosci`), nie z baseline. Zanim uznasz + taką porażkę za regresję — powtórz na świeżych kontenerach (bez `REUSE`). +- **`git checkout ` cofa do HEAD, a nie do „stanu sprzed mutacji".** + Przy mutacyjnym testowaniu NIEZACOMMITOWANEJ zmiany to kasuje całą + implementację. Rób kopię pliku i przywracaj z niej. +- **Django ma DWA mechanizmy walidacji unikalności o różnej polityce + milczenia.** `validate_unique()` (dla `unique=True`/`unique_together`) + zgłasza błąd; `validate_constraints()` (dla `Meta.constraints`) **cicho + pomija** ograniczenie, którego pole warunku jest wykluczone z walidacji — + a `_get_validation_exclusions()` wyklucza wszystko spoza `Meta.fields`, + które admin nadpisuje spłaszczonymi `fieldsets`. Zamiana `unique=True` na + warunkowy `UniqueConstraint` po cichu zabiera walidację w formularzu. +- **`ruff format` na całym katalogu znowu zagarnął cudzy plik.** Formatuj + tylko własne; cofnięcie kosztowało osobny commit. +- **`make tests-without-playwright` zwraca EXIT 0 mimo porażek** (sprawdzone + w fazie 02: 7 failed + 2 errors przy zerowym kodzie). Czytaj podsumowanie + pytest, nie kod wyjścia. diff --git a/docs/superpowers/PROMPT-start-fazy-04.md b/docs/superpowers/PROMPT-start-fazy-04.md new file mode 100644 index 000000000..b5b71e855 --- /dev/null +++ b/docs/superpowers/PROMPT-start-fazy-04.md @@ -0,0 +1,71 @@ +# Prompt startowy — faza 04 soft-delete (guardy PROTECT) + +Skopiuj treść między liniami do nowej sesji Claude Code, uruchomionej +w `~/Programowanie/bpp`. + +--- + +Zrób fazę 04 soft-delete w BPP (guardy PROTECT). + +**Worktree najpierw** — jeszcze nie istnieje, załóż go z `feat/soft-delete` +(tam wylądowały fazy 01–03, merge commit `774f1a72d`): + +```bash +git worktree add ~/Programowanie/bpp-soft-delete-04 -b feat/soft-delete-04 feat/soft-delete +``` + +potem `EnterWorktree path=~/Programowanie/bpp-soft-delete-04`. + +**Zacznij od dwóch dokumentów, w tej kolejności:** + +1. `docs/superpowers/HANDOFF-soft-delete-faza-04.md` — stan, decyzje + właściciela, dług. **Sekcja §4c jest obowiązkowa**: to lista rozjazdów + między planem a kodem, sprawdzona w kodzie 2026-08-09. +2. `docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md` — + plan, 7 zadań. Powstał PRZED fazami 02 i 03, więc **czytaj go przez filtr + §4c handoffu**. Nie odtwarzaj stanu z gita. + +**Czego NIE rób:** nie ufaj numerom linii w planie (wszystkie nieaktualne, +tabela w §4c/R5); nie wstawiaj guarda na rozdziały do +`BppPublikacjaSoftDeleteMixin` (§4c/R3); nie zakładaj, że +`Praca_Habilitacyjna.autor` to `OneToOneField` (§4c/R2). + +**Bloker, o którym trzeba wiedzieć zanim napiszesz Zadanie 3:** miękkie +`Autor.delete()` psuje scalanie autorów w przypadku „kolizja autorstw +w koszu", a Zadanie 6 planu tego nie wykryje. Mechanizm i miejsce poprawki: +§4c/R1. Poprawkę pisz RAZEM z Zadaniem 3, nie po nim. + +**Zasady (te same, które sprawdziły się w fazie 03):** + +- TDD: każda zmiana najpierw czerwonym testem, potem implementacja. + Czerwień z `ImportError` nie liczy się jako dowód — **zweryfikuj mutacyjnie**, + że asercje pilnują tego, co mają. Mutacja, która PRZESZŁA, znaczy albo słaby + test, albo zbędny kod — rozstrzygnij który, zanim cokolwiek dopiszesz. +- Do mutacji rób **kopię pliku**, nie `git checkout` (cofa do HEAD i kasuje + niezacommitowaną implementację). +- `ruff format` i `ruff check` tylko na własnych plikach. +- Testy uruchamiaj lokalnie. Czytaj podsumowanie pytest, **nie kod wyjścia + `make`** (zwraca 0 mimo porażek). `make tests` przerywa się na pierwszym + błędnym kroku — dokończ `tests-only-playwright` i `js-tests` osobno. +- Słowniki w testach bierz z fixture'ów (`jezyki`, `charaktery_formalne`, + `typy_kbn`, `statusy_korekt`, `typy_odpowiedzialnosci`), nie z baseline — + testy transakcyjne potrafią wyczyścić dane referencyjne. Zanim uznasz + porażkę za regresję, powtórz na świeżych kontenerach (bez + `PYTEST_TESTCONTAINERS_REUSE=1`). +- Przy zmianie typu pola albo typu zwracanego: sprawdź nie tylko KTO to woła, + ale CO robi z wynikiem. Szablony Django połykają brak atrybutu bez wyjątku. +- **CI nie biegnie na PR-ach do `feat/soft-delete`** (workflow `Tests` ma + `pull_request: branches: [dev]`). Zielony check na takim PR-ze nie jest + dowodem — jedyną weryfikacją jest przebieg lokalny. +- Newsfragment (`src/bpp/newsfragments/.{feature,bugfix}.rst`) do każdej + zmiany widocznej dla użytkownika. +- **Nie odświeżaj baseline** (`make baseline-update`) — stoi na `bpp/0487` + i tak ma zostać do czasu scalania całego `feat/soft-delete` do `dev`. + +**Na koniec:** pełne `make tests` (wszystkie trzy kroki), `pre-commit` na +plikach fazy, PR do `feat/soft-delete` z opisem mówiącym też, czego faza NIE +domyka, i handoff dla fazy 05. + +Zacznij od Zadania 1 z planu — ale dopiero po przeczytaniu §4c. + +--- 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..d26ec493f --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md @@ -0,0 +1,244 @@ +# 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 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 (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) + +--- + +## Plany fazowe (wykonywać w kolejności) + +| # | Plik | Zakres | Zależy od | +|---|---|---|---| +| 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 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 | +| 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 cache — BLOKER ZDJĘTY (2026-08-06) + +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`. + +**Inwariant, o którego przetrwanie pytał poprzedni box, NIE przetrwał** — +i to zmienia zakres fazy 01: + +| 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. + +--- + +## 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`. +- ⚠️ **`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()` (i `restore()`) 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, oraz dla której `ostatnio_zmieniony`/`auto_now` NIE byłby bumpowany bez naszej interwencji (patrz kontrakt niżej). +- 🔴 **KONTRAKT PINNED: `ostatnio_zmieniony` MUSI być bumpowany przy `delete()` I `restore()`.** + Decyzja właściciela projektu (2026-08-07), **korekta** wcześniejszej oceny ze + specu fazy 01 (traktował brak bumpa jako neutralny efekt uboczny). Soft-delete + jest **modyfikacją rekordu** i tak musi wyglądać dla każdego konsumenta + przyrostowego (OAI-PMH, CERIF, REST API). Zysk praktyczny: nagrobki + („co skasowano od daty X") są odpytywalne od ręki, bez czekania na + `SoftDeleteLog` z fazy 06: + ```python + Model.deleted_objects.filter(ostatnio_zmieniony__gte=X) + ``` + **Realizacja (PINNED, faza 01 — fazy 02 i 04 dziedziczą/powtarzają wzorzec):** + `src/bpp/models/soft_delete.py` udostępnia + `POLE_ZNACZNIKA_ZMIANY = "ostatnio_zmieniony"` oraz + `dopisz_znacznik_zmiany(instance, update_fields)`; mixin nadpisuje `save()`: + ```python + def save(self, *args, **kwargs): + update_fields = kwargs.get("update_fields") + if update_fields: + kwargs["update_fields"] = dopisz_znacznik_zmiany(self, update_fields) + return super().save(*args, **kwargs) + ``` + Zaczep jest w `save()`, **nie** w `delete()`/`restore()` — kaskada i księgowość + transakcji siedzą w kodzie pakietu, a ten w obu ścieżkach woła `self.save()` + z własną listą pól; nie kopiujemy więc ani grama logiki pakietu. + Helper dopisuje nazwę **tylko** gdy `update_fields` zawiera `deleted_at` + (zwykły częściowy zapis zostaje nietknięty) i **tylko** gdy model ma to pole + (`_meta.get_field` → `FieldDoesNotExist`) — faza 04 (`Autor`) i modele bez + znacznika (np. `Element_Repozytorium`) nie mogą się na tym wywrócić. + Dlaczego samo dopisanie nazwy wystarcza: `Model._save_table()` zawęża listę + pól przez `update_fields` **przed** wywołaniem `Field.pre_save()`, a + `auto_now` żyje wyłącznie w `pre_save()`. Zweryfikowane testem + (`src/bpp/tests/test_soft_delete/test_ostatnio_zmieniony.py`), nie lekturą. +- ℹ️ **`ostatnio_zmieniony` PUBLIKACJI nie drga przy soft-delete autorstwa** + (ustalone empirycznie 2026-08-07, test `test_a2_soft_delete_autorstwa_a_znacznik_publikacji`). + Denorm **przelicza** publikację (`opis_bibliograficzny_cache` itd. — potwierdzone + kontrolą pozytywną), ale BPP ma `DENORM_DISABLE_AUTOTIME_DURING_FLUSH = True` + (`src/django_bpp/settings/base.py`), więc `denorm.denorms._build_save_kwargs()` + buduje `update_fields` **jawnie wykluczające** pola `auto_now`. To zachowanie + **prekursorskie wobec soft-delete** — identycznie działa hard-delete autorstwa + na `dev` — więc nie jest regresją fazy 01. Konsekwencja dla harvestu + przyrostowego po stronie publikacji: zmiana samego składu autorów nie podnosi + znacznika rekordu nadrzędnego. Jeśli faza 02/06 tego potrzebuje, to jest + osobna decyzja dotykająca denorma, nie soft-delete. + +### 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)`. + ⚠️ Klient PBN to **pakiet zewnętrzny** — metoda żyje w site-packages + (`pbn_client/mixins/institutions.py:87`), importowana przez + `src/pbn_api/client/__init__.py`. Katalog `src/pbn_api/client/mixins/` + zawiera dziś wyłącznie `__pycache__`. + +### 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)`. +- **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). +- 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: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: **pakiet zewnętrzny** `pbn_client` (site-packages), nie `src/pbn_api/client/mixins/`. `SentData`: `src/pbn_api/models/sentdata.py` — ⚠️ scope'owane po uczelni (`get_for_rec(rec, uczelnia=None)`). 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.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):** +- 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..d8a1c2ba4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -0,0 +1,1223 @@ +# 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. + +> 🔄 **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 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): + +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). + +**Fakty z kodu (zweryfikowane, NIE zmieniać bez ponownej weryfikacji):** +- `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. 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` 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 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ą. + +--- + +## 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_zwracaja_bpp_queryset(): + """Oba managery MUSZĄ zwracać BppSoftDeleteQuerySet — inaczej gate + na update() nie zadziała (pakietowy QuerySet go nie ma).""" + 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" + ) + ``` + + > 🩹 **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 + 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/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` + +**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 + ``` + +> 🩹 **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 + 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 + ``` + 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: + ```bash + uv run python src/manage.py makemigrations bpp --name autor_soft_delete_fields + ``` + (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 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. ⚠️ **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( + model_name="wydawnictwo_zwarte_autor", + index=models.Index( + fields=["deleted_at"], + name="wz_autor_deleted_at_idx", + condition=models.Q(deleted_at__isnull=False), + ), + ), + migrations.AddIndex( + model_name="patent_autor", + 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. 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 + 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/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) " + ``` + +--- + +## 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. + +Jedna migracja, trzy zmiany, w **wymuszonej kolejności**: + +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 + kroku 1**, bo bramka czyta kolumny z `pg_depend` po definicji widoku. + +**Files:** +- 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 testy — `src/bpp/tests/test_soft_delete/test_views_sql.py`: + ```python + """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_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" + + + @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 + ``` + +- [ ] 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): + + ```python + """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 _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. + + 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 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 + 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}();" + ) + ``` + + ⚠️ `_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 + ``` + +- [ ] Sprawdź brak driftu migracji: + ```bash + DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run + ``` + +- [ ] Commit: + ```bash + 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 + +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) " + ``` + +--- + +## 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 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 warunkowy `UniqueConstraint` na `(rekord, autor, typ_odpowiedzialnosci)` + (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", + ), + ] + ``` + (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**. + `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). + +**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. + + 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 + + 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.""" + 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=[ct, 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=[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 + ``` + +- [ ] 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 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 + 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ść **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ą. +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` — 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 new file mode 100644 index 000000000..d913674ac --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md @@ -0,0 +1,938 @@ +# 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. + +> ⚠️ **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). + +**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). + +--- + +## 🧹 Sprzątanie `bpp_kronika_*` — jedno zadanie, nie po kawałku (decyzja 2026-08-07) + +Próba usunięcia trzech martwych widoków `bpp_kronika_{wydawnictwo_ciagle, +wydawnictwo_zwarte,patent}_view` w fazie 01 została **zablokowana**: zależą od +nich dwa widoki nadrzędne, `bpp_kronika_all_unsorted_view` (UNION pięciu) i +`bpp_kronika_view` (`0443_drop_pl_PL_collation.sql:13-14,30-31`). Goły +`DROP VIEW` bez `CASCADE` nie przejdzie. + +Zweryfikowano: **cała siódemka jest martwa** — zero konsumentów w kodzie, +szablonach, `Meta.db_table`, `flexible_reports` i surowym SQL-u. + +**Decyzja: skasować wszystkie 7 naraz, w TEJ fazie**, bo faza 02 i tak dołoży +`bpp_kronika_praca_{doktorska,habilitacyjna}_view` do zakresu kanarka +(rozszerzenie `TABELE_SOFT_DELETE` o tabele publikacji) i wtedy trzeba by je +weryfikować osobno. Jedna migracja, jedna decyzja, pełny graf zależności: + +``` +bpp_kronika_view + └── bpp_kronika_all_unsorted_view + ├── bpp_kronika_wydawnictwo_ciagle_view + ├── bpp_kronika_wydawnictwo_zwarte_view + ├── bpp_kronika_patent_view + ├── bpp_kronika_praca_doktorska_view ← żywotność do potwierdzenia + └── bpp_kronika_praca_habilitacyjna_view ← żywotność do potwierdzenia +``` + +Kolejność `DROP`: od góry (parasole) w dół. Migracja **odwracalna** — `backward` +odtwarza całą siódemkę; wyjdź z `pg_get_viewdef()` przed skasowaniem. +Po zrobieniu: usuń wpisy z `WYJATKI` w `test_kanarek_katalogowy.py`. + +Jeśli któryś z dwóch niezweryfikowanych okaże się mieć konsumenta — **nie +kasuj żadnego**, zostaw wyjątki i zgłoś. + +--- + +## ⚠️ 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~~ | **NIEWYKONANY świadomie** — slug zawiera `pk`, kolizja niemożliwa; patrz box przy tasku | +| 6 | **Task 3b** — `unique_together` na `*_Autor` | niezależny | +| **3.5** | **Task 2d — sumy + rozbieżności (6 widoków)** | **DOPISANY** — inwentaryzacja kanarka na starcie fazy; w planie był tylko ostrzeżeniem (c) | +| 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ł"). +- `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`). +- ⚠️ **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ć) + +- `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`). + +> 🩹 **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`: + ```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 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: + 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!) + # ⚠️ 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 + 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 + 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) + 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/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`. + +- [ ] **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 `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 + + # 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/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) " + ``` + +--- + +## ~~Task 3: `slug` — warunkowy `UniqueConstraint`~~ → ŚWIADOMIE NIEWYKONANY + +> 🛑 **DECYZJA WŁAŚCICIELA, 2026-08-07: tego zadania NIE robimy.** +> `slug` zostaje `unique=True`, bezwarunkowo. To NIE jest dług ani +> przeoczenie — poniżej powód, żeby nikt nie „dokończył" tego w dobrej wierze. +> +> **Przesłanka zadania nie zachodzi.** `get_slug()` +> (`src/bpp/models/util.py:246-254`) skleja slug jako +> `tytuł-źródło-autorzy--`. Slug **zawiera klucz +> główny**, więc dwa różne wiersze nie mogą mieć tego samego slugu, a +> skasowany rekord nie zablokuje nowego — nowy dostaje nowe `pk`, czyli inny +> slug. Kolizja, którą to zadanie miało rozwiązać, jest konstrukcyjnie +> niemożliwa. ID jest w slugu CELOWO, właśnie po to, żeby slug był unikalny. +> +> Plan pisano najwyraźniej przy założeniu slugu opartego wyłącznie na +> tytule. Przy obecnej formule zamiana `unique=True` na +> `UniqueConstraint(condition=Q(deleted_at__isnull=True))` **osłabiłaby** +> gwarancję (unikalność tylko wśród żywych) i wymagała przebudowy indeksu na +> pięciu dużych tabelach — w zamian za rozwiązanie nieistniejącego problemu. +> +> Wyszło przy pisaniu testów TDD do tego zadania: padły wszystkie dziesięć, +> w tym ten, który powinien przechodzić JESZCZE PRZED zmianą. Denorm +> dodatkowo nadpisuje ręcznie ustawiony slug, więc kolizji nie da się nawet +> wywołać sztucznie. +> +> Poniższa treść zostaje wyłącznie jako kontekst historyczny. + +
oryginalna treść tasku (nieaktualna) + +**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/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`). + +> **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 `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 + 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/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) " + ``` + +
+ +--- + +## 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` **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` +- 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 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`~~ → 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. + +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:** +- 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() + 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=(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=(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=(ct_pk, wc.pk)).exists() + ``` + > `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 + 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 (`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`. +- [x] ~~`slug` → warunkowy `UniqueConstraint`~~ — **świadomie NIEwykonane.** `slug` zostaje `unique=True`; slug zawiera `pk`, więc kolizja jest konstrukcyjnie niemożliwa i warunkowanie tylko osłabiłoby gwarancję. Uzasadnienie w boxie przy Tasku 3. +- [ ] `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..3e5b87553 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md @@ -0,0 +1,1020 @@ +# 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):** +- 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. + +--- + +## 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 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 ZMIENIONA 2026-08-08 przez właściciela systemu.** +> Obowiązuje wariant **PRZYWRÓĆ + ODNOTUJ**, opisany zaraz pod tym boksem. +> Poniższy akapit zostaje jako zapis pierwotnego rozumowania — bo argument, +> który podnosił, nadal jest ważny i to on uzasadnia istnienie rejestru. +> +> ~~**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. + +**DECYZJA OBOWIĄZUJĄCA: przywróć rekord z kosza i odnotuj to w rejestrze.** + +Uzasadnienie właściciela: **PBN jest źródłem prawdy** dla tych publikacji — +skoro rekord nadal tam jest, ma wrócić także do BPP. + +Zastrzeżenie z pierwotnej decyzji nie zostało uznane za nieważne, tylko +przeniesione: z „nie róbmy tego" na „róbmy, ale zostawmy ślad". Śladem jest +model `pbn_integrator.RekordPrzywroconyPrzezImport` (migracja `0001`) — +rekord (generic FK), data, publikacja PBN, ścieżka importu. Dzięki niemu +operator, który znajdzie w bazie publikację skasowaną przez siebie tydzień +wcześniej, ma gdzie sprawdzić, że wróciła z importu, kiedy i skąd. + +⚠️ Wpis powstaje **wyłącznie przy realnym wskrzeszeniu**. Zwykły re-import +żywego rekordu nie zostawia nic — inaczej rejestr zapełniłby się szumem +i przestałby cokolwiek znaczyć. + +**Zrealizowane:** helper `pbn_integrator.kosz.przywroc_jesli_w_koszu()`, +wpięty w `articles.py`, `books.py`, `chapters.py`. Testy: +`src/pbn_integrator/tests/test_przywracanie_z_kosza.py`. + +**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 +`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 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 +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 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 + 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..5a242148d --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md @@ -0,0 +1,508 @@ +# 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. +- 🔴 **`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) + +- `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..7e436d24d --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md @@ -0,0 +1,1285 @@ +# Soft-delete — Faza 05: propagacja usunięcia na zewnątrz (PBN + nagrobki) + +> 🔁 **ROZSZERZENIE ZAKRESU 2026-08-08 (decyzja właściciela).** +> Do tej fazy dochodzą **NAGROBKI** — ogłaszanie usunięć konsumentom +> przyrostowym. Powód: to ten sam motyw co wycofanie z PBN — *systemy +> zewnętrzne dowiadują się, że coś zniknęło*. Inny odbiorca, ta sama +> historia. Trzymane osobno przepadłyby między fazami, bo żaden plan ich nie +> obejmował, a handoff fazy 02 (§3.1) traktował je jako bramkę wydania. +> +> **Zakres nagrobków:** +> - **OAI-PMH**: `
` w `src/cerif_export` (serwuje +> `ListRecords`/`ListIdentifiers`/`resumptionToken`; **dziś ZERO obsługi +> `deleted`**), respektujące `from`/`until`; +> - **CERIF**: odpowiednik nagrobka w formacie rekordu; +> - **`/api/v1/`**: sposób odkrycia usuniętych (endpoint „usunięte od…” +> albo parametr). +> +> **Fundament jest gotowy** — soft-delete bumpuje `ostatnio_zmieniony` +> (kontrakt PINNED z fazy 01), więc lista nagrobków to po prostu +> `Model.deleted_objects.filter(ostatnio_zmieniony__gte=X)`. Nie trzeba +> `SoftDeleteLog` z fazy 06. Brakuje wyłącznie ekspozycji. +> +> ⚠️ **Termin:** nagrobki muszą być gotowe przed **fazą 07**, nie przed 04. +> Dopóki kasowanie jest rzadkie, luka w OAI-PMH jest teoretyczna; faza 07 +> czyni kasowanie rutynowym i dopiero wtedy zaczyna realnie boleć. + +> **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ę **po** atomowym zajęciu wiersza (`_zajmij_atomowo()`): `WYCOFANIE` → nowa metoda `withdraw_from_pbn()`, będąca **cienkim wywołaniem prymitywu** `wycofaj_oswiadczenia()` (klient PBN z `self.uczelnia`, klasyfikacja wyjątków przez istniejące `_handle_pbn_exception`); `WYSYLKA` → dotychczasowa ścieżka bez zmian. Gate zakolejkowania: wycofanie tylko gdy rekord ma `pbn_uid_id`; `zamowil` dla operacji systemowych to konto techniczne (Task 05.4a). + +> 📌 **Rewizja 2026-08-07 (dług dokumentacyjny).** Plan opisywał kod sprzed +> przebudowy kolejki (`_zajmij_atomowo`, `LOCKED_ELSEWHERE`, FK `uczelnia`), +> sprzed wyprowadzenia klienta PBN do pakietu `pbn-client` i sprzed +> uczelnia-scopingu `SentData`; numery migracji były o trzy do tyłu; +> Task 05.3 łamał decyzję #16, a Task 05.5 rozjeżdżał się kontraktem +> z fazą 06 i kasował zabezpieczenie TOCTOU. Doszedł **Task 05.4a** +> (konto techniczne + rozróżnienie `IntegrityError`), bo bez niego +> systemowy soft-delete **po cichu** nie wycofywał oświadczeń z PBN. + +**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/`. Stan na + 2026-08-07: ostatnia migracja `pbn_export_queue` to + `0010_atomowa_kolejka_pbn.py` → nowa będzie **`0011_*`** (a NIE `0008_*`, jak + pisała pierwsza wersja planu: `0008`/`0009` dołożyły FK `uczelnia`, `0010` — + częściowy unikat aktywnego wpisu). Ostatnia migracja `pbn_api` to + `0079_constraint_publikacja_instytucji.py` → nowa **`0080_*`**. **Przed + pisaniem sprawdź `ls` obu katalogów** — numery mogły znów urosnąć. +- Po każdym kroku z kodem produkcyjnym: `ruff check` + `ruff format` na dotkniętych plikach, potem commit. +- Commituj **po jawnych ścieżkach** (`git add …`), nie `git add -A` — + na tej gałęzi bywa równolegle więcej niż jeden wykonawca. + +--- + +## Stan zastany (zweryfikowany w kodzie 2026-08-07 — używać tych nazw VERBATIM) + +> ⚠️ **Poprzednia wersja tej sekcji opisywała kod, którego już nie ma.** +> Kolejka została przebudowana na atomowy row-claim, doszedł FK `uczelnia`, +> a klient PBN wyprowadzono do zewnętrznego pakietu. Poniżej stan faktyczny; +> wszystkie cytaty `plik:linia` zweryfikowane. + +`src/pbn_export_queue/models.py`: +- `PBN_Export_Queue` pola: `object_id`, `content_type`, `rekord_do_wysylki` + (GFK), `zamowil` (FK user, `on_delete=CASCADE`, **NOT NULL** — `:101`), + **`uczelnia`** (FK `bpp.Uczelnia`, `null=True`, `related_name= + "pbn_export_queue"` — `:103`), `zamowiono`, `wysylke_podjeto`, + `wysylke_zakonczono`, `ilosc_prob`, `zakonczono_pomyslnie`, `komunikat`, + `retry_after_user_authorised`, `rodzaj_bledu` + (`RodzajBledu.TECHNICZNY/MERYTORYCZNY`), `wykluczone`. +- `Meta.constraints` (`:146-155`): częściowy unikat + **`pbn_export_queue_jeden_aktywny_wpis_na_rekord`** na + `(content_type, object_id)` `WHERE wysylke_zakonczono IS NULL`. +- Manager `PBN_Export_QueueManager` (`:34`): + - `filter_rekord_do_wysylki(rekord)` (filtr `wysylke_zakonczono=None`), + - **`sprobuj_utowrzyc_wpis(user, rekord, uczelnia=None)`** (`:42`) — szybka + ścieżka `exists()` → `AlreadyEnqueuedError`, a następnie + `with transaction.atomic(): self.create(...)` z `except IntegrityError` + tłumaczonym na `AlreadyEnqueuedError` (`:53-63`, zabezpieczenie TOCTOU). + **Tej metody NIE przepisujemy od zera** — rozszerzamy o `operacja` + (Task 05.5) i uszczelniamy rozpoznanie wyjątku (Task 05.4a). Savepoint + + tłumaczenie kolizji unikatu MUSZĄ zostać. +- `SendStatus` (Enum, `:66`): `RETRY_SOON`, `RETRY_LATER`, `RETRY_MUCH_LATER`, + `RETRY_AFTER_USER_AUTHORISED`, `WYKLUCZONE`, `FINISHED_OKAY`, + `FINISHED_ERROR`, **`LOCKED_ELSEWHERE`** (`:80` — dodany przy row-claimie). +- **`_zajmij_atomowo()` (`:419`)** — `select_for_update(skip_locked=True)` + w krótkiej transakcji; ustawia `wysylke_podjeto`, zeruje + `retry_after_user_authorised`, inkrementuje `ilosc_prob` + (`save(update_fields=[...])`), na końcu `refresh_from_db()`. Zwraca `False`, + gdy wiersz trzyma inny worker. ⚠️ **Bloku + „`wysylke_podjeto`/`ilosc_prob`" wewnątrz `send_to_pbn()` JUŻ NIE MA** — + nie ma czego „przenosić" (Task 05.3 dawniej tak kazał). +- **`send_to_pbn()` (`:456`)**, kolejność: `refresh_from_db()` → wczesny zwrot + `FINISHED_OKAY` gdy `wysylke_zakonczono is not None` → + `check_if_record_still_exists()` → `error(...)` → + `if not self._zajmij_atomowo(): return SendStatus.LOCKED_ELSEWHERE` → + import + `sprobuj_wyslac_do_pbn_celery(user=self.zamowil.get_pbn_user(), + obj=self.rekord_do_wysylki, force_upload=True, **uczelnia=self.uczelnia**)` + → `_handle_pbn_exception(exc)` / `error(...)` gdy `sent_data is None` / + `_handle_successful_send(sent_data, notificator)`. +- Pozostałe: `error(msg, rodzaj=None)` (`:241`), **`exclude(msg)`** (`:249` → + `SendStatus.WYKLUCZONE`), `dopisz_komunikat` (`:232`), + `check_if_record_still_exists` (`:170`), `prepare_for_resend` (`:188`), + `sprobuj_wyslac_do_pbn()` (`:227`), `_handle_retry_exception` (`:284` — + `PraceSerwisowe`→RETRY_MUCH_LATER, `NeedsPBNAuthorisation`→ + RETRY_AFTER_USER_AUTHORISED, `ResourceLocked`/HTTP 423→RETRY_LATER, + `StatementsResendFailed`→RETRY_LATER), `_handle_exclude_exception` (`:329`), + `_handle_pbn_exception` (`:351`), `_handle_successful_send` (`:402`). + **`_handle_pbn_exception` jest wspólną klasyfikacją błędów dla obu + operacji** — wycofanie ma z niej korzystać, nie budować własnej drabinki + `except`. + +`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()`, + `LOCKED_ELSEWHERE` obsłużony razem z `FINISHED_ERROR` — `:63-69`). Lock + zwalniany w `finally`. **Ta sama maszyneria obsłuży WYCOFANIE bez zmian** — + `send_to_pbn()` zwraca `SendStatus`. + +**Klient PBN to ZEWNĘTRZNY PAKIET `pbn-client`, nie kod repo.** +`src/pbn_api/client/mixins/` zawiera dziś wyłącznie `__pycache__` — +**każdy cytat `src/pbn_api/client/mixins/institutions.py:87` (w tym planie, +w specu §4.1 i w indeksie 00) jest martwy.** Realne lokalizacje: +- `delete_all_publication_statements(publicationId)` — + **`pbn_client/mixins/institutions.py:87`** (site-packages). Sam mapuje + HTTP 400 z PBN na `CannotDeleteStatementsException` / + `ResourceLockedException` — wołający NIE parsuje treści odpowiedzi. +- `_delete_statements_with_retry(pbn_uid_id, max_tries=5)` — + **`pbn_client/statements.py:211`** (site-packages), ponawia **wyłącznie** + na `CannotDeleteStatementsException`. ⚠️ To NIE jest wzorzec dla naszego + wycofania: u nas `CannotDeleteStatementsException` = „nie ma czego + kasować" = **sukces**, nie powód do retry. +- W repo zostały tylko BPP-owe mixiny: `src/pbn_api/client/publication_sync.py` + (`_pre_upload_clear_pbn_statements_if_any` `:95`, `_post_statements_with_retry` + `:331`, `_download_statements_with_retry` `:498` — `_delete_statements_with_retry` + tam **nie istnieje**) oraz `disciplines.py`; `BppPBNClient` sklejany + w `src/pbn_api/client/__init__.py:85` (zna swoją `uczelnia`). +- **Wzorzec obsługi wyjątków do przejęcia:** + `src/pbn_wysylka_oswiadczen/tasks.py::_delete_existing_statements` + (`:54-76`) — `CannotDeleteStatementsException` → `pass` (OK, nic nie było), + `PraceSerwisoweException` → `raise`, `HttpException` → zapis błędu. +- Wyjątki importuje się z `pbn_api.exceptions` (re-eksport) — tak jak robi to + już `pbn_export_queue/models.py:17-29`. + +`src/pbn_api/models/sentdata.py` — ⚠️ **`SentData` jest scope'owane po +uczelni** (multi-hosted). Wszystkie metody managera mają `uczelnia=None`: +`get_for_rec(rec, uczelnia=None)` (`:23`), `mark_as_successful(rec, +pbn_uid_id=None, api_response_status="", uczelnia=None)` (`:85`), +`mark_as_failed(rec, exception="", api_response_status="", uczelnia=None)` +(`:97`), `create_or_update_before_upload(...)` (`:57`). Model ma FK `uczelnia` +(`:206`). **Gdy dla `(object_id, content_type)` istnieją ≥2 wiersze, lookup +BEZ `uczelnia` rzuca `MultipleObjectsReturned`** — dlatego `mark_as_withdrawn` +MUSI przyjmować `uczelnia` (Task 05.2), a prymityw MUSI je przekazywać. +Pola: `content_type`/`object_id`/`object` (GFK), `uczelnia`, +`submitted_successfully`, `submitted_at`, `api_response_status`, `api_url`, +`uploaded_okay`, `exception`, `fee_sent`, `fee_uploaded_okay`, `pbn_uid` +(FK `pbn_api.Publication`). Pola `withdrawn_at` **nie ma** +(`grep -rn withdrawn_at src/` → 0 trafień). + +`src/bpp/admin/helpers/pbn_api/cli.py:36`: +- `sprobuj_wyslac_do_pbn_celery(user, obj, force_upload=False, + pbn_client=None, **uczelnia=None**)` — buduje + `pbn_client = uczelnia.pbn_client(user.pbn_token)`; bez `uczelnia` robi + `Uczelnia.objects.get()` (przy >1 rzuca — multi-hosted MUSI podać jawnie). + +**Uczelnia: `Uczelnia.objects.get_default()` NIE ISTNIEJE.** Została trwale +usunięta w audycie multi-hosted i jest pilnowana testem-guardem +`src/bpp/tests/test_multihosted_get_default_guard.py::test_get_default_usuniete_na_trwale` +(0 dozwolonych wystąpień w `src/`, **także w komentarzach**). Kod z jej użyciem +wywali CI. Dostępne API (`src/bpp/models/uczelnia.py`): +`get_for_request(request)`, `get_for_pbn_background(uczelnia_id)` (`:144` — +rzuca `ValueError` gdy `None`, świadomie BEZ fallbacku), +`get_single_uczelnia_or_none()` (`:48`), `get_single_uczelnia_or_fail()` +(`:59`), `Uczelnia.pbn_client(token)` (`:936`, zwraca `BppPBNClient`). + +`src/bpp/models/profile.py`: `BppUser.pbn_token` (`CharField`, default `""`), +`get_pbn_user()` (`:207`) — zwraca `przedstawiaj_w_pbn_jako`, gdy ustawione, +inaczej `self`. Istotne dla konta technicznego (Task 05.4a). + +`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). + +⚠️ **Wycofanie jest per-uczelnia, nie globalne.** `SentData` ma FK `uczelnia` +i dwie uczelnie wysyłające ten sam rekord BPP mają DWA niezależne wiersze. +Wycofanie zleca konkretna uczelnia (`PBN_Export_Queue.uczelnia`), więc +`withdrawn_at` ustawiamy **tylko na jej wierszu** — każde dotknięcie +`SentData` w tej fazie przekazuje `uczelnia=` w dół. Wołanie +`get_for_rec(rec)` bez `uczelnia` przy ≥2 wierszach rzuci +`MultipleObjectsReturned`. + +--- + +## 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ą. +> +> ⚠️ **Kolejność:** prymityw woła `SentData.objects.mark_as_withdrawn(...)`, +> którą dostarcza dopiero **Task 05.2**. Zrób więc 05.2 **przed** 05.0 +> (albo scal oba w jeden krok) — inaczej testy 05.0 padną na `AttributeError` +> z powodu, który nie jest przedmiotem testu. + +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 (PINNED — pozostałe taski cytują te nazwy): + +```python +class StatusWycofania(models.TextChoices): + WYCOFANO = "wycofano" # DELETE poszedł, oświadczenia usunięte + BRAK_OSWIADCZEN = "brak" # PBN: nie było czego usuwać — też sukces + POMINIETO = "pominieto" # brak pbn_uid — klienta w ogóle nie wołamy + + +@dataclass +class WynikWycofania: + status: StatusWycofania + komunikat: str + + +def wycofaj_oswiadczenia(publikacja, client, uczelnia=None) -> WynikWycofania: + """Wycofuje oświadczenia dyscyplin publikacji z profilu instytucji PBN. + + Gate: publikacja bez pbn_uid -> WynikWycofania(POMINIETO) (nie błąd), + bez dotykania SentData i bez wołania klienta. + Obiektu publikacji w PBN NIE kasujemy (jest współdzielony). + Po sukcesie aktualizuje SentData WIERSZA TEJ UCZELNI: + submitted_successfully=False + withdrawn_at (SentData.DoesNotExist -> + nie jest błędem, po prostu nie ma czego oznaczać). + + Wyjątki PBN (PraceSerwisowe / ResourceLocked / Http / …) PROPAGUJE — + klasyfikuje je wywołujący (kolejka: _handle_pbn_exception, ścieżka + synchroniczna: własna obsługa). Prymityw odpowiada za semantykę + „co znaczy sukces" i za stan SentData, NIE za politykę retry. + """ +``` + +> **Podział odpowiedzialności (ważne dla 05.3 i 05.9):** prymityw = gate + +> wywołanie klienta + idempotencja (`CannotDeleteStatementsException`) + +> `SentData`. Wywołujący = klasyfikacja wyjątku i decyzja o ponowieniu. +> Dzięki temu kolejka reużywa istniejące `_handle_pbn_exception` +> (`models.py:351`) zamiast dublować drabinkę `except`. + +- [ ] **Krok 05.0.1 — testy obsługi wyjątków (wzorzec do przejęcia: + `src/pbn_wysylka_oswiadczen/tasks.py:54-76`).** Przypadki: + - `CannotDeleteStatementsException` → **SUKCES** `BRAK_OSWIADCZEN` + (oświadczeń nie było — stan docelowy osiągnięty), `SentData` oznaczone + jak przy `WYCOFANO`. To NIE jest błąd; zaległa pułapka. + - `PraceSerwisoweException` → propaguj (retry ma sens, PBN w oknie + serwisowym); `SentData` NIE dotknięte. + - `HttpException` (w tym 423) → propaguj; `SentData` NIE dotknięte. + ⚠️ Zmiana względem pierwszej wersji planu („błąd zaklasyfikowany"): + klasyfikacja należy do wywołującego, inaczej kolejka miałaby dwie + rozjeżdżające się tabele wyjątków. + - `pbn_uid is None` → `POMINIETO`, bez wołania klienta i bez `SentData`. + - `SentData.DoesNotExist` (rekord ma `pbn_uid`, ale nigdy nie było wiersza) + → nadal `WYCOFANO`, bez wyjątku. +- [ ] **Krok 05.0.2 — implementacja** + aktualizacja `SentData` wewnątrz + prymitywu (NIE u wywołującego), z przekazaniem `uczelnia=` do + `mark_as_withdrawn`. +- [ ] **Krok 05.0.3 — PASS** + commit (jawne ścieżki). + +--- + +### 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/0011_pbn_export_queue_operacja.py` (NOWA — + numer wg `ls src/pbn_export_queue/migrations/`; ostatnia dziś to `0010_*`) +- 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 `0011_pbn_export_queue_operacja.py` (kolejny po `0010_atomowa_kolejka_pbn.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/0011_pbn_export_queue_operacja.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit** (jawne ścieżki): `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/0080_sentdata_withdrawn_at.py` (NOWA — numer wg + `makemigrations`; ostatnia dziś to `0079_constraint_publikacja_instytucji.py`) +- 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="", uczelnia=None): + """Oznacza rekord jako wycofany z PBN (oświadczenia usunięte). + + ``uczelnia`` obowiązkowe w praktyce (multi-hosted): wycofanie + dotyczy profilu KONKRETNEJ uczelni, a przy ≥2 wierszach + ``get_for_rec`` bez niej rzuci MultipleObjectsReturned. Default + ``None`` tylko dla zgodności z resztą managera. + + 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, uczelnia) + sd.submitted_successfully = False + sd.withdrawn_at = timezone.now() + if api_response_status: + sd.api_response_status = api_response_status + sd.save() + return sd + ``` + ⚠️ Sygnatura idzie **za** konwencją reszty `SentDataManager` + (`uczelnia=None` jako OSTATNI kwarg — `get_for_rec` `:23`, + `mark_as_successful` `:85`, `mark_as_failed` `:97`). Nie wymyślaj innej. +- [ ] **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.) +- [ ] **Test symetrii per-uczelnia (dopisz):** dwa wiersze `SentData` dla + tego samego rekordu i dwóch uczelni; `mark_as_withdrawn(rec, + uczelnia=u1)` ustawia `withdrawn_at` **tylko** na wierszu `u1`, a wiersz + `u2` zostaje `submitted_successfully=True`. Bez tego testu regresja + „wycofanie jednej uczelni kasuje stan drugiej" przejdzie niezauważona. +- [ ] **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** (jawne ścieżki): `git commit -m "feat(pbn_api): SentData.withdrawn_at + mark_as_withdrawn; reset przy mark_as_successful"` + +--- + +### Task 05.3 — `withdraw_from_pbn()` (CIENKI wrapper na prymityw) + rozgałęzienie w `send_to_pbn()` + +> ⚠️ **Przepisane 2026-08-07.** Poprzednia wersja tego tasku: (a) łamała +> decyzję #16 i niezmiennik §4.2 specu — re-implementowała retry, wołała +> `delete_all_publication_statements` bezpośrednio i sama aktualizowała +> `SentData`, zamiast użyć prymitywu z 05.0; (b) kazała „przenieść blok +> `wysylke_podjeto`/`ilosc_prob`", którego w `send_to_pbn()` **nie ma** — +> zastąpił go `_zajmij_atomowo()` (`models.py:419`); (c) pozyskiwała uczelnię +> przez **nieistniejące** `Uczelnia.objects.get_default()`, co dodatkowo +> wywala guard-test multi-hosted. + +Gałąź `WYCOFANIE` **nie zawiera logiki wycofania**: pozyskuje klienta PBN, +woła `wycofaj_oswiadczenia()` (Task 05.0) i tłumaczy wynik/wyjątek na +`SendStatus` — wyjątki przez istniejące `_handle_pbn_exception` +(`ResourceLocked`→`RETRY_LATER`, `PraceSerwisowe`→`RETRY_MUCH_LATER`, +HTTP 423→`RETRY_LATER`, reszta→`error(...)`). `WYSYLKA` → ścieżka bez zmian. +Lock/`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, uczelnia + ): + 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, + uczelnia=uczelnia, + ) + + wpis = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + uczelnia=uczelnia, + 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 `pbn_wysylka_oswiadczen/tasks.py::get_pbn_client` — uczelnia JAWNA, nigdy „domyślna"): + ```python + def _pozyskaj_klienta_pbn(self): + """Buduje klienta PBN dla TEGO wpisu kolejki. + + Uczelnia z FK wpisu (``self.uczelnia``) — NIE „domyślna": taki + byt nie istnieje (dawne API „uczelni domyślnej" zostało trwale + usunięte i jest pilnowane guardem + ``test_multihosted_get_default_guard.py``). Dla wpisów legacy + (``uczelnia_id is None``) jedyny dozwolony fallback to + „jedyna-albo-głośny-błąd". + + Token: z konta PBN zamawiającego (``get_pbn_user()`` respektuje + ``przedstawiaj_w_pbn_jako``) — jak w wysyłce. + """ + from bpp.models import Uczelnia + + pbn_user = self.zamowil.get_pbn_user() + uczelnia = self.uczelnia or Uczelnia.objects.get_single_uczelnia_or_fail() + return uczelnia.pbn_client(pbn_user.pbn_token) + ``` + ⚠️ **Nie** wołaj tu `Uczelnia.objects.get_default()` ani + `Uczelnia.objects.first()` — oba są zakazane guard-testem multi-hosted + (`src/bpp/tests/test_multihosted_get_default_guard.py`). ⚠️ Guard skanuje + pliki `.py` **regexem, razem z komentarzami i docstringami** — nie + przepisuj tych dwóch nazw nawet do dokumentacji w kodzie (dlatego + docstring wyżej mówi opisowo „dawne API uczelni domyślnej"). +- [ ] **Implementacja — `withdraw_from_pbn` jako CIENKI wrapper.** Cała + logika (retry-semantyka, idempotencja, `SentData`) siedzi w prymitywie + z 05.0; tutaj wyłącznie klient → prymityw → `SendStatus`: + ```python + def withdraw_from_pbn(self): + """Gałąź WYCOFANIE — cienkie wywołanie prymitywu (decyzja #16). + + NIE woła klienta PBN bezpośrednio i NIE dotyka SentData — robi to + wycofaj_oswiadczenia(), wspólne z wejściem synchronicznym (05.9). + Tu tylko: pozyskanie klienta, wywołanie prymitywu i tłumaczenie + wyniku/wyjątku na SendStatus. Klasyfikacja wyjątków PBN idzie + przez wspólne _handle_pbn_exception (ResourceLocked → RETRY_LATER, + PraceSerwisowe → RETRY_MUCH_LATER, HTTP 423 → RETRY_LATER, …). + + :return: SendStatus + """ + from pbn_api.wycofanie import wycofaj_oswiadczenia + + try: + client = self._pozyskaj_klienta_pbn() + except Exception as exc: + return self._handle_pbn_exception(exc) + + try: + wynik = wycofaj_oswiadczenia( + self.rekord_do_wysylki, client, uczelnia=self.uczelnia + ) + except Exception as exc: + zaloguj_polkniety_wyjatek( + "Błąd podczas wycofywania oświadczeń z PBN z kolejki " + f"eksportu (PBN_Export_Queue pk={self.pk})", + logger=logger, + do_rollbar=False, # Rollbar w _handle_pbn_exception + ) + return self._handle_pbn_exception(exc) + + self.wysylke_zakonczono = timezone.now() + self.zakonczono_pomyslnie = True + self.dopisz_komunikat(wynik.komunikat) + self.save() + return SendStatus.FINISHED_OKAY + ``` + (`POMINIETO` też kończy wpis sukcesem — prymityw zwraca wtedy komunikat + „rekord nie ma PBN UID". To gate obronny: `zakolejkuj_wycofanie` takich + wpisów nie tworzy.) +- [ ] **Implementacja — rozgałęzienie w `send_to_pbn`.** ⚠️ **Niczego nie + przenosimy.** Wstaw DOKŁADNIE dwie linie między `_zajmij_atomowo()` + (`models.py:469-472`) a importem `sprobuj_wyslac_do_pbn_celery` + (`models.py:474`): + ```python + if not self._zajmij_atomowo(): + # Inny worker zdążył zająć ten wiersz (row lock) albo go zakończył. + # On dokończy — bieżące zadanie kończymy bez ponawiania. + return SendStatus.LOCKED_ELSEWHERE + + if self.operacja == self.Operacja.WYCOFANIE: + return self.withdraw_from_pbn() + + from bpp.admin.helpers.pbn_api.cli import sprobuj_wyslac_do_pbn_celery + ``` + Dzięki umieszczeniu PO `_zajmij_atomowo()` wycofanie dziedziczy za darmo: + guard „rekord zniknął", licznik `ilosc_prob`, `wysylke_podjeto` i ochronę + przed dwoma workerami. 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. +- [ ] **Guard multi-hosted (obowiązkowy po tym tasku):** + `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -x` → + PASS (pilnuje, że `_pozyskaj_klienta_pbn` nie wprowadził + `get_default()`/`first()`). +- [ ] **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** (jawne ścieżki): `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, uczelnia + ): + wpis = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + uczelnia=uczelnia, + operacja=PBN_Export_Queue.Operacja.WYSYLKA, + wysylke_zakonczono=None, + ) + + sent_data = MagicMock() + with patch.object( + 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() + # ścieżka WYSYLKA przekazuje uczelnię wpisu (multi-hosted) + assert mock_send.call_args.kwargs["uczelnia"] == uczelnia + mock_klient.assert_not_called() + ``` + Uwagi do patchowania (zweryfikowane w kodzie): + - `sprobuj_wyslac_do_pbn_celery` jest importowane **wewnątrz** + `send_to_pbn()` (`models.py:474`), więc patchujemy je w module + źródłowym `bpp.admin.helpers.pbn_api.cli` — NIE w `pbn_export_queue.models`. + - **Nie** patchuj `admin_user.get_pbn_user` przez `patch.object(admin_user, …)`: + `send_to_pbn()` robi `refresh_from_db()` i sięga po `self.zamowil`, czyli + po INNĄ instancję użytkownika. Bez patcha `get_pbn_user()` po prostu + zwraca `self` (`profile.py:207`) i nic nie woła po sieci. + - `_zajmij_atomowo()` używa `select_for_update` — test musi być + `@pytest.mark.django_db` (transakcja pytest-django wystarcza). +- [ ] **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** (jawne ścieżki): `git commit -m "test(pbn_export_queue): guard regresyjny — WYSYLKA nie woła delete_all_statements"` + +--- + +### Task 05.4a — Konto techniczne dla `zamowil` + rozróżnienie `IntegrityError` + +> **Dodane 2026-08-07. To NIE jest kosmetyka — to cichy błąd czekający na +> uruchomienie fazy.** Spec §4.2 przypisywał konto techniczne do „fazy +> 05/06", plan 05 pisał „rozwiązuje faza 06/07", plan 06 pisał „to dług fazy +> 05" — czyli **nikt tego nie robił**. Przypinamy jawnie do **fazy 05**, +> bo to ta faza dostarcza `zakolejkuj_*`. + +**Na czym polega cichy błąd (zweryfikowany w kodzie):** +`PBN_Export_Queue.zamowil` jest NOT NULL (`models.py:101`), a +`sprobuj_utowrzyc_wpis` łapie **każdy** `IntegrityError` i przemianowuje go +na `AlreadyEnqueuedError` (`models.py:53-63`). Systemowy soft-delete +publikacji z `pbn_uid` (sygnał, celery, merge — bez `request.user`) trafi +więc w naruszenie NOT NULL na `zamowil`, dostanie „już w kolejce", +`zakolejkuj_wycofanie` zwróci `None`, `SoftDeleteLog.pbn_status` zostanie +pusty — a **oświadczenia w PBN nigdy nie zostaną wycofane**. Operator +zobaczy komunikat sugerujący, że wszystko jest w porządku. + +**Files:** +- `src/pbn_export_queue/operacje.py` (NOWY — wspólny z Taskiem 05.5: + `pobierz_konto_techniczne()`) +- `src/pbn_export_queue/models.py` (`sprobuj_utowrzyc_wpis`: rozróżnienie + wyjątku) +- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` + +- [ ] **Failing test — prawdziwy `IntegrityError` NIE udaje „już w kolejce".** + ```python + @pytest.mark.django_db + def test_integrity_error_zamowil_nie_udaje_already_enqueued( + wydawnictwo_ciagle, + ): + from django.db import IntegrityError + + # zamowil = NULL łamie NOT NULL, a NIE unikat aktywnego wpisu — + # to MUSI polecieć w górę jako IntegrityError, inaczej systemowe + # wycofanie z PBN cicho zniknie pod komunikatem "już w kolejce". + with pytest.raises(IntegrityError): + PBN_Export_Queue.objects.sprobuj_utowrzyc_wpis( + None, wydawnictwo_ciagle + ) + ``` +- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_integrity_error_zamowil_nie_udaje_already_enqueued -x` + → FAIL: podnosi się `AlreadyEnqueuedError` zamiast `IntegrityError`. +- [ ] **Implementacja — rozróżnienie w `sprobuj_utowrzyc_wpis`.** W + `src/pbn_export_queue/models.py`, przy managerze: + ```python + #: Nazwa częściowego unikatu z Meta.constraints — jedyny IntegrityError, + #: który wolno przetłumaczyć na domenowe „już w kolejce". + NAZWA_UNIKATU_AKTYWNEGO_WPISU = "pbn_export_queue_jeden_aktywny_wpis_na_rekord" + + + def _to_kolizja_aktywnego_wpisu(exc): + """Czy ten IntegrityError NAPRAWDĘ znaczy „już w kolejce"? + + psycopg wystawia nazwę naruszonego ograniczenia w + ``exc.__cause__.diag.constraint_name``; gdy jej nie ma (inny + sterownik/backend) — fallback na tekst wyjątku. Tłumaczenie „w + ciemno" połykało naruszenie NOT NULL na ``zamowil`` (operacja + systemowa bez usera) i zamieniało brak wycofania oświadczeń w PBN + w niewinny komunikat „już w kolejce". + """ + diag = getattr(getattr(exc, "__cause__", None), "diag", None) + nazwa = getattr(diag, "constraint_name", None) + if nazwa: + return nazwa == NAZWA_UNIKATU_AKTYWNEGO_WPISU + return NAZWA_UNIKATU_AKTYWNEGO_WPISU in str(exc) + ``` + a w samym `except IntegrityError as e:` (`models.py:60-63`): + ```python + except IntegrityError as e: + if not _to_kolizja_aktywnego_wpisu(e): + raise + raise AlreadyEnqueuedError( + "ten rekord jest już w kolejce do wysyłki" + ) from e + ``` +- [ ] **Regresja istniejącego zachowania:** + `uv run pytest src/pbn_export_queue/tests/test_pbn_queue_atomicity.py src/pbn_export_queue/tests/test_pbn_queue_manager.py -x` + → PASS. Wyścig na aktywnym wpisie MUSI nadal dawać `AlreadyEnqueuedError` + (`test_sprobuj_utowrzyc_wpis_wyscig_daje_alreadyenqueued`). +- [ ] **Failing test — konto techniczne dla operacji bez usera.** + ```python + @pytest.mark.django_db + def test_zakolejkuj_wycofanie_bez_usera_uzywa_konta_technicznego( + wydawnictwo_ciagle, uczelnia + ): + from pbn_api.models import Publication + from pbn_export_queue.operacje import ( + NAZWA_KONTA_TECHNICZNEGO, + zakolejkuj_wycofanie, + ) + + wydawnictwo_ciagle.pbn_uid = baker.make(Publication) + wydawnictwo_ciagle.save() + + with patch("pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn"): + wpis = zakolejkuj_wycofanie( + wydawnictwo_ciagle, user=None, uczelnia=uczelnia + ) + + assert wpis is not None, ( + "systemowy soft-delete MUSI utworzyć wpis wycofania — " + "zwrot None znaczy, że oświadczenia zostaną w PBN" + ) + assert wpis.zamowil.username == NAZWA_KONTA_TECHNICZNEGO + assert wpis.operacja == PBN_Export_Queue.Operacja.WYCOFANIE + ``` +- [ ] **Implementacja — `pobierz_konto_techniczne()`** w + `src/pbn_export_queue/operacje.py`: + ```python + #: Login konta używanego jako ``zamowil`` przy operacjach systemowych. + NAZWA_KONTA_TECHNICZNEGO = "bpp-system" + + + def pobierz_konto_techniczne(): + """Konto ``zamowil`` dla operacji bez zalogowanego użytkownika. + + ``PBN_Export_Queue.zamowil`` jest NOT NULL, a soft-delete zlecony + sygnałem/celery nie ma requestu. Świadomie NIE robimy ``zamowil`` + nullable (spec §4.2) — psułoby to założenia kolejki i raportów. + + Konto jest nieaktywne i bez hasła: ma istnieć jako podmiot audytu, + nie jako sposób logowania. + """ + from django.contrib.auth import get_user_model + + user, utworzono = get_user_model().objects.get_or_create( + username=NAZWA_KONTA_TECHNICZNEGO, + defaults={ + "first_name": "Konto", + "last_name": "systemowe BPP", + "is_active": False, + "is_staff": False, + "is_superuser": False, + }, + ) + if utworzono: + user.set_unusable_password() + user.save(update_fields=["password"]) + return user + ``` +- [ ] ⚠️ **Krok obowiązkowy — token PBN konta technicznego.** Konto + techniczne nie ma `pbn_token` (`BppUser.pbn_token` default `""`), więc + `_pozyskaj_klienta_pbn()` → `uczelnia.pbn_client("")` → + `UczelniaTransport.authorize` rzuci `WillNotExportError` + (`src/bpp/models/uczelnia.py:936+`). **To jest akceptowalne tylko dlatego, + że kończy się GŁOŚNO** (`_handle_pbn_exception` → `error(..., + MERYTORYCZNY)`), a nie cicho. Wymagane: + - dopisz test: wpis WYCOFANIE zamówiony przez konto techniczne **bez** + tokenu kończy się `SendStatus.FINISHED_ERROR` z komunikatem wskazującym + konfigurację (NIE `FINISHED_OKAY`, NIE cichym `None`); + - udokumentuj obejście produkcyjne: administrator ustawia kontu + technicznemu `przedstawiaj_w_pbn_jako` na konto z ważnym tokenem PBN + (`get_pbn_user()` — `profile.py:207` — sam podmieni użytkownika, żaden + kod się nie zmienia). +- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -k "konta_technicznego or integrity_error" -x` → PASS. +- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py src/pbn_export_queue/operacje.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/operacje.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit** (jawne ścieżki): `git commit -m "fix(pbn_export_queue): konto techniczne dla zamowil + IntegrityError nie udaje AlreadyEnqueued"` +- [ ] **Newsfragment:** `src/bpp/newsfragments/soft-delete-konto-techniczne.bugfix.rst` + — „Systemowe (bez zalogowanego użytkownika) usunięcie publikacji nie + powodowało już cichego pominięcia wycofania oświadczeń z PBN." + +--- + +### Task 05.5 — Funkcje zakolejkowujące + gate na `pbn_uid` + +> ⚠️ **Kontrakt PRZYPIĘTY 2026-08-07 — jedno miejsce, jedna sygnatura.** +> Poprzednia wersja tego tasku dostarczała **metody managera** +> (`PBN_Export_Queue.objects.zakolejkuj_wycofanie(...)`), podczas gdy faza 06 +> sprawdza istnienie **funkcji modułowych** (`uv run python -c "from +> pbn_export_queue.operacje import zakolejkuj_wycofanie, zakolejkuj_wysylke"`, +> plan 06 Task 5) i tak je woła w receiverach. Check fazy 06 nigdy by nie +> przeszedł → powstałby shim → dwie rozbieżne implementacje. +> **Wybór: funkcje modułowe w `src/pbn_export_queue/operacje.py`** (wersja +> fazy 06 — plan 06 NIE wymaga zmian). Manager dostaje tylko rozszerzenie +> `sprobuj_utowrzyc_wpis(..., operacja=...)`; metod `zakolejkuj_*` na +> managerze **nie dodajemy** (jedno miejsce prawdy). +> +> Konsekwencja dla fazy 06: jej **Task 5 (shim) należy POMINĄĆ** — ten task +> go zastępuje. Shim z planu 06 tworzy wpisy przez gołe +> `PBN_Export_Queue.objects.create(...)`, więc omija `sprobuj_utowrzyc_wpis` +> (TOCTOU, `uczelnia`, `operacja`) — gdyby powstał wcześniej, MUSI zniknąć +> przy scalaniu, nie zostać obok. + +`zakolejkuj_wycofanie(instance, user=None, uczelnia=None)` i +`zakolejkuj_wysylke(instance, user=None, uczelnia=None)` — publiczne funkcje +modułowe, wołane potem z fazy 06 (receivery sygnałów). Tworzą wpis przez +`sprobuj_utowrzyc_wpis` (z odpowiednią `operacja`) i delegują do +`task_sprobuj_wyslac_do_pbn.delay(pk)`. Gate: wycofanie tylko gdy +`instance.pbn_uid_id` ustawione (brak PBN UID → no-op, brak wpisu). +Idempotencja: jeśli rekord już w kolejce (`AlreadyEnqueuedError`) → `None`. +`user=None` → konto techniczne (Task 05.4a), **nigdy** `zamowil=None`. +Trzeci kwarg `uczelnia` jest opcjonalny i domyślnie `None`, więc wywołania +z fazy 06 (`zakolejkuj_wycofanie(instance, user)`) pozostają poprawne. + +**Files:** +- `src/pbn_export_queue/operacje.py` (funkcje `zakolejkuj_wycofanie`, + `zakolejkuj_wysylke` — ten sam plik co `pobierz_konto_techniczne` + z Taska 05.4a) +- `src/pbn_export_queue/models.py` (rozszerz `sprobuj_utowrzyc_wpis` + o argument `operacja` — **bez** przepisywania metody) +- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` + +- [ ] **Failing test — gate + utworzenie wpisu WYCOFANIE.** Dopisz + (patch celuje w `pbn_export_queue.tasks…`, bo tam realnie żyje symbol; + `operacje.py` importuje go lokalnie w funkcji, żeby nie robić cyklu — + `tasks.py` importuje `models`): + ```python + @pytest.mark.django_db + def test_zakolejkuj_wycofanie_gate_brak_pbn_uid(wydawnictwo_ciagle, admin_user): + from pbn_export_queue.operacje import zakolejkuj_wycofanie + + assert wydawnictwo_ciagle.pbn_uid_id is None + with patch( + "pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn" + ) as mock_task: + wpis = 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, uczelnia + ): + from pbn_api.models import Publication + from pbn_export_queue.operacje import zakolejkuj_wycofanie + + pub = baker.make(Publication) + wydawnictwo_ciagle.pbn_uid = pub + wydawnictwo_ciagle.save() + + with patch( + "pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn" + ) as mock_task: + wpis = zakolejkuj_wycofanie( + wydawnictwo_ciagle, user=admin_user, uczelnia=uczelnia + ) + + assert wpis is not None + assert wpis.operacja == PBN_Export_Queue.Operacja.WYCOFANIE + assert wpis.uczelnia == uczelnia + mock_task.delay.assert_called_once_with(wpis.pk) + + + @pytest.mark.django_db + def test_zakolejkuj_wysylke_tworzy_wpis(wydawnictwo_ciagle, admin_user): + from pbn_export_queue.operacje import zakolejkuj_wysylke + + with patch( + "pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn" + ) as mock_task: + wpis = 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 + from pbn_export_queue.operacje import zakolejkuj_wycofanie + + wydawnictwo_ciagle.pbn_uid = baker.make(Publication) + wydawnictwo_ciagle.save() + with patch("pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn"): + first = zakolejkuj_wycofanie(wydawnictwo_ciagle, user=admin_user) + second = 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 + ``` + ⚠️ **Gate `pbn_uid` w `zakolejkuj_wysylke`:** faza 06 pisze w kontrakcie + PINNED „None gdy brak pbn_uid" **dla obu** funkcji, ale restore rekordu, + który nigdy nie poszedł do PBN, i tak powinien go wysłać (wysyłka sama + nadaje `pbn_uid`). **Rozstrzygnięcie: `zakolejkuj_wysylke` NIE ma gate'u + na `pbn_uid`** — gate ma tylko wycofanie. Docstring w kodzie musi to + mówić wprost, żeby nikt nie „naprawił" tego pod docstring z planu 06. +- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -k zakolejkuj -x` → `ModuleNotFoundError`/`ImportError` (`pbn_export_queue.operacje`). +- [ ] **Implementacja — rozszerz `sprobuj_utowrzyc_wpis` o `operacja`.** + ⚠️ **Minimalna zmiana istniejącej metody (`models.py:42-63`) — NIE + przepisywać jej.** Zachować MUSZĄ: parametr `uczelnia`, savepoint + `transaction.atomic()` i tłumaczenie kolizji unikatu (z rozróżnieniem + z Taska 05.4a). Dopisujemy wyłącznie `operacja`: + ```python + def sprobuj_utowrzyc_wpis(self, user, rekord, uczelnia=None, operacja=None): + # Szybka ścieżka (przyjazny błąd bez trafiania w constraint bazy). + 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, + "uczelnia": uczelnia, + } + if operacja is not None: + kwargs["operacja"] = operacja + + # (komentarz o TOCTOU — zostawić bez zmian) + try: + with transaction.atomic(): + return self.create(**kwargs) + except IntegrityError as e: + if not _to_kolizja_aktywnego_wpisu(e): # Task 05.4a + raise + raise AlreadyEnqueuedError( + "ten rekord jest już w kolejce do wysyłki" + ) from e + ``` + (`operacja` opcjonalna → wszystkie dotychczasowe wywołania — + `bpp/admin/helpers/pbn_api/gui.py:92`, `pbn_export_queue/tasks.py:165`, + `przemapuj_zrodlo/views.py:159`, `przemapuj_zrodla_pbn/views.py:509` — + działają bez zmian i dostają default modelu `WYSYLKA`.) +- [ ] **Implementacja — `zakolejkuj_wycofanie` / `zakolejkuj_wysylke`** + w `src/pbn_export_queue/operacje.py` (funkcje MODUŁOWE — kontrakt fazy 06): + ```python + def _zakolejkuj(instance, operacja, user=None, uczelnia=None): + from pbn_api.exceptions import AlreadyEnqueuedError + + from pbn_export_queue import tasks + from pbn_export_queue.models import PBN_Export_Queue + + try: + wpis = PBN_Export_Queue.objects.sprobuj_utowrzyc_wpis( + user or pobierz_konto_techniczne(), + instance, + uczelnia=uczelnia, + operacja=operacja, + ) + except AlreadyEnqueuedError: + # Rekord czeka już w kolejce — idempotencja, nie błąd. + return None + tasks.task_sprobuj_wyslac_do_pbn.delay(wpis.pk) + return wpis + + + def zakolejkuj_wysylke(instance, user=None, uczelnia=None): + """Tworzy wpis WYSYLKA i uruchamia wysyłkę w tle. + + Wołane m.in. przy restore publikacji (faza 06). BEZ gate'u na + pbn_uid — rekord bez PBN UID też ma prawo pojechać do PBN (wysyłka + dopiero go nada). Idempotentne: gdy rekord już w kolejce → None. + user=None → konto techniczne (zamowil jest NOT NULL). + """ + return _zakolejkuj( + instance, + PBN_Export_Queue.Operacja.WYSYLKA, + user=user, + uczelnia=uczelnia, + ) + + + def zakolejkuj_wycofanie(instance, user=None, uczelnia=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 → None. + user=None → konto techniczne (zamowil jest NOT NULL); NIGDY nie + przekazujemy None do zamowil, bo IntegrityError udawałby wtedy + „już w kolejce" i wycofanie zniknęłoby po cichu. + """ + if not getattr(instance, "pbn_uid_id", None): + return None + return _zakolejkuj( + instance, + PBN_Export_Queue.Operacja.WYCOFANIE, + user=user, + uczelnia=uczelnia, + ) + ``` + (Import `PBN_Export_Queue` na poziomie modułu jest OK — `operacje.py` + nie jest importowane przez `models.py`; cykl groziłby tylko przy + `tasks`, dlatego `tasks` importujemy w środku funkcji.) +- [ ] **Check zgodności z fazą 06:** + `uv run python -c "from pbn_export_queue.operacje import zakolejkuj_wycofanie, zakolejkuj_wysylke"` + → bez błędu (to dokładnie ten check, który wykonuje Task 5 planu 06, + żeby pominąć swój shim). +- [ ] **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/operacje.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/operacje.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit** (jawne ścieżki): `git commit -m "feat(pbn_export_queue): zakolejkuj_wycofanie/zakolejkuj_wysylke + gate pbn_uid"` + +--- + +### Task 05.6 — Idempotencja/retry wycofania (ResourceLocked + CannotDelete) + +> Oba testy sprawdzają zachowanie, które po przepisaniu 05.3 pochodzi z +> **dwóch różnych warstw**: `CannotDeleteStatementsException` obsługuje +> prymityw (05.0, sukces `BRAK_OSWIADCZEN`), a `ResourceLockedException` +> klasyfikuje wspólne `_handle_pbn_exception` (`models.py:299`). Gdy któryś +> padnie — popraw TĘ warstwę, nie dokładaj drugiej drabinki `except` +> w `withdraw_from_pbn`. + +**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, uczelnia + ): + 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, + uczelnia=uczelnia, + ) + wpis = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + uczelnia=uczelnia, + 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, uczelnia + ).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ść (prymityw z 05.0 zwraca `BRAK_OSWIADCZEN` = sukces). Jeśli FAIL — popraw **prymityw**, nie `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, uczelnia + ): + 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, + uczelnia=uczelnia, + ) + wpis = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + uczelnia=uczelnia, + 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, uczelnia + ).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 (prymityw propaguje `ResourceLockedException`, a `_handle_pbn_exception` → `_handle_retry_exception` (`models.py:299`) zwraca `RETRY_LATER` bez ustawiania `wysylke_zakonczono`). Jeśli FAIL — sprawdź, czy prymityw nie połyka wyjątku. +- [ ] **Test dopełniający — `PraceSerwisoweException` → `RETRY_MUCH_LATER`.** + Ta sama konstrukcja; dowodzi, że wycofanie dziedziczy CAŁĄ tabelę + klasyfikacji z wysyłki, a nie tylko dwa przypadki. +- [ ] **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** (jawne ścieżki): `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` (`:55`, np. zaraz po `"rekord_do_wysylki"`), do `list_filter` (`:68` — tam pierwszy element to `ZamowilUniqueFilter`, więc `"operacja"` dopisz PO nim) i do `readonly_fields` (`:76`). +- [ ] **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** (jawne ścieżki): `git commit -m "feat(pbn_export_queue): admin pokazuje kolumnę/filtr operacja"` + +--- + +### 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?). Stan na 2026-08-07 + (`grep`): `synchronizuj_publikacje` definiowana w + `src/pbn_integrator/utils/synchronization.py:180`, wołana z + `src/pbn_integrator/management/commands/pbn_uploader.py:12` i + `src/pbn_integrator/management/commands/pbn_integrator.py:392` (oba to + komendy CLI) — zweryfikuj, czy nie doszło nic nowego. +- [ ] **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 + ``` + Baseline (2026-08-07, przed fazą 05) — dokładnie te 3 wiersze produkcyjne: + `pbn_api/management/commands/pbn_test_wysylka_interaktywna.py:453`, + `pbn_api/management/commands/pbn_wyslij_oswiadczenia_instytucji.py:192`, + `pbn_wysylka_oswiadczen/tasks.py:68`. **Po fazie 05 mogą dojść wyłącznie + wystąpienia w prymitywie** — żadne w `pbn_export_queue/` ani + `pbn_integrator/`. (`pbn_client` jest w site-packages, więc się tu nie + pokaże — to poprawne.) + +--- + +### 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. +- [ ] **Testy prymitywu:** `uv run pytest src/pbn_api/tests/test_wycofanie.py -x` → 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; szczególnie `test_pbn_queue_atomicity.py` i `test_pbn_queue_manager.py` po zmianie `sprobuj_utowrzyc_wpis`). +- [ ] **Guardy multi-hosted (obowiązkowo — ta faza dotyka klienta PBN i `SentData`):** + `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py src/pbn_api/tests/test_multihosted.py -q` → zielono. +- [ ] **Baseline po migracjach (`0011` w kolejce + `0080` w pbn_api):** + `make baseline-update`, commit `baseline-sql/baseline.sql` + + `baseline-sql/baseline.meta.json`. ⚠️ Wg CLAUDE.md: **nie** odświeżaj + baseline w równoległych feature-branchach — jeśli gałąź soft-delete ma + inne otwarte fazy z migracjami, zrób to RAZ przy scalaniu. +- [ ] **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 src/pbn_api/wycofanie.py` → czysto. +- [ ] **Newsfragment fazy:** `src/bpp/newsfragments/soft-delete-pbn-wycofanie.feature.rst` + — „Usunięcie publikacji wysłanej do PBN wycofuje jej oświadczenia dyscyplin + z profilu instytucji; przywrócenie publikacji wysyła je ponownie." +- [ ] **Commit (jeśli cokolwiek dopięte, jawne ścieżki):** `git commit -m "chore(soft-delete): faza 05 PBN wycofanie — weryfikacja końcowa"` + +--- + +## Podsumowanie zakresu (co ta faza dostarcza fazie 06) + +- Prymityw `pbn_api.wycofanie.wycofaj_oswiadczenia(publikacja, client, + uczelnia=None) -> WynikWycofania` — JEDYNE miejsce wołające + `delete_all_publication_statements` w kontekście soft-delete. +- `PBN_Export_Queue.Operacja` (`WYSYLKA`/`WYCOFANIE`) + pole `operacja` (default `WYSYLKA`). +- **`pbn_export_queue.operacje.zakolejkuj_wycofanie(instance, user=None, + uczelnia=None)` i `zakolejkuj_wysylke(instance, user=None, uczelnia=None)` + — FUNKCJE MODUŁOWE** (nie metody managera), publiczny kontrakt dla + receiverów fazy 06 (`post_soft_delete`→wycofanie, `post_restore`→wysyłka). + Gate `pbn_uid_id` **tylko** dla wycofania; obie idempotentne. + ⇒ **Faza 06 pomija swój Task 5 (shim)** — te funkcje już istnieją pod + ścieżką, której szuka jej check. +- `pbn_export_queue.operacje.pobierz_konto_techniczne()` + + `NAZWA_KONTA_TECHNICZNEGO` — `zamowil` dla operacji systemowych + (`user=None`), zamiast cichego `IntegrityError` udającego „już w kolejce". + **To zamyka dług, który spec §4.2 i plany 05/06 przerzucały między sobą.** +- Po udanym wycofaniu: `SentData.withdrawn_at` ustawione, + `submitted_successfully=False`, wiersz NIE skasowany — **na wierszu TEJ + uczelni**. Restore→WYSYLKA→`mark_as_successful` zeruje `withdrawn_at`. + +## Otwarte / do zgłoszenia poza tą fazą + +- **Spec §4.1 i indeks 00 cytują martwą ścieżkę** + `src/pbn_api/client/mixins/institutions.py:87` (katalog zawiera dziś tylko + `__pycache__`; kod jest w pakiecie `pbn-client`, plik + `pbn_client/mixins/institutions.py:87`). Spec §4.2 wskazuje też + `retry w pbn_api/client/publication_sync.py`, gdzie + `_delete_statements_with_retry` już nie mieszka. Poprawka specu/indeksu — + poza zakresem tego planu (inny właściciel plików). +- **Plan 06** nie wymaga zmian sygnatur (przypięliśmy jego wariant), ale jego + **Task 5 (shim `operacje.py`) jest teraz martwy** i jego uwaga + „`zamowil`… **To dług fazy 05**" jest już spełniona przez Task 05.4a — + warto to tam odnotować przy okazji. 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..ed9edcc13 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md @@ -0,0 +1,914 @@ +# 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/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` + +**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 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() + # 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() + + 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:** +- 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 `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. + +--- + +**Ś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 %} +
+ +
{% csrf_token %} + {% for pk in wybrane_pk %} + + {% endfor %} + + + +

+
+ +

+ + + Anuluj +
+{% 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..491bd2d55 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md @@ -0,0 +1,1213 @@ +# 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 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** + +```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 **wprost, surowym SQL-em** (wzorzec: +`src/bpp/tests/test_cache/test_cache_plpgsql_port.py`). + +> ⚠️ **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.contrib.contenttypes.models import ContentType +from django.db import connection + +from bpp.models.cache.punktacja import Cache_Punktacja_Dyscypliny + + +@pytest.mark.django_db +def test_reprojekcja_ze_zrodla_nie_wskrzesza_skasowanej_publikacji( + transactional_db, wydawnictwo_ciagle_z_dwoma_autorami, +): + """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 + # 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 + + 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 + + +@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=[ + ContentType.objects.get_for_model(type(zwarte_z_dyscyplinami)).pk, + 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=[ + ContentType.objects.get_for_model(type(zwarte_z_dyscyplinami)).pk, + 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 "reprojekcja or cache_punktacji" -v` +Expected: PASS. Jeśli `Cache_Punktacja_Dyscypliny` wraca po delete → +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 +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). + +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ę. +""" + +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. diff --git a/docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md b/docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md new file mode 100644 index 000000000..f54f85d55 --- /dev/null +++ b/docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md @@ -0,0 +1,113 @@ +# Zgłoszenie do upstreamu `django-easy-audit` — gotowy tekst + +**Gdzie wkleić:** komentarz do istniejącego +[issue #175](https://github.com/soynatan/django-easy-audit/issues/175) +(otwarte od 2021-02, dokładnie ten problem). Nowego issue NIE zakładamy — +byłby piątym równoległym wątkiem o tej samej sprawie. + +**Dlaczego warto mimo czterech nieudanych prób:** poprzednie zgłoszenia +(#168, #176, #318, #342) nie zawierały informacji, która jest tu kluczowa — +że obejście sugerowane przez maintainera jest **nieosiągalne**. To jedyna +nowa przesłanka, jaką wnosimy do dyskusji. + +**Po scaleniu:** skasować `src/bpp/easyaudit_shim.py` + wywołanie +`zainstaluj()` w `BppConfig.ready()` + `test_easyaudit_shim.py`. Przypomni +o tym test `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`, który +wtedy zacznie padać. + +--- + +## Treść komentarza (EN) + +> ### The documented workaround for this issue is unreachable +> +> We hit this on a Django 5.2 project with soft-deletable models +> (`django-soft-delete`), on `django-easy-audit==1.3.9`. I want to add one +> piece of information that I don't think has been stated in this thread or +> in the related PRs (#168, #176, #318, #342), because I believe it explains +> why the fix keeps stalling. +> +> In #168 the suggestion was to handle this via +> `DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS`, letting the application +> decide whether to create the `CRUDEvent`. +> +> **That workaround cannot work for this bug.** The exception is raised +> *before* the callbacks are ever consulted. In `signals/model_signals.py` +> (1.3.9): +> +> ```python +> # line 102 — raises DoesNotExist +> old_model = sender.objects.get(pk=instance.pk) +> delta = model_delta(old_model, instance) +> ... +> # line 113 — callbacks are only reached here +> create_crud_event = call_callbacks(...) +> ``` +> +> So an application cannot opt out of the failing lookup: by the time it is +> asked, the lookup has already thrown. With +> `DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = True` (which we need, so that +> audit failures are not silently swallowed) the exception then propagates +> and aborts the user's write. +> +> ### Minimal reproduction +> +> ```python +> class Article(SoftDeleteModel): # objects filters deleted_at IS NULL +> title = models.CharField(max_length=100) +> +> # settings.py +> DJANGO_EASY_AUDIT_REGISTERED_CLASSES = ["myapp.Article"] +> DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = True +> +> a = Article.objects.create(title="x") +> a.delete() # soft delete +> Article.global_objects.get(pk=a.pk).restore() +> # -> Article.DoesNotExist: Article matching query does not exist +> ``` +> +> The row exists the whole time; it is merely hidden from `objects`. Any +> `save()` on a currently-hidden row fails the same way — `restore()` is +> just the case that always hits it, because the row is by definition still +> flagged as deleted at `pre_save` time. +> +> ### Suggested fix +> +> One line: +> +> ```diff +> - old_model = sender.objects.get(pk=instance.pk) +> + old_model = sender._base_manager.get(pk=instance.pk) +> ``` +> +> `_base_manager` rather than `_default_manager`, for the reason +> @sgordon16 already gave in this thread: the *default* manager may itself +> filter, so it does not remove the failure mode. `_base_manager` is the +> one Django documents for exactly this purpose — retrieving related/ +> internal objects — and +> [the docs state it must not filter out any results](https://docs.djangoproject.com/en/5.2/topics/db/managers/#django.db.models.Manager.base_manager_name). +> +> Worth noting: when a model does not set `Meta.base_manager_name`, Django +> creates a plain unfiltered `Manager` for `_base_manager` automatically. +> So this change is a no-op for every project that does not deliberately +> override it, and it does not require model authors to configure anything. +> We verified this on our models — `_base_manager` sees soft-deleted rows, +> `objects` does not. +> +> This is a semantic correction, not a workaround: `pre_save` wants "the row +> currently stored under this pk", which is precisely what `_base_manager` +> means, and is not what `objects` means for any project with a filtering +> default manager. +> +> ### Scope +> +> Grepping 1.3.9, this is the **only** place in the package where an audited +> model's manager is used to fetch an instance; every other `.objects` usage +> is on `CRUDEvent`, `LoginEvent`, `ContentType` or `User`. So the change is +> contained. +> +> We are currently carrying this as a local patch that replaces the +> `pre_save` receiver via its `dispatch_uid`. Happy to open a PR (with a +> regression test using a filtering default manager) if that would help move +> this along — just let me know whether you would prefer it on top of #318 +> or as a fresh branch. diff --git a/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md new file mode 100644 index 000000000..15595962b --- /dev/null +++ b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md @@ -0,0 +1,193 @@ +# Faza 02 — inwentaryzacja wycieków ORM po dołączeniu publikacji + +> Wynik rozszerzenia `RELACJE` w kanarku ORM +> (`src/bpp/tests/test_soft_delete/test_kanarek_orm.py`) o pięć modeli +> publikacji, 2026-08-07. Kanarek zgłosił **14 miejsc**; triage niżej. + +--- + +## ⚠️ SELF-REVIEW (2026-08-07, po napisaniu reszty dokumentu) + +**Ten audyt jest niekompletny i w jednym miejscu wprost nieprawdziwy.** +Zostawiam go w całości — poniższe zastrzeżenia są ważniejsze niż tabelka. + +### 1. Premisa dokumentu jest FAŁSZYWA na obecnym stanie gałęzi + +Sekcja niżej twierdzi, że „zapytania startujące OD publikacji są bezpieczne, +bo `objects` to `BppSoftDeleteManager`". Sprawdzone empirycznie: + +| model | `objects` | widzi skasowane? | +|---|---|---| +| `Wydawnictwo_Ciagle` | `Wydawnictwo_Ciagle_Manager` | **TAK** | +| `Wydawnictwo_Zwarte` | `Wydawnictwo_Zwarte_Manager` | **TAK** | +| `Patent` | `BppSoftDeleteManager` | nie | +| `Praca_Doktorska` | `BppSoftDeleteManager` | nie | +| `Praca_Habilitacyjna` | `BppSoftDeleteManager` | nie | + +Dwa najważniejsze modele mają wciąż menedżera z fazy 01 (mixin opłat + +`models.Manager`), bo **Task 4 — przeplecenie menedżerów — nie jest jeszcze +zrobiony**. Premisa stanie się prawdziwa dopiero po nim. Do tego czasu +KAŻDE `Wydawnictwo_Ciagle.objects...` w kodzie zwraca też rekordy z kosza. + +### 2. Lista nazw relacji jest niekompletna → „14 miejsc" to DOLNA GRANICA + +Audyt użył pięciu nazw modeli. Pominął co najmniej: + +- **`rekord`** — nazwa FK z modeli-dzieci do publikacji + (`*_Streszczenie`, `*_Dodatkowy_Tytul`, `*_Zewnetrzna_Baza_Danych`), +- **`wydawnictwo_nadrzedne`** — self-FK rozdział → książka-matka, +- `wydawnictwa_powiazane_set` — relacja odwrotna do powyższej (0 trafień). + +Po dołożeniu tych nazw skan daje **120 znalezisk zamiast 14**. Zdanie +„lista jest gotowa, nikt nie musi jej odtwarzać" było więc nieuprawnione. + +### 3. …ale te 120 to w większości FAŁSZYWE trafienia — i to jest wniosek o NARZĘDZIU + +Nazwa `rekord` jest wieloznaczna i kanarek nie umie tych znaczeń rozróżnić: + +1. `*_Autor.rekord` → publikacja (zakres fazy 01), +2. `*_Streszczenie.rekord` i pokrewne → publikacja (**realny zakres fazy 02**), +3. `Cache_Punktacja_Autora.rekord` → widok `Rekord`, **już przefiltrowany** + migracją 0497 — czyli bezpieczne, +4. `request.GET.get("rekord__id__exact")`, `cleaned_data.get("rekord")` — + parametry HTTP i formularzy, w ogóle nie ORM. + +**Sedno:** kanarek ORM jest matcherem NAZW, a nie modeli. W fazie 01 działał +świetnie, bo nazwy były dystynktywne (`autorzy_set`, `wydawnictwo_ciagle_autor` +— nic innego się tak nie nazywa). W fazie 02 nazwy to zwykłe słowa (`patent`, +`rekord`, `wydawnictwo_ciagle`), więc precyzja narzędzia się załamuje. To nie +jest usterka do załatania listą wyjątków — to granica metody. + +Uczciwe narzędzie dla fazy 02 musiałoby rozwiązywać ścieżkę lookupu wobec +metadanych Django (`_meta`) i pytać, czy faktycznie dochodzi do tabeli objętej +soft-delete — czyli działać tak, jak kanarek KATALOGOWY działa na `pg_depend`. +To jest osobne narzędzie, nie parametr istniejącego. + +### 4. Błąd w tabeli: pozycja #1 ma odwróconą wymowę + +Wpis dla `usun_zrodla_bez_publikacji` jest zatytułowany **„KASUJE ŹRÓDŁA"**, +co sugeruje utratę danych. Faktyczny kierunek jest odwrotny i łagodny: +`filter(wydawnictwo_ciagle__isnull=True)` **nie znajdzie** źródła, którego +publikacje są w koszu, więc takie źródło NIE zostanie skasowane. Skutek to +zalegające śmieci, nie utrata danych. + +### 5. DOPISANE PÓŹNIEJ: klasa wycieku, której kanarek NIE MOŻE złapać + +Wyszła przy weryfikacji fazy (Task 6), z padającego testu +`test_rok_habilitacji_view` — nie z kanarka. + +**Trawersowanie relacji przez ATRYBUT omija soft-delete.** Widok robił: + +```python +habilitacja = autor.praca_habilitacyjna # odwrotne OneToOne +``` + +Django rozwiązuje to przez `ReverseOneToOneDescriptor`, który pyta +`_base_manager` — z definicji nieprzefiltrowany. Skasowana habilitacja jest +więc tą ścieżką **nadal osiągalna**, mimo że `Praca_Habilitacyjna.objects` +jej nie pokazuje. Widok zwracał 200 zamiast 404. + +To odwrotna strona faktu, który przy okazji easyauditu wyglądał na dobrą +wiadomość: nieprzefiltrowany `_base_manager` ratuje audyt i psuje +trawersowanie relacji. + +⚠️ **Kanarek ORM nie ma szans tego wykryć.** Skanuje ARGUMENTY wywołań ORM +(`filter`, `annotate`, `Count`…), a tutaj nie ma żadnego wywołania — jest +dostęp do atrybutu. Żadne rozszerzanie listy `RELACJE` tego nie zmieni; to +inna oś problemu niż ta, którą kanarek pokrywa. + +Nie da się tego naprawić centralnie: `Meta.base_manager_name` wskazujący +menedżer filtrujący jest przez Django jawnie odradzany (rozwaliłby m.in. +deserializację i `refresh_from_db`). Zostaje sprawdzanie `deleted_at` +w miejscach użycia — tak zrobiono w `RokHabilitacjiView`. + +**Dla fazy 03:** potrzebny osobny przegląd dostępów atrybutowych do relacji +O2O/FK celujących w modele soft-delete. Kandydaci to `autor.praca_habilitacyjna` +i każde `*.wydawnictwo_nadrzedne`. Narzędziem nie może być obecny kanarek. + +### Co z tego wynika dla decyzji o fazie 03 + +Sam podział (faza 02 = warstwa bazodanowa, faza 03 = wywołania ORM) uważam +nadal za słuszny, a `xfail(strict=True)` spełnia swoją rolę. Ale faza 03 NIE +powinna traktować tabelki niżej jako gotowej listy zadań — powinna zacząć od +zbudowania narzędzia model-aware, bo inaczej utonie w fałszywych trafieniach. + +--- + +## Dlaczego to nie jest to samo ryzyko, co w fazie 01 + +Dla publikacji zagrożenie jest skierowane **odwrotnie** niż dla autorstw. +Zapytania startujące OD publikacji są bezpieczne — `Wydawnictwo_Ciagle.objects` +to `BppSoftDeleteManager`, który sam dokłada `deleted_at__isnull=True`. +Przecieka dołączenie **DO** publikacji od strony słownika lub relacji +(`Zrodlo`, `Charakter_Formalny`, `Wydawca`…), bo tam żaden nasz manager się +nie włącza — liczy się surowa tabela. + +## Prawdziwe wycieki (10) + +Wszystkie to `Count()`/`filter()` po odwrotnej relacji do publikacji, bez +predykatu `deleted_at`. + +| # | Miejsce | Co robi | Skutek wycieku | +|---|---|---|---| +| 1 | `bpp/management/commands/usun_zrodla_bez_publikacji.py:23` | `Zrodlo.objects.filter(wydawnictwo_ciagle__isnull=True)` | **KASUJE ŹRÓDŁA.** Źródło, którego wszystkie publikacje są w koszu, nie wygląda na puste → NIE zostanie skasowane. Odwrotnie niż groźnie, ale wynik i tak niezgodny z intencją | +| 2 | `bpp/admin/zrodlo.py:188` | to samo, akcja adminowa „usuń źródła bez prac" | jw. — decyzja o kasowaniu na podstawie zawyżonego licznika | +| 3 | `bpp/admin/zrodlo.py:217` | `Count("wydawnictwo_ciagle", distinct=True)` — kolumna „liczba prac" | operator widzi zawyżoną liczbę | +| 4 | `bpp/admin/filters.py:270` | `Count("wydawnictwo_ciagle")` — filtr „ma prace / nie ma prac" | źródło z samymi skasowanymi pracami trafia do „ma prace" | +| 5 | `admin_dashboard/views/charakter_stats.py:68` | `Count("wydawnictwo_ciagle")` + `Count("wydawnictwo_zwarte")` | statystyki charakterów zawyżone | +| 6 | `deduplikator_zrodel/operations.py:65` | `Count("wydawnictwo_ciagle")` — dobór kandydatów | do deduplikacji wchodzą źródła bez żywych prac | +| 7 | `deduplikator_zrodel/utils.py:72` | jw. | jw. | +| 8 | `deduplikator_zrodel/views.py:83` | `Count("main_zrodlo__wydawnictwo_ciagle")` do pól `_main_live_pub` / `_dup_live_pub` | **nazwa pola mówi `live`, a liczy też skasowane** | +| 9 | `komparator_pbn/views.py:163` | `Count("wydawnictwo_ciagle")` przy źródłach `DELETED` w PBN | zawyżony raport | +| 10 | `przemapuj_zrodla_pbn/views.py:409` | `Count("wydawnictwo_ciagle")` → `liczba_rekordow` | jw. | + +Wzorzec naprawy jest jednolity i znany z fazy 01: + +```python +Count("wydawnictwo_ciagle", filter=Q(wydawnictwo_ciagle__deleted_at__isnull=True)) +``` + +⚠️ Dla wariantu `filter(wydawnictwo_ciagle__isnull=True)` (#1, #2) `Count(...) +FILTER` nie wystarcza — trzeba przejść na `annotate(...)` + `filter(licznik=0)`, +inaczej warunek na odwrotnej relacji zawęzi zbiór źródeł zamiast policzyć zero. + +## Fałszywe trafienia (4) — do wpisu w `DOZWOLONE` albo do zawężenia matchera + +Powstały dlatego, że nazwy relacji fazy 02 są **zwykłymi słowami** (`patent`, +`wydawnictwo_ciagle`), a nie dystynktywnymi jak `autorzy_set` czy +`wydawnictwo_ciagle_autor` z fazy 01. + +| Miejsce | Dlaczego fałszywe | +|---|---| +| `django_bpp/sitemaps.py:110` | pętla po liście etykiet URL-i; literał `"patent"` trafia w gałąź „pętla po nazwach relacji" | +| `ewaluacja_optymalizacja/utils.py:183` | `dict(wydawnictwo_ciagle=isinstance(...), …)` — słownik flag, nie lookup ORM (`dict` jest w `WYWOLANIA_ORM`) | +| `pbn_api/…/pbn_test_wysylka_interaktywna.py:122` | `options.get("wydawnictwo_zwarte")` — opcja CLI (`get` jest w `WYWOLANIA_ORM`) | +| `pbn_api/…/pbn_test_wysylka_interaktywna.py:123` | jw., `wydawnictwo_ciagle` | + +## Rozstrzygnięcie zakresu + +Plan fazy 02 obejmuje warstwę **bazodanową** (modele, widoki, triggery, +ograniczenia, menedżery). Audyt wywołań ORM w imporcie/dedup/PBN jest w +podsumowaniu planu przypisany **fazie 03** („audyt `global_objects` +w imporcie/dedup/PBN → faza 03”), a handoff §7 wymienia dla fazy 03 dług +dotyczący `deduplikator_autorow`. + +Rozwiązanie: kanarek ORM ma teraz **dwa** testy zamiast jednego. + +| Test | Relacje | Stan | +|---|---|---| +| `test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at` | faza 01 (`autorzy_set`, `*_autor`) | ZIELONY — ochrona nienaruszona | +| `test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at` | faza 02 (5 modeli publikacji) | `xfail(strict=True)` — dług fazy 03 | + +Podział jest istotny: wrzucenie wszystkiego do jednego `xfail`-a wyłączyłoby +także ochronę wywalczoną w fazie 01. + +`strict=True` jest tu mechanizmem wymuszającym, nie ozdobą: gdy faza 03 +naprawi te miejsca, test zacznie padać jako XPASS i zmusi do zdjęcia +markera. Bez `strict` naprawa przeszłaby niezauważona, a kanarek zostałby +wyłączony na zawsze — dokładnie ta pułapka („pusty wynik ≠ potwierdzenie”) +jest opisana w handoffie fazy 01. Ten sam wzorzec faza 01 zastosowała wobec +fazy 02 w `test_cache/test_soft_delete_preconditions.py`. + +Kanarek **katalogowy** (widoki) został rozszerzony i jest w pełni ZIELONY — +to zakres fazy 02 i został domknięty. diff --git a/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md new file mode 100644 index 000000000..4e2d6906f --- /dev/null +++ b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md @@ -0,0 +1,97 @@ +# Faza 02 — inwentaryzacja widoków czytających tabele publikacji + +> Wykonana **2026-08-07, na starcie fazy 02**, zanim powstała jakakolwiek +> migracja — dokładnie tak, jak nakazuje handoff (§2) i ostrzeżenie (a) +> w planie fazy 02. Faza 01 odkrywała takich winowajców pojedynczo, przez +> awarie; ten dokument jest wynikiem jednego przebiegu, trwającego 19 sekund. + +## Metoda + +Kanarek katalogowy fazy 01 (`src/bpp/tests/test_soft_delete/ +test_kanarek_katalogowy.py`) eksportuje `znajdz_winowajcow(cur, tabele=...)` +przyjmujące **dowolną** listę tabel. Dzięki temu dało się uzyskać pełną listę +winowajców fazy 02 **bez** modyfikowania stałej `TABELE_SOFT_DELETE`, czyli bez +commitowania czerwonego testu na starcie gałęzi. + +Wywołanie: `TABELE_SOFT_DELETE + _TABELE_PUBLIKACJI_FAZA_02` (8 tabel) na +żywym katalogu Postgresa (testcontainer: baseline + wszystkie migracje). + +**Źródłem prawdy jest `pg_depend` na poziomie kolumny, nie tekst SQL-a.** To +istotne: analiza tekstowa `baseline.sql` przemilczałaby część widoków (baseline +jest snapshotem sprzed części migracji), a matcher substringowy dawał fałszywą +zieleń tam, gdzie widok dziedziczył słowo `deleted_at` z JOIN-a po *innej* +tabeli. + +## Wynik: 15 par (widok, tabela), 15 unikalnych widoków + +### Kategoria A — widoki rdzenia rekordu (5) → Task 2b + +Bez filtra publikacja nie zniknie z `bpp_rekord_mat`, czyli z całego serwisu. + +- `bpp_wydawnictwo_ciagle_view` +- `bpp_wydawnictwo_zwarte_view` +- `bpp_patent_view` +- `bpp_praca_doktorska_view` +- `bpp_praca_habilitacyjna_view` + +### Kategoria B — widoki autorstw bez through-modelu (2) → Task 2b + +Autor leży na wierszu publikacji, więc filtr jest tu „po własnej kolumnie". + +- `bpp_praca_doktorska_autorzy` +- `bpp_praca_habilitacyjna_autorzy` + +### Kategoria C — sumy (5) → **Task 2d (NOWY — brak w tabeli zadań planu)** + +Plan miał dla nich wyłącznie ostrzeżenie (c) „sprawdź, czy wymagają poprawki". +Sprawdzone: **wymagają, wszystkie pięć.** Faza 01 poprawiła w tych widokach +wyłącznie wymiar *autora* (migracja `0495`); wymiar *publikacji* nadal +przecieka. + +- `bpp_nowe_sumy_wydawnictwo_ciagle_view` +- `bpp_nowe_sumy_wydawnictwo_zwarte_view` +- `bpp_nowe_sumy_patent_view` +- `bpp_nowe_sumy_praca_doktorska_view` +- `bpp_nowe_sumy_praca_habilitacyjna_view` + +⚠️ To są widoki **agregujące** — obowiązuje w nich pułapka z handoffu §3.2: +warunek `deleted_at IS NULL` w `WHERE`/`ON` degeneruje `LEFT JOIN` do `INNER +JOIN`. Właściwy wzorzec to `agregat(...) FILTER (WHERE ... deleted_at IS +NULL)`. Sprawdzić **każdy** agregat, nie tylko `count` (`sum`, `min`, `max`, +`array_agg`, `string_agg`, `bool_*`). + +### Kategoria D — rozbieżności dyscyplin (1) → **Task 2d** + +- `rozbieznosci_dyscyplin_rozbieznoscizrodelview` + +Analogicznie do C: faza 01 poprawiła wymiar autora (`rozbieznosci_dyscyplin/ +0022`), wymiar publikacji został. + +### Kategoria E — kronika (2) → zadanie „Sprzątanie `bpp_kronika_*`" + +- `bpp_kronika_praca_doktorska_view` +- `bpp_kronika_praca_habilitacyjna_view` + +Dokładnie te dwa, których żywotności — jak zapowiadał handoff §3.4 — **nikt +jeszcze nie zweryfikował**. Pozostałe trzy z rodziny (`wydawnictwo_ciagle`, +`wydawnictwo_zwarte`, `patent`) nie pojawiają się na liście tylko dlatego, że +siedzą w `WYJATKI` jako zweryfikowanie martwe. + +## Wynik negatywny, który też jest wynikiem + +**Zero winowajców wśród trzech tabel `*_autor` fazy 01** +(`bpp_wydawnictwo_ciagle_autor`, `bpp_wydawnictwo_zwarte_autor`, +`bpp_patent_autor`) — suma par per tabela (2+4+4+3+2) wyczerpuje wszystkie 15, +więc dla tabel fazy 01 nie ma ani jednej. DDL fazy 01 trzyma się na żywym +katalogu. + +## Wniosek dla planu + +Tabela kolejności wykonania w planie fazy 02 dostaje **jedno zadanie więcej**: + +| # | Task | Źródło | +|---|---|---| +| … | **Task 2d — sumy + rozbieżności (6 widoków)** | ta inwentaryzacja; w planie tylko jako ostrzeżenie (c), bez zadania | + +Zakres Taska 2b jest potwierdzony jako dokładnie 7 widoków (5 rdzenia + 2 +autorstw), a nie „5 plus może coś jeszcze". 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..0646e70c3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md @@ -0,0 +1,315 @@ +# Spec: Soft-delete dla rekordów publikacji (Wydawnictwo_Ciagle/Zwarte, Doktorat, Habilitacja, Patent) + +> 🔁 **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 +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. 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..832078b12 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md @@ -0,0 +1,813 @@ +# Spec: Soft-delete publikacji + autorów (jedno opracowanie wdrożeniowe) + +> ✅ **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) +> (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. + 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). +3. **PBN** — soft-delete publikacji wycofuje oświadczenia dyscyplin z profilu + 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 **PL/pgSQL** — po porcie z `plpython3u`, +PR #363), `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` | `SoftDeleteModel`, **wąska kaskada** z rodzica | nie kaskadują od autora | +| Doktorat / habilitacja | Soft-delete (są publikacjami) | FK do autora → `PROTECT` | + +**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: 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 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 / +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 + wąska kaskada na `*_Autor`) + +5 modeli: `Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`, +`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) + +> 🔄 **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`. | + +> ⚠️ **KOREKTA (2026-08-07) do zdania o `ostatnio_zmieniony` w pierwszym +> wierszu tabeli.** Ten spec traktował brak bumpa `ostatnio_zmieniony` jako +> neutralny efekt uboczny. **To był błąd oceny.** Decyzja właściciela projektu: +> soft-delete jest modyfikacją rekordu, więc `ostatnio_zmieniony` **MUSI** się +> zmieniać — przy `delete()` **i** przy `restore()`. Faza 01 realizuje to przez +> `dopisz_znacznik_zmiany()` + override `save()` w `BppAutorstwoSoftDeleteMixin` +> (`src/bpp/models/soft_delete.py`); pełny kontrakt PINNED siedzi w +> `docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md`. +> Dla triggera nic się nie zmienia: bramka `WHEN` nadal musi znać `deleted_at` +> (`ostatnio_zmieniony` nie zasila widoku i nie ma prawa być w bramce), więc +> zdanie „nie ma przypadkowego ratunku" pozostaje prawdziwe. + +**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 — 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 + 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. +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 +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. + +⚠️ **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` + / `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 **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 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). + +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, ..., +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). + +### 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. + +**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` (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 +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. + +### 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")`). + +> **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**, +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 + +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ść). + +> **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 +`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ń (kolejka + ścieżka synchroniczna) + +### 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: +⚠️ **w pakiecie zewnętrznym** `pbn_client/mixins/institutions.py:87` → +`delete_all_publication_statements(publicationId)` (+ selektywne +`delete_publication_statement` w `:135`, retry w +`pbn_client/statements.py:211`). +⚠️ `_delete_statements_with_retry` z pakietu **NIE jest wzorcem** — ponawia +na `CannotDeleteStatementsException`, a u nas ten wyjątek oznacza SUKCES +(oświadczeń nie było, stan docelowy osiągnięty). Wzorzec obsługi: +`src/pbn_wysylka_oswiadczen/tasks.py:54-76`. + +### 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 +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). + +### 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 = +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. + +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 → 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. + +--- + +## 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. **`*_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`, + 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 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 + `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** — 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`, **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, + import, ewaluacja, merge autorów, API. Do ~10 min. + +--- + +## 9. Ryzyka + +- **Cache/trigger** — rozjazd, jeśli `deleted_at` nie obsłużone we wszystkich + 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). +- **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). 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: 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. +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. +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: **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 + 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 + +- `django-soft-delete>=1.0.23` — `pyproject.toml`. +- `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 + 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ń). diff --git a/src/bpp/admin/core.py b/src/bpp/admin/core.py index 78330aaa2..a59ed1990 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,77 @@ 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()`/`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ą 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 + # 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 not formset._should_delete_form(f) + ] + + widziane_typ = {} + widziane_kolejnosc = {} + 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 + + 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 + + def generuj_inline_dla_autorow(baseModel, include_dyscyplina=True): MAKSYMALNA_ILOSC_AUTOROW_W_FORMULARZU = 25 @@ -367,6 +458,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/admin/filters.py b/src/bpp/admin/filters.py index c536039e1..9805d2048 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/praca_habilitacyjna.py b/src/bpp/admin/praca_habilitacyjna.py index ba1cad622..7ea95a71a 100644 --- a/src/bpp/admin/praca_habilitacyjna.py +++ b/src/bpp/admin/praca_habilitacyjna.py @@ -101,6 +101,39 @@ class Praca_HabilitacyjnaForm(forms.ModelForm): widget=autocomplete.ModelSelect2(url="bpp:autor-z-uczelni-autocomplete"), ) + def clean_autor(self): + """Jeden autor — jedna ŻYWA praca habilitacyjna. + + Do fazy 03 pilnował tego `validate_unique()`, bo `Praca_Habilitacyjna. + autor` było `OneToOneField`. Faza 03 zamieniła je na `ForeignKey` + z warunkowym `phab_uniq_autor_zywy` (żeby habilitacja w koszu nie + blokowała scalania autorów), a to przenosi walidację do + `Model.validate_constraints()` — który dla constraintu z `condition` + CICHO POMIJA sprawdzenie, gdy pole warunku (`deleted_at`) jest + wykluczone z walidacji. A jest: `_get_validation_exclusions()` wyklucza + wszystko spoza `Meta.fields`, a te admin nadpisuje spłaszczonymi + `fieldsets`. + + `bpp/admin/core.py` obchodzi to ukrytym polem `deleted_at` na liście + `fields`. Tam to działa, bo chodzi o inline; tutaj `fieldsets` sterują + renderowaniem, więc pole wyprodukowałoby widoczny, pusty wiersz + „Deleted at" w formularzu. Stąd jawne sprawdzenie — czytelniejsze niż + walka z wykluczeniami, i daje komunikat przy właściwym polu. + + Ograniczenie w bazie zostaje ostateczną gwarancją (to jest walidacja + formularza, nie blokada wyścigu). + """ + autor = self.cleaned_data["autor"] + + istniejace = Praca_Habilitacyjna.objects.filter(autor=autor) + if self.instance.pk is not None: + istniejace = istniejace.exclude(pk=self.instance.pk) + + if istniejace.exists(): + raise forms.ValidationError("Ten autor ma już pracę habilitacyjną.") + + return autor + jednostka = forms.ModelChoiceField( queryset=Jednostka.objects.all(), widget=autocomplete.ModelSelect2(url="bpp:jednostka-autocomplete"), 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 d5951a773..d07cbff6b 100644 --- a/src/bpp/admin/wydawnictwo_zwarte.py +++ b/src/bpp/admin/wydawnictwo_zwarte.py @@ -615,6 +615,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/apps.py b/src/bpp/apps.py index eb859be49..370ab210b 100644 --- a/src/bpp/apps.py +++ b/src/bpp/apps.py @@ -46,6 +46,16 @@ def ready(self): if apps.is_installed("favicon"): self._patch_favicon_save_per_site() + # django-easy-audit pobiera poprzednia wersje wiersza przez + # `sender.objects`, wiec na modelach soft-delete wywraca `restore()` + # (wiersz jest wtedy jeszcze skasowany -> DoesNotExist). Podmieniamy + # handler na wersje uzywajaca `_base_manager`. Pelne uzasadnienie, + # zakres bledu i stan zgloszenia upstream: bpp/easyaudit_shim.py. + if apps.is_installed("easyaudit"): + from bpp.easyaudit_shim import zainstaluj + + zainstaluj() + # Initialize Rollbar with global hostname handler from bpp.rollbar_config import configure_rollbar diff --git a/src/bpp/data/autor_djangoql_schema.compact.txt b/src/bpp/data/autor_djangoql_schema.compact.txt index 79d93e54a..4c9cd65c5 100644 --- a/src/bpp/data/autor_djangoql_schema.compact.txt +++ b/src/bpp/data/autor_djangoql_schema.compact.txt @@ -1,8 +1,13 @@ -# BPP 202607.1397 +# BPP 202608.1399 # Model: bpp.Autor Schemat: bpp.djangoql_schema.RekordLLMSchema # Wygenerowano: manage.py opisz_schemat_djangoql_dla_llm # Plik generowany — nie edytuj ręcznie. +# Rekordy skasowane (soft-delete, pole `deleted_at`) są DOMYŚLNIE pomijane. +# Zapytanie bez wzmianki o `deleted_at` widzi tylko żywe wiersze — nie dopisuj +# `deleted_at = None`, to już działa. Żeby zajrzeć do kosza, wymień to pole na +# wybranej relacji: `autorzy_set.deleted_at != None` = TYLKO skasowane autorstwa. + # DjangoQL schema # Query: , combined with and/or, grouped with (). # Negate with != / !~ / not in / not startswith / not endswith (no standalone `not`). @@ -22,7 +27,7 @@ start model: bpp.autor bpp.autor: - aktualna_funkcja -> bpp.funkcja_autora? match nazwa + aktualna_funkcja -> bpp.funkcja_autora? aktualna_jednostka -> bpp.jednostka? autor_dyscyplina -> bpp.autor_dyscyplina? autor_jednostka -> bpp.autor_jednostka? @@ -43,7 +48,7 @@ bpp.autor: patent -> bpp.patent? patent_autor -> bpp.patent_autor? pbn_uid -> pbn_api.scientist? - plec -> bpp.plec? match nazwa + plec -> bpp.plec? pokazuj bool pokazuj_opis bool pokazuj_poprzednie_nazwiska bool @@ -57,8 +62,7 @@ bpp.autor: slug str sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni - tytul -> bpp.tytul? match nazwa - urodzony date? + tytul -> bpp.tytul? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -90,6 +94,7 @@ bpp.jednostka: level int lft int nazwa str + nie_eksportuj_przez_api bool obca_jednostka -> bpp.uczelnia? opis str? ostatnio_zmieniony datetime? @@ -102,7 +107,8 @@ bpp.jednostka: praca_doktorska -> bpp.praca_doktorska? praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int - rodzaj -> bpp.rodzajjednostki? match nazwa + rodzaj -> bpp.rodzajjednostki? + ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" skrot_nazwy str? @@ -123,20 +129,20 @@ bpp.jednostka: bpp.autor_dyscyplina: autor -> bpp.autor - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id int procent_dyscypliny float? procent_subdyscypliny float? - rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa + rodzaj_autora -> ewaluacja_common.rodzaj_autora? rok int - subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? zatrudnienie_do datetime? zatrudnienie_od datetime? bpp.autor_jednostka: autor -> bpp.autor - funkcja -> bpp.funkcja_autora? match nazwa + funkcja -> bpp.funkcja_autora? grupa_pracownicza -> bpp.grupa_pracownicza? id int jednostka -> bpp.jednostka @@ -149,7 +155,7 @@ bpp.autorzy: afiliuje bool autor -> bpp.autor data_oswiadczenia date - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id unknown jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow @@ -158,14 +164,14 @@ bpp.autorzy: profil_orcid bool przypieta bool rekord -> bpp.rekord - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci upowaznienie_pbn bool zapisany_jako str zatrudniony bool bpp.cache_punktacja_autora: autor -> bpp.autor - dyscyplina -> bpp.dyscyplina_naukowa match nazwa + dyscyplina -> bpp.dyscyplina_naukowa id int jednostka -> bpp.jednostka pkdaut float @@ -178,11 +184,12 @@ bpp.patent: cached_punkty_dyscyplin unknown? data_decyzji date? data_zgloszenia date? "Data zgłoszenia" + deleted_at datetime? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str nie_eksportuj_przez_api bool numer_prawa_wylacznego str? "Numer prawa wyłącznego" @@ -197,18 +204,20 @@ bpp.patent: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool - rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? match nazwa + restored_at datetime? + rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str + transaction_id str? tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str utworzono datetime? @@ -222,7 +231,8 @@ bpp.patent_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -233,7 +243,9 @@ bpp.patent_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.patent - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + restored_at datetime? + transaction_id str? + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -263,19 +275,20 @@ bpp.plec: bpp.praca_doktorska: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -303,18 +316,20 @@ bpp.praca_doktorska: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -327,19 +342,20 @@ bpp.praca_doktorska: bpp.praca_habilitacyjna: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -366,18 +382,20 @@ bpp.praca_habilitacyjna: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -398,7 +416,8 @@ bpp.wydawnictwo_ciagle: autorzy -> bpp.autor autorzy_set -> bpp.wydawnictwo_ciagle_autor? cached_punkty_dyscyplin unknown? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_ciagle_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -406,12 +425,12 @@ bpp.wydawnictwo_ciagle: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 @@ -422,12 +441,12 @@ bpp.wydawnictwo_ciagle: nie_eksportuj_przez_api bool nr_zeszytu str numer_odbitki str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? match nazwa "OpenAccess: tryb dostępu" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? "OpenAccess: tryb dostępu" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -454,19 +473,21 @@ bpp.wydawnictwo_ciagle: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_ciagle_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -483,7 +504,8 @@ bpp.wydawnictwo_ciagle_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -494,7 +516,9 @@ bpp.wydawnictwo_ciagle_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_ciagle - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + restored_at datetime? + transaction_id str? + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -505,7 +529,8 @@ bpp.wydawnictwo_zwarte: cached_punkty_dyscyplin unknown? calkowita_liczba_autorow int? calkowita_liczba_redaktorow int? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_zwarte_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -514,13 +539,13 @@ bpp.wydawnictwo_zwarte: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również @@ -531,12 +556,12 @@ bpp.wydawnictwo_zwarte: nie_eksportuj_przez_api bool numer_odbitki str numer_w_serii str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? match nazwa - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -566,20 +591,22 @@ bpp.wydawnictwo_zwarte: recenzowana bool redakcja str rekord -> bpp.rekord? + restored_at datetime? rok int search_index unknown seria_wydawnicza -> bpp.seria_wydawnicza? slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_zwarte_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -603,7 +630,8 @@ bpp.wydawnictwo_zwarte_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -614,7 +642,9 @@ bpp.wydawnictwo_zwarte_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_zwarte - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + restored_at datetime? + transaction_id str? + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -697,7 +727,7 @@ bpp.wymiar_etatu: bpp.rekord: autorzy -> bpp.autorzy? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" @@ -706,18 +736,18 @@ bpp.rekord: index_copernicus float informacje str isbn str - jezyk -> bpp.jezyk match nazwa "Język" + jezyk -> bpp.jezyk "Język" konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 liczba_autorow int liczba_cytowan int liczba_znakow_wydawniczych int? "Liczba znaków wydawniczych" - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str @@ -734,9 +764,9 @@ bpp.rekord: slowa_kluczowe -> taggit.tag? "Słowa kluczowe" — A comma-separated list of tags. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty szczegoly str "Szczegóły" — Np. str. 23-45 - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str tytul_oryginalny str tytul_oryginalny_sort str @@ -771,9 +801,11 @@ bpp.zrodlo_informacji: wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? bpp.rodzaj_prawa_patentowego: - id int - nazwa str - patent -> bpp.patent? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_15cd dla patentu. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, patenty o tym rodzaju prawa zostaną wyeksportowane bez typu zasobu. + eksportuj_jako_patent bool "Eksportuj do CERIF jako patent" — Odznacz dla praw, które nie są patentami w rozumieniu słownika COAR (np. znak towarowy). Takie rekordy nie trafią do eksportu CERIF/OpenAIRE — profil wymaga dla każdego rekordu typu z gałęzi „patent”, więc jedyną alternatywą byłoby zadeklarowanie ich patentami wbrew prawdzie. + id int + nazwa str + patent -> bpp.patent? taggit.tag: id int @@ -798,6 +830,7 @@ bpp.status_korekty: bpp.jezyk: id int + kod_bcp47 str "Kod języka wg BCP 47" — Kod języka w notacji BCP 47 (RFC 5646), np. „pl”, „en”, „en-GB”. Używany w eksporcie CERIF/OpenAIRE jako wartość atrybutu xml:lang; gdy pusty, elementy w tym języku zostaną wyeksportowane bez oznaczenia języka. nazwa str pbn_uid -> pbn_api.language? praca_doktorska -> bpp.praca_doktorska? @@ -836,7 +869,7 @@ pbn_api.publication: bpp.typ_kbn: artykul_pbn bool "Artykuł w PBN" — Wydawnictwa ciągłe posiadające ten typ MNiSW/MEiN zostaną włączone do eksportu PBN jako artykuły - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? id int nazwa str praca_doktorska -> bpp.praca_doktorska? @@ -866,15 +899,16 @@ bpp.wydawca: bpp.charakter_formalny: charakter_ogolny str choices: Artykuł | Książka | Rozdział | Inne - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. id int level int lft int nazwa str nazwa_w_primo str choices: | Artykuł | Książka | Zasób tekstowy | Rozprawa naukowa | Recenzja | Artykuł prasowy | Rozdział | Czasopismo | Dane badawcze | Materiał konferencyjny | Obraz | Baza | Zestaw danych statystycznych | Multimedia | Inny - parent -> bpp.charakter_formalny? match nazwa "Charakter nadrzędny" + parent -> bpp.charakter_formalny? "Charakter nadrzędny" publikacja bool rekord -> bpp.rekord? rght int @@ -892,7 +926,7 @@ bpp.charakter_formalny: bpp.wydawnictwo_ciagle_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_ciagle tytul str "Tytuł" @@ -929,11 +963,13 @@ bpp.licencja_openaccess: nazwa str rekord -> bpp.rekord? skrot str + uri str "Adres URI licencji" — Kanoniczny adres URI licencji, np. https://creativecommons.org/licenses/by/4.0/ . Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace na tej licencji zostaną wyeksportowane bez odnośnika do jej treści. wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? zrodlo -> bpp.zrodlo? bpp.tryb_openaccess_wydawnictwo_ciagle: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -949,12 +985,12 @@ bpp.wersja_tekstu_openaccess: bpp.wydawnictwo_ciagle_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_ciagle streszczenie str bpp.wydawnictwo_ciagle_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_ciagle @@ -965,16 +1001,16 @@ bpp.zrodlo: e_issn str "e-ISSN" id int issn str - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? nazwa str nazwa_alternatywna str? - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" openaccess_tryb_dostepu str "OpenAccess: tryb dostępu" choices: pełny | częściowy ostatnio_zmieniony datetime? pbn_uid -> pbn_api.journal? "Odpowiednik w PBN" poprzednia_nazwa str? "Poprzedni tytuł" rekord -> bpp.rekord? - rodzaj -> bpp.rodzaj_zrodla match nazwa + rodzaj -> bpp.rodzaj_zrodla search unknown skrot str "Skrót" skrot_nazwy_alternatywnej str? @@ -982,11 +1018,11 @@ bpp.zrodlo: www str? wydawca str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? - zasieg -> bpp.zasieg_zrodla? match nazwa + zasieg -> bpp.zasieg_zrodla? bpp.wydawnictwo_zwarte_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_zwarte tytul str "Tytuł" @@ -1001,6 +1037,7 @@ bpp.nagroda: uzasadnienie str bpp.tryb_openaccess_wydawnictwo_zwarte: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -1013,18 +1050,18 @@ bpp.seria_wydawnicza: bpp.wydawnictwo_zwarte_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_zwarte streszczenie str bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_zwarte bpp.zewnetrznebazydanychview: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str rekord -> bpp.rekord @@ -1114,49 +1151,3 @@ bpp.organprzyznajacynagrody: nagroda -> bpp.nagroda? nazwa str skrot str - -dictionaries (shared relation values, referenced above): - bpp.funkcja_autora - nazwa: "adiunkt", "asystent", "kierownik", "młodszy asystent", "starszy asystent" - bpp.plec - nazwa: "kobieta", "mężczyzna" - bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "dr hab. inż.", "dr inż.", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "mgr, inż.", "prof. dr", "prof. dr hab.", "prof. dr hab. inż.", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" - bpp.rodzajjednostki - nazwa: "Koło naukowe", "Standard", "Wydział" - bpp.dyscyplina_naukowa - nazwa: "archeologia", "architektura i urbanistyka", "astronomia", "automatyka, elektronika, elektrotechnika i technologie kosmiczne", "biologia medyczna", "biotechnologia", "ekonomia i finanse", "etnologia i antropologia kulturowa", "filozofia", "geografia społeczno-ekonomiczna i gospodarka przestrzenna", "historia", "informatyka", "informatyka techniczna i telekomunikacja", "inżynieria bezpieczeństwa", "inżynieria biomedyczna", "inżynieria chemiczna", "inżynieria lądowa, geodezja i transport", "inżynieria materiałowa", "inżynieria mechaniczna", "inżynieria środowiska, górnictwo i energetyka", "językoznawstwo", "literaturoznawstwo", "matematyka", "nauki biblijne", "nauki biologiczne", "nauki chemiczne", "nauki farmaceutyczne", "nauki fizyczne", "nauki leśne", "nauki medyczne", "nauki o bezpieczeństwie", "nauki o komunikacji społecznej i mediach", "nauki o kulturze fizycznej", "nauki o kulturze i religii", "nauki o polityce i administracji", "nauki o rodzinie", "nauki o sztuce", "nauki o zarządzaniu i jakości", "nauki o zdrowiu", "nauki o Ziemi i środowisku", "nauki prawne", "nauki socjologiczne", "nauki teologiczne", "pedagogika", "polonistyka", "prawo kanoniczne", "psychologia", "rolnictwo i ogrodnictwo", "stosunki międzynarodowe", "sztuki filmowe i teatralne", "sztuki muzyczne", "sztuki plastyczne i konserwacja dzieł sztuki", "technologia żywności i żywienia", "weterynaria", "zootechnika i rybactwo" - ewaluacja_common.rodzaj_autora - nazwa: "doktorant", "inny zatrudniony, nie naukowy", "pracownik badawczy spoza N", "pracownik naukowy w liczbie N" - bpp.typ_odpowiedzialnosci - nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" - bpp.zrodlo_informacji - nazwa: "od autora", "od redakcji", "wartość szacunkowa" - bpp.rodzaj_prawa_patentowego - nazwa: "odmiana rośliny", "wynalazek", "wzór przemysłowy", "wzór użytkowy", "znak towarowy" - bpp.status_korekty - nazwa: "po korekcie", "przed korektą", "w trakcie korekty" - bpp.jezyk - nazwa: "abchaski", "Achinese", "Acoli", "Adangme", "Adyghe", "afar", "Afrihili", "Afro-Asiatic languages", "afrykanerski", "Ainu", "ajmara", "akadyjski", "akan", "albański", "Aleut", "Algonquian languages", "Altaic languages", "amharski", "angielski", "Angika", "Apache languages", "arabski", "aragoński", "Arapaho", "Arawak", "Aromanian", "Artificial languages", "asamski", "Asturian", "Athapascan languages", "Australian languages", "Austronesian languages", "Awadhi", "awarski", "awestyjski", "azerski", "Balinese", "Baltic languages", "Baluchi", "bambara", "Bamileke languages", "Banda languages", "Bantu languages", "Basa", "baskijski", "baszkirski", "Batak languages", "Beja", "Bemba", "bengalski", "Berber languages", "Bhojpuri", "białoruski", "bihari", "Bikol", "Bini", "birmański", "bislama", "Blin", "Blissymbols", "bośniacki", "Braj", "brak danych", "bretoński", "Buginese", "bułgarski", "Buriat", "Caddo", "Caucasian languages", "Cebuano", "Celtic languages", "Central American Indian languages", "Chagatai", "Chamic languages", "Cherokee", "Cheyenne", "Chibcha", "Chinook jargon", "chiński", "Chipewyan", "Choctaw", "chorwacki", "Chuukese", "Classical Newari", "Coptic", "Creek", "Creoles and pidgins", "Creoles and pidgins, English based", "Creoles and pidgins, French-based", "Creoles and pidgins, Portuguese-based", "Crimean Tatar", "Cushitic languages", "czamorro", "czarnogórski", "czeczeński", "czeski", "cziczewa", "czuwaski", "Dakota", "Dargwa", "Delaware", "Dinka", "Dogri", "Dogrib", "Dravidian languages", "Duala", "duński", "Dutch, Middle (ca.1050-1350)", "Dyula", "dzongkha", "Eastern Frisian", "Efik", "Egyptian (Ancient)", "Ekajuk", "Elamite", "English, Middle (1100-1500)", "Erzya", "esperanto", "estoński", "ewe", "Ewondo", "Fang", "Fanti", "farerski", "fidżyjski", "Filipino", "Finno-Ugrian languages", "fiński", "Fon", "francuski", "French, Middle (ca.1400-1600)", "French, Old (842-ca.1400)", "Friulian", "fryzyjski", "ful", "Ga", "gaelicki szkocki", "Galibi Carib", "galicyjski", "Gayo", "Gbaya", "Geez", "German, Middle High (ca.1050-1500)", "German, Old High (ca.750-1050)", "Germanic languages", "Gilbertese", "Gondi", "Gorontalo", "Gothic", "Grebo", "grecki (nowożytny)", "grecki (starożytny)", "grenlandzki", "gruziński", "guarani", "gudźarati", "Gwich'in", "Haida", "haitański", "hausa", "Hawaiian", "hebrajski", "herero", "Hiligaynon", "Himachali languages", "hindi", "hiri motu", "hiszpański", "Hittite", "Hmong", "holenderski", "Hupa", "Iban", "ido", "igbo", "Ijo languages", "Iloko", "Inari Sami", "Indic languages", "Indo-European languages", "indonezyjski", "Ingush", "inny", "interlingua", "inuktitut", "inupiak", "Iranian languages", "Irish, Middle (900-1200)", "Irish, Old (to 900)", "irlandzki", "Iroquoian languages", "islandzki", "japoński", "jawajski", "jidysz", "joruba", "Judeo-Arabic", "Judeo-Persian", "Kabardian", "Kabyle", "Kachin", "Kalmyk", "Kamba", "kannada", "kanuri", "Kara-Kalpak", "Karachay-Balkar", "Karelian", "Karen languages", "Kashubian", "kaszmirski", "kataloński", "Kawi", "kazachski", "keczua", "Khasi", "khmerski", "Khoisan languages", "Khotanese", "kikuju", "Kimbundu", "kirgiski", "Klingon", "komi", "kongo", "Konkani", "koreański", "kornijski", "korsykański", "Kosraean", "Kpelle", "kri", "Kru languages", "Kumyk", "kurdyjski", "Kurukh", "Kutenai", "kwanyama", "Ladino", "Lahnda", "Lamba", "Land Dayak languages", "laotański", "Lezghian", "limburski", "lingala", "litewski", "Lojban", "Low German", "Lower Sorbian", "Lozi", "Luba-katanga", "Luba-Lulua", "luganda", "Luiseno", "luksemburski", "Lule Sami", "Lunda", "Luo (Kenya and Tanzania)", "Lushai", "łaciński", "łotewski", "macedoński", "Madurese", "Magahi", "Maithili", "Makasar", "malajalam", "malajski", "malediwski", "malgaski", "maltański", "Manchu", "Mandar", "Mandingo", "Manipuri", "Manobo languages", "manx", "maoryski", "Mapudungun", "marathi", "Mari", "marszalski", "Marwari", "Masai", "Mayan languages", "Mende", "Mi'kmaq", "Minangkabau", "Mirandese", "Mohawk", "Moksha", "Mon-Khmer languages", "Mongo", "mongolski", "Mossi", "Munda languages", "N'Ko", "Nahuatl languages", "nauruański", "nawaho", "ndebele południowy", "ndebele północny", "ndonga", "Neapolitan", "Nepal Bhasa", "nepalski", "Nias", "niemiecki", "Niger-Kordofanian languages", "Nilo-Saharan languages", "Niuean", "No linguistic content", "Nogai", "Norse, Old", "North American Indian languages", "Northern Frisian", "norweski", "norweski (bokmål)", "norweski (nynorsk)", "Nubian languages", "nuosu", "Nyamwezi", "Nyankole", "Nyoro", "Nzima", "occidental", "odżibwe", "Official Aramaic (700-300 BCE)", "orija", "ormiański", "oromo", "Osage", "osetyjski", "Otomian languages", "Pahlavi", "Palauan", "pali", "Pampanga", "Pangasinan", "Papiamento", "Papuan languages", "paszto", "Pedi", "pendżabski", "Persian, Old (ca.600-400 B.C.)", "perski", "Philippine languages", "Phoenician", "Pohnpeian", "polski", "portugalski", "północnolapoński", "Prakrit languages", "Provençal, Old (to 1500)", "prowansalski", "Rajasthani", "Rapanui", "Rarotongan", "Romance languages", "romansz", "Romany", "rosyjski", "ruanda", "rumuński", "rundi", "Salishan languages", "Samaritan Aramaic", "Sami languages", "samoański", "Sandawe", "sango", "sanskryt", "Santali", "sardyński", "Sasak", "Scots", "Selkup", "Semitic languages", "serbski", "Serer", "Shan", "shona", "Sicilian", "Sidamo", "Sign Languages", "Siksika", "sindhi", "Sino-Tibetan languages", "Siouan languages", "Skolt Sami", "Slave (Athapascan)", "Slavic languages", "słowacki", "słoweński", "Sogdian", "somalijski", "Songhai languages", "Soninke", "Sorbian languages", "sotho południowy", "South American Indian languages", "Southern Altai", "Southern Sami", "Sranan Tongo", "Standard Moroccan Tamazight", "staro-cerkiewno-słowiański", "suahili", "suazi", "Sukuma", "sumeryjski", "sundajski", "Susu", "Swiss German", "syngaleski", "syryjski", "syryjski (klasyczny)", "szwedzki", "tadżycki", "tagalski", "tahitański", "Tai languages", "tajski", "Tamashek", "tamilski", "tatarski", "telugu", "Tereno", "Tetum", "Tigre", "tigrinia", "Timne", "Tiv", "Tlingit", "Tok Pisin", "Tokelau", "tonga", "Tonga (Nyasa)", "Tsimshian", "tsonga", "tswana", "Tumbuka", "Tupi languages", "turecki", "Turkish, Ottoman (1500-1928)", "turkmeński", "Tuvalu", "Tuvinian", "twi", "tybetański", "Udmurt", "ugarycki", "ujgurski", "ukraiński", "Umbundu", "Uncoded languages", "Undetermined", "Upper Sorbian", "urdu", "uzbecki", "Vai", "venda", "volapük", "Votic", "Wakashan languages", "walijski", "waloński", "Waray", "Washo", "węgierski", "wiele języków", "wietnamski", "włoski", "Wolaitta", "wolof", "xhosa", "Yakut", "Yao", "Yapese", "Yupik languages", "Zande languages", "Zapotec", "Zaza", "Zenaga", "zhuang", "zulu", "Zuni" - bpp.typ_kbn - nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" - bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Editorial", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Poster - streszczenie", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" - bpp.czas_udostepnienia_openaccess - nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" - bpp.licencja_openaccess - nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" - bpp.tryb_openaccess_wydawnictwo_ciagle - nazwa: "Inne", "Otwarte czasopismo", "Otwarte repositorium" - bpp.wersja_tekstu_openaccess - nazwa: "Inna", "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" - bpp.tryb_openaccess_wydawnictwo_zwarte - nazwa: "Inne", "Otwarte repositorium", "Witryna wydawcy" - bpp.charakter_pbn - help_text: "", " ", "Artykuł przeglądowy stanowi podsumowanie aktualnego stanu badań w danym obszarze tematycznym.", "Hasło encykolpedyczne/słownikowe/leksykonowe", "Monografia naukowa to spójne tematycznie, wyczerpujące opracowanie naukowe. W PBN za monografie naukowe uważane są również zbiorowe książki pokonferencyjne (tzw. conference proceedings). Jeśli dodajesz zbiorową książkę pokonferencyjną, pamiętaj o tym, by zaznaczyć również pola "Publikacja zbiorowa" oraz "Publikacja konferencyjna"", "Np. katalog zabytków", "Oryginalny artykuł naukowy przedstawia rezultaty oryginalnych badań naukowych lub eksperymentu.", "Publikacja podlegała recenzji naukowej", "Wstęp/Wprowadzenie/Przedmowa" - bpp.zewnetrzna_baza_danych - nazwa: "Scopus", "Web of Science" - bpp.rodzaj_zrodla - nazwa: "periodyk", "wydawnictwo", "źródło nieindeksowane" - bpp.zasieg_zrodla - nazwa: "krajowy", "międzynarodowy" diff --git a/src/bpp/data/autorzy_djangoql_schema.compact.txt b/src/bpp/data/autorzy_djangoql_schema.compact.txt index 8b51b3301..de8f82ba3 100644 --- a/src/bpp/data/autorzy_djangoql_schema.compact.txt +++ b/src/bpp/data/autorzy_djangoql_schema.compact.txt @@ -1,8 +1,13 @@ -# BPP 202607.1397 +# BPP 202608.1399 # Model: bpp.Autorzy Schemat: bpp.djangoql_schema.RekordLLMSchema # Wygenerowano: manage.py opisz_schemat_djangoql_dla_llm # Plik generowany — nie edytuj ręcznie. +# Rekordy skasowane (soft-delete, pole `deleted_at`) są DOMYŚLNIE pomijane. +# Zapytanie bez wzmianki o `deleted_at` widzi tylko żywe wiersze — nie dopisuj +# `deleted_at = None`, to już działa. Żeby zajrzeć do kosza, wymień to pole na +# wybranej relacji: `autorzy_set.deleted_at != None` = TYLKO skasowane autorstwa. + # DjangoQL schema # Query: , combined with and/or, grouped with (). # Negate with != / !~ / not in / not startswith / not endswith (no standalone `not`). @@ -25,7 +30,7 @@ bpp.autorzy: afiliuje bool autor -> bpp.autor data_oswiadczenia date - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id unknown jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow @@ -34,13 +39,13 @@ bpp.autorzy: profil_orcid bool przypieta bool rekord -> bpp.rekord - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci upowaznienie_pbn bool zapisany_jako str zatrudniony bool bpp.autor: - aktualna_funkcja -> bpp.funkcja_autora? match nazwa + aktualna_funkcja -> bpp.funkcja_autora? aktualna_jednostka -> bpp.jednostka? autor_dyscyplina -> bpp.autor_dyscyplina? autor_jednostka -> bpp.autor_jednostka? @@ -61,7 +66,7 @@ bpp.autor: patent -> bpp.patent? patent_autor -> bpp.patent_autor? pbn_uid -> pbn_api.scientist? - plec -> bpp.plec? match nazwa + plec -> bpp.plec? pokazuj bool pokazuj_opis bool pokazuj_poprzednie_nazwiska bool @@ -75,8 +80,7 @@ bpp.autor: slug str sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni - tytul -> bpp.tytul? match nazwa - urodzony date? + tytul -> bpp.tytul? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -113,6 +117,7 @@ bpp.jednostka: level int lft int nazwa str + nie_eksportuj_przez_api bool obca_jednostka -> bpp.uczelnia? opis str? ostatnio_zmieniony datetime? @@ -125,7 +130,8 @@ bpp.jednostka: praca_doktorska -> bpp.praca_doktorska? praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int - rodzaj -> bpp.rodzajjednostki? match nazwa + rodzaj -> bpp.rodzajjednostki? + ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" skrot_nazwy str? @@ -158,7 +164,7 @@ bpp.kierunek_studiow: bpp.rekord: autorzy -> bpp.autorzy? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" @@ -167,18 +173,18 @@ bpp.rekord: index_copernicus float informacje str isbn str - jezyk -> bpp.jezyk match nazwa "Język" + jezyk -> bpp.jezyk "Język" konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 liczba_autorow int liczba_cytowan int liczba_znakow_wydawniczych int? "Liczba znaków wydawniczych" - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str @@ -195,9 +201,9 @@ bpp.rekord: slowa_kluczowe -> taggit.tag? "Słowa kluczowe" — A comma-separated list of tags. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty szczegoly str "Szczegóły" — Np. str. 23-45 - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str tytul_oryginalny str tytul_oryginalny_sort str @@ -232,20 +238,20 @@ bpp.funkcja_autora: bpp.autor_dyscyplina: autor -> bpp.autor - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id int procent_dyscypliny float? procent_subdyscypliny float? - rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa + rodzaj_autora -> ewaluacja_common.rodzaj_autora? rok int - subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? zatrudnienie_do datetime? zatrudnienie_od datetime? bpp.autor_jednostka: autor -> bpp.autor - funkcja -> bpp.funkcja_autora? match nazwa + funkcja -> bpp.funkcja_autora? grupa_pracownicza -> bpp.grupa_pracownicza? id int jednostka -> bpp.jednostka @@ -256,7 +262,7 @@ bpp.autor_jednostka: bpp.cache_punktacja_autora: autor -> bpp.autor - dyscyplina -> bpp.dyscyplina_naukowa match nazwa + dyscyplina -> bpp.dyscyplina_naukowa id int jednostka -> bpp.jednostka pkdaut float @@ -269,11 +275,12 @@ bpp.patent: cached_punkty_dyscyplin unknown? data_decyzji date? data_zgloszenia date? "Data zgłoszenia" + deleted_at datetime? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str nie_eksportuj_przez_api bool numer_prawa_wylacznego str? "Numer prawa wyłącznego" @@ -288,18 +295,20 @@ bpp.patent: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool - rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? match nazwa + restored_at datetime? + rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str + transaction_id str? tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str utworzono datetime? @@ -313,7 +322,8 @@ bpp.patent_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -324,7 +334,9 @@ bpp.patent_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.patent - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + restored_at datetime? + transaction_id str? + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -354,19 +366,20 @@ bpp.plec: bpp.praca_doktorska: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -394,18 +407,20 @@ bpp.praca_doktorska: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -418,19 +433,20 @@ bpp.praca_doktorska: bpp.praca_habilitacyjna: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -457,18 +473,20 @@ bpp.praca_habilitacyjna: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -489,7 +507,8 @@ bpp.wydawnictwo_ciagle: autorzy -> bpp.autor autorzy_set -> bpp.wydawnictwo_ciagle_autor? cached_punkty_dyscyplin unknown? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_ciagle_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -497,12 +516,12 @@ bpp.wydawnictwo_ciagle: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 @@ -513,12 +532,12 @@ bpp.wydawnictwo_ciagle: nie_eksportuj_przez_api bool nr_zeszytu str numer_odbitki str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? match nazwa "OpenAccess: tryb dostępu" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? "OpenAccess: tryb dostępu" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -545,19 +564,21 @@ bpp.wydawnictwo_ciagle: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_ciagle_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -574,7 +595,8 @@ bpp.wydawnictwo_ciagle_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -585,7 +607,9 @@ bpp.wydawnictwo_ciagle_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_ciagle - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + restored_at datetime? + transaction_id str? + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -596,7 +620,8 @@ bpp.wydawnictwo_zwarte: cached_punkty_dyscyplin unknown? calkowita_liczba_autorow int? calkowita_liczba_redaktorow int? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_zwarte_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -605,13 +630,13 @@ bpp.wydawnictwo_zwarte: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również @@ -622,12 +647,12 @@ bpp.wydawnictwo_zwarte: nie_eksportuj_przez_api bool numer_odbitki str numer_w_serii str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? match nazwa - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -657,20 +682,22 @@ bpp.wydawnictwo_zwarte: recenzowana bool redakcja str rekord -> bpp.rekord? + restored_at datetime? rok int search_index unknown seria_wydawnicza -> bpp.seria_wydawnicza? slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_zwarte_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -694,7 +721,8 @@ bpp.wydawnictwo_zwarte_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -705,7 +733,9 @@ bpp.wydawnictwo_zwarte_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_zwarte - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + restored_at datetime? + transaction_id str? + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -744,15 +774,16 @@ bpp.rodzajjednostki: bpp.charakter_formalny: charakter_ogolny str choices: Artykuł | Książka | Rozdział | Inne - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. id int level int lft int nazwa str nazwa_w_primo str choices: | Artykuł | Książka | Zasób tekstowy | Rozprawa naukowa | Recenzja | Artykuł prasowy | Rozdział | Czasopismo | Dane badawcze | Materiał konferencyjny | Obraz | Baza | Zestaw danych statystycznych | Multimedia | Inny - parent -> bpp.charakter_formalny? match nazwa "Charakter nadrzędny" + parent -> bpp.charakter_formalny? "Charakter nadrzędny" publikacja bool rekord -> bpp.rekord? rght int @@ -770,6 +801,7 @@ bpp.charakter_formalny: bpp.jezyk: id int + kod_bcp47 str "Kod języka wg BCP 47" — Kod języka w notacji BCP 47 (RFC 5646), np. „pl”, „en”, „en-GB”. Używany w eksporcie CERIF/OpenAIRE jako wartość atrybutu xml:lang; gdy pusty, elementy w tym języku zostaną wyeksportowane bez oznaczenia języka. nazwa str pbn_uid -> pbn_api.language? praca_doktorska -> bpp.praca_doktorska? @@ -818,6 +850,7 @@ bpp.licencja_openaccess: nazwa str rekord -> bpp.rekord? skrot str + uri str "Adres URI licencji" — Kanoniczny adres URI licencji, np. https://creativecommons.org/licenses/by/4.0/ . Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace na tej licencji zostaną wyeksportowane bez odnośnika do jej treści. wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? zrodlo -> bpp.zrodlo? @@ -873,7 +906,7 @@ bpp.status_korekty: bpp.typ_kbn: artykul_pbn bool "Artykuł w PBN" — Wydawnictwa ciągłe posiadające ten typ MNiSW/MEiN zostaną włączone do eksportu PBN jako artykuły - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? id int nazwa str praca_doktorska -> bpp.praca_doktorska? @@ -902,7 +935,7 @@ bpp.wydawca: wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? bpp.zewnetrznebazydanychview: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str rekord -> bpp.rekord @@ -913,16 +946,16 @@ bpp.zrodlo: e_issn str "e-ISSN" id int issn str - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? nazwa str nazwa_alternatywna str? - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" openaccess_tryb_dostepu str "OpenAccess: tryb dostępu" choices: pełny | częściowy ostatnio_zmieniony datetime? pbn_uid -> pbn_api.journal? "Odpowiednik w PBN" poprzednia_nazwa str? "Poprzedni tytuł" rekord -> bpp.rekord? - rodzaj -> bpp.rodzaj_zrodla match nazwa + rodzaj -> bpp.rodzaj_zrodla search unknown skrot str "Skrót" skrot_nazwy_alternatywnej str? @@ -930,7 +963,7 @@ bpp.zrodlo: www str? wydawca str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? - zasieg -> bpp.zasieg_zrodla? match nazwa + zasieg -> bpp.zasieg_zrodla? ewaluacja_common.rodzaj_autora: autor_dyscyplina -> bpp.autor_dyscyplina? @@ -961,18 +994,21 @@ bpp.zrodlo_informacji: wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? bpp.rodzaj_prawa_patentowego: - id int - nazwa str - patent -> bpp.patent? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_15cd dla patentu. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, patenty o tym rodzaju prawa zostaną wyeksportowane bez typu zasobu. + eksportuj_jako_patent bool "Eksportuj do CERIF jako patent" — Odznacz dla praw, które nie są patentami w rozumieniu słownika COAR (np. znak towarowy). Takie rekordy nie trafią do eksportu CERIF/OpenAIRE — profil wymaga dla każdego rekordu typu z gałęzi „patent”, więc jedyną alternatywą byłoby zadeklarowanie ich patentami wbrew prawdzie. + id int + nazwa str + patent -> bpp.patent? bpp.wydawnictwo_ciagle_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_ciagle tytul str "Tytuł" bpp.tryb_openaccess_wydawnictwo_ciagle: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -980,19 +1016,19 @@ bpp.tryb_openaccess_wydawnictwo_ciagle: bpp.wydawnictwo_ciagle_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_ciagle streszczenie str bpp.wydawnictwo_ciagle_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_ciagle bpp.wydawnictwo_zwarte_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_zwarte tytul str "Tytuł" @@ -1007,6 +1043,7 @@ bpp.nagroda: uzasadnienie str bpp.tryb_openaccess_wydawnictwo_zwarte: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -1019,12 +1056,12 @@ bpp.seria_wydawnicza: bpp.wydawnictwo_zwarte_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_zwarte streszczenie str bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_zwarte @@ -1114,49 +1151,3 @@ bpp.organprzyznajacynagrody: nagroda -> bpp.nagroda? nazwa str skrot str - -dictionaries (shared relation values, referenced above): - bpp.dyscyplina_naukowa - nazwa: "archeologia", "architektura i urbanistyka", "astronomia", "automatyka, elektronika, elektrotechnika i technologie kosmiczne", "biologia medyczna", "biotechnologia", "ekonomia i finanse", "etnologia i antropologia kulturowa", "filozofia", "geografia społeczno-ekonomiczna i gospodarka przestrzenna", "historia", "informatyka", "informatyka techniczna i telekomunikacja", "inżynieria bezpieczeństwa", "inżynieria biomedyczna", "inżynieria chemiczna", "inżynieria lądowa, geodezja i transport", "inżynieria materiałowa", "inżynieria mechaniczna", "inżynieria środowiska, górnictwo i energetyka", "językoznawstwo", "literaturoznawstwo", "matematyka", "nauki biblijne", "nauki biologiczne", "nauki chemiczne", "nauki farmaceutyczne", "nauki fizyczne", "nauki leśne", "nauki medyczne", "nauki o bezpieczeństwie", "nauki o komunikacji społecznej i mediach", "nauki o kulturze fizycznej", "nauki o kulturze i religii", "nauki o polityce i administracji", "nauki o rodzinie", "nauki o sztuce", "nauki o zarządzaniu i jakości", "nauki o zdrowiu", "nauki o Ziemi i środowisku", "nauki prawne", "nauki socjologiczne", "nauki teologiczne", "pedagogika", "polonistyka", "prawo kanoniczne", "psychologia", "rolnictwo i ogrodnictwo", "stosunki międzynarodowe", "sztuki filmowe i teatralne", "sztuki muzyczne", "sztuki plastyczne i konserwacja dzieł sztuki", "technologia żywności i żywienia", "weterynaria", "zootechnika i rybactwo" - bpp.typ_odpowiedzialnosci - nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" - bpp.funkcja_autora - nazwa: "adiunkt", "asystent", "kierownik", "młodszy asystent", "starszy asystent" - bpp.plec - nazwa: "kobieta", "mężczyzna" - bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "dr hab. inż.", "dr inż.", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "mgr, inż.", "prof. dr", "prof. dr hab.", "prof. dr hab. inż.", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" - bpp.rodzajjednostki - nazwa: "Koło naukowe", "Standard", "Wydział" - bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Editorial", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Poster - streszczenie", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" - bpp.jezyk - nazwa: "abchaski", "Achinese", "Acoli", "Adangme", "Adyghe", "afar", "Afrihili", "Afro-Asiatic languages", "afrykanerski", "Ainu", "ajmara", "akadyjski", "akan", "albański", "Aleut", "Algonquian languages", "Altaic languages", "amharski", "angielski", "Angika", "Apache languages", "arabski", "aragoński", "Arapaho", "Arawak", "Aromanian", "Artificial languages", "asamski", "Asturian", "Athapascan languages", "Australian languages", "Austronesian languages", "Awadhi", "awarski", "awestyjski", "azerski", "Balinese", "Baltic languages", "Baluchi", "bambara", "Bamileke languages", "Banda languages", "Bantu languages", "Basa", "baskijski", "baszkirski", "Batak languages", "Beja", "Bemba", "bengalski", "Berber languages", "Bhojpuri", "białoruski", "bihari", "Bikol", "Bini", "birmański", "bislama", "Blin", "Blissymbols", "bośniacki", "Braj", "brak danych", "bretoński", "Buginese", "bułgarski", "Buriat", "Caddo", "Caucasian languages", "Cebuano", "Celtic languages", "Central American Indian languages", "Chagatai", "Chamic languages", "Cherokee", "Cheyenne", "Chibcha", "Chinook jargon", "chiński", "Chipewyan", "Choctaw", "chorwacki", "Chuukese", "Classical Newari", "Coptic", "Creek", "Creoles and pidgins", "Creoles and pidgins, English based", "Creoles and pidgins, French-based", "Creoles and pidgins, Portuguese-based", "Crimean Tatar", "Cushitic languages", "czamorro", "czarnogórski", "czeczeński", "czeski", "cziczewa", "czuwaski", "Dakota", "Dargwa", "Delaware", "Dinka", "Dogri", "Dogrib", "Dravidian languages", "Duala", "duński", "Dutch, Middle (ca.1050-1350)", "Dyula", "dzongkha", "Eastern Frisian", "Efik", "Egyptian (Ancient)", "Ekajuk", "Elamite", "English, Middle (1100-1500)", "Erzya", "esperanto", "estoński", "ewe", "Ewondo", "Fang", "Fanti", "farerski", "fidżyjski", "Filipino", "Finno-Ugrian languages", "fiński", "Fon", "francuski", "French, Middle (ca.1400-1600)", "French, Old (842-ca.1400)", "Friulian", "fryzyjski", "ful", "Ga", "gaelicki szkocki", "Galibi Carib", "galicyjski", "Gayo", "Gbaya", "Geez", "German, Middle High (ca.1050-1500)", "German, Old High (ca.750-1050)", "Germanic languages", "Gilbertese", "Gondi", "Gorontalo", "Gothic", "Grebo", "grecki (nowożytny)", "grecki (starożytny)", "grenlandzki", "gruziński", "guarani", "gudźarati", "Gwich'in", "Haida", "haitański", "hausa", "Hawaiian", "hebrajski", "herero", "Hiligaynon", "Himachali languages", "hindi", "hiri motu", "hiszpański", "Hittite", "Hmong", "holenderski", "Hupa", "Iban", "ido", "igbo", "Ijo languages", "Iloko", "Inari Sami", "Indic languages", "Indo-European languages", "indonezyjski", "Ingush", "inny", "interlingua", "inuktitut", "inupiak", "Iranian languages", "Irish, Middle (900-1200)", "Irish, Old (to 900)", "irlandzki", "Iroquoian languages", "islandzki", "japoński", "jawajski", "jidysz", "joruba", "Judeo-Arabic", "Judeo-Persian", "Kabardian", "Kabyle", "Kachin", "Kalmyk", "Kamba", "kannada", "kanuri", "Kara-Kalpak", "Karachay-Balkar", "Karelian", "Karen languages", "Kashubian", "kaszmirski", "kataloński", "Kawi", "kazachski", "keczua", "Khasi", "khmerski", "Khoisan languages", "Khotanese", "kikuju", "Kimbundu", "kirgiski", "Klingon", "komi", "kongo", "Konkani", "koreański", "kornijski", "korsykański", "Kosraean", "Kpelle", "kri", "Kru languages", "Kumyk", "kurdyjski", "Kurukh", "Kutenai", "kwanyama", "Ladino", "Lahnda", "Lamba", "Land Dayak languages", "laotański", "Lezghian", "limburski", "lingala", "litewski", "Lojban", "Low German", "Lower Sorbian", "Lozi", "Luba-katanga", "Luba-Lulua", "luganda", "Luiseno", "luksemburski", "Lule Sami", "Lunda", "Luo (Kenya and Tanzania)", "Lushai", "łaciński", "łotewski", "macedoński", "Madurese", "Magahi", "Maithili", "Makasar", "malajalam", "malajski", "malediwski", "malgaski", "maltański", "Manchu", "Mandar", "Mandingo", "Manipuri", "Manobo languages", "manx", "maoryski", "Mapudungun", "marathi", "Mari", "marszalski", "Marwari", "Masai", "Mayan languages", "Mende", "Mi'kmaq", "Minangkabau", "Mirandese", "Mohawk", "Moksha", "Mon-Khmer languages", "Mongo", "mongolski", "Mossi", "Munda languages", "N'Ko", "Nahuatl languages", "nauruański", "nawaho", "ndebele południowy", "ndebele północny", "ndonga", "Neapolitan", "Nepal Bhasa", "nepalski", "Nias", "niemiecki", "Niger-Kordofanian languages", "Nilo-Saharan languages", "Niuean", "No linguistic content", "Nogai", "Norse, Old", "North American Indian languages", "Northern Frisian", "norweski", "norweski (bokmål)", "norweski (nynorsk)", "Nubian languages", "nuosu", "Nyamwezi", "Nyankole", "Nyoro", "Nzima", "occidental", "odżibwe", "Official Aramaic (700-300 BCE)", "orija", "ormiański", "oromo", "Osage", "osetyjski", "Otomian languages", "Pahlavi", "Palauan", "pali", "Pampanga", "Pangasinan", "Papiamento", "Papuan languages", "paszto", "Pedi", "pendżabski", "Persian, Old (ca.600-400 B.C.)", "perski", "Philippine languages", "Phoenician", "Pohnpeian", "polski", "portugalski", "północnolapoński", "Prakrit languages", "Provençal, Old (to 1500)", "prowansalski", "Rajasthani", "Rapanui", "Rarotongan", "Romance languages", "romansz", "Romany", "rosyjski", "ruanda", "rumuński", "rundi", "Salishan languages", "Samaritan Aramaic", "Sami languages", "samoański", "Sandawe", "sango", "sanskryt", "Santali", "sardyński", "Sasak", "Scots", "Selkup", "Semitic languages", "serbski", "Serer", "Shan", "shona", "Sicilian", "Sidamo", "Sign Languages", "Siksika", "sindhi", "Sino-Tibetan languages", "Siouan languages", "Skolt Sami", "Slave (Athapascan)", "Slavic languages", "słowacki", "słoweński", "Sogdian", "somalijski", "Songhai languages", "Soninke", "Sorbian languages", "sotho południowy", "South American Indian languages", "Southern Altai", "Southern Sami", "Sranan Tongo", "Standard Moroccan Tamazight", "staro-cerkiewno-słowiański", "suahili", "suazi", "Sukuma", "sumeryjski", "sundajski", "Susu", "Swiss German", "syngaleski", "syryjski", "syryjski (klasyczny)", "szwedzki", "tadżycki", "tagalski", "tahitański", "Tai languages", "tajski", "Tamashek", "tamilski", "tatarski", "telugu", "Tereno", "Tetum", "Tigre", "tigrinia", "Timne", "Tiv", "Tlingit", "Tok Pisin", "Tokelau", "tonga", "Tonga (Nyasa)", "Tsimshian", "tsonga", "tswana", "Tumbuka", "Tupi languages", "turecki", "Turkish, Ottoman (1500-1928)", "turkmeński", "Tuvalu", "Tuvinian", "twi", "tybetański", "Udmurt", "ugarycki", "ujgurski", "ukraiński", "Umbundu", "Uncoded languages", "Undetermined", "Upper Sorbian", "urdu", "uzbecki", "Vai", "venda", "volapük", "Votic", "Wakashan languages", "walijski", "waloński", "Waray", "Washo", "węgierski", "wiele języków", "wietnamski", "włoski", "Wolaitta", "wolof", "xhosa", "Yakut", "Yao", "Yapese", "Yupik languages", "Zande languages", "Zapotec", "Zaza", "Zenaga", "zhuang", "zulu", "Zuni" - bpp.czas_udostepnienia_openaccess - nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" - bpp.licencja_openaccess - nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" - bpp.wersja_tekstu_openaccess - nazwa: "Inna", "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" - bpp.status_korekty - nazwa: "po korekcie", "przed korektą", "w trakcie korekty" - bpp.typ_kbn - nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" - ewaluacja_common.rodzaj_autora - nazwa: "doktorant", "inny zatrudniony, nie naukowy", "pracownik badawczy spoza N", "pracownik naukowy w liczbie N" - bpp.zrodlo_informacji - nazwa: "od autora", "od redakcji", "wartość szacunkowa" - bpp.rodzaj_prawa_patentowego - nazwa: "odmiana rośliny", "wynalazek", "wzór przemysłowy", "wzór użytkowy", "znak towarowy" - bpp.tryb_openaccess_wydawnictwo_ciagle - nazwa: "Inne", "Otwarte czasopismo", "Otwarte repositorium" - bpp.tryb_openaccess_wydawnictwo_zwarte - nazwa: "Inne", "Otwarte repositorium", "Witryna wydawcy" - bpp.charakter_pbn - help_text: "", " ", "Artykuł przeglądowy stanowi podsumowanie aktualnego stanu badań w danym obszarze tematycznym.", "Hasło encykolpedyczne/słownikowe/leksykonowe", "Monografia naukowa to spójne tematycznie, wyczerpujące opracowanie naukowe. W PBN za monografie naukowe uważane są również zbiorowe książki pokonferencyjne (tzw. conference proceedings). Jeśli dodajesz zbiorową książkę pokonferencyjną, pamiętaj o tym, by zaznaczyć również pola "Publikacja zbiorowa" oraz "Publikacja konferencyjna"", "Np. katalog zabytków", "Oryginalny artykuł naukowy przedstawia rezultaty oryginalnych badań naukowych lub eksperymentu.", "Publikacja podlegała recenzji naukowej", "Wstęp/Wprowadzenie/Przedmowa" - bpp.zewnetrzna_baza_danych - nazwa: "Scopus", "Web of Science" - bpp.rodzaj_zrodla - nazwa: "periodyk", "wydawnictwo", "źródło nieindeksowane" - bpp.zasieg_zrodla - nazwa: "krajowy", "międzynarodowy" diff --git a/src/bpp/data/rekord_djangoql_schema.compact.txt b/src/bpp/data/rekord_djangoql_schema.compact.txt index 62e2e6545..410fd98b0 100644 --- a/src/bpp/data/rekord_djangoql_schema.compact.txt +++ b/src/bpp/data/rekord_djangoql_schema.compact.txt @@ -1,8 +1,13 @@ -# BPP 202607.1397 +# BPP 202608.1399 # Model: bpp.Rekord Schemat: bpp.djangoql_schema.RekordLLMSchema # Wygenerowano: manage.py opisz_schemat_djangoql_dla_llm # Plik generowany — nie edytuj ręcznie. +# Rekordy skasowane (soft-delete, pole `deleted_at`) są DOMYŚLNIE pomijane. +# Zapytanie bez wzmianki o `deleted_at` widzi tylko żywe wiersze — nie dopisuj +# `deleted_at = None`, to już działa. Żeby zajrzeć do kosza, wymień to pole na +# wybranej relacji: `autorzy_set.deleted_at != None` = TYLKO skasowane autorstwa. + # DjangoQL schema # Query: , combined with and/or, grouped with (). # Negate with != / !~ / not in / not startswith / not endswith (no standalone `not`). @@ -23,7 +28,7 @@ start model: bpp.rekord bpp.rekord: autorzy -> bpp.autorzy? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" @@ -32,18 +37,18 @@ bpp.rekord: index_copernicus float informacje str isbn str - jezyk -> bpp.jezyk match nazwa "Język" + jezyk -> bpp.jezyk "Język" konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 liczba_autorow int liczba_cytowan int liczba_znakow_wydawniczych int? "Liczba znaków wydawniczych" - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str @@ -60,9 +65,9 @@ bpp.rekord: slowa_kluczowe -> taggit.tag? "Słowa kluczowe" — A comma-separated list of tags. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty szczegoly str "Szczegóły" — Np. str. 23-45 - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str tytul_oryginalny str tytul_oryginalny_sort str @@ -79,7 +84,7 @@ bpp.autorzy: afiliuje bool autor -> bpp.autor data_oswiadczenia date - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id unknown jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow @@ -88,22 +93,23 @@ bpp.autorzy: profil_orcid bool przypieta bool rekord -> bpp.rekord - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci upowaznienie_pbn bool zapisany_jako str zatrudniony bool bpp.charakter_formalny: charakter_ogolny str choices: Artykuł | Książka | Rozdział | Inne - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. id int level int lft int nazwa str nazwa_w_primo str choices: | Artykuł | Książka | Zasób tekstowy | Rozprawa naukowa | Recenzja | Artykuł prasowy | Rozdział | Czasopismo | Dane badawcze | Materiał konferencyjny | Obraz | Baza | Zestaw danych statystycznych | Multimedia | Inny - parent -> bpp.charakter_formalny? match nazwa "Charakter nadrzędny" + parent -> bpp.charakter_formalny? "Charakter nadrzędny" publikacja bool rekord -> bpp.rekord? rght int @@ -121,6 +127,7 @@ bpp.charakter_formalny: bpp.jezyk: id int + kod_bcp47 str "Kod języka wg BCP 47" — Kod języka w notacji BCP 47 (RFC 5646), np. „pl”, „en”, „en-GB”. Używany w eksporcie CERIF/OpenAIRE jako wartość atrybutu xml:lang; gdy pusty, elementy w tym języku zostaną wyeksportowane bez oznaczenia języka. nazwa str pbn_uid -> pbn_api.language? praca_doktorska -> bpp.praca_doktorska? @@ -169,6 +176,7 @@ bpp.licencja_openaccess: nazwa str rekord -> bpp.rekord? skrot str + uri str "Adres URI licencji" — Kanoniczny adres URI licencji, np. https://creativecommons.org/licenses/by/4.0/ . Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace na tej licencji zostaną wyeksportowane bez odnośnika do jej treści. wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? zrodlo -> bpp.zrodlo? @@ -224,7 +232,7 @@ bpp.status_korekty: bpp.typ_kbn: artykul_pbn bool "Artykuł w PBN" — Wydawnictwa ciągłe posiadające ten typ MNiSW/MEiN zostaną włączone do eksportu PBN jako artykuły - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? id int nazwa str praca_doktorska -> bpp.praca_doktorska? @@ -258,7 +266,8 @@ bpp.wydawnictwo_zwarte: cached_punkty_dyscyplin unknown? calkowita_liczba_autorow int? calkowita_liczba_redaktorow int? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_zwarte_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -267,13 +276,13 @@ bpp.wydawnictwo_zwarte: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również @@ -284,12 +293,12 @@ bpp.wydawnictwo_zwarte: nie_eksportuj_przez_api bool numer_odbitki str numer_w_serii str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? match nazwa - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -319,20 +328,22 @@ bpp.wydawnictwo_zwarte: recenzowana bool redakcja str rekord -> bpp.rekord? + restored_at datetime? rok int search_index unknown seria_wydawnicza -> bpp.seria_wydawnicza? slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_zwarte_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -353,7 +364,7 @@ bpp.wydawnictwo_zwarte: zewnetrzna_baza_danych -> bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych? bpp.zewnetrznebazydanychview: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str rekord -> bpp.rekord @@ -364,16 +375,16 @@ bpp.zrodlo: e_issn str "e-ISSN" id int issn str - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? nazwa str nazwa_alternatywna str? - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" openaccess_tryb_dostepu str "OpenAccess: tryb dostępu" choices: pełny | częściowy ostatnio_zmieniony datetime? pbn_uid -> pbn_api.journal? "Odpowiednik w PBN" poprzednia_nazwa str? "Poprzedni tytuł" rekord -> bpp.rekord? - rodzaj -> bpp.rodzaj_zrodla match nazwa + rodzaj -> bpp.rodzaj_zrodla search unknown skrot str "Skrót" skrot_nazwy_alternatywnej str? @@ -381,10 +392,10 @@ bpp.zrodlo: www str? wydawca str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? - zasieg -> bpp.zasieg_zrodla? match nazwa + zasieg -> bpp.zasieg_zrodla? bpp.autor: - aktualna_funkcja -> bpp.funkcja_autora? match nazwa + aktualna_funkcja -> bpp.funkcja_autora? aktualna_jednostka -> bpp.jednostka? autor_dyscyplina -> bpp.autor_dyscyplina? autor_jednostka -> bpp.autor_jednostka? @@ -405,7 +416,7 @@ bpp.autor: patent -> bpp.patent? patent_autor -> bpp.patent_autor? pbn_uid -> pbn_api.scientist? - plec -> bpp.plec? match nazwa + plec -> bpp.plec? pokazuj bool pokazuj_opis bool pokazuj_poprzednie_nazwiska bool @@ -419,8 +430,7 @@ bpp.autor: slug str sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni - tytul -> bpp.tytul? match nazwa - urodzony date? + tytul -> bpp.tytul? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -457,6 +467,7 @@ bpp.jednostka: level int lft int nazwa str + nie_eksportuj_przez_api bool obca_jednostka -> bpp.uczelnia? opis str? ostatnio_zmieniony datetime? @@ -469,7 +480,8 @@ bpp.jednostka: praca_doktorska -> bpp.praca_doktorska? praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int - rodzaj -> bpp.rodzajjednostki? match nazwa + rodzaj -> bpp.rodzajjednostki? + ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" skrot_nazwy str? @@ -528,7 +540,8 @@ bpp.wydawnictwo_ciagle: autorzy -> bpp.autor autorzy_set -> bpp.wydawnictwo_ciagle_autor? cached_punkty_dyscyplin unknown? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_ciagle_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -536,12 +549,12 @@ bpp.wydawnictwo_ciagle: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 @@ -552,12 +565,12 @@ bpp.wydawnictwo_ciagle: nie_eksportuj_przez_api bool nr_zeszytu str numer_odbitki str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? match nazwa "OpenAccess: tryb dostępu" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? "OpenAccess: tryb dostępu" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -584,19 +597,21 @@ bpp.wydawnictwo_ciagle: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_ciagle_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -618,19 +633,20 @@ pbn_api.language: bpp.praca_doktorska: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -658,18 +674,20 @@ bpp.praca_doktorska: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -682,19 +700,20 @@ bpp.praca_doktorska: bpp.praca_habilitacyjna: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -721,18 +740,20 @@ bpp.praca_habilitacyjna: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -745,26 +766,26 @@ bpp.praca_habilitacyjna: bpp.wydawnictwo_ciagle_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_ciagle streszczenie str bpp.wydawnictwo_ciagle_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_ciagle tytul str "Tytuł" bpp.wydawnictwo_zwarte_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_zwarte streszczenie str bpp.wydawnictwo_zwarte_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_zwarte tytul str "Tytuł" @@ -785,11 +806,12 @@ bpp.patent: cached_punkty_dyscyplin unknown? data_decyzji date? data_zgloszenia date? "Data zgłoszenia" + deleted_at datetime? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str nie_eksportuj_przez_api bool numer_prawa_wylacznego str? "Numer prawa wyłącznego" @@ -804,18 +826,20 @@ bpp.patent: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool - rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? match nazwa + restored_at datetime? + rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str + transaction_id str? tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str utworzono datetime? @@ -847,7 +871,8 @@ bpp.wydawnictwo_zwarte_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -858,7 +883,9 @@ bpp.wydawnictwo_zwarte_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_zwarte - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + restored_at datetime? + transaction_id str? + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -882,6 +909,7 @@ bpp.nagroda: uzasadnienie str bpp.tryb_openaccess_wydawnictwo_zwarte: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -893,7 +921,7 @@ bpp.seria_wydawnicza: wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_zwarte @@ -941,20 +969,20 @@ bpp.funkcja_autora: bpp.autor_dyscyplina: autor -> bpp.autor - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id int procent_dyscypliny float? procent_subdyscypliny float? - rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa + rodzaj_autora -> ewaluacja_common.rodzaj_autora? rok int - subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? zatrudnienie_do datetime? zatrudnienie_od datetime? bpp.autor_jednostka: autor -> bpp.autor - funkcja -> bpp.funkcja_autora? match nazwa + funkcja -> bpp.funkcja_autora? grupa_pracownicza -> bpp.grupa_pracownicza? id int jednostka -> bpp.jednostka @@ -965,7 +993,7 @@ bpp.autor_jednostka: bpp.cache_punktacja_autora: autor -> bpp.autor - dyscyplina -> bpp.dyscyplina_naukowa match nazwa + dyscyplina -> bpp.dyscyplina_naukowa id int jednostka -> bpp.jednostka pkdaut float @@ -976,7 +1004,8 @@ bpp.patent_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -987,7 +1016,9 @@ bpp.patent_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.patent - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + restored_at datetime? + transaction_id str? + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -1025,7 +1056,8 @@ bpp.wydawnictwo_ciagle_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -1036,7 +1068,9 @@ bpp.wydawnictwo_ciagle_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_ciagle - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + restored_at datetime? + transaction_id str? + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -1074,21 +1108,24 @@ bpp.rodzajjednostki: wyklucz_z_rankingu_autorow bool bpp.tryb_openaccess_wydawnictwo_ciagle: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? bpp.wydawnictwo_ciagle_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_ciagle bpp.rodzaj_prawa_patentowego: - id int - nazwa str - patent -> bpp.patent? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_15cd dla patentu. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, patenty o tym rodzaju prawa zostaną wyeksportowane bez typu zasobu. + eksportuj_jako_patent bool "Eksportuj do CERIF jako patent" — Odznacz dla praw, które nie są patentami w rozumieniu słownika COAR (np. znak towarowy). Takie rekordy nie trafią do eksportu CERIF/OpenAIRE — profil wymaga dla każdego rekordu typu z gałęzi „patent”, więc jedyną alternatywą byłoby zadeklarowanie ich patentami wbrew prawdzie. + id int + nazwa str + patent -> bpp.patent? bpp.organprzyznajacynagrody: id int @@ -1114,49 +1151,3 @@ bpp.wymiar_etatu: autor_jednostka -> bpp.autor_jednostka? id int nazwa str - -dictionaries (shared relation values, referenced above): - bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Editorial", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Poster - streszczenie", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" - bpp.jezyk - nazwa: "abchaski", "Achinese", "Acoli", "Adangme", "Adyghe", "afar", "Afrihili", "Afro-Asiatic languages", "afrykanerski", "Ainu", "ajmara", "akadyjski", "akan", "albański", "Aleut", "Algonquian languages", "Altaic languages", "amharski", "angielski", "Angika", "Apache languages", "arabski", "aragoński", "Arapaho", "Arawak", "Aromanian", "Artificial languages", "asamski", "Asturian", "Athapascan languages", "Australian languages", "Austronesian languages", "Awadhi", "awarski", "awestyjski", "azerski", "Balinese", "Baltic languages", "Baluchi", "bambara", "Bamileke languages", "Banda languages", "Bantu languages", "Basa", "baskijski", "baszkirski", "Batak languages", "Beja", "Bemba", "bengalski", "Berber languages", "Bhojpuri", "białoruski", "bihari", "Bikol", "Bini", "birmański", "bislama", "Blin", "Blissymbols", "bośniacki", "Braj", "brak danych", "bretoński", "Buginese", "bułgarski", "Buriat", "Caddo", "Caucasian languages", "Cebuano", "Celtic languages", "Central American Indian languages", "Chagatai", "Chamic languages", "Cherokee", "Cheyenne", "Chibcha", "Chinook jargon", "chiński", "Chipewyan", "Choctaw", "chorwacki", "Chuukese", "Classical Newari", "Coptic", "Creek", "Creoles and pidgins", "Creoles and pidgins, English based", "Creoles and pidgins, French-based", "Creoles and pidgins, Portuguese-based", "Crimean Tatar", "Cushitic languages", "czamorro", "czarnogórski", "czeczeński", "czeski", "cziczewa", "czuwaski", "Dakota", "Dargwa", "Delaware", "Dinka", "Dogri", "Dogrib", "Dravidian languages", "Duala", "duński", "Dutch, Middle (ca.1050-1350)", "Dyula", "dzongkha", "Eastern Frisian", "Efik", "Egyptian (Ancient)", "Ekajuk", "Elamite", "English, Middle (1100-1500)", "Erzya", "esperanto", "estoński", "ewe", "Ewondo", "Fang", "Fanti", "farerski", "fidżyjski", "Filipino", "Finno-Ugrian languages", "fiński", "Fon", "francuski", "French, Middle (ca.1400-1600)", "French, Old (842-ca.1400)", "Friulian", "fryzyjski", "ful", "Ga", "gaelicki szkocki", "Galibi Carib", "galicyjski", "Gayo", "Gbaya", "Geez", "German, Middle High (ca.1050-1500)", "German, Old High (ca.750-1050)", "Germanic languages", "Gilbertese", "Gondi", "Gorontalo", "Gothic", "Grebo", "grecki (nowożytny)", "grecki (starożytny)", "grenlandzki", "gruziński", "guarani", "gudźarati", "Gwich'in", "Haida", "haitański", "hausa", "Hawaiian", "hebrajski", "herero", "Hiligaynon", "Himachali languages", "hindi", "hiri motu", "hiszpański", "Hittite", "Hmong", "holenderski", "Hupa", "Iban", "ido", "igbo", "Ijo languages", "Iloko", "Inari Sami", "Indic languages", "Indo-European languages", "indonezyjski", "Ingush", "inny", "interlingua", "inuktitut", "inupiak", "Iranian languages", "Irish, Middle (900-1200)", "Irish, Old (to 900)", "irlandzki", "Iroquoian languages", "islandzki", "japoński", "jawajski", "jidysz", "joruba", "Judeo-Arabic", "Judeo-Persian", "Kabardian", "Kabyle", "Kachin", "Kalmyk", "Kamba", "kannada", "kanuri", "Kara-Kalpak", "Karachay-Balkar", "Karelian", "Karen languages", "Kashubian", "kaszmirski", "kataloński", "Kawi", "kazachski", "keczua", "Khasi", "khmerski", "Khoisan languages", "Khotanese", "kikuju", "Kimbundu", "kirgiski", "Klingon", "komi", "kongo", "Konkani", "koreański", "kornijski", "korsykański", "Kosraean", "Kpelle", "kri", "Kru languages", "Kumyk", "kurdyjski", "Kurukh", "Kutenai", "kwanyama", "Ladino", "Lahnda", "Lamba", "Land Dayak languages", "laotański", "Lezghian", "limburski", "lingala", "litewski", "Lojban", "Low German", "Lower Sorbian", "Lozi", "Luba-katanga", "Luba-Lulua", "luganda", "Luiseno", "luksemburski", "Lule Sami", "Lunda", "Luo (Kenya and Tanzania)", "Lushai", "łaciński", "łotewski", "macedoński", "Madurese", "Magahi", "Maithili", "Makasar", "malajalam", "malajski", "malediwski", "malgaski", "maltański", "Manchu", "Mandar", "Mandingo", "Manipuri", "Manobo languages", "manx", "maoryski", "Mapudungun", "marathi", "Mari", "marszalski", "Marwari", "Masai", "Mayan languages", "Mende", "Mi'kmaq", "Minangkabau", "Mirandese", "Mohawk", "Moksha", "Mon-Khmer languages", "Mongo", "mongolski", "Mossi", "Munda languages", "N'Ko", "Nahuatl languages", "nauruański", "nawaho", "ndebele południowy", "ndebele północny", "ndonga", "Neapolitan", "Nepal Bhasa", "nepalski", "Nias", "niemiecki", "Niger-Kordofanian languages", "Nilo-Saharan languages", "Niuean", "No linguistic content", "Nogai", "Norse, Old", "North American Indian languages", "Northern Frisian", "norweski", "norweski (bokmål)", "norweski (nynorsk)", "Nubian languages", "nuosu", "Nyamwezi", "Nyankole", "Nyoro", "Nzima", "occidental", "odżibwe", "Official Aramaic (700-300 BCE)", "orija", "ormiański", "oromo", "Osage", "osetyjski", "Otomian languages", "Pahlavi", "Palauan", "pali", "Pampanga", "Pangasinan", "Papiamento", "Papuan languages", "paszto", "Pedi", "pendżabski", "Persian, Old (ca.600-400 B.C.)", "perski", "Philippine languages", "Phoenician", "Pohnpeian", "polski", "portugalski", "północnolapoński", "Prakrit languages", "Provençal, Old (to 1500)", "prowansalski", "Rajasthani", "Rapanui", "Rarotongan", "Romance languages", "romansz", "Romany", "rosyjski", "ruanda", "rumuński", "rundi", "Salishan languages", "Samaritan Aramaic", "Sami languages", "samoański", "Sandawe", "sango", "sanskryt", "Santali", "sardyński", "Sasak", "Scots", "Selkup", "Semitic languages", "serbski", "Serer", "Shan", "shona", "Sicilian", "Sidamo", "Sign Languages", "Siksika", "sindhi", "Sino-Tibetan languages", "Siouan languages", "Skolt Sami", "Slave (Athapascan)", "Slavic languages", "słowacki", "słoweński", "Sogdian", "somalijski", "Songhai languages", "Soninke", "Sorbian languages", "sotho południowy", "South American Indian languages", "Southern Altai", "Southern Sami", "Sranan Tongo", "Standard Moroccan Tamazight", "staro-cerkiewno-słowiański", "suahili", "suazi", "Sukuma", "sumeryjski", "sundajski", "Susu", "Swiss German", "syngaleski", "syryjski", "syryjski (klasyczny)", "szwedzki", "tadżycki", "tagalski", "tahitański", "Tai languages", "tajski", "Tamashek", "tamilski", "tatarski", "telugu", "Tereno", "Tetum", "Tigre", "tigrinia", "Timne", "Tiv", "Tlingit", "Tok Pisin", "Tokelau", "tonga", "Tonga (Nyasa)", "Tsimshian", "tsonga", "tswana", "Tumbuka", "Tupi languages", "turecki", "Turkish, Ottoman (1500-1928)", "turkmeński", "Tuvalu", "Tuvinian", "twi", "tybetański", "Udmurt", "ugarycki", "ujgurski", "ukraiński", "Umbundu", "Uncoded languages", "Undetermined", "Upper Sorbian", "urdu", "uzbecki", "Vai", "venda", "volapük", "Votic", "Wakashan languages", "walijski", "waloński", "Waray", "Washo", "węgierski", "wiele języków", "wietnamski", "włoski", "Wolaitta", "wolof", "xhosa", "Yakut", "Yao", "Yapese", "Yupik languages", "Zande languages", "Zapotec", "Zaza", "Zenaga", "zhuang", "zulu", "Zuni" - bpp.czas_udostepnienia_openaccess - nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" - bpp.licencja_openaccess - nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" - bpp.wersja_tekstu_openaccess - nazwa: "Inna", "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" - bpp.status_korekty - nazwa: "po korekcie", "przed korektą", "w trakcie korekty" - bpp.typ_kbn - nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" - bpp.dyscyplina_naukowa - nazwa: "archeologia", "architektura i urbanistyka", "astronomia", "automatyka, elektronika, elektrotechnika i technologie kosmiczne", "biologia medyczna", "biotechnologia", "ekonomia i finanse", "etnologia i antropologia kulturowa", "filozofia", "geografia społeczno-ekonomiczna i gospodarka przestrzenna", "historia", "informatyka", "informatyka techniczna i telekomunikacja", "inżynieria bezpieczeństwa", "inżynieria biomedyczna", "inżynieria chemiczna", "inżynieria lądowa, geodezja i transport", "inżynieria materiałowa", "inżynieria mechaniczna", "inżynieria środowiska, górnictwo i energetyka", "językoznawstwo", "literaturoznawstwo", "matematyka", "nauki biblijne", "nauki biologiczne", "nauki chemiczne", "nauki farmaceutyczne", "nauki fizyczne", "nauki leśne", "nauki medyczne", "nauki o bezpieczeństwie", "nauki o komunikacji społecznej i mediach", "nauki o kulturze fizycznej", "nauki o kulturze i religii", "nauki o polityce i administracji", "nauki o rodzinie", "nauki o sztuce", "nauki o zarządzaniu i jakości", "nauki o zdrowiu", "nauki o Ziemi i środowisku", "nauki prawne", "nauki socjologiczne", "nauki teologiczne", "pedagogika", "polonistyka", "prawo kanoniczne", "psychologia", "rolnictwo i ogrodnictwo", "stosunki międzynarodowe", "sztuki filmowe i teatralne", "sztuki muzyczne", "sztuki plastyczne i konserwacja dzieł sztuki", "technologia żywności i żywienia", "weterynaria", "zootechnika i rybactwo" - bpp.typ_odpowiedzialnosci - nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" - bpp.charakter_pbn - help_text: "", " ", "Artykuł przeglądowy stanowi podsumowanie aktualnego stanu badań w danym obszarze tematycznym.", "Hasło encykolpedyczne/słownikowe/leksykonowe", "Monografia naukowa to spójne tematycznie, wyczerpujące opracowanie naukowe. W PBN za monografie naukowe uważane są również zbiorowe książki pokonferencyjne (tzw. conference proceedings). Jeśli dodajesz zbiorową książkę pokonferencyjną, pamiętaj o tym, by zaznaczyć również pola "Publikacja zbiorowa" oraz "Publikacja konferencyjna"", "Np. katalog zabytków", "Oryginalny artykuł naukowy przedstawia rezultaty oryginalnych badań naukowych lub eksperymentu.", "Publikacja podlegała recenzji naukowej", "Wstęp/Wprowadzenie/Przedmowa" - bpp.zrodlo_informacji - nazwa: "od autora", "od redakcji", "wartość szacunkowa" - bpp.tryb_openaccess_wydawnictwo_zwarte - nazwa: "Inne", "Otwarte repositorium", "Witryna wydawcy" - bpp.zewnetrzna_baza_danych - nazwa: "Scopus", "Web of Science" - bpp.rodzaj_zrodla - nazwa: "periodyk", "wydawnictwo", "źródło nieindeksowane" - bpp.zasieg_zrodla - nazwa: "krajowy", "międzynarodowy" - bpp.funkcja_autora - nazwa: "adiunkt", "asystent", "kierownik", "młodszy asystent", "starszy asystent" - bpp.plec - nazwa: "kobieta", "mężczyzna" - bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "dr hab. inż.", "dr inż.", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "mgr, inż.", "prof. dr", "prof. dr hab.", "prof. dr hab. inż.", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" - bpp.rodzajjednostki - nazwa: "Koło naukowe", "Standard", "Wydział" - bpp.tryb_openaccess_wydawnictwo_ciagle - nazwa: "Inne", "Otwarte czasopismo", "Otwarte repositorium" - bpp.rodzaj_prawa_patentowego - nazwa: "odmiana rośliny", "wynalazek", "wzór przemysłowy", "wzór użytkowy", "znak towarowy" - ewaluacja_common.rodzaj_autora - nazwa: "doktorant", "inny zatrudniony, nie naukowy", "pracownik badawczy spoza N", "pracownik naukowy w liczbie N" diff --git a/src/bpp/demo_data/orchestrator.py b/src/bpp/demo_data/orchestrator.py index b0da5ea00..03fbda2c3 100644 --- a/src/bpp/demo_data/orchestrator.py +++ b/src/bpp/demo_data/orchestrator.py @@ -302,10 +302,24 @@ def run_cleanup(opts: CleanupOptions, *, stdin=None, stdout=None): total=n_batches, disable=opts.disable_progress, ) + # Manager widzący RÓWNIEŻ kosz i kasowanie NIEODWRACALNE — patrz + # komentarz niżej. `getattr` zamiast `isinstance`, bo lista modeli + # przychodzi z manifestu i miesza modele soft-delete z resztą. + manager = getattr(model, "global_objects", model.objects) + for start in pbar: chunk = sorted_pks[start : start + opts.batch_size] with transaction.atomic(): - model.objects.filter(pk__in=chunk).delete() + qs = manager.filter(pk__in=chunk) + # ⚠️ Na modelu soft-delete `QuerySet.delete()` jest MIĘKKIE: + # wiersz zostaje. Cleanup danych demo musi kasować trwale, + # i to z dwóch powodów. Po pierwsze zostawione publikacje + # trzymają FK z PROTECT (np. `Wydawnictwo_Zwarte.wydawca`), + # więc kasowanie słowników dalej w manifeście wywala się na + # `ProtectedError`. Po drugie „posprzątane" dane demo, które + # nadal siedzą w bazie, mijają się z celem komendy. + hard = getattr(qs, "hard_delete", None) + (hard or qs.delete)() ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") applied = opts.manifest.with_suffix(opts.manifest.suffix + f".applied.{ts}") diff --git a/src/bpp/djangoql_schema.py b/src/bpp/djangoql_schema.py index 567c2ec1d..c98f0339d 100644 --- a/src/bpp/djangoql_schema.py +++ b/src/bpp/djangoql_schema.py @@ -25,6 +25,7 @@ from django.utils.html import strip_tags from djangoql.extras import AutocompleteField, ExtrasSchema +from bpp.djangoql_soft_delete import WykluczSkasowaneMixin from bpp.models import Charakter_Formalny, Jednostka @@ -243,13 +244,16 @@ def get_field_instance(self, model, field_name): return super().get_field_instance(model, field_name) -class BppQLSchema(RelPickerSchemaMixin, ExtrasSchema): +class BppQLSchema(WykluczSkasowaneMixin, RelPickerSchemaMixin, ExtrasSchema): """ExtrasSchema (agregaty + części dat) + auto-pickery ``__rel``. Wspólny dla widoku „Szukaj zapytaniem" i adminów (``djangoql_schema = BppQLSchema``). Mapa modeli jest budowana lazy per-model, więc ten sam schemat obsługuje dowolny model (Rekord, Autor, Wydawnictwo_*, Patent, Praca_Doktorska/Habilitacyjna, …). + + ``WykluczSkasowaneMixin`` domyślnie odsiewa kosz (soft-delete) — + kontrakt „jak zobaczyć kosz" w ``bpp/djangoql_soft_delete.py``. """ def excluded(self, model): @@ -570,7 +574,9 @@ def _build_llm_fk_options(): } -class RekordLLMSchema(DeprecatedAndRestrictedFieldsMixin, ExtrasSchema): +class RekordLLMSchema( + WykluczSkasowaneMixin, DeprecatedAndRestrictedFieldsMixin, ExtrasSchema +): """Schemat dla eksportu opisu DjangoQL do promptu LLM. Baza ``ExtrasSchema`` (agregaty + części dat), **bez** pickerów diff --git a/src/bpp/djangoql_soft_delete.py b/src/bpp/djangoql_soft_delete.py new file mode 100644 index 000000000..f84fa7e32 --- /dev/null +++ b/src/bpp/djangoql_soft_delete.py @@ -0,0 +1,357 @@ +"""Domyślne odsiewanie kosza (soft-delete) w zapytaniach DjangoQL. + +DECYZJA (2026-08-07) — ZMIANA WCZEŚNIEJSZEJ +=========================================== + +Przy naprawie wycieku ORM (commit ``e80d1656e``) DjangoQL zostawiono +świadomie surowy, z uzasadnieniem „język zapytań użytkownika jest +narzędziem audytowym, ma widzieć kosz". Właściciel projektu zdecydował +inaczej: **domyślnie kosz ma być niewidoczny**, a audyt ma być czynnością +JAWNĄ. Ten moduł realizuje tę decyzję. + +KONTRAKT DLA UŻYTKOWNIKA — jak zobaczyć kosz +============================================ + +Regułą steruje sam tekst zapytania, per ŚCIEŻKA RELACJI: + +* zapytanie, które przechodzi przez relację do modelu soft-delete i nie + wspomina o ``deleted_at`` na tej relacji, dostaje domyślny predykat + ``.deleted_at = None`` (kosz niewidoczny):: + + autorzy_set.autor.nazwisko = "Kowalski" + # → tylko ŻYWE autorstwa + +* wystarczy w zapytaniu wymienić ``deleted_at`` na tej samej ścieżce, żeby + BPP przestało cokolwiek dokładać — od tego momentu kosz jest w Twoich + rękach:: + + autorzy_set.autor.nazwisko = "Kowalski" and autorzy_set.deleted_at != None + # → TYLKO kosz (skasowane autorstwa Kowalskiego) + + autorzy_set.autor.nazwisko = "Kowalski" and autorzy_set.deleted_at = None + # → to samo, co domyślnie (predykat napisany ręcznie) + +* zdjęcie predykatu jest PER PREFIKS, nie globalne: w zapytaniu mieszanym + ``autorzy_set.deleted_at != None and wydawnictwo_zwarte_autor.autor.id = 5`` + kosz odsłania się tylko dla ``autorzy_set``; + +* ``.deleted_at != None`` znaczy „relacja MA wiersz w koszu" + (a nie, jak wyszłoby z gołego djangoql, „relacja nie ma wiersza żywego") + — patrz :func:`_deleted_at_pozytywne`. + +DLACZEGO W SCHEMACIE, A NIE W QUERYSECIE BAZOWYM +================================================ + +Filtr na querysecie bazowym (``qs.filter(deleted_at__isnull=True)``) +załatwia wyłącznie model-korzeń — a ten i tak jest odsiany domyślnym +managerem (``BppSoftDeleteManager``). Problemem, który wracał w fazie 01 +trzy razy, jest **JOIN przez relację**: ``filter(autorzy_set__…)`` nie pyta +managera, tylko złącza surową tabelę. Dołożenie predykatu osobnym +``.filter()`` NIE pomaga — Django zrobi wtedy DRUGI, nieskorelowany JOIN +i warunek zdegeneruje się do „istnieje jakiekolwiek żywe autorstwo". +Predykat musi trafić do TEGO SAMEGO ``Q``, które buduje warunek +użytkownika — a jedynym miejscem, gdzie to ``Q`` powstaje, jest +``DjangoQLField.get_lookup()`` zwracane przez ``schema.resolve_name()``. + +Stąd wpięcie w ``resolve_name`` — jedno miejsce, które obsługuje WSZYSTKIE +punkty wejścia DjangoQL w BPP naraz: adminy (``BppDjangoQLSearchMixin``), +widok „Szukaj zapytaniem", ``/api/v1/zapytanie/*``, eksport +multiseek→DjangoQL oraz rozbicie „dlaczego 0 wyników" +(``djangoql.breakdown`` woła ``build_filter`` bezpośrednio). + +ZAKRES — introspekcja, nie lista nazw +===================================== + +Modele objęte odsiewaniem wykrywamy przez ``issubclass(model, +SoftDeleteModel)``. Dziś to trzy through-modele ``*_Autor``; gdy faza 02 +uczyni soft-delete pięć modeli publikacji, ten moduł nie wymaga ŻADNEJ +zmiany. + +ZNANE ŚLEPE PLAMY (świadome) +============================ + +* **M2M przez soft-delete through-model** — ``Autor.wydawnictwo_ciagle`` + idzie przez ``Wydawnictwo_Ciagle_Autor``, ale ``related_model`` tej + relacji to ``Wydawnictwo_Ciagle`` (nie soft-delete), a Django nie + pozwala dołożyć warunku na tabelę pośrednią w ścieżce M2M. Ekwiwalent + z predykatem: ``wydawnictwo_ciagle_autor.rekord.…`` (through jawnie). +* **porównanie samej relacji z None** — ``autorzy_set != None`` nie + dostaje predykatu, bo ``resolve_name()`` zwraca dla takiej ścieżki + ``None`` (djangoql buduje wtedy pole abstrakcyjne poza naszym zasięgiem). + Ekwiwalent z predykatem: ``autorzy_set.id != None``. +* **operatory negujące na ścieżce relacyjnej** (``autorzy_set.autor.id != + 5``) — predykat NIE jest dokładany. Django świadomie DEKORELUJE negację + na relacji wielowartościowej: ``~Q(A) & Q(B)`` kompiluje się do + ``NOT (EXISTS(A) AND EXISTS(B))`` z dwoma NIEZALEŻNYMI podzapytaniami + (udokumentowana różnica ``exclude()`` vs ``filter()`` — „Spanning + multi-valued relationships"). Predykat wciągnięty tam pod negację nie + zawęża wiersza, tylko dokłada osobny warunek „istnieje jakieś żywe + autorstwo" — czyli zmienia znaczenie zapytania. Poprawne rozwiązanie + (``exclude(rel__in=Subquery)``) wymagałoby przepisywania ``Q`` + użytkownika i nie da się go zrobić ogólnie na poziomie pola. Tryb awarii + jest ZACHOWAWCZY: skasowany wiersz może spowodować, że publikacja NIE + zostanie zwrócona — nigdy odwrotnie, więc treść kosza dalej nie wycieka. + Predykat na samym modelu-korzeniu (bez JOIN-u) działa też pod negacją. +* **agregaty relacyjne** (``autorzy_set__count``) — NIE są ślepą plamą, + ale obsługuje je inny mechanizm: podmiana managera źródłowego + podzapytania (:func:`_agregat_bez_skasowanych`), bo agregat nie filtruje + JOIN-em. +""" + +import copy + +from django.db.models import Q +from django_softdelete.models import SoftDeleteModel + +#: Nazwa pola-znacznika soft-delete (``django-soft-delete``). +POLE_SOFT_DELETE = "deleted_at" + +#: Operatory DjangoQL, dla których ``DjangoQLField.get_lookup()`` zwraca +#: ``~Q`` (kopia negatywnej połowy mapy z ``DjangoQLField.get_operator``). +#: Predykatu NIE dokładamy do nich na ścieżkach relacyjnych — patrz +#: „Znane ślepe plamy" w docstringu modułu. +OPERATORY_NEGUJACE = frozenset({"!=", "!~", "not in", "not startswith", "not endswith"}) + + +def jest_modelem_soft_delete(model) -> bool: + """Czy ``model`` jest modelem soft-delete (ma kolumnę ``deleted_at``)?""" + return isinstance(model, type) and issubclass(model, SoftDeleteModel) + + +def prefiksy_z_jawnym_deleted_at(node) -> frozenset: + """Prefiksy ścieżek, na których UŻYTKOWNIK sam wspomniał ``deleted_at``. + + Czysto składniowe — chodzi o intencję („pytam o kosz"), nie o to, czy + pole istnieje; walidację i tak zrobi zaraz potem sam djangoql. Dzięki + temu funkcja nie może wysypać się wcześniej niż walidator i nie musi + znać modeli. + """ + operator = getattr(node, "operator", None) + if getattr(operator, "operator", None) in ("and", "or"): + return prefiksy_z_jawnym_deleted_at(node.left) | prefiksy_z_jawnym_deleted_at( + node.right + ) + parts = getattr(getattr(node, "left", None), "parts", None) + if parts and parts[-1] == POLE_SOFT_DELETE: + return frozenset({tuple(parts[:-1])}) + return frozenset() + + +def _prefiksy_soft_delete(schema, parts): + """Prefiksy ścieżki ``parts``, na których zapytanie WCHODZI w model + soft-delete. ``()`` oznacza sam model-korzeń. + + Odwzorowuje trawersację z ``DjangoQLSchema.resolve_name()`` (łącznie z + hakiem ``resolve_unknown`` na wirtualne pola). Przy nieznanym członie + kończy cicho — komunikat błędu jest zadaniem ``resolve_name()``. + """ + prefiksy = [] + if jest_modelem_soft_delete(schema.current_model): + prefiksy.append(()) + + etykieta = schema.model_label(schema.current_model) + model_cls = schema.current_model + poprzednia_relacja = None + for i, czlon in enumerate(parts): + pole = schema.models.get(etykieta, {}).get(czlon) + if pole is None: + pole = schema.resolve_unknown(model_cls, poprzednia_relacja, czlon) + if pole is None: + break + if pole.type == "relation": + poprzednia_relacja = pole + etykieta = pole.relation + model_cls = pole.related_model + if jest_modelem_soft_delete(model_cls): + prefiksy.append(tuple(parts[: i + 1])) + else: + poprzednia_relacja = None + return prefiksy + + +class _ZrodloBezSkasowanych: + """Podstawka pod ``AggregateField.related_model`` w podzapytaniu agregatu. + + ``djangoql.extras.AggregateField._subquery()`` buduje skorelowane + podzapytanie z ``self.related_model._base_manager``. ``_base_manager`` + Django to goły ``Manager()`` — NIE nasz ``BppSoftDeleteManager`` — + więc ``autorzy_set__count`` liczyłby również kosz. Podstawiamy obiekt, + którego ``_base_manager`` to manager odsiewający. + + Świadomie WĄSKA atrapa: gdyby djangoql zaczął sięgać po inny atrybut + modelu, dostaniemy głośny ``AttributeError`` zamiast cichego powrotu + do liczenia kosza. + """ + + def __init__(self, model): + self._base_manager = model.objects + + +def _deleted_at_pozytywne(pole): + """Kopia pola ``deleted_at``, w której ``!= None`` znaczy „ma wiersz w + koszu", a nie „nie ma wiersza żywego". + + DLACZEGO: djangoql tłumaczy ``!=`` na ``~Q(...)``, a Django kompiluje + negację na relacji wielowartościowej jako ``NOT EXISTS(...)`` — więc + ``autorzy_set.deleted_at != None`` znaczyłoby „publikacja NIE MA + żadnego żywego autorstwa". Dla publikacji z jednym autorstwem + skasowanym i drugim żywym dałoby to pustkę, choć użytkownik pytał o + kosz. Wersja pozytywna (``deleted_at__isnull=False``) jest + skorelowana z tym samym JOIN-em, co reszta warunków użytkownika, więc + ``autorzy_set.autor.id = 5 and autorzy_set.deleted_at != None`` + naprawdę znajduje skasowane autorstwo autora 5. + + Odstępstwo od djangoql jest WĄSKIE: dotyczy wyłącznie pola + ``deleted_at`` na modelu soft-delete i wyłącznie porównania z ``None``. + """ + oryginalny_lookup = pole.get_lookup + kopia = copy.copy(pole) + + def get_lookup(path, operator, value): + if value is None and operator in ("=", "!="): + klucz = "__".join((*path, POLE_SOFT_DELETE, "isnull")) + return Q(**{klucz: operator == "="}) + return oryginalny_lookup(path, operator, value) + + kopia.get_lookup = get_lookup + return kopia + + +def _jest_agregatem_po_soft_delete(pole) -> bool: + """Czy ``pole`` to agregat relacyjny djangoql (``__count``, + ``.__sum``) liczony po modelu soft-delete? + + Rozpoznajemy po atrybutach ``AggregateField`` (``owner_lookup`` + + ``relation_name``), a nie po ``isinstance`` — żeby nie importować + prywatnych klas ``djangoql.extras``. + """ + return ( + hasattr(pole, "owner_lookup") + and hasattr(pole, "relation_name") + and jest_modelem_soft_delete(getattr(pole, "related_model", None)) + ) + + +def prefiks_agregatu(pole, parts) -> tuple: + """Ścieżka relacji, po której liczony jest agregat — do porównania z + prefiksami, na których użytkownik jawnie pyta o kosz. + + Nazwa płaska (``autorzy_set__count``) NIE zawiera relacji w ścieżce, + notacja z kropką (``autorzy_set.procent__sum``) — zawiera + (``relation_hop_in_path``). + """ + poczatek = tuple(parts[:-1]) + if getattr(pole, "relation_hop_in_path", False): + return poczatek + return poczatek + (pole.relation_name,) + + +def _agregat_bez_skasowanych(pole): + """Kopia agregatu, którego podzapytanie liczy TYLKO żywe wiersze. + + Agregat nie filtruje JOIN-em, tylko skorelowanym podzapytaniem po + ``related_model._base_manager`` — dokładanie predykatu do ``Q`` + dołożyłoby osobny, nieskorelowany warunek na zewnętrznym zapytaniu i + nie zmieniłoby wartości licznika. Odsiewamy więc ŹRÓDŁO podzapytania. + """ + kopia = copy.copy(pole) + kopia.related_model = _ZrodloBezSkasowanych(pole.related_model) + return kopia + + +def _z_predykatem_soft_delete(pole, prefiksy): + """Kopia pola DjangoQL, której ``get_lookup()`` domyka JOIN-y predykatem + ``deleted_at IS NULL``. + + Kopiujemy, bo instancje pól są współdzielone w ``schema.models`` między + wywołaniami — mutacja oryginału przeciekłaby na zapytania, które o kosz + poprosiły jawnie. + """ + oryginalny_lookup = pole.get_lookup + kopia = copy.copy(pole) + + predykat = Q() + #: Osobno część „na własnej tabeli" (prefiks ``()``, czyli model-korzeń) + #: — ta jedna jest bezpieczna także pod negacją, bo nie tworzy JOIN-u. + predykat_korzenia = Q() + for prefiks in prefiksy: + klucz = "__".join((*prefiks, POLE_SOFT_DELETE, "isnull")) + czesc = Q(**{klucz: True}) + predykat &= czesc + if not prefiks: + predykat_korzenia &= czesc + + def get_lookup(path, operator, value): + q = oryginalny_lookup(path, operator, value) + if operator in OPERATORY_NEGUJACE: + return q & predykat_korzenia + return q & predykat + + kopia.get_lookup = get_lookup + return kopia + + +class WykluczSkasowaneMixin: + """Domyślnie odsiewa rekordy soft-delete z zapytań DjangoQL. + + Musi stać PRZED klasą bazową schematu w MRO. Szczegóły kontraktu — + docstring modułu. + """ + + def __init__(self, model): + # Bezpieczny domyślny stan: „użytkownik o kosz nie prosił". + # Ustawiane przed ``super()``, bo konstruktor bazy robi + # introspekcję i może wołać metody schematu. + self._jawne_prefiksy_soft_delete = frozenset() + self._glebokosc_validate = 0 + super().__init__(model) + + def validate(self, node): + """Zapamiętaj prefiksy, na których użytkownik JAWNIE pyta o kosz. + + ``apply_search()`` woła ``validate()`` z CAŁYM drzewem, zanim + zbuduje filtr, więc analizę robimy na węźle NAJWYŻSZYM — ``validate()`` + rekuruje po sobie, stąd licznik głębokości. + + Licznik (a nie jednorazowa flaga) dlatego, że instancja schematu + bywa DŁUGOWIECZNA: ``bpp.multiseek_registry.djangoql_export`` + trzyma ``BppQLSchemaOgraniczony(Rekord)`` jako singleton bramki + walidacyjnej. Z flagą pierwsze zapytanie zamrażałoby swoje prefiksy + na zawsze i kolejne dostawałyby cudzą odpowiedź na pytanie „czy + użytkownik prosił o kosz". + """ + self._glebokosc_validate += 1 + try: + if self._glebokosc_validate == 1: + self._jawne_prefiksy_soft_delete = prefiksy_z_jawnym_deleted_at(node) + return super().validate(node) + finally: + self._glebokosc_validate -= 1 + + def resolve_name(self, name): + pole = super().resolve_name(name) + if pole is None: + return None + if _jest_agregatem_po_soft_delete(pole): + # Agregat obsługujemy WYŁĄCZNIE podmianą źródła podzapytania — + # predykat na JOIN-ie nie zmieniłby wartości licznika, a dołożyłby + # zewnętrzny warunek „istnieje żywy wiersz". + if prefiks_agregatu(pole, name.parts) in self._jawne_prefiksy_soft_delete: + return pole + return _agregat_bez_skasowanych(pole) + + wszystkie = _prefiksy_soft_delete(self, name.parts) + if name.parts[-1] == POLE_SOFT_DELETE and tuple(name.parts[:-1]) in wszystkie: + # Samo ``deleted_at`` na modelu soft-delete: predykatu nie + # dokładamy (to jest właśnie jawne pytanie o kosz), ale + # naprawiamy semantykę ``!= None``. + return _deleted_at_pozytywne(pole) + + prefiksy = [ + prefiks + for prefiks in wszystkie + if prefiks not in self._jawne_prefiksy_soft_delete + ] + if not prefiksy: + return pole + return _z_predykatem_soft_delete(pole, prefiksy) diff --git a/src/bpp/easyaudit_shim.py b/src/bpp/easyaudit_shim.py new file mode 100644 index 000000000..36fc35723 --- /dev/null +++ b/src/bpp/easyaudit_shim.py @@ -0,0 +1,163 @@ +"""Poprawka do ``django-easy-audit``: nie zakładaj, że ``objects`` widzi +wszystkie wiersze. + +PROBLEM +------- +``easyaudit.signals.model_signals.pre_save`` pobiera poprzednią wersję +zapisywanego wiersza przez DOMYŚLNEGO menedżera:: + + old_model = sender.objects.get(pk=instance.pk) + +Dla modeli soft-delete ``objects`` filtruje ``deleted_at IS NULL``, a przy +``restore()`` wiersz w bazie jest w tym momencie WCIĄŻ skasowany — więc +lookup rzuca ``DoesNotExist``. Ponieważ BPP ma +``DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = True``, wyjątek wychodzi na +zewnątrz i wywraca cały ``restore()``. + +Poprawne jest ``_base_manager``: Django dokumentuje go jako menedżera, który +MUSI zwracać wszystkie obiekty, i sam tworzy dla niego zwykły, nieodfiltrowany +``Manager``, gdy model nie ustawia ``Meta.base_manager_name``. Sprawdzone na +naszych modelach — ``_base_manager`` widzi rekordy z kosza, ``objects`` nie. + +ZAKRES BŁĘDU — SZERSZY NIŻ FAZA 02 +---------------------------------- +To NIE jest problem wprowadzony przez soft-delete publikacji. +``Zgloszenie_Publikacji`` jest ``SoftDeleteModel`` od dawna i figuruje +w ``DJANGO_EASY_AUDIT_REGISTERED_CLASSES``, więc na ``dev`` **nie da się dziś +przywrócić skasowanego zgłoszenia**. Faza 02 rozszerza zasięg z jednego +modelu na sześć. Ten shim naprawia oba przypadki naraz. + +Through-modele ``*_Autor`` (faza 01) nie były dotknięte, bo nie ma ich +w ``REGISTERED_CLASSES``. + +DLACZEGO SHIM, A NIE FORK +------------------------- +Wadliwe wywołanie jest w całym pakiecie DOKŁADNIE JEDNO, a sygnały są +podpinane z ``dispatch_uid`` — można więc czysto podmienić sam handler, +bez forka i bez monkeypatchowania wnętrzności modułu. + +Upstream (``soynatan/django-easy-audit``) zna ten błąd jako issue #175, +otwarte od 2021-02. Cztery próby naprawy (PR #168, #176, #318, #342) w sześć +lat, żadna nie scalona — mimo że projekt jest aktywnie wydawany. Z wątku +przy #168 wynika, dlaczego: maintainer odsyła do obejścia przez +``DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS``, które NIE DZIAŁA, bo +wyjątek leci zanim callbacki zostaną w ogóle sprawdzone. + +Gdy poprawka wejdzie upstream — skasować ten moduł i wywołanie +``zainstaluj()`` w ``BppConfig.ready()``. Pilnuje tego +``test_easyaudit_shim.py``, który pada, gdy upstream się zmieni. +""" + +import json +from functools import partial + +from django.conf import settings +from django.core import serializers +from django.db import transaction +from django.db.models import signals + +#: ``dispatch_uid``, którym easyaudit podpina swój handler +#: (``model_signals.py``, na dole modułu). Używamy TEGO SAMEGO klucza, więc +#: podmiana jest czysta w obie strony — patrz ``zainstaluj()``. +DISPATCH_UID = "easy_audit_signals_pre_save" + +#: Wersja pakietu, na której kopiowano ciało handlera. Nie jest sprawdzana +#: w runtime — pilnuje jej test, żeby aktualizacja pakietu była GŁOŚNA. +WERSJA_UPSTREAM = "1.3.9" + + +def pre_save(sender, instance, raw, using, update_fields, **kwargs): + """Kopia ``easyaudit.signals.model_signals.pre_save`` (1.3.9) z JEDNĄ + zmianą: ``sender.objects`` -> ``sender._base_manager``. + + Cała reszta — łącznie z obsługą wyjątków, kolejnością callbacków + i ``transaction.on_commit`` — jest importowana z upstreamu, a nie + przepisana. Powielamy wyłącznie strukturę funkcji, bo jedyny sposób + na podmianę jednej linii w cudzym ciele to podmiana całej funkcji. + """ + # Import lokalny: moduł easyaudit ma być ładowany dopiero wtedy, gdy + # aplikacja jest faktycznie zainstalowana (w BPP wchodzi tylko + # w local.py/production.py). + from easyaudit.signals.crud_flows import pre_save_crud_flow + from easyaudit.signals.model_signals import ( + call_callbacks, + handle_signal_exception, + should_audit, + ) + from easyaudit.utils import model_delta + + if raw: + # Return if loading Fixtures + return None + + try: + if not should_audit(instance): + return False + + with transaction.atomic(using=using): + try: + object_json_repr = serializers.serialize("json", [instance]) + except Exception: + # We need a better way for this to work. ManyToMany will fail on + # pre_save on create + return None + + # Determine if the instance is a create + created = instance.pk is None or instance._state.adding + + # created or updated? + delta = {} + if not created: + # ↓↓↓ JEDYNA ZMIANA WOBEC UPSTREAMU ↓↓↓ + # Upstream: sender.objects.get(pk=instance.pk) + old_model = sender._base_manager.get(pk=instance.pk) + delta = model_delta(old_model, instance) + + if not delta and getattr( + settings, + "DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP", + False, + ): + return False + + # callbacks + create_crud_event = call_callbacks( + instance, object_json_repr, created, raw, using, update_fields, **kwargs + ) + + # Create crud event only if all callbacks returned True + if create_crud_event and not created: + crud_flow = partial( + pre_save_crud_flow, + instance=instance, + object_json_repr=object_json_repr, + changed_fields=json.dumps(delta), + ) + + if getattr(settings, "TEST", False): + crud_flow() + else: + transaction.on_commit(crud_flow, using=using) + except Exception: + handle_signal_exception("pre_save") + + +def zainstaluj(): + """Podmienia handler ``pre_save`` easyauditu na nasz. + + Działa NIEZALEŻNIE od kolejności ``ready()`` aplikacji, bo używamy tego + samego ``dispatch_uid`` co upstream: + + - gdy nasz ``ready()`` biegnie PIERWSZY (tak jest dziś: ``bpp`` stoi + w ``INSTALLED_APPS`` przed ``easyaudit``, który jest doklejany na + końcu w ``local.py``/``production.py``) — ``disconnect`` jest no-opem, + a nasz ``connect`` zajmuje klucz. Późniejszy ``connect`` easyauditu + jest wtedy pomijany, bo ``Signal.connect`` ignoruje duplikat + ``dispatch_uid``; + - gdy kolejność się kiedyś odwróci — ``disconnect`` zdejmuje ich + handler, a ``connect`` wstawia nasz. + + Idempotentne: powtórne wywołanie nie dokłada drugiego odbiorcy. + """ + signals.pre_save.disconnect(dispatch_uid=DISPATCH_UID) + signals.pre_save.connect(pre_save, dispatch_uid=DISPATCH_UID) diff --git a/src/bpp/management/commands/export_bibtex.py b/src/bpp/management/commands/export_bibtex.py index 499d6df86..43bbd10bb 100644 --- a/src/bpp/management/commands/export_bibtex.py +++ b/src/bpp/management/commands/export_bibtex.py @@ -59,6 +59,23 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): + publications = self._zbierz_publikacje(options) + + # Generate BibTeX content + self.stdout.write(f"Exporting {len(publications)} publications to BibTeX...") + bibtex_content = export_to_bibtex(publications) + + self._zapisz_wynik(options, bibtex_content) + + self.stdout.write( + self.style.SUCCESS(f"Export completed: {len(publications)} publications") + ) + + def _zbierz_publikacje(self, options): + """Buduje zapytania wg opcji CLI i zwraca listę pasujących publikacji. + + Rzuca ``CommandError``, gdy po zastosowaniu filtrów nic nie zostanie. + """ publications = [] # Build queries based on options @@ -72,8 +89,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 @@ -103,23 +129,18 @@ def handle(self, *args, **options): if not publications: raise CommandError("No publications found matching the criteria.") - # Generate BibTeX content - self.stdout.write(f"Exporting {len(publications)} publications to BibTeX...") - bibtex_content = export_to_bibtex(publications) + return publications - # Output to file or stdout + def _zapisz_wynik(self, options, bibtex_content): + """Zapisuje wygenerowany BibTeX do pliku (``--output``) albo na stdout.""" if options["output"]: try: 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}") + raise CommandError(f"Error writing to file: {e}") from e else: sys.stdout.write(bibtex_content) - - self.stdout.write( - self.style.SUCCESS(f"Export completed: {len(publications)} publications") - ) diff --git a/src/bpp/management/commands/opisz_schemat_djangoql_dla_llm.py b/src/bpp/management/commands/opisz_schemat_djangoql_dla_llm.py index 1346b9410..eb39bb160 100644 --- a/src/bpp/management/commands/opisz_schemat_djangoql_dla_llm.py +++ b/src/bpp/management/commands/opisz_schemat_djangoql_dla_llm.py @@ -208,6 +208,23 @@ def _zapisz(self, content, target, fmt): f"Zapisano opis schematu ({fmt}, BPP {VERSION}) → {target}", ) + #: Nota o kontrakcie soft-delete doklejana do nagłówka ``compact``. + #: Samo pojawienie się ``deleted_at`` w opisie pól nie mówi konsumentowi + #: (LLM, integrator API), CO to pole robi domyślnie — a robi dużo: + #: zapytanie bez wzmianki o nim nie widzi kosza. Bez tej noty klient + #: dopisywałby ``deleted_at = None`` w kółko albo — gorzej — sądziłby, + #: że wyniki obejmują skasowane. Treść odzwierciedla kontrakt z + #: ``bpp/djangoql_soft_delete.py``. + NOTA_SOFT_DELETE = ( + "# Rekordy skasowane (soft-delete, pole `deleted_at`) są DOMYŚLNIE pomijane.", + "# Zapytanie bez wzmianki o `deleted_at` widzi tylko żywe wiersze — " + "nie dopisuj", + "# `deleted_at = None`, to już działa. Żeby zajrzeć do kosza, wymień " + "to pole na", + "# wybranej relacji: `autorzy_set.deleted_at != None` = TYLKO " + "skasowane autorstwa.", + ) + def _render(self, bundle, *, fmt, model_label, schema_path, indent): if fmt == "compact": header = "\n".join( @@ -217,6 +234,8 @@ def _render(self, bundle, *, fmt, model_label, schema_path, indent): "# Wygenerowano: manage.py opisz_schemat_djangoql_dla_llm", "# Plik generowany — nie edytuj ręcznie.", "", + *self.NOTA_SOFT_DELETE, + "", "", ) ) diff --git a/src/bpp/management/commands/wyczysc_publikacje_importu.py b/src/bpp/management/commands/wyczysc_publikacje_importu.py index f3b31b2e2..c368dd5db 100644 --- a/src/bpp/management/commands/wyczysc_publikacje_importu.py +++ b/src/bpp/management/commands/wyczysc_publikacje_importu.py @@ -286,16 +286,39 @@ def _author_counts(self, publication_models): for model in publication_models ] + @staticmethod + def _wszystkie_wiersze(model): + """Manager widzący RÓWNIEŻ kosz. + + Od fazy 02 soft-delete ``model.objects`` ukrywa wiersze z + ``deleted_at``. Ta komenda ma CZYŚCIĆ, a nie chować — pominięcie kosza + zostawiłoby publikacje, które operator uznał już za usunięte, a które + przy kolejnym imporcie kolidowałyby jako niewidoczne duplikaty. + """ + return getattr(model, "global_objects", model.objects) + + @staticmethod + def _skasuj_trwale(queryset): + """Kasowanie NIEODWRACALNE, także gdy model jest soft-delete. + + ``QuerySet.delete()`` na modelu soft-delete jest MIĘKKIE — wiersze + zostają, a razem z nimi ich dzieci (bo CASCADE nie ma czego kasować). + Dla komendy czyszczącej to nie byłoby kasowanie, tylko oznaczanie. + """ + hard = getattr(queryset, "hard_delete", None) + return hard() if hard is not None else queryset.delete() + def _delete_publications(self, publication_models, batch_size): deleted = {} for model in publication_models: - pks = list(model.objects.order_by("pk").values_list("pk", flat=True)) + manager = self._wszystkie_wiersze(model) + pks = list(manager.order_by("pk").values_list("pk", flat=True)) total = len(pks) self._progress(f" - {model._meta.label}: usuwam {total} rekordow") for start in range(0, total, batch_size): batch = pks[start : start + batch_size] - _, details = model.objects.filter(pk__in=batch).delete() + _, details = self._skasuj_trwale(manager.filter(pk__in=batch)) for model_label, count in details.items(): deleted[model_label] = deleted.get(model_label, 0) + count @@ -323,7 +346,7 @@ def _delete_generic_references(self, specs, content_types, *, progress_label): f" [{index}/{len(grouped_queries)}] {model._meta.label}: " f"usuwam {planned_count}" ) - count, _ = queryset.delete() + count, _ = self._skasuj_trwale(queryset) results.append(GenericReferenceResult(model._meta.label, "usunieto", count)) return results 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/0488_autor_soft_delete_fields.py b/src/bpp/migrations/0488_autor_soft_delete_fields.py new file mode 100644 index 000000000..3a1cfc20d --- /dev/null +++ b/src/bpp/migrations/0488_autor_soft_delete_fields.py @@ -0,0 +1,90 @@ +# 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): + 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( + condition=Q(("deleted_at__isnull", False)), + fields=["deleted_at"], + name="wc_autor_deleted_at_idx", + ), + ), + 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", + ), + ), + 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 new file mode 100644 index 000000000..b0f0ae286 --- /dev/null +++ b/src/bpp/migrations/0489_soft_delete_autorzy_views.py @@ -0,0 +1,227 @@ +"""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). + + ⚠️ 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 + 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/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/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/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/bpp/migrations/0496_publikacje_soft_delete_fields.py b/src/bpp/migrations/0496_publikacje_soft_delete_fields.py new file mode 100644 index 000000000..fa7a9a8ea --- /dev/null +++ b/src/bpp/migrations/0496_publikacje_soft_delete_fields.py @@ -0,0 +1,110 @@ +# Generated by Django 5.2.16 on 2026-08-07 12:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0495_nowe_sumy_bez_skasowanych'), + ('pbn_api', '0079_constraint_publikacja_instytucji'), + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ] + + operations = [ + migrations.AddField( + model_name='patent', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='patent', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='patent', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_doktorska', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_doktorska', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_doktorska', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_habilitacyjna', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_habilitacyjna', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_habilitacyjna', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_ciagle', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_ciagle', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_ciagle', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_zwarte', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_zwarte', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_zwarte', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddIndex( + model_name='patent', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='patent_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='praca_doktorska', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='pdok_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='praca_habilitacyjna', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='phab_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='wydawnictwo_ciagle', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='wc_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='wydawnictwo_zwarte', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='wz_deleted_at_idx'), + ), + ] diff --git a/src/bpp/migrations/0497_soft_delete_rekord_views.py b/src/bpp/migrations/0497_soft_delete_rekord_views.py new file mode 100644 index 000000000..2bba70950 --- /dev/null +++ b/src/bpp/migrations/0497_soft_delete_rekord_views.py @@ -0,0 +1,283 @@ +"""Soft-delete PUBLIKACJI: filtr w widokach + gałąź kasująca + bramka WHEN. + +Odpowiednik migracji ``0489`` (faza 01) dla drugiej ścieżki: tamta objęła +3 tabele ``*_autor``, ta obejmuje 5 tabel publikacji. Kolejność operacji jest +WYMUSZONA z dokładnie tego samego powodu: + + 1) widoki (wprowadzają ``deleted_at`` do drzewa zapytania) -> + 2) funkcje ``bpp_refresh_rekord_*`` (gałąź kasująca) -> + 3) regeneracja bramki WHEN (czyta ``pg_depend`` PO kroku 1) + +Odwrócenie 1<->3 daje bramkę bez ``deleted_at``, czyli cichy staleness: +soft-deletowana publikacja zostaje w ``bpp_rekord_mat``. + +Żadna z trzech zmian nie wystarcza sama — uzasadnienie jak w ``0489``: +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; a bez +``deleted_at`` w bramce ``WHEN`` UPDATE soft-delete w ogóle nie dochodzi do +funkcji triggera. + +DLACZEGO NIE DA SIĘ UŻYĆ ``_filtruj_widok`` Z ``0489`` +------------------------------------------------------ +Tamta funkcja dopisuje ``WHERE`` na KOŃCU definicji i asertuje, że definicja +kończy się gołym ``FROM ``. Dla widoków publikacji to założenie jest +FAŁSZYWE — występują tu trzy różne kształty (zweryfikowane introspekcją +żywego katalogu, nie lekturą plików ``.sql``): + +- **A**: ``bpp_wydawnictwo_ciagle_view``, ``bpp_wydawnictwo_zwarte_view``, + ``bpp_patent_view`` — kończą się ``LEFT JOIN _autor ... GROUP BY + .id`` i NIE mają ``WHERE`` najwyższego poziomu. Dopisanie ``WHERE`` + na końcu byłoby błędem składni (``WHERE`` po ``GROUP BY``), więc predykat + wstawiamy PRZED ``GROUP BY``. +- **B**: ``bpp_praca_doktorska_view``, ``bpp_praca_habilitacyjna_view`` — + kończą się gołym ``FROM ``; tu wzorzec z ``0489`` działa wprost. +- **C**: ``bpp_praca_doktorska_autorzy``, + ``bpp_praca_habilitacyjna_autorzy`` — mają WŁASNY ``WHERE`` najwyższego + poziomu (join po przecinku z ``bpp_autor``), więc predykat wstawiamy do + niego, zaraz za słowem ``WHERE``. + +⚠️ Rozpoznawanie kształtu NIE może iść po samym wystąpieniu słowa ``WHERE``: +widoki rodziny A zawierają ``count(...) FILTER (WHERE ... deleted_at IS +NULL)`` (migracja ``0494``), a wszystkie zawierają ``WHERE`` w skalarnych +podzapytaniach o ``django_content_type``. Dyskryminatorem jest ``WHERE``/ +``GROUP BY`` na WCIĘCIU DWÓCH SPACJI, czyli na poziomie klauzuli głównej +w formacie ``pg_get_viewdef(..., pretty=true)``. + +⚠️ AGREGATY: w rodzinie A filtrujemy tabelę PUBLIKACJI, która jest lewą +(napędzającą) stroną ``LEFT JOIN``-a — to jest bezpieczne. Pułapka opisana w +handoffie (§3.2) dotyczy filtrowania strony PRAWEJ (``*_autor``): warunek na +niej zdegenerowałby ``LEFT JOIN`` do ``INNER JOIN`` i publikacja, której +wszystkich autorów skasowano, wypadłaby z widoku. Dlatego ``liczba_autorow`` +jest liczona przez ``count(...) FILTER (...)`` (``0494``) i tej konstrukcji +tutaj NIE ruszamy. + +⚠️ KLUCZ w rodzinie C: ``object_id_raw`` to id PUBLIKACJI (autor leży na jej +wierszu), więc filtr po własnej kolumnie ``deleted_at`` tabeli publikacji +jest poprawny. To INNA sytuacja niż w widokach ``*_autorzy`` trzech typów +z through-modelem (faza 01), gdzie filtrowano po kolumnie wiersza through. +Nie kopiować klucza między fazami bez sprawdzenia, co dana kolumna znaczy. + +Definicje są GENEROWANE z introspekcji (jak w ``0432``/``0433``/``0489``), +a nie przepisane do ``.sql`` — kopia rozjechałaby się przy najbliższej +zmianie kolumn. +""" + +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") + +REKORD_SITES = _p0432.REKORD_SITES # [(tabela, model, autor_na_wierszu), ...] + +# Klauzule główne w formacie pg_get_viewdef(pretty=true) stoją na wcięciu +# dwóch spacji. To odróżnia je od WHERE-ów w podzapytaniach skalarnych +# (wcięcie 10) i od FILTER (WHERE ...) w liście SELECT. +MARKER_GROUP_BY = "\n GROUP BY " +MARKER_WHERE = "\n WHERE " + + +def _widoki(tabela, autor_na_wierszu): + """Widoki danej publikacji objęte filtrem: rekordowy + ewentualny + autorski (tylko gdy autor leży na wierszu publikacji).""" + yield tabela + "_view" + if autor_na_wierszu: + yield tabela + "_autorzy" + + +def _viewdef(cur, widok): + cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok]) + return cur.fetchone()[0].rstrip().rstrip(";") + + +def _z_filtrem(orig, tabela, widok): + """Definicja widoku wzbogacona o ``.deleted_at IS NULL``. + + Kształt rozpoznajemy JAWNIE i każdy nieoczekiwany wariant kończy się + ``RuntimeError`` — cicha akceptacja dałaby widok bez filtra, czyli + dokładnie ten rodzaj przecieku, który ta migracja ma zamknąć. + """ + predykat = f"{tabela}.deleted_at IS NULL" + n_group_by = orig.count(MARKER_GROUP_BY) + n_where = orig.count(MARKER_WHERE) + + if n_group_by == 1 and n_where == 0: # rodzina A + glowa, ogon = orig.split(MARKER_GROUP_BY) + return f"{glowa}\n WHERE {predykat}{MARKER_GROUP_BY}{ogon}" + + if n_group_by == 0 and n_where == 1: # rodzina C + glowa, ogon = orig.split(MARKER_WHERE) + # Wstawiamy ZARAZ ZA "WHERE", a nie na końcu definicji — dzięki temu + # jesteśmy odporni na to, co po WHERE jeszcze następuje. + return f"{glowa}{MARKER_WHERE}{predykat} AND {ogon}" + + if n_group_by == 0 and n_where == 0: # rodzina B + if not orig.endswith(f"FROM {tabela}"): + raise RuntimeError( + f"{widok}: brak GROUP BY i WHERE, ale definicja nie konczy " + f"sie na 'FROM {tabela}' -- dopisanie WHERE bylo by " + f"niepoprawne. Definicja: ...{orig[-160:]!r}" + ) + return f"{orig}\n WHERE {predykat}" + + raise RuntimeError( + f"{widok}: nierozpoznany ksztalt definicji (GROUP BY x{n_group_by}, " + f"WHERE x{n_where}) -- nie zgaduje, gdzie wstawic filtr" + ) + + +def _bez_filtra(orig, tabela, widok): + """Odwrotność ``_z_filtrem`` — usuwa DOKŁADNIE ten tekst, który tamta + wstawiła. Symetria jest tu ważniejsza od elegancji: odtwarzanie + oryginału z plików ``.sql`` (jak w ``0489.backward``) nie zadziała, bo + widoki publikacji były redefiniowane w kilku migracjach (m.in. ``0494`` + dokładająca ``count(...) FILTER``) i nie ma jednego pliku źródłowego. + + ⚠️ Szukamy DWÓCH form predykatu, bo katalog nie przechowuje tekstu, który + wstawiliśmy, tylko jego postać ZNORMALIZOWANĄ. Gdy w zasięgu zapytania + jest jedna tabela (rodzina B), Postgres usuwa zbędną kwalifikację i + ``bpp_praca_doktorska.deleted_at IS NULL`` wraca jako gołe + ``deleted_at IS NULL``. + + ⚠️ KOLEJNOŚĆ KANDYDATÓW JEST NOŚNA, nie kosmetyczna. Wariant z ``AND`` + (rodzina C) MUSI być sprawdzany pierwszy: w definicji + ``WHERE AND bpp_autor.id = ...`` wzorzec + ``\\n WHERE `` pasuje jako PREFIKS, a usunięcie samego prefiksu + zostawiłoby ``WHERE`` zaczynające się od ``AND`` — składniowy gruz. + """ + kandydaci = [] + for predykat in (f"{tabela}.deleted_at IS NULL", "deleted_at IS NULL"): + kandydaci.append(f"{predykat} AND ") # rodzina C — NAJPIERW + for predykat in (f"{tabela}.deleted_at IS NULL", "deleted_at IS NULL"): + kandydaci.append(f"\n WHERE {predykat}") # rodziny A i B + + for wstawka in kandydaci: + if orig.count(wstawka) == 1: + return orig.replace(wstawka, "", 1) + raise RuntimeError( + f"{widok}: nie znalazlem (dokladnie jednej) wstawki z filtrem " + f"soft-delete -- definicja zmieniona poza ta migracja?" + ) + + +def _funkcja_z_galezia_kasujaca(cur, table, model, autor_na_wierszu): + """``bpp_refresh_rekord_()`` z ``0432`` + gałąź kasująca. + + Ciało generowane identycznie jak ``_p0432._create_rekord_function`` + (ten sam upsert z pozycyjnym mapowaniem kolumn), z jedną różnicą: gdy + wiersz jest soft-deletowany, kasujemy go z tabel ``_mat`` i wychodzimy. + + ``pg_advisory_xact_lock`` stoi PRZED rozgałęzieniem — kasowanie musi brać + ten sam lock co upsert (uzasadnienie w ``0489``). + + ⚠️ Doktorat i habilitacja (``autor_na_wierszu=True``) dotykają OBU tabel + ``_mat``, bo autor leży na wierszu publikacji. Nazwy kluczy są RÓŻNE: + 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``). + + Restore (``deleted_at`` -> NULL) leci normalną ścieżką upsertu — wiersz + wraca do widoku źródłowego, więc nic dodatkowego nie trzeba. + """ + body_autorzy = "" + kasuj_autorzy = "" + if autor_na_wierszu: + upsert_autorzy = _p0432._upsert_sql( + cur, "bpp_autorzy_mat", table + "_autorzy", "object_id_raw = NEW.id" + ) + body_autorzy = ( + "\n DELETE FROM bpp_autorzy_mat " + "WHERE rekord_id = ARRAY[ct, NEW.id]::integer[];\n" + f" {upsert_autorzy};" + ) + kasuj_autorzy = ( + "\n DELETE FROM bpp_autorzy_mat " + "WHERE rekord_id = ARRAY[ct, NEW.id]::integer[];" + ) + upsert_rekord = _p0432._upsert_sql( + cur, "bpp_rekord_mat", table + "_view", "object_id_raw = NEW.id" + ) + return f""" +CREATE OR REPLACE FUNCTION bpp_refresh_rekord_{model}() RETURNS trigger +LANGUAGE plpgsql AS $bpp_body$ +DECLARE ct integer; +BEGIN + {_p0432._ct_lookup(model)} + PERFORM pg_advisory_xact_lock(ct, NEW.id); + IF NEW.deleted_at IS NOT NULL THEN + DELETE FROM bpp_rekord_mat WHERE id = ARRAY[ct, NEW.id]::integer[];\ +{kasuj_autorzy} + RETURN NULL; + END IF; + {upsert_rekord};{body_autorzy} + RETURN NULL; +END $bpp_body$; +""" + + +def _regeneruj_bramke(): + """Ta sama logika co ``0433.forward``, ale tylko dla tabel PUBLIKACJI. + + 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 ``*_autor`` obsłużyła faza 01 — tu ich nie ruszamy. + """ + with connection.cursor() as cur: + for tabela, refresh_fn, widoki in _p0433.GATED: + if 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 " + f"kolumny (widoki={widoki}) -- nie tworze " + f"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 _przebuduj_widoki(cur, transformacja): + for tabela, _model, autor_na_wierszu in REKORD_SITES: + for widok in _widoki(tabela, autor_na_wierszu): + orig = _viewdef(cur, widok) + nowa = transformacja(orig, tabela, widok) + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {nowa}") + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + _przebuduj_widoki(cur, _z_filtrem) # 1) widoki + for tabela, model, autor_na_wierszu in REKORD_SITES: # 2) funkcje + cur.execute( + _funkcja_z_galezia_kasujaca(cur, tabela, model, autor_na_wierszu) + ) + _regeneruj_bramke() # 3) bramka + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + _przebuduj_widoki(cur, _bez_filtra) # 1) widoki bez filtra + for tabela, model, autor_na_wierszu in REKORD_SITES: # 2) funkcje + cur.execute( + _p0432._create_rekord_function(cur, tabela, model, autor_na_wierszu) + ) + _regeneruj_bramke() # 3) bramka (deleted_at zniknie z pg_depend samo) + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0496_publikacje_soft_delete_fields"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/migrations/0498_nowe_sumy_bez_skasowanych_publikacji.py b/src/bpp/migrations/0498_nowe_sumy_bez_skasowanych_publikacji.py new file mode 100644 index 000000000..28bc4249c --- /dev/null +++ b/src/bpp/migrations/0498_nowe_sumy_bez_skasowanych_publikacji.py @@ -0,0 +1,74 @@ +"""Ranking autorów przestaje punktować soft-deletowane PUBLIKACJE. + +Dopełnienie migracji ``0495``, która zamknęła w tych widokach wymiar +AUTORSTWA (``*_autor.deleted_at``). Wymiar PUBLIKACJI został wtedy otwarty, +bo publikacje stały się soft-delete dopiero w fazie 02 (``0496``). + +Bez tego filtra soft-deletowana publikacja dalej wnosi punkty do sum: +``bpp_nowe_sumy__view`` -> UNION ALL ``bpp_nowe_sumy_view`` -> modele +``Nowe_Sumy_View``/``Sumy`` (``bpp/models/sumy_views.py``) -> +``ranking_autorow/views.py``. + +Zakres jest SZERSZY niż w ``0495``: tamta objęła 3 widoki (tylko typy +z through-modelem), ta obejmuje wszystkie 5. ``praca_doktorska`` i +``praca_habilitacyjna`` nie mają tabeli ``*_autor`` — autor leży na wierszu +publikacji — więc ``0495`` nie miała tam czego filtrować, ale soft-delete +samej pracy dotyczy ich tak samo jak reszty. + +Widoki ustalone INWENTARYZACJĄ kanarka katalogowego na starcie fazy 02 +(``docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md``), +nie zgadywaniem — plan fazy 02 miał dla nich wyłącznie ostrzeżenie +„sprawdź, czy wymagają poprawki". Wymagają, wszystkie pięć. + +PUŁAPKA AGREGATU TU NIE WYSTĘPUJE (sprawdzone, nie założone). Handoff (§3.2) +ostrzega, że warunek w ``WHERE`` degeneruje ``LEFT JOIN`` do ``INNER JOIN``, +przez co wiersz z zerem znika zamiast wyzerować licznik. Te widoki: + +- nie mają ANI JEDNEGO ``LEFT JOIN``-a (złączenia po przecinku albo jawne + ``JOIN``, czyli semantyka wewnętrzna od początku), +- nie mają ``GROUP BY`` ani żadnej funkcji agregującej — sumowanie dzieje + się dopiero w modelach Django, nad ``UNION ALL``. + +Semantyka jest tu zresztą odwrotna niż przy ``liczba_autorow``: skasowana +publikacja MA wypaść z rankingu, a nie zostać w nim z zerem. Zwykły warunek +w ``WHERE`` jest więc poprawną konstrukcją, a nie skrótem. +""" + +from django.db import connection, migrations + +from bpp.migration_util import widok_dopisz_warunek, widok_usun_warunek + +# (widok sum, tabela PUBLIKACJI, którą joinuje) +SUMY = [ + ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), + ("bpp_nowe_sumy_wydawnictwo_zwarte_view", "bpp_wydawnictwo_zwarte"), + ("bpp_nowe_sumy_patent_view", "bpp_patent"), + ("bpp_nowe_sumy_praca_doktorska_view", "bpp_praca_doktorska"), + ("bpp_nowe_sumy_praca_habilitacyjna_view", "bpp_praca_habilitacyjna"), +] + + +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", "0497_soft_delete_rekord_views"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/migrations/0499_drop_kronika_views.py b/src/bpp/migrations/0499_drop_kronika_views.py new file mode 100644 index 000000000..ffbfe8b1a --- /dev/null +++ b/src/bpp/migrations/0499_drop_kronika_views.py @@ -0,0 +1,98 @@ +"""Kasuje MARTWĄ rodzinę siedmiu widoków ``bpp_kronika_*``. + +DLACZEGO WSZYSTKIE SIEDEM NARAZ. Faza 01 chciała skasować trzy z nich +(``wydawnictwo_ciagle``/``wydawnictwo_zwarte``/``patent``) i została +zablokowana: zależą od nich dwa widoki nadrzędne, więc goły ``DROP VIEW`` +bez ``CASCADE`` nie przechodzi, a ``CASCADE`` po cichu zabrałby też te +nadrzędne. Rozbicie na kilka migracji nic nie daje — graf trzeba rozciąć +w jednym miejscu albo wcale. + +DOWÓD MARTWOTY (zweryfikowany 2026-08-07, faza 02): + +1. ``pg_depend``: JEDYNE zależności od tych siedmiu widoków są WEWNĄTRZ + rodziny — ``bpp_kronika_view`` <- ``bpp_kronika_all_unsorted_view`` <- + pięć widoków liści. Nic spoza rodziny na nich nie stoi. To mocniejszy + dowód niż grep: obejmuje też widoki i reguły, których nazwa nie zawiera + słowa „kronika". +2. Kod: zero trafień „kronika" w ``.py``/``.html``/``.json``/``.js``/SQL-u + poza katalogami migracji i ``baseline-sql/`` (czyli poza definicjami + samych widoków). Zero ``Meta.db_table`` wskazujących na którykolwiek. +3. Repozytoria SIOSTRZANE (to są obiekty bazodanowe, więc konsument nie + musi mieszkać w repo aplikacji — self-review, 2026-08-07): ``bpp-mcp`` + i ``bpp-skills`` czyste; ``bpp-deploy`` odwołuje się do nich WYŁĄCZNIE + w komentarzach i w jednej kontrolce diagnostycznej — patrz uwaga niżej. +4. ``flexible_reports``: definicje raportów żyją jako wiersze w bazie + produkcyjnej, więc z repo nie da się ich sprawdzić. **Potwierdzone przez + właściciela systemu (2026-08-07): żaden raport nie odpytuje tych + widoków.** To był jedyny element dowodu oparty na założeniu. + +Fazę 01 zweryfikowała tak trzy widoki; faza 02 dołożyła +``bpp_kronika_praca_{doktorska,habilitacyjna}_view``, których żywotności +nikt wcześniej nie sprawdzał — wyszły na jaw, gdy kanarek katalogowy +dostał do zakresu tabele publikacji. + +⚠️ DO ZROBIENIA W ``bpp-deploy`` (poza tym repo, nieblokujące): +``scripts/pg-collation-migrate-3-load.sh`` drukuje po załadowaniu bazy +kontrolkę ``SELECT 'kronika views: '||count(*) ... LIKE 'bpp_kronika%'``. +Po tej migracji wypisze ``0`` — a to sanity-check po odtworzeniu bazy, więc +operator ma prawo odczytać zero jako nieudany load. Do zdjęcia razem +z komentarzami w ``lib-pg-collation-migrate.sh`` i +``pg-collation-migrate-2-fix.sh``, które uzasadniają migrację kolacji przez +„5 widoków ``bpp_kronika_*``". Sam ``sed`` celuje we wzorzec ``COLLATE``, +a nie w nazwy widoków, więc skrypty NIE przestają działać. + +ODWRACALNOŚĆ: ``backward`` odtwarza całą siódemkę z sidecara +``0499_drop_kronika_views.sql``. Plik został WYGENEROWANY z +``pg_get_viewdef()`` na żywym katalogu, a nie przepisany ręcznie — przy +7 KB SQL-a przepisywanie byłoby proszeniem się o cichą literówkę. + +⚠️ Po tej migracji trzeba usunąć trzy wpisy ``WYJATKI`` z kanarka +katalogowego (``test_kanarek_katalogowy.py``) — wskazują na widoki, których +już nie ma. +""" + +from django.db import connection, migrations + +from bpp.migration_util import load_custom_sql + +# Kolejnosc KASOWANIA: od szczytu w dol (parasole przed liscmi). +# Odwrotna kolejnosc (odtwarzania) siedzi w sidecarze .sql. +RODZINA_OD_SZCZYTU = [ + "bpp_kronika_view", + "bpp_kronika_all_unsorted_view", + "bpp_kronika_wydawnictwo_ciagle_view", + "bpp_kronika_wydawnictwo_zwarte_view", + "bpp_kronika_patent_view", + "bpp_kronika_praca_doktorska_view", + "bpp_kronika_praca_habilitacyjna_view", +] + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + for widok in RODZINA_OD_SZCZYTU: + # Dwie NIEZALEŻNE decyzje w jednej instrukcji: + # + # bez CASCADE — gdyby cokolwiek spoza rodziny zdążyło się na + # którymś oprzeć, chcemy głośnego błędu, a nie cichego zabrania + # tego czegoś razem z widokiem; + # + # z IF EXISTS — bo to piętnastoletnie, martwe widoki i któryś + # DBA mógł je już ręcznie sprzątnąć; bez tego migracja twardo + # pada na takiej bazie. IF EXISTS NIE osłabia głośności: + # przy istniejącej zależności DROP dalej rzuca błąd. + cur.execute(f"DROP VIEW IF EXISTS {widok}") + + +def backward(apps, schema_editor): + load_custom_sql("0499_drop_kronika_views") + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0498_nowe_sumy_bez_skasowanych_publikacji"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/migrations/0499_drop_kronika_views.sql b/src/bpp/migrations/0499_drop_kronika_views.sql new file mode 100644 index 000000000..93f9f47f2 --- /dev/null +++ b/src/bpp/migrations/0499_drop_kronika_views.sql @@ -0,0 +1,196 @@ +-- Odtworzenie rodziny widokow bpp_kronika_* (backward migracji 0499). +-- +-- WYGENEROWANE z pg_get_viewdef() na zywym katalogu, nie przepisane recznie. +-- Kolejnosc jest WYMUSZONA grafem zaleznosci: piec widokow lisci, potem +-- bpp_kronika_all_unsorted_view (UNION po nich), na koncu bpp_kronika_view. +-- +-- Ta rodzina jest MARTWA (zero konsumentow) i migracja 0499 ja kasuje. +-- Plik istnieje wylacznie po to, zeby ta migracja byla odwracalna. + +CREATE VIEW bpp_kronika_wydawnictwo_ciagle_view AS + SELECT bpp_autor.id AS autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_jednostka.id AS jednostka_id, + bpp_wydawnictwo_ciagle.tytul_oryginalny, + bpp_wydawnictwo_ciagle.tytul_oryginalny_sort, + bpp_wydawnictwo_ciagle.rok, + bpp_wydawnictwo_ciagle_autor.kolejnosc, + 'bpp_wydawnictwo_ciagle'::text AS object, + bpp_wydawnictwo_ciagle.id AS object_pk, + bpp_wydawnictwo_ciagle.id, + bpp_wydawnictwo_ciagle.zrodlo_id + FROM bpp_wydawnictwo_ciagle, + bpp_wydawnictwo_ciagle_autor, + bpp_jednostka, + bpp_zrodlo, + bpp_autor + WHERE bpp_wydawnictwo_ciagle_autor.autor_id = bpp_autor.id AND bpp_wydawnictwo_ciagle_autor.rekord_id = bpp_wydawnictwo_ciagle.id AND bpp_wydawnictwo_ciagle_autor.jednostka_id = bpp_jednostka.id AND bpp_zrodlo.id = bpp_wydawnictwo_ciagle.zrodlo_id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_wydawnictwo_zwarte_view AS + SELECT bpp_autor.id AS autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_jednostka.id AS jednostka_id, + bpp_wydawnictwo_zwarte.tytul_oryginalny, + bpp_wydawnictwo_zwarte.tytul_oryginalny_sort, + bpp_wydawnictwo_zwarte.rok, + bpp_wydawnictwo_zwarte_autor.kolejnosc, + 'bpp_wydawnictwo_zwarte'::text AS object, + bpp_wydawnictwo_zwarte.id AS object_pk, + bpp_wydawnictwo_zwarte.id, + NULL::integer AS zrodlo_id + FROM bpp_wydawnictwo_zwarte, + bpp_wydawnictwo_zwarte_autor, + bpp_jednostka, + bpp_autor + WHERE bpp_wydawnictwo_zwarte_autor.autor_id = bpp_autor.id AND bpp_wydawnictwo_zwarte_autor.rekord_id = bpp_wydawnictwo_zwarte.id AND bpp_wydawnictwo_zwarte_autor.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_patent_view AS + SELECT bpp_autor.id AS autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_jednostka.id AS jednostka_id, + bpp_patent.tytul_oryginalny, + bpp_patent.tytul_oryginalny_sort, + bpp_patent.rok, + bpp_patent_autor.kolejnosc, + 'bpp_patent'::text AS object, + bpp_patent.id AS object_pk, + bpp_patent.id, + NULL::integer AS zrodlo_id + FROM bpp_patent, + bpp_autor, + bpp_patent_autor, + bpp_jednostka + WHERE bpp_patent_autor.autor_id = bpp_autor.id AND bpp_patent_autor.rekord_id = bpp_patent.id AND bpp_patent_autor.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_praca_doktorska_view AS + SELECT bpp_praca_doktorska.autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_praca_doktorska.jednostka_id, + bpp_praca_doktorska.tytul_oryginalny, + bpp_praca_doktorska.tytul_oryginalny_sort, + bpp_praca_doktorska.rok, + 1 AS kolejnosc, + 'bpp_praca_doktorska'::text AS object, + bpp_praca_doktorska.id AS object_pk, + bpp_praca_doktorska.id, + NULL::integer AS zrodlo_id + FROM bpp_praca_doktorska, + bpp_jednostka, + bpp_autor + WHERE bpp_praca_doktorska.autor_id = bpp_autor.id AND bpp_praca_doktorska.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_praca_habilitacyjna_view AS + SELECT bpp_praca_habilitacyjna.autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_praca_habilitacyjna.jednostka_id, + bpp_praca_habilitacyjna.tytul_oryginalny, + bpp_praca_habilitacyjna.tytul_oryginalny_sort, + bpp_praca_habilitacyjna.rok, + 1 AS kolejnosc, + 'bpp_praca_habilitacyjna'::text AS object, + bpp_praca_habilitacyjna.id AS object_pk, + bpp_praca_habilitacyjna.id, + NULL::integer AS zrodlo_id + FROM bpp_praca_habilitacyjna, + bpp_jednostka, + bpp_autor + WHERE bpp_praca_habilitacyjna.autor_id = bpp_autor.id AND bpp_praca_habilitacyjna.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_all_unsorted_view AS + SELECT bpp_kronika_patent_view.autor_id, + bpp_kronika_patent_view.imiona, + bpp_kronika_patent_view.nazwisko, + bpp_kronika_patent_view.jednostka_id, + bpp_kronika_patent_view.tytul_oryginalny, + bpp_kronika_patent_view.tytul_oryginalny_sort, + bpp_kronika_patent_view.rok, + bpp_kronika_patent_view.kolejnosc, + bpp_kronika_patent_view.object, + bpp_kronika_patent_view.object_pk, + bpp_kronika_patent_view.id, + bpp_kronika_patent_view.zrodlo_id + FROM bpp_kronika_patent_view +UNION + SELECT bpp_kronika_praca_habilitacyjna_view.autor_id, + bpp_kronika_praca_habilitacyjna_view.imiona, + bpp_kronika_praca_habilitacyjna_view.nazwisko, + bpp_kronika_praca_habilitacyjna_view.jednostka_id, + bpp_kronika_praca_habilitacyjna_view.tytul_oryginalny, + bpp_kronika_praca_habilitacyjna_view.tytul_oryginalny_sort, + bpp_kronika_praca_habilitacyjna_view.rok, + bpp_kronika_praca_habilitacyjna_view.kolejnosc, + bpp_kronika_praca_habilitacyjna_view.object, + bpp_kronika_praca_habilitacyjna_view.object_pk, + bpp_kronika_praca_habilitacyjna_view.id, + bpp_kronika_praca_habilitacyjna_view.zrodlo_id + FROM bpp_kronika_praca_habilitacyjna_view +UNION + SELECT bpp_kronika_praca_doktorska_view.autor_id, + bpp_kronika_praca_doktorska_view.imiona, + bpp_kronika_praca_doktorska_view.nazwisko, + bpp_kronika_praca_doktorska_view.jednostka_id, + bpp_kronika_praca_doktorska_view.tytul_oryginalny, + bpp_kronika_praca_doktorska_view.tytul_oryginalny_sort, + bpp_kronika_praca_doktorska_view.rok, + bpp_kronika_praca_doktorska_view.kolejnosc, + bpp_kronika_praca_doktorska_view.object, + bpp_kronika_praca_doktorska_view.object_pk, + bpp_kronika_praca_doktorska_view.id, + bpp_kronika_praca_doktorska_view.zrodlo_id + FROM bpp_kronika_praca_doktorska_view +UNION + SELECT bpp_kronika_wydawnictwo_ciagle_view.autor_id, + bpp_kronika_wydawnictwo_ciagle_view.imiona, + bpp_kronika_wydawnictwo_ciagle_view.nazwisko, + bpp_kronika_wydawnictwo_ciagle_view.jednostka_id, + bpp_kronika_wydawnictwo_ciagle_view.tytul_oryginalny, + bpp_kronika_wydawnictwo_ciagle_view.tytul_oryginalny_sort, + bpp_kronika_wydawnictwo_ciagle_view.rok, + bpp_kronika_wydawnictwo_ciagle_view.kolejnosc, + bpp_kronika_wydawnictwo_ciagle_view.object, + bpp_kronika_wydawnictwo_ciagle_view.object_pk, + bpp_kronika_wydawnictwo_ciagle_view.id, + bpp_kronika_wydawnictwo_ciagle_view.zrodlo_id + FROM bpp_kronika_wydawnictwo_ciagle_view +UNION + SELECT bpp_kronika_wydawnictwo_zwarte_view.autor_id, + bpp_kronika_wydawnictwo_zwarte_view.imiona, + bpp_kronika_wydawnictwo_zwarte_view.nazwisko, + bpp_kronika_wydawnictwo_zwarte_view.jednostka_id, + bpp_kronika_wydawnictwo_zwarte_view.tytul_oryginalny, + bpp_kronika_wydawnictwo_zwarte_view.tytul_oryginalny_sort, + bpp_kronika_wydawnictwo_zwarte_view.rok, + bpp_kronika_wydawnictwo_zwarte_view.kolejnosc, + bpp_kronika_wydawnictwo_zwarte_view.object, + bpp_kronika_wydawnictwo_zwarte_view.object_pk, + bpp_kronika_wydawnictwo_zwarte_view.id, + bpp_kronika_wydawnictwo_zwarte_view.zrodlo_id + FROM bpp_kronika_wydawnictwo_zwarte_view +; + +CREATE VIEW bpp_kronika_view AS + SELECT autor_id, + imiona, + nazwisko, + jednostka_id, + tytul_oryginalny, + tytul_oryginalny_sort, + rok, + kolejnosc, + object, + object_pk, + id, + zrodlo_id + FROM bpp_kronika_all_unsorted_view + ORDER BY nazwisko, imiona, tytul_oryginalny_sort +; diff --git a/src/bpp/migrations/0500_praca_habilitacyjna_warunkowy_unique_autora.py b/src/bpp/migrations/0500_praca_habilitacyjna_warunkowy_unique_autora.py new file mode 100644 index 000000000..9577a905b --- /dev/null +++ b/src/bpp/migrations/0500_praca_habilitacyjna_warunkowy_unique_autora.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.16 on 2026-08-08 20:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0499_drop_kronika_views"), + ("pbn_api", "0079_constraint_publikacja_instytucji"), + ( + "taggit", + "0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx", + ), + ] + + operations = [ + migrations.AlterField( + model_name="praca_habilitacyjna", + name="autor", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="bpp.autor" + ), + ), + migrations.AddConstraint( + model_name="praca_habilitacyjna", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("autor",), + name="phab_uniq_autor_zywy", + violation_error_message="Ten autor ma już pracę habilitacyjną.", + ), + ), + ] 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/models/patent.py b/src/bpp/models/patent.py index 49cfd1831..d2f2b728b 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 ( @@ -25,20 +26,32 @@ RekordBPPBaza, ) from bpp.models.autor import Autor +from bpp.models.soft_delete import ( + BppAutorstwoSoftDeleteMixin, + BppPublikacjaSoftDeleteMixin, +) 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( "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: @@ -46,12 +59,59 @@ 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. 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"], + condition=Q(deleted_at__isnull=True), + name="pat_autor_uniq_rekord_autor_typ", + ), + # 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. + ExclusionConstraint( + name="pat_autor_excl_rekord_kolejnosc", + expressions=[ + ("rekord", RangeOperators.EQUAL), + ("kolejnosc", RangeOperators.EQUAL), + ], + condition=Q(deleted_at__isnull=True), + deferrable=Deferrable.DEFERRED, + ), + ] + indexes = [ + # 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 + # @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 @@ -67,6 +127,7 @@ def jezyk(self): class Patent( + BppPublikacjaSoftDeleteMixin, RekordBPPBaza, ModelZRokiem, ModelZeStatusem, @@ -120,6 +181,15 @@ class Meta: verbose_name = "patent" verbose_name_plural = "patenty" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="patent_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def __str__(self): return self.tytul_oryginalny diff --git a/src/bpp/models/praca_doktorska.py b/src/bpp/models/praca_doktorska.py index 53edbb894..1e6c593cb 100644 --- a/src/bpp/models/praca_doktorska.py +++ b/src/bpp/models/praca_doktorska.py @@ -1,7 +1,7 @@ from denorm import denormalized, depend_on_fields, depend_on_related from django.contrib.postgres.fields import ArrayField from django.db import models -from django.db.models import CASCADE, SET_NULL +from django.db.models import CASCADE, SET_NULL, Q from django.utils.functional import cached_property from bpp.models import ( @@ -11,6 +11,7 @@ NieMaProcentowMixin, ) from bpp.models.abstract import DwaTytuly +from bpp.models.soft_delete import BppPublikacjaSoftDeleteMixin from .autor import Autor from .struktura import Jednostka @@ -149,7 +150,7 @@ def typ_odpowiedzialnosci_autor(self): _Praca_Doktorska_PropertyCache = _Praca_Doktorska_PropertyCache() -class Praca_Doktorska(Praca_Doktorska_Baza): +class Praca_Doktorska(BppPublikacjaSoftDeleteMixin, Praca_Doktorska_Baza): autor = models.ForeignKey(Autor, CASCADE) promotor = models.ForeignKey( @@ -165,6 +166,15 @@ class Meta: verbose_name_plural = "prace doktorskie" app_label = "bpp" ordering = ("rok", "tytul_oryginalny") + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="pdok_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def clean(self): DwaTytuly.clean(self) diff --git a/src/bpp/models/praca_habilitacyjna.py b/src/bpp/models/praca_habilitacyjna.py index 14b07310d..e70010b23 100644 --- a/src/bpp/models/praca_habilitacyjna.py +++ b/src/bpp/models/praca_habilitacyjna.py @@ -1,11 +1,12 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models -from django.db.models import CASCADE, PROTECT +from django.db.models import CASCADE, PROTECT, Q from django.utils.functional import cached_property from bpp.models import Autor, Charakter_Formalny, DwaTytuly, ModelZOplataZaPublikacje from bpp.models.praca_doktorska import Praca_Doktorska_Baza +from bpp.models.soft_delete import BppPublikacjaSoftDeleteMixin class Publikacja_Habilitacyjna(models.Model): @@ -43,8 +44,24 @@ def charakter_formalny(self): _Praca_Habilitacyjna_PropertyCache = _Praca_Habilitacyjna_PropertyCache() -class Praca_Habilitacyjna(Praca_Doktorska_Baza): - autor = models.OneToOneField(Autor, PROTECT) +class Praca_Habilitacyjna(BppPublikacjaSoftDeleteMixin, Praca_Doktorska_Baza): + # ``ForeignKey``, a NIE ``OneToOneField``, wyłącznie po to, żeby dało się + # zdjąć bezwarunkowy ``UNIQUE (autor_id)`` — Django wymusza `unique=True` + # w ``OneToOneField.__init__`` i nie ma tego jak wyłączyć. Reguła „jeden + # autor, jedna habilitacja" nie znika: pilnuje jej warunkowy + # ``phab_uniq_autor_zywy`` w ``Meta.constraints`` niżej. + # + # Twarde UNIQUE nie znało kosza. Duplikat z habilitacją soft-skasowaną + # + główny autor z żywą wywracał CAŁE scalanie autorów ``IntegrityError``-em + # (scalanie przenosi wiersze RAZEM Z KOSZEM, żeby nie zostawiać sierot). + # + # ⚠️ Akcesor odwrotny to teraz ``autor.praca_habilitacyjna_set`` (manager), + # a nie ``autor.praca_habilitacyjna`` (obiekt). Ścieżka FILTROWANIA w ORM / + # DjangoQL się NIE zmienia — ``related_query_name`` domyślnie i tak jest + # nazwą modelu. Zmiana akcesora to zarazem naprawa: manager relacji używa + # ``_default_manager`` (filtruje kosz), podczas gdy odwrotne OneToOne szło + # przez ``_base_manager`` i pokazywało rekordy skasowane. + autor = models.ForeignKey(Autor, PROTECT) publikacje_habilitacyjne = GenericRelation(Publikacja_Habilitacyjna) @@ -56,6 +73,40 @@ class Meta: verbose_name = "praca habilitacyjna" verbose_name_plural = "prace habilitacyjne" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="phab_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] + constraints = [ + # Następca bezwarunkowego `UNIQUE (autor_id)` z `OneToOneField`. + # Ten sam wzorzec, co faza 01 zastosowała do `*_Autor`: reguła + # obowiązuje TYLKO wśród żywych wierszy, więc habilitacja w koszu + # nie blokuje ani ponownego wprowadzenia, ani przeniesienia przy + # scalaniu autorów. + # + # ⚠️ To ograniczenie NIE jest samo z siebie widoczne w formularzu. + # `Model.validate_constraints()` (Django >=4.1) po cichu POMIJA + # sprawdzenie, gdy pole użyte w `condition` (`deleted_at`) jest + # wykluczone z walidacji — a admin wyklucza wszystko spoza swoich + # `fieldsets`. Komunikat dla operatora daje jawne `clean_autor()` + # w `bpp/admin/praca_habilitacyjna.py`; tutejsze ograniczenie jest + # ostateczną gwarancją na poziomie bazy. + models.UniqueConstraint( + fields=["autor"], + condition=Q(deleted_at__isnull=True), + name="phab_uniq_autor_zywy", + # Bez tego operator zobaczyłby w adminie domyślne + # „Constraint “phab_uniq_autor_zywy” is violated." — Django nie + # umie zmapować UniqueConstraint z `condition` na komunikat + # przy polu, więc trafia to w błędy ogólne formularza. + violation_error_message="Ten autor ma już pracę habilitacyjną.", + ), + ] def clean(self): DwaTytuly.clean(self) diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py new file mode 100644 index 000000000..bf0a46f08 --- /dev/null +++ b/src/bpp/models/soft_delete.py @@ -0,0 +1,387 @@ +"""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. + +INWARIANT (dot. ``ostatnio_zmieniony``): soft-delete JEST modyfikacją +rekordu, więc ``ostatnio_zmieniony`` MUSI być bumpowany przy ``delete()`` +ORAZ ``restore()`` — tak, jak wygląda to dla każdego konsumenta +przyrostowego (OAI-PMH, CERIF, REST API). Realizują to +``POLE_ZNACZNIKA_ZMIANY`` + ``dopisz_znacznik_zmiany()`` + +``BppAutorstwoSoftDeleteMixin.save()`` — również PINNED (kontrakt dopisany +2026-08-07, obowiązuje też fazy 02 i 04). Dzięki niemu nagrobki dla +harvestu przyrostowego są odpytywalne od ręki, bez ``SoftDeleteLog`` +z fazy 06:: + + Model.deleted_objects.filter(ostatnio_zmieniony__gte=X) +""" + +import uuid + +from django.core.exceptions import FieldDoesNotExist +from django.db import transaction +from django.utils import timezone +from django_softdelete.managers import ( + DeletedManager, + DeletedQuerySet, + GlobalManager, + SoftDeleteManager, + SoftDeleteQuerySet, +) +from django_softdelete.models import SoftDeleteModel +from django_softdelete.signals import post_restore, post_soft_delete + +#: Akcesor relacji odwrotnej publikacja -> wiersze ``*_Autor``. Istnieje +#: jako PRAWDZIWA relacja tylko dla trzech typów z through-modelem; +#: ``Praca_Doktorska``/``Praca_Habilitacyjna`` mają pod tą nazwą property +#: zwracającą atrapy — patrz ``BppPublikacjaSoftDeleteMixin``. +NAZWA_RELACJI_AUTORSTW = "autorzy_set" + +#: Nazwa pola-znacznika czasu modyfikacji. Ta sama w +#: ``BazaModeluOdpowiedzialnosciAutorow`` (``abstract/authors.py``) i w +#: ``ModelZAdnotacjami`` (``abstract/metadata.py``), więc jedna stała +#: obsługuje autorstwa (faza 01), publikacje (faza 02) i ``Autor`` (04). +POLE_ZNACZNIKA_ZMIANY = "ostatnio_zmieniony" + + +def dopisz_znacznik_zmiany(instance, update_fields): + """Dokłada ``ostatnio_zmieniony`` do ``update_fields`` zapisu, który + ustawia ``deleted_at`` — czyli soft-delete ALBO restore: pakiet w obu + ścieżkach woła ``save(update_fields=['deleted_at', 'restored_at', + 'transaction_id'])``. + + DLACZEGO PRZEZ ``save()``, a nie przez nadpisanie ``delete()``/ + ``restore()``: kaskada i księgowość transakcji siedzą w kodzie + pakietu, a ten woła ``self.save()`` z własną listą pól. Doklejenie + się do ``save()`` obsługuje KAŻDĄ ścieżkę pakietu (instancja, + queryset, kaskada one-to-many) i nie kopiuje ani grama logiki + pakietu — przeżyje więc jego aktualizację. + + DLACZEGO SAMO DOPISANIE NAZWY WYSTARCZA: ``Model._save_table()`` + najpierw zawęża listę pól do zapisu przez ``update_fields``, a + dopiero na tak zawężonej liście woła ``Field.pre_save()``. + ``DateTimeField.pre_save()`` to jedyne miejsce, w którym ``auto_now`` + podstawia ``timezone.now()`` — pole nieobecne w ``update_fields`` + nie jest więc bumpowane, a obecne jest. (Zweryfikowane testem + ``test_ostatnio_zmieniony.py``, nie tylko lekturą źródeł Django.) + + Model bez pola ``ostatnio_zmieniony`` (np. ``Element_Repozytorium``, + a docelowo część modeli z faz 02/04) dostaje ``update_fields`` + nietknięte — dopisanie nieistniejącej nazwy wywaliłoby ``save()`` + na ``ValueError``. + """ + if not update_fields: + # Pełny zapis (``update_fields=None``) i tak przepuszcza wszystkie + # pola przez ``pre_save()`` — ``auto_now`` zadziała samo. + return update_fields + + nazwy = set(update_fields) + if "deleted_at" not in nazwy: + # Zwykły częściowy zapis — nie nasza sprawa. Podbijanie znacznika + # przy KAŻDYM ``update_fields`` zmieniłoby semantykę daleko poza + # soft-deletem (np. denorm celowo wyklucza pola ``auto_now``). + return update_fields + if POLE_ZNACZNIKA_ZMIANY in nazwy: + return update_fields + + try: + instance._meta.get_field(POLE_ZNACZNIKA_ZMIANY) + except FieldDoesNotExist: + return update_fields + + return list(update_fields) + [POLE_ZNACZNIKA_ZMIANY] + + +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) + + 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): + 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) + + +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. + + 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 = BppDeletedManager() + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + """Soft-delete i restore MUSZĄ podbić ``ostatnio_zmieniony``. + + Pakiet zapisuje obie operacje przez ``save(update_fields=[ + 'deleted_at', 'restored_at', 'transaction_id'])``. Django woła + ``pre_save()`` (a więc i ``auto_now``) tylko dla pól obecnych w + ``update_fields``, więc bez dopisania nazwy znacznik stałby w + miejscu — a soft-delete jest modyfikacją rekordu i tak musi + wyglądać dla konsumentów przyrostowych (OAI-PMH, CERIF, REST API). + + Czytamy ``update_fields`` wyłącznie z ``kwargs``: pakiet (i cały + kod BPP) przekazuje je nazwanie, a przekazanie pozycyjne jest w + Django deprecated. Klucza nie wstrzykujemy, gdy go nie było — + pełny zapis ma zostać pełnym zapisem. + """ + update_fields = kwargs.get("update_fields") + if update_fields: + kwargs["update_fields"] = dopisz_znacznik_zmiany(self, update_fields) + return super().save(*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 + `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. + + 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(strict, transaction_id, *args, **kwargs) + + +class BppPublikacjaSoftDeleteMixin(SoftDeleteModel): + """SoftDeleteModel dla 5 modeli PUBLIKACJI (faza 02) z **wąską, + kontrolowaną** kaskadą na własne wiersze ``*_Autor`` pod wspólnym + ``transaction_id``. + + DLACZEGO NIE REFLEKSYJNA KASKADA PAKIETU: ``SoftDeleteModel.delete()`` + przechodzi po wszystkich relacjach zwrotnych. Zjechałaby więc po + ``*_Streszczenie``, ``*_Zewnetrzna_Baza_Danych`` i + ``Publikacja_Habilitacyjna`` — modelach, które soft-delete NIE obejmuje. + Co gorsza zrobiłaby to CICHO: ``delete()`` pakietu ma domyślnie + ``strict=False``, więc nie usłyszelibyśmy ``SoftDeleteException``. + Dlatego kaskadę piszemy sami i zatrzymujemy ją na ``*_Autor``. + + Kaskada NIE jest jednolita dla wszystkich pięciu modeli — patrz + ``_relacja_autorstw()``. + """ + + # Managery z gate'em na .update() (faza 01). ``deleted_objects`` to + # NASZ ``BppDeletedManager``, nie pakietowy ``DeletedManager`` — + # pakietowy zwraca queryset z ``strict=True`` w ``restore()``, co łamie + # inwariant z docstringu modułu. + objects = BppSoftDeleteManager() + global_objects = BppGlobalManager() + deleted_objects = BppDeletedManager() + + class Meta: + abstract = True + + # --- rozpoznanie kształtu kaskady ---------------------------------- + + def _relacja_autorstw(self): + """Relacja odwrotna ``autorzy_set`` (``ForeignObjectRel``) albo + ``None``, gdy publikacja nie ma through-modelu autorstw. + + DLACZEGO PYTAMY ``_meta``, A NIE TYPU ATRYBUTU: pod nazwą + ``autorzy_set`` kryją się dwie różne rzeczy. Dla + ``Wydawnictwo_Ciagle``/``Wydawnictwo_Zwarte``/``Patent`` to + prawdziwy related manager (``related_name`` FK w modelu + ``*_Autor``). Dla ``Praca_Doktorska``/``Praca_Habilitacyjna`` to + PROPERTY zwracająca ``FakeSet`` z atrapami (autor leży na wierszu + samej publikacji) — ``FakeSet`` jest podklasą ``list``, nie ma + ``.model``, a atrapy nie mają ``.delete()``. + + Rozpoznawanie tego przez ``type(self).__dict__.get("autorzy_set")`` + NIE DZIAŁA: property jest zadeklarowana na abstrakcyjnej bazie + ``Praca_Doktorska_Baza``, a dziedziczenie abstrakcyjne w Django + kopiuje do klasy potomnej POLA, nie zwykłe atrybuty Pythona — te + zostają na bazie i są znajdowane dopiero przez MRO. ``__dict__`` + klasy konkretnej jest więc pusty i test na ``property`` dawałby + fałszywe „to jest through-model", a zaraz potem ``AttributeError`` + na ``FakeSet.model``. + + ``_meta.related_objects`` zna wyłącznie PRAWDZIWE relacje, więc + odpowiada na pytanie, które faktycznie zadajemy. + """ + for rel in self._meta.related_objects: + if rel.get_accessor_name() == NAZWA_RELACJI_AUTORSTW: + return rel + return None + + def _model_through(self): + """Model ``*_Autor`` tej publikacji albo ``None``.""" + rel = self._relacja_autorstw() + return rel.related_model if rel is not None else None + + def _autorstwa_do_kaskady(self): + """Wiersze ``*_Autor`` do soft-delete; pusto bez through-modelu. + + Czyta przez domyślny manager (``objects``, więc już-skasowane są + pominięte) — kasowanie drugi raz nie szkodzi, ale i nie ma po co. + """ + if self._relacja_autorstw() is None: + return [] + return list(getattr(self, NAZWA_RELACJI_AUTORSTW).all()) + + # --- kontrakt zapisu ------------------------------------------------ + + def save(self, *args, **kwargs): + """Bump ``ostatnio_zmieniony`` przy soft-delete i restore. + + Ten sam kontrakt (PINNED), co + ``BppAutorstwoSoftDeleteMixin.save()`` — pełne uzasadnienie + mechanizmu jest w docstringu ``dopisz_znacznik_zmiany()``. Bez + tego nagrobki dla harvestu przyrostowego (OAI-PMH, CERIF, REST + API) byłyby nieodpytywalne po ``ostatnio_zmieniony__gte``, bo + ``auto_now`` nie rusza pola nieobecnego w ``update_fields``. + """ + update_fields = kwargs.get("update_fields") + if update_fields: + kwargs["update_fields"] = dopisz_znacznik_zmiany(self, update_fields) + return super().save(*args, **kwargs) + + # --- soft-delete / restore ------------------------------------------ + + def delete(self, *args, user=None, reason="", **kwargs): + """Soft-delete publikacji + wąska kaskada na ``*_Autor``. + + ``user``/``reason`` są na razie wyłącznie przepuszczane — konsumuje + je ``SoftDeleteLog`` z fazy 06. W sygnaturze MUSZĄ być już teraz + (kontrakt PINNED), żeby wołający kod nie wymagał później zmiany. + """ + txid = kwargs.pop("transaction_id", None) or uuid.uuid4() + with transaction.atomic(): + # 1. kaskada per-instancja — NIGDY bulk update(deleted_at=...), + # bo omijałby post_save, sygnały i reversion (gate w + # BppSoftDeleteQuerySet.update() egzekwuje to fail-fast). + for autorstwo in self._autorstwa_do_kaskady(): + autorstwo.delete(transaction_id=txid) + # 2. własny wiersz + self.deleted_at = timezone.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, strict: bool = False, user=None, **kwargs): + """Przywrócenie publikacji + tych ``*_Autor``, które zniknęły RAZEM + z nią (ten sam ``transaction_id``). + + Filtr po ``transaction_id`` jest istotny: autorstwo skasowane + wcześniej, osobną decyzją operatora, ma POZOSTAĆ w koszu. + + ``strict`` przyjmujemy i ignorujemy świadomie — przekazują go + ścieżki queryset-owe (``global_objects``/``deleted_objects``), a my + i tak nie wołamy ``super().restore()``, więc pakietowy + ``SoftDeleteException`` nie ma jak polecieć (inwariant z docstringu + modułu). + """ + txid = self.transaction_id + rel = self._relacja_autorstw() + with transaction.atomic(): + if txid is not None and rel is not None: + # Nazwa pola FK z metadanych relacji — bez zaszywania + # "rekord" na sztywno. + filtr = {rel.field.name: self, "transaction_id": txid} + for autorstwo in rel.related_model.deleted_objects.filter(**filtr): + 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 diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py index 9584b4eab..cfe9409fe 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, @@ -46,12 +47,18 @@ ModelZWWW, Wydawnictwo_Baza, ) +from bpp.models.soft_delete import ( + BppAutorstwoSoftDeleteMixin, + BppPublikacjaSoftDeleteMixin, + BppSoftDeleteManager, +) 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.""" @@ -60,9 +67,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: @@ -70,12 +85,80 @@ 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. 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"], + condition=Q(deleted_at__isnull=True), + name="wc_autor_uniq_rekord_autor_typ", + ), + # 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 + # 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 = [ + # 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 + # @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( @@ -90,11 +173,30 @@ class Meta: abstract = True -class Wydawnictwo_Ciagle_Manager(ManagerModeliZOplataZaPublikacjeMixin, models.Manager): - pass +class Wydawnictwo_Ciagle_Manager( + ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager +): + """Menedżer opłat PRZEPLECIONY z filtrem soft-delete (faza 02). + + Nośna jest DRUGA BAZA, nie kolejność. Do fazy 02 stało tu + ``models.Manager``, więc ``objects`` w ogóle nie znało ``deleted_at`` + i pokazywało kosz — mimo że model dziedziczył już + ``BppPublikacjaSoftDeleteMixin`` (menedżer zadeklarowany w ciele klasy + przesłania ten wniesiony przez bazę abstrakcyjną). Podmiana na + ``BppSoftDeleteManager`` to naprawia. + + Kolejność baz jest natomiast WYŁĄCZNIE konwencją (mixiny przed klasą + bazową) — sprawdzone mutacyjnie: odwrócenie jej nie zmienia zachowania. + Powód: ``ManagerModeliZOplataZaPublikacjeMixin`` NIE jest menedżerem, + tylko czystym mixinem z jedną metodą (``self.exclude(...)``), więc nie + wnosi własnego ``get_queryset()`` i nie ma o co konkurować w MRO. + Dzięki temu ``rekordy_z_oplata()`` operuje na już-przefiltrowanym + querysecie bez jednej linijki kodu o soft-delete. + """ class Wydawnictwo_Ciagle( + BppPublikacjaSoftDeleteMixin, ZapobiegajNiewlasciwymCharakterom, Wydawnictwo_Baza, DwaTytuly, @@ -150,6 +252,22 @@ class Meta: verbose_name = "wydawnictwo ciągłe" verbose_name_plural = "wydawnictwa ciągłe" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY (`WHERE deleted_at IS NOT NULL`) — ten sam + # wzorzec i to samo uzasadnienie, co `wc_autor_deleted_at_idx` + # wyżej (faza 01): predykat `deleted_at IS NULL` pasuje do ~100% + # wierszy, więc planner nigdy nie wybrałby pod niego indeksu, a + # pełny btree byłby wyłącznie kosztem (rozmiar + wpis przy + # każdym INSERT/UPDATE publikacji). Selektywne jest zapytanie + # ODWROTNE — kosz/audyt (`deleted_objects`) — i to ono dostaje + # tu mikroskopijny indeks. KANONICZNE UZASADNIENIE dla + # wszystkich pięciu tabel publikacji. + models.Index( + fields=["deleted_at"], + name="wc_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def punktacja_zrodla(self): """Funkcja - skrót do użycia w templatkach, zwraca punktację zrodla diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py index f0ffac5ea..cb53df3d7 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 @@ -53,6 +54,11 @@ ) from bpp.models.autor import Autor from bpp.models.nagroda import Nagroda +from bpp.models.soft_delete import ( + BppAutorstwoSoftDeleteMixin, + BppPublikacjaSoftDeleteMixin, + BppSoftDeleteManager, +) from bpp.models.system import Zewnetrzna_Baza_Danych from bpp.models.util import ZapobiegajNiewlasciwymCharakterom from bpp.models.wydawca import Wydawca @@ -60,6 +66,7 @@ class Wydawnictwo_Zwarte_Autor( DirtyFieldsMixin, + BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutorow, ): """Model zawierający informację o przywiązaniu autorów do wydawnictwa @@ -69,9 +76,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: @@ -79,12 +94,59 @@ 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. 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"], + condition=Q(deleted_at__isnull=True), + name="wz_autor_uniq_rekord_autor_typ", + ), + # 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. + ExclusionConstraint( + name="wz_autor_excl_rekord_kolejnosc", + expressions=[ + ("rekord", RangeOperators.EQUAL), + ("kolejnosc", RangeOperators.EQUAL), + ], + condition=Q(deleted_at__isnull=True), + deferrable=Deferrable.DEFERRED, + ), + ] + indexes = [ + # 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 + # @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 @@ -174,7 +236,18 @@ class Meta: rok_regex = re.compile(r"\s[12]\d\d\d") -class Wydawnictwo_Zwarte_Manager(ManagerModeliZOplataZaPublikacjeMixin, models.Manager): +class Wydawnictwo_Zwarte_Manager( + ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager +): + """Jak ``Wydawnictwo_Ciagle_Manager`` — uzasadnienie doboru bazy + (i tego, dlaczego kolejność NIE jest nośna) w jego docstringu + (``wydawnictwo_ciagle.py``). + + ``wydawnictwa_nadrzedne_dla_innych()`` też korzysta na przepleceniu: + po fazie 02 nie zwróci już książki-matki, której jedyne rozdziały + trafiły do kosza. + """ + def wydawnictwa_nadrzedne_dla_innych(self): return ( self.exclude(wydawnictwo_nadrzedne_id=None) @@ -184,6 +257,7 @@ def wydawnictwa_nadrzedne_dla_innych(self): class Wydawnictwo_Zwarte( + BppPublikacjaSoftDeleteMixin, ZapobiegajNiewlasciwymCharakterom, Wydawnictwo_Zwarte_Baza, ModelZCharakterem, @@ -259,6 +333,15 @@ class Meta: verbose_name = "wydawnictwo zwarte" verbose_name_plural = "wydawnictwa zwarte" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="wz_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def wydawnictwa_powiazane_posortowane(self): """ diff --git a/src/bpp/newsfragments/djangoql-kosz-domyslnie-niewidoczny.feature.rst b/src/bpp/newsfragments/djangoql-kosz-domyslnie-niewidoczny.feature.rst new file mode 100644 index 000000000..57d2a0fd6 --- /dev/null +++ b/src/bpp/newsfragments/djangoql-kosz-domyslnie-niewidoczny.feature.rst @@ -0,0 +1,6 @@ +Zapytania w języku DjangoQL — w module Redagowanie, na stronie „Szukaj +zapytaniem" oraz w REST API (``/api/v1/zapytanie/``) — domyślnie NIE pokazują +już rekordów skasowanych (kosz). Żeby zajrzeć do kosza, wystarczy wymienić w +zapytaniu pole ``deleted_at`` na wybranej relacji: warunek +``autorzy_set.deleted_at != None`` pokazuje wyłącznie skasowane pozycje, +a ``autorzy_set.deleted_at = None`` jest tym samym, co zachowanie domyślne. 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..b6d6d43bc --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst @@ -0,0 +1,13 @@ +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. Skasowane przypisania znikają też z widoków źródłowych i +z materializowanego cache'u bibliografii, więc nie pokazują się na stronach +publikacji, w liczbie publikacji autora, w podpowiedziach wyszukiwarki, +w eksporcie BibTeX, w listach panelu administracyjnego ani w zestawieniach +oświadczeń wysyłanych do PBN. +Skasowanie i przywrócenie przypisania aktualizuje jego znacznik „ostatnio +zmieniony", dzięki czemu systemy pobierające dane przyrostowo widzą taką +operację jako zwykłą modyfikację rekordu. +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/newsfragments/soft-delete-guard-django-db-setup.bugfix.rst b/src/bpp/newsfragments/soft-delete-guard-django-db-setup.bugfix.rst new file mode 100644 index 000000000..6b412bbcc --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-guard-django-db-setup.bugfix.rst @@ -0,0 +1,7 @@ +Sesja testów bez ani jednego testu korzystającego z bazy danych wywalała +się na gałęzi soft-delete autorstwa błędem ``UndefinedColumn: kolumna +deleted_at nie istnieje`` przy instalacji triggerów denorm, gdy testowa +baza pochodziła sprzed migracji dodającej tę kolumnę (np. reużyty +kontener testowy ze starym schematem). Rozpoznajemy teraz ten jeden, +konkretny przypadek i pomijamy instalację triggerów z czytelnym +ostrzeżeniem zamiast wywalać całą sesję pytest. diff --git a/src/bpp/newsfragments/soft-delete-habilitacja-warunkowy-unique.bugfix.rst b/src/bpp/newsfragments/soft-delete-habilitacja-warunkowy-unique.bugfix.rst new file mode 100644 index 000000000..0bbe2a965 --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-habilitacja-warunkowy-unique.bugfix.rst @@ -0,0 +1,3 @@ +Scalanie autorów nie kończy się już błędem, gdy duplikat ma pracę +habilitacyjną w koszu, a autor główny — żywą. Gdy obaj mają żywą pracę +habilitacyjną, operator dostaje czytelny komunikat zamiast błędu bazy danych. diff --git a/src/bpp/newsfragments/soft-delete-import-wskrzesza-z-kosza.bugfix.rst b/src/bpp/newsfragments/soft-delete-import-wskrzesza-z-kosza.bugfix.rst new file mode 100644 index 000000000..5829a9662 --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-import-wskrzesza-z-kosza.bugfix.rst @@ -0,0 +1,3 @@ +Import z PBN trafiający na publikację, której rekord BPP jest w koszu, nie +wywala się już błędem unikalności ``pbn_uid`` — rekord wraca z kosza, a fakt +jest odnotowany w rejestrze „Rekordy przywrócone przez import". diff --git a/src/bpp/newsfragments/soft-delete-rejestr-wskrzeszen-admin.feature.rst b/src/bpp/newsfragments/soft-delete-rejestr-wskrzeszen-admin.feature.rst new file mode 100644 index 000000000..6e5493b58 --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-rejestr-wskrzeszen-admin.feature.rst @@ -0,0 +1,3 @@ +Rejestr „Rekordy przywrócone przez import" jest widoczny w panelu +administracyjnym (tylko do odczytu): można sprawdzić, która publikacja wróciła +z kosza przy imporcie z PBN, kiedy i z której ścieżki importu. diff --git a/src/bpp/newsfragments/soft-delete-scalanie-a-kosz.bugfix.rst b/src/bpp/newsfragments/soft-delete-scalanie-a-kosz.bugfix.rst new file mode 100644 index 000000000..36dfafee4 --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-scalanie-a-kosz.bugfix.rst @@ -0,0 +1,3 @@ +Scalanie duplikatów autorów przestało dublować autorstwa w koszu (co blokowało +późniejsze przywrócenie publikacji) oraz przestało kolejkować do eksportu +w PBN publikacje, które są w koszu. diff --git a/src/bpp/templates/browse/autor.html b/src/bpp/templates/browse/autor.html index 9ae3183b6..0eac485ac 100644 --- a/src/bpp/templates/browse/autor.html +++ b/src/bpp/templates/browse/autor.html @@ -187,7 +187,12 @@

Metryki ewaluacyjne

{% endwith %} - {% if autor.praca_doktorska_set.exists or autor.praca_habilitacyjna %} + {# `praca_habilitacyjna_set`, a nie `praca_habilitacyjna`: pole `autor` #} + {# jest od fazy 03 zwyklym ForeignKey (warunkowy unique zamiast #} + {# twardego), wiec akcesor odwrotny to manager, nie obiekt. Manager #} + {# relacji filtruje kosz — odwrotne OneToOne szlo przez `_base_manager` #} + {# i pokazywalo rekordy skasowane. #} + {% if autor.praca_doktorska_set.exists or autor.praca_habilitacyjna_set.exists %}

Stopnie naukowe

{% if autor.praca_doktorska_set.exists %} @@ -200,13 +205,15 @@

Stopnie naukowe

{% endfor %} {% endif %} - {% if autor.praca_habilitacyjna %} -

- Praca habilitacyjna: - - {{ autor.praca_habilitacyjna|safe }} - -

+ {% if autor.praca_habilitacyjna_set.exists %} + {% for praca_habilitacyjna in autor.praca_habilitacyjna_set.all %} +

+ Praca habilitacyjna: + + {{ praca_habilitacyjna|safe }} + +

+ {% endfor %} {% endif %}
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..917a07521 --- /dev/null +++ b/src/bpp/tests/test_admin/test_autor_inline_unique.py @@ -0,0 +1,373 @@ +"""Task 3c — czy zamiana `unique_together` na warunkowy `UniqueConstraint`/ +`ExclusionConstraint` (``condition=deleted_at__isnull``) NIE zregresowała +walidacji formularza w adminie. + +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()``), 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). +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 +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_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. 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) + 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 + 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_admin/test_praca_habilitacyjna_unique.py b/src/bpp/tests/test_admin/test_praca_habilitacyjna_unique.py new file mode 100644 index 000000000..3dd9fa569 --- /dev/null +++ b/src/bpp/tests/test_admin/test_praca_habilitacyjna_unique.py @@ -0,0 +1,157 @@ +"""Formularz admina musi łapać drugą ŻYWĄ habilitację tego samego autora. + +Do fazy 03 pilnowało tego ``OneToOneField`` — Django sprawdzał to zwykłym +``validate_unique()`` i operator dostawał błąd formularza. + +Faza 03 zamieniła pole na ``ForeignKey`` + warunkowy +``UniqueConstraint(condition=Q(deleted_at__isnull=True))``, żeby habilitacja +w koszu nie blokowała scalania autorów. To przenosi walidację z +``validate_unique()`` (patrzy na ``Meta.unique_together``/``unique``) do +``validate_constraints()`` (patrzy na ``Meta.constraints``, Django >=4.1). + +I tu jest pułapka: ``Model.validate_constraints()`` **CICHO POMIJA** constraint, +którego pole warunku (``deleted_at``) jest wykluczone z walidacji — a jest, +bo ``_get_validation_exclusions()`` wyklucza wszystko spoza ``Meta.fields``, +które admin nadpisuje spłaszczonymi ``fieldsets``. Bez jawnego sprawdzenia +admin przepuściłby duplikat i wywalił się ``IntegrityError``-em (HTTP 500) +dopiero przy zapisie. + +Stąd ``Praca_HabilitacyjnaForm.clean_autor()``. Te testy pilnują trzech rzeczy +naraz: że kolizja jest łapana, że rekord nie koliduje SAM ZE SOBĄ przy edycji, +i że praca w koszu NIE blokuje wprowadzenia nowej (bo inaczej „naprawą" byłby +powrót do bezwarunkowej unikalności, czyli cofnięcie całej pozycji 3.2). +""" + +import pytest +from django.contrib import admin as django_admin +from django.test import RequestFactory +from model_bakery import baker + +from bpp.models import Autor, Jednostka, Praca_Habilitacyjna + + +def _klasa_formularza(admin_user): + """Formularz DOKŁADNIE taki, jaki zbuduje admin. + + ``Praca_HabilitacyjnaForm`` sam nie ma ``Meta.model`` — dostaje go dopiero + z ``ModelAdmin.get_form()``. Idziemy tą samą drogą, bo testujemy zachowanie + admina, a nie wyizolowanej klasy formularza. + """ + request = RequestFactory().get("/") + request.user = admin_user + model_admin = django_admin.site._registry[Praca_Habilitacyjna] + return model_admin.get_form(request, obj=None, change=False) + + +def _dane_formularza(autor, jednostka, typy_kbn, statusy_korekt, jezyki): + from bpp.models import Jezyk, Typ_KBN + + return { + "tytul_oryginalny": "Druga habilitacja tego samego autora", + "autor": autor.pk, + "jednostka": jednostka.pk, + "rok": 2023, + "jezyk": Jezyk.objects.get(skrot="pol.").pk, + "typ_kbn": Typ_KBN.objects.first().pk, + "status_korekty": statusy_korekt["przed korektą"].pk, + # Pola punktacji są wymagane przez formularz. Wypełniamy KOMPLET, + # żeby jedynym możliwym powodem odrzucenia był konflikt autora — + # inaczej pierwszy test byłby czerwony z byle powodu. + "punkty_kbn": 0, + "impact_factor": 0, + "index_copernicus": 0, + "punktacja_snip": 0, + "punktacja_wewnetrzna": 0, + } + + +@pytest.mark.django_db +def test_admin_nie_pozwala_na_druga_zywa_habilitacje_autora( + admin_user, + typy_kbn, + statusy_korekt, + jezyki, + charaktery_formalne, + typy_odpowiedzialnosci, +): + autor = baker.make(Autor) + jednostka = baker.make(Jednostka) + baker.make(Praca_Habilitacyjna, autor=autor) + + form = _klasa_formularza(admin_user)( + data=_dane_formularza(autor, jednostka, typy_kbn, statusy_korekt, jezyki) + ) + + assert not form.is_valid(), ( + "formularz przepuscil druga zywa habilitacje — przy zapisie poleci " + "IntegrityError (HTTP 500) zamiast bledu formularza" + ) + # Komunikat ma być dla człowieka, a nie „Constraint “phab_uniq_autor_zywy” + # is violated." — Django nie umie zmapować UniqueConstraint z `condition` + # na pole, więc bez `violation_error_message` operator dostaje nazwę + # ograniczenia z bazy. + assert "Ten autor ma już pracę habilitacyjną." in str(form.errors), ( + f"blad nie tlumaczy przyczyny: {form.errors.as_json()}" + ) + + +@pytest.mark.django_db +def test_admin_pozwala_zapisac_edycje_istniejacej_habilitacji( + admin_user, + typy_kbn, + statusy_korekt, + jezyki, + charaktery_formalne, + typy_odpowiedzialnosci, +): + """Kontrola: rekord nie może kolidować SAM ZE SOBĄ. + + Bez wykluczenia edytowanego wiersza każda zmiana istniejącej habilitacji + (poprawka literówki w tytule!) byłaby odrzucana jako duplikat. + """ + autor = baker.make(Autor) + jednostka = baker.make(Jednostka) + habilitacja = baker.make(Praca_Habilitacyjna, autor=autor) + + request = RequestFactory().get("/") + request.user = admin_user + model_admin = django_admin.site._registry[Praca_Habilitacyjna] + FormKlasa = model_admin.get_form(request, obj=habilitacja, change=True) + + form = FormKlasa( + instance=habilitacja, + data=_dane_formularza(autor, jednostka, typy_kbn, statusy_korekt, jezyki), + ) + + assert form.is_valid(), ( + f"edycja wlasnej habilitacji odrzucona jako duplikat: {form.errors.as_json()}" + ) + + +@pytest.mark.django_db +def test_admin_pozwala_na_habilitacje_gdy_poprzednia_jest_w_koszu( + admin_user, + typy_kbn, + statusy_korekt, + jezyki, + charaktery_formalne, + typy_odpowiedzialnosci, +): + """Kontrola: warunkowy unique NIE obejmuje kosza. + + Bez tej asercji „naprawą" pierwszego testu mogłoby być przywrócenie + bezwarunkowej unikalności — czyli cofnięcie całej pozycji 3.2. + """ + autor = baker.make(Autor) + jednostka = baker.make(Jednostka) + stara = baker.make(Praca_Habilitacyjna, autor=autor) + stara.delete() + + form = _klasa_formularza(admin_user)( + data=_dane_formularza(autor, jednostka, typy_kbn, statusy_korekt, jezyki) + ) + + assert form.is_valid(), ( + f"habilitacja w koszu nie moze blokowac wprowadzenia nowej: " + f"{form.errors.as_json()}" + ) 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] 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..13403102a --- /dev/null +++ b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py @@ -0,0 +1,239 @@ +"""Warunki wstepne soft-delete wobec triggerow cache (PR #312, faza 01/02). + +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). 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``), a faza 02 -- 5 tabel PUBLIKACJI +(migracje 0496 i 0497, patrz ``test_soft_delete/test_views_sql_publikacje.py``). + +Historia tego pliku, bo tlumaczy jego ksztalt: + +- oryginalne dwa testy zostaly po fazie 01 ODWROCONE na docelowe asercje (to, + co soft-delete MA robic) i oznaczone ``xfail(strict=True)``, bo dla + publikacji mechanizmu jeszcze nie bylo. Zeby w ogole dalo sie je napisac, + dokladaly kolumne ``deleted_at`` ALTER-em i owijaly widok filtrem WEWNATRZ + transakcji testowej; +- faza 02 dostarczyla jedno i drugie NAPRAWDE, wiec symulacja zostala + usunieta, a wraz z nia markery ``xfail``. Testy sa teraz zwyklymi testami + regresyjnymi; +- ich ODPOWIEDNIKI dla ``bpp_wydawnictwo_ciagle_autor`` / ``bpp_autorzy_mat`` + (dopisane w fazie 01) zostaja -- pokrywaja druga sciezke. + +Surowy SQL, zeby izolowac sam trigger bazodanowy (bez denorm / sygnalow +Django). +""" + +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 + + +@pytest.mark.django_db +def test_update_samego_deleted_at_odpala_trigger(): + """Bramka WHEN zna deleted_at -> UPDATE soft-delete odpala 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). Do fazy 02 test byl ``xfail(strict=True)``, + a kolumne ``deleted_at`` dokladal ALTER-em w transakcji testowej -- + faza 01 dotknela wylacznie tabel ``*_Autor``. Faza 02 (migracje 0496 + i 0497) dodala kolumne i bramke NAPRAWDE, wiec symulacja zniknela, + a marker ``xfail`` razem z nia. + """ + 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 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.django_db +def test_soft_delete_usuwa_wiersz_z_mat(): + """Goly ``UPDATE ... SET deleted_at`` usuwa wiersz z bpp_rekord_mat. + + Odwrocenie ``test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat`` (nazwa i + asercja sprzed fazy 01). Jak w tescie wyzej: do fazy 02 byl to + ``xfail(strict=True)`` z symulacja (ALTER + owijka widoku); faza 02 + dostarczyla filtr i galaz kasujaca naprawde (migracja 0497). + + Filtr widoku jest aktywny juz przy tworzeniu rekordu, ale ``deleted_at`` + jest wtedy NULL, wiec wiersz normalnie wchodzi do bpp_rekord_mat. + """ + 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], + ) + + 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( + "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_autor(cur, ct, pk) + + 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_djangoql_schema_swiezosc.py b/src/bpp/tests/test_djangoql_schema_swiezosc.py new file mode 100644 index 000000000..ae2aa53a8 --- /dev/null +++ b/src/bpp/tests/test_djangoql_schema_swiezosc.py @@ -0,0 +1,137 @@ +"""Test świeżości commitowanych artefaktów schematu DjangoQL. + +``src/bpp/data/{rekord,autor,autorzy}_djangoql_schema.compact.txt`` to opisy +przestrzeni wyszukiwania wystawiane KONSUMENTOM (asystenci LLM, integratorzy +``/api/v1/zapytanie/``). Są generowane komendą +``opisz_schemat_djangoql_dla_llm``, ale do tej pory NIC nie pilnowało, żeby +nadążały za modelami — i faktycznie rozjechały się cicho: po fazie 01 +soft-delete nie wymieniały ``deleted_at`` w ogóle. Ta klasa zaniedbania wraca +przy każdej zmianie modeli w allow-liście (faza 02 doda ``deleted_at`` pięciu +modelom publikacji). + +Ten test porównuje artefakt z repo z artefaktem wygenerowanym z AKTUALNYCH +modeli. Porównanie NIE jest bajt-w-bajt — patrz :func:`_normalizuj`. + +Gdy test padnie +=============== + +To znaczy, że schemat DjangoQL zmienił się razem z modelami. Zregeneruj:: + + uv run python src/manage.py opisz_schemat_djangoql_dla_llm \\ + --wszystkie-korzenie + +i zacommituj trzy pliki z ``src/bpp/data/``. Regeneruj przeciw bazie +zbudowanej z ``baseline-sql/baseline.sql`` (np. ``uv run run-site run`` bez +dumpa) — wtedy do repo open-source nie trafiają dane Twojej instytucji. +""" + +import re + +import pytest +from django.core.management import call_command + +from bpp.management.commands.opisz_schemat_djangoql_dla_llm import ( + DATA_DIR, + KORZENIE, +) + +#: Nagłówek sekcji ze słownikami — wszystko od niej w dół to WARTOŚCI wierszy +#: pobrane z bazy, nie opis modeli. +SEKCJA_SLOWNIKOW = "dictionaries (shared relation values, referenced above):" + +#: Marker „po czym dopasować obiekt tej relacji" (``match nazwa``). djangoql +#: emituje go tylko wtedy, gdy w bazie SĄ jakieś wartości do dopasowania — +#: czyli jest funkcją zawartości bazy, nie modeli. +RE_MARKER_MATCH = re.compile(r" match [a-z_]+(?= |$)") + + +def _normalizuj(tekst: str) -> str: + """Zostaw z artefaktu to, co jest funkcją MODELI, wytnij resztę. + + Świadomie pomijane (i dlaczego): + + 1. **Linia ``# BPP ``** — stempel wersji zmienia się przy KAŻDYM + wydaniu (``promote.yml`` podmienia ``version.py``), a schemat nie. + Bez tego wyjątku test świeciłby się na czerwono po każdym release, + zamiast na realnym rozjeździe modeli. + 2. **Sekcja ``dictionaries``** oraz **markery ``match ``** — + to WARTOŚCI wierszy słowników odczytane z bazy, na której puszczono + generator. Baza testowa (baseline) ma inny zestaw niż instalacja + konkretnej uczelni, więc porównywanie ich zamieniłoby test świeżości + modeli w test zawartości bazy (i uzależniło CI od danych). + + Wszystko inne — komplet modeli, komplet pól, typy, nullowalność, choices, + verbose_name/help_text, nagłówek z kontraktem soft-delete — porównujemy + dosłownie. To wystarczy, żeby zniknięcie albo pojawienie się pola + (``deleted_at``!) zapaliło czerwone światło. + """ + linie = [] + for linia in tekst.splitlines(): + if linia.startswith("# BPP "): + continue + if linia.startswith(SEKCJA_SLOWNIKOW): + break + if "->" in linia: + linia = RE_MARKER_MATCH.sub("", linia) + linie.append(linia) + return "\n".join(linie).rstrip("\n") + "\n" + + +@pytest.mark.django_db +@pytest.mark.parametrize(("etykieta_modelu", "nazwa_pliku"), sorted(KORZENIE.items())) +def test_artefakt_djangoql_nadaza_za_modelami(etykieta_modelu, nazwa_pliku, tmp_path): + """Artefakt w repo == artefakt wygenerowany z aktualnych modeli.""" + swiezy = tmp_path / nazwa_pliku + call_command( + "opisz_schemat_djangoql_dla_llm", + "--model", + etykieta_modelu, + "--output", + str(swiezy), + ) + + zacommitowany = DATA_DIR / nazwa_pliku + assert zacommitowany.exists(), f"brak artefaktu {zacommitowany}" + + assert _normalizuj(zacommitowany.read_text(encoding="utf-8")) == _normalizuj( + swiezy.read_text(encoding="utf-8") + ), ( + f"Artefakt {nazwa_pliku} rozjechał się z modelami. Zregeneruj:\n" + f" uv run python src/manage.py opisz_schemat_djangoql_dla_llm " + f"--wszystkie-korzenie\n" + f"Szczegóły — docstring src/bpp/tests/test_djangoql_schema_swiezosc.py" + ) + + +def test_normalizacja_widzi_roznice_pol(): + """Meta-test: normalizacja NIE zjada tego, czego ma pilnować. + + Bez niego „zielony test świeżości" nie niósłby informacji — dokładnie ten + sam powód, dla którego kanarki ORM/katalogowy mają swoje meta-testy. + """ + bazowy = "bpp.wydawnictwo_ciagle_autor:\n afiliuje bool\n" + z_polem = ( + "bpp.wydawnictwo_ciagle_autor:\n afiliuje bool\n deleted_at datetime?\n" + ) + + assert _normalizuj(bazowy) != _normalizuj(z_polem) + + +def test_normalizacja_ignoruje_wersje_slowniki_i_markery(): + """…a jednocześnie milczy na tym, co świadomie pomijamy.""" + wspolne = 'bpp.rekord:\n jezyk -> bpp.jezyk match nazwa "Język"\n' + + a = ( + "# BPP 202601.1\n" + + wspolne + + SEKCJA_SLOWNIKOW + + '\n bpp.jezyk\n nazwa: "pol."\n' + ) + b = ( + "# BPP 202699.9\n" + + wspolne.replace(" match nazwa", "") + + SEKCJA_SLOWNIKOW + + '\n bpp.jezyk\n nazwa: "ang.", "pol."\n' + ) + + assert _normalizuj(a) == _normalizuj(b) diff --git a/src/bpp/tests/test_export/test_export_bibtex_command.py b/src/bpp/tests/test_export/test_export_bibtex_command.py new file mode 100644 index 000000000..afc5df94d --- /dev/null +++ b/src/bpp/tests/test_export/test_export_bibtex_command.py @@ -0,0 +1,65 @@ +""" +Testy dla komendy zarządzającej ``export_bibtex``. + +Wcześniej istniały wyłącznie testy funkcji z ``bpp.export.bibtex`` (patrz +``test_export_bibtex.py`` / ``test_export_bibtex_simple.py``) — sama komenda +(``handle()``) nie miała żadnego pokrycia. Poniższe testy to smoke test +sprawdzający, że polecenie faktycznie działa end-to-end po refaktorze +rozbijającym ``handle()`` na ``_zbierz_publikacje`` / ``_zapisz_wynik`` +(pod C901 z ruff — patrz PR). +""" + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle + + +@pytest.mark.django_db +def test_export_bibtex_do_pliku(tmp_path): + """``--output`` zapisuje wynik do pliku (ścieżka ``_zapisz_wynik``).""" + baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Testowy Artykul", rok=2024) + output = tmp_path / "out.bib" + + call_command("export_bibtex", "--output", str(output)) + + tresc = output.read_text("utf-8") + assert "@article{" in tresc + assert "Testowy Artykul" in tresc + + +@pytest.mark.django_db +def test_export_bibtex_na_stdout(capsys): + """Bez ``--output`` wynik trafia na stdout (druga gałąź ``_zapisz_wynik``).""" + baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Drugi Artykul", rok=2024) + + call_command("export_bibtex") + + out = capsys.readouterr().out + assert "Drugi Artykul" in out + + +@pytest.mark.django_db +def test_export_bibtex_filtr_po_roku(capsys): + """Filtr ``--year`` ogranicza wynik (ścieżka ``_zbierz_publikacje``).""" + baker.make( + Wydawnictwo_Ciagle, tytul_oryginalny="Z Dwudziestego Trzeciego", rok=2023 + ) + baker.make( + Wydawnictwo_Ciagle, tytul_oryginalny="Z Dwudziestego Czwartego", rok=2024 + ) + + call_command("export_bibtex", "--year", "2024") + + out = capsys.readouterr().out + assert "Z Dwudziestego Czwartego" in out + assert "Z Dwudziestego Trzeciego" not in out + + +@pytest.mark.django_db +def test_export_bibtex_brak_wynikow_rzuca_command_error(): + """Brak trafień po filtrach -> ``CommandError`` (koniec ``_zbierz_publikacje``).""" + with pytest.raises(CommandError): + call_command("export_bibtex", "--year", "1900") 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_audyt_kategorii_b.py b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py new file mode 100644 index 000000000..4bae45bb9 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py @@ -0,0 +1,178 @@ +"""Kontrakt akcesorów wobec kosza (faza 03). + +⚠️ KOREKTA 2026-08-08 (decyzja właściciela). Pierwsza wersja tej fazy kazała +akcesorom `rekord_w_bpp` / `get_bpp_publication` / `matchuj_publikacje` +ZAGLĄDAĆ DO KOSZA i zwracać skasowaną publikację. To był błąd i zostało +cofnięte. + +Powód: soft-delete znaczy, że rekordu **nie ma**. `Rekord` (widok +`bpp_rekord_mat`) jest odfiltrowany po `deleted_at` już od fazy 01, i tak ma +zostać. Akcesor, który zwraca rzecz skasowaną, łamie kontrakt „`Rekord` albo +`None`", na którym stoją wszyscy jego konsumenci — m.in. admin PBN, który woła +`.original` (istniejące tylko na `Rekord`) i wywalał się `AttributeError` na +całej changeliście. + +Zaglądanie do kosza jest decyzją **importera**, nie akcesora, i ma siedzieć +w ścieżce importu — tam, gdzie zapada. + +Testy niżej przypinają kontrakt: skasowane = nieznalezione. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle + + +@pytest.mark.django_db +def test_get_bpp_publication_NIE_widzi_soft_deletowanego(): + """Skasowana publikacja jest dla akcesora NIEISTNIEJĄCA. + + To jest kontrakt, nie ograniczenie: `Rekord` to widok odfiltrowany po + `deleted_at`, a `get_bpp_publication` ma zwracać `Rekord` albo `None`. + Zwrócenie modelu konkretnego (co robiła pierwsza wersja fazy 03) wywalało + admina PBN, bo ten woła `.original` — atrybut istniejący wyłącznie na + `Rekord`. + """ + from pbn_api.models import Publication + + publication = baker.make(Publication) + rec = baker.make( + Wydawnictwo_Ciagle, + tytul_oryginalny="Testowy artykuł", + rok=2020, + pbn_uid=publication, + ) + + assert publication.get_bpp_publication() is not None, ( + "setup zepsuty — zywy rekord powinien byc znaleziony" + ) + + rec.delete() # soft-delete + + assert publication.get_bpp_publication() is None, ( + "akcesor zwraca rekord z kosza — lamie kontrakt 'Rekord albo None'" + ) + + +@pytest.mark.django_db +def test_oswiadczenie_instytucji_get_bpp_publication_NIE_widzi_kosza(): + """Ta sama nazwa metody, INNY model — i też zostaje na `objects`. + + ⚠️ Łatwo pomylić z `Publication.get_bpp_publication` wyżej. + `Oswiadczenie_Instytucji.get_bpp_publication` iteruje po czterech modelach + publikacji po `pbn_uid`. Przy pierwszym audycie nie została rozstrzygnięta + w ogóle — ani zmieniona, ani wpisana jako „zostawiona świadomie". + + ROZSTRZYGNIĘCIE (2026-08-09): zostaje na `objects`, bo to **akcesor**, + a zaglądanie do kosza jest decyzją importera. Nie jest to jednak martwy + przepis — łańcuch wołaczy realizuje wariant A w całości: + + `integruj_oswiadczenia...` (statements.py:404) + → get_bpp_publication() zwraca None (rekord w koszu = "nie ma") + → importuj_publikacje_instytucji(...) + → importuj_publikacje_po_pbn_uid_id(...) + → znajdz_lub_wskrzes_rekord() ← TU rekord wraca z kosza + + Wołacz przyjmuje wynik obu kształtów: `Rekord` rozpakowuje przez + `.original`, model konkretny (taki wraca ze wskrzeszenia) przepuszcza. + Gdyby akcesor sam zaglądał do kosza, importer nigdy by się nie odpalił — + i nie powstałby wpis w `RekordPrzywroconyPrzezImport`. + """ + from pbn_api.models import OswiadczenieInstytucji, Publication + + publication = baker.make(Publication) + rec = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + oswiadczenie = baker.make(OswiadczenieInstytucji, publicationId=publication) + + assert oswiadczenie.get_bpp_publication() is not None, ( + "setup zepsuty — zywy rekord powinien byc znaleziony" + ) + + rec.delete() # soft-delete + + assert oswiadczenie.get_bpp_publication() is None, ( + "akcesor oswiadczenia widzi kosz — importer nigdy nie dostanie szansy " + "wskrzesic rekordu i wpisac tego do rejestru" + ) + + +# --------------------------------------------------------------------------- +# REJESTR DECYZJI AUDYTU (Task 7) — miejsca ZOSTAWIONE na `objects` +# --------------------------------------------------------------------------- +# +# Reguła nadrzędna: default „pomijaj skasowane" jest tu ZNACZNIE bezpieczniejszy +# niż odwrotny. Zmieniamy wyłącznie miejsca, które MUSZĄ widzieć kosz, żeby nie +# zostawić sierot albo nie zgubić transferu. Poniższe zostają bez zmian — +# świadomie, a nie przez przeoczenie: +# +# * ewaluacja (`ewaluacja_optymalizacja/**`, `ewaluacja_dwudyscyplinowcy`) — +# praca w koszu nie bierze udziału w punktacji ani optymalizacji; +# * snapshot odpięć (`snapshot_odpiec`) — praca w koszu to brak odpięcia do +# zapisania; +# * komparator PBN (`komparator_pbn/views.py`) — soft-delete wycofuje +# oświadczenia z PBN (faza 05), więc BPP tej pracy już nie deklaruje; +# `objects` daje obraz spójny z PBN; +# * REST API (`api_v1/viewsets/*`) — publiczne API nie może serwować autorstw +# pracy, która sama z API zniknęła; +# * `Oswiadczenie_Instytucji.get_bpp_publication` +# (`pbn_api/models/oswiadczenie_instytucji.py`) — akcesor, nie importer; +# wskrzeszanie robi wołacz o poziom wyżej. Dopisane 2026-08-09, bo przy +# pierwszym audycie NIE zostało rozstrzygnięte w ogóle. Patrz test wyżej; +# * skanowanie do dedupu publikacji (`deduplikator_publikacji/tasks.py`) — +# patrz test niżej. +# +# ⚠️ Wszystkie `.update()` w tych miejscach dotyczą `przypieta`, +# `dyscyplina_naukowa` i `afiliuje` — NIE `deleted_at`, więc gate +# `BppSoftDeleteQuerySet.update()` z fazy 01 się nie odpala. Sprawdzone. + + +@pytest.mark.django_db +def test_dedup_publikacji_NIE_widzi_kosza(): + """Skanowanie do deduplikacji ma pomijać kosz — i to jest poprawne. + + Rekord skasowany nie jest duplikatem do rozstrzygnięcia; podsuwanie go + operatorowi kazałoby mu scalać rzecz, którą sam usunął. To odwrotna + decyzja niż przy matchingu importu (który MUSI widzieć kosz, żeby nie + tworzyć duplikatów) — i właśnie dlatego jest tu przypięta testem, a nie + zostawiona jako „oczywista". + """ + from deduplikator_publikacji.tasks import _get_publications_to_scan + + zywa = baker.make(Wydawnictwo_Ciagle, rok=2020) + kosz = baker.make(Wydawnictwo_Ciagle, rok=2020) + kosz.delete() + + zebrane = {pub.pk for _ct, pub in _get_publications_to_scan(2020, 2020)} + + assert zywa.pk in zebrane + assert kosz.pk not in zebrane, "dedup podsuwa operatorowi rekord z kosza" + + +@pytest.mark.django_db +def test_transfer_przy_scalaniu_autorow_widzi_kosz(): + """Scalanie autorów MUSI przenieść też autorstwa w koszu. + + Inaczej zostają SIEROTY: wiersze wskazujące na autora-duplikat, który po + scaleniu ma zniknąć. W fazie 04 zablokują dodatkowo guard PROTECT — czyli + problem ujawniłby się dopiero tam, w miejscu niezwiązanym z przyczyną. + """ + from bpp.models import Autor, Wydawnictwo_Ciagle_Autor + from deduplikator_autorow.utils.merge import wiersze_do_transferu + + duplikat = baker.make(Autor) + wc = baker.make(Wydawnictwo_Ciagle) + zywe = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, autor=duplikat, kolejnosc=0) + skasowane = baker.make( + Wydawnictwo_Ciagle_Autor, rekord=wc, autor=duplikat, kolejnosc=1 + ) + skasowane.delete() + + do_transferu = { + x.pk for x in wiersze_do_transferu(Wydawnictwo_Ciagle_Autor, duplikat) + } + + assert zywe.pk in do_transferu + assert skasowane.pk in do_transferu, ( + "autorstwo w koszu nie zostanie przeniesione -> sierota przy duplikacie" + ) 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" + ) 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..157318b1d --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py @@ -0,0 +1,134 @@ +"""*_Autor jako SoftDeleteModel: pola, managery, soft-delete/restore per +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 +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() + + +@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 zglos_publikacje.models import Zgloszenie_Publikacji_Autor + + assert not issubclass(Zgloszenie_Publikacji_Autor, SoftDeleteModel) + assert not hasattr(Zgloszenie_Publikacji_Autor, "global_objects") 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", + ) 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 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..650a739c7 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_denorm_gate.py @@ -0,0 +1,111 @@ +"""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 +from model_bakery import baker + + +@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)" + ) + + +# --- Faza 02: publikacje -------------------------------------------------- +# +# INWENTARYZACJA (2026-08-07). W całym kodzie produkcyjnym są DOKŁADNIE DWIE +# zależności ``@depend_on_related`` celujące w model publikacji — obie to +# ``("self", "wydawnictwo_nadrzedne")`` na ``Wydawnictwo_Zwarte`` +# (``wydawnictwo_zwarte.py:384`` i ``:434``), obie BEZ ``only=``. +# +# Brak ``only=`` oznacza, że denorm buduje bramkę WHEN ze WSZYSTKICH kolumn, +# więc ``deleted_at`` wchodzi do niej automatycznie — bez żadnej zmiany +# w kodzie. Potwierdzone niezależnie i przypadkiem: testy odwracalności +# migracji padały na ``CREATE TRIGGER ... WHEN (OLD."deleted_at" IS DISTINCT +# FROM ...)`` dla ``bpp_patent``, czyli denorm tę kolumnę widzi. +# +# Dlatego faza 02 NIE dokłada tu ``denorm_always_only`` ani list ``only=`` — +# byłby to martwy kod. Zostaje jedna rzecz warta przypięcia testem: +# semantyka poniżej. + + +@pytest.mark.django_db +def test_soft_delete_ksiazki_matki_nie_psuje_cache_rozdzialu(denorms): + """Soft-delete książki-matki NIE może uszkodzić rozdziału ani jego cache'u. + + Kaskada fazy 02 jest WĄSKA — zatrzymuje się na ``*_Autor`` rodzica i nie + rusza rozdziałów. Rozdział zostaje żywy, a jego opis nadal odwołuje się + do tytułu matki, bo soft-delete NIE zmienia tytułu: usunięty rekord dalej + istnieje i dalej ma swoje dane. + + To jest odpowiedź na pytanie postawione w planie fazy 02 („czy soft-delete + książki-matki ma unieważniać denorm-cache rozdziałów"): NIE ma czego + unieważniać. Gdyby cache miał się tu zmieniać, znaczyłoby to, że opis + rozdziału zależy od tego, czy matka jest w koszu — a nie zależy i nie + powinien. + + Faza 04 doda guard PROTECT, który prawdopodobnie w ogóle zablokuje + skasowanie książki mającej rozdziały. Do tego czasu ta ścieżka jest + osiągalna i ten test opisuje, co się na niej dzieje. + """ + from bpp.models import Wydawnictwo_Zwarte + + matka = baker.make(Wydawnictwo_Zwarte, tytul_oryginalny="Ksiazka Matka Zzz") + rozdzial = baker.make( + Wydawnictwo_Zwarte, + tytul_oryginalny="Rozdzial Podrzedny Yyy", + wydawnictwo_nadrzedne=matka, + ) + denorms.flush() + + rozdzial.refresh_from_db() + slug_przed = rozdzial.slug + # ``slugify_function`` BPP NIE obniża wielkości liter — porównujemy bez + # rozróżniania, żeby test nie zależał od tego szczegółu. + assert "matka" in slug_przed.lower(), ( + f"setup zepsuty — slug rozdzialu nie zawiera tytulu matki: {slug_przed!r}" + ) + + matka.delete() # soft + denorms.flush() + + assert Wydawnictwo_Zwarte.objects.filter(pk=rozdzial.pk).exists(), ( + "rozdzial znikl razem z matka — kaskada NIE jest waska" + ) + + rozdzial.refresh_from_db() + assert rozdzial.deleted_at is None, "rozdzial zostal soft-skasowany kaskadowo" + assert rozdzial.slug == slug_przed, ( + f"denorm-cache rozdzialu zmienil sie po skasowaniu matki: " + f"{slug_przed!r} -> {rozdzial.slug!r}" + ) diff --git a/src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py b/src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py new file mode 100644 index 000000000..5c800522b --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py @@ -0,0 +1,228 @@ +"""DjangoQL domyślnie NIE pokazuje kosza (soft-delete), ale da się go +jawnie poprosić o pokazanie. + +Kontrakt (zmiana decyzji z fazy „wyciek ORM", commit ``e80d1656e``, gdzie +DjangoQL świadomie zostawiono surowy): + +* zapytanie, które przechodzi przez relację do modelu soft-delete i NIE + wspomina ``deleted_at`` na tej relacji, dostaje domyślny predykat + ``.deleted_at = None`` **na tym samym JOIN-ie**; +* zapytanie, które wspomina ``deleted_at`` na danej relacji, NIE dostaje nic + — użytkownik świadomie steruje koszem (``!= None`` = tylko kosz). + +Testy semantyczne (nie substringowe) — patrz ``bpp/djangoql_soft_delete.py``. +""" + +import pytest +from djangoql.queryset import apply_search + +from bpp.djangoql_schema import BppQLSchema, BppQLSchemaOgraniczony, RekordLLMSchema +from bpp.models import Autor, Wydawnictwo_Ciagle + + +def _pk_set(qs): + return set(qs.values_list("pk", flat=True)) + + +def _szukaj_wc(zapytanie, schema=BppQLSchema): + return _pk_set( + apply_search(Wydawnictwo_Ciagle.objects.all(), zapytanie, schema=schema) + ) + + +@pytest.mark.django_db +def test_djangoql_domyslnie_odsiewa_skasowane_autorstwo(wydawnictwo_ciagle_z_autorem): + """Zapytanie BEZ wzmianki o ``deleted_at`` nie widzi skasowanego + autorstwa — wcześniej JOIN po ``autorzy_set`` pokazywał husk.""" + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + zapytanie = f"autorzy_set.autor.id = {autor_id}" + + assert wc.pk in _szukaj_wc(zapytanie) + + wc.autorzy_set.first().delete() + + assert wc.pk not in _szukaj_wc(zapytanie) + + +@pytest.mark.django_db +def test_djangoql_jawny_warunek_pokazuje_kosz(wydawnictwo_ciagle_z_dwoma_autorami): + """Jawne ``deleted_at != None`` na tej samej relacji ZDEJMUJE domyślny + predykat — narzędzie audytowe dalej działa, tylko trzeba o kosz poprosić. + + Publikacja ma DWA autorstwa (jedno zostanie skasowane, drugie żyje) — + wyrocznia na to, że ``!= None`` jest skorelowane z tym samym JOIN-em. + Gdyby zostało zanegowanym ``~Q(deleted_at = None)``, Django zrobiłby + z tego „publikacja nie ma ŻADNEGO żywego autorstwa" i żywy współautor + zabrałby wynik. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + autorstwo = wc.autorzy_set.order_by("kolejnosc").first() + autor_id = autorstwo.autor_id + zapytanie = f"autorzy_set.autor.id = {autor_id} and autorzy_set.deleted_at != None" + + assert wc.pk not in _szukaj_wc(zapytanie) + + autorstwo.delete() + + assert wc.pk in _szukaj_wc(zapytanie) + + +@pytest.mark.django_db +def test_djangoql_jawne_deleted_at_none_nadal_dziala(wydawnictwo_ciagle_z_autorem): + """Jawne ``deleted_at = None`` (zapytanie deduplikatora) daje ten sam + wynik co domyślne odsiewanie — dublowanie predykatu nic nie psuje.""" + 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" + + assert wc.pk in _szukaj_wc(zapytanie) + + wc.autorzy_set.first().delete() + + assert wc.pk not in _szukaj_wc(zapytanie) + + +@pytest.mark.django_db +def test_djangoql_predykat_jest_skorelowany_z_tym_samym_joinem( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """Predykat MUSI wisieć na tym samym JOIN-ie co warunek użytkownika. + + Wyrocznia: publikacja z DWOMA autorstwami, z których jedno skasowano. + Nieskorelowany predykat (osobny ``.filter()``) dałby „istnieje jakieś + żywe autorstwo" i przepuściłby zapytanie o autora skasowanego. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + pierwsze = wc.autorzy_set.order_by("kolejnosc").first() + drugie = wc.autorzy_set.order_by("kolejnosc").last() + assert pierwsze.pk != drugie.pk + + skasowany_autor_id = pierwsze.autor_id + zywy_autor_id = drugie.autor_id + pierwsze.delete() + + # Żywe autorstwo (drugi autor) NIE może „uratować" zapytania o autora, + # którego autorstwo skasowano. + assert wc.pk not in _szukaj_wc(f"autorzy_set.autor.id = {skasowany_autor_id}") + assert wc.pk in _szukaj_wc(f"autorzy_set.autor.id = {zywy_autor_id}") + + +@pytest.mark.django_db +def test_djangoql_negacja_na_relacji_jest_zachowawcza( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """ŚLEPA PLAMA (przypięta świadomie): operator negujący na ścieżce + relacyjnej NIE dostaje predykatu — patrz „Znane ślepe plamy" w + ``bpp/djangoql_soft_delete.py``. + + Django dekoreluje negację na relacji wielowartościowej, więc predykat + wciągnięty pod ``~`` zmieniałby znaczenie zapytania zamiast zawężać + wiersz. Skutek: skasowane autorstwo nadal WYKLUCZA publikację z wyniku + ``!=``. To tryb ZACHOWAWCZY — kosz się nie ujawnia, wynik bywa węższy. + + Test istnieje po to, żeby ograniczenie było widoczne i żeby jego + przyszła naprawa (``exclude(rel__in=Subquery)``) zapaliła czerwone + światło tutaj, a nie u użytkownika. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + pierwsze = wc.autorzy_set.order_by("kolejnosc").first() + skasowany_autor_id = pierwsze.autor_id + + assert wc.pk not in _szukaj_wc(f"autorzy_set.autor.id != {skasowany_autor_id}") + + pierwsze.delete() + + assert wc.pk not in _szukaj_wc(f"autorzy_set.autor.id != {skasowany_autor_id}") + + +@pytest.mark.django_db +def test_djangoql_agregat_count_nie_liczy_kosza(wydawnictwo_ciagle_z_dwoma_autorami): + """``autorzy_set__count`` filtruje skorelowanym PODZAPYTANIEM, nie + JOIN-em — źródłem jest ``related_model._base_manager`` (goły ``Manager``, + NIE nasz ``BppSoftDeleteManager``). Bez podmiany źródła agregat + zawyżałby licznik o kosz.""" + wc = wydawnictwo_ciagle_z_dwoma_autorami + + assert wc.pk in _szukaj_wc("autorzy_set__count = 2") + + wc.autorzy_set.order_by("kolejnosc").first().delete() + + assert wc.pk not in _szukaj_wc("autorzy_set__count = 2") + assert wc.pk in _szukaj_wc("autorzy_set__count = 1") + + # …a jawna wzmianka o koszu na tej samej relacji zdejmuje odsiewanie + # również z agregatu (licznik znów obejmuje skasowane autorstwo). + assert wc.pk in _szukaj_wc( + "autorzy_set__count = 2 and autorzy_set.deleted_at != None" + ) + + +@pytest.mark.django_db +def test_schemat_ograniczony_i_llm_maja_ten_sam_kontrakt( + wydawnictwo_ciagle_z_autorem, +): + """Wszystkie trzy schematy BPP (admin, web-edytor, API/LLM) odsiewają + kosz tak samo — inaczej ten sam string zapytania dawałby różne wyniki + w zależności od punktu wejścia.""" + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + wc.autorzy_set.first().delete() + + for schema in (BppQLSchema, BppQLSchemaOgraniczony, RekordLLMSchema): + assert wc.pk not in _szukaj_wc( + f"autorzy_set.autor.id = {autor_id}", schema=schema + ), schema.__name__ + + +@pytest.mark.django_db +def test_dlugowieczna_instancja_schematu_nie_pamieta_prosby_o_kosz( + wydawnictwo_ciagle_z_autorem, +): + """Instancja schematu bywa SINGLETONEM (``multiseek_registry. + djangoql_export`` trzyma ``BppQLSchemaOgraniczony(Rekord)`` jako bramkę + walidacyjną). Prośba o kosz z jednego zapytania nie może przeciekać na + następne — inaczej pierwsze zapytanie po starcie procesu decydowałoby + o widoczności kosza dla wszystkich kolejnych.""" + from djangoql.parser import DjangoQLParser + from djangoql.queryset import build_filter + + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + wc.autorzy_set.first().delete() + + schemat = BppQLSchema(Wydawnictwo_Ciagle) + + def _uruchom(zapytanie): + ast = DjangoQLParser().parse(zapytanie) + schemat.validate(ast) + return _pk_set(Wydawnictwo_Ciagle.objects.filter(build_filter(ast, schemat))) + + z_koszem = f"autorzy_set.autor.id = {autor_id} and autorzy_set.deleted_at != None" + assert wc.pk in _uruchom(z_koszem) + + # …a NASTĘPNE zapytanie na tej samej instancji znów odsiewa kosz. + assert wc.pk not in _uruchom(f"autorzy_set.autor.id = {autor_id}") + + +@pytest.mark.django_db +def test_api_zapytanie_autor_nie_widzi_skasowanego_autorstwa( + wydawnictwo_ciagle_z_autorem, +): + """Ścieżka ``/api/v1/zapytanie/autor/`` (``RekordLLMSchema`` na korzeniu + ``Autor``) — JOIN po ``wydawnictwo_ciagle_autor``.""" + wc = wydawnictwo_ciagle_z_autorem + autorstwo = wc.autorzy_set.first() + zapytanie = f"wydawnictwo_ciagle_autor.rekord.id = {wc.pk}" + + def _autorzy(q): + return _pk_set(apply_search(Autor.objects.all(), q, schema=RekordLLMSchema)) + + assert autorstwo.autor_id in _autorzy(zapytanie) + + autorstwo.delete() + + assert autorstwo.autor_id not in _autorzy(zapytanie) + assert autorstwo.autor_id in _autorzy( + zapytanie + " and wydawnictwo_ciagle_autor.deleted_at != None" + ) diff --git a/src/bpp/tests/test_soft_delete/test_easyaudit_shim.py b/src/bpp/tests/test_soft_delete/test_easyaudit_shim.py new file mode 100644 index 000000000..11c58d578 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_easyaudit_shim.py @@ -0,0 +1,168 @@ +"""Shim naprawiający ``django-easy-audit`` na modelach soft-delete. + +Pełne uzasadnienie w ``src/bpp/easyaudit_shim.py``. Tu pilnujemy trzech +rzeczy: że shim jest wpięty, że naprawia realny objaw i że zniknie, gdy +przestanie być potrzebny. +""" + +import inspect +import weakref + +import pytest +from django.apps import apps +from django.db.models import signals +from model_bakery import baker + +from bpp.easyaudit_shim import DISPATCH_UID, WERSJA_UPSTREAM +from bpp.easyaudit_shim import pre_save as nasz_pre_save +from bpp.models import Wydawnictwo_Ciagle + +pytestmark = pytest.mark.skipif( + not apps.is_installed("easyaudit"), + reason="easyaudit wchodzi tylko w local.py/production.py", +) + + +def _odbiorcy_pod_uid(): + """Funkcje podpięte pod ``pre_save`` naszym ``dispatch_uid``. + + ``Signal.receivers`` (Django 5.2) trzyma krotki + ``((dispatch_uid, id_nadawcy), odbiorca, is_async)``, a ``odbiorca`` bywa + słabą referencją (``connect(weak=True)`` jest domyślne) — stąd + rozpakowanie i ewentualne rozwinięcie ``weakref``. + """ + znalezione = [] + for klucz, odbiorca, *_ in signals.pre_save.receivers: + if klucz[0] != DISPATCH_UID: + continue + if isinstance(odbiorca, weakref.ReferenceType): + odbiorca = odbiorca() + znalezione.append(odbiorca) + return znalezione + + +def test_shim_jest_wpiety_zamiast_handlera_upstreamu(): + """Nasz handler zajmuje ``dispatch_uid`` easyauditu, nie stoi obok niego. + + Gdyby stał obok, oba by się wykonywały i audyt dublowałby wpisy. + """ + odbiorcy = _odbiorcy_pod_uid() + assert odbiorcy, "nikt nie jest podpiety pod dispatch_uid easyauditu" + assert len(odbiorcy) == 1, ( + f"handler podpiety {len(odbiorcy)} razy — zainstaluj() nie jest " + f"idempotentne albo obok naszego stoi handler upstreamu" + ) + assert odbiorcy[0] is nasz_pre_save, ( + f"pod {DISPATCH_UID} siedzi {odbiorcy[0]!r}, a nie nasz shim — " + "sprawdz kolejnosc ready() aplikacji" + ) + + +@pytest.mark.django_db +def test_restore_publikacji_nie_wywala_sie_na_audycie(): + """OBJAW, dla którego shim powstał. + + Bez niego ``restore()`` leci ``DoesNotExist``: easyaudit szuka + poprzedniej wersji wiersza przez ``objects``, a ten w trakcie + przywracania jest jeszcze odfiltrowany jako skasowany. + """ + wc = baker.make(Wydawnictwo_Ciagle) + pk = wc.pk + wc.delete() + + wc.restore() + + assert Wydawnictwo_Ciagle.objects.filter(pk=pk).exists(), ( + "po restore publikacja nie wrocila do objects" + ) + + +@pytest.mark.django_db +def test_restore_zgloszenia_publikacji_czyli_blad_ZASTANY(): + """``Zgloszenie_Publikacji`` jest ``SoftDeleteModel`` OD DAWNA i również + figuruje w ``DJANGO_EASY_AUDIT_REGISTERED_CLASSES``. + + Ten test dowodzi, że naprawiamy błąd ZASTANY, a nie wyłącznie skutek + uboczny fazy 02: na ``dev`` (bez shimu) przywrócenie skasowanego + zgłoszenia jest niemożliwe. + + ``strict=False`` jest tu potrzebne z INNEGO powodu — pakietowy + ``restore()`` domyślnie wymaga, żeby każdy model powiązany też był + ``SoftDeleteModel`` (``Zgloszenie_Publikacji_Autor`` nie jest). To ten + sam inwariant, który dla naszych modeli domyka ``soft_delete.py``. + """ + from zglos_publikacje.models import Zgloszenie_Publikacji + + z = baker.make(Zgloszenie_Publikacji) + pk = z.pk + z.delete() + + Zgloszenie_Publikacji.global_objects.get(pk=pk).restore(strict=False) + + assert Zgloszenie_Publikacji.objects.filter(pk=pk).exists() + + +@pytest.mark.django_db +def test_audyt_dalej_dziala_czyli_shim_niczego_nie_wycisza(admin_user, rf, settings): + """Shim ma naprawiać lookup, a nie wyłączać audyt. + + Bez tego testu „zielono" znaczyłoby tylko tyle, że nic nie wybucha — + a najprostszym sposobem, żeby nic nie wybuchało, byłoby przestać + audytować. + + Zanim ten test cokolwiek mierzył, trzeba było zdjąć DWIE bramki, z + których żadna nie ma związku z shimem — obie po kolei dawały „zero + zdarzeń", czyli objaw nieodróżnialny od zepsutego shimu: + + 1. **Request z użytkownikiem.** BPP ma + ``DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS = + ["bpp.util.dont_log_anonymous_crud_events"]``, który świadomie + odrzuca zdarzenia bez zalogowanego użytkownika. + 2. **``settings.TEST``.** Bez niego easyaudit odkłada zapis zdarzenia + na ``transaction.on_commit``, a w teście transakcja jest + rollbackowana — callback nigdy nie odpala. To własny przełącznik + pakietu, przewidziany dokładnie na tę sytuację. + """ + from easyaudit.middleware.easyaudit import _thread_locals + from easyaudit.models import CRUDEvent + + settings.TEST = True + request = rf.get("/") + request.user = admin_user + _thread_locals.request = request + try: + wc = baker.make(Wydawnictwo_Ciagle) + przed = CRUDEvent.objects.count() + + wc.tytul_oryginalny = "Zmieniony tytul dla audytu" + wc.save() + + assert CRUDEvent.objects.count() > przed, ( + "zapis nie wygenerowal zdarzenia audytu — shim wycisza easyaudit" + ) + finally: + del _thread_locals.request + + +def test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny(): + """STRAŻNIK ODWROTNY: pada, gdy shim przestanie być potrzebny. + + Sprawdza, czy ``easyaudit`` NADAL pobiera poprzedni wiersz przez + ``objects``. Gdy upstream scali poprawkę (issue #175, otwarte od 2021), + ten test spadnie na czerwono — i to jest sygnał, żeby skasować + ``bpp/easyaudit_shim.py`` razem z wywołaniem w ``BppConfig.ready()``, + a nie żeby test „naprawić". + + Bez tego strażnika shim zostałby w kodzie na zawsze, cicho duplikując + logikę, którą pakiet już by miał poprawną. + """ + from easyaudit.signals import model_signals + + zrodlo = inspect.getsource(model_signals.pre_save) + + assert "sender.objects.get(pk=instance.pk)" in zrodlo, ( + f"easyaudit (kopiowano z {WERSJA_UPSTREAM}) NIE uzywa juz " + f"`sender.objects` — poprawka prawdopodobnie weszla upstream. " + f"SKASUJ bpp/easyaudit_shim.py i wywolanie zainstaluj() " + f"w BppConfig.ready(), zamiast poprawiac ten test." + ) 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..743eb0fa5 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py @@ -0,0 +1,590 @@ +"""Kanarek katalogowy: KAŻDY widok czytający tabelę objętą soft-delete +MUSI zależeć od jej kolumny ``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_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 + +- 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``). + +## 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 importlib +import re + +import pytest +from django.db import connection + +from bpp.migration_util import viewdef + +# 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 (2026-08-07): pięć tabel publikacji. +# +# Rozszerzenie o fazę 02 zrobiono NA STARCIE tej fazy, jeszcze przed +# napisaniem jakiegokolwiek DDL — i to jest cała wartość tego kanarka. +# Zamiast odkrywać konsumentów pojedynczo, przez awarie (tak przebiegła +# faza 01), dostaliśmy pełną listę 15 widoków jednym przebiegiem trwającym +# 19 sekund. Rdzeń (pg_depend) jest generyczny względem tej listy, więc było +# to dopisanie pięciu linijek i nic więcej. +# +# Inwentaryzacja i to, co z niej wynikło: +# docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md +# +# Przewidywanie fazy 01 się sprawdziło: rozszerzenie listy złapało też +# ``bpp_kronika_praca_{doktorska,habilitacyjna}_view``, których żywotności +# nikt wcześniej nie badał. Nie zostały wyciszone wyjątkiem — okazały się +# martwe i cała siedmioelementowa rodzina ``bpp_kronika_*`` została +# skasowana migracją ``0499``. +TABELE_SOFT_DELETE = [ + "bpp_wydawnictwo_ciagle_autor", + "bpp_wydawnictwo_zwarte_autor", + "bpp_patent_autor", + "bpp_wydawnictwo_ciagle", + "bpp_wydawnictwo_zwarte", + "bpp_patent", + "bpp_praca_doktorska", + "bpp_praca_habilitacyjna", +] + +# 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. +# PUSTE — i to jest stan docelowy, nie przeoczenie. +# +# Faza 01 trzymała tu trzy widoki ``bpp_kronika_*``, zweryfikowane jako +# martwe, ale niemożliwe do skasowania w tamtej fazie: zależały od nich dwa +# widoki nadrzędne, więc goły ``DROP VIEW`` nie przechodził, a ``CASCADE`` +# zabrałby po cichu także je. Faza 02 rozcięła cały graf jedną migracją +# (``0499_drop_kronika_views``) — po siódemce nie ma śladu, więc wyjątki +# straciły przedmiot. +# +# Jeśli kanarek zacznie zgłaszać NOWY widok, to jest FAIL do zbadania, a nie +# sygnał, żeby dopisać go tutaj. Wyjątek wpisuje się dopiero po weryfikacji +# martwoty (``pg_depend`` + grep po kodzie/szablonach/``Meta.db_table``) +# i zawsze z uzasadnieniem oraz datą. +WYJATKI = {} + + +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). + + 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. + + 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 _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 (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" + " (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 (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 + zależeć (wg pg_depend) od jej kolumny ``deleted_at``, chyba że jest + jawnie wyjątkiem.""" + with connection.cursor() as cur: + winowajcy = znajdz_winowajcow(cur) + 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_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: + # 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ć). + 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 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( + "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: "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). 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), + ], +) +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 + + +# --- Test dowodowy: fałszywa zieleń starego rdzenia --------------------- + +# Funkcje transformujące definicję widoku, wzięte WPROST z migracji 0497 — +# zamiast kopii, która rozjechałaby się przy pierwszej zmianie kształtu +# widoku. Nazwy modułów migracji zaczynają się od cyfry, więc zwykły import +# nie przejdzie. +_p0497 = importlib.import_module("bpp.migrations.0497_soft_delete_rekord_views") + + +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_stary_rdzen_przepuszczal_widok_dziedziczacy_deleted_at_z_joina(): + """DOWÓD, że przejście na ``pg_depend`` zamknęło REALNĄ dziurę. + + Poprzednia wersja tego testu (faza 01) była symulacją: brała trzy + widoki, które NAPRAWDĘ czytały wtedy ``bpp_wydawnictwo_ciagle`` bez + filtra, i pokazywała, że stary (tekstowy) rdzeń ich nie widzi. Faza 02 + te widoki naprawiła, więc dowód stracił materiał — nie dlatego, że + przestał być prawdziwy, tylko dlatego, że opierał się na usterce, której + już nie ma. + + Wersja obecna wytwarza tę usterkę SAMA, w transakcji testowej, więc + zostaje prawdziwa niezależnie od stanu bazy. Zdejmujemy z + ``bpp_wydawnictwo_ciagle_view`` filtr po WŁASNYM ``deleted_at`` + (dokładnie tę wstawkę, którą zakłada migracja ``0497``) i pytamy oba + warianty algorytmu. + + Sedno fałszywej zieleni: po zdjęciu filtra w definicji NADAL jest słowo + ``deleted_at`` — bo widok liczy ``count(...) FILTER (WHERE + bpp_wydawnictwo_ciagle_autor.deleted_at IS NULL)`` (migracja ``0494``). + Matcher tekstowy widzi więc „jest deleted_at, jest OK", mimo że filtr + dotyczy INNEJ tabeli — tej po drugiej stronie JOIN-a. Zależność + kolumnowa w ``pg_depend`` takiej pomyłki nie popełnia. + """ + widok = "bpp_wydawnictwo_ciagle_view" + tabela = "bpp_wydawnictwo_ciagle" + para = (widok, tabela) + + with connection.cursor() as cur: + oryginal = viewdef(cur, widok) + zepsuta = _p0497._bez_filtra(oryginal, tabela, widok) + + assert "deleted_at" in zepsuta, ( + "po zdjęciu filtra w definicji NIE MA już słowa 'deleted_at' — " + "cały dowód opiera się na tym, że ono zostaje (przez FILTER po " + "tabeli *_autor). Widok się zmienił, zaktualizuj dowód." + ) + + try: + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {zepsuta}") + + widoki = _pobierz_widoki_publiczne(cur) + stary = set( + _winowajcy_starym_algorytmem(widoki, TABELE_SOFT_DELETE, WYJATKI) + ) + nowy = set(znajdz_winowajcow(cur, tabele=TABELE_SOFT_DELETE)) + finally: + # Przywracamy NIEZALEŻNIE od wyniku asercji (rollback transakcji + # testowej i tak by to cofnął, ale test ma dowodzić całego cyklu). + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {oryginal}") + + # Dowód #1 — ISTNIENIE DZIURY: stary rdzeń przepuszcza zepsuty widok. + assert para not in stary, ( + "Stary (tekstowy) rdzeń ZŁAPAŁ widok bez filtra, choć miał go " + "przepuścić — czyli fałszywa zieleń, którą ten test dokumentuje, " + "już nie zachodzi. Sprawdź, czy widok nadal zawiera 'deleted_at' " + "wyłącznie przez FILTER po tabeli *_autor." + ) + + # Dowód #2 — NAPRAWA DZIAŁA: rdzeń oparty o pg_depend go łapie. + assert para in nowy, ( + "Rdzeń oparty o pg_depend NIE złapał widoku, który czyta " + f"{tabela} bez zależności od jej deleted_at. Naprawa się cofnęła." + ) + + +@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). 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: + 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 + # _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 — " + "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}") + + winowajcy = znajdz_winowajcow(cur) + 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}") + + 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 " + "oryginalnej definicji" + ) diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_orm.py b/src/bpp/tests/test_soft_delete/test_kanarek_orm.py new file mode 100644 index 000000000..ecacf01ab --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_kanarek_orm.py @@ -0,0 +1,397 @@ +"""Kanarek ORM-owy: nowy JOIN po soft-delete'owanym ``*_Autor`` bez predykatu. + +Odpowiednik ``test_kanarek_katalogowy`` (tamten pilnuje ``pg_views``) o warstwę +wyżej. Przeszukuje KOD PRODUKCYJNY w poszukiwaniu wywołań ORM, które sięgają +przez relację do ``Wydawnictwo_Ciagle_Autor`` / ``Wydawnictwo_Zwarte_Autor`` / +``Patent_Autor``, i wymaga, żeby w tej samej instrukcji padło ``deleted_at``. + +Dlaczego to jest potrzebne mimo managerów soft-delete: JOIN przez relację +(``filter(autorzy_set__…)``, ``Count("autorzy_set")``, +``Count("wydawnictwo_ciagle_autor")``) NIE pyta managera — Django złącza +surową tabelę. Ta klasa błędu wróciła w fazie 01 już trzy razy (widoki +``bpp_*_autorzy``, widoki pochodne, ``rozbieznosci_dyscyplin_zrodel``), za +każdym razem odkryta przez awarię, nie przez test. + +Jak to działa +============= + +Parsujemy każdy produkcyjny ``.py`` do AST i szukamy wywołań, które faktycznie +budują zapytanie (``WYWOLANIA_ORM``: ``filter``/``exclude``/``annotate``/ +``Q``/``Count``/…). Dopiero w NICH sprawdzamy nazwy argumentów kluczowych i +literały napisowe pod kątem ścieżek relacji (``PODEJRZANE_SCIEZKI``). Werdykt +zapada na poziomie **instrukcji** (``ast.stmt``), nie linii — bo +``.filter(\\n a=…,\\n autorzy_set__deleted_at__isnull=True,\\n)`` rozkłada +predykat na inną linię niż lookup. + +Świadomie WĄSKO, żeby kanarek nie krzyczał na: + +* ``prefetch_related("autorzy_set__autor")`` i ``select_related`` — idą przez + ``_default_manager``, są bezpieczne (przypina to + ``test_prefetch_related_autorzy_set_odcina_skasowane``); +* ``router.register(r"patent_autor", …)`` i mapy nazw endpointów — to nazwy + URL-i, nie relacje; +* docstringi cytujące ścieżki (``kompletnosc_polon/reguly.py``). + +Znane ślepe plamy (świadome, dlatego OBOK stoją testy semantyczne w +``test_orm_wyciek_join.py``): + +* **M2M ``autorzy__…``** — ``Q(autorzy__nazwisko=…)`` też złącza surową + tabelę ``*_autor``, ale ta sama nazwa relacji na ``Rekord`` idzie po + bezpiecznym ``bpp_autorzy_mat``. Rozróżnienie wymaga znajomości modelu po + LEWEJ stronie zapytania, a ten bywa w innej instrukcji (tak jest w + ``export_bibtex``). Statyczny kanarek tego nie rozstrzygnie; +* **odwrotne M2M od ``Autor``** — ``Count("wydawnictwo_ciagle")`` na + ``Autor`` przechodzi przez through-model, ale ta sama nazwa na ``Zrodlo`` + czy ``Charakter_Formalny`` to zwykły FK. Znowu: zależy od lewego modelu; +* ścieżka sklejona w innej instrukcji niż wywołanie, ``**kwargs`` przekazane + przez kilka warstw; +* surowe parametry GET changelistu admina (``?autorzy_set__…=``) — whitelist + w ``lookup_allowed`` przepuszcza je bez predykatu; wszystkie linki + GENEROWANE przez BPP predykat niosą (patrz ``orm-leak-report.md``, poz. C). + +**DjangoQL nie jest już ślepą plamą.** Wcześniejsze uzasadnienie w tym +miejscu („język zapytań użytkownika = narzędzie audytowe, świadomie surowe, +ma widzieć kosz") jest NIEAKTUALNE — decyzją właściciela projektu z +2026-08-07 język zapytań domyślnie odsiewa skasowane, a kosz pokazuje +dopiero na jawne żądanie. Realizuje to ``bpp/djangoql_soft_delete.py`` +(kontrakt w docstringu modułu), pilnują testy +``test_djangoql_bez_skasowanych.py``. Ten kanarek i tak nie miałby czego +skanować — zapytania DjangoQL powstają w runtime, nie w kodzie. + +Gdy test padnie +=============== + +Nie dopisuj wpisu do ``DOZWOLONE`` odruchowo. Najpierw rozstrzygnij: + +* czy zapytanie MA widzieć skasowane (audyt, rebuild denorm)? → dopisz + do ``DOZWOLONE`` z powodem merytorycznym; +* w każdym innym przypadku dołóż ``…__deleted_at__isnull=True`` na TYM SAMYM + joinie (nie osobnym ``filter()`` — Django zrobi drugi, nieskorelowany JOIN) + i dopisz test regresji do ``test_orm_wyciek_join.py``. +""" + +import ast +import pathlib + +import pytest + +#: Katalog ``src/`` — korzeń skanowania. +KORZEN = pathlib.Path(__file__).resolve().parents[3] + +#: Nazwy relacji do soft-delete'owanych modeli. +#: +#: Faza 01 — trzy through-modele autorstwa. ``autorzy_set`` to strona +#: publikacji; pozostałe to nazwy odwrotne od strony ``Autor``, +#: ``Jednostka`` i słowników (brak ``related_name`` → nazwa modelu małymi). +#: +#: Faza 02 — pięć modeli publikacji. Tu ryzyko jest odwrotnie skierowane: +#: zapytania startujące OD publikacji są bezpieczne, bo ``objects`` to +#: ``BppSoftDeleteManager`` i sam filtruje ``deleted_at``. Przecieka +#: dołączenie DO publikacji od strony słownika/relacji (``Zrodlo``, +#: ``Wydawca``, ``Charakter_Formalny``, ``Autor``…), gdzie żaden manager +#: się nie włącza — i to łapią nazwy niżej. +RELACJE = ( + "autorzy_set", + "wydawnictwo_ciagle_autor", + "wydawnictwo_zwarte_autor", + "patent_autor", +) + +#: Relacje do pięciu modeli PUBLIKACJI (faza 02). Trzymane OSOBNO od +#: ``RELACJE``, bo pilnuje ich osobny test — patrz ``xfail`` niżej. +RELACJE_PUBLIKACJI = ( + "wydawnictwo_ciagle", + "wydawnictwo_zwarte", + "patent", + "praca_doktorska", + "praca_habilitacyjna", +) + +#: Wywołania, w których nazwa argumentu / literał napisowy jest ścieżką ORM. +#: ``prefetch_related``/``select_related``/``Prefetch`` CELOWO poza listą. +WYWOLANIA_ORM = frozenset( + { + "filter", + "exclude", + "annotate", + "aggregate", + "alias", + "order_by", + "values", + "values_list", + "only", + "defer", + "distinct", + "get", + "get_or_create", + "update", + "Q", + "F", + "Count", + "Sum", + "Avg", + "Min", + "Max", + "Exists", + "OuterRef", + "Subquery", + # ``dict(...)`` — bo zestawy lookupów bywają budowane jako + # ``filter_kw = dict(rok=…, autorzy_set__…)`` i wstrzykiwane ``**``. + "dict", + # denorm: kwargs lecą prosto do ``qs.filter()``. + "rebuild_instances_of", + } +) + +#: Katalogi/pliki spoza kodu produkcyjnego. +POMIJANE_FRAGMENTY_SCIEZKI = ( + "/tests/", + "/migrations/", + "/test_", + "/conftest", + "/fixtures/", + "/demo_data/", +) + +#: Znane, ŚWIADOME wystąpienia bez ``deleted_at``. Klucz: ścieżka względem +#: ``src/`` + fragment kodu, który ma zostać rozpoznany. Wartość: powód. +#: Dopisanie tu czegokolwiek jest decyzją projektową — patrz docstring modułu. +DOZWOLONE = { + ("bpp/models/dyscyplina_naukowa.py", "rebuild_instances_of"): ( + "ŚWIADOMIE: rebuild_instances_of wybiera publikacje DO PRZELICZENIA. " + "Publikacja ze skasowanym autorstwem potrzebuje przeliczenia tak " + "samo (a nawet bardziej) niż ta z żywym — predykat wyciąłby ją ze " + "zbioru i zostawił nieaktualne punkty. Przypięte testem " + "test_denorm_rebuild_CELOWO_widzi_skasowane." + ), +} + + +def _pliki_produkcyjne(): + for sciezka in sorted(KORZEN.rglob("*.py")): + wzgledna = sciezka.relative_to(KORZEN).as_posix() + if any(frag in "/" + wzgledna for frag in POMIJANE_FRAGMENTY_SCIEZKI): + continue + if wzgledna.endswith("tests.py"): + continue + yield sciezka, wzgledna + + +def _jest_sciezka_relacji(tekst: str, relacje=RELACJE) -> bool: + """Czy ``tekst`` (nazwa kwargu albo literał) jest ścieżką ORM do + through-modelu? ``autorzy_set__autor`` tak, ``patent_autor`` (nazwa + endpointu w routerze) — tylko gdy stoi samo albo z ``__``.""" + if not isinstance(tekst, str): + return False + for relacja in relacje: + if tekst == relacja or tekst.startswith(relacja + "__"): + return True + if "__" + relacja + "__" in tekst or tekst.endswith("__" + relacja): + return True + return False + + +def _nazwa_wywolania(wezel: ast.Call) -> str | None: + if isinstance(wezel.func, ast.Attribute): + return wezel.func.attr + if isinstance(wezel.func, ast.Name): + return wezel.func.id + return None + + +def _wywolanie_dotyka_relacji(wezel: ast.Call, relacje=RELACJE) -> bool: + if _nazwa_wywolania(wezel) not in WYWOLANIA_ORM: + return False + for kw in wezel.keywords: + if kw.arg and _jest_sciezka_relacji(kw.arg, relacje): + return True + for arg in wezel.args: + if isinstance(arg, ast.Constant) and _jest_sciezka_relacji(arg.value, relacje): + return True + return False + + +def _podejrzane_instrukcje(zrodlo: str, wzgledna: str, relacje=RELACJE): + """Zwraca [(linia, pierwsza linia instrukcji)] — instrukcje z wywołaniem + ORM po relacji do ``*_Autor``, bez ``deleted_at``, spoza ``DOZWOLONE``.""" + try: + drzewo = ast.parse(zrodlo) + except SyntaxError: + # Plik niekompilowalny — nie zadanie tego kanarka. + return [] + + zlozone = ( + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.ClassDef, + ast.If, + ast.While, + ast.With, + ast.AsyncWith, + ast.Try, + ast.Module, + ) + wynik = [] + for wezel in ast.walk(drzewo): + if not isinstance(wezel, ast.stmt) or isinstance(wezel, zlozone): + continue + + if isinstance(wezel, (ast.For, ast.AsyncFor)): + # Pętla po LIŚCIE nazw relacji (``for relation in ("wydawnictwo_ + # ciagle_autor", …)``) — ścieżka jest tu, a agregat w ciele. + # Patrzymy na iterator, werdykt na całej pętli. + literaly = [ + w.value for w in ast.walk(wezel.iter) if isinstance(w, ast.Constant) + ] + if not any(_jest_sciezka_relacji(x, relacje) for x in literaly): + continue + elif not any( + isinstance(w, ast.Call) and _wywolanie_dotyka_relacji(w, relacje) + for w in ast.walk(wezel) + ): + continue + + segment = ast.get_source_segment(zrodlo, wezel) + if not segment or "deleted_at" in segment: + continue + if any( + plik == wzgledna and fragment in segment for (plik, fragment) in DOZWOLONE + ): + continue + wynik.append((wezel.lineno, segment.strip().splitlines()[0][:110])) + return wynik + + +def _skanuj(relacje): + """``[plik:linia: fragment]`` dla wszystkich podejrzanych instrukcji.""" + znaleziska = [] + for sciezka, wzgledna in _pliki_produkcyjne(): + zrodlo = sciezka.read_text(encoding="utf-8") + if not any(relacja in zrodlo for relacja in relacje): + continue + for lineno, fragment in _podejrzane_instrukcje(zrodlo, wzgledna, relacje): + znaleziska.append(f"{wzgledna}:{lineno}: {fragment}") + return znaleziska + + +def test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at(): + """Żadne produkcyjne wywołanie ORM nie JOIN-uje ``*_Autor`` bez + ``deleted_at``. + + Wyrocznia jest strukturalna (AST) + tekstowa (obecność ``deleted_at`` + w tej samej instrukcji), więc kanarek NIE dowodzi poprawności + semantycznej — dowodzi tylko, że autor kodu w ogóle pomyślał o + soft-delete. Semantyki pilnują testy w ``test_orm_wyciek_join.py``. + """ + znaleziska = _skanuj(RELACJE) + + assert not znaleziska, ( + "JOIN po soft-delete'owanym through-modelu bez predykatu " + "``deleted_at`` (Django nie pyta tu managera!):\n " + + "\n ".join(znaleziska) + + "\n\nCo zrobić — patrz docstring modułu " + "src/bpp/tests/test_soft_delete/test_kanarek_orm.py" + ) + + +@pytest.mark.xfail( + reason="faza 03 — audyt wywolan ORM w imporcie/dedup/PBN", + strict=True, +) +def test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at(): + """To samo, ale dla pięciu modeli PUBLIKACJI (faza 02). + + ⚠️ ``xfail(strict=True)`` JEST TU CELOWY i ma się utrzymać do fazy 03. + + Ryzyko jest skierowane odwrotnie niż przy autorstwach: zapytania + startujące OD publikacji są bezpieczne, bo ``objects`` to + ``BppSoftDeleteManager``. Przecieka dołączenie DO publikacji od strony + słownika (``Zrodlo``, ``Charakter_Formalny``…), gdzie żaden manager się + nie włącza. + + Faza 02 zamknęła warstwę BAZODANOWĄ (modele, widoki, triggery, + ograniczenia, menedżery). Audyt wywołań ORM w imporcie/dedup/PBN plan + przypisuje jawnie FAZIE 03 — dlatego ten test dokumentuje dług, zamiast + go po cichu ukrywać albo wymuszać naprawę poza zakresem fazy. + + Pełna, przetriagowana lista (10 prawdziwych wycieków + 4 fałszywe + trafienia, z uzasadnieniem każdego) jest w + ``docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md`` — + faza 03 nie musi jej odtwarzać. + + ``strict=True`` znaczy, że gdy faza 03 naprawi te miejsca, test zacznie + PADAĆ jako XPASS i zmusi do zdjęcia markera. Bez ``strict`` naprawa + przeszłaby niezauważona, a kanarek zostałby wyłączony na zawsze — tę + dokładnie pułapkę odnotowuje handoff fazy 01 („pusty wynik ≠ + potwierdzenie"). + """ + znaleziska = _skanuj(RELACJE_PUBLIKACJI) + + assert not znaleziska, ( + "JOIN po soft-delete'owanej PUBLIKACJI bez predykatu " + "``deleted_at``:\n " + + "\n ".join(znaleziska) + + "\n\nTriage: docs/superpowers/reviews/" + "2026-08-07-faza-02-inwentaryzacja-orm.md" + ) + + +def test_kanarek_orm_wykrywa_swiezy_wyciek(): + """Meta-test: kanarek naprawdę łapie wzorce, a nie zawsze przechodzi. + + Bez tego „zielony kanarek" nie niósłby informacji — ten sam powód, dla + którego ``test_kanarek_katalogowy`` ma test semantyczny obok + substringowych. + """ + assert _podejrzane_instrukcje( + "Wydawnictwo_Ciagle.objects.filter(autorzy_set__autor=x)\n", "p.py" + ) + assert _podejrzane_instrukcje( + 'Autor.objects.annotate(n=Count("wydawnictwo_ciagle_autor"))\n', "p.py" + ) + assert _podejrzane_instrukcje( + "Wydawnictwo_Ciagle_Streszczenie.objects.filter(\n" + " rekord__autorzy_set__jednostka__uczelnia=u\n" + ")\n", + "p.py", + ) + assert _podejrzane_instrukcje( + 'for relation in ("wydawnictwo_ciagle_autor", "patent_autor"):\n' + " Autor.objects.annotate(n=Count(relation))\n", + "p.py", + ) + + # ...a na poprawnych i bezpiecznych formach MILCZY: + assert not _podejrzane_instrukcje( + "Wydawnictwo_Ciagle.objects.filter(\n" + " autorzy_set__autor=x,\n" + " autorzy_set__deleted_at__isnull=True,\n" + ")\n", + "p.py", + ) + assert not _podejrzane_instrukcje( + 'Wydawnictwo_Ciagle.objects.prefetch_related("autorzy_set__autor")\n', + "p.py", + ) + assert not _podejrzane_instrukcje( + 'router.register(r"patent_autor", Patent_AutorViewSet)\n', "p.py" + ) + assert not _podejrzane_instrukcje( + '"""Docstring cytujący autorzy_set__upowaznienie_pbn."""\n', "p.py" + ) + + +@pytest.mark.parametrize("klucz", sorted(DOZWOLONE)) +def test_wpisy_dozwolone_nadal_istnieja(klucz): + """Każdy wpis w ``DOZWOLONE`` musi wciąż pasować do realnego kodu. + + Bez tego lista zamienia się w cmentarz nieaktualnych wyjątków, który po + cichu przepuści przyszły wyciek w tym samym pliku. + """ + plik, fragment = klucz + sciezka = KORZEN / plik + assert sciezka.exists(), f"DOZWOLONE wskazuje na nieistniejący plik {plik}" + assert fragment in sciezka.read_text(encoding="utf-8"), ( + f"DOZWOLONE[{klucz}] nie pasuje już do kodu — usuń wpis albo " + f"zaktualizuj fragment." + ) diff --git a/src/bpp/tests/test_soft_delete/test_kronika_usunieta.py b/src/bpp/tests/test_soft_delete/test_kronika_usunieta.py new file mode 100644 index 000000000..bceb4e614 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_kronika_usunieta.py @@ -0,0 +1,65 @@ +"""Rodzina ``bpp_kronika_*`` została skasowana (migracja ``0499``). + +Siedem martwych widoków, kasowanych jednym cięciem, bo graf zależności +(``bpp_kronika_view`` <- ``bpp_kronika_all_unsorted_view`` <- pięć liści) +nie daje się rozciąć na raty bez ``CASCADE``. +""" + +import pytest +from django.core.management import call_command +from django.db import connection + +RODZINA = [ + "bpp_kronika_view", + "bpp_kronika_all_unsorted_view", + "bpp_kronika_wydawnictwo_ciagle_view", + "bpp_kronika_wydawnictwo_zwarte_view", + "bpp_kronika_patent_view", + "bpp_kronika_praca_doktorska_view", + "bpp_kronika_praca_habilitacyjna_view", +] + + +def _istniejace(cur): + cur.execute( + "SELECT viewname FROM pg_views " + "WHERE schemaname = 'public' AND viewname = ANY(%s)", + [RODZINA], + ) + return {r[0] for r in cur.fetchall()} + + +@pytest.mark.django_db +def test_rodzina_kronika_nie_istnieje(): + with connection.cursor() as cur: + zostale = _istniejace(cur) + assert not zostale, f"widoki kronika nadal istnieja: {sorted(zostale)}" + + +@pytest.mark.django_db +def test_0499_odwracalna(): + """``backward`` odtwarza całą siódemkę z sidecara ``.sql``. + + Sidecar był generowany maszynowo z ``pg_get_viewdef()``, ale to nie + dowodzi, że da się go ODTWORZYĆ — kolejność ``CREATE VIEW`` musi + respektować graf zależności, a ``pg_get_viewdef`` nie zwraca jej sam + z siebie. Ten test jest jedynym sprawdzeniem tej kolejności. + """ + try: + call_command("migrate", "bpp", "0498", verbosity=0) + + with connection.cursor() as cur: + odtworzone = _istniejace(cur) + brakujace = set(RODZINA) - odtworzone + assert not brakujace, ( + f"backward nie odtworzyl: {sorted(brakujace)} — sprawdz kolejnosc " + f"CREATE VIEW w sidecarze 0499_drop_kronika_views.sql" + ) + finally: + # ZAWSZE wracamy na szczyt: baza testowa jest wspoldzielona przez + # caly przebieg, wiec zostawienie jej z odtworzona kronika psuloby + # test_rodzina_kronika_nie_istnieje w kolejnych plikach. + call_command("migrate", "bpp", verbosity=0) + + with connection.cursor() as cur: + assert not _istniejace(cur), "po ponownym forward kronika nie znikla" 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" + ) 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..54066b76b --- /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(bez_reinstalacji_denorma): + 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_migracja_0497_rewers.py b/src/bpp/tests/test_soft_delete/test_migracja_0497_rewers.py new file mode 100644 index 000000000..ef94b22bc --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_migracja_0497_rewers.py @@ -0,0 +1,74 @@ +"""Odwracalność ``0497``: filtr soft-delete w widokach publikacji. + +``0497.backward`` nie odtwarza definicji widoków z plików ``.sql`` (jak robi +to ``0489.backward`` dla ścieżki autorstwa), bo widoki publikacji były +redefiniowane w kilku migracjach — m.in. ``0494`` dokładająca +``count(...) FILTER`` — i nie ma jednego pliku źródłowego. Zamiast tego +usuwa DOKŁADNIE ten tekst, który wstawiła ``_z_filtrem``. + +To rozwiązanie symetryczne, ale i kruche: literówka w usuwanym fragmencie +albo zmiana kształtu definicji sprawia, że ``_bez_filtra`` albo rzuci +wyjątkiem, albo (gorzej) zostawi widok z filtrem. Ten test jest jedynym +dowodem, że cykl forward->backward->forward faktycznie domyka się na żywym +katalogu. + +Test zjeżdża do ``0496`` i wraca do NAJNOWSZEJ migracji ``bpp`` +(``migrate bpp`` bez numeru) — powrót „do 0497" byłby błędem, bo baza +testowa jest współdzielona przez cały przebieg i migracje późniejsze niż cel +zostałyby niezastosowane, psując kolejne testy. +""" + +import pytest +from django.core.management import call_command +from django.db import connection + +from bpp.tests.test_soft_delete.test_kanarek_katalogowy import ( + _widoki_zalezne_od_deleted_at, +) + +TABELE = [ + "bpp_wydawnictwo_ciagle", + "bpp_wydawnictwo_zwarte", + "bpp_patent", + "bpp_praca_doktorska", + "bpp_praca_habilitacyjna", +] + +# 7 widokow: 5 rekordowych + 2 autorskie (doktorat/habilitacja maja autora +# na wlasnym wierszu, wiec ich widoki *_autorzy tez czytaja tabele publikacji) +OCZEKIWANE_PARY = {(t + "_view", t) for t in TABELE} | { + (t + "_autorzy", t) for t in ("bpp_praca_doktorska", "bpp_praca_habilitacyjna") +} + + +def _pary_zalezne(): + with connection.cursor() as cur: + return _widoki_zalezne_od_deleted_at(cur, TABELE) + + +@pytest.mark.django_db +def test_0497_odwracalna(bez_reinstalacji_denorma): + # Stan wyjsciowy: wszystkie 7 widokow zalezy od deleted_at swojej tabeli. + assert OCZEKIWANE_PARY <= _pary_zalezne(), ( + "stan przed testem juz jest niepoprawny — migracja 0497 nie jest " + "zastosowana albo widoki zostaly zmienione" + ) + + try: + call_command("migrate", "bpp", "0496", verbosity=0) + + po_cofnieciu = _pary_zalezne() + nadmiarowe = OCZEKIWANE_PARY & po_cofnieciu + assert not nadmiarowe, ( + f"backward NIE zdjal filtra z: {sorted(nadmiarowe)} — " + "_bez_filtra nie usunelo wstawki" + ) + finally: + # ZAWSZE wracamy do najnowszej, nawet gdy asercja wyzej padnie — + # inaczej zostawiamy wspoldzielona baze testowa w stanie sprzed 0497 + # i psujemy kazdy kolejny test w przebiegu. + call_command("migrate", "bpp", verbosity=0) + + assert OCZEKIWANE_PARY <= _pary_zalezne(), ( + "po ponownym forward filtr nie wrocil do wszystkich 7 widokow" + ) diff --git a/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py b/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py new file mode 100644 index 000000000..aaa6aee7b --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py @@ -0,0 +1,93 @@ +"""Odwracalność serii 0490-0493 (Task 3c): warunkowe ograniczenia na *_Autor. + +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 +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 + + +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_migracje_0490_0493_odwracalne(bez_reinstalacji_denorma): + with connection.cursor() as cur: + _sprawdz_stan_po(cur) + + 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_excl_rekord_kolejnosc" not in nazwy + assert LEGACY_CONSTRAINT[tabela] in nazwy + + call_command("migrate", "bpp", verbosity=0) + + with connection.cursor() as cur: + _sprawdz_stan_po(cur) 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/tests/test_soft_delete/test_ostatnio_zmieniony.py b/src/bpp/tests/test_soft_delete/test_ostatnio_zmieniony.py new file mode 100644 index 000000000..9e09b9503 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_ostatnio_zmieniony.py @@ -0,0 +1,187 @@ +"""``ostatnio_zmieniony`` przy soft-delete (kontrakt PINNED, faza 01). + +Soft-delete JEST modyfikacją rekordu i tak musi wyglądać dla każdego +konsumenta przyrostowego (OAI-PMH, CERIF, REST API). Pakiet +``django-soft-delete`` zapisuje przez +``save(update_fields=['deleted_at','restored_at','transaction_id'])``, +a Django woła ``Field.pre_save()`` WYŁĄCZNIE dla pól obecnych w +``update_fields`` — więc bez naszej interwencji ``auto_now`` na +``ostatnio_zmieniony`` się nie odpala i znacznik czasu stoi w miejscu. + +Praktyczny zysk z bumpa: lista nagrobków „co skasowano od daty X" to +``Model.deleted_objects.filter(ostatnio_zmieniony__gte=X)`` — bez +czekania na ``SoftDeleteLog`` z fazy 06. +""" + +import datetime + +import pytest +from django.utils import timezone + +from bpp.models.soft_delete import dopisz_znacznik_zmiany +from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor + + +def _znacznik(pk): + """Wartość ``ostatnio_zmieniony`` PROSTO Z BAZY (nie z instancji w + pamięci) — inaczej test mógłby przejść na zaktualizowanym atrybucie + Pythona przy niezapisanej kolumnie.""" + return Wydawnictwo_Ciagle_Autor.global_objects.values_list( + "ostatnio_zmieniony", flat=True + ).get(pk=pk) + + +def _cofnij_znacznik(pk, o_ile=datetime.timedelta(hours=1)): + """Cofa ``ostatnio_zmieniony`` w bazie o zadany czas, omijając + ``auto_now`` (bulk ``update()`` nie woła ``pre_save``). Dzięki temu + asercja „wzrósł" nie zależy od rozdzielczości zegara.""" + stary = timezone.now() - o_ile + # Gate z BppSoftDeleteQuerySet blokuje wyłącznie deleted_at/restored_at. + Wydawnictwo_Ciagle_Autor.global_objects.filter(pk=pk).update( + ostatnio_zmieniony=stary + ) + return stary + + +@pytest.mark.django_db +def test_soft_delete_bumpuje_ostatnio_zmieniony(wydawnictwo_ciagle_z_autorem): + """delete() (soft) MUSI podbić ``ostatnio_zmieniony`` wiersza. + + Wyrocznia: gdyby override ``save()`` w ``BppAutorstwoSoftDeleteMixin`` + zniknął, ``update_fields`` pakietu nie zawierałoby + ``ostatnio_zmieniony``, ``auto_now`` by nie wystrzeliło i znacznik + zostałby na cofniętej wartości. + """ + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + stary = _cofnij_znacznik(wca.pk) + + Wydawnictwo_Ciagle_Autor.objects.get(pk=wca.pk).delete() + + assert _znacznik(wca.pk) > stary + + +@pytest.mark.django_db +def test_restore_bumpuje_ostatnio_zmieniony(wydawnictwo_ciagle_z_autorem): + """restore() też jest modyfikacją — i też MUSI podbić znacznik.""" + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + Wydawnictwo_Ciagle_Autor.objects.get(pk=wca.pk).delete() + + po_delete = _cofnij_znacznik(wca.pk) + + Wydawnictwo_Ciagle_Autor.global_objects.get(pk=wca.pk).restore() + + assert _znacznik(wca.pk) > po_delete + + +@pytest.mark.django_db +def test_nagrobki_odpytywalne_po_ostatnio_zmieniony( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """Podstawa nagrobków dla harvestu przyrostowego: + ``deleted_objects.filter(ostatnio_zmieniony__gte=okno)`` zwraca to, co + skasowano W OKNIE — i nic więcej. + + Wyrocznia: bez bumpa skasowany wiersz miałby znacznik sprzed okna i + wypadłby z wyniku (pierwsza asercja). Gdyby z kolei bump wyciekał na + wiersze nietknięte, upadłaby asercja o liczności. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + pierwszy, drugi = wc.autorzy_set.all()[:2] + + # Oba wiersze „stare"; skasowany dostanie świeży znacznik od delete(). + _cofnij_znacznik(pierwszy.pk) + _cofnij_znacznik(drugi.pk) + + okno_od = timezone.now() + Wydawnictwo_Ciagle_Autor.objects.get(pk=pierwszy.pk).delete() + + nagrobki = Wydawnictwo_Ciagle_Autor.deleted_objects.filter( + ostatnio_zmieniony__gte=okno_od + ) + assert list(nagrobki.values_list("pk", flat=True)) == [pierwszy.pk] + + # Nietknięty wiersz nie udaje nagrobka ani nie dostał bumpa. + assert not Wydawnictwo_Ciagle_Autor.deleted_objects.filter(pk=drugi.pk).exists() + assert _znacznik(drugi.pk) < okno_od + + +def test_dopisz_znacznik_zmiany_model_bez_pola(): + """Faza 04 obejmie ``Autor``; helper nie może zakładać, że pole + ``ostatnio_zmieniony`` w ogóle istnieje na modelu. + + ``Element_Repozytorium`` jest ``SoftDeleteModel`` i NIE ma tego pola — + helper musi oddać ``update_fields`` bez zmian (a nie rzucić + ``FieldDoesNotExist`` ani dopisać nieistniejącej kolumny, co + wywaliłoby ``save()`` na ``ValueError``). + """ + from bpp.models.repozytorium import Element_Repozytorium + + obj = Element_Repozytorium() + pola = ["deleted_at", "restored_at", "transaction_id"] + assert dopisz_znacznik_zmiany(obj, pola) == pola + + +def test_dopisz_znacznik_zmiany_nie_rusza_zwyklego_zapisu(): + """Zwykły ``save(update_fields=['kolejnosc'])`` NIE jest soft-deletem — + helper nie ma prawa dokładać tam znacznika (auto_now i tak zadziała + dopiero gdy pole jest w update_fields; podbijanie go przy KAŻDYM + częściowym zapisie zmieniłoby semantykę poza soft-deletem).""" + obj = Wydawnictwo_Ciagle_Autor() + assert dopisz_znacznik_zmiany(obj, ["kolejnosc"]) == ["kolejnosc"] + # ... a pełny zapis (update_fields=None) auto_now obsługuje sam. + assert dopisz_znacznik_zmiany(obj, None) is None + + +@pytest.mark.django_db +def test_a2_soft_delete_autorstwa_a_znacznik_publikacji( + denorms, wydawnictwo_ciagle_z_dwoma_autorami +): + """ZADANIE A2 — stan PRZYPIĘTY, nie postulat. + + Pytanie: czy soft-delete *autorstwa* podbija ``ostatnio_zmieniony`` + PUBLIKACJI (pośrednio, przez przeliczenie denormem + ``opis_bibliograficzny_cache``)? + + Odpowiedź: NIE. BPP ma ``DENORM_DISABLE_AUTOTIME_DURING_FLUSH = True`` + (``src/django_bpp/settings/base.py``), przez co + ``denorm.denorms._build_save_kwargs()`` buduje ``update_fields`` + JAWNIE WYKLUCZAJĄCE pola ``auto_now``. Flush denorma nigdy nie + dotyka ``ostatnio_zmieniony`` publikacji. + + To NIE jest regresja fazy 01 — dokładnie tak samo zachowuje się + hard-delete autorstwa na ``dev``. Test przypina fakt, żeby zmiana + (celowa albo przypadkowa) była widoczna. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + denorms.flush() + + def _stan_publikacji(): + return Wydawnictwo_Ciagle.objects.values_list( + "ostatnio_zmieniony", + "opis_bibliograficzny_zapisani_autorzy_cache", + ).get(pk=wc.pk) + + stary = timezone.now() - datetime.timedelta(hours=1) + Wydawnictwo_Ciagle.objects.filter(pk=wc.pk).update(ostatnio_zmieniony=stary) + _, opis_przed = _stan_publikacji() + + wca = wc.autorzy_set.first() + Wydawnictwo_Ciagle_Autor.objects.get(pk=wca.pk).delete() + denorms.flush() + + znacznik_po, opis_po = _stan_publikacji() + + # Kontrola pozytywna: denorm NAPRAWDĘ przeliczył publikację (skasowany + # autor wypadł z opisu). Bez tej asercji test przechodziłby także w + # scenariuszu „denorm w ogóle nie zauważył soft-delete", a to byłoby + # zupełnie inne (i groźniejsze) znalezisko. + assert opis_po != opis_przed, ( + "Denorm nie przeliczył publikacji po soft-delete autorstwa — " + "to inny problem niż A2, patrz denorm_always_only na *_Autor." + ) + + # ... a mimo zapisu publikacji znacznik NIE drgnął. + assert znacznik_po == stary, ( + "Znacznik publikacji zmienił się — zachowanie denorma/auto_now " + "uległo zmianie; zweryfikuj wnioski A2 przed zmianą tego testu." + ) 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" + ) diff --git a/src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py b/src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py new file mode 100644 index 000000000..25c490ba3 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py @@ -0,0 +1,251 @@ +"""Kontrakt DDL po fazie 02: widok filtruje, funkcja kasuje, bramka przepuszcza. + +Odpowiednik ``test_views_sql.py`` (faza 01, ścieżka autorstwa) dla ścieżki +PUBLIKACJI. Podział ról jest ten sam: testy „substringowe" +(``pg_get_viewdef``/``pg_get_functiondef``/``pg_get_triggerdef``) pilnują +wyłącznie tego, że migracja ``0497`` w ogóle się odbyła, a wyrocznią +zachowania są testy SEMANTYCZNE na końcu pliku. + +Najważniejszy z nich to ``test_publikacja_bez_zywych_autorow_ZOSTAJE``. Broni +przed „poprawką", która wygląda naturalnie i niszczy dane: dopisaniem +``deleted_at IS NULL`` tabeli ``*_autor`` do ``WHERE``/``ON`` widoku +rekordowego. Taki warunek degeneruje ``LEFT JOIN`` do ``INNER JOIN``, więc +publikacja, której WSZYSTKICH autorów soft-skasowano, wypada z +``bpp_rekord_mat`` — czyli znika z całego serwisu, mimo że sama nie została +skasowana. Testy substringowe tego NIE łapią: ``deleted_at`` jest w definicji +w obu wariantach. +""" + +import pytest +from django.db import connection +from model_bakery import baker + +from bpp.models import Jednostka, Praca_Doktorska, Wydawnictwo_Ciagle +from bpp.tests.test_soft_delete.test_kanarek_katalogowy import ( + _widoki_zalezne_od_deleted_at, +) + +# (tabela publikacji, czy autor lezy na jej wierszu) +PUBLIKACJE = [ + ("bpp_wydawnictwo_ciagle", False), + ("bpp_wydawnictwo_zwarte", False), + ("bpp_patent", False), + ("bpp_praca_doktorska", True), + ("bpp_praca_habilitacyjna", True), +] + +WIDOKI = [(t + "_view", t) for t, _ in PUBLIKACJE] + [ + (t + "_autorzy", t) for t, autor_na_wierszu in PUBLIKACJE if autor_na_wierszu +] + +FUNKCJE = [ + ("bpp_refresh_rekord_wydawnictwo_ciagle", False), + ("bpp_refresh_rekord_wydawnictwo_zwarte", False), + ("bpp_refresh_rekord_patent", False), + ("bpp_refresh_rekord_praca_doktorska", True), + ("bpp_refresh_rekord_praca_habilitacyjna", True), +] + +TRIGGERY = [(t, f"{t}_cache_upd") for t, _ in PUBLIKACJE] + + +# --- kontrakt DDL (dowód, że migracja się odbyła) ----------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize("widok,tabela", WIDOKI) +def test_widok_zalezy_od_wlasnego_deleted_at(widok, tabela): + """Widok musi zależeć od kolumny ``deleted_at`` WŁASNEJ tabeli. + + Pytamy ``pg_depend`` (przez helper kanarka katalogowego), a NIE tekst + ``pg_get_viewdef``, z dwóch niezależnych powodów: + + 1. Samo ``"deleted_at" in defn`` daje FAŁSZYWĄ ZIELEŃ: widoki rekordowe + trzech typów z through-modelem zawierają ``count(...) FILTER (WHERE + _autor.deleted_at IS NULL)`` z migracji ``0494``, więc to + słowo jest w nich obecne niezależnie od tej fazy. + 2. Dopisanie kwalifikacji (``.deleted_at``) też nie działa: + w widokach rodziny B (jedna tabela w zasięgu, bez JOIN-a) Postgres + NORMALIZUJE predykat do gołego ``deleted_at IS NULL`` — kwalifikacja + jest zbędna, więc ``pg_get_viewdef`` jej nie zwraca. Test tekstowy + padałby mimo poprawnego filtra. + + Zależność kolumnowa w katalogu jest odporna na jedno i drugie. + """ + with connection.cursor() as cur: + zalezne = _widoki_zalezne_od_deleted_at(cur, [tabela]) + assert (widok, tabela) in zalezne, ( + f"{widok} nie zalezy od kolumny {tabela}.deleted_at — " + f"filtr soft-delete nie zostal wpiety" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("fn,autor_na_wierszu", FUNKCJE) +def test_funkcja_refresh_ma_galaz_kasujaca(fn, autor_na_wierszu): + """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_rekord_mat" in src, f"{fn}: brak DELETE z rekord_mat" + if autor_na_wierszu: + # Autor leży na wierszu publikacji, więc soft-delete publikacji musi + # wyczyścić OBIE tabele _mat. Uwaga na różne nazwy klucza: + # bpp_rekord_mat.id vs bpp_autorzy_mat.rekord_id. + assert "DELETE FROM bpp_autorzy_mat" in src, ( + f"{fn}: autor na wierszu, a brak DELETE z autorzy_mat" + ) + + +@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" + + +# --- testy semantyczne (wyrocznia zachowania) --------------------------- + + +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 _jest_w_rekord_mat(cur, model, pk): + cur.execute( + "SELECT count(*) FROM bpp_rekord_mat WHERE id = ARRAY[%s, %s]::integer[]", + [_ct(cur, model), pk], + ) + return cur.fetchone()[0] > 0 + + +def _liczba_w_autorzy_mat(cur, model, pk): + cur.execute( + "SELECT count(*) FROM bpp_autorzy_mat " + "WHERE rekord_id = ARRAY[%s, %s]::integer[]", + [_ct(cur, model), pk], + ) + return cur.fetchone()[0] + + +@pytest.mark.django_db +def test_soft_delete_publikacji_znika_z_rekord_mat_restore_wraca(): + jednostka = baker.make(Jednostka) + autor = baker.make("bpp.Autor", imiona="Jan", nazwisko="Kowalski") + wc = baker.make(Wydawnictwo_Ciagle, szczegoly="sz", uwagi="u") + wc.dodaj_autora(autor, jednostka) + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "publikacja nie trafiła do bpp_rekord_mat jeszcze przed kasowaniem " + "— setup testu jest zepsuty, nie sprawdzamy niczego" + ) + + wc.delete() + + with connection.cursor() as cur: + assert not _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "soft-skasowana publikacja została w bpp_rekord_mat" + ) + assert _liczba_w_autorzy_mat(cur, "wydawnictwo_ciagle", wc.pk) == 0, ( + "autorstwa soft-skasowanej publikacji zostały w bpp_autorzy_mat" + ) + + wc.restore() + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "po restore publikacja nie wróciła do bpp_rekord_mat" + ) + assert _liczba_w_autorzy_mat(cur, "wydawnictwo_ciagle", wc.pk) == 1, ( + "po restore autorstwo nie wróciło do bpp_autorzy_mat" + ) + + +@pytest.mark.django_db +def test_publikacja_bez_zywych_autorow_ZOSTAJE(): + """REGRESJA na pułapkę agregatu — patrz docstring modułu. + + Publikacja, której WSZYSTKIE autorstwa są soft-skasowane, ma zostać w + ``bpp_rekord_mat`` z ``liczba_autorow = 0``. Sama publikacja nie została + skasowana, więc jej zniknięcie z serwisu byłoby utratą danych. + """ + jednostka = baker.make(Jednostka) + autor = baker.make("bpp.Autor", imiona="Jan", nazwisko="Jedyny") + wc = baker.make(Wydawnictwo_Ciagle, szczegoly="sz", uwagi="u") + wca = wc.dodaj_autora(autor, jednostka) + + wca.delete() # kasujemy AUTORSTWO, nie publikację + + # WYROCZNIĄ JEST WIDOK, NIE CACHE. Wiersz w ``bpp_rekord_mat`` pochodzi + # z chwili utworzenia publikacji, a soft-delete autorstwa odpala trigger + # ścieżki autorstwa (``bpp_autorzy_mat``) — ``bpp_rekord_mat`` nie jest + # wtedy przeliczany. Sprawdzanie samego cache'u przepuszczało zepsuty + # widok: nieświeży wiersz maskował degenerację LEFT JOIN-a (potwierdzone + # mutacyjnie przy pisaniu tego testu). + with connection.cursor() as cur: + cur.execute( + "SELECT liczba_autorow FROM bpp_wydawnictwo_ciagle_view " + "WHERE object_id_raw = %s", + [wc.pk], + ) + wiersz = cur.fetchone() + assert wiersz is not None, ( + "publikacja bez żywych autorów WYPADŁA z bpp_wydawnictwo_ciagle_view " + "— LEFT JOIN zdegenerował do INNER JOIN (utrata danych!)" + ) + assert wiersz[0] == 0, f"liczba_autorow = {wiersz[0]}, oczekiwano 0" + + # Dopiero teraz cache: wymuszamy przeliczenie rekordu i sprawdzamy, że + # projekcja widoku faktycznie do niego dociera. + wc.save() + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "publikacja bez żywych autorów WYPADŁA z bpp_rekord_mat po przeliczeniu" + ) + cur.execute( + "SELECT liczba_autorow FROM bpp_rekord_mat " + "WHERE id = ARRAY[%s, %s]::integer[]", + [_ct(cur, "wydawnictwo_ciagle"), wc.pk], + ) + assert cur.fetchone()[0] == 0, "liczba_autorow w cache nie zeszła do zera" + + +@pytest.mark.django_db +def test_soft_delete_doktoratu_czysci_obie_tabele_mat(): + """Doktorat ma autora na WŁASNYM wierszu, więc jego soft-delete musi + wyczyścić zarówno ``bpp_rekord_mat``, jak i ``bpp_autorzy_mat`` — to + jedyne dwa modele, dla których gałąź kasująca dotyka obu tabel.""" + pd = baker.make(Praca_Doktorska, szczegoly="sz", uwagi="u") + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "praca_doktorska", pd.pk), ( + "doktorat nie trafił do bpp_rekord_mat — setup testu zepsuty" + ) + assert _liczba_w_autorzy_mat(cur, "praca_doktorska", pd.pk) == 1, ( + "autor doktoratu nie trafił do bpp_autorzy_mat — setup zepsuty" + ) + + pd.delete() + + with connection.cursor() as cur: + assert not _jest_w_rekord_mat(cur, "praca_doktorska", pd.pk), ( + "soft-skasowany doktorat został w bpp_rekord_mat" + ) + assert _liczba_w_autorzy_mat(cur, "praca_doktorska", pd.pk) == 0, ( + "autor soft-skasowanego doktoratu został w bpp_autorzy_mat" + ) 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..c45443a47 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py @@ -0,0 +1,183 @@ +"""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 django.db import connection + +from bpp.models.cache import Rekord +from bpp.models.sumy_views import Sumy +from bpp.tests.test_soft_delete.test_kanarek_katalogowy import ( + _widoki_zalezne_od_deleted_at, +) + +# (widok sum, tabela PUBLIKACJI) — wymiar domkniety w fazie 02 (bpp.0498). +# Wymiar AUTORSTWA (0495) obejmowal tylko pierwsze trzy, bo pozostale dwa +# nie maja tabeli *_autor. +SUMY_PUBLIKACJE = [ + ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), + ("bpp_nowe_sumy_wydawnictwo_zwarte_view", "bpp_wydawnictwo_zwarte"), + ("bpp_nowe_sumy_patent_view", "bpp_patent"), + ("bpp_nowe_sumy_praca_doktorska_view", "bpp_praca_doktorska"), + ("bpp_nowe_sumy_praca_habilitacyjna_view", "bpp_praca_habilitacyjna"), +] + + +@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() + + +# --- wymiar PUBLIKACJI (faza 02, migracja 0498) ------------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize("widok,tabela", SUMY_PUBLIKACJE) +def test_sumy_zaleza_od_deleted_at_publikacji(widok, tabela): + """Kontrakt DDL: dowód, że ``0498`` w ogóle się wykonała. + + Pytamy ``pg_depend`` o zależność KOLUMNOWĄ, nie tekst definicji — trzy + z tych widoków zawierają już ``deleted_at`` z migracji ``0495`` (wymiar + autorstwa), więc test substringowy dawałby dla nich fałszywą zieleń. + """ + with connection.cursor() as cur: + assert (widok, tabela) in _widoki_zalezne_od_deleted_at(cur, [tabela]), ( + f"{widok} nie zalezy od kolumny {tabela}.deleted_at" + ) + + +@pytest.mark.django_db +def test_sumy_pomijaja_soft_deletowana_publikacje_IZOLOWANE( + wydawnictwo_ciagle_z_autorem, +): + """Wyrocznia dla SAMEGO wymiaru publikacji. + + ⚠️ Kasujemy publikację SUROWYM UPDATE-em, a nie ``wc.delete()``, i jest + to celowe. ``delete()`` kaskaduje na autorstwa, więc wiersz zniknąłby + z sum z DWÓCH niezależnych powodów: przez filtr publikacji (``0498``, + czyli to, co ten test ma sprawdzać) ORAZ przez filtr autorstwa + (``0495``). Test przechodziłby wtedy nawet po cofnięciu ``0498`` — czyli + nie byłby wyrocznią niczego. + + Surowy SQL omija też gate na ``.update(deleted_at=...)`` + (``BppSoftDeleteQuerySet``), co poza testem izolującym wymiar jest + zakazane. + """ + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + + assert Sumy.objects.filter(autor_id=autor_id).exists() + + with connection.cursor() as cur: + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s", + [wc.pk], + ) + + assert not Sumy.objects.filter(autor_id=autor_id).exists(), ( + "soft-skasowana publikacja dalej wnosi punkty do rankingu" + ) + + +@pytest.mark.django_db +def test_sumy_pomijaja_soft_deletowana_publikacje_end_to_end( + wydawnictwo_ciagle_z_autorem, +): + """Realna ścieżka (``delete()`` z kaskadą) + powrót po ``restore()``. + + Nie izoluje wymiaru (patrz test wyżej), ale pokrywa to, co faktycznie + robi operator, łącznie z odwracalnością. + """ + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + + assert Sumy.objects.filter(autor_id=autor_id).exists() + + wc.delete() + assert not Sumy.objects.filter(autor_id=autor_id).exists() + + wc.restore() + assert Sumy.objects.filter(autor_id=autor_id).exists(), ( + "po restore publikacja nie wrocila do rankingu" + ) diff --git a/src/bpp/tests/test_soft_delete_publikacje.py b/src/bpp/tests/test_soft_delete_publikacje.py new file mode 100644 index 000000000..c9c44631c --- /dev/null +++ b/src/bpp/tests/test_soft_delete_publikacje.py @@ -0,0 +1,207 @@ +"""Soft-delete PUBLIKACJI (faza 02). + +Testy wąskiej, kontrolowanej kaskady `publikacja -> *_Autor` pod wspólnym +`transaction_id`, bez refleksyjnej kaskady pakietu `django-soft-delete` +(która ruszyłaby `*_Streszczenie` i inne nie-soft dzieci). +""" + +import pytest +from model_bakery import baker + +from bpp.models import ( + Patent, + Praca_Doktorska, + Praca_Habilitacyjna, + Wydawnictwo_Ciagle, + Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte, +) + +#: Wszystkie 5 modeli publikacji objętych fazą 02. +MODELE_PUBLIKACJI = [ + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, + Patent, + Praca_Doktorska, + Praca_Habilitacyjna, +] + + +@pytest.mark.django_db +def test_soft_delete_publikacji_kaskaduje_na_autor_wspolny_txid(): + wc = baker.make(Wydawnictwo_Ciagle) + # `kolejnosc` JAWNIE różna. Bez tego `baker` nadaje obu wierszom 0, co + # łamie ograniczenie wykluczające `wc_autor_excl_rekord_kolejnosc` + # (faza 01). Ograniczenie jest DEFERRABLE i warunkowane + # `deleted_at IS NULL`, więc soft-delete wyprowadzał oba wiersze poza + # jego zakres, zanim zdążyło zadziałać przy COMMIT — test przechodził + # wtedy CZĘŚCIOWO z powodu efektu ubocznego, a nie samej kaskady. + a1 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=0) + a2 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=1) + + 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" + + +# --- Task 4: przeplecenie menedżerów ------------------------------------ + + +@pytest.mark.django_db +@pytest.mark.parametrize("klasa", MODELE_PUBLIKACJI) +def test_objects_ukrywa_skasowane_global_widzi(klasa): + """``objects`` musi ukrywać kosz na WSZYSTKICH pięciu modelach. + + Do fazy 02 ``Wydawnictwo_Ciagle`` i ``Wydawnictwo_Zwarte`` miały własne + menedżery (mixin opłat + goły ``models.Manager``), które przesłaniały + menedżer wniesiony przez ``BppPublikacjaSoftDeleteMixin`` — i NIE + filtrowały ``deleted_at``. Pozostałe trzy modele dostawały filtr + „z urodzenia". Ta asymetria była niewidoczna, dopóki nie sprawdzono jej + wprost: żaden wcześniejszy test fazy 02 na nią nie trafiał, bo wszystkie + szły przez widoki albo przez surowy SQL. + """ + zywy = baker.make(klasa) + kosz = baker.make(klasa) + kosz.delete() + + widoczne = set(klasa.objects.values_list("pk", flat=True)) + assert zywy.pk in widoczne + assert kosz.pk not in widoczne, ( + f"{klasa.__name__}.objects ({type(klasa.objects).__name__}) " + f"NIE ukrywa skasowanych" + ) + + wszystkie = set(klasa.global_objects.values_list("pk", flat=True)) + assert kosz.pk in wszystkie, f"{klasa.__name__}.global_objects nie widzi kosza" + + skasowane = set(klasa.deleted_objects.values_list("pk", flat=True)) + assert skasowane == {kosz.pk} & skasowane and kosz.pk in skasowane, ( + f"{klasa.__name__}.deleted_objects nie zwraca skasowanego" + ) + assert zywy.pk not in skasowane, ( + f"{klasa.__name__}.deleted_objects zwraca ŻYWY rekord" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "klasa", [Wydawnictwo_Ciagle, Wydawnictwo_Zwarte], ids=["ciagle", "zwarte"] +) +def test_metoda_oplat_zachowana_i_tez_filtruje(klasa): + """``rekordy_z_oplata()`` (mixin opłat) ma DALEJ działać po przepleceniu + menedżerów — i sama z siebie pomijać kosz. + + To jest sedno Taska 4: filtr soft-delete i metody opłat muszą współżyć + bez nadpisywania tych drugich. ``ManagerModeliZOplataZaPublikacjeMixin`` + woła ``self.exclude(...)``, więc działa nad KAŻDYM querysetem — o ile + stoi w MRO PRZED menedżerem dostarczającym queryset. + """ + zywy = baker.make(klasa, opl_pub_cost_free=True) + kosz = baker.make(klasa, opl_pub_cost_free=True) + kosz.delete() + + z_oplata = set(klasa.objects.rekordy_z_oplata().values_list("pk", flat=True)) + assert zywy.pk in z_oplata, "rekordy_z_oplata() zgubiło żywy rekord" + assert kosz.pk not in z_oplata, "rekordy_z_oplata() pokazuje kosz" + + +@pytest.mark.django_db +def test_wydawnictwa_nadrzedne_dla_innych_zachowane(): + """Druga metoda menedżera ``Wydawnictwo_Zwarte`` przeżywa przeplecenie.""" + matka = baker.make(Wydawnictwo_Zwarte) + baker.make(Wydawnictwo_Zwarte, wydawnictwo_nadrzedne=matka) + + nadrzedne = set(Wydawnictwo_Zwarte.objects.wydawnictwa_nadrzedne_dla_innych()) + assert matka.pk in nadrzedne + + +# --- Task 5: integracja -------------------------------------------------- + + +@pytest.mark.django_db +def test_restore_przywraca_autorstwa_po_tym_samym_txid(): + """``restore()`` podnosi wyłącznie autorstwa skasowane RAZEM z publikacją. + + Autorstwo skasowane WCZEŚNIEJ, osobną decyzją operatora, ma zostać + w koszu — inaczej przywrócenie publikacji cofałoby też decyzje, których + nikt nie cofał. + """ + wc = baker.make(Wydawnictwo_Ciagle) + wczesniej = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=0) + razem = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=1) + + wczesniej.delete() # osobna decyzja, INNY transaction_id + wc.delete() # kaskada obejmuje tylko `razem` + + wc.restore() + + assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=razem.pk).exists(), ( + "autorstwo skasowane RAZEM z publikacja nie wrocilo" + ) + assert not Wydawnictwo_Ciagle_Autor.objects.filter(pk=wczesniej.pk).exists(), ( + "restore podniosl autorstwo skasowane WCZESNIEJ, osobna decyzja" + ) + + +@pytest.mark.django_db +def test_post_soft_delete_emitowany(): + """Sygnał musi lecieć — konsumuje go ``SoftDeleteLog`` z fazy 06.""" + from django_softdelete.signals import post_soft_delete + + odebrane = [] + + def odbiorca(sender, instance, **kwargs): + odebrane.append(instance) + + post_soft_delete.connect(odbiorca, sender=Wydawnictwo_Ciagle) + try: + wc = baker.make(Wydawnictwo_Ciagle) + wc.delete() + finally: + post_soft_delete.disconnect(odbiorca, sender=Wydawnictwo_Ciagle) + + assert len(odebrane) == 1 + assert odebrane[0].pk == wc.pk + + +@pytest.mark.django_db +def test_kaskada_nie_rusza_streszczenia(): + """Kaskada zatrzymuje się na ``*_Autor``. + + Gdybyśmy użyli refleksyjnej kaskady pakietu, zjechałaby po + ``*_Streszczenie``. Co gorsza CICHO: ``delete()`` pakietu ma domyślnie + ``strict=False``, więc nie usłyszelibyśmy ``SoftDeleteException`` — + stąd asercja na SAM BRAK wyjątku nie wystarcza i sprawdzamy też, że + streszczenie fizycznie zostało. + """ + from bpp.models import Wydawnictwo_Ciagle_Streszczenie + + wc = baker.make(Wydawnictwo_Ciagle) + strz = baker.make(Wydawnictwo_Ciagle_Streszczenie, rekord=wc) + + wc.delete() # NIE moze rzucic SoftDeleteException + + assert Wydawnictwo_Ciagle_Streszczenie.objects.filter(pk=strz.pk).exists(), ( + "kaskada zjechala po streszczeniu" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("klasa", MODELE_PUBLIKACJI) +def test_bulk_update_deleted_at_zabroniony(klasa): + """Kontrakt z reversion: soft-delete idzie WYŁĄCZNIE per-instancja. + + Bulk ``update(deleted_at=...)`` omija ``post_save``, kaskadę na + ``*_Autor``, sygnały i przyszły ``SoftDeleteLog``. Gate z fazy 01 ma to + blokować fail-fast, a nie „na ogół". + """ + baker.make(klasa) + with pytest.raises(RuntimeError): + klasa.objects.update(deleted_at="2026-01-01") diff --git a/src/bpp/tests/test_views/test_api.py b/src/bpp/tests/test_views/test_api.py index 2adeda83b..4723087c4 100644 --- a/src/bpp/tests/test_views/test_api.py +++ b/src/bpp/tests/test_views/test_api.py @@ -267,11 +267,18 @@ def test_rok_habilitacji_view(): assert str(CURRENT_YEAR) in res.content.decode() assert json.loads(res.content)["rok"] == CURRENT_YEAR - h.delete() + h.delete() # od fazy 02 MIĘKKO res = rhv.post(request) assert res.status_code == 404 assert "Habilitacja" in res.content.decode() + # ⚠️ Soft-delete ZOSTAWIA wiersz, a `Praca_Habilitacyjna.autor` to O2O + # z PROTECT — więc dopóki habilitacja siedzi w koszu, autora nie da się + # skasować (`ProtectedError`). To NIE jest regresja: rekord nadal + # istnieje i nie wolno go osierocić. Ten test sprawdza ścieżki 404 + # widoku, a nie semantykę kasowania, więc usuwamy habilitację trwale, + # żeby dojść do gałęzi „nie ma autora". + h.hard_delete() a.delete() res = rhv.post(request) assert res.status_code == 404 diff --git a/src/bpp/views/api/__init__.py b/src/bpp/views/api/__init__.py index 141ab17c3..f6752fc6d 100644 --- a/src/bpp/views/api/__init__.py +++ b/src/bpp/views/api/__init__.py @@ -32,9 +32,19 @@ def post(self, request, *args, **kw): except Autor.DoesNotExist: return HttpResponseNotFound("Autor") - try: - habilitacja = autor.praca_habilitacyjna - except Praca_Habilitacyjna.DoesNotExist: + # Jawne zapytanie po `objects` (menedżer filtrujący kosz), a NIE po + # akcesorze odwrotnym. Wcześniej `autor.praca_habilitacyjna` szło przez + # `ReverseOneToOneDescriptor`, ten pyta `_base_manager` — z definicji + # NIEprzefiltrowany — i habilitacja z kosza była tą ścieżką nadal + # osiągalna, więc trzeba było dokładać ręczny warunek na `deleted_at`. + # + # Od czasu, gdy `Praca_Habilitacyjna.autor` jest zwykłym `ForeignKey` + # (warunkowy unique, faza 03), tamten akcesor już nie istnieje. + # `first()` zamiast `get()`, bo unikalność jest teraz pilnowana + # warunkowo — a niepowtarzalny jest tylko wiersz ŻYWY, czyli dokładnie + # ten, którego `objects` szuka. + habilitacja = Praca_Habilitacyjna.objects.filter(autor=autor).first() + if habilitacja is None: return HttpResponseNotFound("Habilitacja") return JsonResponse({"rok": habilitacja.rok}) 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 27d9fed07..a1c236341 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/conftest.py b/src/conftest.py index 620ed9118..4a46346f1 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -1,6 +1,7 @@ import os import random import time +import warnings from datetime import date from uuid import uuid4 @@ -10,6 +11,7 @@ from django.core.management import call_command from django.core.management.base import CommandError from django.db import connections +from django.db.models.signals import post_migrate from django.db.utils import OperationalError from django.test import TransactionTestCase from django.test.client import Client, RequestFactory @@ -1137,6 +1139,36 @@ def constance_cache_warmed_up(db): return config +# Tabele powiązań autorstwa, na które triggery denorm (denorm_always_only = +# ("deleted_at",) w Patent, Wydawnictwo_Ciagle, Wydawnictwo_Zwarte) generują +# SQL referujący NEW."deleted_at". Kolumna przyszła w migracji +# bpp.0488_autor_soft_delete_fields — patrz guard w django_db_setup niżej. +_AUTOR_TABELE_Z_DELETED_AT = ( + "bpp_patent_autor", + "bpp_wydawnictwo_ciagle_autor", + "bpp_wydawnictwo_zwarte_autor", +) + + +def _autor_tabele_maja_deleted_at(connection) -> bool: + """True, gdy WSZYSTKIE tabele ``*_autor`` mają już kolumnę ``deleted_at``. + + Rozpoznaje wyłącznie ten jeden, konkretny przypadek (baza sprzed migracji + 0488 — reużyty kontener/baza ze starym schematem) — nie łyka żadnych + innych błędów ``install_triggers()``. Patrz komentarz w + ``django_db_setup``. + """ + with connection.cursor() as cursor: + cursor.execute( + "SELECT table_name FROM information_schema.columns " + "WHERE table_schema = 'public' AND column_name = 'deleted_at' " + "AND table_name = ANY(%s)", + [list(_AUTOR_TABELE_Z_DELETED_AT)], + ) + znalezione = {row[0] for row in cursor.fetchall()} + return znalezione == set(_AUTOR_TABELE_Z_DELETED_AT) + + @pytest.fixture(scope="session") def django_db_setup(django_db_setup, django_db_blocker): """Rebuild denorm triggers on the freshly-built test DB, then jitter @@ -1158,13 +1190,43 @@ def django_db_setup(django_db_setup, django_db_blocker): 1.12.1) usuwa wszystkie ``d_*``-triggery, a install_triggers() instaluje świeże, rozwiązujące content_type dynamicznie (patrz ``denorm.helpers.content_type_select_sql``). + + Fixture jest session-scoped, ale uruchamia się w KAŻDEJ sesji pytest — + także bez ani jednego testu ``django_db`` — bo autouse ``_audit_wipe_once`` + (niżej) go zależnościowo wymusza. Od fazy 01 soft-delete triggery denorm + dla Patent/Wydawnictwo_Ciagle/Wydawnictwo_Zwarte referują + ``NEW."deleted_at"`` (denorm_always_only). Na bazie sprzed migracji + bpp.0488 (typowo: reużyty kontener testcontainers albo ``--reuse-db`` ze + starym schematem) tej kolumny jeszcze nie ma i + ``install_triggers()`` pada ``UndefinedColumn``, wywalając CAŁĄ sesję + pytest — również tę bez testów bazodanowych. Guard rozpoznaje WYŁĄCZNIE + ten jeden przypadek (brak kolumny) i pomija instalację triggerów z + ostrzeżeniem zamiast crashować; każdy inny błąd leci dalej. """ from denorm import denorms from django.db import connection with django_db_blocker.unblock(): - denorms.drop_triggers() - denorms.install_triggers() + if _autor_tabele_maja_deleted_at(connection): + denorms.drop_triggers() + denorms.install_triggers() + else: + warnings.warn( + "Pomijam denorm.drop_triggers()/install_triggers(): tabele " + + ", ".join(_AUTOR_TABELE_Z_DELETED_AT) + + " nie mają jeszcze kolumny deleted_at (migracja " + "bpp.0488_autor_soft_delete_fields nie została zastosowana " + "na tej bazie testowej — najczęściej reużyty kontener " + "testcontainers albo baza --reuse-db ze starym schematem). " + 'Triggery denorm referują NEW."deleted_at" ' + "(denorm_always_only), więc install_triggers() padłby " + "UndefinedColumn. Zastosuj migracje " + "(`uv run python src/manage.py migrate`) albo uruchom testy " + "na świeżej bazie (np. `--create-db` / świeży kontener " + "testcontainers).", + RuntimeWarning, + stacklevel=2, + ) # Przesuń każdą sekwencję w public o losową wartość z zakresu # [50 000, 500 000], niezależnie per sekwencja. Cel: nie pozwolić @@ -1198,3 +1260,54 @@ def django_db_setup(django_db_setup, django_db_blocker): for sn, lv, ic in rows ] cursor.execute("\n".join(alter_stmts)) + + +@pytest.fixture +def bez_reinstalacji_denorma(): + """Odpina globalną przebudowę triggerów ``django-denorm`` na czas testu. + + PO CO: ``denorm`` podpina się pod ``post_migrate`` i po KAŻDYM ``migrate`` + odbudowuje wszystkie swoje triggery — zawsze z AKTUALNYCH definicji + modeli (``denorm/apps.py``: ``denorm_install_triggers_after_migrate``). + Test, który zejdzie migracjami poniżej ``bpp.0496``, ma wtedy bazę bez + kolumny ``deleted_at`` na tabelach publikacji, ale modele Pythona nadal ją + deklarują — więc ``denorm`` generuje bramkę + ``WHEN (OLD."deleted_at" IS DISTINCT FROM ...)``, a ``CREATE TRIGGER`` + pada na ``UndefinedColumn``. + + KTO TEGO POTRZEBUJE — dwie różne rodziny testów, stąd fixture siedzi + w GLOBALNYM conftescie, a nie przy testach soft-delete: + + 1. testy odwracalności migracji (``bpp/tests/test_soft_delete/``), które + schodzą poniżej ``0496`` świadomie; + 2. testy e2e migracji w INNYCH aplikacjach (``pbn_api``), które cofają + swoją aplikację ``MigrationExecutor``-em — a że migracje ``bpp`` + zależą od ``pbn_api``, Django cofa razem z nimi także ``0496``. + Tam wybuchało to dopiero w TEARDOWNIE i o przyczynie nie mówiło nic. + + DLACZEGO TO NIE JEST PROBLEM PRODUKCYJNY: w prawdziwym rollbacku wycofuje + się KOD razem ze schematem, a stare modele nie mają ``deleted_at`` — + ``denorm`` wygeneruje wtedy poprawne triggery. Kombinacja „nowy kod + + stary schemat" powstaje wyłącznie w teście, który rusza sam schemat. + + Po teście wpinamy handler z powrotem i odpalamy przebudowę RĘCZNIE — baza + testowa jest współdzielona przez cały przebieg, więc nie wolno zostawić + jej z triggerami niepasującymi do modeli. + """ + from denorm import denorms + from denorm.apps import denorm_install_triggers_after_migrate + + sender = apps.get_app_config("denorm") + post_migrate.disconnect(denorm_install_triggers_after_migrate, sender=sender) + try: + yield + finally: + post_migrate.connect(denorm_install_triggers_after_migrate, sender=sender) + # Doprowadzamy schemat z powrotem do najnowszej migracji SAMI, zamiast + # ufać, że test zdążył to zrobić: gdy asercja padnie w połowie, test + # przerywa przed swoim `migrate` i baza zostaje w stanie sprzed + # docelowej migracji. Wywołanie jest idempotentne (no-op, gdy już + # jesteśmy na szczycie), a bez niego przebudowa niżej wywaliłaby się + # na brakującej kolumnie, przykrywając PRAWDZIWY powód porażki testu. + call_command("migrate", "bpp", verbosity=0) + denorms.install_triggers() 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/deduplikator_autorow/tests/test_scal_autora_soft_delete.py b/src/deduplikator_autorow/tests/test_scal_autora_soft_delete.py new file mode 100644 index 000000000..7b2d69723 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_scal_autora_soft_delete.py @@ -0,0 +1,241 @@ +"""Scalanie autorów a kosz (faza 03 soft-delete, pozycje 3.2–3.4 handoffu). + +Scalanie należy do „kategorii B": przenosi rekordy duplikatu RAZEM Z KOSZEM, +żeby po usunięciu duplikatu nie zostały sieroty. To poprawne — ale odsłoniło +trzy miejsca, w których reszta kodu zakłada, że wiersz w koszu nie istnieje: + +3.2. ``Praca_Habilitacyjna.autor`` było ``OneToOneField`` → twardy + ``UNIQUE (autor_id)``, który kosza NIE zna. +3.3. Detekcja kolizji autorstw szła po menedżerze ŻYWYCH. +3.4. Kolejkowanie do PBN nie sprawdzało, czy publikacja jest w koszu. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Praca_Habilitacyjna +from deduplikator_autorow.utils.merge import scal_autora + + +@pytest.fixture +def user(db): + return baker.make("bpp.BppUser") + + +@pytest.fixture +def main_dup(autor_maker, tytuly, typy_odpowiedzialnosci, charaktery_formalne): + # Słowniki z fixture'ów, nie z baseline: testy transakcyjne z tego samego + # przebiegu potrafią wyczyścić dane referencyjne (patrz zapis habilitacji, + # który sięga po Typ_Odpowiedzialnosci przy przebudowie cache'u). + glowny = autor_maker(imiona="Jan", nazwisko="Kowalski") + duplikat = autor_maker(imiona="Jan", nazwisko="Kowalski-Duplikat") + return glowny, duplikat + + +# --- 3.2. Habilitacja: unique musi znać kosz ------------------------------- + + +def test_scalanie_przenosi_habilitacje_z_kosza_gdy_glowny_ma_zywa(main_dup, user): + """Habilitacja duplikatu W KOSZU nie może wywrócić całego scalania. + + ``UNIQUE (autor_id)`` z ``OneToOneField`` nie zna kosza: przeniesienie + skasowanej habilitacji duplikatu na autora, który ma już żywą, wywalało + ``IntegrityError`` — a ten leci przez ``except Exception`` w ``scal_autora`` + i cała operacja zwraca porażkę. Przed fazą 03 wiersz z kosza w ogóle nie był + przenoszony, więc scalanie się udawało; po włączeniu transferu „razem + z koszem" zaczęło padać. + """ + glowny, duplikat = main_dup + baker.make(Praca_Habilitacyjna, autor=glowny) + hab_duplikatu = baker.make(Praca_Habilitacyjna, autor=duplikat) + hab_duplikatu.delete() + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=True) + + assert wynik["success"] is True, ( + f"scalanie padlo zamiast przeniesc habilitacje z kosza: {wynik.get('error')!r}" + ) + hab_duplikatu.refresh_from_db() + assert hab_duplikatu.autor_id == glowny.pk, ( + "habilitacja z kosza zostala przy usunietym duplikacie — sierota" + ) + assert hab_duplikatu.deleted_at is not None, ( + "transfer nie moze przy okazji wskrzeszac rekordu z kosza" + ) + + +def test_dwie_zywe_habilitacje_daja_czytelny_komunikat_nie_integrityerror( + main_dup, user +): + """Prawdziwy konflikt ma być nazwany po imieniu. + + Gdy OBAJ autorzy mają ŻYWĄ pracę habilitacyjną, scalenie jest niemożliwe — + ale operator ma się dowiedzieć DLACZEGO, zamiast dostać komunikat bazy + o naruszeniu ograniczenia. + """ + glowny, duplikat = main_dup + baker.make(Praca_Habilitacyjna, autor=glowny) + baker.make(Praca_Habilitacyjna, autor=duplikat) + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=True) + + assert wynik["success"] is False + assert "obaj mają pracę habilitacyjną" in wynik["error"], ( + f"komunikat nie tlumaczy przyczyny: {wynik['error']!r}" + ) + assert "duplicate key" not in wynik["error"] + assert "UNIQUE" not in wynik["error"] + + # Konflikt nie moze zostawic bazy w polowie scalonej. + assert type(glowny).objects.filter(pk=duplikat.pk).exists(), ( + "duplikat zostal usuniety mimo nieudanego scalania" + ) + + +def test_habilitacja_duplikatu_przechodzi_gdy_glowny_nie_ma_zadnej(main_dup, user): + """Kontrola: bez konfliktu transfer ma działać jak dotąd.""" + glowny, duplikat = main_dup + habilitacja = baker.make(Praca_Habilitacyjna, autor=duplikat) + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=True) + + assert wynik["success"] is True, wynik.get("error") + habilitacja.refresh_from_db() + assert habilitacja.autor_id == glowny.pk + + +# --- 3.3. Detekcja kolizji autorstw musi widzieć kosz ---------------------- + + +def test_kolizja_autorstw_w_koszu_nie_tworzy_dwoch_wierszy( + main_dup, user, wydawnictwo_ciagle, jednostka +): + """Kosz nie może stać się drzwiami jednokierunkowymi. + + Gdy publikacja jest w koszu, oba autorstwa (głównego i duplikatu) też tam + są. Detekcja kolizji szła po ``model.objects`` — menedżerze ŻYWYCH — więc + kolizji nie widziała i przepuszczała transfer. W koszu lądowały DWA wiersze + ``(rekord, glowny, typ)``, a to znaczy, że ``publikacja.restore()`` wywala + się ``IntegrityError``-em na ``wc_autor_uniq_rekord_autor_typ``: rekordu + nie da się już wyjąć z kosza. + + Ta sama pułapka dotyczy wskrzeszania z importu (pozycja 3.1). + """ + from bpp.models import Wydawnictwo_Ciagle_Autor + + glowny, duplikat = main_dup + wydawnictwo_ciagle.dodaj_autora(glowny, jednostka) + wydawnictwo_ciagle.dodaj_autora(duplikat, jednostka) + # Kasujemy publikację — kaskada z fazy 02 zabiera oba autorstwa do kosza. + wydawnictwo_ciagle.delete() + + duplikat_pk = duplikat.pk + wynik = scal_autora(glowny, duplikat, user, skip_pbn=True) + + assert wynik["success"] is True, wynik.get("error") + + # 1. Zdublowane autorstwo nie może zostać przy autorze, który znika — + # inaczej w koszu leży sierota wskazująca na nieistniejącego autora. + assert not Wydawnictwo_Ciagle_Autor.global_objects.filter( + autor_id=duplikat_pk + ).exists(), "autorstwo w koszu zostalo przy usunietym duplikacie — sierota" + + # 2. Rekord musi dać się wyjąć z kosza, i to z DOKŁADNIE jednym żywym + # wierszem (rekord, glowny, typ). Dwa wiersze w koszu z tym samym + # `transaction_id` wróciłyby razem i wywaliły `restore()` + # na `wc_autor_uniq_rekord_autor_typ`. + wydawnictwo_ciagle.restore() + + assert ( + Wydawnictwo_Ciagle_Autor.objects.filter( + rekord=wydawnictwo_ciagle, autor=glowny + ).count() + == 1 + ), "restore wskrzesil zdublowane autorstwo" + + +def test_kolizja_zywych_autorstw_nadal_wykrywana( + main_dup, user, wydawnictwo_ciagle, jednostka +): + """Kontrola: przejście na globalny menedżer nie może zgubić starej ścieżki. + + Kolizja dwóch ŻYWYCH autorstw ma nadal kasować wiersz duplikatu i zgłaszać + to w ostrzeżeniach. + """ + from bpp.models import Wydawnictwo_Ciagle_Autor + + glowny, duplikat = main_dup + wydawnictwo_ciagle.dodaj_autora(glowny, jednostka) + wydawnictwo_ciagle.dodaj_autora(duplikat, jednostka) + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=True) + + assert wynik["success"] is True, wynik.get("error") + assert ( + Wydawnictwo_Ciagle_Autor.objects.filter( + rekord=wydawnictwo_ciagle, autor=glowny + ).count() + == 1 + ) + assert any("już ma publikację" in w for w in wynik["warnings"]) + + +# --- 3.4. Nie kolejkujemy do PBN tego, czego w BPP „nie ma" ---------------- + + +def test_scalanie_nie_kolejkuje_do_pbn_publikacji_z_kosza( + main_dup, user, wydawnictwo_ciagle, jednostka +): + """Publikacja w koszu nie ma czego szukać w kolejce eksportu do PBN. + + Kierunek fazy 05 jest odwrotny: soft-delete ma WYCOFYWAĆ oświadczenia + z PBN, a nie je tam wysyłać. Scalanie autorów kolejkowało wszystko, co + przeniosło — łącznie z rekordami, które operator wcześniej skasował. + """ + from pbn_export_queue.models import PBN_Export_Queue + + glowny, duplikat = main_dup + wydawnictwo_ciagle.dodaj_autora(duplikat, jednostka) + wydawnictwo_ciagle.delete() + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=False) + + assert wynik["success"] is True, wynik.get("error") + assert not PBN_Export_Queue.objects.exists(), ( + "rekord z kosza trafil do kolejki eksportu do PBN" + ) + assert wynik["publications_queued_for_pbn"] == [] + + +def test_scalanie_nie_kolejkuje_do_pbn_habilitacji_z_kosza(main_dup, user): + """To samo dla „prostych" publikacji (sam obiekt jest publikacją).""" + from pbn_export_queue.models import PBN_Export_Queue + + glowny, duplikat = main_dup + habilitacja = baker.make(Praca_Habilitacyjna, autor=duplikat) + habilitacja.delete() + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=False) + + assert wynik["success"] is True, wynik.get("error") + assert not PBN_Export_Queue.objects.exists(), ( + "habilitacja z kosza trafila do kolejki eksportu do PBN" + ) + + +def test_scalanie_nadal_kolejkuje_zywa_publikacje( + main_dup, user, wydawnictwo_ciagle, jednostka +): + """Kontrola: pominięcie kosza nie może wyłączyć kolejkowania w ogóle.""" + from pbn_export_queue.models import PBN_Export_Queue + + glowny, duplikat = main_dup + wydawnictwo_ciagle.dodaj_autora(duplikat, jednostka) + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=False) + + assert wynik["success"] is True, wynik.get("error") + assert PBN_Export_Queue.objects.count() == 1, ( + "zywa publikacja przestala trafiac do kolejki PBN" + ) + assert wynik["publications_queued_for_pbn"] != [] diff --git a/src/deduplikator_autorow/utils/merge.py b/src/deduplikator_autorow/utils/merge.py index 6307c555b..6a0baa20c 100644 --- a/src/deduplikator_autorow/utils/merge.py +++ b/src/deduplikator_autorow/utils/merge.py @@ -18,6 +18,24 @@ logger = logging.getLogger(__name__) +class KonfliktScalania(Exception): + """Scalania nie da się wykonać z powodu danych, nie z powodu awarii. + + Wydzielone z ``Exception``, żeby ``scal_autora`` mogło odróżnić „operator + poprosił o coś sprzecznego" od „coś się zepsuło": to pierwsze wraca jako + czytelny komunikat i NIE trafia do Rollbara. + """ + + +def _w_koszu(obiekt): + """``True``, gdy obiekt jest soft-skasowany. + + ``getattr`` z fallbackiem, bo scalanie przechodzi też przez modele spoza + soft-delete (jak ``wiersze_do_transferu``). + """ + return getattr(obiekt, "deleted_at", None) is not None + + def _assign_discipline_if_missing( autor_record, glowny_autor, rok, auto_assign_discipline, use_subdiscipline, warnings ): @@ -142,8 +160,18 @@ def _transfer_authorship_record( # Store old discipline before any changes old_discipline = record.dyscyplina_naukowa - # CHECK IF MAIN AUTHOR ALREADY HAS THIS PUBLICATION - existing = model.objects.filter( + # CZY GŁÓWNY AUTOR JUŻ MA TĘ PUBLIKACJĘ — SZUKAMY RAZEM Z KOSZEM. + # + # Po menedżerze ŻYWYCH kolizja była niewidoczna dokładnie wtedy, gdy + # publikacja jest w koszu: wtedy oba autorstwa (głównego i duplikatu) też + # tam są. Transfer przechodził i w koszu lądowały DWA wiersze + # `(rekord, glowny, typ)` — a warunkowy `wc_autor_uniq_rekord_autor_typ` + # obowiązuje wśród ŻYWYCH, więc kolizja wybuchała dopiero przy + # `publikacja.restore()`. Kosz stawał się drzwiami jednokierunkowymi: + # rekordu nie dało się już z niego wyjąć (dotyczy też wskrzeszania + # z importu, `pbn_integrator/kosz.py`). + manager = getattr(model, "global_objects", model.objects) + existing = manager.filter( rekord=record.rekord, autor=glowny_autor, typ_odpowiedzialnosci=record.typ_odpowiedzialnosci, @@ -156,6 +184,11 @@ def _transfer_authorship_record( f"z typem odpowiedzialności {record.typ_odpowiedzialnosci}. " f"Usunięto duplikat." ) + # Wiersz duplikatu zostaje przy duplikacie i znika razem z nim + # (`autor_duplikat.delete()` na końcu scalania kaskaduje TWARDO po FK). + # Dla wiersza już skasowanego `delete()` odświeża tylko `deleted_at` — + # to no-op, ale trzymamy jedną ścieżkę zamiast rozgałęziać na coś, + # czego i tak za chwilę nie będzie. record.delete() return False @@ -212,8 +245,11 @@ def _transfer_authorship_record( **log_ctx, ) - # Dodaj do kolejki PBN - if not skip_pbn and record.rekord: + # Dodaj do kolejki PBN — ale NIE rekordu z kosza. Kierunek soft-delete jest + # odwrotny: faza 05 ma oświadczenia z PBN WYCOFYWAĆ, a nie wysyłać tam + # rzeczy, których w BPP „nie ma". Scalanie kolejkowało wszystko, co + # przeniosło, łącznie z rekordami skasowanymi wcześniej przez operatora. + if not skip_pbn and record.rekord and not _w_koszu(record.rekord): content_type = ContentType.objects.get_for_model(record.rekord) PBN_Export_Queue.objects.create( content_type=content_type, @@ -227,22 +263,63 @@ def _transfer_authorship_record( return True +def wiersze_do_transferu(model, autor_duplikat): + """Rekordy duplikatu do przeniesienia — RAZEM Z KOSZEM. + + Scalanie autorów należy do „kategorii B". Gdyby transfer widział tylko + żywe wiersze, autorstwa (i prace) soft-skasowane zostałyby przy autorze + duplikacie — a ten po scaleniu ma zniknąć. Powstałyby SIEROTY: wiersze + w koszu wskazujące na autora, którego już nie ma. W fazie 04 zablokują + dodatkowo guard PROTECT, więc problem ujawniłby się dopiero tam — + w miejscu niezwiązanym z przyczyną. + + ``getattr`` z fallbackiem, bo ta sama funkcja obsługuje modele + soft-delete (publikacje z fazy 02, through-modele z fazy 01) i takie, + które nimi nie są. + """ + manager = getattr(model, "global_objects", model.objects) + return manager.filter(autor=autor_duplikat) + + def _transfer_simple_authorship( - model, model_label, glowny_autor, autor_duplikat, user, skip_pbn, results + model, + model_label, + glowny_autor, + autor_duplikat, + user, + skip_pbn, + results, + opis_konfliktu=None, ): """ Przenosi proste rekordy autorstwa (Praca_Habilitacyjna / Praca_Doktorska), gdzie sam obiekt jest publikacją — przemapowuje autora i kolejkuje do PBN. + + ``opis_konfliktu`` podaje się dla modeli, w których autor może mieć tylko + JEDEN żywy wiersz (habilitacja). Wtedy zderzenie dwóch żywych prac jest + prawdziwym konfliktem danych i kończy scalanie czytelnym komunikatem, + zamiast pozwalać bazie rzucić ``IntegrityError``. """ from pbn_export_queue.models import PBN_Export_Queue - for praca in model.objects.filter(autor=autor_duplikat): + for praca in wiersze_do_transferu(model, autor_duplikat): + # Kolizja dotyczy WYŁĄCZNIE wierszy żywych — warunkowy unique + # (`deleted_at IS NULL`) nie obejmuje kosza, więc praca skasowana + # przechodzi na głównego autora bez przeszkód. + if ( + opis_konfliktu + and getattr(praca, "deleted_at", None) is None + and model.objects.filter(autor=glowny_autor).exists() + ): + raise KonfliktScalania(f"Nie można scalić autorów: {opis_konfliktu}.") + # Przemapuj autora praca.autor = glowny_autor praca.save() - # Dodaj do kolejki PBN - if not skip_pbn: + # Dodaj do kolejki PBN — z pominięciem kosza, jak w + # `_transfer_authorship_record`. + if not skip_pbn and not _w_koszu(praca): content_type = ContentType.objects.get_for_model(praca) PBN_Export_Queue.objects.create( content_type=content_type, @@ -304,10 +381,12 @@ def scal_autora( ("Wydawnictwo_Zwarte_Autor", Wydawnictwo_Zwarte_Autor, False), ("Patent_Autor", Patent_Autor, False), ] - # Proste publikacje (sam obiekt jest publikacją). + # Proste publikacje (sam obiekt jest publikacją). Trzeci element to opis + # konfliktu dla modeli z regułą „jeden żywy wiersz na autora"; ``None`` + # znaczy, że autor może mieć takich prac wiele (doktorat). simple_models = [ - ("Praca_Habilitacyjna", Praca_Habilitacyjna), - ("Praca_Doktorska", Praca_Doktorska), + ("Praca_Habilitacyjna", Praca_Habilitacyjna, "obaj mają pracę habilitacyjną"), + ("Praca_Doktorska", Praca_Doktorska, None), ] try: @@ -337,7 +416,7 @@ def scal_autora( # 1-3. Rekordy autorstwa (ciągłe, zwarte, patenty) for model_label, model, log_publication in authorship_models: - for record in model.objects.filter(autor=autor_duplikat): + for record in wiersze_do_transferu(model, autor_duplikat): _transfer_authorship_record( record, glowny_autor, @@ -352,7 +431,7 @@ def scal_autora( ) # 4-5. Prace doktorskie / habilitacyjne - for model_label, model in simple_models: + for model_label, model, opis_konfliktu in simple_models: _transfer_simple_authorship( model, model_label, @@ -361,12 +440,22 @@ def scal_autora( user, skip_pbn, results, + opis_konfliktu=opis_konfliktu, ) autor_duplikat.delete() return results + except KonfliktScalania as e: + # Sprzeczne dane, nie awaria — operator ma dostać komunikat, którym może + # coś zrobić, a Rollbar nie ma dostać szumu. `transaction.atomic` już + # wycofał częściowe zmiany. + logger.info("Scalanie autorow przerwane konfliktem danych: %s", e) + results["success"] = False + results["error"] = str(e) + return results + except Exception as e: traceback.print_exc() rollbar.report_exc_info(sys.exc_info()) 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. #}
    {% if brak_oswiadczenia_ciagle_count > 0 %} - Wydawnictwa ciągłe w admin {% endif %} {% if brak_oswiadczenia_zwarte_count > 0 %} - Wydawnictwa zwarte w admin diff --git a/src/ewaluacja_optymalizacja/views/verification.py b/src/ewaluacja_optymalizacja/views/verification.py index d948aa03c..ac09e8171 100644 --- a/src/ewaluacja_optymalizacja/views/verification.py +++ b/src/ewaluacja_optymalizacja/views/verification.py @@ -60,11 +60,19 @@ def database_verification_view(request): ).select_related("rekord", "autor", "dyscyplina_naukowa") # Liczba publikacji z rok >= 2022, gdzie autor ma dyscyplinę, ale brak daty oświadczenia + # + # ``autorzy_set__deleted_at__isnull=True`` jest KONIECZNE: filtr przez + # relację buduje JOIN po SUROWEJ tabeli ``*_autor`` i nie pyta managera + # soft-delete. Bez tego licznik obejmowałby publikacje, których jedyne + # „problematyczne" autorstwo zostało skasowane — a przycisk obok + # (changelist admina z tym samym zestawem lookupów) też jest o to + # rozszerzony, żeby liczba i lista się zgadzały. brak_oswiadczenia_ciagle_count = ( Wydawnictwo_Ciagle.objects.filter( rok__gte=2022, autorzy_set__dyscyplina_naukowa__isnull=False, autorzy_set__data_oswiadczenia__isnull=True, + autorzy_set__deleted_at__isnull=True, ) .distinct() .count() @@ -75,6 +83,7 @@ def database_verification_view(request): rok__gte=2022, autorzy_set__dyscyplina_naukowa__isnull=False, autorzy_set__data_oswiadczenia__isnull=True, + autorzy_set__deleted_at__isnull=True, ) .distinct() .count() diff --git a/src/import_common/core/autor.py b/src/import_common/core/autor.py index 7436c942f..b5fdb2e88 100644 --- a/src/import_common/core/autor.py +++ b/src/import_common/core/autor.py @@ -415,10 +415,16 @@ def _publikacji_counts_bulk(pks: list[int]) -> dict[int, int]: Count("zwarte") + Count("patent")`` w jednym querysecie Django robi cross-JOIN i kardynalności się mnożą. Tu mamy 3 round-tripy na cały request, niezależnie od liczby kandydatów. + + ``filter=Q(…__deleted_at__isnull=True)`` jest KONIECZNY: agregat idzie + JOIN-em po surowej tabeli ``*_autor``, a nie przez ``objects`` tych + modeli, więc bez tego predykatu soft-delete autorstwa w ogóle nie + zmniejszałby licznika. Deduplikator pokazuje tę liczbę przy kandydacie + — husk autora wyglądałby na aktywnego. """ from collections import defaultdict - from django.db.models import Count + from django.db.models import Count, Q totals: dict[int, int] = defaultdict(int) for relation in ( @@ -428,7 +434,12 @@ def _publikacji_counts_bulk(pks: list[int]) -> dict[int, int]: ): rows = ( Autor.objects.filter(pk__in=pks) - .annotate(_n=Count(relation)) + .annotate( + _n=Count( + relation, + filter=Q(**{f"{relation}__deleted_at__isnull": True}), + ) + ) .values_list("pk", "_n") ) for pk, n in rows: diff --git a/src/pbn_api/management/commands/pbn_wyslij_oswiadczenia_instytucji.py b/src/pbn_api/management/commands/pbn_wyslij_oswiadczenia_instytucji.py index 3832da9d3..302396f8a 100644 --- a/src/pbn_api/management/commands/pbn_wyslij_oswiadczenia_instytucji.py +++ b/src/pbn_api/management/commands/pbn_wyslij_oswiadczenia_instytucji.py @@ -443,10 +443,16 @@ def _get_single_publication(self, publication_id): def _get_publications_by_year(self, year): """Get all publications (Wydawnictwo_Zwarte and Wydawnictwo_Ciagle) by year.""" + # ``autorzy_set__deleted_at__isnull=True``: lookup przez relację to + # JOIN po SUROWEJ tabeli ``*_autor`` — manager soft-delete nie jest + # pytany. Bez tego predykatu do wysyłki oświadczeń trafiałaby + # publikacja, której jedyne autorstwo z dyscypliną zostało + # skasowane (a więc bez czego wysłać). filter_kw = dict( rok=year, pbn_uid_id__isnull=False, autorzy_set__dyscyplina_naukowa__isnull=False, + autorzy_set__deleted_at__isnull=True, ) # Get Wydawnictwo_Zwarte publications zwarte_qs = Wydawnictwo_Zwarte.objects.filter(**filter_kw).select_related( diff --git a/src/pbn_api/tests/test_admin_rekord_w_bpp.py b/src/pbn_api/tests/test_admin_rekord_w_bpp.py new file mode 100644 index 000000000..4cb617d4a --- /dev/null +++ b/src/pbn_api/tests/test_admin_rekord_w_bpp.py @@ -0,0 +1,49 @@ +"""Kolumna „rekord w BPP" na changeliście publikacji PBN. + +Ta metoda admina nie miała ŻADNEGO pokrycia, a woła `.original` — atrybut +istniejący wyłącznie na `Rekord` (`bpp/models/cache/rekord.py`). Pierwsza +wersja fazy 03 zmieniła `rekord_w_bpp` tak, że zwracał model konkretny, +i wywalała `AttributeError` na CAŁEJ changeliście (~20 tys. rekordów). +Wyszło dopiero w recenzji. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle + + +@pytest.mark.django_db +def test_changelist_publikacji_dziala_gdy_jest_powiazanie_z_bpp(admin_client): + """Changelista nie może się wywalić, gdy publikacja PBN ma odpowiednik. + + To jest test na TYP zwracany przez `rekord_w_bpp`: admin buduje z niego + link przez `.original`, więc każda zmiana typu musi tu zapalić czerwone. + """ + from pbn_api.models import Publication + + publication = baker.make(Publication) + baker.make(Wydawnictwo_Ciagle, pbn_uid=publication, rok=2020) + + res = admin_client.get("/admin/pbn_api/publication/") + + assert res.status_code == 200 + + +@pytest.mark.django_db +def test_changelist_publikacji_dziala_gdy_odpowiednik_w_koszu(admin_client): + """Po soft-delete odpowiednika changelista dalej działa. + + Rekordu wtedy NIE MA (`Rekord` jest odfiltrowany po `deleted_at`), więc + kolumna ma pokazać link do importu zamiast linku do rekordu — a nie + wywalić stronę. + """ + from pbn_api.models import Publication + + publication = baker.make(Publication) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication, rok=2020) + wc.delete() + + res = admin_client.get("/admin/pbn_api/publication/") + + assert res.status_code == 200 diff --git a/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py b/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py index 2811d6989..2eb6a6be0 100644 --- a/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py +++ b/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py @@ -45,7 +45,7 @@ def _policz(sql, *params): @pytest.mark.django_db(transaction=True) -def test_migracja_przechodzi_na_bazie_z_duplikatami(): +def test_migracja_przechodzi_na_bazie_z_duplikatami(bez_reinstalacji_denorma): uuid_slownika, uuid_dyscypliny = uuid4(), uuid4() MigrationExecutor(connection).migrate([PRZED]) diff --git a/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py b/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py index b3f5a111f..6d747c83e 100644 --- a/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py +++ b/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py @@ -29,7 +29,7 @@ def _policz(sql, *params): @pytest.mark.django_db(transaction=True) -def test_migracja_przechodzi_na_bazie_z_duplikatami(): +def test_migracja_przechodzi_na_bazie_z_duplikatami(bez_reinstalacji_denorma): # cofnij się PRZED constraint — dopiero wtedy baza wpuści duplikaty MigrationExecutor(connection).migrate([PRZED]) diff --git a/src/pbn_export_queue/models.py b/src/pbn_export_queue/models.py index 3fdd17eea..e5f2fc77f 100644 --- a/src/pbn_export_queue/models.py +++ b/src/pbn_export_queue/models.py @@ -177,11 +177,21 @@ def check_if_record_still_exists(self): return False try: - if self.content_type.get_object_for_this_type(pk=self.object_id): - return True - + obiekt = self.content_type.get_object_for_this_type(pk=self.object_id) except ObjectDoesNotExist: return False + + # ⚠️ `get_object_for_this_type` pyta `_base_manager`, który z + # definicji NIE filtruje (Django wymaga, żeby zwracał wszystkie + # wiersze). Rekord soft-skasowany jest więc tą drogą nadal + # znajdowany, mimo że `objects` go nie pokazuje. Dla kolejki PBN + # „w koszu" ma znaczyć „nie ma go" — inaczej wysyłalibyśmy do PBN + # publikację, którą operator usunął. + if getattr(obiekt, "deleted_at", None) is not None: + return False + + if obiekt: + return True except self.content_type.model_class().DoesNotExist: return False diff --git a/src/pbn_import/utils/publication_import.py b/src/pbn_import/utils/publication_import.py index 8e256f820..53038b03a 100644 --- a/src/pbn_import/utils/publication_import.py +++ b/src/pbn_import/utils/publication_import.py @@ -130,8 +130,23 @@ def _delete_existing_publications(self, current_step, total_steps): ) self.log("warning", "Usuwanie istniejących publikacji PBN") - deleted_zwarte = Wydawnictwo_Zwarte.objects.exclude(pbn_uid_id=None).delete()[0] - deleted_ciagle = Wydawnictwo_Ciagle.objects.exclude(pbn_uid_id=None).delete()[0] + # ⚠️ `global_objects` ORAZ `hard_delete()` — jedno i drugie konieczne. + # + # `hard_delete()`, bo po fazie 02 `QuerySet.delete()` na modelu + # soft-delete jest MIĘKKIE: publikacje zostałyby w bazie, a ten krok + # ma dać czysty stan przed pełnym re-importem z PBN. + # + # `global_objects`, bo `objects` nie widzi kosza: publikacje PBN + # skasowane wcześniej przez operatora przetrwałyby „usuwanie + # istniejących", a potem re-import by je wskrzesił (patrz + # `przywroc_jesli_w_koszu`) — operator zobaczyłby z powrotem rekordy, + # które usunął, i to bez śladu, że przeżyły czyszczenie. + deleted_zwarte = Wydawnictwo_Zwarte.global_objects.exclude( + pbn_uid_id=None + ).hard_delete()[0] + deleted_ciagle = Wydawnictwo_Ciagle.global_objects.exclude( + pbn_uid_id=None + ).hard_delete()[0] self.log( "info", diff --git a/src/pbn_integrator/admin.py b/src/pbn_integrator/admin.py index 846f6b406..29bf8e63e 100644 --- a/src/pbn_integrator/admin.py +++ b/src/pbn_integrator/admin.py @@ -1 +1,50 @@ -# Register your models here. +"""Admin `pbn_integrator` — na razie wyłącznie rejestr wskrzeszeń. + +Rejestr osiągalny tylko przez ``manage.py shell`` nie spełniałby swojej roli: +to jego dostępność dla operatora była całym uzasadnieniem zmiany decyzji #14 +planu fazy 03 („POMIŃ + ZARAPORTUJ" → „PRZYWRÓĆ + ODNOTUJ"). Kontekst: +docstring ``RekordPrzywroconyPrzezImport``. +""" + +from django.contrib import admin + +from .models import RekordPrzywroconyPrzezImport + + +@admin.register(RekordPrzywroconyPrzezImport) +class RekordPrzywroconyPrzezImportAdmin(admin.ModelAdmin): + """Ślad audytowy — TYLKO DO ODCZYTU. + + Wpisy powstają wyłącznie w ``pbn_integrator/kosz.py``, na ścieżce importu. + Rejestr, w którym operator może dopisać, zmienić albo skasować wpis, + przestaje być dowodem — a dowodem miał być, bo to on równoważy ryzyko + auto-restore'u wskazane w pierwotnej decyzji #14. Stąd trzy `False` niżej, + a nie samo `readonly_fields` (to ostatnie chowa pola, ale wciąż pozwala + dodać i skasować wiersz). + """ + + list_display = ( + "rekord", + "przywrocono", + "zrodlo_importu", + "pbn_uid", + ) + list_filter = ("zrodlo_importu", "content_type", "przywrocono") + # `object_id` w wyszukiwarce, bo operator idzie tu zwykle od konkretnej + # publikacji („czy TEN rekord wrócił?") — pod to jest indeks + # `pbnint_przywr_ct_objid_idx`. + search_fields = ("object_id", "pbn_uid__pk") + date_hierarchy = "przywrocono" + # `rekord` to GenericForeignKey — nie da się go `select_related`, więc + # changelista i tak dobija po jednym zapytaniu na wiersz przy renderowaniu + # `__str__`. `content_type` jest zwykłym FK i tyle da się tanio uprzedzić. + list_select_related = ("content_type", "pbn_uid") + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/src/pbn_integrator/importer/articles.py b/src/pbn_integrator/importer/articles.py index 987813fbe..a8b6fc7d9 100644 --- a/src/pbn_integrator/importer/articles.py +++ b/src/pbn_integrator/importer/articles.py @@ -15,6 +15,7 @@ from bpp.util import safe_tytul_html from pbn_api.client import PBNClient from pbn_api.models import Publication +from pbn_integrator.kosz import znajdz_lub_wskrzes_rekord from .authors import utworz_autorow from .cache import ( @@ -66,9 +67,15 @@ def importuj_artykul( except Publication.DoesNotExist as err: raise NotImplementedError(f"Publikacja {mongoId=} nie istnieje") from err - ret = pbn_publication.rekord_w_bpp - if ret is not None and not force: - return ret + if not force: + # Rekord moze juz byc w BPP: zywy (wtedy ``Rekord``) albo w koszu -- + # wtedy wraca, bo PBN jest zrodlem prawdy, a fakt laduje w rejestrze + # ``RekordPrzywroconyPrzezImport`` (zmiana wobec decyzji #14 planu). + istniejacy = znajdz_lub_wskrzes_rekord( + pbn_publication, Wydawnictwo_Ciagle, "articles" + ) + if istniejacy is not None: + return istniejacy pbn_json = pbn_publication.current_version["object"] orig_pbn_json = copy.deepcopy(pbn_json) # noqa diff --git a/src/pbn_integrator/importer/books.py b/src/pbn_integrator/importer/books.py index 5ecdc4e92..6695bf437 100644 --- a/src/pbn_integrator/importer/books.py +++ b/src/pbn_integrator/importer/books.py @@ -13,6 +13,7 @@ ) from bpp.util import safe_tytul_html from pbn_api.client import PBNClient +from pbn_integrator.kosz import znajdz_lub_wskrzes_rekord from .authors import utworz_autorow from .cache import ( @@ -64,10 +65,16 @@ def importuj_ksiazke( ) return None - ret = pbn_publication.rekord_w_bpp - - if ret is not None and not force: - return ret + if not force: + # Kosz jest niewidoczny dla matchingu (``Rekord`` filtruje po + # ``deleted_at``), ale widoczny dla unique na ``pbn_uid``. Bez zajrzenia + # tam re-import ksiazki z kosza wywala sie ``IntegrityError``-em. + # Szczegoly: docstring ``znajdz_lub_wskrzes_rekord``. + istniejacy = znajdz_lub_wskrzes_rekord( + pbn_publication, Wydawnictwo_Zwarte, "books" + ) + if istniejacy is not None: + return istniejacy pbn_json = pbn_publication.current_version["object"] orig_pbn_json = copy.deepcopy(pbn_json) # noqa diff --git a/src/pbn_integrator/importer/chapters.py b/src/pbn_integrator/importer/chapters.py index d9f657470..aed26cf46 100644 --- a/src/pbn_integrator/importer/chapters.py +++ b/src/pbn_integrator/importer/chapters.py @@ -16,6 +16,7 @@ from bpp.util import safe_tytul_html from pbn_api.client import PBNClient from pbn_api.models import Publication +from pbn_integrator.kosz import przywroc_jesli_w_koszu, znajdz_lub_wskrzes_rekord from .authors import utworz_autorow from .books import importuj_ksiazke @@ -37,6 +38,28 @@ logger = logging.getLogger(__name__) +def znajdz_ksiazke_nadrzedna(pbn_book_id): + """Książka-matka rozdziału, po ``pbn_uid`` — RAZEM Z KOSZEM. + + Wydzielone z ``importuj_rozdzial`` po to, żeby dało się to przetestować + bez budowania całego JSON-a z PBN. + + Bez widzenia kosza re-import rozdziału, którego książka-matka została + skasowana, uznawał ją za nieistniejącą i importował PONOWNIE — tworząc + duplikat książki, a przy okazji odpinając rozdziały od oryginału. + + Trafienie w kosz oznacza wskrzeszenie (decyzja właściciela, 2026-08-08): + skoro PBN nadal ma tę książkę, ma ją mieć też BPP. Fakt ląduje + w rejestrze ``RekordPrzywroconyPrzezImport``. + """ + ksiazka = Wydawnictwo_Zwarte.global_objects.filter(pbn_uid_id=pbn_book_id).first() + if ksiazka is None: + return None + + przywroc_jesli_w_koszu(ksiazka, ksiazka.pbn_uid, "chapters:ksiazka-nadrzedna") + return ksiazka + + def _chapter_json_z_nadrzednego(wydawnictwo_nadrzedne, mongoId): """Zwraca sub-słownik rozdziału z ``chapters`` wyd. nadrzędnego albo ``{}``. @@ -87,17 +110,21 @@ def importuj_rozdzial( except Publication.DoesNotExist as err: raise NotImplementedError(f"Publikacja {mongoId=} nie istnieje") from err - ret = pbn_publication.rekord_w_bpp - - if ret is not None and not force: - return ret + if not force: + # Jak w ksiazkach: kosz jest niewidoczny dla matchingu, ale widoczny dla + # unique na ``pbn_uid``. Szczegoly: docstring + # ``znajdz_lub_wskrzes_rekord``. + istniejacy = znajdz_lub_wskrzes_rekord( + pbn_publication, Wydawnictwo_Zwarte, "chapters" + ) + if istniejacy is not None: + return istniejacy pbn_json = pbn_publication.current_version["object"] orig_pbn_json = copy.deepcopy(pbn_json) # noqa pbn_book_id = pbn_json.pop("book")["id"] - try: - wydawnictwo_nadrzedne = Wydawnictwo_Zwarte.objects.get(pbn_uid_id=pbn_book_id) - except Wydawnictwo_Zwarte.DoesNotExist: + wydawnictwo_nadrzedne = znajdz_ksiazke_nadrzedna(pbn_book_id) + if wydawnictwo_nadrzedne is None: wydawnictwo_nadrzedne = importuj_ksiazke( pbn_book_id, default_jednostka, diff --git a/src/pbn_integrator/kosz.py b/src/pbn_integrator/kosz.py new file mode 100644 index 000000000..ca9eef8f6 --- /dev/null +++ b/src/pbn_integrator/kosz.py @@ -0,0 +1,110 @@ +"""Obsługa trafienia importu w rekord soft-skasowany („w koszu"). + +Kontekst decyzyjny jest w docstringu ``RekordPrzywroconyPrzezImport``. +""" + +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db import transaction + +from pbn_integrator.models import RekordPrzywroconyPrzezImport + +logger = logging.getLogger(__name__) + + +def przywroc_jesli_w_koszu(rekord, pbn_publication, zrodlo_importu): + """Wskrzesza ``rekord``, jeśli jest w koszu, i odnotowuje to w rejestrze. + + Zwraca ``True`` tylko przy REALNYM wskrzeszeniu — dzięki temu wołający + może to policzyć, a rejestr nie zapełnia się zwykłymi re-importami. + + ⚠️ ``rekord`` bywa czymś innym niż instancją modelu. ``rekord_w_bpp`` + zwraca STRING ze sklejonymi tytułami, gdy po ``pbn_uid`` trafi więcej niż + jedna publikacja (zachowanie historyczne, utrzymane w Tasku 2), albo + ``None``, gdy nie ma nic. Ten helper stoi na ścieżce importu, więc musi to + przyjąć bez wyjątku — inaczej wywróciłby cały przebieg z powodu, który + z koszem nie ma nic wspólnego. Stąd ``getattr`` zamiast ``isinstance`` + i wczesny zwrot. + """ + if getattr(rekord, "deleted_at", None) is None: + return False + + with transaction.atomic(): + # `restore()` mixinu fazy 02 podnosi też autorstwa skasowane tym samym + # `transaction_id` — czyli dokładnie te, które zniknęły RAZEM z tą + # publikacją. Autorstwa skasowane wcześniej, osobną decyzją operatora, + # zostają w koszu. + rekord.restore() + + RekordPrzywroconyPrzezImport.objects.create( + content_type=ContentType.objects.get_for_model(type(rekord)), + object_id=rekord.pk, + pbn_uid=pbn_publication, + zrodlo_importu=zrodlo_importu, + ) + + logger.warning( + "Import (%s) wskrzesil rekord z kosza: %r (pk=%s, pbn_uid=%s). " + "Wpis w rejestrze RekordPrzywroconyPrzezImport.", + zrodlo_importu, + rekord, + rekord.pk, + getattr(pbn_publication, "pk", None), + ) + return True + + +def wskrzes_z_kosza_po_pbn_uid(model, pbn_publication, zrodlo_importu): + """Szuka w koszu rekordu ``model`` po ``pbn_uid`` i wskrzesza go. + + Zwraca wskrzeszony rekord albo ``None``, gdy w koszu nic nie ma. + + To jest JAWNE zajrzenie do kosza po stronie importera. Akcesory + (``rekord_w_bpp``, ``get_bpp_publication``, ``matchuj_publikacje``) tego nie + robią i robić nie będą — idą po ``Rekord``, czyli po widoku odfiltrowanym + po ``deleted_at``. Skoro soft-delete znaczy „rekordu nie ma", to decyzja + „a jednak zajrzyj do kosza" należy do wołającego, nie do akcesora. + + Bez tego re-import publikacji, której rekord BPP jest w koszu, wchodzi + w gałąź „utwórz nowy" i wywala się ``IntegrityError`` — ``pbn_uid`` to + ``OneToOneField(unique=True)`` BEZ warunku partial, więc rekord w koszu + nadal to pole trzyma. Kosz jest jednocześnie niewidoczny dla matchingu + i widoczny dla bazy. + + ⚠️ ``model`` musi mieć pole ``pbn_uid`` (mixin ``ModelZPBN_UID``). ``Patent`` + go NIE ma — ``.filter(pbn_uid_id=…)`` na nim wywala ``FieldError``, bo + Django resolwuje nazwy pól natychmiast. Stąd model jest parametrem, a nie + pętlą po wszystkich modelach publikacji. + """ + rekord = model.deleted_objects.filter(pbn_uid_id=pbn_publication.pk).first() + if rekord is None: + return None + + przywroc_jesli_w_koszu(rekord, pbn_publication, zrodlo_importu) + return rekord + + +def znajdz_lub_wskrzes_rekord(pbn_publication, model, zrodlo_importu): + """Rekord tej publikacji, który JUŻ jest w BPP — żywy albo wyjęty z kosza. + + ``None`` znaczy „nie ma go nigdzie" — dopiero wtedy importer tworzy nowy. + + Preambuła wspólna dla trzech importerów (artykuł / książka / rozdział). + Wydzielona, bo różnią się wyłącznie modelem i etykietą źródła, a rozjazd + między nimi już raz kosztował: wskrzeszanie siedziało w gałęzi + ``ret is not None``, gdzie z definicji było no-opem (``Rekord`` nie ma + ``deleted_at``). + + ⚠️ Zwraca trzy różne rzeczy i wołający musi to znieść: ``Rekord`` (widok + cache) dla trafienia żywego, instancję modelu konkretnego dla wskrzeszenia + z kosza, oraz STRING ze sklejonymi tytułami, gdy po ``pbn_uid`` trafi więcej + niż jedna publikacja (zachowanie historyczne ``rekord_w_bpp``). Admin + rozróżnia je przez ``isinstance(..., Rekord)`` przed sięgnięciem po + ``.original``. + """ + ret = pbn_publication.rekord_w_bpp + if ret is not None: + return ret + + return wskrzes_z_kosza_po_pbn_uid(model, pbn_publication, zrodlo_importu) diff --git a/src/pbn_integrator/migrations/0001_initial.py b/src/pbn_integrator/migrations/0001_initial.py new file mode 100644 index 000000000..6172ab58b --- /dev/null +++ b/src/pbn_integrator/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.16 on 2026-08-07 23:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('pbn_api', '0079_constraint_publikacja_instytucji'), + ] + + operations = [ + migrations.CreateModel( + name='RekordPrzywroconyPrzezImport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('przywrocono', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Kiedy import wskrzesił ten rekord.', verbose_name='Przywrócono')), + ('zrodlo_importu', models.CharField(help_text='Która ścieżka importu wskrzesiła rekord (articles / books / chapters).', max_length=50, verbose_name='Źródło importu')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('pbn_uid', models.ForeignKey(blank=True, help_text='Rekord PBN, przez który import trafił na tę publikację.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='pbn_api.publication', verbose_name='Publikacja w PBN')), + ], + options={ + 'verbose_name': 'rekord przywrócony przez import', + 'verbose_name_plural': 'rekordy przywrócone przez import', + 'ordering': ('-przywrocono',), + 'indexes': [models.Index(fields=['-przywrocono', 'content_type'], name='pbnint_przywr_data_ct_idx')], + }, + ), + ] diff --git a/src/pbn_integrator/migrations/0002_indeks_content_type_object_id.py b/src/pbn_integrator/migrations/0002_indeks_content_type_object_id.py new file mode 100644 index 000000000..452acd234 --- /dev/null +++ b/src/pbn_integrator/migrations/0002_indeks_content_type_object_id.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.16 on 2026-08-09 08:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("pbn_api", "0079_constraint_publikacja_instytucji"), + ("pbn_integrator", "0001_initial"), + ] + + operations = [ + migrations.AddIndex( + model_name="rekordprzywroconyprzezimport", + index=models.Index( + fields=["content_type", "object_id"], name="pbnint_przywr_ct_objid_idx" + ), + ), + ] diff --git a/src/pbn_integrator/models.py b/src/pbn_integrator/models.py index 6b2021999..58821ad90 100644 --- a/src/pbn_integrator/models.py +++ b/src/pbn_integrator/models.py @@ -1 +1,76 @@ -# Create your models here. +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class RekordPrzywroconyPrzezImport(models.Model): + """Rejestr publikacji WSKRZESZONYCH z kosza przez import z PBN. + + PO CO TO ISTNIEJE. Plan fazy 03 zakładał pierwotnie, że import ma rekord + w koszu POMIJAĆ (decyzja #14), bo auto-restore pozwala „wskrzeszać rzeczy + skasowane celowo". Właściciel systemu rozstrzygnął inaczej — PBN jest dla + tych publikacji źródłem prawdy, więc skoro rekord tam jest, ma wrócić + także do BPP. + + Ryzyko wskazane w tamtej decyzji nie znika, tylko zmienia postać: + z „nie róbmy tego" na „róbmy, ale zostawmy ślad". Ten model JEST tym + śladem. Operator, który znajdzie w bazie publikację skasowaną przez siebie + tydzień wcześniej, ma gdzie sprawdzić, że wróciła z importu, kiedy + i z którego źródła — zamiast zakładać, że coś mu się przywidziało. + + Wpis powstaje WYŁĄCZNIE przy realnym wskrzeszeniu. Zwykły re-import + żywego rekordu nie zostawia tu nic — inaczej rejestr zapełniłby się szumem + i przestałby cokolwiek znaczyć. + """ + + content_type = models.ForeignKey(ContentType, models.CASCADE) + object_id = models.PositiveIntegerField() + #: Publikacja, która wróciła z kosza. GenericFK, bo import dotyka kilku + #: modeli (wydawnictwa ciągłe, zwarte, prace doktorskie/habilitacyjne). + rekord = GenericForeignKey("content_type", "object_id") + + przywrocono = models.DateTimeField( + "Przywrócono", + auto_now_add=True, + db_index=True, + help_text="Kiedy import wskrzesił ten rekord.", + ) + pbn_uid = models.ForeignKey( + "pbn_api.Publication", + models.SET_NULL, + null=True, + blank=True, + verbose_name="Publikacja w PBN", + help_text="Rekord PBN, przez który import trafił na tę publikację.", + ) + zrodlo_importu = models.CharField( + "Źródło importu", + max_length=50, + help_text="Która ścieżka importu wskrzesiła rekord (articles / books / " + "chapters).", + ) + + class Meta: + verbose_name = "rekord przywrócony przez import" + verbose_name_plural = "rekordy przywrócone przez import" + ordering = ("-przywrocono",) + indexes = [ + # Najczęstsze pytanie operatora brzmi „co wróciło ostatnio" — + # stąd indeks po dacie malejąco razem z typem rekordu. + models.Index( + fields=["-przywrocono", "content_type"], + name="pbnint_przywr_data_ct_idx", + ), + # Drugie pytanie, i jedyne zadawane WPROST o konkretną publikację: + # „czy TEN rekord wrócił?". Indeks po dacie go nie obsługuje, a sam + # `content_type_id` (indeks domyślny FK) zawęża do typu, nie do + # wiersza — przy jednym typie publikacji to praktycznie skan całej + # tabeli, która rośnie z każdym importem. + models.Index( + fields=["content_type", "object_id"], + name="pbnint_przywr_ct_objid_idx", + ), + ] + + def __str__(self): + return f"{self.rekord} (przywrócono: {self.przywrocono})" diff --git a/src/pbn_integrator/tests/test_importer_wskrzesza_z_kosza.py b/src/pbn_integrator/tests/test_importer_wskrzesza_z_kosza.py new file mode 100644 index 000000000..1e02f87be --- /dev/null +++ b/src/pbn_integrator/tests/test_importer_wskrzesza_z_kosza.py @@ -0,0 +1,167 @@ +"""Import trafiający w rekord w koszu wskrzesza go, zamiast tworzyć duplikat. + +Kontekst (faza 03 soft-delete, pozycja 3.1 handoffu): + +Akcesory (``rekord_w_bpp``, ``get_bpp_publication``, ``matchuj_publikacje``) +NIE zaglądają do kosza — decyzja właściciela: „soft-delete znaczy, że rekordu +nie ma". Wszystkie idą po ``Rekord``, czyli po widoku odfiltrowanym po +``deleted_at``. + +Skutkiem jest asymetria, która sama w sobie jest błędem importu: + +- dla matchingu rekord w koszu **nie istnieje** (``rekord_w_bpp`` → ``None``), +- ale ``pbn_uid`` to ``OneToOneField(unique=True)`` **bez** warunku partial, + więc rekord w koszu **wciąż trzyma** to pole. + +Import wchodzi więc w gałąź „utwórz nowy rekord" i wywala się ``IntegrityError`` +na unique ``pbn_uid_id``. Zaglądanie do kosza jest decyzją IMPORTERA (a nie +akcesora), i należy do gałęzi ``ret is None``. + +Wariant A (decyzja właściciela): PBN jest źródłem prawdy — skoro publikacja +tam nadal jest, rekord wraca do BPP, a fakt ląduje w rejestrze +``RekordPrzywroconyPrzezImport``. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Zwarte +from pbn_api.models import Publication +from pbn_integrator.importer.articles import importuj_artykul +from pbn_integrator.importer.books import importuj_ksiazke +from pbn_integrator.importer.chapters import importuj_rozdzial +from pbn_integrator.models import RekordPrzywroconyPrzezImport + +ARTICLE_OBJECT = { + "type": "ARTICLE", + "title": "Artykul, ktory wrocil z kosza", + "year": 2023, +} + +BOOK_OBJECT = { + "type": "BOOK", + "title": "Ksiazka, ktora wrocila z kosza", + "year": 2023, +} + +CHAPTER_OBJECT = { + "type": "CHAPTER", + "title": "Rozdzial, ktory wrocil z kosza", + "year": 2023, + "book": {"id": "nieuzywany-bo-wracamy-wczesniej"}, +} + + +def _publikacja(mongo_id, obiekt): + return baker.make( + Publication, + mongoId=mongo_id, + versions=[{"current": True, "object": dict(obiekt)}], + status="ACTIVE", + ) + + +def _sprawdz_wskrzeszenie(zwrocony, rekord, zrodlo_importu, model): + """Wspólne asercje: ten sam wiersz wrócił, bez duplikatu, ze śladem.""" + assert zwrocony is not None, "import nie zwrocil nic — rekord z kosza zgubiony" + assert zwrocony.pk == rekord.pk, ( + "import utworzyl NOWY rekord zamiast wskrzesic ten z kosza" + ) + assert model.global_objects.count() == 1, "w bazie powstal duplikat" + + rekord.refresh_from_db() + assert rekord.deleted_at is None, "rekord nie zostal wskrzeszony" + + wpis = RekordPrzywroconyPrzezImport.objects.get() + assert wpis.object_id == rekord.pk + assert wpis.zrodlo_importu == zrodlo_importu + + +@pytest.mark.django_db +def test_import_artykulu_wskrzesza_rekord_z_kosza(): + publication = _publikacja("a-kosz", ARTICLE_OBJECT) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + wc.delete() + + assert publication.rekord_w_bpp is None, ( + "zalozenie testu: matching NIE widzi kosza (akcesory po Rekord)" + ) + + # client=None jest bezpieczne: gałąź wskrzeszenia zwraca zanim cokolwiek + # sięgnie po PBN. + zwrocony = importuj_artykul("a-kosz", default_jednostka=None, client=None) + + _sprawdz_wskrzeszenie(zwrocony, wc, "articles", Wydawnictwo_Ciagle) + + +@pytest.mark.django_db +def test_import_ksiazki_wskrzesza_rekord_z_kosza(): + publication = _publikacja("b-kosz", BOOK_OBJECT) + wz = baker.make(Wydawnictwo_Zwarte, pbn_uid=publication) + wz.delete() + + zwrocony = importuj_ksiazke("b-kosz", default_jednostka=None, client=None) + + _sprawdz_wskrzeszenie(zwrocony, wz, "books", Wydawnictwo_Zwarte) + + +@pytest.mark.django_db +def test_import_rozdzialu_wskrzesza_rekord_z_kosza(): + publication = _publikacja("c-kosz", CHAPTER_OBJECT) + wz = baker.make(Wydawnictwo_Zwarte, pbn_uid=publication) + wz.delete() + + zwrocony = importuj_rozdzial("c-kosz", default_jednostka=None, client=None) + + _sprawdz_wskrzeszenie(zwrocony, wz, "chapters", Wydawnictwo_Zwarte) + + +@pytest.mark.django_db +def test_zywy_rekord_wraca_jako_rekord_i_nie_trafia_do_rejestru(): + """Kontrola: ścieżka bez kosza ma zostać nietknięta. + + Trafienie w ŻYWY rekord nadal zwraca obiekt ``Rekord`` (kontrakt, na którym + stoi admin: ``isinstance(created_record, Rekord)`` → ``.original``), a + rejestr wskrzeszeń zostaje pusty. + """ + from bpp.models import Rekord + + publication = _publikacja("a-zywy", ARTICLE_OBJECT) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + + zwrocony = importuj_artykul("a-zywy", default_jednostka=None, client=None) + + assert isinstance(zwrocony, Rekord) + assert zwrocony.pbn_uid_id == publication.pk + assert zwrocony.original.pk == wc.pk + assert not RekordPrzywroconyPrzezImport.objects.exists() + + +@pytest.mark.django_db +def test_pusty_kosz_idzie_dalej_do_tworzenia_nowego_rekordu( + jezyki, charaktery_formalne, typy_kbn, statusy_korekt, typy_odpowiedzialnosci +): + """Kontrola negatywna: pusty kosz nie może udawać „wskrzeszenia". + + Bez tej asercji implementacja mogłaby zatrzymać sterowanie na gałęzi kosza + (np. zwracać coś prawdziwego, gdy nic nie znalazła) i test wskrzeszenia + nadal by przechodził — a import przestałby cokolwiek importować. + + Ten jeden test przechodzi CAŁĄ ścieżkę tworzenia, więc potrzebuje słowników. + Bierzemy je z fixture'ów zamiast z baseline, bo testy transakcyjne z tego + samego przebiegu potrafią wyczyścić dane referencyjne. + """ + from bpp.models import Rodzaj_Zrodla + + # Artykuł bez ``journal`` dostaje źródło zastępcze o tym rodzaju. + Rodzaj_Zrodla.objects.get_or_create(nazwa="źródło nieindeksowane") + + _publikacja("a-brak", ARTICLE_OBJECT) + + zwrocony = importuj_artykul("a-brak", default_jednostka=None, client=None) + + assert isinstance(zwrocony, Wydawnictwo_Ciagle), ( + "import nie utworzyl nowego rekordu — sterowanie utknelo na koszu" + ) + assert zwrocony.pbn_uid_id == "a-brak" + assert not RekordPrzywroconyPrzezImport.objects.exists() diff --git a/src/pbn_integrator/tests/test_przywracanie_z_kosza.py b/src/pbn_integrator/tests/test_przywracanie_z_kosza.py new file mode 100644 index 000000000..fe4bd5b24 --- /dev/null +++ b/src/pbn_integrator/tests/test_przywracanie_z_kosza.py @@ -0,0 +1,121 @@ +"""Import z PBN wskrzesza publikacje trafione w koszu i to odnotowuje. + +DECYZJA WŁAŚCICIELA (2026-08-08) — ZMIANA wobec decyzji #14 z planu fazy 03. + +Plan zakładał „POMIŃ + ZARAPORTUJ", argumentując, że auto-restore pozwoliłby +importowi wskrzeszać rzeczy skasowane celowo. Właściciel systemu rozstrzygnął +inaczej: **PBN jest źródłem prawdy** dla tych publikacji, więc skoro rekord +nadal tam jest, ma wrócić także do BPP. + +Ryzyko, na które wskazywała pierwotna decyzja, nie znika — zostaje tylko +przeniesione z „nie róbmy tego" na „róbmy, ale zostaw ślad". Stąd rejestr +``RekordPrzywroconyPrzezImport``: operator, który znajdzie w bazie rekord +skasowany przez siebie tydzień temu, ma gdzie sprawdzić, że wrócił z importu, +kiedy i z którego źródła. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle + + +@pytest.mark.django_db +def test_przywraca_rekord_z_kosza_i_zapisuje_w_rejestrze(): + from pbn_api.models import Publication + from pbn_integrator.kosz import przywroc_jesli_w_koszu + from pbn_integrator.models import RekordPrzywroconyPrzezImport + + publication = baker.make(Publication) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + wc.delete() + + assert Wydawnictwo_Ciagle.objects.filter(pk=wc.pk).count() == 0 + + przywrocono = przywroc_jesli_w_koszu(wc, publication, "articles") + + assert przywrocono is True + assert Wydawnictwo_Ciagle.objects.filter(pk=wc.pk).count() == 1, ( + "rekord nie wrocil z kosza" + ) + + wpis = RekordPrzywroconyPrzezImport.objects.get() + assert wpis.rekord == wc + assert wpis.pbn_uid_id == publication.pk + assert wpis.zrodlo_importu == "articles" + assert wpis.przywrocono is not None + + +@pytest.mark.django_db +def test_zywy_rekord_nie_trafia_do_rejestru(): + """Wpis w rejestrze ma oznaczać REALNE wskrzeszenie. + + Bez tej asercji rejestr zapełniłby się każdym zwykłym re-importem + i przestałby cokolwiek znaczyć — a jego jedyną wartością jest to, że + operator może mu zaufać. + """ + from pbn_api.models import Publication + from pbn_integrator.kosz import przywroc_jesli_w_koszu + from pbn_integrator.models import RekordPrzywroconyPrzezImport + + publication = baker.make(Publication) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + + assert przywroc_jesli_w_koszu(wc, publication, "articles") is False + assert not RekordPrzywroconyPrzezImport.objects.exists() + + +@pytest.mark.django_db +def test_niemodelowy_wynik_matchingu_nie_wywala_helpera(): + """``rekord_w_bpp`` bywa STRINGIEM. + + Przy wielu trafieniach po ``pbn_uid`` zwraca sklejone tytuły (zachowanie + historyczne, utrzymane w Tasku 2). Helper stoi na ścieżce importu, więc + musi to przyjąć bez wyjątku — inaczej wywróciłby cały przebieg z powodu, + który z koszem nie ma nic wspólnego. + """ + from pbn_api.models import Publication + from pbn_integrator.kosz import przywroc_jesli_w_koszu + + publication = baker.make(Publication) + + assert przywroc_jesli_w_koszu("Tytul A ;; Tytul B", publication, "books") is False + assert przywroc_jesli_w_koszu(None, publication, "books") is False + + +@pytest.mark.django_db +def test_ksiazka_nadrzedna_w_koszu_jest_znajdowana_i_wskrzeszana(): + """Re-import rozdziału nie może zgubić skasowanej książki-matki. + + Bez widzenia kosza importer uznawał ją za nieistniejącą i importował + PONOWNIE — powstawał duplikat książki, a rozdziały odpinały się od + oryginału. + """ + from bpp.models import Wydawnictwo_Zwarte + from pbn_api.models import Publication + from pbn_integrator.importer.chapters import znajdz_ksiazke_nadrzedna + from pbn_integrator.models import RekordPrzywroconyPrzezImport + + pub_ksiazki = baker.make(Publication) + ksiazka = baker.make(Wydawnictwo_Zwarte, pbn_uid=pub_ksiazki) + ksiazka.delete() + + znaleziona = znajdz_ksiazke_nadrzedna(pub_ksiazki.pk) + + assert znaleziona is not None, "ksiazka-matka w koszu nie zostala znaleziona" + assert znaleziona.pk == ksiazka.pk + assert Wydawnictwo_Zwarte.objects.filter(pk=ksiazka.pk).exists(), ( + "ksiazka-matka nie zostala wskrzeszona" + ) + assert RekordPrzywroconyPrzezImport.objects.filter( + object_id=ksiazka.pk, zrodlo_importu="chapters:ksiazka-nadrzedna" + ).exists() + + +@pytest.mark.django_db +def test_brak_ksiazki_nadrzednej_zwraca_none(): + """Kontrakt zachowany: brak trafienia to ``None``, a nie wyjątek — + wołający ma wtedy zaimportować książkę z PBN.""" + from pbn_integrator.importer.chapters import znajdz_ksiazke_nadrzedna + + assert znajdz_ksiazke_nadrzedna("nie-istnieje-taki-pbn-uid") is None diff --git a/src/pbn_integrator/tests/test_rejestr_wskrzeszen_admin.py b/src/pbn_integrator/tests/test_rejestr_wskrzeszen_admin.py new file mode 100644 index 000000000..90a697d70 --- /dev/null +++ b/src/pbn_integrator/tests/test_rejestr_wskrzeszen_admin.py @@ -0,0 +1,112 @@ +"""Rejestr wskrzeszeń musi być DOSTĘPNY, inaczej nie spełnia swojej roli. + +Zmiana decyzji #14 planu fazy 03 („POMIŃ + ZARAPORTUJ" → „PRZYWRÓĆ + ODNOTUJ") +została uzasadniona tym, że ryzyko nie znika, tylko zostaje przeniesione: +z „nie róbmy tego" na „róbmy, ale zostaw ślad". Całym uzasadnieniem jest więc +ŚLAD — operator, który znajdzie w bazie rekord skasowany przez siebie tydzień +temu, ma gdzie sprawdzić, że wrócił z importu, kiedy i z którego źródła. + +Rejestr osiągalny wyłącznie przez ``manage.py shell`` tego nie spełnia. + +Dwie rzeczy, które testujemy: + +1. **Widoczność w adminie** — i to jako trwały ślad audytowy, którego nie da + się dopisać ani zmienić z UI. Rejestr, który operator może edytować, + przestaje być dowodem. +2. **Indeks pod pytanie „czy TEN rekord wrócił?"** — czyli po + ``(content_type, object_id)``. Sprawdzamy PRAWDZIWY indeks w ``pg_indexes``, + a nie deklarację w ``Meta``: interesuje nas, czy migracja go założyła. + Wzorzec z ``bpp/tests/test_soft_delete/test_autor_rekord_index.py``. +""" + +import pytest +from django.contrib import admin as django_admin +from django.db import connection +from django.urls import reverse +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle +from pbn_api.models import Publication +from pbn_integrator.models import RekordPrzywroconyPrzezImport + +TABELA = RekordPrzywroconyPrzezImport._meta.db_table + + +def _wpis_rejestru(): + from django.contrib.contenttypes.models import ContentType + + publication = baker.make(Publication) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + return RekordPrzywroconyPrzezImport.objects.create( + content_type=ContentType.objects.get_for_model(Wydawnictwo_Ciagle), + object_id=wc.pk, + pbn_uid=publication, + zrodlo_importu="articles", + ) + + +@pytest.mark.django_db +def test_rejestr_ma_indeks_na_content_type_i_object_id(): + """„Czy TEN rekord wrócił?" nie może być seq scanem. + + To jedyne pytanie, jakie operator zada temu rejestrowi wprost — z poziomu + konkretnej publikacji. Bez indeksu na parze kolumn generic-FK odpowiedź + wymaga przejrzenia całej tabeli, która rośnie z każdym importem. + """ + with connection.cursor() as cur: + cur.execute("SELECT indexdef FROM pg_indexes WHERE tablename = %s", [TABELA]) + indeksy = [wiersz[0] for wiersz in cur.fetchall()] + + assert any("(content_type_id, object_id)" in indexdef for indexdef in indeksy), ( + f"brak indeksu na (content_type_id, object_id) w {TABELA}; są: {indeksy}" + ) + + +@pytest.mark.django_db +def test_rejestr_jest_zarejestrowany_w_adminie(): + assert RekordPrzywroconyPrzezImport in django_admin.site._registry, ( + "rejestr wskrzeszen nie jest widoczny w adminie — czyli slad, ktorym " + "uzasadniono zmiane decyzji #14, jest nieosiagalny dla operatora" + ) + + +@pytest.mark.django_db +def test_changelist_pokazuje_wpis_ze_zrodlem_importu(admin_client): + wpis = _wpis_rejestru() + + res = admin_client.get( + reverse("admin:pbn_integrator_rekordprzywroconyprzezimport_changelist") + ) + + assert res.status_code == 200 + tresc = res.content.decode() + + # Asercje po klasach `field-`, którymi Django oznacza KOMÓRKI + # tabeli. Samo „articles in tresc" byłoby fałszywie zielone: ten łańcuch + # pojawia się też w bocznym filtrze `list_filter`, więc przechodziłoby + # nawet po wyrzuceniu kolumny z `list_display` (sprawdzone mutacyjnie). + assert 'class="field-zrodlo_importu"' in tresc, ( + "changelista nie ma kolumny ze zrodlem importu" + ) + assert 'class="field-rekord"' in tresc, ( + "changelista nie pokazuje, KTORY rekord wrocil" + ) + assert str(wpis.rekord) in tresc + assert wpis.zrodlo_importu in tresc + + +@pytest.mark.django_db +def test_rejestru_nie_da_sie_dopisac_ani_zmienic_z_admina(admin_client): + """Ślad audytowy, który operator może edytować, nie jest dowodem. + + Wpisy powstają wyłącznie w kodzie importu (``pbn_integrator/kosz.py``). + """ + wpis = _wpis_rejestru() + model_admin = django_admin.site._registry[RekordPrzywroconyPrzezImport] + request = admin_client.get( + reverse("admin:pbn_integrator_rekordprzywroconyprzezimport_changelist") + ).wsgi_request + + assert not model_admin.has_add_permission(request) + assert not model_admin.has_change_permission(request, wpis) + assert not model_admin.has_delete_permission(request, wpis) diff --git a/src/pbn_wysylka_oswiadczen/queries.py b/src/pbn_wysylka_oswiadczen/queries.py index af0ff1f80..6de5b93fd 100644 --- a/src/pbn_wysylka_oswiadczen/queries.py +++ b/src/pbn_wysylka_oswiadczen/queries.py @@ -47,10 +47,20 @@ def get_publications_queryset( # Add annotations if needed (for views displaying counts) if with_annotations: + # ``autorzy_set__deleted_at__isnull=True`` w OBU licznikach: agregat + # po ``autorzy_set`` idzie JOIN-em po SUROWEJ tabeli ``*_autor``, + # z pominięciem managera soft-delete. Bez tego predykatu skasowane + # autorstwo liczy się do ``liczba_oswiadczen``, a filtr + # ``tylko_odpiete`` (liczba_oswiadczen > 0 AND liczba_przypietych = 0) + # wskazuje do wycofania z PBN rekordy, na których nie ma już ani + # jednego żywego oświadczenia. annotations = { "liczba_oswiadczen": Count( "autorzy_set__pk", - filter=Q(autorzy_set__dyscyplina_naukowa__isnull=False), + filter=Q( + autorzy_set__dyscyplina_naukowa__isnull=False, + autorzy_set__deleted_at__isnull=True, + ), distinct=True, ), "liczba_przypietych": Count( @@ -58,6 +68,7 @@ def get_publications_queryset( filter=Q( autorzy_set__przypieta=True, autorzy_set__dyscyplina_naukowa__isnull=False, + autorzy_set__deleted_at__isnull=True, ), distinct=True, ), diff --git a/src/rozbieznosci/views.py b/src/rozbieznosci/views.py index de5aa1bd0..389a81cf6 100644 --- a/src/rozbieznosci/views.py +++ b/src/rozbieznosci/views.py @@ -107,11 +107,21 @@ def _scope_do_uczelni(qs, request): No-op (zwraca ten sam qs) gdy brak mapowania Site→Uczelnia (fail-open, jak ``scope_rekord_do_uczelni``) albo gdy w systemie jest jedna uczelnia (guard ``tylko_jedna_uczelnia`` — single-host = brak zawężenia). + + ``autorzy_set__deleted_at__isnull=True`` jest częścią reguły atrybucji, nie + ozdobnikiem: lookup przez relację buduje JOIN po SUROWEJ tabeli + ``bpp_wydawnictwo_ciagle_autor``, więc bez tego predykatu SKASOWANE + autorstwo dalej przypisywałoby rekord do uczelni — czyli pokazywałoby go + na cudzym hoście. ``scope_rekord_do_uczelni`` tego problemu nie ma, bo + idzie po ``bpp_autorzy_mat`` (widok już filtruje ``deleted_at``). """ uczelnia = Uczelnia.objects.get_for_request(request) if uczelnia is None or tylko_jedna_uczelnia(): return qs - return qs.filter(autorzy_set__jednostka__uczelnia=uczelnia).distinct() + return qs.filter( + autorzy_set__jednostka__uczelnia=uczelnia, + autorzy_set__deleted_at__isnull=True, + ).distinct() class RozbieznosciView(MetrykaMixin, GroupRequiredMixin, ListView): 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), + ] diff --git a/src/rozbieznosci_dyscyplin/migrations/0023_rozbieznosci_zrodel_bez_skasowanych_publikacji.py b/src/rozbieznosci_dyscyplin/migrations/0023_rozbieznosci_zrodel_bez_skasowanych_publikacji.py new file mode 100644 index 000000000..a5dda8a6c --- /dev/null +++ b/src/rozbieznosci_dyscyplin/migrations/0023_rozbieznosci_zrodel_bez_skasowanych_publikacji.py @@ -0,0 +1,52 @@ +"""Raport rozbieżności dyscyplin źródeł pomija soft-deletowane PUBLIKACJE. + +Dopełnienie migracji ``0022``, która zamknęła w tym widoku wymiar AUTORSTWA +(``bpp_wydawnictwo_ciagle_autor.deleted_at``). Wymiar PUBLIKACJI został +wtedy otwarty, bo publikacje stały się soft-delete dopiero w fazie 02 +(``bpp.0496``). + +Bez tego warunku skasowane wydawnictwo ciągłe dalej generuje pozycje +w raporcie rozbieżności — operator dostaje do rozstrzygnięcia rekord, +którego w serwisie już nie ma. + +Widok wskazany INWENTARYZACJĄ kanarka katalogowego na starcie fazy 02 +(``docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md``). + +Definicja żyjąca w bazie pochodzi z pliku ``.sql`` przeładowywanego przez +kilka migracji (patrz ``0022``), więc — tak samo jak tam — pliku nie +ruszamy, tylko dokładamy warunek osobną migracją, przez introspekcję. + +Pozostałe widoki tej aplikacji czytają ``bpp_autorzy``, a nie surowe tabele +publikacji — potwierdzone inwentaryzacją (``pg_depend``), nie gremem. +""" + +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.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", "0022_rozbieznosci_zrodel_bez_skasowanych"), + # Kolumna `deleted_at` na tabelach publikacji powstaje w bpp.0496; + # bez tej zależności migracja mogłaby pójść przed nią i wywalić się + # na nieznanej kolumnie. + ("bpp", "0496_publikacje_soft_delete_fields"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] 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..b60b8ee3b --- /dev/null +++ b/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py @@ -0,0 +1,75 @@ +"""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 django.db import connection +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() + + +@pytest.mark.django_db +def test_rozbieznosci_zrodel_pomijaja_soft_deletowana_publikacje(rozbieznosc_zrodla): + """Wymiar PUBLIKACJI (faza 02, migracja 0023 tej aplikacji). + + ⚠️ Kasujemy publikację SUROWYM UPDATE-em, nie ``rekord.delete()``, i jest + to celowe: ``delete()`` kaskaduje na autorstwa, więc wiersz raportu + zniknąłby z DWÓCH niezależnych powodów — przez filtr publikacji (to, co + ten test sprawdza) ORAZ przez filtr autorstwa z migracji ``0022``. Test + przechodziłby wtedy nawet po cofnięciu ``0023``, czyli nie byłby + wyrocznią niczego. + """ + autor_id = rozbieznosc_zrodla.autor_id + assert RozbieznosciZrodelView.objects.filter(autor_id=autor_id).exists() + + with connection.cursor() as cur: + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s", + [rozbieznosc_zrodla.rekord_id], + ) + + assert not RozbieznosciZrodelView.objects.filter(autor_id=autor_id).exists(), ( + "soft-skasowana publikacja dalej generuje wiersz raportu rozbieznosci" + ) diff --git a/src/rozbieznosci_dyscyplin/util.py b/src/rozbieznosci_dyscyplin/util.py index 6dd0ef5a8..39436366e 100644 --- a/src/rozbieznosci_dyscyplin/util.py +++ b/src/rozbieznosci_dyscyplin/util.py @@ -1,7 +1,9 @@ from django.core.exceptions import ObjectDoesNotExist -def object_or_something(model, attrname, default_pk=-1, default_attr="nazwa", default_value="--", **kwargs): +def object_or_something( + model, attrname, default_pk=-1, default_attr="nazwa", default_value="--", **kwargs +): """ Funkcja do rozsądnego zwracania czegoś w przypadku, gdy obiekt powiązany (ForeignKey) może być None/NULL. W takich sytuacjach Django zwraca błąd.