diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-07.md b/docs/superpowers/HANDOFF-soft-delete-faza-07.md new file mode 100644 index 000000000..4be917ef7 --- /dev/null +++ b/docs/superpowers/HANDOFF-soft-delete-faza-07.md @@ -0,0 +1,255 @@ +# Handoff: soft-delete, start fazy 07 + +> Po zamknięciu **fazy 06** (`SoftDeleteLog` + receivery sygnałów + atrybucja +> usera), 2026-08-16. Czytaj to zamiast odtwarzania historii z gita. + +--- + +## 1. Gdzie jesteśmy + +| | | +|---|---| +| Stan fazy 06 | gałąź `feat/soft-delete-06`, odbita od `feat/soft-delete-05b` | +| Plan fazy 06 | [`plans/2026-06-04-soft-delete-06-softdeletelog.md`](plans/2026-06-04-soft-delete-06-softdeletelog.md) — wykonany, z odstępstwami opisanymi w §3 | +| Migracja fazy 06 | `bpp/0503_softdeletelog` (jedna, tylko `CreateModel`) | +| Task 5 planu | **POMINIĘTY** zgodnie z handoffem fazy 06 — `zakolejkuj_*` istnieją z fazy 05a | + +### Stos PR-ów + +``` +#312 feat/soft-delete -> dev +#745 feat/soft-delete-04 -> feat/soft-delete +#755 feat/soft-delete-05 -> feat/soft-delete-04 +#767 feat/soft-delete-05b -> feat/soft-delete-05 + feat/soft-delete-06 -> feat/soft-delete-05b (faza 06, ta praca) +``` + +⚠️ Żaden PR ze stosu nie jest scalony. Faza 06 dziedziczy commity 05b. + +⚠️ **Baseline (`baseline-sql/`) nadal NIEŚWIEŻY** — stoi na `bpp/0487`. +Doszła `0503`. Odświeżenie robi się **raz, przy scalaniu** całego +`feat/soft-delete` do `dev`. + +⚠️ **CI NIE URUCHAMIA SIĘ na PR-ach do gałęzi `feat/soft-delete*`** — bez +zmian. Jedyną weryfikacją jest przebieg lokalny. + +### Wynik weryfikacji fazy 06 + +`make tests-without-playwright`: **9647 passed, 2 failed, 4 skipped, +2 xfailed** (8:06). + +Obie porażki to `Failed: Timeout (>90.0s)` — NIE asercje: + +- `bpp/tests/test_soft_delete/test_kronika_usunieta.py::test_0499_odwracalna` +- `pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py::test_migracja_ + przechodzi_na_bazie_z_duplikatami` + +**Nie są regresją fazy 06** — zmierzone czasy seryjne na obu gałęziach: + +| Test | `feat/soft-delete-05b` | `feat/soft-delete-06` | +|---|---|---| +| `test_migracja_...duplikatami` | 45,70 s | 43,16 s | +| `test_0499_odwracalna` | 31,88 s | 32,33 s | + +Identyczne co do szumu. Żaden z nich nie wykonuje operacji soft-delete +(w `test_kronika_usunieta.py` nie ma ani jednego `.delete()`/`.restore()`/ +`hard_delete`), więc receivery nie mają się w nich gdzie odpalić; wkład +fazy 06 do grafu migracji to jedno `CreateModel`. + +⚠️ **To testy o ~2× zapasie do limitu 90 s**, a `-n auto` odpala 10 workerów. +Na współdzielonym hoście przekraczają limit — ta sama klasa problemu, co +w handoffie fazy 05a §8 (load 6+ wywracał start testcontainerów). Kandydat +do podniesienia `@pytest.mark.timeout` dla tych dwóch plików; poza zakresem +fazy 06. + +--- + +## 2. Co faza 06 dostarcza (kontrakt dla fazy 07) + +```python +from bpp.models.soft_delete_context import soft_delete_context +from bpp.models.soft_delete_log import SoftDeleteLog +``` + +| Element | Gdzie | Uwaga | +|---|---|---| +| `SoftDeleteLog` | `bpp/models/soft_delete_log.py` | GFK + `akcja`/`user`/`powod` + `pbn_queue_entry`/`pbn_status` | +| `soft_delete_context(user=, reason=)` | `bpp/models/soft_delete_context.py` | thread-local; **pominięty argument DZIEDZICZY** (patrz §3.3) | +| `current_soft_delete_user/reason()` | tamże | akcesory dla receiverów | +| Trzy receivery | `bpp/receivers/soft_delete.py` | podpięte BEZ `sender=`, w `BppConfig.ready()` | +| `BppPkPrzedHardDeleteMixin` | `bpp/models/soft_delete.py` | **każdy** model soft-delete MUSI go mieć | +| `pk_dla_audytu(instance)` | tamże | pk po twardym skasowaniu; głośny `RuntimeError` bez mixinu | +| `BppPublikacjaSoftDeleteMixin.uczelnia_rekordu()` | tamże | atrybucja tenanta bez requestu | +| `hard_delete_per_instancja(queryset)` | tamże | queryset-owy `hard_delete()` z sygnałami | + +**Realne API atrybucji to `delete(user=, reason=)` / `restore(user=)`** — +fazy 02/04 przyjmowały te argumenty i je porzucały; faza 06 domknęła +obietnicę. Faza 07 (admin) ma po prostu przekazywać `request.user`. + +--- + +## 3. ⚠️ Cztery miejsca, w których plan rozjechał się z kodem + +Wszystkie wykryte PRZED napisaniem kodu albo przez padający test — nie po +fakcie. Trzy pierwsze to błędy rzeczowe w planie, nie zmiany zakresu. + +### 3.1 `Autor` MA `pbn_uid` (blokujące) + +Plan §„Detekcja publikacja z `pbn_uid`" twierdził — jako fakt zweryfikowany — +że „`Autor` i `*_Autor` nie mają `pbn_uid`", i na tym opierał uniwersalny gate +`getattr(instance, "pbn_uid_id", None)`. `Autor.pbn_uid` **istnieje** (FK do +`pbn_api.Scientist`, `autor.py`). Gate z planu wstawiłby **autora** do kolejki +eksportu **publikacji** i odpalił dla niego wysyłkę do PBN. + +Sam `pbn_uid` nie wystarczyłby też od drugiej strony: `zakolejkuj_wysylke` +świadomie NIE MA gate'u na `pbn_uid` (faza 05a), więc przy RESTORE przyjęłaby +każdy wiersz `*_Autor` przywracany kaskadą — publikacja z N autorami dałaby +N zbędnych zleceń. + +**Rozstrzygnięcie:** gate po typie — +`isinstance(instance, BppPublikacjaSoftDeleteMixin)`. Oba testy są w +`test_receivers.py`. + +### 3.2 Fazy 02/04 nie mogły wpiąć kontekstu tak, jak plan zakładał + +Plan zakładał, że owijają `super().delete()`. One **nie wołają +`super().delete()` w ogóle** — świadomie, żeby uniknąć refleksyjnej kaskady +pakietu (skasowałaby twardo `Autor_Jednostka`, `Autor_Dyscyplina` itd.). Same +ustawiają `deleted_at` i same wysyłają sygnał. Faza 06 zakłada więc kontekst +w czterech metodach (publikacje `delete`/`restore`, `Autor` `delete`/`restore`), +obejmując CAŁE ciało — kaskada `*_Autor` ma dziedziczyć atrybucję. + +### 3.3 Dwa sposoby atrybucji z planu wykluczały się nawzajem + +Wykryte przez padający test **z samego planu**. `delete()` zakłada kontekst +ZAWSZE, więc wywołanie bez `user=` wewnątrz jawnego +`soft_delete_context(user=X)` zerowało X i operacja szła do logu jako niczyja. + +**Reguła:** pominięty argument **dziedziczy**, nie zeruje. Siedzi w samym +context managerze, nie w czterech miejscach wywołań — inaczej następny model +soft-delete zgubiłby usera po cichu. Uzasadnienie: `None` znaczy „nie wiem", +a nie „wiem, że nikt". + +### 3.4 Gate punktacji obejmuje TRZY modele, nie pięć + +Plan (Task 6b) mówił „tylko 5 modeli publikacji". +`przelicz_punkty_dyscyplin()` mają wyłącznie `Wydawnictwo_Ciagle`, +`Wydawnictwo_Zwarte` i `Patent` — dziedziczą `ModelZPrzeliczaniemDyscyplin`. +Prace dyplomowe **nie mają tej metody**, więc gate „5 modeli" wywaliłby się +na `AttributeError` przy kasowaniu doktoratu. + +--- + +## 4. Szósty model soft-delete, o którym nie wiedział nikt + +Asercja testowa „każdy `SoftDeleteModel` ma `BppPkPrzedHardDeleteMixin`" +(nad `apps.get_models()`) natychmiast wykryła +**`zglos_publikacje.Zgloszenie_Publikacji`** — model soft-delete +**niezależny od faz 01–04**, używający pakietu od dawna na własne potrzeby. +Nie było go na żadnej liście: ani w planie, ani w handoffach faz 02/04/05. + +To powtórka lekcji z handoffu fazy 05a §4.2 (faza 04 pominęła czwartego +dziedzica abstraktu). **Nauka: wyliczanka modeli z głowy jest niepełna; +asercja nad `apps.get_models()` nie jest.** + +**Skutek do odnotowania:** operacje na `Zgloszenie_Publikacji` trafiają teraz +do `SoftDeleteLog`. Bez skutków PBN (gate z §3.1 obejmuje wyłącznie +publikacje). Jeśli to niepożądane, zawężenie jest jednolinijkowe — ale +domyślnie audyt jest szerszy, nie węższy. + +--- + +## 5. Fakt o pakiecie utrwalony testem + +`post_hard_delete` leci **po** `Model.delete()`, a kolektor Django zeruje +wtedy `pk` instancji. Receiver dostaje więc `instance.pk is None`. Naiwny +zapis dałby `object_id=None` → `IntegrityError` **w receiverze** → wywrócone +`hard_delete()`: audyt zepsułby operację, którą ma tylko obserwować. + +Stąd `BppPkPrzedHardDeleteMixin`. Test-sonda +(`test_pakiet_zeruje_pk_przed_wyslaniem_post_hard_delete`) pilnuje tego +założenia — gdy aktualizacja pakietu je zmieni, zapali się tam, a nie +w postaci logów czytających pole zapasowe. + +--- + +## 6. Czego faza 06 NIE domyka (świadomie) + +- **`pbn_status=""` jest dwuznaczne.** Nie odróżnia „nie było czego + kolejkować" od „pominięto, bo rekord już czeka w kolejce" — obie funkcje + kolejkujące zwracają `None` i receiver nie ma ich jak rozróżnić bez + dodatkowego zapytania. Praktyczna konsekwencja: szybkie „usuń i cofnij" + zostawia w kolejce samo WYCOFANIE, a log RESTORE pokazuje pusty status. + Udokumentowane testem + `test_restore_przy_niezakonczonym_wycofaniu_nie_dubluje_wpisu`. +- **`uczelnia_rekordu()` zwraca `None` przy dwuznaczności** (praca + współautorska między uczelniami). Wpis kolejki degraduje wtedy do + zachowania sprzed fazy 06: jedna uczelnia w bazie → działa, kilka → + głośny błąd. Wybranie „pierwszej z brzegu" wysłałoby wycofanie przez konto + PBN cudzego tenanta, więc to celowo nie jest zgadywane. +- **Reguła przynależności jest zduplikowana.** `uczelnia_rekordu()` odwraca + `naleza_wydawnictwa`/`naleza_prace` z `cerif_export` (faza 05b). Nie da się + jej wołać wprost, bo `cerif_export` zależy od `bpp`, nie odwrotnie. Gdy + tamta się zmieni, ta MUSI pójść za nią — dziś pilnują tego tylko testy + po obu stronach. +- **Brak UI dla logu.** `SoftDeleteLog` nie ma admina ani widoku — faza 07. +- **`SoftDeleteLog` nie niesie `pbn_uid`.** Dług z handoffu 05a §6 (twarde + skasowanie publikacji zostawia oświadczenia w PBN na zawsze) NIE jest + domknięty: log zapisuje `object_id`, nie PBN UID. Domknięcie wymaga + dołożenia pola i wypełnienia go w receiverze HARD_DELETE. + +--- + +## 7. Wskazówki wprost dla fazy 07 (admin kosza) + +- **Masowe przywracanie potrzebuje zadania w tle.** Zmierzone: `restore()` + z przeliczeniem punktacji to **46,7 ms** dla rekordu z 2 autorami. Poniżej + progu 1 s z planu, więc bez eskalacji — ale koszt jest liniowy: 100 + rekordów ≈ 4,7 s w jednym żądaniu HTTP. +- **Masowe twarde kasowanie jest teraz N zapytań, nie jedno.** + `hard_delete()` na querysecie iteruje per instancja, żeby leciały sygnały + (Task 8). Świadomy koszt — ale akcja admina nad dużym zaznaczeniem to + kandydat na zadanie w tle. +- **Admin ma request, więc ma tenanta.** Jeśli faza 07 zechce, żeby wpisy + kolejki niosły uczelnię z requestu zamiast wyprowadzanej z rekordu + (dokładniejsze dla prac współautorskich), naturalne miejsce to + rozszerzenie `soft_delete_context` o `uczelnia=`. Właściciel świadomie + wybrał wyprowadzanie z rekordu, bo działa też dla CLI i celery. + +--- + +## 8. Dług nadal otwarty (z faz 01–05b) + +| Sprawa | Stan | +|---|---| +| **Asymetria gate'u `.update(deleted_at=...)`** | `BppSoftDeleteQuerySet` blokuje, `BppDeletedQuerySet` **nie** — `Autor.deleted_objects...update(deleted_at=...)` rzuca, to samo na `Wydawnictwo_Ciagle` przechodzi. Wygląda na przeoczenie 05b, nie decyzję | +| **Kaskada `Jednostka` → `Autor`** | `aktualna_jednostka`/`aktualna_funkcja` nadal `CASCADE` | +| **`Autor.slug` `unique=True` bezwarunkowo** | husk trzyma slug zarezerwowany | +| **Wycieki ORM (kanarek `xfail(strict=True)`)** | bez zmian | +| **PR upstream `django-easy-audit`** | [#348](https://github.com/soynatan/django-easy-audit/pull/348) | +| **Brak UI dla `ProtectedError`** | w adminie gołe 500; faza 07 | +| **`Autor` nie ma kosza w adminie** | faza 07 | +| **`hard_delete()` na querysecie bez sygnału** | **ZAMKNIĘTE w fazie 06** (Task 8) | +| Słowniki (`Zrodlo`, `Konferencja`, `Projekt`, `Jednostka`) bez soft-delete | bez zmian | +| Pomiar `0492` i narzutu GiST | wciąż nikt nie zmierzył | +| **Strategia wydania** | bramka na fazie 07 | + +--- + +## 9. Proces — co się sprawdziło w fazie 06 + +- **Weryfikacja planu przed kodowaniem zwróciła się natychmiast.** Trzy + z czterech rozjazdów z §3 to błędy RZECZOWE w planie, a nie zmiany + zakresu. Najgroźniejszy (`Autor.pbn_uid`) był oznaczony w planie jako + „zweryfikowane" — etykieta wiarygodności nie zastępuje sprawdzenia. +- **Asercja nad `apps.get_models()` bije wyliczankę.** Znalazła szósty model + soft-delete w pierwszym uruchomieniu (§4). Ten sam błąd — lista modeli + z pamięci — wystąpił już w fazie 04. +- **Mutacja jako dowód, nie rytuał.** Podmiana `global_objects` → `objects` + w `uczelnia_rekordu()` wywaliła dokładnie dwa testy, które tego pilnują. + Potwierdziło to empirycznie, że kolejność „kaskada przed sygnałem rodzica" + jest realnym zagrożeniem, a nie teoretycznym. +- **Test z planu wykrył sprzeczność w samym planie.** `test_soft_delete_ + tworzy_log_z_userem` padł po wpięciu kontekstu i wymusił regułę + dziedziczenia (§3.3). Gdyby go pominąć jako „oczywisty", sprzeczność + wyszłaby dopiero w fazie 07. diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-08.md b/docs/superpowers/HANDOFF-soft-delete-faza-08.md new file mode 100644 index 000000000..c89ad8cb2 --- /dev/null +++ b/docs/superpowers/HANDOFF-soft-delete-faza-08.md @@ -0,0 +1,331 @@ +# Handoff: soft-delete, start fazy 08 + +> Po zamknięciu **fazy 07** (kosz w adminie: filtr, przywracanie, trwałe +> usuwanie, powód), 2026-08-25. Czytaj to zamiast odtwarzania historii z gita. + +--- + +## 1. Gdzie jesteśmy + +| | | +|---|---| +| Stan fazy 07 | gałąź `feat/soft-delete-06` (faza 07 dopisana na tej samej gałęzi) | +| Plan fazy 07 | [`plans/2026-06-04-soft-delete-07-admin.md`](plans/2026-06-04-soft-delete-07-admin.md) — wykonany, z odstępstwami z §3 | +| Migracje fazy 07 | **ŻADNE.** Mixin admina nie dotyka schematu | +| Następny plan | [`plans/2026-06-04-soft-delete-08-testy-regresji.md`](plans/2026-06-04-soft-delete-08-testy-regresji.md) — 11 tasków, faza czysto testowa | + +### Stos PR-ów + +``` +#312 feat/soft-delete -> dev +#745 feat/soft-delete-04 -> feat/soft-delete +#755 feat/soft-delete-05 -> feat/soft-delete-04 +#767 feat/soft-delete-05b -> feat/soft-delete-05 +#792 feat/soft-delete-06 -> feat/soft-delete-05b (fazy 06 ORAZ 07) +``` + +⚠️ Żaden PR ze stosu nie jest scalony. Nowość względem handoffu fazy 07: +gałąź `feat/soft-delete-06` (fazy 06 **i** 07) jest wypchnięta i ma wreszcie +własny PR — [#792](https://github.com/iplweb/bpp/pull/792). Wcześniej była +jedynym ogniwem stosu bez numeru, bo faza 06 nigdy nie została wypchnięta. + +⚠️ **Baseline (`baseline-sql/`) nadal NIEŚWIEŻY** — stoi na `bpp/0487`, ostatnia +migracja to `0503` (faza 06). Faza 07 nic tu nie zmieniła. Odświeżenie robi się +**raz, przy scalaniu** całego `feat/soft-delete` do `dev`. + +⚠️ **CI NIE URUCHAMIA SIĘ na PR-ach do gałęzi `feat/soft-delete*`** — bez zmian. +Jedyną weryfikacją jest przebieg lokalny. + +### Wynik weryfikacji fazy 07 + +| Przebieg | Wynik | +|---|---| +| `pytest src/bpp/tests/test_soft_delete/test_admin.py` (suita fazy) | **21 passed** | +| `pytest src/bpp/tests/test_soft_delete/` (cała rodzina soft-delete) | **197 passed, 1 xfailed** | +| `pytest src/bpp/tests/ -k admin` (regresja adminów, z Playwrightem) | **845 passed** | +| `make tests-without-playwright` (`-n auto`) | **9669 passed, 1 failed, 4 skipped, 2 xfailed** (5:32) | +| `ruff check` / `ruff format --check` na plikach fazy | czysto (9 plików) | +| `pre-commit` na plikach fazy + oba hooki szablonowe | Passed | + +Jedyna porażka to `test_soft_delete/test_kronika_usunieta.py::test_0499_odwracalna` +— `Failed: Timeout (>90.0s)`, NIE asercja. **To nie jest regresja fazy 07:** + +- serialnie ten test przechodzi w **32,98 s** (zmierzone po fazie 07); faza 06 + mierzyła 31,88 s na `05b` i 32,33 s na `06` — identycznie co do szumu, +- faza 07 **nie dokłada żadnej migracji**, a to jest test odwracalności migracji + `0499`; nie wykonuje ani jednej operacji admina, +- to jeden z DWÓCH testów wskazanych już w handoffie fazy 07 §1 jako mające + ~2× zapasu do limitu 90 s i przekraczające go pod `-n auto` (10 workerów) na + współdzielonym hoście. Drugi (`pbn_api/…/test_migracja_dyscypliny_uuid_e2e`) + tym razem przeszedł — co potwierdza, że rzecz jest w obciążeniu, nie w kodzie. + +Kandydat do podniesienia `@pytest.mark.timeout` dla tych dwóch plików — +**wciąż poza zakresem**, teraz już drugą fazę z rzędu. + +--- + +--- + +## 2. Co faza 07 dostarcza (kontrakt dla fazy 08) + +Jedna klasa, `src/bpp/admin/helpers/mixins.py`: + +```python +from bpp.admin.helpers.mixins import BppSoftDeleteAdminMixin, PokazSkasowaneFilter +``` + +| Element | Uwaga | +|---|---| +| `BppSoftDeleteAdminMixin` | wpięty w 6 adminów; **OSTATNI** na liście baz, patrz §3.3 | +| `PokazSkasowaneFilter` | `?is_deleted=true` = tylko kosz, `=all` = wszystko, brak = tylko żywe | +| `_soft_delete_user_context(request)` | JEDEN punkt wstrzyknięcia usera; deleguje do `soft_delete_context` | +| `_soft_delete_jeden(request, obj, powod)` | soft-delete jednej instancji z obsługą guarda fazy 04 | +| akcja `usun_do_kosza` | strona pośrednia z polem „powód" → `SoftDeleteLog.powod` | +| akcja `przywroc_zaznaczone` | restore przez ten sam hook | +| akcja `usun_trwale_zaznaczone` | superuser-only, **tylko rekordy z kosza** | +| `templates/admin/bpp/soft_delete_powod.html` | faktyczna ścieżka: `src/django_bpp/templates/…` | +| fixture `staff_user` / `staff_client` | `src/conftest.py`; staff z grupą „wprowadzanie danych" | + +Objęte adminy: `Wydawnictwo_CiagleAdmin`, `Wydawnictwo_ZwarteAdmin`, +`Patent_Admin`, `Praca_DoktorskaAdmin`, `Praca_HabilitacyjnaAdmin`, `AutorAdmin`. + +**Poza zakresem (świadomie):** `Zgloszenie_Publikacji` — szósty model +soft-delete znaleziony w fazie 06 (§4 tamtego handoffu). Nie ma admina kosza, +bo plan fazy 07 obejmował 5 publikacji + `Autor`. Operacje na nim i tak trafiają +do `SoftDeleteLog` (receivery są podpięte bez `sender=`). + +--- + +## 3. ⚠️ Cztery miejsca, w których plan rozjechał się z kodem + +Wszystkie wykryte PRZED napisaniem kodu, przez weryfikację „PINNED kontraktów" +planu wobec źródeł. Trzy to błędy rzeczowe w planie, jeden — luka bezpieczeństwa. + +### 3.1 API atrybucji z planu nie istnieje (a overview mówił to wprost) + +Plan §„Kontrakty z fazą 06 (PINNED — używaj VERBATIM)" pinuje +`set_soft_delete_user` / `get_soft_delete_user` / `clear_soft_delete_user` +w `bpp/models/soft_delete.py`. **Żadna z tych funkcji nie istnieje.** Faza 06 +dostarczyła context manager `soft_delete_context(user=, reason=)` w +`bpp/models/soft_delete_context.py`. + +Co jest tu warte zapamiętania: **overview ostrzegał przed dokładnie tym błędem** +(`2026-06-04-soft-delete-00-overview.md:187` — „NIE wymyślać osobnego +`set/get/clear_soft_delete_user` — używać `soft_delete_context`"). Plan fazy 07 +powstał wcześniej i nie został zsynchronizowany. Etykieta „PINNED — używaj +VERBATIM" była więc *silniejsza* niż jej pokrycie w kodzie — to ta sama lekcja, +co `Autor.pbn_uid` w fazie 06 §3.1 („zweryfikowane" ≠ zweryfikowane). + +### 3.2 `hard_delete()` nie przyjmuje `user=` ani `reason=` + +Plan (Task 4) wołał `obj.hard_delete(user=request.user, reason=powod)` → +`TypeError`. `BppPkPrzedHardDeleteMixin.hard_delete(*args, **kwargs)` przekazuje +argumenty prosto do pakietu, który o userze nie wie. + +**Skutek projektowy, nie kosmetyczny:** to jest powód, dla którego +`_soft_delete_user_context` MUSI być context managerem, a nie „przekaż `user=` +do metody". Dla trwałego usuwania kontekst jest JEDYNYM kanałem atrybucji. + +### 3.3 „Mixin PIERWSZY" otwierało lukę wielotenantową (blokujące) + +Plan powtarza w Task 1 i Task 6: `BppSoftDeleteAdminMixin` ma być **PIERWSZY** +na liście baz, „żeby jego metody wygrywały". + +`get_queryset` tego mixinu **nie może wołać `super()`** — musi podmienić manager +bazowy na `global_objects`, a `super()` zwraca już przefiltrowany `objects`. +Z pierwszej pozycji w MRO ucina więc CAŁY łańcuch. W `AutorAdmin` w tym łańcuchu +stoi `SiteFilteredAdminMixin.get_queryset`, zawężające widok nie-superusera do +jego uczelni (`kiedykolwiek_zwiazani`, FD#390). Wpięcie wg planu dałoby +personelowi uczelni A widok i kasowanie autorów uczelni B. + +**Rozstrzygnięcie:** mixin wpinany jako **OSTATNI**, tuż przed terminalną bazą +(`admin.ModelAdmin` / `Wydawnictwo_ZwarteAdmin_Baza` / +`Praca_Doktorska_Habilitacyjna_Admin_Base`). Z tej pozycji jego `get_queryset` +jest PODSTAWĄ łańcucha — zwraca `global_objects`, a wszystkie mixiny wyżej +nakładają na to swoje filtry normalnie. Zweryfikowane: w tych 6 łańcuchach +jedynym innym `get_queryset(self, request)` jest ten z `SiteFilteredAdminMixin` +i woła `super()`; `get_actions` / `get_list_filter` w łańcuchu +(`AutorAdmin.get_actions`, `ExportActionsMixin.get_actions`) też wołają `super()` +i tylko DOKŁADAJĄ pozycje — więc z ostatniej pozycji działają tak samo. + +Strażnikiem jest `test_admin.py::test_mixin_nie_znosi_zawezenia_do_uczelni_w_autoradmin`. +**Nie usuwaj go przy refaktorze MRO** — to jedyne miejsce, które zapala się, +gdy ktoś „posprząta" kolejność baz z powrotem do wersji z planu. + +### 3.4 Plan odwracał semantykę parametru filtra na podstawie błędnego odczytu + +Plan (Task 1, Step 5) twierdzi, że pakietowy `SoftDeleteFilter` ma mylącą +semantykę (`'true'` → „Deleted Softly" przez mapę `{'true': False}`) i każe +odwrócić ją tak, by `?is_deleted=false` znaczyło „pokaż kosz". + +Pakiet czyta się jednak inaczej niż plan go przeczytał: `{'true': False}` trafia +do `filter(deleted_at__isnull=value)`, czyli `deleted_at IS NOT NULL` — a więc +`is_deleted=true` JUŻ znaczy „rekordy skasowane". Odwrócenie sprawiłoby, że +parametr w URL-u kłamie (bookmark `is_deleted=false` pokazywałby kosz). + +**Rozstrzygnięcie:** zostaje semantyka pakietu. `PokazSkasowaneFilter` zmienia +wyłącznie zachowanie DOMYŚLNE (brak parametru = tylko żywe zamiast wszystkiego), +bo po poszerzeniu `get_queryset` do `global_objects` nie ma już nikogo innego, +kto by kosz schował. + +--- + +## 4. Decyzje wykraczające poza plan (i ich uzasadnienia) + +### 4.1 Trwałe usuwanie WYŁĄCZNIE z kosza + +Plan nie ograniczał `usun_trwale_zaznaczone` do rekordów skasowanych. Ogranicza +je faza 07 — i nie jest to ostrożnościowy rytuał: + +`Cache_Punktacja_*` nie ma FK do publikacji (klucz to tablica +`[content_type_id, pk]`), więc nie sprząta jej ani kolektor Django, ani triggery. +Kasuje ją dopiero receiver `post_soft_delete` (faza 06, `_skasuj_punktacje`). +Twarde skasowanie rekordu Z POMINIĘCIEM kosza zostawiłoby więc wiersze punktacji +wskazujące na nieistniejący rekord — **i wciąż liczące się do ewaluacji**. +Rekordy spoza kosza są pomijane z komunikatem. + +To jest kandydat na test regresji w fazie 08 (Task 2/7 dotykają dokładnie tej +materii). + +### 4.2 Akcje działają na querysecie changelisty, nigdy na `deleted_objects` + +`przywroc_zaznaczone` można było zrobić „wygodniej": dociągnąć zaznaczone pk +z `Model.deleted_objects`, żeby akcja działała niezależnie od aktywnego filtra. +**Świadomie tego nie robimy** — dociąganie po pk omija każde zawężenie nałożone +przez łańcuch, w tym `SiteFilteredAdminMixin`. Personel jednej uczelni mógłby +przywrócić rekord drugiej, podając jego pk. + +Koszt: przywracanie wymaga wcześniejszego ustawienia filtra „🗑️ Tylko +skasowane". Puste zaznaczenie tłumaczy komunikat, zamiast cicho zwrócić +„przywrócono: 0". + +### 4.3 Strona pośrednia przenosi `select_across`, nie tylko pk-i + +Django przy `select_across=1` IGNORUJE `_selected_action` i podaje akcji cały +przefiltrowany queryset. Gdyby strona z powodem re-postowała same zaznaczone +pk-i, „zaznacz wszystkie 40 000 pasujących" skurczyłoby się po cichu do 100 +wierszy bieżącej strony — a operator byłby przekonany, że skasował całość. +Dlatego formularz niesie `select_across` i `index`. + +Podgląd listy jest przycięty do `LIMIT_PODGLADU_KOSZA = 50`; licznik idzie +z `.count()`, więc liczba pozostaje prawdziwa przy skróconej liście. + +### 4.4 Komunikat guarda bierzemy z wyjątku, nie piszemy własnego + +`raise_if_has_protected_children` (faza 04) zna liczbę i rodzaj powiązań i już +je opisuje po polsku. Drugi tekst w adminie byłby kopią tej samej reguły — +rozjechałby się z oryginałem przy pierwszej zmianie listy relacji. Admin +pokazuje `e.args[0]`. + +Łapanie jest **per instancja**: jeden zablokowany autor nie przewraca całego +zaznaczenia. + +--- + +## 5. Bug znaleziony po drodze, NIE naprawiony (poza zakresem) + +**Staff bez żadnej grupy dostaje 500 na każdej stronie admina.** + +`src/django_bpp/menu.py:302` robi bezwarunkowe +`del menu.children[-1].children[-1]`, podczas gdy `flt(...)` kilka linii wyżej +dokłada submenu tylko członkom grupy. Użytkownik `is_staff=True` bez grup ma +`menu.children == []` → `IndexError: list assignment index out of range`. + +Reprodukcja: dowolny staff bez grup, dowolna strona `/admin/…`. + +Fixture `staff_user` obchodzi to, dopisując użytkownika do grupy +`GR_WPROWADZANIE_DANYCH` — co przy okazji modeluje realnego redaktora, więc nie +jest to obejście „na siłę". Ale **bug istnieje w produkcji** i wywróci się przy +pierwszym koncie staff założonym bez grupy. Naprawa jest jednolinijkowa +(warunek na niepustą listę), tylko nie należy do soft-delete. + +--- + +## 6. Czego faza 07 NIE domyka (świadomie) + +- **Brak admina dla samego `SoftDeleteLog`.** Log powstaje, ale nie ma widoku, + w którym operator obejrzy „co, kto i dlaczego usunął". Plan fazy 07 tego nie + obejmował (mimo że handoff fazy 06 §6 zapowiadał „UI dla logu — faza 07"). + **To jest realna luka w domknięciu fazy** — bez niej `powod` zapisywany przez + `usun_do_kosza` jest widoczny wyłącznie przez ORM. +- **`Zgloszenie_Publikacji` bez kosza w adminie** — patrz §2. +- **Masowe operacje nadal synchroniczne.** Handoff fazy 06 §7 mierzył + `restore()` z przeliczeniem punktacji na 46,7 ms/rekord; 100 rekordów ≈ 4,7 s + w jednym żądaniu HTTP. `hard_delete()` na querysecie to N zapytań (faza 06, + Task 8). Zadanie w tle nadal nie istnieje — przy zaznaczeniu „wszystkie + pasujące" na dużej bazie akcja się wywali na timeoucie. +- **Rekord z `pbn_uid` — asymetria uprawnień.** `RestrictDeletionWhenPBNUIDSetMixin` + nadal zwraca `has_delete_permission=False` dla rekordu z `pbn_uid`, więc + przycisk „Usuń" na changeformie i `delete_selected` są dla niego niedostępne. + Własna akcja `usun_do_kosza` **działa** (akcje w Django nie przechodzą przez + `has_delete_permission`). Jest to zgodne z intencją planu („soft-delete rekordu + z `pbn_uid` realizujemy mimo to" — faza 05 istnieje właśnie po to, żeby + zakolejkować `WYCOFANIE`), ale dwie ścieżki do tej samej operacji mają różne + reguły dostępu. Jeśli to ma być spójne, właściwym miejscem jest jawne + `has_soft_delete_permission()` — plan je zapowiadał, ale nigdy nie zdefiniował. + +--- + +## 7. Dług nadal otwarty (z faz 01–06) + +Bez zmian względem handoffu fazy 07, z dwiema pozycjami zamkniętymi: + +| Sprawa | Stan | +|---|---| +| **Brak UI dla `ProtectedError`** | **ZAMKNIĘTE w fazie 07** (§4.4) | +| **`Autor` nie ma kosza w adminie** | **ZAMKNIĘTE w fazie 07** | +| **Brak admina dla `SoftDeleteLog`** | **NOWE/otwarte** — patrz §6 | +| **Staff bez grupy = 500 w adminie** | **NOWE/otwarte** — patrz §5 | +| **Asymetria gate'u `.update(deleted_at=...)`** | `BppSoftDeleteQuerySet` blokuje, `BppDeletedQuerySet` **nie** | +| **Kaskada `Jednostka` → `Autor`** | `aktualna_jednostka`/`aktualna_funkcja` nadal `CASCADE` | +| **`Autor.slug` `unique=True` bezwarunkowo** | husk trzyma slug zarezerwowany | +| **Wycieki ORM (kanarek `xfail(strict=True)`)** | bez zmian | +| **PR upstream `django-easy-audit`** | [#348](https://github.com/soynatan/django-easy-audit/pull/348) | +| **`SoftDeleteLog` nie niesie `pbn_uid`** | dług z 05a §6 nadal otwarty | +| **`pbn_status=""` dwuznaczne** | bez zmian (faza 06 §6) | +| **`uczelnia_rekordu()` → `None` przy dwuznaczności** | bez zmian | +| **Reguła przynależności zduplikowana z `cerif_export`** | bez zmian | +| Słowniki (`Zrodlo`, `Konferencja`, `Projekt`, `Jednostka`) bez soft-delete | bez zmian | +| Pomiar `0492` i narzutu GiST | wciąż nikt nie zmierzył | +| **Strategia wydania** | bramka nadal otwarta | + +--- + +## 8. Wskazówki wprost dla fazy 08 (suita regresji E2E) + +- **Fixture `staff_user`/`staff_client` już są** (`src/conftest.py`). Staff ma + uprawnienia modelowe do `wydawnictwo_ciagle` i `autor` oraz grupę + „wprowadzanie danych". Jeśli faza 08 potrzebuje staffu dla innego modelu — + poszerz `content_type__model__in`, nie twórz drugiego fixture'u. +- **`_log(model, pk, akcja)`** w `test_soft_delete/test_admin.py` filtruje + `SoftDeleteLog` po `content_type` ORAZ `object_id`. Plan fazy 07 filtrował po + samym `object_id` — to nie jest unikalne globalnie, a kaskada fazy 02 loguje + przy okazji wiersze `*_Autor`, które łatwo trafiają na tę samą wartość `pk`. + Skopiuj ten helper, nie wzorzec z planu. +- **Task 2 i Task 7 planu fazy 08** (`Cache_Punktacja_*`, ewaluacja) dotykają + dokładnie tej materii, co decyzja §4.1. Test „hard-delete rekordu żywego + zostawia osierocone wiersze punktacji" byłby dowodem, że ograniczenie jest + potrzebne — dziś opiera się na lekturze kodu, nie na eksperymencie. +- **Task 4 planu fazy 08** (regresja `SoftDeleteLog`) częściowo pokrywa się + z `test_soft_delete/test_admin.py` — sprawdź, zanim napiszesz duplikat. + +--- + +## 9. Proces — co się sprawdziło w fazie 07 + +- **Weryfikacja „PINNED" kontraktów planu wobec źródeł zwróciła się natychmiast.** + Cztery rozjazdy z §3, wszystkie wykryte przed napisaniem pierwszej linii kodu. + Najgroźniejszy (§3.3, luka wielotenantowa) nie zapaliłby się w żadnym teście + z planu — plan testował wyłącznie superuserem, a dla superusera + `SiteFilteredAdminMixin` i tak jest no-opem. **Test na regresję MRO trzeba było + dopisać, bo w planie go nie było.** +- **Overview był aktualniejszy niż plan fazy.** §3.1 to błąd, przed którym + overview ostrzegał explicite. Przy rozjeździe plan-vs-overview zaufaj temu, + co potwierdzasz w kodzie, a overview czytaj ZANIM zaczniesz task. +- **Pierwszy „padający test" bywa padający z niewłaściwego powodu.** Test akcji + „Przywróć" padał nie dlatego, że akcji nie było, ale dlatego, że akcja + dostawała puste zaznaczenie (domyślny filtr chowa kosz). Rozpoznanie tego + ujawniło realną własność bezpieczeństwa (§4.2), której plan nie widział. +- **Pomiar „co faktycznie robi pakiet" bije cytat z planu.** §3.4 — plan + streszczał zachowanie `SoftDeleteFilter` i streścił je odwrotnie. Trzy linie + kodu pakietu rozstrzygnęły sprawę w minutę. diff --git a/src/bpp/admin/autor.py b/src/bpp/admin/autor.py index 5330c8faf..662aa5b31 100644 --- a/src/bpp/admin/autor.py +++ b/src/bpp/admin/autor.py @@ -31,6 +31,7 @@ WydzialAutoraFilter, ) from .helpers.fieldsets import ADNOTACJE_FIELDSET, ZapiszZAdnotacjaMixin +from .helpers.mixins import BppSoftDeleteAdminMixin from .helpers.site_filtered import SiteFilteredAdminMixin from .helpers.widgets import CHARMAP_SINGLE_LINE from .xlsx_export import resources @@ -251,6 +252,8 @@ class AutorAdmin( EksportDanychMixin, BaseBppAdminMixin, DynamicColumnsMixin, + # OSTATNI przed baza terminalna — patrz docstring BppSoftDeleteAdminMixin. + BppSoftDeleteAdminMixin, admin.ModelAdmin, ): uczelnia_field_path = "aktualna_jednostka__uczelnia" diff --git a/src/bpp/admin/helpers/mixins.py b/src/bpp/admin/helpers/mixins.py index 4b5f7de6e..ed94f01ae 100644 --- a/src/bpp/admin/helpers/mixins.py +++ b/src/bpp/admin/helpers/mixins.py @@ -1,6 +1,14 @@ +from contextlib import contextmanager + from django import forms +from django.contrib import admin, messages +from django.contrib.admin import helpers as admin_helpers +from django.db.models import ProtectedError +from django.template.response import TemplateResponse from django.urls import reverse +from django_softdelete.filters import SoftDeleteFilter +from bpp.models.soft_delete_context import soft_delete_context from bpp.models.system import Status_Korekty @@ -90,3 +98,360 @@ def has_delete_permission(self, request, obj=None): if obj.pbn_uid_id is not None: return False return super().has_delete_permission(request, obj) + + +class PokazSkasowaneFilter(SoftDeleteFilter): + """Filtr „kosz" dla changelisty modeli soft-delete. + + RÓŻNICA WOBEC PAKIETU JEST JEDNA: brak parametru w URL-u znaczy tutaj + „tylko żywe", a nie „wszystko". Pakietowy ``SoftDeleteFilter`` mapuje + ``self.value() or 'all'`` na ``'ALL'`` i zwraca cały queryset, więc + wejście na gołą changelistę pokazywałoby kosz wymieszany z żywymi + rekordami. Skoro ``get_queryset`` mixinu podaje ``global_objects``, + to ten filtr jest JEDYNYM miejscem, które chowa kosz — bez tej zmiany + nie schowałby go nikt. + + SEMANTYKA WARTOŚCI ZOSTAJE PAKIETOWA, celowo: ``is_deleted=true`` to + rekordy skasowane (pakiet mapuje ``'true'`` na + ``deleted_at__isnull=False``). Parametr w URL-u czyta się więc wprost + i da się go bezpiecznie zabookmarkować. + """ + + title = "Kosz" + + def lookups(self, request, model_admin): + return ( + ("true", "🗑️ Tylko skasowane"), + ("all", "Wszystkie (żywe + kosz)"), + ) + + def choices(self, changelist): + """Pierwsza pozycja to stan domyślny — a ten NIE znaczy „wszystkie". + + Odziedziczona etykieta „All" opisywałaby dokładnie odwrotność tego, + co filtr robi bez parametru. + """ + wybory = super().choices(changelist) + domyslny = next(iter(wybory)) + domyslny["display"] = "Bez kosza (tylko żywe)" + yield domyslny + yield from wybory + + def queryset(self, request, queryset): + wartosc = self.value() + if wartosc == "all": + return queryset + if wartosc == "true": + return queryset.filter(deleted_at__isnull=False) + return queryset.filter(deleted_at__isnull=True) + + +class BppSoftDeleteAdminMixin: + """Kosz w adminie dla modeli soft-delete (5 publikacji + ``Autor``). + + Daje: changelistę nad ``global_objects`` z filtrem kosza, akcje + „przywróć" / „usuń do kosza (z powodem)" / „usuń trwale" + (superuser-only) oraz JEDEN punkt wstrzyknięcia ``request.user``. + + ⚠️ MIEJSCE W LIŚCIE BAZ: **OSTATNIE**, tuż przed terminalnym + ``admin.ModelAdmin`` (albo przed konkretną klasą bazową admina, np. + ``Wydawnictwo_ZwarteAdmin_Baza``). NIE pierwsze. + + Plan fazy 07 kazał wpinać mixin jako PIERWSZY — to był błąd i wpiąłby + lukę wielotenantową. ``get_queryset`` poniżej nie woła ``super()`` + (nie ma jak: musi podmienić manager bazowy na ``global_objects``), + więc postawiony na początku MRO uciąłby cały łańcuch — w tym + ``SiteFilteredAdminMixin.get_queryset`` w ``AutorAdmin``, które zawęża + widok nie-superusera do jego uczelni (FD#390). Personel uczelni A + zobaczyłby i skasował autorów uczelni B. + + Na końcu łańcucha ``get_queryset`` tego mixinu jest jego PODSTAWĄ: + zwraca ``global_objects``, a wszystkie mixiny stojące wyżej nakładają + na to swoje filtry normalnie. Pozostałe nadpisane metody + (``get_actions``, ``get_list_filter``) działają z tej pozycji tak samo, + bo tamte implementacje w łańcuchu wołają ``super()`` i tylko DOKŁADAJĄ + pozycje. Strażnikiem tej decyzji jest + ``test_admin.py::test_staff_widzi_tylko_autorow_swojej_uczelni``. + """ + + def get_queryset(self, request): + """Podstawa łańcucha: ``global_objects`` (żywe + kosz). + + Skasowanego rekordu nie da się inaczej ani otworzyć, ani + przywrócić — changeform szuka obiektu w tym samym querysecie. + Kosz chowa dopiero ``PokazSkasowaneFilter``, więc domyślny widok + listy się nie zmienia. + """ + qs = self.model.global_objects.get_queryset() + ordering = self.get_ordering(request) + if ordering: + qs = qs.order_by(*ordering) + return qs + + def get_urls(self): + """# SZEW reversion (odłożone). + + Gdy włączymy ``django-reversion``, jego widok „recover deleted" + (URL ``recover/``) musi tu zostać UKRYTY albo przekierowany na + ``restore()``. Recover wskrzesza rekord POZA przepływem + soft-delete: bez zlecenia ``WYSYLKA`` do PBN, bez wpisu + ``SoftDeleteLog``, bez przeliczenia punktacji i z pominięciem + warunkowego unique na ``Autor.slug`` (husk trzyma slug + zarezerwowany). Byłaby to druga, cicha ścieżka przywracania — + dokładnie to, czemu ta faza ma zapobiegać. + """ + return super().get_urls() + + def get_list_filter(self, request): + list_filter = list(super().get_list_filter(request) or []) + if PokazSkasowaneFilter not in list_filter: + list_filter.insert(0, PokazSkasowaneFilter) + return list_filter + + # --- jeden punkt wstrzykniecia usera -------------------------------- + + @contextmanager + def _soft_delete_user_context(self, request): + """JEDEN punkt wstrzyknięcia ``request.user`` dla całego przepływu + kosza w adminie: ``delete_model``, ``delete_queryset``, + ``usun_do_kosza``, ``przywroc_zaznaczone``, ``usun_trwale_zaznaczone``. + + Deleguje do ``soft_delete_context`` z fazy 06 — thread-locala czytają + receivery sygnałów, bo sygnały pakietu ``django-soft-delete`` niosą + wyłącznie ``sender`` i ``instance``. + + DLACZEGO KONTEKST, SKORO ``delete()`` PRZYJMUJE ``user=``: bo + ``hard_delete()`` go NIE przyjmuje. ``BppPkPrzedHardDeleteMixin. + hard_delete()`` przekazuje argumenty prosto do pakietu, który o + żadnym userze nie wie — atrybucja trwałego usunięcia ma więc tylko + ten jeden kanał. Plan fazy 07 zakładał tu ``hard_delete(user=, + reason=)``; taka sygnatura nie istnieje. + + # SZEW reversion: tutaj (i tylko tutaj) dojdzie w przyszłości + # ``reversion.set_user(request.user)`` — jeden hook, nie dwa + # konkurencyjne. Patrz overview, „Kontrakty z reversion". + """ + with soft_delete_context( + user=request.user, reason=self._powod_z_requestu(request) + ): + yield + + def _powod_z_requestu(self, request): + """Powód operacji podany na stronie pośredniej (``usun_do_kosza``). + + Puste, gdy ścieżka nie pyta o powód — ``soft_delete_context`` + traktuje pusty łańcuch jako „nie wnoszę informacji" i dziedziczy + powód z kontekstu zewnętrznego, zamiast go zerować. + """ + if request.method != "POST": + return "" + return request.POST.get("powod", "") + + # --- kasowanie = kosz ----------------------------------------------- + + def _soft_delete_jeden(self, request, obj, powod): + """Soft-delete jednego rekordu; ``False`` gdy guard fazy 04 zablokował. + + POKAZUJEMY KOMUNIKAT Z WYJĄTKU, nie własny. ``raise_if_has_protected_ + children`` zna liczbę i rodzaj powiązań i już je opisuje po polsku; + drugi tekst tutaj byłby kopią tej samej reguły, która rozjedzie się + z oryginałem przy pierwszej zmianie listy relacji. + + Łapiemy per instancja, żeby jeden zablokowany autor nie przewracał + całego zaznaczenia — reszta ma trafić do kosza. + """ + try: + obj.delete(user=request.user, reason=powod) + return True + except ProtectedError as e: + self.message_user(request, e.args[0], level=messages.ERROR) + return False + + def delete_model(self, request, obj): + """Przycisk „Usuń" na changeformie przenosi do kosza. + + Bez tego nadpisania rekord i tak trafiłby do kosza (``delete()`` + modelu jest miękkie), ale BEZ atrybucji — wpis ``SoftDeleteLog`` + powstawałby z ``user=None``. + """ + with self._soft_delete_user_context(request): + # Guard rzuca ProtectedError takze tutaj — Django zrobi wtedy + # redirect z komunikatem bledu, a rekord zostanie. + self._soft_delete_jeden(request, obj, self._powod_z_requestu(request)) + + def delete_queryset(self, request, queryset): + """Akcja ``delete_selected`` — soft-delete PER INSTANCJA. + + Nigdy zbiorczo: kaskada ``*_Autor`` (faza 02), ``SoftDeleteLog`` + i sprzątanie ``Cache_Punktacja_*`` wiszą na sygnałach per obiekt, + a gate w ``BppSoftDeleteQuerySet.update()`` i tak blokuje bulk + ustawienie ``deleted_at``. + """ + with self._soft_delete_user_context(request): + powod = self._powod_z_requestu(request) + for obj in queryset: + self._soft_delete_jeden(request, obj, powod) + + # --- akcje kosza ---------------------------------------------------- + + @admin.action(description="♻️ Przywróć zaznaczone (z kosza)") + def przywroc_zaznaczone(self, request, queryset): + """Przywraca rekordy z kosza przez ten sam hook usera. + + Zaznaczenie idzie z ``global_objects``, więc może zawierać rekordy + żywe — te pomijamy zamiast wołać na nich ``restore()``. To nie jest + kosmetyka: ``restore()`` publikacji przelicza punktację dyscyplin + (operacja licząca, nie ``UPDATE``) i kolejkuje wysyłkę do PBN, + więc „przywrócenie" nieskasowanego rekordu byłoby kosztownym + no-opem z fałszywym wpisem w ``SoftDeleteLog``. + """ + with self._soft_delete_user_context(request): + przywrocono = 0 + for obj in queryset: + if obj.deleted_at is None: + continue + obj.restore(user=request.user) + przywrocono += 1 + if not przywrocono: + # Najczestsza przyczyna: operator zaznaczyl rekordy na liscie + # bez filtra kosza, wiec do akcji nie doszedl ani jeden + # skasowany wiersz. Ciche "przywrocono: 0" wygladaloby jak + # awaria przywracania. + self.message_user( + request, + "Nie przywrócono nic — w zaznaczeniu nie było rekordów " + "z kosza. Ustaw filtr „Kosz” na „🗑️ Tylko skasowane”, " + "zaznacz rekordy i powtórz akcję.", + level=messages.WARNING, + ) + return + self.message_user( + request, + f"Przywrócono z kosza: {przywrocono}.", + level=messages.SUCCESS, + ) + + #: Ile rekordow wypisac na stronie posredniej z powodem. Zaznaczenie + #: "wszystkie pasujace" (``select_across``) potrafi objac dziesiatki + #: tysiecy wierszy — wypisanie wszystkich zabiloby te strone, a nikt + #: i tak nie czyta takiej listy. + LIMIT_PODGLADU_KOSZA = 50 + + @admin.action(description="🗑️ Usuń do kosza (z powodem)") + def usun_do_kosza(self, request, queryset): + """Kasowanie do kosza z powodem podanym na stronie pośredniej. + + DLACZEGO OSOBNA AKCJA, SKORO ``delete_selected`` TEŻ IDZIE DO KOSZA: + bo Django nie ma gdzie zapytać o powód. Jego strona potwierdzenia + jest zbudowana wokół kolektora (co jeszcze zniknie), nie wokół + metadanych operacji, a ``SoftDeleteLog.powod`` odpowiada na pytanie + „dlaczego", na które sam kolektor nie odpowie. + + ``delete_selected`` zostaje działające (kosz bez powodu) — to ta + sama operacja, tylko uboższa o uzasadnienie. + """ + if request.POST.get("powod_potwierdzony"): + with self._soft_delete_user_context(request): + powod = self._powod_z_requestu(request) + usunieto = 0 + for obj in queryset: + if self._soft_delete_jeden(request, obj, powod): + usunieto += 1 + if usunieto: + self.message_user( + request, + f"Przeniesiono do kosza: {usunieto}.", + level=messages.SUCCESS, + ) + return None + + ile = queryset.count() + podglad = list(queryset[: self.LIMIT_PODGLADU_KOSZA]) + context = { + **self.admin_site.each_context(request), + "title": "Usuń do kosza", + "obiekty": podglad, + "ile_obiektow": ile, + "ile_ukrytych": max(0, ile - len(podglad)), + "wybrane_pk": request.POST.getlist(admin_helpers.ACTION_CHECKBOX_NAME), + "opts": self.model._meta, + "action_checkbox_name": admin_helpers.ACTION_CHECKBOX_NAME, + # Przenosimy stan zaznaczenia dalej — bez ``select_across`` + # potwierdzenie zawezilo by "wszystkie pasujace" do biezacej + # strony i czesc rekordow po cichu nie trafilaby do kosza. + "action_index": request.POST.get("index", 0), + "select_across": request.POST.get("select_across", "0"), + } + return TemplateResponse(request, "admin/bpp/soft_delete_powod.html", context) + + @admin.action(description="❌ Usuń TRWALE (nieodwracalnie, tylko superuser)") + def usun_trwale_zaznaczone(self, request, queryset): + """Opróżnianie kosza: fizyczne skasowanie rekordów. + + TYLKO Z KOSZA, nigdy wprost z rekordu żywego — i nie jest to + ostrożnościowy rytuał. ``Cache_Punktacja_*`` nie ma FK do + publikacji (klucz to tablica ``[content_type_id, pk]``), więc nie + sprząta jej ani kolektor Django, ani triggery. Kasuje ją dopiero + receiver ``post_soft_delete`` (faza 06). Twarde skasowanie rekordu + z pominięciem kosza zostawiłoby więc wiersze punktacji wskazujące + na nieistniejący rekord — i liczące się do ewaluacji. + + Podwójny gate na superusera (tu i w ``get_actions``) jest celowy: + ``get_actions`` decyduje o WIDOCZNOŚCI, ta metoda o WYKONANIU. + Django odrzuca akcje spoza ``get_actions``, ale to jest operacja + nieodwracalna — jeden mechanizm obronny na nią za mało. + """ + 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): + usunieto = 0 + pominieto = 0 + for obj in queryset: + if obj.deleted_at is None: + pominieto += 1 + continue + # hard_delete() NIE przyjmuje user=/reason= — atrybucję + # niesie wyłącznie kontekst powyżej. + obj.hard_delete() + usunieto += 1 + + if pominieto: + self.message_user( + request, + f"Pominięto rekordów spoza kosza: {pominieto}. Trwale usuwać " + "można wyłącznie to, co jest już w koszu — najpierw „🗑️ Usuń " + "do kosza”, potem „❌ Usuń TRWALE”.", + level=messages.WARNING, + ) + if usunieto: + self.message_user( + request, + f"Usunięto trwale (nieodwracalnie): {usunieto}.", + level=messages.SUCCESS, + ) + + def get_actions(self, request): + """Dokłada akcje kosza do tych zebranych przez resztę łańcucha. + + Wołamy ``super()`` i tylko DOKŁADAMY klucze — dlatego mixin działa + także z ostatniej pozycji w MRO (patrz docstring klasy), mimo że + wyżej stoją ``AutorAdmin.get_actions`` i ``ExportActionsMixin. + get_actions``. + """ + actions = super().get_actions(request) + actions["usun_do_kosza"] = self.get_action("usun_do_kosza") + actions["przywroc_zaznaczone"] = self.get_action("przywroc_zaznaczone") + if request.user.is_superuser: + actions["usun_trwale_zaznaczone"] = self.get_action( + "usun_trwale_zaznaczone" + ) + else: + # Gdyby ktos dopisal ta akcje wyzej w lancuchu — zdejmij. + actions.pop("usun_trwale_zaznaczone", None) + return actions diff --git a/src/bpp/admin/patent.py b/src/bpp/admin/patent.py index f37e8058b..152152d6e 100644 --- a/src/bpp/admin/patent.py +++ b/src/bpp/admin/patent.py @@ -21,7 +21,11 @@ POZOSTALE_MODELE_FIELDSET, AdnotacjeZDatamiMixin, ) -from .helpers.mixins import DomyslnyStatusKorektyMixin, Wycinaj_W_z_InformacjiMixin +from .helpers.mixins import ( + BppSoftDeleteAdminMixin, + DomyslnyStatusKorektyMixin, + Wycinaj_W_z_InformacjiMixin, +) from .wydawnictwo_zwarte import Wydawnictwo_ZwarteAdmin_Baza from .xlsx_export import resources from .xlsx_export.mixins import EksportDanychZFormatowanieMixin, ExportActionsMixin @@ -89,6 +93,8 @@ class Patent_Admin( AdnotacjeZDatamiMixin, EksportDanychZFormatowanieMixin, ExportActionsMixin, + # OSTATNI przed baza terminalna — patrz docstring BppSoftDeleteAdminMixin. + BppSoftDeleteAdminMixin, Wydawnictwo_ZwarteAdmin_Baza, ): djangoql_completion_enabled_by_default = False diff --git a/src/bpp/admin/praca_doktorska.py b/src/bpp/admin/praca_doktorska.py index 0d6626ebd..a92ef0b0e 100644 --- a/src/bpp/admin/praca_doktorska.py +++ b/src/bpp/admin/praca_doktorska.py @@ -32,7 +32,11 @@ POZOSTALE_MODELE_FIELDSET, AdnotacjeZDatamiMixin, ) -from .helpers.mixins import DomyslnyStatusKorektyMixin, Wycinaj_W_z_InformacjiMixin +from .helpers.mixins import ( + BppSoftDeleteAdminMixin, + DomyslnyStatusKorektyMixin, + Wycinaj_W_z_InformacjiMixin, +) from .wydawnictwo_ciagle import CleanDOIWWWPublicWWWMixin from .xlsx_export import resources from .xlsx_export.mixins import EksportDanychZFormatowanieMixin, ExportActionsMixin @@ -211,6 +215,8 @@ class Praca_DoktorskaAdmin( ConstanceScoringFieldsMixin, EksportDanychZFormatowanieMixin, ExportActionsMixin, + # OSTATNI przed baza terminalna — patrz docstring BppSoftDeleteAdminMixin. + BppSoftDeleteAdminMixin, Praca_Doktorska_Habilitacyjna_Admin_Base, ): resource_classes = [Praca_DoktorskaResource] diff --git a/src/bpp/admin/praca_habilitacyjna.py b/src/bpp/admin/praca_habilitacyjna.py index 7ea95a71a..bbfdf7a75 100644 --- a/src/bpp/admin/praca_habilitacyjna.py +++ b/src/bpp/admin/praca_habilitacyjna.py @@ -35,7 +35,11 @@ MODEL_ZE_SZCZEGOLAMI, POZOSTALE_MODELE_FIELDSET, ) -from .helpers.mixins import DomyslnyStatusKorektyMixin, Wycinaj_W_z_InformacjiMixin +from .helpers.mixins import ( + BppSoftDeleteAdminMixin, + DomyslnyStatusKorektyMixin, + Wycinaj_W_z_InformacjiMixin, +) from .praca_doktorska import Praca_Doktorska_Habilitacyjna_Admin_Base # @@ -208,6 +212,8 @@ class Praca_HabilitacyjnaAdmin( ConstanceScoringFieldsMixin, EksportDanychZFormatowanieMixin, ExportActionsMixin, + # OSTATNI przed baza terminalna — patrz docstring BppSoftDeleteAdminMixin. + BppSoftDeleteAdminMixin, Praca_Doktorska_Habilitacyjna_Admin_Base, ): resource_classes = [Praca_HabilitacyjnaResource] diff --git a/src/bpp/admin/wydawnictwo_ciagle.py b/src/bpp/admin/wydawnictwo_ciagle.py index 1100224a0..dc07c3f63 100644 --- a/src/bpp/admin/wydawnictwo_ciagle.py +++ b/src/bpp/admin/wydawnictwo_ciagle.py @@ -86,7 +86,11 @@ MODEL_Z_OPLATA_ZA_PUBLIKACJE_FIELDSET, MODEL_Z_POLAMI_EWALUACJI_PBN_FIELDSET, ) -from .helpers.mixins import OptionalPBNSaveMixin, RestrictDeletionWhenPBNUIDSetMixin +from .helpers.mixins import ( + BppSoftDeleteAdminMixin, + OptionalPBNSaveMixin, + RestrictDeletionWhenPBNUIDSetMixin, +) from .xlsx_export import resources from .xlsx_export.mixins import EksportDanychZFormatowanieMixin, ExportActionsMixin from .zglos_publikacje_helpers import UzupelniajWstepneDanePoNumerzeZgloszeniaMixin @@ -286,6 +290,8 @@ class Wydawnictwo_CiagleAdmin( ExportActionsMixin, DynamicColumnsMixin, RestrictDeletionWhenPBNUIDSetMixin, + # OSTATNI przed ModelAdmin — patrz docstring BppSoftDeleteAdminMixin. + BppSoftDeleteAdminMixin, admin.ModelAdmin, ): change_list_template = "admin/bpp/wydawnictwo_ciagle/change_list.html" diff --git a/src/bpp/admin/wydawnictwo_zwarte.py b/src/bpp/admin/wydawnictwo_zwarte.py index d07cbff6b..edfaf974b 100644 --- a/src/bpp/admin/wydawnictwo_zwarte.py +++ b/src/bpp/admin/wydawnictwo_zwarte.py @@ -59,7 +59,11 @@ sprawdz_duplikaty_www_doi, ) from .helpers.constance_field_mixin import ConstanceScoringFieldsMixin -from .helpers.mixins import OptionalPBNSaveMixin, RestrictDeletionWhenPBNUIDSetMixin +from .helpers.mixins import ( + BppSoftDeleteAdminMixin, + OptionalPBNSaveMixin, + RestrictDeletionWhenPBNUIDSetMixin, +) from .nagroda import NagrodaInline # Proste tabele @@ -496,6 +500,8 @@ class Wydawnictwo_ZwarteAdmin( AdminCrossrefAPIMixin, AdminCrossrefPBNAPIMixin, RestrictDeletionWhenPBNUIDSetMixin, + # OSTATNI przed baza terminalna — patrz docstring BppSoftDeleteAdminMixin. + BppSoftDeleteAdminMixin, Wydawnictwo_ZwarteAdmin_Baza, ): change_list_template = "admin/bpp/wydawnictwo_zwarte/change_list.html" diff --git a/src/bpp/apps.py b/src/bpp/apps.py index 370ab210b..d1f5f9a3b 100644 --- a/src/bpp/apps.py +++ b/src/bpp/apps.py @@ -56,6 +56,13 @@ def ready(self): zainstaluj() + # Receivery soft-delete -> SoftDeleteLog + kolejka PBN (faza 06). + # Podpięte bez `sender=`, czyli obejmują KAŻDY model soft-delete — + # dodanie kolejnego nie wymaga dopisywania nic tutaj. + from bpp.receivers import soft_delete as soft_delete_receivers + + soft_delete_receivers.register() + # Initialize Rollbar with global hostname handler from bpp.rollbar_config import configure_rollbar diff --git a/src/bpp/migrations/0503_softdeletelog.py b/src/bpp/migrations/0503_softdeletelog.py new file mode 100644 index 000000000..e34e9cf2f --- /dev/null +++ b/src/bpp/migrations/0503_softdeletelog.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.16 on 2026-08-16 20:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0502_autor_soft_delete'), + ('contenttypes', '0002_remove_content_type_name'), + ('pbn_export_queue', '0011_pbn_export_queue_operacja'), + ] + + operations = [ + migrations.CreateModel( + name='SoftDeleteLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField(db_index=True, verbose_name='ID obiektu')), + ('akcja', models.CharField(choices=[('delete', 'Usunięcie (kosz)'), ('restore', 'Przywrócenie'), ('hard_delete', 'Usunięcie trwałe')], db_index=True, max_length=20, verbose_name='Akcja')), + ('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_status', models.CharField(blank=True, default='', max_length=50, verbose_name='Status PBN')), + ('content_type', models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Typ rekordu')), + ('pbn_queue_entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='pbn_export_queue.pbn_export_queue', verbose_name='Wpis kolejki PBN')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Użytkownik')), + ], + options={ + 'verbose_name': 'Log operacji soft-delete', + 'verbose_name_plural': 'Logi operacji soft-delete', + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['content_type', 'object_id'], name='bpp_softdel_content_e5d237_idx')], + }, + ), + ] diff --git a/src/bpp/models/__init__.py b/src/bpp/models/__init__.py index 2116c2bb0..4cad1fa40 100644 --- a/src/bpp/models/__init__.py +++ b/src/bpp/models/__init__.py @@ -52,3 +52,4 @@ from .opi_2012 import * # noqa from .oplaty_log import * # noqa +from .soft_delete_log import * # noqa diff --git a/src/bpp/models/autor.py b/src/bpp/models/autor.py index ed7447b5b..dca48520c 100644 --- a/src/bpp/models/autor.py +++ b/src/bpp/models/autor.py @@ -32,10 +32,12 @@ from bpp.models import LinkDoPBNMixin, ModelZAdnotacjami, ModelZNazwa, NazwaISkrot from bpp.models.abstract import ModelZPBN_ID from bpp.models.soft_delete import ( + BppPkPrzedHardDeleteMixin, BppSoftDeleteQuerySet, dopisz_znacznik_zmiany, raise_if_has_protected_children, ) +from bpp.models.soft_delete_context import soft_delete_context from bpp.util import FulltextSearchMixin, zaloguj_polkniety_wyjatek logger = logging.getLogger(__name__) @@ -256,7 +258,13 @@ def _relacje_chronione_autora(): ] -class Autor(LinkDoPBNMixin, ModelZAdnotacjami, ModelZPBN_ID, SoftDeleteModel): +class Autor( + BppPkPrzedHardDeleteMixin, + LinkDoPBNMixin, + ModelZAdnotacjami, + ModelZPBN_ID, + SoftDeleteModel, +): url_do_pbn = const.LINK_PBN_DO_AUTORA imiona = models.CharField(max_length=512, db_index=True) @@ -596,9 +604,13 @@ def delete(self, *args, user=None, reason="", **kwargs): autorstw (spec §1, §10.1). Autor z autorstwami i tak tu nie dojdzie — zatrzyma go guard. - ``user``/``reason`` są tylko przepuszczane; konsumuje je - ``SoftDeleteLog`` z fazy 06. W sygnaturze muszą być już teraz - (kontrakt PINNED). + ``user``/``reason`` trafiają do ``SoftDeleteLog`` (faza 06) przez + thread-local ``soft_delete_context``. + + Kontekst zakładamy PO guardzie, nie przed: gdy guard rzuci + ``ProtectedError``, żaden sygnał nie poleci, więc nie ma czego + atrybuować. (Poprawność nie zależy od tej kolejności — context + manager sprząta w ``finally`` — ale węższy zakres jest uczciwszy.) """ raise_if_has_protected_children( self, @@ -607,7 +619,7 @@ def delete(self, *args, user=None, reason="", **kwargs): ) txid = kwargs.pop("transaction_id", None) or uuid.uuid4() - with transaction.atomic(): + with soft_delete_context(user=user, reason=reason), transaction.atomic(): self.deleted_at = timezone.now() self.restored_at = None self.transaction_id = txid @@ -632,7 +644,7 @@ def restore(self, *args, strict: bool = False, user=None, **kwargs): nie zabrało, więc nie ma czego wskrzeszać. """ txid = self.transaction_id - with transaction.atomic(): + with soft_delete_context(user=user), transaction.atomic(): self.deleted_at = None self.restored_at = timezone.now() self.transaction_id = None diff --git a/src/bpp/models/repozytorium.py b/src/bpp/models/repozytorium.py index 85f3bc110..129ff79b3 100644 --- a/src/bpp/models/repozytorium.py +++ b/src/bpp/models/repozytorium.py @@ -8,6 +8,7 @@ from model_utils import Choices from bpp.const import TRYB_DOSTEPU +from bpp.models.soft_delete import BppPkPrzedHardDeleteMixin def element_repozytorium_upload_to(instance, filename): @@ -15,7 +16,7 @@ def element_repozytorium_upload_to(instance, filename): return f"protected/repozytorium/{uuid.uuid4()}{ext}" -class Element_Repozytorium(SoftDeleteModel): +class Element_Repozytorium(BppPkPrzedHardDeleteMixin, SoftDeleteModel): ER_TRYB_DOSTEPU = Choices( (TRYB_DOSTEPU.NIEJAWNY, "niejawny"), (TRYB_DOSTEPU.TYLKO_W_SIECI, "tylko w sieci"), diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py index 1fda922e2..e6e317de7 100644 --- a/src/bpp/models/soft_delete.py +++ b/src/bpp/models/soft_delete.py @@ -38,6 +38,7 @@ """ import uuid +from collections import defaultdict from django.core.exceptions import FieldDoesNotExist from django.db import transaction @@ -53,6 +54,8 @@ from django_softdelete.models import SoftDeleteModel from django_softdelete.signals import post_restore, post_soft_delete +from bpp.models.soft_delete_context import soft_delete_context + #: 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 @@ -162,10 +165,47 @@ def raise_if_has_protected_children(instance, relations, label): ) +def hard_delete_per_instancja(queryset): + """``hard_delete()`` wiersz po wierszu, żeby leciał ``post_hard_delete``. + + DLACZEGO NIE BULK: pakietowy ``hard_delete()`` na querysecie to goły + ``super().delete()`` (``django_softdelete/managers.py``) — jedno + zapytanie, ZERO sygnałów. Rekordy znikały fizycznie i bez jednego wpisu + w ``SoftDeleteLog``, czyli dokładnie ta klasa cichej utraty, przed którą + ten log ma chronić. Najbardziej prawdopodobna droga do tej luki to + opróżnianie kosza z admina (faza 07). + + To ta sama zasada, którą kieruje się gate w ``BppSoftDeleteQuerySet. + update()`` i wąska kaskada fazy 02: operacja masowa nie ma prawa być + tańsza kosztem pominięcia sygnałów. Pakiet stosuje ją zresztą sam — + jego ``SoftDeleteQuerySet.delete()`` też iteruje po instancjach. + + KOSZT: N zapytań zamiast jednego. Świadomy — audyt masowego kasowania + jest wart więcej niż pojedynczy ``DELETE ... WHERE id IN (...)``. + + Zwrotka jak w Django: ``(łączna_liczba, {etykieta_modelu: liczba})``, + zsumowana po instancjach. + """ + laczna = 0 + liczniki = defaultdict(int) + # list(): iterujemy po materializowanej liście, bo kasujemy w trakcie. + for obiekt in list(queryset): + ile, per_model = obiekt.hard_delete() + laczna += ile + for etykieta, n in per_model.items(): + liczniki[etykieta] += n + return laczna, dict(liczniki) + + class BppSoftDeleteQuerySet(SoftDeleteQuerySet): """Gate: blokuje bulk-ustawienie deleted_at/restored_at przez .update() (omijałoby post_save, kaskadę *_Autor, SoftDeleteLog i reversion).""" + def hard_delete(self): + return hard_delete_per_instancja(self) + + hard_delete.alters_data = True + def update(self, **kwargs): if "deleted_at" in kwargs or "restored_at" in kwargs: raise RuntimeError( @@ -220,6 +260,13 @@ def restore(self, strict: bool = False, *args, **kwargs): restore.alters_data = True + def hard_delete(self): + """Opróżnianie kosza też musi zostawiać ślad — patrz + ``hard_delete_per_instancja()``.""" + return hard_delete_per_instancja(self) + + hard_delete.alters_data = True + class BppDeletedManager(DeletedManager): def get_queryset(self): @@ -228,7 +275,64 @@ def get_queryset(self): ) -class BppAutorstwoSoftDeleteMixin(SoftDeleteModel): +#: Atrybut, pod którym ``hard_delete()`` zostawia ``pk`` na czas sygnału. +ATRYBUT_PK_PRZED_HARD_DELETE = "_bpp_pk_przed_hard_delete" + + +class BppPkPrzedHardDeleteMixin: + """Zapamiętuje ``pk`` przed twardym skasowaniem, dla receivera fazy 06. + + ``SoftDeleteModel.hard_delete()`` woła ``Model.delete()``, a kolektor + Django na samym końcu zeruje ``pk`` skasowanych instancji — + ``post_hard_delete`` leci PO tym (pakiet, ``models.py:84-85``). Receiver + dostaje więc instancję z ``pk is None`` i nie ma z czego zapisać + ``SoftDeleteLog.object_id``. + + Naiwne „zapisz ``instance.pk``" dałoby ``object_id=None``, czyli + ``IntegrityError`` w receiverze — a wyjątek z receivera przewróciłby + ``hard_delete()``. Audyt zepsułby operację, którą ma tylko obserwować. + + NIE jest modelem (zwykła klasa, nie ``models.Model``) — dlatego + dopisanie go do baz istniejącego modelu NIE generuje migracji. Musi + stać PRZED ``SoftDeleteModel`` w liście baz, żeby jego ``hard_delete`` + wygrał w MRO. + + Strażnikiem kompletności jest + ``test_receivers.py::test_kazdy_model_soft_delete_zachowuje_pk`` — + nowy model soft-delete bez tego mixinu zapala się w testach, nie + dopiero przy pierwszym twardym skasowaniu na produkcji. + """ + + def hard_delete(self, *args, **kwargs): + setattr(self, ATRYBUT_PK_PRZED_HARD_DELETE, self.pk) + return super().hard_delete(*args, **kwargs) + + hard_delete.alters_data = True + + +def pk_dla_audytu(instance): + """``pk`` instancji, także po twardym skasowaniu (zerującym ``pk``). + + Rzuca ``RuntimeError``, gdy ``pk`` nie jest znany — to znaczy, że model + soft-delete nie ma ``BppPkPrzedHardDeleteMixin``. Głośno, bo cichy + ``return`` zamieniłby audyt w atrapę dokładnie w tym przypadku, przed + którym ma chronić: rekord znikający fizycznie i bez śladu. + """ + if instance.pk is not None: + return instance.pk + + pk = getattr(instance, ATRYBUT_PK_PRZED_HARD_DELETE, None) + if pk is None: + raise RuntimeError( + f"Nie znam pk dla {instance._meta.label} — model soft-delete bez " + f"BppPkPrzedHardDeleteMixin, więc SoftDeleteLog nie ma czego " + f"zapisać w object_id. Dopisz ten mixin do baz modelu (przed " + f"SoftDeleteModel)." + ) + return pk + + +class BppAutorstwoSoftDeleteMixin(BppPkPrzedHardDeleteMixin, SoftDeleteModel): """SoftDeleteModel + nasze managery dla through-modeli *_Autor. Wpinany w 3 KONKRETNE modele (Wydawnictwo_Ciagle_Autor, @@ -287,7 +391,7 @@ def restore( return super().restore(strict, transaction_id, *args, **kwargs) -class BppPublikacjaSoftDeleteMixin(SoftDeleteModel): +class BppPublikacjaSoftDeleteMixin(BppPkPrzedHardDeleteMixin, SoftDeleteModel): """SoftDeleteModel dla 5 modeli PUBLIKACJI (faza 02) z **wąską, kontrolowaną** kaskadą na własne wiersze ``*_Autor`` pod wspólnym ``transaction_id``. @@ -362,6 +466,56 @@ def _autorstwa_do_kaskady(self): return [] return list(getattr(self, NAZWA_RELACJI_AUTORSTW).all()) + # --- atrybucja tenanta ---------------------------------------------- + + def uczelnia_rekordu(self): + """Uczelnia, do której należy rekord, albo ``None`` przy dwuznaczności. + + PO CO: wpisy ``PBN_Export_Queue`` tworzone przez receivery fazy 06 + nie mają requestu, z którego reszta kodu bierze tenanta + (``Uczelnia.objects.get_for_request``). Bez uczelni + ``_pozyskaj_klienta_pbn()`` spada na „jedyna-albo-głośny-błąd", więc + w multi-hosted wycofanie oświadczeń kończyłoby się + ``FINISHED_ERROR``. + + REGUŁA JEST ODWRÓCENIEM PRZYNALEŻNOŚCI Z FAZY 05b — ``naleza_ + wydawnictwa`` / ``naleza_prace`` (``cerif_export/providers/ + publikacje.py``). Nie definiujemy drugiej, konkurencyjnej: gdy + tamta się zmieni, ta musi pójść za nią. Odwracamy, zamiast wołać + wprost, bo ``cerif_export`` zależy od ``bpp``, nie odwrotnie. + + ``global_objects`` JEST KONIECZNE, nie ostrożnościowe: wąska + kaskada fazy 02 kasuje wiersze ``*_Autor`` PRZED wysłaniem + ``post_soft_delete`` rodzica. W momencie odczytu autorstwa są już + w koszu, więc ``objects`` zwróciłoby pustkę i uczelnia wychodziłaby + ``None`` przy każdym kasowaniu — czyli zawsze wtedy, kiedy jest + potrzebna. + + DWUZNACZNOŚĆ → ``None``. Praca współautorska między uczelniami nie + ma jednego właściciela, a wybranie „pierwszej z brzegu" wysłałoby + wycofanie przez konto PBN cudzego tenanta. ``None`` degraduje do + zachowania sprzed tej fazy: jedna uczelnia w bazie działa, kilka + daje głośny błąd na wpisie kolejki. + """ + from bpp.models.uczelnia import Uczelnia + + rel = self._relacja_autorstw() + if rel is None: + # Praca_Doktorska / Praca_Habilitacyjna — autor i jednostka + # siedzą na wierszu samej pracy (odpowiednik ``naleza_prace``). + uczelnie = {self.jednostka.uczelnia_id} + else: + uczelnie = set( + rel.related_model.global_objects.filter( + **{rel.field.name: self} + ).values_list("jednostka__uczelnia_id", flat=True) + ) + + uczelnie.discard(None) + if len(uczelnie) != 1: + return None + return Uczelnia.objects.filter(pk=uczelnie.pop()).first() + # --- kontrakt zapisu ------------------------------------------------ def save(self, *args, **kwargs): @@ -384,12 +538,16 @@ def save(self, *args, **kwargs): 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. + ``user``/``reason`` trafiają do ``SoftDeleteLog`` (faza 06) przez + thread-local ``soft_delete_context``. Kontekst obejmuje CAŁE ciało, + nie samo ``post_soft_delete.send()``: kaskada na ``*_Autor`` wysyła + własne sygnały (przez ``delete()`` pakietu), a te mają zostać + zalogowane z tym samym userem i powodem. Zawężenie kontekstu do + ostatniej linii dałoby wpisy autorstw z ``user=None`` mimo + świadomej decyzji operatora. """ txid = kwargs.pop("transaction_id", None) or uuid.uuid4() - with transaction.atomic(): + with soft_delete_context(user=user, reason=reason), 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). @@ -420,7 +578,7 @@ def restore(self, *args, strict: bool = False, user=None, **kwargs): """ txid = self.transaction_id rel = self._relacja_autorstw() - with transaction.atomic(): + with soft_delete_context(user=user), transaction.atomic(): if txid is not None and rel is not None: # Nazwa pola FK z metadanych relacji — bez zaszywania # "rekord" na sztywno. diff --git a/src/bpp/models/soft_delete_context.py b/src/bpp/models/soft_delete_context.py new file mode 100644 index 000000000..a12016409 --- /dev/null +++ b/src/bpp/models/soft_delete_context.py @@ -0,0 +1,80 @@ +"""Kanał atrybucji „kto i dlaczego" dla operacji soft-delete. + +DLACZEGO THREAD-LOCAL, A NIE ARGUMENT: sygnały pakietu +``django-soft-delete`` (``post_soft_delete``, ``post_restore``, +``post_hard_delete``) niosą wyłącznie ``sender`` i ``instance`` — usera ani +powodu nie przekazują i nie da się ich dołożyć bez forka pakietu. Receivery +z ``bpp/receivers/soft_delete.py`` są jednak JEDYNYM miejscem, w którym +powstaje ``SoftDeleteLog``, więc user musi tam jakoś dojechać. Kontekst +ustawiany przez ``delete(user=, reason=)`` tuż przed wysłaniem sygnału jest +najwęższym kanałem, jaki załatwia sprawę dla wszystkich modeli naraz. + +KONTRAKT Z REVERSION: to ten SAM punkt wstrzyknięcia, w który wepnie się +przyszłe ``reversion.set_user`` — jeden hook, nie dwa konkurencyjne. + +Operacje bez zalogowanego użytkownika (scalanie duplikatów, celery, gołe +``.delete()`` w skryptach) po prostu nie wchodzą w kontekst: akcesory +zwracają wtedy ``None``/``""`` i log powstaje bez atrybucji. To jest +poprawny wynik, nie błąd — „nie wiadomo kto" jest uczciwszą odpowiedzią niż +podstawienie pierwszego lepszego konta. +""" + +import threading +from contextlib import contextmanager + +__all__ = [ + "soft_delete_context", + "current_soft_delete_user", + "current_soft_delete_reason", +] + +_ctx = threading.local() + + +@contextmanager +def soft_delete_context(user=None, reason=""): + """Ustawia thread-local user/reason na czas soft-delete / restore. + + Receivery czytają to przez ``current_soft_delete_user()`` / + ``current_soft_delete_reason()``. + + REENTRANCJA JEST WYMAGANA, nie „miła": soft-delete publikacji kasuje + najpierw swoje wiersze ``*_Autor`` (wąska kaskada fazy 02), a każdy + z nich wchodzi w ten kontekst ponownie. Zapamiętanie i odtworzenie + poprzednich wartości sprawia, że kaskada dziedziczy usera rodzica, + zamiast go wyzerować w połowie operacji. + + Sprzątanie siedzi w ``finally``, bo ``delete()`` bywa przerywany — + guard fazy 04 rzuca ``ProtectedError`` dla autora z pracami. Kontekst, + który przeciekłby po wyjątku, przypisałby cudzego usera NASTĘPNEJ + operacji w tym wątku (w produkcji: kolejnemu requestowi na tym samym + workerze). Byłby to błąd cichy i nie do wykrycia po fakcie. + + POMINIĘTY ARGUMENT DZIEDZICZY, NIE ZERUJE. Wejście bez ``user`` + zachowuje usera z kontekstu zewnętrznego. Bez tej reguły oba + przewidziane sposoby atrybucji wykluczałyby się nawzajem: ``delete()`` + (fazy 02/04) zakłada ten kontekst ZAWSZE, więc wywołanie bez ``user=`` + wewnątrz jawnego ``soft_delete_context(user=X)`` wyzerowałoby X i + operacja trafiłaby do logu jako niczyja. Opakowanie, które nie wnosi + informacji, nie ma prawa jej niszczyć — ``None`` znaczy tu „nie wiem", + a nie „wiem, że nikt". + """ + prev_user = getattr(_ctx, "user", None) + prev_reason = getattr(_ctx, "reason", "") + _ctx.user = user if user is not None else prev_user + _ctx.reason = reason if reason else prev_reason + try: + yield + finally: + _ctx.user = prev_user + _ctx.reason = prev_reason + + +def current_soft_delete_user(): + """User bieżącej operacji soft-delete albo ``None`` poza kontekstem.""" + return getattr(_ctx, "user", None) + + +def current_soft_delete_reason(): + """Powód bieżącej operacji soft-delete albo ``""`` poza kontekstem.""" + return getattr(_ctx, "reason", "") diff --git a/src/bpp/models/soft_delete_log.py b/src/bpp/models/soft_delete_log.py new file mode 100644 index 000000000..9abe1a643 --- /dev/null +++ b/src/bpp/models/soft_delete_log.py @@ -0,0 +1,83 @@ +"""Dedykowany audyt operacji soft-delete (kto / kiedy / dlaczego / PBN). + +DLACZEGO OSOBNY MODEL, SKORO JEST ``django-easy-audit``: easy-audit +zapisuje ZMIANY PÓL, więc soft-delete widzi jako „``deleted_at``: null → +2026-08-16" — bez powodu, bez związku ze zleceniem wycofania z PBN i bez +odpowiedzi na pytanie „co jeszcze poszło do kosza tą samą decyzją". +Ten model odpowiada na pytania operacyjne: kto usunął, dlaczego, czy +oświadczenia wyjechały z PBN i pod jakim wpisem kolejki. + +DLACZEGO GFK, A NIE FK: log musi przeżyć rekord. Przy ``HARD_DELETE`` +wiersz publikacji znika fizycznie, a wpis zostaje jako jedyny ślad, że +istniała. FK z ``CASCADE`` skasowałby dowód razem z rekordem, a FK +z ``PROTECT`` uniemożliwiłby samo kasowanie. +""" + +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + +__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" + + # db_index=False: redundantny wobec Meta.indexes Index(content_type, + # object_id) — content_type jest tam kolumną wiodącą. Wzorzec jak + # w OplatyPublikacjiLog. + content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + verbose_name="Typ rekordu", + db_index=False, + ) + 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( + settings.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"] + # Bez osobnego Index(["timestamp"]) — pole ma już db_index=True, + # a drugi identyczny indeks to wyłącznie koszt zapisu przy każdym + # wpisie logu (a wpis powstaje przy KAŻDYM soft-delete, także dla + # każdego wiersza *_Autor kasowanego kaskadą). + indexes = [ + models.Index(fields=["content_type", "object_id"]), + ] + + def __str__(self): + return f"{self.get_akcja_display()}: {self.content_object} ({self.timestamp})" diff --git a/src/bpp/newsfragments/soft-delete-06-softdeletelog.feature.rst b/src/bpp/newsfragments/soft-delete-06-softdeletelog.feature.rst new file mode 100644 index 000000000..cbf3e71d3 --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-06-softdeletelog.feature.rst @@ -0,0 +1,6 @@ +Operacje usuwania rekordów do kosza, przywracania ich i kasowania trwale są +odtąd zapisywane w dzienniku „Log operacji soft-delete": kto, kiedy, z jakim +powodem oraz czy zlecono wycofanie lub ponowną wysyłkę oświadczeń do PBN. +Wrzucenie pracy do kosza usuwa też jej punktację z pamięci podręcznej +ewaluacji (praca w koszu nie wnosi już slotów ani punktów), a przywrócenie +przelicza ją na nowo. diff --git a/src/bpp/newsfragments/soft-delete-admin-kosz.feature.rst b/src/bpp/newsfragments/soft-delete-admin-kosz.feature.rst new file mode 100644 index 000000000..6727b572b --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-admin-kosz.feature.rst @@ -0,0 +1,7 @@ +W panelu admina pojawił się „kosz" dla pięciu typów publikacji i dla +autorów: usunięcie rekordu jest odwracalne, filtr „Kosz" pokazuje rekordy +skasowane, akcja „♻️ Przywróć zaznaczone" je odzyskuje, a akcja „🗑️ Usuń +do kosza (z powodem)" zapisuje w dzienniku operacji powód usunięcia wraz +z osobą, która je wykonała. Nieodwracalne „❌ Usuń TRWALE" jest dostępne +wyłącznie dla superużytkownika i wyłącznie dla rekordów już będących +w koszu. diff --git a/src/bpp/receivers/__init__.py b/src/bpp/receivers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/bpp/receivers/soft_delete.py b/src/bpp/receivers/soft_delete.py new file mode 100644 index 000000000..d9ee087a0 --- /dev/null +++ b/src/bpp/receivers/soft_delete.py @@ -0,0 +1,184 @@ +"""Receivery sygnałów ``django-soft-delete`` → ``SoftDeleteLog``. + +JEDEN punkt podpięcia dla WSZYSTKICH modeli soft-delete — receivery są +rejestrowane bez ``sender=``, więc nowy model soft-delete jest logowany bez +dopisywania czegokolwiek tutaj. Rejestracja: ``BppConfig.ready()``. + +Objęte modele (stan 2026-08-16): 5 publikacji + 3 ``*_Autor`` (fazy 02/01), +``Autor`` (04), ``Element_Repozytorium`` (01) oraz +``zglos_publikacje.Zgloszenie_Publikacji`` — ten ostatni jest soft-delete +NIEZALEŻNIE od faz 01-04 i nie było go na żadnej liście w planach; znalazła +go dopiero asercja ``test_kazdy_model_soft_delete_zachowuje_pk`` nad +``apps.get_models()``. Nie wypisuj tej listy nigdzie w kodzie — to ta +asercja jest źródłem prawdy. + +Usera i powód wnosi thread-local ``soft_delete_context`` — sygnały pakietu +ich nie niosą (patrz ``bpp/models/soft_delete_context.py``). +""" + +from django.contrib.contenttypes.models import ContentType + +from bpp.models.soft_delete import BppPublikacjaSoftDeleteMixin, pk_dla_audytu +from bpp.models.soft_delete_context import ( + current_soft_delete_reason, + current_soft_delete_user, +) +from bpp.models.soft_delete_log import SoftDeleteLog + +#: Wartości ``SoftDeleteLog.pbn_status`` (kontrakt PINNED planu fazy 06). +#: To OSOBNY słownik od ``PBN_Export_Queue.Operacja`` (``"wysylka"`` / +#: ``"wycofanie"``) — tamten opisuje wpis kolejki, ten opisuje, co receiver +#: z tym wpisem zrobił. +STATUS_WYCOFANIE = "WYCOFANIE" +STATUS_WYSYLKA = "WYSYLKA" + + +def _kolejkuj_pbn(instance, zakolejkuj, status): + """Zleca operację PBN dla PUBLIKACJI. Zwraca ``(wpis, status)``. + + GATE IDZIE PO TYPIE, nie po obecności ``pbn_uid``. Plan fazy 06 + proponował uniwersalne ``getattr(instance, "pbn_uid_id", None)``, + opierając się na twierdzeniu, że „``Autor`` i ``*_Autor`` nie mają + ``pbn_uid``". ``Autor.pbn_uid`` jednak ISTNIEJE (FK do + ``pbn_api.Scientist``), więc tamten gate wstawiłby autora do kolejki + eksportu PUBLIKACJI i odpalił dla niego wysyłkę. + + Sam ``pbn_uid`` nie wystarczyłby też od drugiej strony: przy RESTORE + wołamy ``zakolejkuj_wysylke``, która świadomie NIE MA gate'u na + ``pbn_uid`` (faza 05a) — przyjęłaby więc każdy wiersz ``*_Autor`` + przywracany kaskadą i publikacja z N autorami dałaby N zbędnych zleceń. + + ``uczelnia`` wyprowadzana z rekordu, bo receiver nie ma requestu — + patrz ``BppPublikacjaSoftDeleteMixin.uczelnia_rekordu()``. + """ + if not isinstance(instance, BppPublikacjaSoftDeleteMixin): + return None, "" + + wpis = zakolejkuj( + instance, + user=current_soft_delete_user(), + uczelnia=instance.uczelnia_rekordu(), + ) + # ``None`` znaczy „gate niespełniony ALBO rekord już czeka w kolejce" + # (idempotencja ``sprobuj_utowrzyc_wpis``). Obu przypadków nie da się + # tu rozróżnić — obie funkcje kolejkujące zwracają ``None``. + return wpis, (status if wpis is not None else "") + + +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=pk_dla_audytu(instance), + akcja=akcja, + user=current_soft_delete_user(), + powod=current_soft_delete_reason(), + pbn_queue_entry=pbn_queue_entry, + pbn_status=pbn_status, + ) + + +def _wylicza_punktacje(instance): + """Czy rekord wnosi wiersze do ``Cache_Punktacja_*``. + + Gate zawęża do ``Wydawnictwo_Ciagle``, ``Wydawnictwo_Zwarte`` + i ``Patent`` — tylko one dziedziczą ``ModelZPrzeliczaniemDyscyplin``. + Prace dyplomowe, ``Autor``, ``*_Autor`` i ``Element_Repozytorium`` + nie mają nawet metody ``przelicz_punkty_dyscyplin()``, więc bez gate'u + receiver wywracałby się na ``AttributeError``. + + Gate załatwia przy okazji drugą rzecz: kaskada ``*_Autor`` emituje + własne sygnały, a te wiersze tego abstraktu nie dziedziczą — więc + przeliczenie zachodzi RAZ, przy sygnale rodzica, a nie 1 + N razy. + """ + from bpp.models.abstract import ModelZPrzeliczaniemDyscyplin + + return isinstance(instance, ModelZPrzeliczaniemDyscyplin) + + +def _skasuj_punktacje(instance): + """Kosz zabiera rekordowi sloty i punkty w ewaluacji. + + ``Cache_Punktacja_*`` nie ma FK do publikacji (klucz to tablica + ``rekord_id = [content_type_id, pk]``), więc nie sprząta jej ani + kaskada Django, ani kaskada ``*_Autor`` z fazy 02, ani triggery + denormalizacji. Bez tego praca w koszu nadal liczyłaby się do + ewaluacji. + """ + from bpp.models.sloty.core import IPunktacjaCacher + + IPunktacjaCacher(instance).removeEntries() + + +def on_post_soft_delete(sender, instance, **kwargs): + """Kosz → zlecenie wycofania oświadczeń z PBN + wpis audytu.""" + from pbn_export_queue.operacje import zakolejkuj_wycofanie + + if _wylicza_punktacje(instance): + _skasuj_punktacje(instance) + + wpis, status = _kolejkuj_pbn(instance, zakolejkuj_wycofanie, STATUS_WYCOFANIE) + _utworz_log( + instance, + SoftDeleteLog.Akcja.DELETE, + pbn_queue_entry=wpis, + pbn_status=status, + ) + + +def on_post_restore(sender, instance, **kwargs): + """Przywrócenie → ponowna wysyłka do PBN, punktacja i wpis audytu. + + Punktację PRZELICZAMY, a nie odtwarzamy z kopii: wiersze + ``Cache_Punktacja_*`` zostały skasowane, a przez czas pobytu w koszu + mogły się zmienić dyscypliny autorów albo progi punktowe. Odtworzenie + starych wartości przywróciłoby nieaktualny stan. + + ⚠️ To operacja LICZĄCA, nie ``UPDATE``. Przy masowym przywracaniu + z admina (faza 07) koszt rośnie liniowo — patrz notatka w handoffie. + """ + from pbn_export_queue.operacje import zakolejkuj_wysylke + + if _wylicza_punktacje(instance): + instance.przelicz_punkty_dyscyplin() + + wpis, status = _kolejkuj_pbn(instance, zakolejkuj_wysylke, STATUS_WYSYLKA) + _utworz_log( + instance, + SoftDeleteLog.Akcja.RESTORE, + pbn_queue_entry=wpis, + pbn_status=status, + ) + + +def on_post_hard_delete(sender, instance, **kwargs): + """Hard-delete: rekord fizycznie znika, więc bez operacji PBN. + + Wycofanie oświadczeń wymaga ``pbn_uid`` odczytanego z rekordu, a tego + już nie ma — dług opisany w handoffie fazy 05a §6. Log powstaje mimo + to i jest wtedy JEDYNYM śladem, że rekord istniał. + """ + _utworz_log(instance, SoftDeleteLog.Akcja.HARD_DELETE) + + +def register(): + """Podłącza receivery. Woła to ``BppConfig.ready()``. + + ``dispatch_uid`` przy każdym połączeniu: ``ready()`` bywa wołane więcej + niż raz (m.in. przy ``TransactionTestCase``), a bez uid-a receiver + zostałby podpięty dwa razy i każdy soft-delete produkowałby dwa wpisy + logu. Import sygnałów jest lokalny, żeby moduł dał się zaimportować + poza kontekstem gotowej aplikacji. + """ + from django_softdelete.signals import ( + post_hard_delete, + post_restore, + post_soft_delete, + ) + + post_soft_delete.connect( + on_post_soft_delete, dispatch_uid="bpp.soft_delete.post_soft_delete" + ) + post_restore.connect(on_post_restore, dispatch_uid="bpp.soft_delete.post_restore") + post_hard_delete.connect( + on_post_hard_delete, dispatch_uid="bpp.soft_delete.post_hard_delete" + ) diff --git a/src/bpp/tests/test_soft_delete/conftest.py b/src/bpp/tests/test_soft_delete/conftest.py new file mode 100644 index 000000000..e1df1142a --- /dev/null +++ b/src/bpp/tests/test_soft_delete/conftest.py @@ -0,0 +1,16 @@ +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def bez_celery(): + """Odcina realną wysyłkę do PBN z receiverów fazy 06. + + Kolejkowanie kończy się ``task_sprobuj_wyslac_do_pbn.delay()``, a testy + biegną z ``CELERY_TASK_ALWAYS_EAGER = True`` (``settings/test.py``) — + bez tej atrapy zadanie wykonałoby się synchronicznie, w środku + ``delete()``/``restore()``, i próbowało pogadać z PBN-em. + """ + with patch("pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn") as mock_task: + yield mock_task diff --git a/src/bpp/tests/test_soft_delete/test_admin.py b/src/bpp/tests/test_soft_delete/test_admin.py new file mode 100644 index 000000000..aec707b50 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_admin.py @@ -0,0 +1,466 @@ +"""Faza 07: kosz w adminie — filtr, przywracanie, trwałe usuwanie, powód. + +KONTRAKT PARAMETRU FILTRA: ``?is_deleted=true`` pokazuje WYŁĄCZNIE kosz, +``?is_deleted=all`` żywe razem z koszem, brak parametru — tylko żywe. Ta +sama semantyka, co w pakietowym ``SoftDeleteFilter`` (``'true'`` mapuje +tam na ``deleted_at__isnull=False``), więc parametr w URL-u nie kłamie. +""" + +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_domyslnie_ukrywa_kosz(superuser_client): + baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Żywa praca") + skasowany = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Praca w koszu") + skasowany.delete(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 + assert "Praca w koszu" not in content + + +@pytest.mark.django_db +def test_filtr_pokazuje_wylacznie_kosz(superuser_client): + baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Żywa praca") + skasowany = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Praca w koszu") + skasowany.delete(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 + assert "Żywa praca" not in content + + +@pytest.mark.django_db +def test_filtr_wszystkie_pokazuje_zywe_i_kosz(superuser_client): + baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Żywa praca") + skasowany = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Praca w koszu") + skasowany.delete(reason="test") + + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + resp = superuser_client.get(url, {"is_deleted": "all"}) + content = resp.content.decode("utf-8") + + assert resp.status_code == 200 + assert "Praca w koszu" in content + assert "Żywa praca" in content + + +@pytest.mark.django_db +def test_changeform_otwiera_skasowany_rekord(superuser_client): + """Bez ``global_objects`` w ``get_queryset`` admin dawałby 302/404 — + a bez otwarcia rekordu nie ma jak go przywrócić ani obejrzeć.""" + skasowany = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Praca w koszu") + skasowany.delete(reason="test") + + url = reverse("admin:bpp_wydawnictwo_ciagle_change", args=[skasowany.pk]) + resp = superuser_client.get(url) + + assert resp.status_code == 200 + + +def _log(model, pk, akcja): + """Najnowszy wpis ``SoftDeleteLog`` dla konkretnego rekordu. + + Filtr po ``content_type`` jest istotny: ``object_id`` nie jest unikalne + globalnie, a kaskada fazy 02 loguje przy okazji wiersze ``*_Autor``, + ktore z latwoscia trafiaja na te sama wartosc ``pk``. + """ + from django.contrib.contenttypes.models import ContentType + + from bpp.models import SoftDeleteLog + + return ( + SoftDeleteLog.objects.filter( + content_type=ContentType.objects.get_for_model(model), + object_id=pk, + akcja=akcja, + ) + .order_by("-timestamp", "-pk") + .first() + ) + + +@pytest.mark.django_db +def test_delete_w_adminie_soft_deletuje_i_zapisuje_usera(superuser, superuser_client): + """Przycisk „Usuń" na changeformie = kosz, nie fizyczne skasowanie.""" + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Do kosza") + pk = obj.pk + url = reverse("admin:bpp_wydawnictwo_ciagle_delete", args=[pk]) + + assert superuser_client.get(url).status_code == 200 + + resp = superuser_client.post(url, {"post": "yes"}) + assert resp.status_code == 302 + + assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + assert Wydawnictwo_Ciagle.global_objects.get(pk=pk).deleted_at is not None + + wpis = _log(Wydawnictwo_Ciagle, pk, "delete") + assert wpis is not None + assert wpis.user_id == superuser.pk + + +@pytest.mark.django_db +def test_akcja_przywroc_dziala_i_zapisuje_usera(superuser, superuser_client): + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Wraca z kosza") + pk = obj.pk + obj.delete(reason="test") + assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + + # Akcja dziala na tym, co widac na liscie — a kosz widac dopiero pod + # filtrem. Django POST-uje formularz akcji na biezacy URL RAZEM z + # query stringiem, wiec tak wyglada realny przeplyw operatora. + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + "?is_deleted=true" + resp = superuser_client.post( + url, + {"action": "przywroc_zaznaczone", "_selected_action": [str(pk)]}, + ) + assert resp.status_code in (200, 302) + + assert Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + assert Wydawnictwo_Ciagle.global_objects.get(pk=pk).deleted_at is None + + wpis = _log(Wydawnictwo_Ciagle, pk, "restore") + assert wpis is not None + assert wpis.user_id == superuser.pk + + +@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(reason="test") + + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + "?is_deleted=true" + assert b"usun_trwale_zaznaczone" in superuser_client.get(url).content + + resp = superuser_client.post( + url, + {"action": "usun_trwale_zaznaczone", "_selected_action": [str(pk)]}, + ) + assert resp.status_code in (200, 302) + assert not Wydawnictwo_Ciagle.global_objects.filter(pk=pk).exists() + + # Rekord zniknal fizycznie, wiec wpis audytu jest jedynym sladem, ze + # istnial — i ma nosic, KTO go skasowal. + wpis = _log(Wydawnictwo_Ciagle, pk, "hard_delete") + assert wpis is not None + assert wpis.user_id == superuser.pk + + +@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(reason="test") + + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + "?is_deleted=true" + assert b"usun_trwale_zaznaczone" not in staff_client.get(url).content + + # Wymuszony POST tez nie usuwa trwale — akcji nie ma w get_actions, + # wiec Django odrzuca ja jako niedozwolony wybor. + resp = staff_client.post( + url, + {"action": "usun_trwale_zaznaczone", "_selected_action": [str(pk)]}, + ) + assert resp.status_code in (200, 302, 403) + assert Wydawnictwo_Ciagle.global_objects.filter(pk=pk).exists() + + +@pytest.mark.django_db +def test_usun_trwale_pomija_rekordy_spoza_kosza(superuser_client): + """Trwale kasujemy WYLACZNIE z kosza. + + ``Cache_Punktacja_*`` nie ma FK do publikacji, wiec sprzata ja dopiero + receiver ``post_soft_delete``. Twarde skasowanie rekordu zywego + zostawiloby wiersze punktacji wskazujace na nieistniejacy rekord. + """ + zywy = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Żywy, nie do kosza") + pk = zywy.pk + + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + "?is_deleted=all" + resp = superuser_client.post( + url, + {"action": "usun_trwale_zaznaczone", "_selected_action": [str(pk)]}, + follow=True, + ) + assert resp.status_code == 200 + assert Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + assert "Pominięto rekordów spoza kosza: 1" in resp.content.decode("utf-8") + + +@pytest.mark.django_db +def test_akcja_usun_do_kosza_z_powodem_trafia_do_logu(superuser, superuser_client): + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Z powodem") + pk = obj.pk + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + + # Krok 1: wybor akcji bez potwierdzenia -> strona posrednia z polem. + resp1 = superuser_client.post( + url, + {"action": "usun_do_kosza", "_selected_action": [str(pk)]}, + ) + assert resp1.status_code == 200 + tresc = resp1.content.decode("utf-8") + assert 'name="powod"' in tresc + assert "Z powodem" in tresc + # Nic sie jeszcze nie stalo: + assert Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + + # 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() + wpis = _log(Wydawnictwo_Ciagle, pk, "delete") + assert wpis is not None + assert wpis.powod == "Duplikat rekordu" + assert wpis.user_id == superuser.pk + + +@pytest.mark.django_db +def test_powod_dziedziczy_kaskada_na_autorstwa(superuser, superuser_client): + """Powod i user maja objac takze wiersze ``*_Autor`` kasowane kaskada. + + Kontekst fazy 06 obejmuje CALE cialo ``delete()``, wiec kaskada + dziedziczy atrybucje rodzica — bez tego wpisy autorstw byłyby niczyje + mimo swiadomej decyzji operatora. + """ + from bpp.models import Wydawnictwo_Ciagle_Autor + + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Z autorem") + autorstwo = baker.make(Wydawnictwo_Ciagle_Autor, rekord=obj) + pk_autorstwa = autorstwo.pk + + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + superuser_client.post( + url, + { + "action": "usun_do_kosza", + "_selected_action": [str(obj.pk)], + "powod_potwierdzony": "1", + "powod": "Wycofanie calego rekordu", + }, + ) + + wpis = _log(Wydawnictwo_Ciagle_Autor, pk_autorstwa, "delete") + assert wpis is not None + assert wpis.powod == "Wycofanie calego rekordu" + assert wpis.user_id == superuser.pk + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "model_name,admin_slug", + [ + ("Wydawnictwo_Zwarte", "wydawnictwo_zwarte"), + ("Patent", "patent"), + ("Praca_Doktorska", "praca_doktorska"), + ("Praca_Habilitacyjna", "praca_habilitacyjna"), + ("Autor", "autor"), + ], +) +def test_kosz_w_adminie_dla_kazdego_modelu(model_name, admin_slug, superuser_client): + import bpp.models + + model = getattr(bpp.models, model_name) + obj = baker.make(model) + pk = obj.pk + obj.delete(reason="test") + + # Changeform otwiera skasowany rekord (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, a akcje kosza sa oferowane: + url_list = reverse(f"admin:bpp_{admin_slug}_changelist") + "?is_deleted=true" + resp = superuser_client.get(url_list) + assert resp.status_code == 200 + assert b"przywroc_zaznaczone" in resp.content + assert b"usun_trwale_zaznaczone" in resp.content + assert b"usun_do_kosza" in resp.content + + # Przywracanie dziala: + 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() + + +@pytest.mark.django_db +def test_mixin_nie_znosi_zawezenia_do_uczelni_w_autoradmin(staff_user, rf): + """REGRESJA MRO: kosz nie ma prawa poszerzyc widoku poza wlasna uczelnie. + + Plan fazy 07 kazal wpinac ``BppSoftDeleteAdminMixin`` jako PIERWSZY. + Jego ``get_queryset`` nie woła ``super()`` (podmienia manager bazowy), + wiec z pierwszej pozycji uciolby cały łańcuch — w tym + ``SiteFilteredAdminMixin.get_queryset`` w ``AutorAdmin``, zawezajace + widok nie-superusera do jego uczelni (FD#390). Personel uczelni A + zobaczylby (i skasowal) autorow uczelni B. + + Ten test pilnuje, ze mixin stoi na koncu MRO — czyli ze jest PODSTAWA + lancucha, a nie jego obcieciem. + """ + from django.contrib.admin.sites import site + + from bpp.models import Autor, Autor_Jednostka, Jednostka, Uczelnia + + uczelnia_a = baker.make(Uczelnia, nazwa="Uczelnia A", skrot="UA") + uczelnia_b = baker.make(Uczelnia, nazwa="Uczelnia B", skrot="UB") + jednostka_a = baker.make(Jednostka, uczelnia=uczelnia_a) + jednostka_b = baker.make(Jednostka, uczelnia=uczelnia_b) + + autor_a = baker.make(Autor) + baker.make(Autor_Jednostka, autor=autor_a, jednostka=jednostka_a) + autor_b = baker.make(Autor) + baker.make(Autor_Jednostka, autor=autor_b, jednostka=jednostka_b) + autor_b_w_koszu = baker.make(Autor) + baker.make(Autor_Jednostka, autor=autor_b_w_koszu, jednostka=jednostka_b) + autor_b_w_koszu.delete(reason="test") + + request = rf.get("/admin/bpp/autor/") + request.user = staff_user + request._uczelnia = uczelnia_b + + qs = site._registry[Autor].get_queryset(request) + widoczne = set(qs.values_list("pk", flat=True)) + + assert autor_b.pk in widoczne, "wlasna uczelnia musi byc widoczna" + assert autor_b_w_koszu.pk in widoczne, "kosz wlasnej uczelni tez (global_objects)" + assert autor_a.pk not in widoczne, "CUDZA uczelnia NIE MOZE byc widoczna" + + +@pytest.mark.django_db +def test_soft_delete_autora_z_pracami_pokazuje_komunikat(superuser_client): + """Guard fazy 04 ma dawac komunikat, nie 500. + + ``usun_do_kosza`` jest tu sciezka krytyczna: wlasna akcja NIE + przechodzi przez kolektor Django (ktory dla ``delete_selected`` + zatrzymalby operacje na FK ``PROTECT``), wiec ``ProtectedError`` + wyleciałby prosto z ``Autor.delete()``. + """ + from bpp.models import Autor, Wydawnictwo_Ciagle_Autor + + autor = baker.make(Autor) + 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 + assert Autor.objects.filter(pk=autor.pk).exists() + tresc = resp.content.decode("utf-8").lower() + assert "nie można usunąć" in tresc + + +@pytest.mark.django_db +def test_guard_nie_blokuje_pozostalych_z_zaznaczenia(superuser_client): + """Jeden zablokowany rekord nie moze przewrocic calej operacji.""" + from bpp.models import Autor, Wydawnictwo_Ciagle_Autor + + zablokowany = baker.make(Autor) + baker.make(Wydawnictwo_Ciagle_Autor, autor=zablokowany) + wolny = baker.make(Autor) + + url = reverse("admin:bpp_autor_changelist") + resp = superuser_client.post( + url, + { + "action": "usun_do_kosza", + "_selected_action": [str(zablokowany.pk), str(wolny.pk)], + "powod_potwierdzony": "1", + "powod": "próba", + }, + follow=True, + ) + + assert resp.status_code == 200 + assert Autor.objects.filter(pk=zablokowany.pk).exists() + assert not Autor.objects.filter(pk=wolny.pk).exists() + + +@pytest.mark.django_db +def test_staff_moze_do_kosza_ale_nie_trwale(staff_user, staff_client): + """Podzial uprawnien: kosz dla staffu, nieodwracalne kasowanie nie.""" + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Staff do kosza") + pk = obj.pk + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + + resp = staff_client.post( + url, + { + "action": "usun_do_kosza", + "_selected_action": [str(pk)], + "powod_potwierdzony": "1", + "powod": "staff kasuje", + }, + follow=True, + ) + assert resp.status_code == 200 + + # Kosz, nie fizyczne skasowanie: + assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + assert Wydawnictwo_Ciagle.global_objects.filter(pk=pk).exists() + + wpis = _log(Wydawnictwo_Ciagle, pk, "delete") + assert wpis is not None + assert wpis.user_id == staff_user.pk + assert wpis.powod == "staff kasuje" + + # Akcja trwalego usuwania nie jest staffowi w ogole oferowana: + resp_list = staff_client.get(url + "?is_deleted=true") + assert b"usun_trwale_zaznaczone" not in resp_list.content + assert b"przywroc_zaznaczone" in resp_list.content + + +@pytest.mark.django_db +def test_delete_selected_tez_idzie_do_kosza(superuser, superuser_client): + """Domyslna akcja Django przechodzi przez nasz delete_queryset.""" + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Przez delete_selected") + pk = obj.pk + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + + resp = superuser_client.post( + url, + {"action": "delete_selected", "_selected_action": [str(pk)], "post": "yes"}, + follow=True, + ) + assert resp.status_code == 200 + assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + assert Wydawnictwo_Ciagle.global_objects.filter(pk=pk).exists() + + wpis = _log(Wydawnictwo_Ciagle, pk, "delete") + assert wpis is not None + assert wpis.user_id == superuser.pk diff --git a/src/bpp/tests/test_soft_delete/test_cache_punktacji.py b/src/bpp/tests/test_soft_delete/test_cache_punktacji.py new file mode 100644 index 000000000..767f6d148 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_cache_punktacji.py @@ -0,0 +1,90 @@ +"""Praca w koszu nie liczy się do ewaluacji (faza 06, Task 6b). + +Luka nieobjęta żadnym innym mechanizmem: ``Cache_Punktacja_Autora`` +i ``Cache_Punktacja_Dyscypliny`` NIE MAJĄ FK do publikacji — kluczem jest +tablica ``rekord_id = [content_type_id, pk]``. Nie rusza ich więc ani +kaskada Django, ani wąska kaskada ``*_Autor`` z fazy 02, ani triggery +denormalizacji. Bez receiverów soft-deletowana praca nadal wnosiłaby sloty +i punkty do ewaluacji. +""" + +import pytest +from django.contrib.contenttypes.models import ContentType + +from bpp.models.cache.punktacja import ( + Cache_Punktacja_Autora, + Cache_Punktacja_Dyscypliny, +) + + +def _klucz(rekord): + # ``content_type`` nie jest atrybutem modeli publikacji (to property na + # RekordBase), więc bierzemy przez ContentType. + return [ContentType.objects.get_for_model(type(rekord)).pk, rekord.pk] + + +@pytest.mark.django_db +def test_soft_delete_kasuje_cache_punktacji(zwarte_z_dyscyplinami, bez_celery): + zw = zwarte_z_dyscyplinami + zw.przelicz_punkty_dyscyplin() + klucz = _klucz(zw) + + 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() + + +@pytest.mark.django_db +def test_restore_przywraca_cache_punktacji(zwarte_z_dyscyplinami, bez_celery): + zw = zwarte_z_dyscyplinami + zw.przelicz_punkty_dyscyplin() + klucz = _klucz(zw) + + zw.delete() + assert not Cache_Punktacja_Autora.objects.filter(rekord_id=klucz).exists() + + zw.restore() + + assert Cache_Punktacja_Autora.objects.filter(rekord_id=klucz).exists() + assert Cache_Punktacja_Dyscypliny.objects.filter(rekord_id=klucz).exists() + + +@pytest.mark.django_db +def test_przeliczenie_zachodzi_raz_mimo_kaskady(zwarte_z_dyscyplinami, bez_celery): + """Kaskada ``*_Autor`` NIE MOŻE wyzwalać przeliczenia po raz drugi. + + Soft-delete publikacji z N autorami emituje 1 + N sygnałów. Gdyby + kasowanie punktacji zachodziło przy każdym, dokładalibyśmy N-1 zbędnych + par zapytań do każdego usunięcia. Gate po ``ModelZPrzeliczaniemDyscyplin`` + zawęża to do sygnału rodzica — wiersze ``*_Autor`` tego abstraktu nie + dziedziczą. + """ + from unittest.mock import patch + + zw = zwarte_z_dyscyplinami + zw.przelicz_punkty_dyscyplin() + assert zw.autorzy_set.count() >= 2, "test ma sens tylko dla >1 autorstwa" + + with patch("bpp.models.sloty.core.IPunktacjaCacher.removeEntries") as mock_remove: + zw.delete() + + assert mock_remove.call_count == 1 + + +@pytest.mark.django_db +def test_autor_nie_dotyka_cache_punktacji(autor_jan_kowalski, bez_celery): + """Receiver jest globalny — dla ``Autor`` ta gałąź nie może się odpalić. + + ``Autor`` nie ma ``przelicz_punkty_dyscyplin()``; bez gate'u receiver + wywaliłby się na ``AttributeError`` przy każdym soft-delete autora. + """ + from unittest.mock import patch + + with patch("bpp.models.sloty.core.IPunktacjaCacher") as mock_cacher: + autor_jan_kowalski.delete() + + mock_cacher.assert_not_called() diff --git a/src/bpp/tests/test_soft_delete/test_receivers.py b/src/bpp/tests/test_soft_delete/test_receivers.py new file mode 100644 index 000000000..5bfda0227 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_receivers.py @@ -0,0 +1,528 @@ +"""Receivery sygnałów soft-delete → ``SoftDeleteLog`` (faza 06).""" + +import pytest +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone +from django_softdelete.signals import post_hard_delete +from model_bakery import baker + +from bpp.models.soft_delete_context import soft_delete_context +from bpp.models.soft_delete_log import SoftDeleteLog + + +def _z_pbn_uid(rekord): + from pbn_api.models import Publication + + rekord.pbn_uid = baker.make(Publication) + rekord.save() + return rekord + + +def _kolejne_ciagle(nr): + """Kolejne wydawnictwo ciągłe obok tego z fixture'u. + + Fixture-factory ``wydawnictwo_ciagle_maker`` w ``fixtures/ + conftest_publications.py`` nie ma dekoratora ``@pytest.fixture``, więc + pytest go nie rejestruje — sięgamy po sam maker. + """ + from fixtures.conftest_publications import _wydawnictwo_ciagle_maker + + return _wydawnictwo_ciagle_maker(tytul_oryginalny=f"Hard delete {nr}") + + +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_pakiet_zeruje_pk_przed_wyslaniem_post_hard_delete(wydawnictwo_ciagle): + """Fakt o pakiecie, na którym opiera się cała obsługa HARD_DELETE. + + ``SoftDeleteModel.hard_delete()`` woła ``Model.delete()``, a kolektor + Django na koniec zeruje ``pk`` skasowanych instancji — ``post_hard_delete`` + leci PO tym. Receiver nie ma więc z czego wziąć ``object_id`` i musi + dostać ``pk`` zapamiętany wcześniej. + + Ten test jest strażnikiem założenia: gdyby aktualizacja pakietu + przestawiła kolejność (sygnał przed zerowaniem), zapali się tutaj, + a nie w postaci logów z ``object_id`` wziętym z zapasowego pola. + """ + zebrane = [] + + def _sonda(sender, instance, **kwargs): + zebrane.append(instance.pk) + + post_hard_delete.connect(_sonda, dispatch_uid="test.sonda.pk", weak=False) + try: + wydawnictwo_ciagle.hard_delete() + finally: + post_hard_delete.disconnect(dispatch_uid="test.sonda.pk") + + assert zebrane == [None], ( + "Pakiet zaczął wysyłać post_hard_delete z niewyzerowanym pk — " + "stash pk w BppPkPrzedHardDeleteMixin można wtedy uprościć." + ) + + +def test_kazdy_model_soft_delete_zachowuje_pk(): + """KAŻDY model soft-delete musi mieć ``BppPkPrzedHardDeleteMixin``. + + Bez niego ``post_hard_delete`` dostaje instancję z ``pk is None``, + a ``pk_dla_audytu()`` rzuca ``RuntimeError`` — czyli twarde skasowanie + takiego modelu przewróciłoby się w produkcji. Ten test przenosi wykrycie + z produkcji do testów: nowy model soft-delete zapala się TUTAJ. + """ + from django.apps import apps + from django_softdelete.models import SoftDeleteModel + + from bpp.models.soft_delete import BppPkPrzedHardDeleteMixin + + bez_mixinu = [ + model._meta.label + for model in apps.get_models() + if issubclass(model, SoftDeleteModel) + and not issubclass(model, BppPkPrzedHardDeleteMixin) + ] + assert bez_mixinu == [], ( + "Modele soft-delete bez BppPkPrzedHardDeleteMixin — ich hard_delete() " + f"wywali sie na pk_dla_audytu(): {bez_mixinu}" + ) + + +@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 + + +@pytest.mark.django_db +def test_soft_delete_tworzy_log_z_userem(wydawnictwo_ciagle, superuser): + """Wariant z jawnym kontekstem — tak woła kod bez własnego ``delete()``.""" + 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): + """Operacja bez zalogowanego usera (celery, skrypt, scalanie duplikatów). + + ``user=None`` jest POPRAWNYM wynikiem, nie awarią — „nie wiadomo kto" + jest uczciwsze niż podstawienie pierwszego lepszego konta. + """ + wydawnictwo_ciagle.delete() + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get() + assert log.user is None + assert log.powod == "" + + +@pytest.mark.django_db +def test_delete_z_argumentem_user_loguje_usera(wydawnictwo_ciagle, superuser): + """REALNE API: ``delete(user=, reason=)``, bez ręcznego kontekstu. + + Fazy 02/04 przyjmowały te argumenty i je porzucały („konsumuje je + SoftDeleteLog z fazy 06"). Ten test pilnuje, że faza 06 domknęła + obietnicę — bez niego cały mechanizm atrybucji działałby wyłącznie dla + wołających, którzy sami wejdą w context manager, czyli dla nikogo. + """ + wydawnictwo_ciagle.delete(user=superuser, reason="zdublowany import") + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get() + assert log.user == superuser + assert log.powod == "zdublowany import" + + +@pytest.mark.django_db +def test_restore_z_argumentem_user_loguje_usera(wydawnictwo_ciagle, superuser): + wydawnictwo_ciagle.delete(user=superuser, reason="pomyłka") + wydawnictwo_ciagle.restore(user=superuser) + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.RESTORE).get() + assert log.user == superuser + + +@pytest.mark.django_db +def test_autor_delete_z_userem_loguje_usera(autor_jan_kowalski, superuser): + """``Autor`` ma własny ``delete()`` (faza 04) — osobna ścieżka do wpięcia.""" + autor_jan_kowalski.delete(user=superuser, reason="duplikat osoby") + log = _logi(autor_jan_kowalski, SoftDeleteLog.Akcja.DELETE).get() + assert log.user == superuser + assert log.powod == "duplikat osoby" + + +@pytest.mark.django_db +def test_kaskada_autorstw_dziedziczy_usera(wydawnictwo_ciagle_z_autorem, superuser): + """Kaskada ``*_Autor`` loguje się z tym samym userem i powodem. + + Soft-delete publikacji z N autorami emituje 1 + N sygnałów, bo wąska + kaskada fazy 02 kasuje każdy wiersz osobno. Log jest wierny sygnałom: + powstaje wpis dla rekordu i dla każdego autorstwa. Atrybucja przenosi + się przez reentrancję context managera — gdyby zawiodła, wiersze + ``*_Autor`` miałyby ``user=None`` mimo świadomej decyzji operatora. + """ + from bpp.models import Wydawnictwo_Ciagle_Autor + + autorstwo = wydawnictwo_ciagle_z_autorem.autorzy_set.get() + wydawnictwo_ciagle_z_autorem.delete(user=superuser, reason="wycofany artykuł") + + log_rekordu = _logi(wydawnictwo_ciagle_z_autorem, SoftDeleteLog.Akcja.DELETE).get() + assert log_rekordu.user == superuser + + log_autorstwa = SoftDeleteLog.objects.get( + content_type=ContentType.objects.get_for_model(Wydawnictwo_Ciagle_Autor), + object_id=autorstwo.pk, + akcja=SoftDeleteLog.Akcja.DELETE, + ) + assert log_autorstwa.user == superuser + assert log_autorstwa.powod == "wycofany artykuł" + + +# --- Task 6: kolejkowanie PBN z receiverów ----------------------------- + + +@pytest.mark.django_db +def test_soft_delete_z_pbn_uid_kolejkuje_wycofanie( + wydawnictwo_ciagle, superuser, bez_celery +): + from pbn_export_queue.models import PBN_Export_Queue + + _z_pbn_uid(wydawnictwo_ciagle) + wydawnictwo_ciagle.delete(user=superuser, reason="x") + + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get() + assert log.pbn_queue_entry is not None + assert log.pbn_status == "WYCOFANIE" + assert log.pbn_queue_entry.operacja == PBN_Export_Queue.Operacja.WYCOFANIE + assert log.pbn_queue_entry.zamowil == superuser + + +@pytest.mark.django_db +def test_soft_delete_bez_pbn_uid_nie_kolejkuje(wydawnictwo_ciagle, superuser): + """Bez PBN UID nic do PBN nie pojechało, więc nie ma czego wycofywać.""" + wydawnictwo_ciagle.delete(user=superuser) + + 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_kolejkuje_wysylke(wydawnictwo_ciagle, superuser, bez_celery): + """RESTORE kolejkuje wysyłkę także BEZ ``pbn_uid``. + + ``zakolejkuj_wysylke`` świadomie nie ma gate'u na ``pbn_uid`` (faza 05a): + rekord, który nigdy nie był w PBN, po przywróceniu ma prawo pojechać — + wysyłka dopiero nadaje UID. + """ + from pbn_export_queue.models import PBN_Export_Queue + + wydawnictwo_ciagle.delete(user=superuser) + wydawnictwo_ciagle.restore(user=superuser) + + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.RESTORE).get() + assert log.pbn_queue_entry is not None + assert log.pbn_status == "WYSYLKA" + assert log.pbn_queue_entry.operacja == PBN_Export_Queue.Operacja.WYSYLKA + + +@pytest.mark.django_db +def test_restore_przy_niezakonczonym_wycofaniu_nie_dubluje_wpisu( + wydawnictwo_ciagle, superuser, bez_celery +): + """Przywrócenie, gdy wycofanie WCIĄŻ czeka w kolejce, nie tworzy wpisu. + + ``sprobuj_utowrzyc_wpis`` odrzuca drugi AKTYWNY wpis dla tego samego + rekordu (``AlreadyEnqueuedError`` → ``None``), więc szybkie + „usuń i cofnij" zostawia w kolejce samo wycofanie. Log mówi wtedy + ``pbn_status=""``. + + ⚠️ To ta sama pusta wartość, co przy „nie było czego kolejkować" — + z samego logu nie odróżnisz „pominięto (już w kolejce)" od + „nie dotyczy". Świadome uproszczenie: obie funkcje kolejkujące zwracają + ``None`` i receiver nie ma ich jak rozróżnić bez dodatkowego zapytania. + """ + _z_pbn_uid(wydawnictwo_ciagle) + wydawnictwo_ciagle.delete(user=superuser) + wydawnictwo_ciagle.restore(user=superuser) + + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.RESTORE).get() + assert log.pbn_queue_entry is None + assert log.pbn_status == "" + + +@pytest.mark.django_db +def test_restore_po_zakonczonym_wycofaniu_kolejkuje_wysylke( + wydawnictwo_ciagle, superuser, bez_celery +): + """Realna sekwencja: wycofanie przetworzone, potem przywrócenie.""" + from pbn_export_queue.models import PBN_Export_Queue + + _z_pbn_uid(wydawnictwo_ciagle) + wydawnictwo_ciagle.delete(user=superuser) + + wycofanie = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get() + wycofanie.pbn_queue_entry.wysylke_zakonczono = timezone.now() + wycofanie.pbn_queue_entry.save() + + wydawnictwo_ciagle.restore(user=superuser) + + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.RESTORE).get() + assert log.pbn_status == "WYSYLKA" + assert log.pbn_queue_entry.operacja == PBN_Export_Queue.Operacja.WYSYLKA + + +@pytest.mark.django_db +def test_autor_z_pbn_uid_nie_trafia_do_kolejki( + autor_jan_kowalski, superuser, bez_celery +): + """``Autor`` MA ``pbn_uid`` — gate na sam atrybut byłby błędny. + + Plan fazy 06 twierdzi (jako fakt zweryfikowany), że „Autor i *_Autor nie + mają pbn_uid", i na tym opiera uniwersalny gate + ``getattr(instance, "pbn_uid_id", None)``. W kodzie ``Autor.pbn_uid`` + jest zwykłym FK (``autor.py``), więc tamten gate wstawiłby AUTORA do + kolejki eksportu PUBLIKACJI i odpalił dla niego wysyłkę do PBN. + Gate idzie po typie: wyłącznie ``BppPublikacjaSoftDeleteMixin``. + """ + from pbn_api.models import Scientist + from pbn_export_queue.models import PBN_Export_Queue + + autor_jan_kowalski.pbn_uid = baker.make(Scientist) + autor_jan_kowalski.save() + assert autor_jan_kowalski.pbn_uid_id is not None + + autor_jan_kowalski.delete(user=superuser, reason="duplikat") + + log = _logi(autor_jan_kowalski, SoftDeleteLog.Akcja.DELETE).get() + assert log.pbn_queue_entry is None + assert log.pbn_status == "" + assert not PBN_Export_Queue.objects.exists() + bez_celery.delay.assert_not_called() + + +@pytest.mark.django_db +def test_kaskada_autorstw_nie_kolejkuje_osobno( + wydawnictwo_ciagle_z_autorem, superuser, bez_celery +): + """Publikacja z N autorami → DOKŁADNIE JEDEN wpis kolejki. + + Kaskada fazy 02 emituje 1 + N sygnałów. Gdyby receiver kolejkował dla + każdego, powstałoby N zbędnych wpisów dla wierszy ``*_Autor`` — a przy + RESTORE szczególnie łatwo, bo ``zakolejkuj_wysylke`` nie ma gate'u na + ``pbn_uid`` i przyjęłaby cokolwiek. + """ + from pbn_export_queue.models import PBN_Export_Queue + + _z_pbn_uid(wydawnictwo_ciagle_z_autorem) + wydawnictwo_ciagle_z_autorem.delete(user=superuser) + + assert PBN_Export_Queue.objects.count() == 1 + wpis = PBN_Export_Queue.objects.get() + assert wpis.rekord_do_wysylki == wydawnictwo_ciagle_z_autorem + + +# --- Task 6: uczelnia wyprowadzona z rekordu --------------------------- + + +@pytest.mark.django_db +def test_wpis_kolejki_dostaje_uczelnie_rekordu( + wydawnictwo_ciagle_z_autorem, uczelnia, superuser, bez_celery +): + """Wpis z receivera niesie uczelnię — inaczej multi-hosted by go nie wysłał. + + Wszyscy pozostali wołający kolejki biorą uczelnię z requestu + (``Uczelnia.objects.get_for_request``). Receiver requestu nie ma, więc + odwraca regułę przynależności z fazy 05b (``naleza_wydawnictwa``): + uczelnia jednostek autorów rekordu. Bez tego ``_pozyskaj_klienta_pbn`` + spadłby na ``get_single_uczelnia_or_fail()`` i przy dwóch uczelniach + wycofanie kończyłoby się ``FINISHED_ERROR``. + """ + from pbn_export_queue.models import PBN_Export_Queue + + _z_pbn_uid(wydawnictwo_ciagle_z_autorem) + wydawnictwo_ciagle_z_autorem.delete(user=superuser) + + wpis = PBN_Export_Queue.objects.get() + assert wpis.uczelnia == uczelnia + + +@pytest.mark.django_db +def test_uczelnia_czytana_mimo_skasowanych_autorstw( + wydawnictwo_ciagle_z_autorem, uczelnia, superuser, bez_celery +): + """Atrybucja MUSI iść przez ``global_objects``, nie ``objects``. + + Wąska kaskada fazy 02 kasuje wiersze ``*_Autor`` PRZED wysłaniem + ``post_soft_delete`` rodzica. Gdy receiver pyta o uczelnię, autorstwa + są już w koszu — odczyt przez ``objects`` zwróciłby pustkę i uczelnia + wychodziłaby ``None`` przy KAŻDYM kasowaniu, czyli dokładnie wtedy, + gdy jest potrzebna. + """ + from pbn_export_queue.models import PBN_Export_Queue + + _z_pbn_uid(wydawnictwo_ciagle_z_autorem) + wydawnictwo_ciagle_z_autorem.delete(user=superuser) + + assert not wydawnictwo_ciagle_z_autorem.autorzy_set.exists() + assert PBN_Export_Queue.objects.get().uczelnia == uczelnia + + +@pytest.mark.django_db +def test_uczelnia_niejednoznaczna_daje_none( + wydawnictwo_ciagle_z_autorem, + autor_uczelnia2, + jednostka_uczelnia2, + typy_odpowiedzialnosci, + superuser, + bez_celery, +): + """Praca współautorska między uczelniami → ``uczelnia=None``, nie zgadywanie. + + Wybranie „pierwszej z brzegu" wysłałoby wycofanie przez konto PBN + niewłaściwego tenanta. ``None`` degraduje do zachowania sprzed fazy 06: + jedna uczelnia w bazie → działa, kilka → głośny błąd na wpisie kolejki. + """ + from pbn_export_queue.models import PBN_Export_Queue + + wydawnictwo_ciagle_z_autorem.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + _z_pbn_uid(wydawnictwo_ciagle_z_autorem) + wydawnictwo_ciagle_z_autorem.delete(user=superuser) + + assert PBN_Export_Queue.objects.get().uczelnia is None + + +@pytest.mark.django_db +def test_praca_doktorska_bierze_uczelnie_z_jednostki( + praca_doktorska, uczelnia, superuser, bez_celery +): + """Prace dyplomowe nie mają through-modelu — atrybucja przez FK jednostki. + + Ta sama dwoistość, którą faza 05b rozstrzygnęła w ``naleza_prace`` + kontra ``naleza_wydawnictwa``. + """ + from pbn_export_queue.models import PBN_Export_Queue + + _z_pbn_uid(praca_doktorska) + praca_doktorska.delete(user=superuser) + + assert PBN_Export_Queue.objects.get().uczelnia == uczelnia + + +# --- Task 8: hard_delete na querysecie też musi zostawiać ślad --------- + + +@pytest.mark.django_db +def test_hard_delete_na_querysecie_tworzy_logi(wydawnictwo_ciagle, superuser): + """Masowe twarde kasowanie NIE MOŻE być niewidoczne dla audytu. + + Pakietowy ``hard_delete()`` na querysecie to goły ``super().delete()`` — + jedno zapytanie bulk, bez ``post_hard_delete``, więc bez wpisu w logu. + Rekord znikał fizycznie i bez śladu, czyli dokładnie ta klasa cichej + utraty, przed którą ``SoftDeleteLog`` ma chronić. Handoff §7 przypisuje + tę lukę fazie 06. + """ + from bpp.models import Wydawnictwo_Ciagle + + pk_i = [wydawnictwo_ciagle.pk] + [_kolejne_ciagle(i).pk for i in range(2)] + ct = ContentType.objects.get_for_model(Wydawnictwo_Ciagle) + + with soft_delete_context(user=superuser, reason="czystka"): + Wydawnictwo_Ciagle.objects.filter(pk__in=pk_i).hard_delete() + + assert not Wydawnictwo_Ciagle.global_objects.filter(pk__in=pk_i).exists() + + logi = SoftDeleteLog.objects.filter( + content_type=ct, object_id__in=pk_i, akcja=SoftDeleteLog.Akcja.HARD_DELETE + ) + assert logi.count() == 3 + assert {log.object_id for log in logi} == set(pk_i) + assert all(log.user == superuser and log.powod == "czystka" for log in logi) + + +@pytest.mark.django_db +def test_hard_delete_na_deleted_objects_tworzy_logi(wydawnictwo_ciagle, superuser): + """Ta sama luka na ``deleted_objects`` — czyli na opróżnianiu kosza. + + To najbardziej prawdopodobna droga masowego twardego kasowania + w adminie fazy 07. + """ + from bpp.models import Wydawnictwo_Ciagle + + pk = wydawnictwo_ciagle.pk + wydawnictwo_ciagle.delete(user=superuser) + + Wydawnictwo_Ciagle.deleted_objects.filter(pk=pk).hard_delete() + + assert SoftDeleteLog.objects.filter( + content_type=ContentType.objects.get_for_model(Wydawnictwo_Ciagle), + object_id=pk, + akcja=SoftDeleteLog.Akcja.HARD_DELETE, + ).exists() + + +@pytest.mark.django_db +def test_hard_delete_na_querysecie_zwraca_licznik(wydawnictwo_ciagle): + """Kontrakt zwrotki ``(liczba, {etykieta: liczba})`` jak w Django.""" + from bpp.models import Wydawnictwo_Ciagle + + pk_i = [wydawnictwo_ciagle.pk, _kolejne_ciagle(9).pk] + + ile, liczniki = Wydawnictwo_Ciagle.objects.filter(pk__in=pk_i).hard_delete() + + assert liczniki["bpp.Wydawnictwo_Ciagle"] == 2 + assert ile >= 2 + + +# --- Task 7: rejestracja przez apps.ready() ---------------------------- + + +def test_receivery_zarejestrowane_przez_apps_ready(): + """Receivery mają być podpięte przez ``BppConfig.ready()``. + + Wszystkie pozostałe testy tego pliku przeszłyby także wtedy, gdyby + ``register()`` wołał ktoś inny (albo gdyby podpinał je import + ubocznie). Ten test dowodzi, że mechanizm działa w produkcji: nikt + w kodzie aplikacyjnym nie woła ``register()`` ręcznie. + """ + from django_softdelete.signals import ( + post_hard_delete, + post_restore, + post_soft_delete, + ) + + def _uids(sygnal): + # Signal.receivers: [(lookup_key, receiver, ...), ...], gdzie + # lookup_key == (dispatch_uid_albo_id_receivera, id_nadawcy). + return {klucz[0] for klucz, *_ in sygnal.receivers} + + 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) + + +def test_rejestracja_jest_idempotentna(): + """Powtórne ``register()`` nie może zdublować receiverów. + + ``AppConfig.ready()`` bywa wołane więcej niż raz (m.in. przy + ``TransactionTestCase``). Bez ``dispatch_uid`` każdy soft-delete + tworzyłby wtedy dwa identyczne wpisy w logu — audyt zacząłby zmyślać. + """ + from django_softdelete.signals import post_soft_delete + + from bpp.receivers import soft_delete as receivery + + przed = len(post_soft_delete.receivers) + receivery.register() + assert len(post_soft_delete.receivers) == przed diff --git a/src/bpp/tests/test_soft_delete/test_soft_delete_context.py b/src/bpp/tests/test_soft_delete/test_soft_delete_context.py new file mode 100644 index 000000000..c889082cf --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_soft_delete_context.py @@ -0,0 +1,80 @@ +"""Kontekst atrybucji usera dla soft-delete (faza 06, Task 1). + +Sygnały pakietu ``django-soft-delete`` niosą wyłącznie ``sender`` +i ``instance`` — usera ani powodu nie przekazują. Ten thread-local jest +kanałem, którym ``delete(user=, reason=)`` dostarcza je receiverowi. +""" + +import pytest + +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): + """Reentrancja: kaskada ``*_Autor`` odpala się WEWNĄTRZ kontekstu + rodzica, więc zagnieżdżone wejście nie może zgubić usera zewnętrznego. + """ + 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 + + +def test_context_pominiety_argument_dziedziczy_zewnetrzny(django_user_model, db): + """Wejście bez ``user`` NIE zeruje atrybucji z zewnątrz. + + ``delete()`` faz 02/04 zakłada ten kontekst zawsze — także gdy wołający + nie podał ``user=``. Gdyby brak argumentu zerował, wzorzec + ``with soft_delete_context(user=X): obj.delete()`` dawałby log + z ``user=None``, czyli oba przewidziane sposoby atrybucji wykluczałyby + się nawzajem. + """ + u = django_user_model.objects.create(username="zewnetrzny") + with soft_delete_context(user=u, reason="powod zewnetrzny"): + with soft_delete_context(): + assert current_soft_delete_user() == u + assert current_soft_delete_reason() == "powod zewnetrzny" + + # Jawnie podany argument nadal wygrywa nad odziedziczonym. + with soft_delete_context(reason="powod wewnetrzny"): + assert current_soft_delete_user() == u + assert current_soft_delete_reason() == "powod wewnetrzny" + + +def test_context_czysci_takze_przy_wyjatku(django_user_model, db): + """``delete()`` może paść (np. ``ProtectedError`` guardu fazy 04). + + Gdyby kontekst przeciekł, NASTĘPNA operacja w tym samym wątku — + w produkcji: kolejny request na tym samym workerze gunicorna — + zalogowałaby cudzego usera. To cichy błąd atrybucji audytu, więc + sprzątanie musi być w ``finally``, nie po ``yield``. + """ + u = django_user_model.objects.create(username="pechowiec") + with pytest.raises(ValueError): + with soft_delete_context(user=u, reason="bum"): + raise ValueError("bum") + assert current_soft_delete_user() is None + assert current_soft_delete_reason() == "" diff --git a/src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py b/src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py new file mode 100644 index 000000000..004f9ebac --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py @@ -0,0 +1,70 @@ +"""Model ``SoftDeleteLog`` — audyt operacji soft-delete (faza 06, Task 2).""" + +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): + """Skasowanie konta NIE MOŻE zabrać ze sobą wpisów audytu. + + ``SET_NULL``, nie ``CASCADE``: log ma przeżyć odejście pracownika — + inaczej usunięcie konta wymazywałoby ślad po jego decyzjach, czyli + dokładnie to, przed czym ten model ma chronić. + """ + 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 + + +@pytest.mark.django_db +def test_softdeletelog_przezywa_twarde_skasowanie_rekordu(wydawnictwo_ciagle): + """Wpis logu MUSI przetrwać zniknięcie rekordu, którego dotyczy. + + To jest cały powód istnienia GFK zamiast FK: przy ``HARD_DELETE`` + wiersz publikacji znika fizycznie, a log ma zostać jako jedyny ślad, + że kiedykolwiek istniała. FK z ``CASCADE`` skasowałby dowód razem + z dowodem winy; FK z ``PROTECT`` zablokowałby samo kasowanie. + """ + ct = ContentType.objects.get_for_model(wydawnictwo_ciagle) + pk = wydawnictwo_ciagle.pk + log = SoftDeleteLog.objects.create( + content_type=ct, object_id=pk, akcja=SoftDeleteLog.Akcja.HARD_DELETE + ) + wydawnictwo_ciagle.hard_delete() + + log.refresh_from_db() + assert log.object_id == pk + assert log.content_object is None diff --git a/src/conftest.py b/src/conftest.py index 4a46346f1..516b05632 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -714,6 +714,55 @@ def superuser_client(client, superuser): return client +STAFF_USERNAME = "staff" +STAFF_PASSWORD = "staffpass" + + +@pytest.fixture +def staff_user(db): + """Uzytkownik z dostepem do admina, ale BEZ ``is_superuser``. + + Potrzebny wszedzie tam, gdzie testujemy roznice miedzy „staff" a + „superuser" — sam ``is_staff`` nie wystarczy, bo bez uprawnien + modelowych Django nie wpuszcza nawet na changeliste i test mierzylby + brak dostepu zamiast badanej reguly. + """ + from django.contrib.auth.models import Group, Permission + + from bpp.const import GR_WPROWADZANIE_DANYCH + + u = User.objects.create_user( + username=STAFF_USERNAME, + password=STAFF_PASSWORD, + email="staff@example.com", + ) + u.is_staff = True + u.save() + u.user_permissions.add( + *Permission.objects.filter( + content_type__app_label="bpp", + content_type__model__in=("wydawnictwo_ciagle", "autor"), + ) + ) + # Grupa „wprowadzanie danych" to nie ozdobnik. Staff BEZ zadnej grupy + # wywraca render menu admina 500-tka: `_add_group_submenus` dokleja + # submenu tylko czlonkom grupy, a zaraz potem bezwarunkowo robi + # `del menu.children[-1].children[-1]` (src/django_bpp/menu.py:302) — + # przy pustym `menu.children` leci IndexError. To bug niezalezny od + # soft-delete; fixture modeluje realnego redaktora, ktory te grupe ma. + grupa, _ = Group.objects.get_or_create(name=GR_WPROWADZANIE_DANYCH) + u.groups.add(grupa) + return u + + +@pytest.fixture +def staff_client(client, staff_user): + """Zalogowany ``staff_user`` (nie-superuser).""" + if not client.login(username=STAFF_USERNAME, password=STAFF_PASSWORD): + raise Exception("Cannot login staff") + return client + + @pytest.fixture def user_request_factory(test_user): """Fixture zwracający UserRequestFactory.""" diff --git a/src/django_bpp/templates/admin/bpp/soft_delete_powod.html b/src/django_bpp/templates/admin/bpp/soft_delete_powod.html new file mode 100644 index 000000000..5e038db10 --- /dev/null +++ b/src/django_bpp/templates/admin/bpp/soft_delete_powod.html @@ -0,0 +1,46 @@ +{% extends "admin/base_site.html" %} +{% load admin_urls %} + +{% block content %} +{# Strona pośrednia akcji "Usuń do kosza": pyta o powód operacji. #} +{# Powód wędruje przez reason= do SoftDeleteLog.powod (receiver fazy 06). #} + +

🗑️ Zaznaczone rekordy zostaną przeniesione do kosza.

+

+ Operacja jest odwracalna — rekordy wrócą akcją „♻️ Przywróć zaznaczone” + na liście z filtrem Kosz → 🗑️ Tylko skasowane. +

+ +

Do kosza trafi rekordów: {{ ile_obiektow }}.

+ + + +{% if ile_ukrytych %} + {# Przy zaznaczeniu "wszystkie pasujące" lista bywa ogromna — pokazujemy #} + {# próbkę, a nie materializujemy dziesiątek tysięcy rekordów na stronie. #} +

… oraz {{ ile_ukrytych }} dalszych (lista skrócona).

+{% endif %} + +
{% csrf_token %} + {% for pk in wybrane_pk %} + + {% endfor %} + + + + + +

+ +
+ +

+ + + Anuluj +
+{% endblock %} diff --git a/src/zglos_publikacje/models.py b/src/zglos_publikacje/models.py index ab1fe39dd..5e61fc3ad 100644 --- a/src/zglos_publikacje/models.py +++ b/src/zglos_publikacje/models.py @@ -17,6 +17,7 @@ ModelZOplataZaPublikacje, ModelZRokiem, ) +from bpp.models.soft_delete import BppPkPrzedHardDeleteMixin from bpp.models.wydawca import Wydawca from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte from pbn_api.models.publication import Publication as PBN_Publication @@ -58,7 +59,16 @@ def zgloszenie_publikacji_upload_to(instance, filename): class Zgloszenie_Publikacji( - ModelZRokiem, DwaTytuly, ModelZDOI, ModelZOplataZaPublikacje, SoftDeleteModel + # BppPkPrzedHardDeleteMixin: ten model jest soft-delete NIEZALEŻNIE od + # faz 01-04 (używa pakietu od dawna, na własne potrzeby), więc receivery + # fazy 06 obejmują go razem z resztą — a wtedy jego hard_delete() + # potrzebuje zachowanego pk. Patrz docstring mixinu. + BppPkPrzedHardDeleteMixin, + ModelZRokiem, + DwaTytuly, + ModelZDOI, + ModelZOplataZaPublikacje, + SoftDeleteModel, ): email = models.EmailField("E-mail zgłaszającego")