diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md new file mode 100644 index 000000000..15eb87728 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md @@ -0,0 +1,162 @@ +# Soft-delete publikacji + autorów — Plan-indeks (00) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement these plans task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wdrożyć odwracalny soft-delete dla 5 typów publikacji + wąską kaskadę na `*_Autor`, soft-delete autora bez prac (z PROTECT dla autora/książki z zależnościami), wycofanie z PBN przez kolejkę, audyt `SoftDeleteLog` i wsparcie w adminie superusera. + +**Architecture:** `django-soft-delete` (`SoftDeleteModel`) na 5 modelach publikacji + 3 through-modelach `*_Autor`; spójność cache w JEDNYM punkcie — filtr `deleted_at IS NULL` w widokach źródłowych PostgreSQL (mechanizm #1) + opcjonalny trigger-skip. Override `delete()` robi wąską kaskadę na `*_Autor`. PBN-wycofanie idzie przez rozszerzoną `pbn_export_queue`. `SoftDeleteLog` zasilany sygnałami pakietu. + +**Tech Stack:** Django, PostgreSQL (`plpython3u` triggery + widoki), `django-soft-delete>=1.0.23`, `django-denorm-iplweb`, Celery + `pbn_export_queue`, pytest + model_bakery. + +**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) + +--- + +## Plany fazowe (wykonywać w kolejności) + +| # | Plik | Zakres | Zależy od | +|---|---|---|---| +| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; **filtr `deleted_at` TYLKO w widokach źródłowych** (funkcja triggera NIE ruszana); spójność przez `full_refresh()`. ⚠️ patrz „Koordynacja: trigger" niżej | — / trigger ⚠️ | +| 02 | `2026-06-04-soft-delete-02-publikacje.md` | 5 modeli → SoftDeleteModel; override `delete()`/`restore()` z wąską kaskadą na `*_Autor`; `slug` warunkowy unique; przeplecenie menedżerów | 01 | +| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 90 miejsc `*_Autor.objects` | 02 | +| 04 | `2026-06-04-soft-delete-04-guardy-protect.md` | flip FK `CASCADE→PROTECT` (autor, doktorat, `wydawnictwo_nadrzedne`); guard w soft `delete()` (autor + książka-matka); soft-delete husku autora | 02 | +| 05 | `2026-06-04-soft-delete-05-pbn-wycofanie.md` | `pbn_export_queue.operacja = WYSYLKA\|WYCOFANIE`; `WYCOFANIE` → `delete_all_publication_statements`; restore → `WYSYLKA`; integracja `SentData` | 02 | +| 06 | `2026-06-04-soft-delete-06-softdeletelog.md` | model `SoftDeleteLog`; receivery `post_soft_delete`/`post_restore`/`post_hard_delete`; wstrzykiwanie `user` | 02, 05 | +| 07 | `2026-06-04-soft-delete-07-admin.md` | admin superuser-only: kosz/filtr/przywróć/usuń-trwale/powód (5 modeli + Autor); jeden hook usera | 04, 06 | +| 08 | `2026-06-04-soft-delete-08-testy-regresji.md` | pełna suita regresji: PBN duplikaty/wycofanie, dashboard, import, ewaluacja, merge autorów, API | 01–07 | + +--- + +## ⚠️ Koordynacja: trigger `bpp_refresh_cache` (BLOKER fazy 01) + +Funkcja `bpp_refresh_cache()` jest **równolegle optymalizowana w osobnej gałęzi** +(prace użytkownika). **Faza 01 NIE startuje, dopóki ta gałąź nie wyląduje** i +`feat/soft-delete` nie zostanie na nią zaktualizowana (rebase na `dev` lub +merge gałęzi optymalizacji). + +**DECYZJA: faza 01 NIE rusza funkcji `bpp_refresh_cache()` — zmieniamy WYŁĄCZNIE +widoki źródłowe** (`bpp_rekord`, `bpp_*_autorzy` — filtr `deleted_at IS NULL`). +Trigger-skip WYCIĘTY (zbędny przy zachowanym inwariancie delete-first). Utajony +bug z krotkami `(table, id_col)` — NIE naprawiamy tutaj, zostaje optymalizacji +triggera. Ortogonalność pełna: Ty = funkcja triggera, ja = widoki. + +**Inwariant, który MUSI przetrwać optymalizację** (inaczej filtr widoku przestaje +wystarczać i wraca konieczność trigger-skip): na `UPDATE/INSERT` trigger robi +**bezwarunkowy `DELETE` z `_mat` przed re-insertem/upsertem** — tzn. nie +re-insertuje wiersza, którego źródłowy widok nie zwraca. Po wskazaniu gałęzi: +zweryfikować ten inwariant + czy optymalizacja rusza widoki + status utajonego +buga (string w liście krotek `(table, id_col)`). + +--- + +## Wspólne kontrakty (PINNED — wszystkie fazy używają tych nazw VERBATIM) + +### Pakiet `django-soft-delete` (punkt wyjścia, nie zmieniamy) +- `SoftDeleteModel` — abstrakcyjny; pola `deleted_at`, `restored_at`, `transaction_id`. +- Menedżery: `objects` (`SoftDeleteManager`, ukrywa skasowane), `global_objects` (`GlobalManager`, wszystkie), `deleted_objects` (`DeletedManager`, tylko skasowane). +- Metody instancji: `.delete()` (soft, woła `self.save(update_fields=[...])` + `post_soft_delete`), `.hard_delete()`, `.restore()`. +- `SoftDeleteQuerySet.delete()` iteruje per-instancję (`for obj in self.iterator(): obj.delete()`) — bezpieczny dla sygnałów. **NIE** robi bulk update. +- Sygnały (`django_softdelete/signals.py`): `post_soft_delete`, `post_hard_delete`, `post_restore`. + +### Nowy moduł `src/bpp/models/soft_delete.py` (tworzy faza 01) +```python +from django_softdelete.managers import ( + SoftDeleteQuerySet, SoftDeleteManager, DeletedManager, GlobalManager, +) + + +class BppSoftDeleteQuerySet(SoftDeleteQuerySet): + """Gate: blokuje bulk-ustawienie deleted_at/restored_at przez .update() + (omijałoby post_save, kaskadę *_Autor, SoftDeleteLog i reversion).""" + + def update(self, **kwargs): + if "deleted_at" in kwargs or "restored_at" in kwargs: + raise RuntimeError( + "Nie ustawiaj deleted_at/restored_at przez .update() — " + "użyj .delete()/.restore(). Bulk update omija post_save, " + "kaskadę *_Autor, SoftDeleteLog i reversion." + ) + return super().update(**kwargs) + + +class BppSoftDeleteManager(SoftDeleteManager): + def get_queryset(self): + return BppSoftDeleteQuerySet(self.model, using=self._db).filter( + deleted_at__isnull=True + ) + + +class BppGlobalManager(GlobalManager): + def get_queryset(self): + return BppSoftDeleteQuerySet(self.model, using=self._db) +``` +- Tu też ląduje **guard zależności** (faza 04): +```python +def raise_if_has_protected_children(instance, relations: list[str], label: str): + """relations: nazwy reverse-managerów liczone przez global_objects. + Rzuca django.db.models.ProtectedError gdy są dzieci.""" +``` + +### `SoftDeleteLog` — `src/bpp/models/soft_delete_log.py` (tworzy faza 06) +Pola PINNED: `content_type` (FK ContentType), `object_id` (PositiveIntegerField, db_index), `content_object` (GenericForeignKey), `akcja` (`models.TextChoices`: `DELETE="delete"`, `RESTORE="restore"`, `HARD_DELETE="hard_delete"`), `user` (FK `AUTH_USER_MODEL`, null=True, on_delete=SET_NULL), `timestamp` (DateTimeField auto_now_add, db_index), `powod` (TextField blank, default=""), `pbn_queue_entry` (FK `pbn_export_queue.PBN_Export_Queue`, null=True, on_delete=SET_NULL), `pbn_status` (CharField blank). + +### `pbn_export_queue` rozszerzenie (faza 05) +- Nowe pole na `PBN_Export_Queue`: `operacja = models.CharField(choices=Operacja.choices, default=Operacja.WYSYLKA)` gdzie `class Operacja(models.TextChoices): WYSYLKA="wysylka"; WYCOFANIE="wycofanie"`. +- Gałąź w logice wysyłki: `WYCOFANIE` → `client.delete_all_publication_statements(pbn_uid)` (`src/pbn_api/client/mixins/institutions.py:87`). + +### Wstrzykiwanie `user` (PINNED — API thread-local, faza 06 tworzy, 07 używa) +- Override sygnatury: `delete(self, *args, user=None, reason="", **kwargs)` i `restore(self, *args, user=None, **kwargs)`. +- **Kanoniczne API (faza 06, `src/bpp/models/soft_delete_context.py`):** context manager `soft_delete_context(user=None, reason="")` (thread-local, reentrant — wąska kaskada `*_Autor` dziedziczy kontekst rodzica) + akcesory `get_soft_delete_user()` / `get_soft_delete_reason()`. Receivery sygnałów czytają akcesory (sygnał nie niesie usera). +- **Faza 07 (admin) używa `soft_delete_context`** — jeden hook (`delete_model`/`delete_queryset`/akcja „Przywróć") owija operację w `with soft_delete_context(user=request.user, reason=...):`. Ten sam moment ma w przyszłości zasilić `reversion.set_user` (patrz „Kontrakty z reversion"). NIE wymyślać osobnego `set/get/clear_soft_delete_user` — używać `soft_delete_context`. +- **`zamowil` w `pbn_export_queue` jest NOT NULL** — gdy `user is None` (operacje systemowe/celery), zakolejkowanie używa konta technicznego (`get_or_create`), nie `None`. +- Operacje systemowe (merge autora, celery): `user=None` w `SoftDeleteLog` (FK nullable), konto techniczne tylko dla `zamowil` kolejki. + +### Punkty zaczepienia w istniejącym kodzie (zweryfikowane) +- Rejestracja sygnałów: `src/bpp/apps.py` → `BppConfig.ready()` (linia 8). +- Menedżery publikacji: `src/bpp/models/wydawnictwo_ciagle.py:87` (`Wydawnictwo_Ciagle_Manager`), `wydawnictwo_zwarte.py:167` (`Wydawnictwo_Zwarte_Manager`), oba po `ManagerModeliZOplataZaPublikacjeMixin` (`src/bpp/models/abstract/fees.py`). +- Through-model FK autora: `src/bpp/models/abstract/authors.py:22` (`autor = ForeignKey("bpp.Autor", CASCADE)`). +- Doktorat FK: `src/bpp/models/praca_doktorska.py:136` (CASCADE). Habilitacja: `praca_habilitacyjna.py:42` (O2O PROTECT, bez zmian). +- Self-FK rozdziałów: `src/bpp/models/wydawnictwo_zwarte.py:202` (`wydawnictwo_nadrzedne`). +- Trigger/widoki: `src/bpp/migrations/0001_cache_functions.sql` (funkcja `bpp_refresh_cache`), `src/bpp/migrations/0001_widoki_autorzy.sql`, `0001_widoki_rekord.sql`. +- `Rekord` czyta widok `bpp_rekord`: `src/bpp/models/cache/rekord.py:357`. Mat-tabela: `:347`. +- `verify_cache`: `src/bpp/management/commands/verify_cache.py`. +- Admin tych modeli: `src/bpp/admin/{wydawnictwo_ciagle,wydawnictwo_zwarte,patent,praca_doktorska,praca_habilitacyjna,autor}.py`; mixiny `src/bpp/admin/helpers/mixins.py`. +- PBN klient: `src/pbn_api/client/mixins/institutions.py:87`. `SentData`: `src/pbn_api/models/sentdata.py`. Kolejka: `src/pbn_export_queue/{models,tasks,admin}.py`. +- Merge autorów: `src/deduplikator_autorow/views/merge.py:155`, `utils/merge.py:191,284,354`. +- Precedens wzorca: `src/zglos_publikacje/models.py` (`Zgłoszenie_Publikacji` już `SoftDeleteModel`). + +--- + +## Kontrakty z django-reversion (NIE implementujemy — odłożone; tylko nie łamiemy) + +Równoległy spec [`../specs/2026-06-04-historia-zmian-reversion-design.md`](../specs/2026-06-04-historia-zmian-reversion-design.md) (odłożony do PO soft-delete) wymaga zostawienia czystych szwów: + +1. **`save()` per-instancja (twardy warunek).** Override `delete()`/`restore()` oraz kaskada na `*_Autor` MUSZĄ iść przez per-instancję `.delete()`/`save()`, **nigdy** bulk `queryset.update(deleted_at=...)`. Inaczej `post_save` nie odpali → przyszła historia reversion cicho zniknie. Gate w `BppSoftDeleteQuerySet.update()` to egzekwuje fail-fast. +2. **Jeden hook usera.** Punkt wstrzyknięcia `request.user` w adminie (faza 07) ma być jedną metodą, którą reversion później doczepi do `set_user`. +3. **Świadomość recover.** Warstwa admina (faza 07) zostawia miejsce na późniejsze ukrycie reversion „recover deleted" (recover po `hard_delete` wskrzeszałby rekord poza przepływem — bez `WYSYLKA`, bez `SoftDeleteLog`, łamiąc warunkowy unique `slug`). + +--- + +## Mapa plików (tworzonych/modyfikowanych w całym wdrożeniu) + +**Tworzone:** +- `src/bpp/models/soft_delete.py` (queryset+gate, managery, guard helper) — faza 01/04 +- `src/bpp/models/soft_delete_log.py` (model `SoftDeleteLog`) — faza 06 +- `src/bpp/migrations/0XXX_*` — migracje pól soft-delete (`*_Autor`, 5 publikacji), `slug` constraint, FK flips, `SoftDeleteLog`, `pbn_export_queue.operacja` +- `src/bpp/migrations/0XXX_soft_delete_views.sql` — filtr `deleted_at` w `bpp_rekord`/`bpp_*_autorzy` + trigger-skip +- `src/bpp/receivers/soft_delete.py` (lub w istniejącym module sygnałów) — receivery — faza 06 + +**Modyfikowane (główne):** +- modele: `wydawnictwo_ciagle.py`, `wydawnictwo_zwarte.py`, `patent.py`, `praca_doktorska.py`, `praca_habilitacyjna.py`, `autor.py`, `abstract/authors.py` +- admin: jw. 6 plików + `admin/helpers/mixins.py` +- `pbn_export_queue/models.py`, `tasks.py`, `admin.py` +- `pbn_api/models/sentdata.py` +- `pbn_import/utils/publication_import.py` (jawny `.hard_delete()`) +- `import_common/`, `crossref_bpp/`, `deduplikator_publikacji/`, `pbn_integrator/`, `ewaluacja_optymalizacja/` (audyt `global_objects`) +- `src/bpp/apps.py` (rejestracja receiverów) + +--- + +## Wykonanie + +Fazy 01→08 sekwencyjnie. Po każdej fazie: pełne testy danej fazy zielone + `ruff check`/`format` + commit. Trigger/cache (01) najwrażliwsze — testy spójności przed czymkolwiek innym. Gałąź: `feat/soft-delete` (ten worktree). diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md new file mode 100644 index 000000000..7f49fb58f --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -0,0 +1,783 @@ +# Soft-delete — Faza 01: `*_Autor` → SoftDeleteModel + widoki źródłowe + trigger Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> ⚠️ **AKTUALIZACJA ZAKRESU (decyzja użytkownika 2026-06-04) — CZYTAJ PRZED STARTEM:** +> 1. **NIE ruszamy funkcji `bpp_refresh_cache()`.** Zmieniamy **wyłącznie widoki +> źródłowe** (`bpp_*_autorzy`, ew. `bpp_rekord`) — filtr `deleted_at IS NULL`. +> Każde zadanie tego planu dotyczące **trigger-skip** / modyfikacji kopii +> `0399` / fixu utajonego buga z krotkami — **POMIŃ** (zostaje równoległej +> optymalizacji triggera). +> 2. **BLOKER:** funkcja `bpp_refresh_cache()` jest równolegle optymalizowana w +> osobnej gałęzi. Tej fazy **NIE startować**, dopóki ta gałąź nie wyląduje i +> `feat/soft-delete` nie zostanie na nią zaktualizowana. Po aktualizacji +> zweryfikować inwariant: trigger na `UPDATE/INSERT` robi **bezwarunkowy +> `DELETE` z `_mat` przed re-insertem/upsertem** (na tym wisi wystarczalność +> filtra widoku). Jeśli inwariant zniknie → dopiero wtedy rozważyć trigger-skip. + +**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez wspólną bazę `BazaModeluOdpowiedzialnosciAutorow`), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, oraz wpiąć filtr `deleted_at IS NULL` do widoków źródłowych PostgreSQL (`bpp_*_autorzy` + gałęzie UNION `bpp_rekord`) tak, by soft-deletowane autorstwa znikały z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i wracały po `restore`. Opcjonalnie: trigger-skip w `bpp_refresh_cache()`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować. + +**Architecture:** Mechanizm nadrzędny to **filtr widoku (#1)** — każda tabela `bpp_*_autor` ma własną kolumnę `deleted_at`, a widoki źródłowe `bpp_wydawnictwo_ciagle_autorzy` / `bpp_wydawnictwo_zwarte_autorzy` / `bpp_patent_autorzy` (`0001_widoki_autorzy.sql`) dostają `AND .deleted_at IS NULL` po **własnej** kolumnie (bez JOIN do rekordu nadrzędnego). To pokrywa WSZYSTKIE ścieżki: re-insert triggera `bpp_refresh_cache()`, bezpośredni odczyt `Rekord`/`RekordView` z widoku `bpp_rekord`, oraz pełną re-projekcję cache. Gałęzie `UNION` w `bpp_rekord` (`0001_widoki_rekord.sql`) per typ publikacji NIE filtrują po `*_autor.deleted_at` (rekord publikacji żyje niezależnie od soft-delete pojedynczego autorstwa — soft-delete publikacji to faza 02), ale dla spójności kontraktu dodajemy filtr `deleted_at IS NULL` na poziomie tabeli autorskiej tylko w widokach `bpp_*_autorzy`. Trigger-skip (#2) to opcjonalna optymalizacja w gałęzi `UPDATE/INSERT` funkcji `bpp_refresh_cache()` (aktualna wersja: `0399_fix_refresh_cache_upsert.sql`): gdy `TD['new']['deleted_at'] is not None` → pomiń upsert (delete-only). Nie zastępuje #1. + +**Tech Stack:** Django 4.2, PostgreSQL (`plpython3u` trigger + widoki), `django-soft-delete>=1.0.23` (`SoftDeleteModel`, `SoftDeleteManager`/`GlobalManager`/`DeletedManager`), pytest + model_bakery, `denorm` (django-denorm-iplweb). Python wyłącznie przez `uv run`. + +**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§1, §2.1, §2.2, §8 pkt 1). Indeks: [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md). + +**Fakty z kodu (zweryfikowane, NIE zmieniać bez ponownej weryfikacji):** +- `BazaModeluOdpowiedzialnosciAutorow` jest `models.Model` (abstract), `src/bpp/models/abstract/authors.py:16`. Po niej dziedziczą wszystkie 3 through-modele. +- `Wydawnictwo_Ciagle_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_ciagle.py:52`. FK `rekord` → `Wydawnictwo_Ciagle`, `related_name="autorzy_set"`, `src/bpp/models/wydawnictwo_ciagle.py:58`. +- `Wydawnictwo_Zwarte_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_zwarte.py:60`. FK `rekord`, `related_name="autorzy_set"`, `:67`. +- `Patent_Autor(BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/patent.py:32`. FK `rekord`, `related_name="autorzy_set"`, `:35`. +- Wszystkie 3 mają `Meta.unique_together` (NIE ruszamy; `deleted_at` nie wchodzi w `unique_together` — autorstwa nie mają warunkowego unique w tej fazie, sług to faza 02). +- `BazaModeluOdpowiedzialnosciAutorow.objects` NIE jest jawnie zdefiniowany → po wpięciu `SoftDeleteModel` domyślne `objects` = `SoftDeleteManager` (z pakietu). Nadpiszemy je naszymi `Bpp*` z `src/bpp/models/soft_delete.py`. +- `SoftDeleteModel.delete()` (pakiet, `django_softdelete/models.py`) robi **refleksyjną kaskadę** po reverse relacjach — dla `*_Autor` reverse relacji do soft-delete dzieci NIE ma (ich dzieci to nie-soft `Autor`/`Jednostka` przez FK forward), więc kaskada jest no-op. `delete()` woła `self.save(update_fields=['deleted_at','restored_at','transaction_id'])` → odpala trigger `bpp_*_autor_cache_trigger` jako `UPDATE`. To jest pożądane. +- Widok `bpp_autorzy_mat` (model `Autorzy`, `src/bpp/models/cache/autorzy.py:39`, `db_table="bpp_autorzy_mat"`) zasilany triggerem z `bpp_autorzy` (UNION `bpp_*_autorzy`). +- Aktualna funkcja triggera to `0399_fix_refresh_cache_upsert.sql` (NIE `0001_cache_functions.sql` — ta jest baseline, nadpisana przez 0399). Trigger-skip dopisujemy do **kopii treści 0399** w nowym pliku SQL. +- `transactional_db` fixture wymagany dla testów dotykających trigger/cache (trigger działa tylko z prawdziwym commitem). Fixture `denorms` (`src/fixtures/conftest_system.py:193`) daje `denorms.flush()`. Fixtury: `wydawnictwo_ciagle_z_dwoma_autorami`, `wydawnictwo_ciagle_z_autorem`, `autor_jan_kowalski`, `jednostka`, `standard_data`, `typy_odpowiedzialnosci`. +- Jedyny liść migracji `bpp`: `0420_autor_pokazuj_siec_powiazan_and_more`. Nowe migracje od niego zależą i są łańcuchowane: `0421 → 0422 (SQL)`. + +**Kontrakt z reversion (PINNED):** soft-delete idzie WYŁĄCZNIE per-instancja przez `.delete()`/`.save()` (nigdy `queryset.update(deleted_at=...)`). `BppSoftDeleteQuerySet.update()` to egzekwuje fail-fast (gate). W tej fazie testujemy gate i kaskadę queryset-ową. + +--- + +## Task 1 — Moduł `src/bpp/models/soft_delete.py` (queryset gate + managery) + +Tworzy współdzielony fundament menedżerów dla całego wdrożenia. Guard zależności (`raise_if_has_protected_children`) dopisuje faza 04 — tu tylko QuerySet + 3 managery (PINNED z indeksu §39-69). + +**Files:** +- Create: `src/bpp/models/soft_delete.py` +- Test (create): `src/bpp/tests/test_soft_delete/__init__.py`, `src/bpp/tests/test_soft_delete/test_managers.py` + +**Steps:** + +- [ ] Utwórz katalog testowy i pusty `__init__.py`: + ```bash + mkdir -p src/bpp/tests/test_soft_delete && touch src/bpp/tests/test_soft_delete/__init__.py + ``` + +- [ ] Napisz failing test gate'a `update()` — `src/bpp/tests/test_soft_delete/test_managers.py`: + ```python + """Testy menedżerów i queryset-gate'a soft-delete.""" + + import pytest + + from bpp.models.soft_delete import ( + BppGlobalManager, + BppSoftDeleteManager, + BppSoftDeleteQuerySet, + ) + + + def test_queryset_gate_blokuje_deleted_at(): + """update(deleted_at=...) musi rzucić RuntimeError (omija post_save, + kaskadę *_Autor, SoftDeleteLog i reversion).""" + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + + qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor) + with pytest.raises(RuntimeError, match="Nie ustawiaj deleted_at"): + qs.update(deleted_at="2026-06-04") + + + def test_queryset_gate_blokuje_restored_at(): + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + + qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor) + with pytest.raises(RuntimeError, match="Nie ustawiaj deleted_at"): + qs.update(restored_at="2026-06-04") + + + def test_queryset_gate_przepuszcza_inne_pola(): + """update() na zwykłym polu działa normalnie (nie rzuca).""" + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + + qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor).none() + assert qs.update(kolejnosc=5) == 0 # pusty QS, ale nie rzuca + + + def test_managery_sa_wlasciwych_klas(): + assert issubclass(BppSoftDeleteManager.__bases__[0].__mro__[0], object) + assert isinstance( + BppSoftDeleteManager().get_queryset.__func__.__qualname__, str + ) + ``` + +- [ ] Uruchom (oczekiwany FAIL — `ModuleNotFoundError: bpp.models.soft_delete`): + ```bash + uv run pytest src/bpp/tests/test_soft_delete/test_managers.py -q + ``` + +- [ ] Minimalna implementacja — `src/bpp/models/soft_delete.py` (VERBATIM z indeksu §40-69): + ```python + """Wspólny fundament soft-delete dla BPP: queryset-gate blokujący bulk + ustawienie deleted_at/restored_at + managery przepleciające filtr soft-delete + z naszą podklasą queryset (gate). Guard zależności (PROTECT) dokłada faza 04. + """ + + from django_softdelete.managers import ( + GlobalManager, + SoftDeleteManager, + SoftDeleteQuerySet, + ) + + + class BppSoftDeleteQuerySet(SoftDeleteQuerySet): + """Gate: blokuje bulk-ustawienie deleted_at/restored_at przez .update() + (omijałoby post_save, kaskadę *_Autor, SoftDeleteLog i reversion).""" + + def update(self, **kwargs): + if "deleted_at" in kwargs or "restored_at" in kwargs: + raise RuntimeError( + "Nie ustawiaj deleted_at/restored_at przez .update() — " + "użyj .delete()/.restore(). Bulk update omija post_save, " + "kaskadę *_Autor, SoftDeleteLog i reversion." + ) + return super().update(**kwargs) + + + class BppSoftDeleteManager(SoftDeleteManager): + def get_queryset(self): + return BppSoftDeleteQuerySet(self.model, using=self._db).filter( + deleted_at__isnull=True + ) + + + class BppGlobalManager(GlobalManager): + def get_queryset(self): + return BppSoftDeleteQuerySet(self.model, using=self._db) + ``` + +- [ ] Uruchom (oczekiwany PASS): + ```bash + uv run pytest src/bpp/tests/test_soft_delete/test_managers.py -q + ``` + +- [ ] Commit: + ```bash + git add src/bpp/models/soft_delete.py src/bpp/tests/test_soft_delete/ + git commit -m "feat(soft-delete): moduł soft_delete.py — gate na update() + managery + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Task 2 — `BazaModeluOdpowiedzialnosciAutorow` dziedziczy `SoftDeleteModel` + migracja pól + +Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`restored_at`/`transaction_id` do WSZYSTKICH 3 konkretnych tabel `*_autor`. Nadpisujemy managery (`objects`/`global_objects`/`deleted_objects`) naszymi `Bpp*` z Task 1, żeby gate był aktywny. Migracja dodaje 3 pola × 3 tabele + indeks na `deleted_at` × 3. + +**Files:** +- Modify: `src/bpp/models/abstract/authors.py:16` (deklaracja klasy + managery), import `:1-13`. +- Create: `src/bpp/migrations/0421_autor_soft_delete_fields.py` +- Test (create): `src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py` + +**Steps:** + +- [ ] Napisz failing test — `src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py`: + ```python + """*_Autor jako SoftDeleteModel: pola, managery, soft-delete/restore per + instancja (bez sprawdzania cache — to Task 4).""" + + import pytest + from django_softdelete.models import SoftDeleteModel + + from bpp.models.patent import Patent_Autor + from bpp.models.soft_delete import BppGlobalManager, BppSoftDeleteManager + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte_Autor + + THROUGH_MODELE = [ + Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte_Autor, + Patent_Autor, + ] + + + @pytest.mark.parametrize("klass", THROUGH_MODELE) + def test_through_jest_softdeletemodel(klass): + assert issubclass(klass, SoftDeleteModel) + + + @pytest.mark.parametrize("klass", THROUGH_MODELE) + def test_through_ma_pola_soft_delete(klass): + nazwy = {f.name for f in klass._meta.get_fields()} + assert {"deleted_at", "restored_at", "transaction_id"} <= nazwy + + + @pytest.mark.parametrize("klass", THROUGH_MODELE) + def test_through_ma_nasze_managery(klass): + assert isinstance(klass.objects, BppSoftDeleteManager) + assert isinstance(klass.global_objects, BppGlobalManager) + + + @pytest.mark.django_db + def test_soft_delete_ukrywa_w_objects_widoczne_w_global( + wydawnictwo_ciagle_z_autorem, + ): + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + pk = wca.pk + wca.delete() + assert not Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists() + assert Wydawnictwo_Ciagle_Autor.global_objects.filter(pk=pk).exists() + assert Wydawnictwo_Ciagle_Autor.deleted_objects.filter(pk=pk).exists() + + + @pytest.mark.django_db + def test_restore_przywraca_do_objects(wydawnictwo_ciagle_z_autorem): + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + pk = wca.pk + wca.delete() + Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore() + assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists() + ``` + +- [ ] Uruchom (oczekiwany FAIL — `test_through_jest_softdeletemodel`: brak `SoftDeleteModel` w MRO; brak pól): + ```bash + uv run pytest src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py -q + ``` + +- [ ] Zmodyfikuj import w `src/bpp/models/abstract/authors.py` — dodaj po linii `from django.db.models import CASCADE, SET_NULL, Q, Sum` (`:10`): + ```python + from django_softdelete.models import SoftDeleteModel + + from bpp.models.soft_delete import ( + BppGlobalManager, + BppSoftDeleteManager, + ) + from django_softdelete.managers import DeletedManager + ``` + (UWAGA na cykl importów: `soft_delete.py` nie importuje modeli BPP, więc bezpieczne. `authors.py` już importuje z `bpp.models.dyscyplina_naukowa` — kolejność OK.) + +- [ ] Zmień deklarację klasy `src/bpp/models/abstract/authors.py:16` z: + ```python + class BazaModeluOdpowiedzialnosciAutorow(models.Model): + ``` + na: + ```python + class BazaModeluOdpowiedzialnosciAutorow(SoftDeleteModel): + ``` + +- [ ] Dodaj jawne managery w ciele klasy `BazaModeluOdpowiedzialnosciAutorow`, tuż przed `class Meta:` (`:92`). Wstaw przed linią ` class Meta:`: + ```python + # Nadpisujemy managery pakietu naszymi (gate na update()). + # Kolejność: pierwszy zdefiniowany manager = _default_manager. + objects = BppSoftDeleteManager() + global_objects = BppGlobalManager() + deleted_objects = DeletedManager() + + ``` + +- [ ] Uruchom `makemigrations` — wygeneruje migrację dla 3 konkretnych modeli: + ```bash + uv run python src/manage.py makemigrations bpp --name autor_soft_delete_fields + ``` + (Spodziewany plik: `src/bpp/migrations/0421_autor_soft_delete_fields.py`, 3 pola × 3 modele = 9 `AddField`. Manager-y są `use_in_migrations=False` domyślnie, więc nie pojawią się w migracji.) + +- [ ] Zweryfikuj treść wygenerowanej migracji — musi zawierać `AddField` `deleted_at`/`restored_at`/`transaction_id` dla `wydawnictwo_ciagle_autor`, `wydawnictwo_zwarte_autor`, `patent_autor`. Jeśli Django dorzuciło `AlterModelManagers` — usuń tę operację ręcznie (Edit), bo managery soft-delete nie idą do schematu. Dependency MUSI być `("bpp", "0420_autor_pokazuj_siec_powiazan_and_more")`. + +- [ ] Dodaj indeks na `deleted_at` do każdej z 3 tabel. Dopisz do `operations` w `0421_autor_soft_delete_fields.py` (po `AddField`-ach), używając `AddIndex`: + ```python + migrations.AddIndex( + model_name="wydawnictwo_ciagle_autor", + index=models.Index( + fields=["deleted_at"], + name="wc_autor_deleted_at_idx", + ), + ), + migrations.AddIndex( + model_name="wydawnictwo_zwarte_autor", + index=models.Index( + fields=["deleted_at"], + name="wz_autor_deleted_at_idx", + ), + ), + migrations.AddIndex( + model_name="patent_autor", + index=models.Index( + fields=["deleted_at"], + name="patent_autor_deleted_at_idx", + ), + ), + ``` + (Nazwy indeksów ≤ 30 znaków — wymóg PostgreSQL/Django. Jeśli `makemigrations` samo dodało `Meta.indexes` przez zmianę modelu — nie dublować; w tej fazie indeks definiujemy WYŁĄCZNIE w migracji, bo `Meta.indexes` w abstrakcyjnej bazie dałby kolizję nazw między 3 tabelami.) + +- [ ] Uruchom `makemigrations --check` (oczekiwane: brak nowych zmian — model i migracja zgodne): + ```bash + uv run python src/manage.py makemigrations bpp --check --dry-run + ``` + +- [ ] Uruchom test (oczekiwany PASS): + ```bash + uv run pytest src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py -q + ``` + +- [ ] Sanity: czy nie rozjechały się inne testy modeli/adminu autorstwa (manager `objects` zmienił klasę): + ```bash + uv run pytest src/bpp/tests/test_cache/ -q + ``` + (Oczekiwany PASS — filtr `deleted_at__isnull=True` na świeżych danych = no-op.) + +- [ ] Commit: + ```bash + git add src/bpp/models/abstract/authors.py src/bpp/migrations/0421_autor_soft_delete_fields.py src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py + git commit -m "feat(soft-delete): *_Autor → SoftDeleteModel + migracja pól deleted_at + indeks + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Task 3 — Migracja SQL: filtr `deleted_at IS NULL` w widokach `bpp_*_autorzy` + trigger-skip + +Przedefiniowanie 3 widoków źródłowych (`bpp_wydawnictwo_ciagle_autorzy`, `bpp_wydawnictwo_zwarte_autorzy`, `bpp_patent_autorzy`) z filtrem po **własnej** kolumnie `deleted_at` tabeli `*_autor` (mechanizm #1, obowiązkowy). Po `DROP ... CASCADE` widoku `bpp_*_autorzy` trzeba odtworzyć też zależny `bpp_autorzy` (UNION). Dodatkowo trigger-skip (#2, opcjonalny) — przedefiniowanie `bpp_refresh_cache()` na bazie 0399 z regułą „deleted_at NOT NULL → delete-only". Migracja ładuje plik `.sql` wzorcem `0399`. + +**Files:** +- Create: `src/bpp/migrations/0422_soft_delete_views.sql` +- Create: `src/bpp/migrations/0422_soft_delete_views.py` +- Test: pokrycie w Task 4 (testy spójności cache) — tu tylko migracja stosuje się czysto. + +**Steps:** + +- [ ] Napisz failing test smoke — dopisz do `src/bpp/tests/test_soft_delete/test_views_sql.py`: + ```python + """Widoki źródłowe bpp_*_autorzy filtrują po własnym deleted_at.""" + + import pytest + from django.db import connection + + + WIDOKI = [ + "bpp_wydawnictwo_ciagle_autorzy", + "bpp_wydawnictwo_zwarte_autorzy", + "bpp_patent_autorzy", + ] + + + @pytest.mark.django_db + @pytest.mark.parametrize("widok", WIDOKI) + def test_widok_zrodlowy_ma_filtr_deleted_at(widok): + """Definicja widoku w pg_get_viewdef musi zawierać 'deleted_at' + (filtr po własnej kolumnie tabeli autorskiej).""" + with connection.cursor() as cur: + cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok]) + defn = cur.fetchone()[0] + assert "deleted_at" in defn, f"{widok} nie filtruje po deleted_at" + ``` + +- [ ] Uruchom (oczekiwany FAIL — widoki jeszcze bez `deleted_at`): + ```bash + uv run pytest src/bpp/tests/test_soft_delete/test_views_sql.py -q + ``` + +- [ ] Utwórz `src/bpp/migrations/0422_soft_delete_views.sql`. Treść = 3 widoki `bpp_*_autorzy` z dodanym `AND .deleted_at IS NULL`, odtworzenie `bpp_autorzy` (UNION, bo `DROP CASCADE` go skasuje), oraz przedefiniowanie `bpp_refresh_cache()` skopiowane z `0399_fix_refresh_cache_upsert.sql` z dopisanym trigger-skip w gałęzi `UPDATE/INSERT`: + ```sql + BEGIN; + + -- ── Mechanizm #1 (OBOWIĄZKOWY): filtr deleted_at w widokach źródłowych ── + -- Po DROP ... CASCADE widoku bpp_*_autorzy znika też zależny bpp_autorzy, + -- więc odtwarzamy go niżej. Filtr po WŁASNEJ kolumnie deleted_at tabeli + -- autorskiej (bez JOIN do rekordu nadrzędnego) — patrz spec §2.1. + + DROP VIEW IF EXISTS bpp_wydawnictwo_ciagle_autorzy CASCADE; + CREATE OR REPLACE VIEW bpp_wydawnictwo_ciagle_autorzy AS + select + django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id, + django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id, + django_content_type.id AS content_type_id, + rekord_id as object_id, + autor_id, + jednostka_id, + kolejnosc, + typ_odpowiedzialnosci_id, + zapisany_jako + from bpp_wydawnictwo_ciagle_autor, django_content_type + WHERE django_content_type.model = 'wydawnictwo_ciagle' + AND django_content_type.app_label = 'bpp' + AND bpp_wydawnictwo_ciagle_autor.deleted_at IS NULL; + + DROP VIEW IF EXISTS bpp_wydawnictwo_zwarte_autorzy CASCADE; + CREATE OR REPLACE VIEW bpp_wydawnictwo_zwarte_autorzy AS + select + django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id, + django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id, + django_content_type.id AS content_type_id, + rekord_id as object_id, + autor_id, + jednostka_id, + kolejnosc, + typ_odpowiedzialnosci_id, + zapisany_jako + from bpp_wydawnictwo_zwarte_autor, django_content_type + WHERE django_content_type.model = 'wydawnictwo_zwarte' + AND django_content_type.app_label = 'bpp' + AND bpp_wydawnictwo_zwarte_autor.deleted_at IS NULL; + + DROP VIEW IF EXISTS bpp_patent_autorzy CASCADE; + CREATE OR REPLACE VIEW bpp_patent_autorzy AS + select + django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id, + django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id, + django_content_type.id AS content_type_id, + rekord_id as object_id, + autor_id, + jednostka_id, + kolejnosc, + typ_odpowiedzialnosci_id, + zapisany_jako + from bpp_patent_autor, django_content_type + WHERE django_content_type.model = 'patent' + AND django_content_type.app_label = 'bpp' + AND bpp_patent_autor.deleted_at IS NULL; + + -- Odtworzenie UNION bpp_autorzy (skasowany przez DROP ... CASCADE powyżej). + -- bpp_praca_doktorska_autorzy / bpp_praca_habilitacyjna_autorzy NIE były + -- ruszane (autorstwo doktoratu/habilitacji nie jest *_Autor SoftDeleteModel + -- w tej fazie) — wciąż istnieją, więc UNION je dociągnie. + DROP VIEW IF EXISTS bpp_autorzy; + CREATE VIEW bpp_autorzy AS + SELECT * FROM bpp_wydawnictwo_ciagle_autorzy + UNION + SELECT * FROM bpp_wydawnictwo_zwarte_autorzy + UNION + SELECT * FROM bpp_patent_autorzy + UNION + SELECT * FROM bpp_praca_doktorska_autorzy + UNION + SELECT * FROM bpp_praca_habilitacyjna_autorzy; + + -- ── Mechanizm #2 (OPCJONALNY): trigger-skip w bpp_refresh_cache() ── + -- Kopia 0399_fix_refresh_cache_upsert.sql z jedną zmianą: w gałęzi + -- UPDATE/INSERT, gdy nowy wiersz ma deleted_at IS NOT NULL, pomijamy upsert + -- (zostaje samo DELETE z _mat). Filtr widoku #1 i tak pokrywa odczyt, ale to + -- oszczędza no-op SELECT/INSERT przy kaskadzie soft-delete na *_Autor. + CREATE OR REPLACE FUNCTION bpp_refresh_cache() + RETURNS TRIGGER + LANGUAGE plpython3u + AS $$ + cache_key = "django_content_type_ver_1" + columns_cache_key = "table_columns_ver_1" + table_name = TD["table_name"] + app_name, model_name = table_name.split("_", 1) + + refresh_rekord = True + refresh_autor = False + + trigger_field_name = "new" + if TD['event'] in ["DELETE", "UPDATE"]: + trigger_field_name = "old" + + TABELE_AUTORSKIE = ['bpp_wydawnictwo_ciagle_autor', 'bpp_wydawnictwo_zwarte_autor', 'bpp_patent_autor'] + id_field_name = 'id' + extra_where = '' + if table_name in TABELE_AUTORSKIE: + id_field_name = 'rekord_id' + model_name = model_name.replace("_autor", "") + refresh_autor = True + refresh_rekord = False + extra_where = ' AND autor_id = %s' % TD[trigger_field_name]['autor_id'] + + object_id = TD[trigger_field_name][id_field_name] + + if GD.get(cache_key) is None: + GD[cache_key] = {} + + if GD.get(columns_cache_key) is None: + GD[columns_cache_key] = {} + + try: + content_type_id = GD[cache_key][table_name] + except KeyError: + query = "SELECT id FROM django_content_type WHERE app_label = '%s' AND model = '%s'" % (app_name, model_name) + res = plpy.execute(query) + GD[cache_key][table_name] = res[0]['id'] + content_type_id = GD[cache_key][table_name] + + if TD["table_name"] in ["bpp_praca_doktorska", "bpp_praca_habilitacyjna"]: + refresh_autor = True + + where = "WHERE %%s = ARRAY[%s, %s]::INTEGER[2]" % (content_type_id, object_id) + where += extra_where + + # ── trigger-skip: soft-delete (UPDATE z deleted_at IS NOT NULL) ── + # zachowuje się jak DELETE (samo wyczyszczenie _mat, bez re-insertu). + skip_reinsert = ( + TD["event"] in ["UPDATE", "INSERT"] + and TD["new"] is not None + and TD["new"].get("deleted_at") is not None + ) + + refresh_tables = [] + if refresh_rekord: + refresh_tables.append(("bpp_rekord_mat", "id")) + refresh_tables.append(("bpp_autorzy_mat", "rekord_id")) + if refresh_autor: + if "bpp_autorzy_mat" not in [t for t, _ in refresh_tables]: + refresh_tables.append(("bpp_autorzy_mat", "rekord_id")) + + def get_table_columns(mat_table): + if mat_table not in GD[columns_cache_key]: + query = """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = '%s' + ORDER BY ordinal_position + """ % mat_table + res = plpy.execute(query) + GD[columns_cache_key][mat_table] = [row['column_name'] for row in res] + return GD[columns_cache_key][mat_table] + + def get_unique_constraint_column(mat_table): + return "id" + + with plpy.subtransaction(): + for table, id_col in refresh_tables: + lock_key = hash(f"{table}_{content_type_id}_{object_id}") % (2**31) + plpy.execute(f"SELECT pg_advisory_xact_lock({lock_key})") + + if TD["event"] == "DELETE" or skip_reinsert: + query = "DELETE FROM " + table + " " + (where % id_col) + plpy.execute(query) + elif TD["event"] in ["UPDATE", "INSERT"]: + source_view = table.replace("_mat", "") + columns = get_table_columns(table) + conflict_col = get_unique_constraint_column(table) + columns_str = ", ".join(columns) + update_columns = [col for col in columns if col != conflict_col] + set_clause = ", ".join([f"{col} = EXCLUDED.{col}" for col in update_columns]) + delete_query = "DELETE FROM " + table + " " + (where % id_col) + plpy.execute(delete_query) + select_query = f"SELECT {columns_str} FROM {source_view} " + (where % id_col) + upsert_query = f""" + INSERT INTO {table} ({columns_str}) + {select_query} + ON CONFLICT ({conflict_col}) DO UPDATE SET {set_clause} + """ + plpy.execute(upsert_query) + $$; + + COMMIT; + ``` + (UWAGA: `refresh_tables` w 0399 to lista krotek `(table, id_col)`, więc sprawdzenie `"bpp_autorzy_mat" not in refresh_tables` z 0399 było błędne dla krotek — tu poprawiamy na `not in [t for t, _ in refresh_tables]`. Reszta logiki 1:1 z 0399.) + +- [ ] Utwórz `src/bpp/migrations/0422_soft_delete_views.py` (wzorzec `0399`): + ```python + from pathlib import Path + + from django.db import connection, migrations + + + def load_sql(apps, schema_editor): + sql_file = Path(__file__).parent / "0422_soft_delete_views.sql" + with open(sql_file) as f: + sql = f.read() + # connection.cursor() zamiast schema_editor.execute(): schema_editor + # interpretuje %s jako placeholdery parametrów (a w plpython3u są %s + # w stringach SQL budowanych ręcznie). + with connection.cursor() as cursor: + cursor.execute(sql) + + + class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0421_autor_soft_delete_fields"), + ] + + operations = [ + migrations.RunPython(load_sql, migrations.RunPython.noop), + ] + ``` + +- [ ] Uruchom test smoke (oczekiwany PASS — migracja zastosuje się przy starcie testowej bazy, widoki będą miały `deleted_at`): + ```bash + uv run pytest src/bpp/tests/test_soft_delete/test_views_sql.py -q + ``` + +- [ ] Zweryfikuj brak driftu migracji i czystość modeli: + ```bash + uv run python src/manage.py makemigrations bpp --check --dry-run + ``` + +- [ ] Commit: + ```bash + git add src/bpp/migrations/0422_soft_delete_views.sql src/bpp/migrations/0422_soft_delete_views.py src/bpp/tests/test_soft_delete/test_views_sql.py + git commit -m "feat(soft-delete): filtr deleted_at w widokach bpp_*_autorzy + trigger-skip + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Task 4 — Testy spójności cache (mat-view) po soft-delete `*_Autor` + +Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` (model `Autorzy`) i z `bpp_autorzy` (model `AutorzyView`); restore → wraca; edycja autorstwa skasowanej publikacji nie wskrzesza wiersza w cache; kaskada queryset-owa (`.delete()` na QS) działa per-instancja. Testy wymagają `transactional_db` (trigger działa tylko z prawdziwym commitem). + +**Files:** +- Test (create): `src/bpp/tests/test_soft_delete/test_cache_consistency.py` +- Modify (jeśli testy ujawnią drift): brak planowanych — testy mają przejść na implementacji z Task 2-3. + +**Steps:** + +- [ ] Napisz testy spójności — `src/bpp/tests/test_soft_delete/test_cache_consistency.py`: + ```python + """Spójność materializowanego cache (bpp_autorzy_mat / model Autorzy) po + soft-delete wierszy *_Autor. Wymaga transactional_db — trigger plpython3u + odpala się dopiero przy realnym commicie.""" + + import pytest + + from bpp.models.cache import Autorzy, AutorzyView + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + + + def _autorzy_mat_dla(wca): + """Wiersze bpp_autorzy_mat (model Autorzy) wskazujące na danego autora + w danym rekordzie.""" + return Autorzy.objects.filter( + autor_id=wca.autor_id, + rekord_id=[wca.rekord.content_type_id, wca.rekord_id], + ) + + + def test_soft_delete_autorstwa_znika_z_mat( + transactional_db, denorms, wydawnictwo_ciagle_z_dwoma_autorami + ): + wc = wydawnictwo_ciagle_z_dwoma_autorami + denorms.flush() + wca = wc.autorzy_set.first() + autor_id = wca.autor_id + + # Przed: autor jest w bpp_autorzy_mat + assert Autorzy.objects.filter(autor_id=autor_id).exists() + # ... i w bpp_autorzy (widok źródłowy) + assert AutorzyView.objects.filter(autor_id=autor_id).exists() + + wca.delete() # soft-delete per instancja + + # Po: znika z mat-view (trigger + filtr widoku) ... + assert not Autorzy.objects.filter(autor_id=autor_id).exists() + # ... i z widoku źródłowego (mechanizm #1) + assert not AutorzyView.objects.filter(autor_id=autor_id).exists() + # Drugi autor pracy NIE zniknął + assert Autorzy.objects.filter(rekord_id__isnull=False).exists() + + + def test_restore_autorstwa_wraca_do_mat( + transactional_db, denorms, wydawnictwo_ciagle_z_autorem + ): + wc = wydawnictwo_ciagle_z_autorem + denorms.flush() + wca = wc.autorzy_set.first() + autor_id = wca.autor_id + pk = wca.pk + + wca.delete() + assert not Autorzy.objects.filter(autor_id=autor_id).exists() + + Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore() + + # Restore → re-insert do mat-view + assert Autorzy.objects.filter(autor_id=autor_id).exists() + assert AutorzyView.objects.filter(autor_id=autor_id).exists() + + + def test_edycja_skasowanego_autorstwa_nie_wskrzesza_w_mat( + transactional_db, denorms, wydawnictwo_ciagle_z_autorem + ): + """Zapis skasowanego wiersza *_Autor (np. zmiana kolejnosc) NIE wraca + do bpp_autorzy_mat — widok źródłowy go odfiltrowuje po własnym + deleted_at (mechanizm #1).""" + wc = wydawnictwo_ciagle_z_autorem + denorms.flush() + wca = wc.autorzy_set.first() + autor_id = wca.autor_id + pk = wca.pk + + wca.delete() + assert not Autorzy.objects.filter(autor_id=autor_id).exists() + + # Edycja skasowanego wiersza (przez global_objects, bo objects ukrywa) + skasowany = Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk) + skasowany.kolejnosc = 99 + skasowany.save() # odpala trigger jako UPDATE z deleted_at NOT NULL + + # Nadal nie ma go w mat-view (kluczowy przypadek brzegowy ze spec §2.1) + assert not Autorzy.objects.filter(autor_id=autor_id).exists() + + + def test_queryset_delete_kaskaduje_per_instancja( + transactional_db, denorms, wydawnictwo_ciagle_z_dwoma_autorami + ): + """.delete() na QuerySet soft-deletuje per instancję (iterator) — + wszystkie wiersze znikają z mat-view, gate update() nie blokuje QS-delete.""" + wc = wydawnictwo_ciagle_z_dwoma_autorami + denorms.flush() + assert Autorzy.objects.count() >= 2 + + Wydawnictwo_Ciagle_Autor.objects.filter(rekord=wc).delete() + + # Wszystkie autorstwa tej pracy zniknęły z mat-view + assert not Autorzy.objects.filter( + rekord_id=[wc.content_type_id, wc.pk] + ).exists() + # ... ale wiersze fizycznie żyją (soft, nie hard) + assert Wydawnictwo_Ciagle_Autor.global_objects.filter(rekord=wc).count() >= 2 + ``` + +- [ ] Uruchom (oczekiwany PASS — implementacja z Task 2+3 pokrywa wszystkie ścieżki): + ```bash + uv run pytest src/bpp/tests/test_soft_delete/test_cache_consistency.py -q + ``` + Jeśli `test_edycja_skasowanego_autorstwa_nie_wskrzesza_w_mat` FAIL → znaczy, że trigger-skip lub filtr widoku nie działa. Diagnoza: sprawdź `pg_get_viewdef('bpp_wydawnictwo_ciagle_autorzy')` (czy `deleted_at IS NULL` obecne) — to obowiązkowy mechanizm #1; trigger-skip sam nie wystarcza dla tej ścieżki (potwierdza spec §2.1). Użyj superpowers:systematic-debugging, NIE łataj testu. + +- [ ] Commit: + ```bash + git add src/bpp/tests/test_soft_delete/test_cache_consistency.py + git commit -m "test(soft-delete): spójność bpp_autorzy_mat po soft-delete/restore *_Autor + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Task 5 — Weryfikacja całości fazy (ruff + regresja cache/autorstwa) + +Gejt zamykający fazę: lint czysty, brak driftu migracji, testy cache + admin autorstwa + API nie regresują przez zmianę domyślnego managera `objects` na `BppSoftDeleteManager`. + +**Files:** brak (tylko uruchomienia). + +**Steps:** + +- [ ] Lint i format (NIE używaj `--fix`; fixy ręczne przez Edit): + ```bash + ruff format src/bpp/models/soft_delete.py src/bpp/models/abstract/authors.py src/bpp/tests/test_soft_delete/ + ruff check src/bpp/models/soft_delete.py src/bpp/models/abstract/authors.py src/bpp/tests/test_soft_delete/ + ``` + +- [ ] Brak driftu migracji: + ```bash + uv run python src/manage.py makemigrations --check --dry-run + ``` + +- [ ] Pełna regresja podsystemów dotkniętych zmianą managera `*_Autor.objects` (cache, admin, API autorstwa). To wyłapie ewentualne miejsca, gdzie kod zakładał, że `objects` zwraca też „skasowane" (w tej fazie nic nie jest skasowane na świeżych fixtach → musi przejść): + ```bash + uv run pytest src/bpp/tests/test_cache/ src/bpp/tests/test_soft_delete/ src/api_v1/ -q + ``` + +- [ ] Jeśli wszystko zielone — faza 01 gotowa. Commit jeśli ruff coś poprawił: + ```bash + git add -A + git commit -m "chore(soft-delete): ruff + weryfikacja regresji fazy 01 + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Założenia i ostrzeżenia między-fazowe (dla faz 02+) + +1. **Domyślny manager `*_Autor.objects` zmienił klasę** na `BppSoftDeleteManager` (filtruje `deleted_at__isnull=True`). Faza 03 (audyt kat. B) MUSI przejść 90 miejsc `*_Autor.objects` — w fazie 01 nic nie jest skasowane, więc filtr jest no-op, ale od fazy 02 (kaskada soft-delete publikacji) zacznie ukrywać. Guard autora (faza 04) MUSI liczyć przez `global_objects` (spec §3.2). +2. **Trigger-skip oparty na 0399**, nie 0001. Każda przyszła zmiana `bpp_refresh_cache()` musi wychodzić od `0422_soft_delete_views.sql` (nie od 0399 ani 0001). Naprawiono przy okazji błąd `"bpp_autorzy_mat" not in refresh_tables` (lista krotek) z 0399 — zweryfikować, czy 0399 faktycznie nie dublował `bpp_autorzy_mat` (jeśli dublował, to drobny regres wydajności, nie poprawności). +3. **Widoki `bpp_praca_doktorska_autorzy` / `bpp_praca_habilitacyjna_autorzy` NIE filtrowane** — autorstwo doktoratu/habilitacji nie jest `*_Autor` SoftDeleteModel (autor doktoratu to FK `Praca_Doktorska.autor`, nie through). Faza 02 (soft-delete publikacji doktorat/habilitacja) musi zadbać o ich zniknięcie z `bpp_rekord` przez własne `deleted_at` na tabeli publikacji — to NIE jest pokryte tą fazą. +4. **Gałęzie UNION `bpp_rekord` NIE dotknięte** w fazie 01 — soft-delete publikacji (kolumna `deleted_at` na `bpp_wydawnictwo_ciagle` itd.) to faza 02; dopiero ona doda filtr `deleted_at IS NULL` do `bpp_*_view`. Faza 01 dotyka wyłącznie ścieżki autorstwa. +5. **`unique_together` na `*_Autor` zachowane bez `deleted_at`** — w tej fazie autorstwa nie mają warunkowego unique. Jeśli przyszła faza pozwoli na re-add tego samego autora po soft-delete (kolizja `(rekord, autor, typ_odpowiedzialnosci)`), trzeba będzie przejść na `UniqueConstraint(condition=Q(deleted_at__isnull=True))` — odłożone, poza zakresem 01. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md new file mode 100644 index 000000000..a88c879f3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md @@ -0,0 +1,558 @@ +# Soft-delete — Faza 02: Publikacje (5 modeli → SoftDeleteModel + wąska kaskada na `*_Autor`) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Każdy krok TDD: napisz padający test → uruchom (oczekiwany FAIL) → minimalna implementacja → uruchom (PASS) → commit. + +**Goal:** Uczynić 5 modeli publikacji (`Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`, `Praca_Habilitacyjna`, `Patent`) `SoftDeleteModel`-ami z **wąską, kontrolowaną kaskadą** soft-delete na własne wiersze `*_Autor` (`Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) pod wspólnym `transaction_id`, **bez** refleksyjnej kaskady pakietu (która ruszyłaby `*_Streszczenie` itd.). Zamienić `slug unique=True` na warunkowy `UniqueConstraint` (reuse slug po soft-delete). Przepleść filtr soft-delete z istniejącymi menedżerami `Wydawnictwo_*_Manager` (mixin opłat) przez wspólny QuerySet/MRO, bez nadpisywania metod fees. + +**Architecture:** `django-soft-delete` daje `SoftDeleteModel` (pola `deleted_at`/`restored_at`/`transaction_id`, menedżery `objects`/`global_objects`/`deleted_objects`, sygnały `post_soft_delete`/`post_restore`/`post_hard_delete`). Faza 01 utworzyła `src/bpp/models/soft_delete.py` z `BppSoftDeleteQuerySet` (gate na bulk `update(deleted_at=...)`), `BppSoftDeleteManager`, `BppGlobalManager` oraz uczyniła 3 modele `*_Autor` SoftDeleteModel-ami (+ filtr `deleted_at` w widokach źródłowych). **Ta faza zależy od 01.** Tu nadpisujemy `delete()`/`restore()` na 5 modelach: per-instancja `save()` (NIGDY bulk `update`), jawna wąska kaskada na `autorzy_set` (related_name `*_Autor`→publikacja) przez `.delete(transaction_id=...)`/`.restore(transaction_id=...)` na każdym wierszu (kontrakt z reversion: zawsze per-instancja). + +**Tech Stack:** Django, PostgreSQL, `django-soft-delete>=1.0.23`, `django-denorm-iplweb` (slug jest polem `@denormalized`!), pytest + `model_bakery.baker`. Python wyłącznie przez `uv run`. Linia ≤88 znaków (ruff). Komentarze/komunikaty po polsku. + +**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§2.2 wąska kaskada, §2.3 slug, §2.4 menedżery). **Indeks (kontrakty PINNED):** [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md). + +--- + +## Założenia wejściowe (z fazy 01 — VERBATIM, nie zmieniać) + +- `src/bpp/models/soft_delete.py` istnieje i eksportuje `BppSoftDeleteQuerySet`, `BppSoftDeleteManager`, `BppGlobalManager` (kod w indeksie §"Nowy moduł"). +- `BppSoftDeleteQuerySet.update()` rzuca `RuntimeError`, gdy w kwargs jest `deleted_at` lub `restored_at` (gate fail-fast). **Z tego wynika twardy zakaz `.update(deleted_at=...)` w tej fazie — kaskada MUSI iść per-instancja.** +- `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` są już `SoftDeleteModel` (mają `deleted_at`, `.delete()`/`.restore()`/`global_objects`/`deleted_objects`). +- Related name `*_Autor` → publikacja: `autorzy_set` (potwierdzone: `wydawnictwo_ciagle.py:59`, `wydawnictwo_zwarte.py:68`, `patent.py:35`). + +## Fakty z kodu (zweryfikowane — używaj tych nazw VERBATIM) + +- `Wydawnictwo_Ciagle` (`src/bpp/models/wydawnictwo_ciagle.py:91`): `objects = Wydawnictwo_Ciagle_Manager()` (`:185`). Manager `Wydawnictwo_Ciagle_Manager(ManagerModeliZOplataZaPublikacjeMixin, models.Manager)` (`:87`). +- `Wydawnictwo_Zwarte` (`:176`): `objects = Wydawnictwo_Zwarte_Manager()` (`:197`). Manager `Wydawnictwo_Zwarte_Manager(ManagerModeliZOplataZaPublikacjeMixin, models.Manager)` (`:167`) z metodą `wydawnictwa_nadrzedne_dla_innych()`. Self-FK `wydawnictwo_nadrzedne` (`:202`, CASCADE — flip na PROTECT robi faza 04, NIE tu). +- `ManagerModeliZOplataZaPublikacjeMixin` (`src/bpp/models/abstract/fees.py:9`) — czysty mixin (NIE Manager), jedyna metoda `rekordy_z_oplata(self)` → `self.exclude(opl_pub_cost_free=None)`. Operuje na queryset menedżera, więc działa poprawnie nad każdym QuerySet-em. +- `Patent`, `Praca_Doktorska`, `Praca_Habilitacyjna` — **NIE mają** własnego menedżera (używają domyślnego `models.Manager` jako `objects`). +- `Praca_Doktorska.autor` FK CASCADE (`praca_doktorska.py:136`), `Praca_Habilitacyjna.autor` O2O PROTECT (`praca_habilitacyjna.py:42`). Te FK to **faza 04** — NIE ruszamy tu. +- **`slug` jest polem `@denormalized(models.SlugField, max_length=400, unique=True, db_index=True, null=True, blank=True)`** (denorm z `django-denorm-iplweb`), w: `wydawnictwo_ciagle.py:246`, `wydawnictwo_zwarte.py:325` (w `Wydawnictwo_Zwarte`), `patent.py:180`, `praca_doktorska.py:105` (w `Praca_Doktorska_Baza` → dziedziczone przez `Praca_Doktorska` **i** `Praca_Habilitacyjna`). Denorm field jest fizyczną kolumną w DB → migracja zmiany `unique=True`→`UniqueConstraint` jest realną migracją schematu. +- `Praca_Habilitacyjna` i `Praca_Doktorska` dziedziczą slug z `Praca_Doktorska_Baza` (abstract) — zmiana atrybutu pola w abstrakcie dotyka OBU modeli; migracje per model (każdy ma własną kolumnę `slug`). +- Następny numer migracji: `0421` (ostatnia: `0420_autor_pokazuj_siec_powiazan_and_more.py`). NIE modyfikuj istniejących migracji. +- `Zgloszenie_Publikacji` (`src/zglos_publikacje/models.py:60`) — precedens: po prostu dziedziczy `SoftDeleteModel` bez własnego menedżera. + +## Kontrakt z reversion (PINNED — NIE łamać) + +- `delete()`/`restore()` i kaskada na `*_Autor` idą **wyłącznie** przez per-instancja `.delete()`/`.restore()`/`save()`. **NIGDY** `autorzy_set.update(deleted_at=...)` ani `autorzy_set.all().delete()` jeśli to bulk — używamy pętli per-instancja, żeby `post_save`/sygnały odpaliły. Gate w `BppSoftDeleteQuerySet.update()` egzekwuje to fail-fast (test to weryfikuje). +- Sygnatury override: `delete(self, *args, user=None, reason="", **kwargs)` i `restore(self, *args, user=None, **kwargs)`. `user`/`reason` na razie tylko przepuszczamy do `super()`/sygnałów (konsumuje je faza 06); tu MUSZĄ istnieć w sygnaturze. + +--- + +## Task 1: Mixin `delete()`/`restore()` z wąską kaskadą — `BppPublikacjaSoftDeleteMixin` + +**Files:** +- Modify: `src/bpp/models/soft_delete.py` (dodaj klasę mixinu na końcu) +- Test path: `src/bpp/tests/test_soft_delete_publikacje.py` (nowy plik) + +Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-instancja `save()` rodzica, jawna wąska kaskada na `autorzy_set` (każdy wiersz `*_Autor` przez `.delete(transaction_id=...)`), bez refleksyjnej kaskady pakietu (`super().delete()` nie wołamy — sami ustawiamy `deleted_at` + emitujemy sygnał, żeby NIE ruszać `*_Streszczenie`). Wszystkie 5 modeli mają `autorzy_set` (potwierdzone), więc kaskada jest jednolita. + +- [ ] **Krok 1.1 — padający test: soft-delete publikacji kaskaduje na `*_Autor` tym samym `transaction_id`.** + Dopisz do `src/bpp/tests/test_soft_delete_publikacje.py`: + ```python + import pytest + from model_bakery import baker + + from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor + + + @pytest.mark.django_db + def test_soft_delete_publikacji_kaskaduje_na_autor_wspolny_txid(): + wc = baker.make(Wydawnictwo_Ciagle) + a1 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc) + a2 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc) + + wc.delete() + + wc.refresh_from_db() + assert wc.deleted_at is not None + assert wc.transaction_id is not None + + for a in (a1, a2): + row = Wydawnictwo_Ciagle_Autor.global_objects.get(pk=a.pk) + assert row.deleted_at is not None, "autorstwo nie zostało soft-skasowane" + assert row.transaction_id == wc.transaction_id, "różny transaction_id" + ``` +- [ ] **Krok 1.2 — uruchom, oczekiwany FAIL** (kaskady jeszcze nie ma; `*_Autor` zostaje nieskasowane): + ```bash + uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_soft_delete_publikacji_kaskaduje_na_autor_wspolny_txid -x + ``` + Oczekiwane: `AssertionError: autorstwo nie zostało soft-skasowane` (lub `Wydawnictwo_Ciagle` nie jest jeszcze SoftDeleteModel → `AttributeError`/błąd importu; obie wersje to FAIL przed implementacją — implementację robią Task 1+2 razem). +- [ ] **Krok 1.3 — minimalna implementacja: mixin.** Dopisz do `src/bpp/models/soft_delete.py`: + ```python + import uuid + + from django.db import transaction + from django.utils import timezone + from django_softdelete.models import SoftDeleteModel + from django_softdelete.signals import post_restore, post_soft_delete + + + class BppPublikacjaSoftDeleteMixin(SoftDeleteModel): + """Wąska, kontrolowana kaskada soft-delete: rodzic + własne wiersze + `*_Autor` (related_name `autorzy_set`) pod wspólnym `transaction_id`. + + NIE używa refleksyjnej kaskady pakietu (rzuciłaby SoftDeleteException + na `*_Streszczenie`/`*_Zewnetrzna_Baza_Danych`/`Publikacja_Habilitacyjna` + przy strict=True, albo twardo skasowała je przy strict=False). Kaskada + zatrzymuje się na `*_Autor`. Kontrakt z reversion: zawsze per-instancja + save()/delete(), NIGDY bulk update(deleted_at=...). + """ + + class Meta: + abstract = True + + def delete(self, *args, user=None, reason="", **kwargs): + now = timezone.now() + txid = kwargs.pop("transaction_id", None) or uuid.uuid4() + with transaction.atomic(): + # 1. wąska kaskada na własne *_Autor (per-instancja!) + for autorstwo in self.autorzy_set.all(): + autorstwo.delete(transaction_id=txid) + # 2. własne deleted_at + save (NIGDY bulk update) + self.deleted_at = now + self.restored_at = None + self.transaction_id = txid + self.save( + update_fields=["deleted_at", "restored_at", "transaction_id"] + ) + post_soft_delete.send(sender=self.__class__, instance=self) + return 1, {self._meta.label: 1} + + delete.alters_data = True + + def restore(self, *args, user=None, **kwargs): + txid = self.transaction_id + with transaction.atomic(): + # przywróć własne *_Autor skasowane tym samym transaction_id + if txid is not None: + for autorstwo in self.autorzy_set.model.deleted_objects.filter( + rekord=self, transaction_id=txid + ): + autorstwo.restore(transaction_id=txid) + self.deleted_at = None + self.restored_at = timezone.now() + self.transaction_id = None + self.save( + update_fields=["deleted_at", "restored_at", "transaction_id"] + ) + post_restore.send( + sender=self.__class__, instance=self, transaction_id=txid + ) + + restore.alters_data = True + ``` + > Uwaga: `self.autorzy_set.all()` używa **domyślnego** menedżera `*_Autor` (`objects` ukrywa już-skasowane) — przy delete to OK (skasowane drugi raz nie szkodzi, a zwykle nie ma takich). `restore()` celowo czyta przez `deleted_objects` po `transaction_id`, bo `objects` ukrywa skasowane. `*_Autor.restore()` pochodzi z `SoftDeleteModel` (faza 01) — bez własnej kaskady (liść). +- [ ] **Krok 1.4** — implementacja sama nie przejdzie testu, dopóki `Wydawnictwo_Ciagle` nie dziedziczy mixinu (Task 2). NIE uruchamiaj jeszcze testu na PASS — przejdź do Task 2 (mixin + model muszą być razem). Po Task 2 wrócimy. +- [ ] **Krok 1.5 — commit szkieletu mixinu:** + ```bash + git add src/bpp/models/soft_delete.py src/bpp/tests/test_soft_delete_publikacje.py + git commit -m "feat(soft-delete): mixin waskiej kaskady delete/restore na *_Autor + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Task 2: 5 modeli dziedziczy mixin + migracje pól `deleted_at`/`restored_at`/`transaction_id` + indeks + +**Files:** +- Modify: `src/bpp/models/wydawnictwo_ciagle.py:91` (klasa `Wydawnictwo_Ciagle` — dopisz `BppPublikacjaSoftDeleteMixin` do baz) +- Modify: `src/bpp/models/wydawnictwo_zwarte.py:176` (klasa `Wydawnictwo_Zwarte`) +- Modify: `src/bpp/models/patent.py:62` (klasa `Patent`) +- Modify: `src/bpp/models/praca_doktorska.py:135` (klasa `Praca_Doktorska`) +- Modify: `src/bpp/models/praca_habilitacyjna.py:41` (klasa `Praca_Habilitacyjna`) +- Create: `src/bpp/migrations/0421_publikacje_soft_delete_fields.py` +- Test path: `src/bpp/tests/test_soft_delete_publikacje.py` + +> **Kolejność MRO:** mixin dopisujemy jako **pierwszą** bazę (przed pozostałymi mixinami modelu), żeby jego `delete()`/`restore()` wygrały w MRO nad `models.Model.delete()`. NIE jako ostatnią. `BppPublikacjaSoftDeleteMixin(SoftDeleteModel)` wnosi też pola `deleted_at`/`restored_at`/`transaction_id` i menedżery — ale menedżery dla `Wydawnictwo_*` nadpiszemy w Task 4 (interleaving fees); dla `Patent`/`Praca_*` zostaną menedżery z `SoftDeleteModel`. + +- [ ] **Krok 2.1 — dopisz mixin do baz 5 modeli.** Import na górze każdego pliku: + ```python + from bpp.models.soft_delete import BppPublikacjaSoftDeleteMixin + ``` + i dodaj `BppPublikacjaSoftDeleteMixin,` jako **pierwszą** bazę klasy modelu. Np. w `wydawnictwo_ciagle.py`: + ```python + class Wydawnictwo_Ciagle( + BppPublikacjaSoftDeleteMixin, + ZapobiegajNiewlasciwymCharakterom, + Wydawnictwo_Baza, + ... + ``` + Analogicznie `Wydawnictwo_Zwarte`, `Patent`, `Praca_Doktorska`, `Praca_Habilitacyjna`. + > Uwaga `Praca_Doktorska`/`Praca_Habilitacyjna`: dziedziczą po `Praca_Doktorska_Baza`. Dodaj mixin jako pierwszą bazę **konkretnej** klasy (`Praca_Doktorska`, `Praca_Habilitacyjna`), NIE do abstraktu `Praca_Doktorska_Baza` (inaczej `Praca_Habilitacyjna.autor` O2O PROTECT + abstrakt namieszają w MRO; trzymamy mixin na klasach konkretnych). +- [ ] **Krok 2.2 — wygeneruj migrację pól:** + ```bash + uv run python src/manage.py makemigrations bpp --name publikacje_soft_delete_fields + ``` + Oczekiwane: nowa migracja `0421_publikacje_soft_delete_fields.py` z `AddField` `deleted_at`/`restored_at`/`transaction_id` dla 5 modeli. Zweryfikuj nazwę pliku (`0421_`); jeśli numer inny — użyj faktycznego. +- [ ] **Krok 2.3 — dopisz indeks per model na `deleted_at`.** Do wygenerowanej migracji dołóż operacje `AddIndex` (lub edytuj `Meta.indexes` modeli i przegeneruj). Ręcznie w migracji, po `AddField`-ach: + ```python + from django.db import migrations, models + + # w operations, dla każdego z 5 modeli: + migrations.AddIndex( + model_name="wydawnictwo_ciagle", + index=models.Index( + fields=["deleted_at"], name="wc_deleted_at_idx" + ), + ), + # ... analogicznie: wz_deleted_at_idx, patent_deleted_at_idx, + # pdok_deleted_at_idx, phab_deleted_at_idx + ``` + > Nazwy indeksów ≤30 znaków (limit Postgres dla auto-nazw nie obowiązuje przy jawnej nazwie, ale trzymaj krótkie i unikalne). Na produkcji rozważ `AddIndexConcurrently` (osobna migracja `atomic=False`) — dla dużych tabel; tu wystarcza zwykły `AddIndex` (decyzja deploymentu, poza zakresem testów). +- [ ] **Krok 2.4 — sprawdź spójność migracji:** + ```bash + uv run python src/manage.py makemigrations --check --dry-run bpp + ``` + Oczekiwane: `No changes detected` (po dołożeniu indeksów ręcznie — jeśli zgłasza zmiany, dorównaj `Meta.indexes` modeli do migracji). +- [ ] **Krok 2.5 — uruchom test z Task 1 (teraz PASS):** + ```bash + uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_soft_delete_publikacji_kaskaduje_na_autor_wspolny_txid -x + ``` + Oczekiwane: PASS. +- [ ] **Krok 2.6 — commit:** + ```bash + git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0421_publikacje_soft_delete_fields.py + git commit -m "feat(soft-delete): 5 modeli publikacji -> SoftDeleteModel + migracje pol/indeksow + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Task 3: `slug` — warunkowy `UniqueConstraint` (reuse slug po soft-delete) + +**Files:** +- Modify: `src/bpp/models/wydawnictwo_ciagle.py:246` (denorm `slug`: `unique=True`→brak unique; `Meta.constraints`) +- Modify: `src/bpp/models/wydawnictwo_zwarte.py:325` +- Modify: `src/bpp/models/patent.py:180` +- Modify: `src/bpp/models/praca_doktorska.py:105` (w `Praca_Doktorska_Baza`) +- Create: `src/bpp/migrations/0422_publikacje_slug_warunkowy_unique.py` +- Test path: `src/bpp/tests/test_soft_delete_publikacje.py` + +Zamiana `unique=True` na `models.UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True), name="...")` per model. Skasowany rekord trzyma slug → nowy rekord z tym samym slug-iem nie koliduje (constraint pomija `deleted_at IS NOT NULL`). + +> **Wrinkle denorm:** `slug` to `@denormalized(models.SlugField, ..., unique=True, ...)`. `unique=True` jest kwargiem przekazywanym do `SlugField`. Usuwamy `unique=True` z denormalized-deklaracji i dodajemy `UniqueConstraint` w `Meta`. Denorm regeneruje wartość pola po zapisie — sam constraint go nie dotyka, działa na poziomie DB. `Praca_Doktorska_Baza.slug` jest abstrakcyjny → constraint w `Meta` abstraktu **nie** propaguje automatycznie do konkretnych klas; dlatego `UniqueConstraint` dla `Praca_Doktorska`/`Praca_Habilitacyjna` dodaj w `Meta` KAŻDEJ konkretnej klasy (każda ma własną kolumnę `slug`). + +- [ ] **Krok 3.1 — padający test: reuse slug po soft-delete.** Dopisz: + ```python + @pytest.mark.django_db + def test_reuse_slug_po_soft_delete(): + wc1 = baker.make(Wydawnictwo_Ciagle) + wc1.refresh_from_db() + slug = wc1.slug + assert slug + + wc1.delete() # soft + + wc2 = baker.make(Wydawnictwo_Ciagle) + wc2.slug = slug + wc2.save(update_fields=["slug"]) # nie może rzucić IntegrityError + wc2.refresh_from_db() + assert wc2.slug == slug + ``` +- [ ] **Krok 3.2 — uruchom, oczekiwany FAIL** (`IntegrityError: duplicate key value violates unique constraint` na `slug`, bo `unique=True` jeszcze żyje): + ```bash + uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_reuse_slug_po_soft_delete -x + ``` +- [ ] **Krok 3.3 — implementacja: usuń `unique=True`, dodaj constraint.** W każdym z 4 miejsc denorm-deklaracji `slug` usuń linię `unique=True,` (zostaw `db_index=True` — indeks nadal pożądany). W `Meta` każdego z 5 modeli (`Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Patent`, `Praca_Doktorska`, `Praca_Habilitacyjna`) dodaj: + ```python + from django.db.models import Q # jeśli brak importu w pliku + + class Meta: + ... + constraints = [ + models.UniqueConstraint( + fields=["slug"], + condition=Q(deleted_at__isnull=True), + name="wc_slug_uniq_zywe", # unikalna nazwa per model + ), + ] + ``` + Nazwy: `wc_slug_uniq_zywe`, `wz_slug_uniq_zywe`, `patent_slug_uniq_zywe`, `pdok_slug_uniq_zywe`, `phab_slug_uniq_zywe`. +- [ ] **Krok 3.4 — migracja:** + ```bash + uv run python src/manage.py makemigrations bpp --name publikacje_slug_warunkowy_unique + ``` + Oczekiwane: `RemoveField`/`AlterField` (zdjęcie `unique`) + `AddConstraint` dla 5 modeli. Zweryfikuj numer `0422_`. +- [ ] **Krok 3.5 — uruchom test (PASS) + check migracji:** + ```bash + uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_reuse_slug_po_soft_delete -x + uv run python src/manage.py makemigrations --check --dry-run bpp + ``` + Oczekiwane: PASS + `No changes detected`. +- [ ] **Krok 3.6 — commit:** + ```bash + git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0422_publikacje_slug_warunkowy_unique.py + git commit -m "feat(soft-delete): slug -> warunkowy UniqueConstraint (reuse po soft-delete) + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Task 4: Przeplecenie menedżerów `Wydawnictwo_*_Manager` z filtrem soft-delete (wspólny QuerySet/MRO) + +**Files:** +- Modify: `src/bpp/models/wydawnictwo_ciagle.py:87` (`Wydawnictwo_Ciagle_Manager`) +- Modify: `src/bpp/models/wydawnictwo_zwarte.py:167` (`Wydawnictwo_Zwarte_Manager` + metoda `wydawnictwa_nadrzedne_dla_innych`) +- Modify: `src/bpp/models/wydawnictwo_ciagle.py:185`, `wydawnictwo_zwarte.py:197` (przypisanie `objects`/`global_objects`/`deleted_objects`) +- Test path: `src/bpp/tests/test_soft_delete_publikacje.py` + +Po wpięciu mixinu `Wydawnictwo_Ciagle`/`Wydawnictwo_Zwarte` dostają z `SoftDeleteModel` menedżery `objects`/`global_objects`/`deleted_objects` — ALE one nadpisują/kolidują z istniejącym `objects = Wydawnictwo_*_Manager()` (mixin opłat). Trzeba **przepleść**: menedżer publikacji ma równocześnie (a) filtrować `deleted_at__isnull=True`, (b) zachować `rekordy_z_oplata()`/`wydawnictwa_nadrzedne_dla_innych()`. Robimy to przez wspólny `BppSoftDeleteQuerySet` + MRO (mixin opłat operuje na queryset, więc działa nad każdym QS), **bez** nadpisywania metod fees. + +> **Klucz:** `ManagerModeliZOplataZaPublikacjeMixin` to mixin metod menedżera (`self.exclude(...)`), niezależny od źródła QS. `BppSoftDeleteManager` z fazy 01 już zwraca `BppSoftDeleteQuerySet(...).filter(deleted_at__isnull=True)`. Składamy: `class Wydawnictwo_Ciagle_Manager(ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager)`. `rekordy_z_oplata()` woła `self.exclude(...)` → działa na już-przefiltrowanym (żywym) QS. Zero nadpisywania fees. + +- [ ] **Krok 4.1 — padający test: `objects` ukrywa skasowane, `rekordy_z_oplata` też, `global_objects` widzi wszystko.** Dopisz: + ```python + @pytest.mark.django_db + def test_menedzery_publikacji_filtruja_soft_delete(): + wc_zywy = baker.make(Wydawnictwo_Ciagle, opl_pub_cost_free=True) + wc_kosz = baker.make(Wydawnictwo_Ciagle, opl_pub_cost_free=True) + wc_kosz.delete() + + ids = set(Wydawnictwo_Ciagle.objects.values_list("pk", flat=True)) + assert wc_zywy.pk in ids + assert wc_kosz.pk not in ids, "objects nie ukrywa skasowanych" + + # metoda fees nadal działa i też pomija kosz: + oplata_ids = set( + Wydawnictwo_Ciagle.objects.rekordy_z_oplata().values_list( + "pk", flat=True + ) + ) + assert wc_zywy.pk in oplata_ids + assert wc_kosz.pk not in oplata_ids + + all_ids = set( + Wydawnictwo_Ciagle.global_objects.values_list("pk", flat=True) + ) + assert wc_kosz.pk in all_ids, "global_objects nie widzi skasowanych" + ``` +- [ ] **Krok 4.2 — uruchom, oczekiwany FAIL.** Przed implementacją `objects` to wciąż stary `Wydawnictwo_Ciagle_Manager(... models.Manager)` (NIE filtruje `deleted_at`) → `wc_kosz.pk` jest w `ids`: + ```bash + uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_menedzery_publikacji_filtruja_soft_delete -x + ``` + Oczekiwane: `AssertionError: objects nie ukrywa skasowanych` (albo `AttributeError: global_objects` jeśli mixin opłat przesłonił menedżery SoftDeleteModel). +- [ ] **Krok 4.3 — implementacja: przeplecione menedżery.** Import w obu plikach: + ```python + from bpp.models.soft_delete import BppGlobalManager, BppSoftDeleteManager + ``` + W `wydawnictwo_ciagle.py` zamień definicję menedżera: + ```python + class Wydawnictwo_Ciagle_Manager( + ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager + ): + pass + ``` + i w ciele `Wydawnictwo_Ciagle` (przy `objects = ...`): + ```python + objects = Wydawnictwo_Ciagle_Manager() + global_objects = BppGlobalManager() + deleted_objects = DeletedManager() # import: from django_softdelete.managers import DeletedManager + ``` + W `wydawnictwo_zwarte.py`: + ```python + class Wydawnictwo_Zwarte_Manager( + ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager + ): + def wydawnictwa_nadrzedne_dla_innych(self): + return ( + self.exclude(wydawnictwo_nadrzedne_id=None) + .values_list("wydawnictwo_nadrzedne_id", flat=True) + .distinct() + ) + ``` + i w ciele `Wydawnictwo_Zwarte`: + ```python + objects = Wydawnictwo_Zwarte_Manager() + global_objects = BppGlobalManager() + deleted_objects = DeletedManager() + ``` + > `Patent`/`Praca_Doktorska`/`Praca_Habilitacyjna` NIE mają mixinu opłat — dostają `objects`/`global_objects`/`deleted_objects` wprost z `SoftDeleteModel` (przez `BppPublikacjaSoftDeleteMixin`). NIC tu nie zmieniamy dla nich. Ale upewnij się, że ich `objects` to `BppSoftDeleteManager` — jeśli mixin dziedziczy gołe `SoftDeleteModel`, menedżer to package-owy `SoftDeleteManager` (też filtruje `deleted_at`, ale bez gate na `.update()`). **Decyzja:** w `BppPublikacjaSoftDeleteMixin` ustaw jawnie `objects = BppSoftDeleteManager()`, `global_objects = BppGlobalManager()`, `deleted_objects = DeletedManager()` w ciele mixinu — wtedy 3 proste modele dostają gate'owany QuerySet z fazy 01 za darmo, a `Wydawnictwo_*` nadpisują `objects` własnym (przeplecionym) menedżerem. +- [ ] **Krok 4.4 — dopisz menedżery do mixinu (Task 1).** W `BppPublikacjaSoftDeleteMixin` (ciało, przed `Meta`): + ```python + from django_softdelete.managers import DeletedManager + + objects = BppSoftDeleteManager() + global_objects = BppGlobalManager() + deleted_objects = DeletedManager() + ``` + (importy `BppSoftDeleteManager`/`BppGlobalManager` są już w `soft_delete.py`). +- [ ] **Krok 4.5 — uruchom test (PASS) + sprawdź, że nie powstała migracja menedżera.** Menedżery nie tworzą migracji schematu (chyba że `use_in_migrations`): + ```bash + uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_menedzery_publikacji_filtruja_soft_delete -x + uv run python src/manage.py makemigrations --check --dry-run bpp + ``` + Oczekiwane: PASS + `No changes detected` (jeśli Django chce migrację `default_manager` — wygeneruj ją: `--name publikacje_menedzery` i dołącz do commitu). +- [ ] **Krok 4.6 — commit:** + ```bash + git add -A + git commit -m "feat(soft-delete): przeplecenie menedzerow Wydawnictwo_* z filtrem soft-delete + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Task 5: Testy integracyjne — znika z Rekord/Autorzy, restore, post_soft_delete, *_Streszczenie nietknięte, gate bulk-update + +**Files:** +- Modify: `src/bpp/tests/test_soft_delete_publikacje.py` +- Test path: ten sam + +> Te testy zależą od fazy 01 (trigger/widoki muszą usuwać z `bpp_rekord_mat`/`bpp_autorzy_mat` na podstawie `deleted_at`). Jeśli któryś z testów Rekord/Autorzy padnie z powodu cache, to regresja fazy 01 — zgłoś, NIE łataj tu. + +- [ ] **Krok 5.1 — test: publikacja znika z `Rekord` i `Autorzy`, restore wraca.** Dopisz: + ```python + from bpp.models import Autorzy, Rekord + + + @pytest.mark.django_db + def test_soft_delete_znika_z_rekord_i_autorzy_restore_wraca(denorms): + wc = baker.make(Wydawnictwo_Ciagle) + baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc) + wc.refresh_from_db() + ct_pk = wc.content_type_id if hasattr(wc, "content_type_id") else None + + assert Rekord.objects.filter( + id=(wc.content_type_id, wc.pk) + ).exists() or Rekord.objects.filter(tytul_oryginalny=wc.tytul_oryginalny).exists() + + wc.delete() + assert not Rekord.objects.filter( + tytul_oryginalny=wc.tytul_oryginalny + ).exists(), "skasowany rekord wciąż w Rekord" + assert not Autorzy.objects.filter(rekord_id=(wc.content_type_id, wc.pk)).exists() + + wc.restore() + assert Rekord.objects.filter( + tytul_oryginalny=wc.tytul_oryginalny + ).exists(), "po restore rekord nie wrócił do Rekord" + assert Autorzy.objects.filter(rekord_id=(wc.content_type_id, wc.pk)).exists() + ``` + > `Rekord.id` to tuple `(content_type_id, object_id)`. Jeśli `Rekord`/`Autorzy` API różni się — dostosuj filtr po realnym kontrakcie `src/bpp/models/cache/`. Fixture `denorms` (z `src/conftest.py`) odpala denorm flush — sprawdź czy istnieje; jeśli nie, użyj właściwej fixtury cache z repo (`flush_denorm`/`denorm_rebuild`). +- [ ] **Krok 5.2 — test: restore przywraca `*_Autor` po tym samym transaction_id.** + ```python + @pytest.mark.django_db + def test_restore_przywraca_autorow(): + wc = baker.make(Wydawnictwo_Ciagle) + a1 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc) + wc.delete() + assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=a1.pk).count() == 0 + + wc.restore() + row = Wydawnictwo_Ciagle_Autor.objects.get(pk=a1.pk) + assert row.deleted_at is None + assert row.transaction_id is None + ``` +- [ ] **Krok 5.3 — test: `post_soft_delete` emitowany.** + ```python + from django_softdelete.signals import post_soft_delete + + + @pytest.mark.django_db + def test_post_soft_delete_emitowany(): + odebrane = [] + + def receiver(sender, instance, **kwargs): + odebrane.append(instance) + + post_soft_delete.connect(receiver, sender=Wydawnictwo_Ciagle) + try: + wc = baker.make(Wydawnictwo_Ciagle) + wc.delete() + finally: + post_soft_delete.disconnect(receiver, sender=Wydawnictwo_Ciagle) + + assert len(odebrane) == 1 + assert odebrane[0].pk == wc.pk + ``` +- [ ] **Krok 5.4 — test: `*_Streszczenie` NIE jest ruszane przez kaskadę.** Znajdź realny model streszczeń (`grep -rn "class Streszczenie\|Wydawnictwo_Ciagle_Streszczenie" src/bpp/models/` — prawdopodobnie `Wydawnictwo_Ciagle_Streszczenie` z FK `rekord`). Dopisz: + ```python + from bpp.models import Wydawnictwo_Ciagle_Streszczenie # zweryfikuj nazwę/import + + + @pytest.mark.django_db + def test_kaskada_nie_rusza_streszczenia(): + wc = baker.make(Wydawnictwo_Ciagle) + strz = baker.make(Wydawnictwo_Ciagle_Streszczenie, rekord=wc) + wc.delete() # NIE może rzucić SoftDeleteException + # streszczenie fizycznie istnieje, nietknięte (nie jest SoftDeleteModel) + assert Wydawnictwo_Ciagle_Streszczenie.objects.filter(pk=strz.pk).exists() + ``` + > Jeśli `Wydawnictwo_Ciagle_Streszczenie` ma inną nazwę pola FK niż `rekord` — sprawdź modelem. Kluczowy asercja: `wc.delete()` NIE rzuca `SoftDeleteException` (dowód, że nie używamy refleksyjnej kaskady pakietu) i streszczenie zostaje. +- [ ] **Krok 5.5 — test: gate `BppSoftDeleteQuerySet.update(deleted_at=...)` rzuca (kontrakt reversion).** + ```python + @pytest.mark.django_db + def test_bulk_update_deleted_at_zabroniony(): + baker.make(Wydawnictwo_Ciagle) + with pytest.raises(RuntimeError): + Wydawnictwo_Ciagle.objects.update(deleted_at="2026-01-01") + ``` +- [ ] **Krok 5.6 — uruchom cały plik:** + ```bash + uv run pytest src/bpp/tests/test_soft_delete_publikacje.py -x + ``` + Oczekiwane: wszystkie testy PASS. Jeśli test Rekord/Autorzy (5.1) padnie na cache — to regresja fazy 01, zgłoś. +- [ ] **Krok 5.7 — lint:** + ```bash + ruff format src/bpp/tests/test_soft_delete_publikacje.py src/bpp/models/soft_delete.py src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py + ruff check src/bpp/models/soft_delete.py src/bpp/tests/test_soft_delete_publikacje.py + ``` + Oczekiwane: czysto (linia ≤88). Napraw każdy zgłoszony problem ręcznie (Edit), NIE `--fix`. +- [ ] **Krok 5.8 — commit:** + ```bash + git add src/bpp/tests/test_soft_delete_publikacje.py + git commit -m "test(soft-delete): integracja publikacji - Rekord/Autorzy/restore/sygnaly/streszczenie/gate + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Task 6: Weryfikacja końcowa fazy + +**Files:** — + +- [ ] **Krok 6.1 — pełny check migracji + testy soft-delete + sąsiednie regresje publikacji:** + ```bash + uv run python src/manage.py makemigrations --check --dry-run bpp + uv run pytest src/bpp/tests/test_soft_delete_publikacje.py + uv run pytest src/bpp/tests/ -k "wydawnictwo or patent or doktor or habilit" -q + ``` + Oczekiwane: `No changes detected` + zielone testy. Jeśli istniejące testy publikacji padają, bo zakładały hard-delete — przejrzyj: prawdziwa regresja vs. test do aktualizacji (hard-delete → `.hard_delete()`). Aktualizuj tylko testy jawnie testujące kasowanie; NIE maskuj realnych regresji. +- [ ] **Krok 6.2 — `pre-commit` na zmienionych plikach (bez argumentów-akcji):** + ```bash + pre-commit run --files src/bpp/models/soft_delete.py src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/tests/test_soft_delete_publikacje.py + ``` + Napraw issues ręcznie, NIE batch-fix. +- [ ] **Krok 6.3 — finalny commit (jeśli zostały zmiany po lintach):** + ```bash + git add -A + git commit -m "chore(soft-delete): finalizacja fazy 02 publikacje (lint + regresje) + +Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Definition of Done (faza 02) + +- [ ] 5 modeli publikacji to `SoftDeleteModel` (przez `BppPublikacjaSoftDeleteMixin`); migracje `deleted_at`/`restored_at`/`transaction_id` + indeks per model (`0421_`). +- [ ] `delete(self, *args, user=None, reason="", **kwargs)` / `restore(self, *args, user=None, **kwargs)` — per-instancja `save()`, wąska kaskada na `autorzy_set` pod wspólnym `transaction_id`, BEZ refleksyjnej kaskady pakietu, BEZ bulk `update(deleted_at=)`. +- [ ] `*_Streszczenie` (i pozostałe nie-soft dzieci) nietknięte; `delete()` nie rzuca `SoftDeleteException`. +- [ ] `slug` → warunkowy `UniqueConstraint(condition=Q(deleted_at__isnull=True))` (`0422_`); reuse slug po soft-delete działa. +- [ ] `Wydawnictwo_*_Manager` przeplecione: `objects` filtruje `deleted_at` ORAZ ma `rekordy_z_oplata()`/`wydawnictwa_nadrzedne_dla_innych()`; `global_objects`/`deleted_objects` dostępne na wszystkich 5 modelach. +- [ ] Testy: kaskada wspólny txid, znika z Rekord/Autorzy + restore, restore `*_Autor`, `post_soft_delete`, `*_Streszczenie` nietknięte, gate bulk-update — zielone. +- [ ] `makemigrations --check --dry-run bpp` → `No changes detected`. Istniejące migracje NIE modyfikowane. + +--- + +## Podsumowanie (3 punkty) + +1. **Co robi faza:** wpina `SoftDeleteModel` w 5 modeli publikacji przez nowy mixin `BppPublikacjaSoftDeleteMixin` (w `src/bpp/models/soft_delete.py`), który nadpisuje `delete()`/`restore()` na **jawną wąską kaskadę** soft-delete na `autorzy_set` (`*_Autor`) pod wspólnym `transaction_id` — z pominięciem refleksyjnej kaskady pakietu (żeby nie ruszać `*_Streszczenie` i innych nie-soft dzieci). Dokłada migracje pól+indeksów (`0421`), warunkowy `UniqueConstraint` na `slug` (`0422`, reuse po soft-delete) i przeplata menedżery `Wydawnictwo_*_Manager` (mixin opłat × `BppSoftDeleteManager`) tak, by `objects` filtrowały `deleted_at`, zachowując `rekordy_z_oplata()`/`wydawnictwa_nadrzedne_dla_innych()`. + +2. **Krytyczne kontrakty utrzymane:** (a) zawsze per-instancja `save()`/`.delete()` — NIGDY bulk `update(deleted_at=)` (szew pod reversion, egzekwowany gate'em fazy 01); (b) sygnatury `delete(... user=None, reason="")` / `restore(... user=None)` (konsumuje je faza 06/07, tu tylko obecne); (c) wspólny `transaction_id` rodzic↔`*_Autor` (restore po nim); (d) emisja `post_soft_delete`/`post_restore`. + +3. **Założenia między-fazowe (zweryfikowane):** zależy od **fazy 01** (moduł `soft_delete.py` z `BppSoftDeleteQuerySet`/`BppSoftDeleteManager`/`BppGlobalManager`, `*_Autor` już SoftDeleteModel, filtr `deleted_at` w widokach `bpp_rekord`/`bpp_*_autorzy`). Testy Rekord/Autorzy (Task 5) walidują integrację z fazą 01 — ich porażka = regresja 01, NIE łatać tu. Świadomie **NIE** ruszamy: FK flips `CASCADE→PROTECT` i guardy (autor, `wydawnictwo_nadrzedne`) → **faza 04**; audyt `global_objects` w imporcie/dedup/PBN → **faza 03**; PBN-wycofanie → **faza 05**; `SoftDeleteLog`+receivery (konsumują `user`/`reason`) → **faza 06**; admin (kosz/przywróć/usuń-trwale, hook usera) → **faza 07**. **Wrinkle do pilnowania:** `slug` jest polem `@denormalized` (`django-denorm-iplweb`) z `unique=True` jako kwargiem — zdejmujemy `unique`, dodajemy `UniqueConstraint` w `Meta` każdej konkretnej klasy (dla `Praca_Doktorska`/`Praca_Habilitacyjna` slug pochodzi ze wspólnego abstraktu `Praca_Doktorska_Baza`, ale constraint per konkretny model). diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md new file mode 100644 index 000000000..f94b48e8a --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md @@ -0,0 +1,938 @@ +# Soft-delete — Faza 03: audyt kategorii B Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Przełączyć miejsca matchingu importu/dedupu/PBN na `global_objects`, żeby re-import lub deduplikacja NIE tworzyły duplikatów rekordów skasowanych miękko, oraz wymusić jawny `.hard_delete()` tam, gdzie kod celowo czyści fizycznie przed re-importem. + +**Architecture:** Po fazie 02 modele 5 publikacji są `SoftDeleteModel`; domyślny menedżer `objects` ukrywa skasowane (kategoria A — czysta automatycznie). Kategoria B to miejsca, które MUSZĄ widzieć skasowane: matching przez `pbn_uid` / DOI / ISBN / tytuł podczas re-importu i dedup. Jeśli te miejsca użyją ukrywającego menedżera, skasowany rekord stanie się „niewidzialny" → import utworzy DUPLIKAT (a denormalizowany warunkowy `slug` z fazy 02 i tak będzie kolidował dopiero przy restore). Dlatego matching przełączamy na `global_objects`. Osobno: `pbn_import` czyści publikacje PBN fizycznie przed re-importem — po fazie 02 `.delete()` na querysecie stałby się soft → trzeba jawnie `.hard_delete()`. + +**Tech Stack:** Django, PostgreSQL, `django-soft-delete>=1.0.23` (`objects` / `global_objects` / `deleted_objects`, `.hard_delete()`), pytest + model_bakery (`baker.make`). + +--- + +## Zależności i kontrakty (z fazy 02 + indeksu 00) + +- **Zależy od fazy 02.** Po fazie 02: + - `Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`, + `Praca_Habilitacyjna`, `Patent`, oraz `Wydawnictwo_Ciagle_Autor`, + `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` są `SoftDeleteModel`. + - Menedżery (VERBATIM z indeksu 00): `objects` (ukrywa skasowane), + `global_objects` (wszystkie), `deleted_objects` (tylko skasowane). + - Metody instancji: `.delete()` (soft), `.hard_delete()` (fizyczny), + `.restore()`. + - Menedżery publikacji (`Wydawnictwo_Ciagle_Manager`, + `Wydawnictwo_Zwarte_Manager`) mają przepleciony filtr soft-delete — + `.objects` zwraca tylko nieusunięte, ale nadal udostępnia metody + domenowe (`wydawnictwa_nadrzedne_dla_innych()` itd.). **`global_objects` + pochodzi z `GlobalManager` pakietu i NIE ma metod domenowych** — przy + przełączaniu sprawdzić, czy dane miejsce nie woła metody domenowej (jeśli + woła — patrz nota w odpowiednim Tasku). +- **Niezmienna reguła BPP:** NIE modyfikować istniejących plików migracji + w `src/*/migrations/`. Ten plan nie tworzy migracji (zmiany tylko w kodzie + zapytań + testy). +- **Linia ≤88 znaków** (ruff). Komendy Pythona przez `uv run`. +- **Testy:** pytest, standalone functions, `@pytest.mark.django_db`, + `baker.make`. Bez `unittest.TestCase`. + +--- + +## Mapa plików tej fazy + +**Modyfikowane (produkcyjne):** +- `src/pbn_api/models/publication.py` — `rekord_w_bpp`, `get_bpp_publication`, + `matchuj_do_rekordu_bpp` matchują przez `Rekord` (cache-view filtrowany po + `deleted_at`) → przełączyć na matching widzący skasowane (`global_objects` + modeli źródłowych). +- `src/import_common/core/publikacja.py` — `matchuj_publikacje` i 6 helperów + `_try_match_pub_by_*` używają `klass.objects` → `klass` ma dostać menedżer + widzący skasowane. +- `src/deduplikator_publikacji/tasks.py:140,147` — skanowanie do dedupu na + `.objects` (pomija skasowane — poprawne, ale udokumentować decyzję; bez + zmiany kodu). +- `src/pbn_integrator/importer/chapters.py:64` — `Wydawnictwo_Zwarte.objects + .get(pbn_uid_id=...)` (matching książki-matki rozdziału) → `global_objects`. +- `src/pbn_import/utils/publication_import.py:115-116` — jawny `.hard_delete()` + zamiast `.delete()` (kod celowo czyści fizycznie przed re-importem). +- `src/deduplikator_autorow/utils/merge.py:172,178,265,271,335,341` — + transfer through-rows duplikatu → `global_objects` (łapie kaskadowo + soft-deletowane autorstwa; inaczej zostaną sieroty blokujące guard fazy 04). + +**Tworzone (testy):** +- `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py` + +**Bez zmian (decyzja audytu udokumentowana w planie):** +- 90 miejsc `*_Autor.objects` w ewaluacji / API / przemapuj — patrz Task 7. +- `src/komparator_pbn/views.py`, `src/snapshot_odpiec/tasks.py`, + `src/ewaluacja_dwudyscyplinowcy/core.py` — patrz Task 7. + +--- + +## Task 1: Test bazowy — re-import po soft-delete znajduje rekord przez `pbn_uid` (FAIL przed zmianą) + +Po fazie 02 `Rekord` (cache-view `bpp_rekord_mat`/`bpp_rekord`) jest filtrowany +po `deleted_at` (faza 01). `pbn_api.Publication.rekord_w_bpp` matchuje przez +`Rekord.objects.get(pbn_uid_id=...)` — więc dla soft-deletowanej publikacji +zwróci `None` (rekord zniknął z widoku), a importer utworzy DUPLIKAT. Ten test +to pokazuje. + +**Files:** +- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py` + +- [ ] **Step 1: Utwórz katalog testów i napisz failing test** + +Utwórz `src/bpp/tests/test_soft_delete/__init__.py` (pusty) oraz +`src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`: + +```python +import pytest +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle + + +@pytest.mark.django_db +def test_get_bpp_publication_widzi_soft_deletowany_rekord(): + """Re-import: matching po pbn_uid MUSI znaleźć soft-deletowaną + publikację, inaczej importer utworzy duplikat.""" + from pbn_api.models import Publication + + publication = baker.make(Publication) + rec = baker.make( + Wydawnictwo_Ciagle, + tytul_oryginalny="Testowy artykuł", + rok=2020, + pbn_uid=publication, + ) + rec.delete() # soft-delete + + assert Wydawnictwo_Ciagle.objects.filter(pk=rec.pk).count() == 0 + assert Wydawnictwo_Ciagle.global_objects.filter(pk=rec.pk).count() == 1 + + znaleziony = publication.get_bpp_publication() + assert znaleziony is not None, ( + "matching po pbn_uid musi widzieć soft-deletowany rekord" + ) + assert znaleziony.pk == rec.pk +``` + +- [ ] **Step 2: Uruchom test — ma FAILować** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_get_bpp_publication_widzi_soft_deletowany_rekord -v` +Expected: FAIL — `znaleziony is None` (`Rekord.objects.get(pbn_uid_id=...)` +nie widzi soft-deletowanego rekordu, bo widok `bpp_rekord` jest filtrowany). + +- [ ] **Step 3: Commit testu** + +```bash +git add src/bpp/tests/test_soft_delete/__init__.py \ + src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +git commit -m "test(soft-delete): re-import po pbn_uid widzi soft-deletowany rekord (failing)" +``` + +--- + +## Task 2: `get_bpp_publication` matchuje po `pbn_uid` przez `global_objects` modeli źródłowych + +`Rekord` to widok (`managed=False`), NIE `SoftDeleteModel` — nie ma +`global_objects`. Matching po `pbn_uid` musi odpytać modele źródłowe ich +menedżerem `global_objects`. `pbn_uid` jest unikalny w obrębie typu, więc +przeszukujemy 5 modeli i zwracamy pierwsze trafienie. + +**Files:** +- Modify: `src/pbn_api/models/publication.py:121-128` (`get_bpp_publication`) + +- [ ] **Step 1: Podejrzyj obecny kod (kontekst)** + +`get_bpp_publication` (linie 121-128) i `rekord_w_bpp` (130-143) matchują +przez `Rekord.objects.get(pbn_uid_id=self.pk)`. + +- [ ] **Step 2: Dodaj helper i przepisz `get_bpp_publication`** + +Zamień metodę `get_bpp_publication` (linie 121-128) na: + +```python + def _modele_publikacji_global(self): + """Modele publikacji z menedżerem widzącym soft-deletowane. + Rekord (widok) NIE ma global_objects, więc matching po pbn_uid + idzie po modelach źródłowych.""" + from bpp.models import ( + Patent, + Praca_Doktorska, + Praca_Habilitacyjna, + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, + ) + + return [ + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, + Praca_Doktorska, + Praca_Habilitacyjna, + Patent, + ] + + def get_bpp_publication(self): + """Zwraca rekord BPP powiązany przez PBN UID (bez fuzzy matching). + + Używa global_objects, więc widzi też soft-deletowane rekordy — + inaczej re-import utworzyłby duplikat skasowanej publikacji. + """ + for klass in self._modele_publikacji_global(): + obj = klass.global_objects.filter(pbn_uid_id=self.pk).first() + if obj is not None: + return obj + return None +``` + +- [ ] **Step 3: Uruchom test Task 1 — ma PASS** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_get_bpp_publication_widzi_soft_deletowany_rekord -v` +Expected: PASS + +- [ ] **Step 4: ruff** + +Run: `ruff format src/pbn_api/models/publication.py && ruff check src/pbn_api/models/publication.py` +Expected: brak błędów + +- [ ] **Step 5: Commit** + +```bash +git add src/pbn_api/models/publication.py +git commit -m "fix(soft-delete): get_bpp_publication matchuje po pbn_uid przez global_objects" +``` + +--- + +## Task 3: `rekord_w_bpp` widzi soft-deletowany rekord (re-import przez pbn_integrator) + +`pbn_integrator/importer/books.py:44` i `articles.py:62` matchują istniejący +rekord przez `pbn_publication.rekord_w_bpp` i pomijają tworzenie, gdy +`ret is not None`. `rekord_w_bpp` (linie 130-143) wciąż używa +`Rekord.objects.get(pbn_uid_id=...)` → dla soft-deletowanego zwróci None → +duplikat. Trzeba je oprzeć na `get_bpp_publication` (już naprawione w Task 2), +zachowując dotychczasowy fallback do fuzzy-matchingu (`matchuj_do_rekordu_bpp`) +i obsługę „wielu rekordów o tym samym pbn_uid". + +**Files:** +- Modify: `src/pbn_api/models/publication.py:130-143` (`rekord_w_bpp`) +- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py` + +- [ ] **Step 1: Dopisz failing test re-importu (pbn_integrator)** + +Dopisz do `test_audyt_kategorii_b.py`: + +```python +@pytest.mark.django_db +def test_rekord_w_bpp_widzi_soft_deletowany_rekord(): + """rekord_w_bpp (używany przez pbn_integrator do pominięcia tworzenia) + musi zwrócić soft-deletowany rekord, inaczej powstanie duplikat.""" + from pbn_api.models import Publication + + publication = baker.make(Publication) + rec = baker.make( + Wydawnictwo_Ciagle, + tytul_oryginalny="Artykuł do re-importu", + rok=2021, + pbn_uid=publication, + ) + rec.delete() + + # cached_property — świeża instancja, żeby nie czytać cache + publication_fresh = Publication.objects.get(pk=publication.pk) + assert publication_fresh.rekord_w_bpp is not None + assert publication_fresh.rekord_w_bpp.pk == rec.pk +``` + +- [ ] **Step 2: Uruchom — ma FAILować** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_rekord_w_bpp_widzi_soft_deletowany_rekord -v` +Expected: FAIL — `rekord_w_bpp is None` (matching przez widok `bpp_rekord`). + +- [ ] **Step 3: Przepisz `rekord_w_bpp`** + +Zamień metodę `rekord_w_bpp` (linie 130-143) na: + +```python + @cached_property + def rekord_w_bpp(self): + from bpp.models.cache import Rekord + + # Najpierw szybki lookup po pbn_uid w modelach źródłowych + # (global_objects — widzi też soft-deletowane, by re-import nie + # tworzył duplikatu). Obsługa "wielu rekordów o tym samym pbn_uid" + # jak dotychczas: zwróć łańcuch tytułów (sygnał błędu danych). + trafienia = [ + obj + for klass in self._modele_publikacji_global() + for obj in klass.global_objects.filter(pbn_uid_id=self.pk) + ] + if len(trafienia) == 1: + # Zwróć obiekt Rekord (zachowanie zgodne z poprzednim API), + # czytając z global widoku po pk modelu źródłowego. + obj = trafienia[0] + from django.contrib.contenttypes.models import ContentType + + ct = ContentType.objects.get_for_model(type(obj)) + rec = Rekord.objects.filter( + content_type=ct, object_id=obj.pk + ).first() + # Soft-deletowany rekord znika z widoku Rekord — wtedy zwróć + # obiekt źródłowy (importer i tak używa go tylko do .pk). + return rec if rec is not None else obj + if len(trafienia) > 1: + return ";; ".join(o.tytul_oryginalny for o in trafienia) + + return self.matchuj_do_rekordu_bpp() +``` + +> **Nota:** importer (`books.py`/`articles.py`) używa `rekord_w_bpp` wyłącznie +> jako „czy istnieje" + dostęp do pól; zwrócenie obiektu źródłowego zamiast +> `Rekord` dla soft-deletowanego rekordu jest bezpieczne (oba mają `pk`, +> `tytul_oryginalny`). Dla niesoft-deletowanych zachowujemy zwrot `Rekord`. + +- [ ] **Step 4: Uruchom oba testy pbn_uid — mają PASS** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py -k "widzi_soft_deletowany" -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Regresja — pbn_api publication** + +Run: `uv run pytest src/pbn_api/ -k "rekord_w_bpp or get_bpp_publication or publication" -q` +Expected: PASS (brak regresji na istniejących testach matchingu). + +- [ ] **Step 6: ruff + commit** + +```bash +ruff format src/pbn_api/models/publication.py +ruff check src/pbn_api/models/publication.py +git add src/pbn_api/models/publication.py src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +git commit -m "fix(soft-delete): rekord_w_bpp widzi soft-deletowane rekordy (re-import bez duplikatów)" +``` + +--- + +## Task 4: `import_common.matchuj_publikacje` matchuje przez `global_objects` + +`matchuj_publikacje(klass, ...)` (`src/import_common/core/publikacja.py:264`) +i 6 helperów (`_try_match_pub_by_doi/zrodlo/isbn/uri/title`, +`_build_isbn_query`) używają `klass.objects`. Przy `klass = Wydawnictwo_*` +soft-deletowane rekordy są ukryte → fuzzy-matching nie znajdzie skasowanego +duplikatu → re-import go odtworzy. Trzeba odpytywać widzącym menedżerem. + +> **Uwaga na metodę domenową:** `_build_isbn_query` woła +> `Wydawnictwo_Zwarte.objects.wydawnictwa_nadrzedne_dla_innych()` — to metoda +> menedżera domenowego, której `global_objects` (`GlobalManager` pakietu) NIE +> ma. Dla matchingu po ISBN „tylko nadrzędne" akceptujemy, że nadrzędne +> liczone są spośród nieusuniętych (książka-matka skasowana → i tak PROTECT +> w fazie 04). Tę jedną ścieżkę zostawiamy na `objects`; zmieniamy tylko +> pozostałe lookupy w helperach na widzący menedżer. + +**Files:** +- Modify: `src/import_common/core/publikacja.py` — helper + `klass.objects` + w `_try_match_pub_by_doi:87`, `_try_match_pub_by_zrodlo:108`, + `_try_match_pub_by_uri:181`, `_try_match_pub_by_title:235,249`. +- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py` + +- [ ] **Step 1: Failing test — matchuj_publikacje po tytule widzi skasowany** + +Dopisz do `test_audyt_kategorii_b.py`: + +```python +@pytest.mark.django_db +def test_matchuj_publikacje_widzi_soft_deletowany(): + """Fuzzy matching importu po tytule+rok musi znaleźć soft-deletowaną + publikację, inaczej re-import utworzy duplikat.""" + from import_common.core.publikacja import matchuj_publikacje + + rec = baker.make( + Wydawnictwo_Ciagle, + tytul_oryginalny="Unikalny tytul do matchowania importu", + rok=2019, + ) + rec.delete() + + wynik = matchuj_publikacje( + Wydawnictwo_Ciagle, + title="Unikalny tytul do matchowania importu", + year=2019, + ) + assert wynik is not None + assert wynik.pk == rec.pk +``` + +- [ ] **Step 2: Uruchom — ma FAILować** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_matchuj_publikacje_widzi_soft_deletowany -v` +Expected: FAIL — `wynik is None` (`klass.objects` ukrywa skasowany rekord). + +- [ ] **Step 3: Dodaj helper `_manager_dla_matchingu` i podmień lookupy** + +Na początku `src/import_common/core/publikacja.py` (po importach) dodaj: + +```python +def _manager_dla_matchingu(klass): + """Menedżer widzący także soft-deletowane rekordy (kat. B: re-import + NIE może omijać skasowanych, inaczej tworzy duplikaty). Modele bez + soft-delete (np. Rekord-view) nie mają global_objects → fallback do + objects.""" + return getattr(klass, "global_objects", klass.objects) +``` + +Następnie podmień w helperach `klass.objects` na `_manager_dla_matchingu(klass)`: + +- `_try_match_pub_by_doi` (linia 87): + `zapytanie = klass.objects.filter(doi__istartswith=doi, rok=year)` + → `zapytanie = _manager_dla_matchingu(klass).filter(doi__istartswith=doi, rok=year)` +- `_try_match_pub_by_zrodlo` (linia 108): + `return klass.objects.get(` → `return _manager_dla_matchingu(klass).get(` +- `_try_match_pub_by_uri` (linia 181): + `klass.objects.filter(Q(www=public_uri) | Q(public_www=public_uri))` + → `_manager_dla_matchingu(klass).filter(Q(www=public_uri) | Q(public_www=public_uri))` +- `_try_match_pub_by_title` (linia 235): + `klass.objects.filter(tytul_oryginalny__istartswith=title, rok=year)` + → `_manager_dla_matchingu(klass).filter(tytul_oryginalny__istartswith=title, rok=year)` +- `_try_match_pub_by_title` (linia 249): + `klass.objects.filter(rok=year)` + → `_manager_dla_matchingu(klass).filter(rok=year)` + +> `_build_isbn_query` zostaje na `klass.objects` (patrz Uwaga w nagłówku +> Tasku — woła metodę domenową `wydawnictwa_nadrzedne_dla_innych()`). + +- [ ] **Step 4: Uruchom test — ma PASS** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_matchuj_publikacje_widzi_soft_deletowany -v` +Expected: PASS + +- [ ] **Step 5: Regresja matchingu importu** + +Run: `uv run pytest src/import_common/ -q` +Expected: PASS + +- [ ] **Step 6: ruff + commit** + +```bash +ruff format src/import_common/core/publikacja.py +ruff check src/import_common/core/publikacja.py +git add src/import_common/core/publikacja.py src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +git commit -m "fix(soft-delete): matchuj_publikacje widzi soft-deletowane rekordy (import bez duplikatów)" +``` + +--- + +## Task 5: `pbn_integrator/importer/chapters.py` — matching książki-matki przez `global_objects` + +`chapters.py:64` woła `Wydawnictwo_Zwarte.objects.get(pbn_uid_id=pbn_book_id)` +żeby znaleźć książkę-matkę rozdziału. Jeśli książka jest soft-deletowana, +`objects.get` rzuci `DoesNotExist` → import rozdziału stworzy nową książkę +(duplikat) lub się wywali. Matching musi widzieć skasowaną książkę-matkę. + +> **Spójność z fazą 04:** faza 04 ustawia `wydawnictwo_nadrzedne` na PROTECT +> (książka z rozdziałami nie da się skasować). Tu chodzi o sytuację, gdy +> książka-matka została skasowana zanim importowano rozdział — matching ma ją +> odnaleźć przez `global_objects`, nie tworzyć duplikatu. + +**Files:** +- Modify: `src/pbn_integrator/importer/chapters.py:64` +- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py` + +- [ ] **Step 1: Failing test** + +Dopisz do `test_audyt_kategorii_b.py`: + +```python +@pytest.mark.django_db +def test_chapters_matchuje_soft_deletowana_ksiazke_matke(): + """Import rozdziału po pbn_uid książki-matki musi znaleźć soft-deletowaną + książkę przez global_objects, nie tworzyć duplikatu.""" + from bpp.models import Wydawnictwo_Zwarte + from pbn_api.models import Publication + + pub_ksiazki = baker.make(Publication) + ksiazka = baker.make( + Wydawnictwo_Zwarte, + tytul_oryginalny="Ksiazka matka", + rok=2018, + pbn_uid=pub_ksiazki, + ) + ksiazka.delete() + + znaleziona = Wydawnictwo_Zwarte.global_objects.get( + pbn_uid_id=pub_ksiazki.pk + ) + assert znaleziona.pk == ksiazka.pk + # objects (ukrywający) NIE znajdzie — to różnica, którą naprawiamy + with pytest.raises(Wydawnictwo_Zwarte.DoesNotExist): + Wydawnictwo_Zwarte.objects.get(pbn_uid_id=pub_ksiazki.pk) +``` + +- [ ] **Step 2: Uruchom — ma PASS już teraz (test kontraktu menedżera)** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_chapters_matchuje_soft_deletowana_ksiazke_matke -v` +Expected: PASS — to test kontraktu (`global_objects` widzi, `objects` nie). +Potwierdza powód zmiany kodu w Step 3. + +- [ ] **Step 3: Zmień lookup w chapters.py** + +`src/pbn_integrator/importer/chapters.py:64`: +```python + wydawnictwo_nadrzedne = Wydawnictwo_Zwarte.objects.get(pbn_uid_id=pbn_book_id) +``` +→ +```python + wydawnictwo_nadrzedne = Wydawnictwo_Zwarte.global_objects.get( + pbn_uid_id=pbn_book_id + ) +``` + +- [ ] **Step 4: Regresja pbn_integrator chapters** + +Run: `uv run pytest src/pbn_integrator/ -k "chapter or rozdzial" -q` +Expected: PASS (jeśli brak testów dla chapters — `no tests ran`, OK). + +- [ ] **Step 5: ruff + commit** + +```bash +ruff format src/pbn_integrator/importer/chapters.py +ruff check src/pbn_integrator/importer/chapters.py +git add src/pbn_integrator/importer/chapters.py src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +git commit -m "fix(soft-delete): chapters matchuje soft-deletowana ksiazke-matke przez global_objects" +``` + +--- + +## Task 6: `pbn_import` — jawny `.hard_delete()` przy czyszczeniu przed re-importem + +`src/pbn_import/utils/publication_import.py:115-116` celowo USUWA FIZYCZNIE +publikacje PBN przed pełnym re-importem z PBN. Po fazie 02 +`.objects.exclude(...).delete()` na querysecie stałby się soft-delete → stare +rekordy zostałyby w koszu, a re-import (Task 2/3 — matching widzący skasowane) +zwróciłby je jako „istniejące", więc re-import by ich nie odtworzył ALE też +nie zaktualizował, a slug-i (warunkowy unique tylko dla nieusuniętych z fazy +02) by nie kolidowały — efekt: dryf danych i rosnący kosz. Intencja kodu to +czystka fizyczna → jawny `.hard_delete()`. + +**Files:** +- Modify: `src/pbn_import/utils/publication_import.py:115-116` +- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py` + +- [ ] **Step 1: Failing test — hard-delete fizycznie usuwa (nie soft)** + +Dopisz do `test_audyt_kategorii_b.py`: + +```python +@pytest.mark.django_db +def test_pbn_import_czyszczenie_jest_hard_delete(): + """_delete_existing_publications musi FIZYCZNIE usunąć publikacje PBN + (hard_delete), nie zostawiać ich w koszu (soft).""" + from bpp.models import Wydawnictwo_Zwarte + from pbn_api.models import Publication + + pub = baker.make(Publication) + baker.make(Wydawnictwo_Zwarte, rok=2020, pbn_uid=pub) + + # Symulacja linii czyszczenia z publication_import.py:115 + Wydawnictwo_Zwarte.objects.exclude(pbn_uid_id=None).hard_delete() + + # Nic nie zostaje — ani w objects, ani w global_objects (kosz pusty) + assert Wydawnictwo_Zwarte.global_objects.filter( + pbn_uid_id=pub.pk + ).count() == 0 +``` + +- [ ] **Step 2: Uruchom — ma PASS (test kontraktu hard_delete)** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_pbn_import_czyszczenie_jest_hard_delete -v` +Expected: PASS — potwierdza, że `.hard_delete()` na querysecie czyści fizycznie +(uzasadnia zmianę w Step 3). + +- [ ] **Step 3: Zmień kod czyszczenia na `.hard_delete()`** + +`src/pbn_import/utils/publication_import.py:115-116`: +```python + deleted_zwarte = Wydawnictwo_Zwarte.objects.exclude(pbn_uid_id=None).delete()[0] + deleted_ciagle = Wydawnictwo_Ciagle.objects.exclude(pbn_uid_id=None).delete()[0] +``` +→ +```python + # Re-import PBN wymaga fizycznego czyszczenia (NIE soft-delete) — + # inaczej stare rekordy zostają w koszu i kolidują przy re-imporcie. + deleted_zwarte = Wydawnictwo_Zwarte.objects.exclude( + pbn_uid_id=None + ).hard_delete() + deleted_ciagle = Wydawnictwo_Ciagle.objects.exclude( + pbn_uid_id=None + ).hard_delete() +``` + +> **Uwaga na wartość zwracaną:** stare `.delete()` zwracało krotkę +> `(liczba, {model: liczba})`, stąd `[0]`. `SoftDeleteQuerySet.hard_delete()` +> w `django-soft-delete` zwraca wynik bazowego `QuerySet.delete()` +> (krotkę) — ale to zależy od wersji pakietu. Następny krok to weryfikuje +> i, jeśli trzeba, koryguje rozpakowanie. + +- [ ] **Step 4: Zweryfikuj typ zwracany `hard_delete()` i skoryguj rozpakowanie** + +Run: `uv run python -c "import inspect, django_softdelete.managers as m; print(inspect.getsource(m.SoftDeleteQuerySet.hard_delete))"` +Expected: zobacz, co zwraca. Jeśli zwraca krotkę `(int, dict)` — zachowaj +`[0]` (usuń je z powyższego diffu: `... .hard_delete()[0]`). Jeśli zwraca +`int` lub `None` — dostosuj: gdy `int`, zostaw bez `[0]`; gdy `None`, policz +przed usunięciem: +```python + zwarte_qs = Wydawnictwo_Zwarte.objects.exclude(pbn_uid_id=None) + deleted_zwarte = zwarte_qs.count() + zwarte_qs.hard_delete() + ciagle_qs = Wydawnictwo_Ciagle.objects.exclude(pbn_uid_id=None) + deleted_ciagle = ciagle_qs.count() + ciagle_qs.hard_delete() +``` +Zastosuj wariant zgodny z faktycznym zwrotem (log używa `deleted_zwarte`/ +`deleted_ciagle` jako liczb w komunikacie linii 118-121). + +- [ ] **Step 5: Regresja pbn_import** + +Run: `uv run pytest src/pbn_import/ -q` +Expected: PASS + +- [ ] **Step 6: ruff + commit** + +```bash +ruff format src/pbn_import/utils/publication_import.py +ruff check src/pbn_import/utils/publication_import.py +git add src/pbn_import/utils/publication_import.py src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +git commit -m "fix(soft-delete): pbn_import czysci publikacje przez hard_delete przed re-importem" +``` + +--- + +## Task 7: Audyt 90 miejsc `*_Autor.objects` — decyzje (zostaw `objects` / zmień na `global_objects`) + +Po fazie 02 `*_Autor.objects` ukrywa kaskadowo soft-deletowane autorstwa +(kaskada §2.2). To jest **poprawny default dla ewaluacji** (praca w koszu nie +liczy się do punktacji). Audyt: dla każdej grupy miejsc zapada decyzja +z uzasadnieniem. Większość = ZOSTAW `objects`. Wyjątek kat. B (musi widzieć +skasowane) → `global_objects` (tu: tylko transfer w merge autorów, Step „merge"). + +> **Reguła nadrzędna (spec §2.5):** default „pomijaj skasowane" jest tu +> znacznie bezpieczniejszy niż przeciwny. Zmieniamy tylko miejsca, które +> *muszą* widzieć skasowane, by nie zostawić sierot lub nie zgubić transferu. +> +> **Gate `BppSoftDeleteQuerySet.update()` (z fazy 01):** miejsca robiące +> `*_Autor.objects.filter(...).update(...)` są bezpieczne, DOPÓKI nie ustawiają +> `deleted_at`/`restored_at`. Audyt potwierdza, że wszystkie poniższe `.update()` +> dotyczą `przypieta` / `dyscyplina_naukowa` / `afiliuje` — gate nie zadziała. + +**Files (tylko decyzje — zmiana kodu tylko w „merge"):** +- Decyzje (bez zmian): pliki ewaluacji, API, przemapuj, snapshot, komparator, + dwudyscyplinowcy. +- Modify: `src/deduplikator_autorow/utils/merge.py:172,178,265,271,335,341` +- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py` + +- [ ] **Step 1: Udokumentuj decyzje audytu (komentarz w teście jako rejestr)** + +Decyzje per-miejsce (uzasadnienie w nagłówku — w kodzie bez zmian): + + **ZOSTAW `objects` (default „pomijaj skasowane" poprawny):** + - `src/ewaluacja_optymalizacja/tasks/reset_pins.py:34,54,75` + (`.update(przypieta=...)`) — reset przypięć dotyczy tylko prac liczonych + do ewaluacji; prace w koszu nie biorą udziału w optymalizacji. `.update` + nie tyka `deleted_at` → gate nie zadziała. **Zostaw.** + - `src/ewaluacja_optymalizacja/management/commands/reset_disciplines.py:59,63,67,90,100,108` + (count + `.update(dyscyplina_naukowa=...)`) — jw., reset dyscyplin tylko + dla prac w ewaluacji. **Zostaw.** + - `src/ewaluacja_optymalizacja/tasks/unpin_all_sensible.py:134,140,146` + (`.update`) — odpinanie prac ewaluowanych. **Zostaw.** + - `src/ewaluacja_optymalizacja/solve_helpers/unpinning.py:54,62` — jw. + **Zostaw.** + - `src/ewaluacja_optymalizacja/tasks/helpers.py:121,139`, + `tasks/optimization.py:536,539,573,576` (liczenie udziałów / przypiętych) — + optymalizacja MA pomijać prace w koszu. **Zostaw.** + - `src/ewaluacja_optymalizacja/views/author_works.py:75,93,111`, + `views/evaluation_browser/builders.py:136,141,170,174`, + `views/evaluation_browser/filters.py:44,55`, `views/verification.py:52,57` + (przeglądarka/weryfikacja ewaluacji) — prezentują stan ewaluacji; praca + w koszu nie powinna się pokazywać. **Zostaw.** + - `src/ewaluacja_dwudyscyplinowcy/core.py:125` — analiza dwudyscyplinowości + dla ewaluacji; kosz pomijamy. **Zostaw.** + - `src/snapshot_odpiec/tasks.py:6,9,12,20,23,26` — snapshot stanu odpięć + dyscyplin dla bieżącej ewaluacji; praca w koszu = brak odpięcia do + zapisania. **Zostaw.** + - `src/komparator_pbn/views.py:85,89,267,301` — porównanie „co BPP + deklaruje" vs „co jest w PBN". Soft-delete publikacji wycofuje oświadczenia + z PBN (faza 05), więc BPP NIE deklaruje już tej pracy → `objects` + (ukrywający) daje spójny obraz z PBN. **Zostaw.** + - `src/api_v1/viewsets/{wydawnictwo_ciagle.py:21,wydawnictwo_zwarte.py:19,patent.py:10}` + (`queryset = *_Autor.objects.all()`) — publiczne/REST API NIE może + serwować autorstw skasowanych prac (spójność z resztą API, gdzie sama + publikacja znika). **Zostaw.** + - `src/przemapuj_prace_autora/{views.py,forms.py}` (wiele linii) — narzędzie + przemapowania prac autora operuje na pracach aktywnych; skasowane (w koszu) + nie powinny być przemapowywane (operator najpierw je przywraca). **Zostaw.** + - `src/bpp/views/autocomplete/authors.py`, `src/ranking_autorow/forms.py` — + publiczne UI; kosz pomijamy. **Zostaw.** + - `src/bpp/management/commands/{ukryj_nieuzywane_dyscypliny.py,ustaw_daty_oswiadczenia_pbn.py}` — + operują na aktywnych autorstwach. **Zostaw.** + - `src/bpp/migrations/0403_*`, `src/zglos_publikacje/migrations/0019_*` — + **migracje: NIE RUSZAĆ** (reguła BPP). Działają na stanie historycznym. + - `src/conftest.py`, `src/bpp/tests/**`, `src/*/tests*.py`, + `src/integration_tests/test_conftest.py`, + `src/bpp/demo_data/generators/*` — fixtures/testy/demo: tworzą obiekty + (`.create`) — `objects` poprawne. **Zostaw.** + + **ZMIEŃ na `global_objects` (kat. B — MUSI widzieć skasowane):** + - `src/deduplikator_autorow/utils/merge.py:172,178,265,271,335,341` — + transfer through-rows z autora-duplikatu na głównego. Jeśli duplikat ma + autorstwo wskazujące na soft-deletowaną publikację (kaskada §2.2), to + autorstwo jest też soft-deletowane → `objects` go NIE przeniesie → + zostanie sierota wskazująca duplikat → guard fazy 04 (liczący przez + `global_objects`) zablokuje usunięcie husku duplikatu. Transfer MUSI + widzieć wszystkie autorstwa. **Zmień na `global_objects`.** + +- [ ] **Step 2: Failing test — merge przenosi też autorstwo soft-deletowanej pracy** + +Dopisz do `test_audyt_kategorii_b.py`: + +```python +@pytest.mark.django_db +def test_merge_przenosi_autorstwo_soft_deletowanej_pracy(): + """Merge autorów musi przenieść także autorstwo wskazujące na + soft-deletowaną publikację (kaskadowo skasowane *_Autor), inaczej + zostaje sierota blokująca guard usunięcia duplikatu (faza 04).""" + from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor + from deduplikator_autorow.utils.merge import przenies_wydawnictwa_ciagle + + autor_glowny = baker.make("bpp.Autor") + autor_duplikat = baker.make("bpp.Autor") + jednostka = baker.make("bpp.Jednostka") + + praca = baker.make(Wydawnictwo_Ciagle, rok=2020) + Wydawnictwo_Ciagle_Autor.objects.create( + rekord=praca, + autor=autor_duplikat, + jednostka=jednostka, + kolejnosc=0, + typ_odpowiedzialnosci_id=1, + ) + praca.delete() # kaskadowo soft-deletuje też Wydawnictwo_Ciagle_Autor + + # autorstwo jest teraz ukryte w objects, widoczne w global_objects + assert Wydawnictwo_Ciagle_Autor.objects.filter( + autor=autor_duplikat + ).count() == 0 + assert Wydawnictwo_Ciagle_Autor.global_objects.filter( + autor=autor_duplikat + ).count() == 1 + + przenies_wydawnictwa_ciagle(autor_duplikat, autor_glowny) + + # po transferze autorstwo wskazuje na autora głównego, duplikat czysty + assert Wydawnictwo_Ciagle_Autor.global_objects.filter( + autor=autor_duplikat + ).count() == 0 + assert Wydawnictwo_Ciagle_Autor.global_objects.filter( + autor=autor_glowny + ).count() == 1 +``` + +> **Uwaga:** dopasuj nazwę funkcji (`przenies_wydawnictwa_ciagle`) i jej +> sygnaturę do faktycznego API `src/deduplikator_autorow/utils/merge.py` +> (przeczytaj linie 160-200 przed uruchomieniem). Jeśli nazwa/sygnatura inna — +> popraw wywołanie w teście. `typ_odpowiedzialnosci_id=1` zakłada istnienie +> rekordu w bazie testowej; jeśli go nie ma, użyj +> `baker.make("bpp.Typ_Odpowiedzialnosci")` i przekaż obiekt. + +- [ ] **Step 3: Uruchom — ma FAILować** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_merge_przenosi_autorstwo_soft_deletowanej_pracy -v` +Expected: FAIL — autorstwo nie zostało przeniesione (`objects` go nie widział), +duplikat nadal ma 1 wiersz w `global_objects`. + +- [ ] **Step 4: Zmień lookupy transferu na `global_objects`** + +W `src/deduplikator_autorow/utils/merge.py` zamień (po przeczytaniu kontekstu +każdej linii — zachowaj resztę wyrażenia): + +- linia 172: `wc_autorzy = Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor_duplikat)` + → `wc_autorzy = Wydawnictwo_Ciagle_Autor.global_objects.filter(autor=autor_duplikat)` +- linia 178: `existing = Wydawnictwo_Ciagle_Autor.objects.filter(` + → `existing = Wydawnictwo_Ciagle_Autor.global_objects.filter(` +- linia 265: `wz_autorzy = Wydawnictwo_Zwarte_Autor.objects.filter(autor=autor_duplikat)` + → `wz_autorzy = Wydawnictwo_Zwarte_Autor.global_objects.filter(autor=autor_duplikat)` +- linia 271: `existing = Wydawnictwo_Zwarte_Autor.objects.filter(` + → `existing = Wydawnictwo_Zwarte_Autor.global_objects.filter(` +- linia 335: `patent_autorzy = Patent_Autor.objects.filter(autor=autor_duplikat)` + → `patent_autorzy = Patent_Autor.global_objects.filter(autor=autor_duplikat)` +- linia 341: `existing = Patent_Autor.objects.filter(` + → `existing = Patent_Autor.global_objects.filter(` + +> **Nota:** „existing" to sprawdzenie kolizji (czy autor główny ma już to +> autorstwo). Liczenie kolizji przez `global_objects` jest poprawne — kolizja +> z soft-deletowanym autorstwem głównego też ma być wykryta, by nie powstał +> duplikat through-row po restore. + +- [ ] **Step 5: Uruchom test — ma PASS** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_merge_przenosi_autorstwo_soft_deletowanej_pracy -v` +Expected: PASS + +- [ ] **Step 6: Regresja merge autorów** + +Run: `uv run pytest src/deduplikator_autorow/ -q` +Expected: PASS + +- [ ] **Step 7: ruff + commit** + +```bash +ruff format src/deduplikator_autorow/utils/merge.py +ruff check src/deduplikator_autorow/utils/merge.py +git add src/deduplikator_autorow/utils/merge.py src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +git commit -m "fix(soft-delete): merge autorow przenosi tez kaskadowo-skasowane autorstwa (global_objects)" +``` + +--- + +## Task 8: Dedup publikacji — potwierdź `objects` (ZOSTAW) + test regresji „dedup pomija kosz" + +`src/deduplikator_publikacji/tasks.py:140,147` skanuje publikacje do +wyszukiwania duplikatów na `.objects`. Soft-deletowana publikacja jest „w +koszu" — NIE chcemy jej raportować jako duplikatu (operator ją świadomie +usunął). Default `objects` (ukrywający) jest poprawny. Bez zmiany kodu; test +broni decyzji przed regresją. + +**Files:** +- Bez zmian: `src/deduplikator_publikacji/tasks.py:140,147` +- Test: `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py` + +- [ ] **Step 1: Test regresji — skan dedupu pomija soft-deletowane** + +Dopisz do `test_audyt_kategorii_b.py`: + +```python +@pytest.mark.django_db +def test_dedup_skan_pomija_soft_deletowane(): + """Skaner duplikatów NIE może zgłaszać prac z kosza (operator je + świadomie usunął) — _get_publications_to_scan używa objects.""" + from deduplikator_publikacji.tasks import _get_publications_to_scan + + aktywna = baker.make(Wydawnictwo_Ciagle, rok=2020) + skasowana = baker.make(Wydawnictwo_Ciagle, rok=2020) + skasowana.delete() + + publikacje = _get_publications_to_scan(2020, 2020) + pks = {pub.pk for _ct, pub in publikacje} + assert aktywna.pk in pks + assert skasowana.pk not in pks +``` + +- [ ] **Step 2: Uruchom — ma PASS od razu (potwierdza decyzję ZOSTAW)** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py::test_dedup_skan_pomija_soft_deletowane -v` +Expected: PASS (kod już używa `objects`; test broni przed przyszłą regresją). + +- [ ] **Step 3: Commit** + +```bash +git add src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +git commit -m "test(soft-delete): dedup publikacji pomija prace w koszu (regresja)" +``` + +--- + +## Task 9: Pełny test fazy + ruff + zamknięcie + +- [ ] **Step 1: Cała suita testów tej fazy** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py -v` +Expected: PASS (wszystkie testy zielone). + +- [ ] **Step 2: Regresja przylegających podsystemów** + +Run: `uv run pytest src/import_common/ src/pbn_integrator/ src/pbn_import/ src/deduplikator_autorow/ src/deduplikator_publikacji/ -q` +Expected: PASS (brak regresji matchingu / merge / importu). + +- [ ] **Step 3: ruff całość zmienionych plików** + +Run: +```bash +ruff format src/pbn_api/models/publication.py src/import_common/core/publikacja.py \ + src/pbn_integrator/importer/chapters.py src/pbn_import/utils/publication_import.py \ + src/deduplikator_autorow/utils/merge.py \ + src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +ruff check src/pbn_api/models/publication.py src/import_common/core/publikacja.py \ + src/pbn_integrator/importer/chapters.py src/pbn_import/utils/publication_import.py \ + src/deduplikator_autorow/utils/merge.py \ + src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +``` +Expected: brak błędów. + +- [ ] **Step 4: Commit zamykający (jeśli ruff coś zmienił)** + +```bash +git add -A +git commit -m "chore(soft-delete): faza 03 audyt kat. B — format/lint" +``` + +--- + +## Self-Review (autor planu) + +**Spec coverage (§2.5 + lista miejsc):** +- Matching importu (`import_common`) → Task 4. ✓ +- Crossref dedup (`crossref_bpp/core.py:178,182` — `znajdz_w_tabelach` używa + `Wydawnictwo_*.objects`): patrz **Nota** niżej. +- `deduplikator_publikacji/tasks.py` → Task 8 (ZOSTAW + test). ✓ +- PBN matching po `pbn_uid` (`pbn_integrator/utils/synchronization.py`, + `importer/chapters.py`, `pbn_api`): Task 2, 3 (rdzeń: `rekord_w_bpp`/ + `get_bpp_publication` — używane przez `books.py`/`articles.py`), Task 5 + (chapters). `synchronization.py` matchuje przez `pbn_uid_id` na `rec` + (obiekt już w ręku) i `_pobierz_prace_po_elemencie` (po stronie PBN) — nie + tworzy duplikatów BPP z ukrywającego menedżera; **decyzja: bez zmian** + (patrz Nota). `pbn_api/management/*` — komendy operacyjne na istniejących + rekordach, nie matching tworzący duplikaty; **bez zmian**. +- `pbn_import/utils/publication_import.py:115-116` jawny `.hard_delete()` → + Task 6. ✓ +- Audyt 90 miejsc `*_Autor.objects` → Task 7 (decyzje per-miejsce; jedyna + zmiana: merge → `global_objects`). ✓ +- Testy wymagane przez zlecenie: re-import nie tworzy duplikatu (Task 1-4), + matching po `pbn_uid` znajduje soft-deletowaną (Task 1-3), ewaluacja pomija + kosz (Task 7 decyzje + Task 8 test), pbn_import hard-delete fizycznie + usuwa (Task 6). ✓ + +**Nota — `crossref_bpp/core.py:178,182` (`znajdz_w_tabelach`):** używa +`Wydawnictwo_Zwarte.objects` / `Wydawnictwo_Ciagle.objects` do podpowiedzi +„czy w BPP jest już taki rekord (po DOI/tytule)" w UI crossref. To kat. B +(matching importu), ale ZESPÓŁ helperów matchingu używanym przez crossref +import jest `import_common.matchuj_publikacje` (Task 4 — naprawione). +`znajdz_w_tabelach` to *podgląd dla operatora* (top-10 kandydatów), nie +automatyczny dedup tworzący rekordy. **Decyzja: ZOSTAW `objects`** — +pokazywanie operatorowi rekordów z kosza jako „kandydatów do scalenia" +byłoby mylące; jeśli rekord jest w koszu, operator najpierw go przywraca. +Gdyby w fazie 08 (regresja) okazało się, że crossref-import dubluje rekordy +przez `znajdz_w_tabelach`, dodać `global_objects` tam punktowo. Zapis tej +decyzji wystarcza dla kat. B (brak ścieżki automatycznego tworzenia duplikatu +przez tę metodę). + +**Placeholder scan:** brak „TBD"/„handle edge cases"; każdy krok ma kod lub +konkretną komendę. Kroki z zależnością od wersji pakietu (`hard_delete` zwrot, +Task 6 Step 4) i od faktycznej sygnatury merge (Task 7 Step 2) mają jawną +instrukcję weryfikacji przed użyciem — to świadome, nie placeholder. + +**Type consistency:** `_modele_publikacji_global()` zdefiniowane w Task 2, +użyte w Task 3 — ta sama nazwa. `_manager_dla_matchingu` zdefiniowane raz +(Task 4), użyte w 5 helperach. `global_objects` / `objects` / `hard_delete` +zgodne z kontraktem PINNED w indeksie 00. + +--- + +## Execution Handoff + +Plan complete and saved to +`docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md`. + +Dwie opcje wykonania: +1. **Subagent-Driven (zalecane)** — świeży subagent per Task, review między + Taskami (`superpowers:subagent-driven-development`). +2. **Inline Execution** — wykonanie w tej sesji z checkpointami + (`superpowers:executing-plans`). + +Faza 03 zależy od ukończonej fazy 02 (modele już `SoftDeleteModel`, +`global_objects` istnieje). Bez fazy 02 testy nie ruszą. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md b/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md new file mode 100644 index 000000000..8b2042427 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md @@ -0,0 +1,502 @@ +# Soft-delete — Faza 04: Guardy PROTECT (autor + książka-matka) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Wykonuj zadania po kolei; po każdym zadaniu uruchom podaną komendę i zacommituj. + +**Goal:** Dodać dwuwarstwową ochronę przed kasowaniem rekordów z zależnościami: (1) flip FK `CASCADE→PROTECT` na powiązaniach autora (`Wydawnictwo_*_Autor.autor`, `Praca_Doktorska.autor`) oraz na self-FK rozdziałów (`Wydawnictwo_Zwarte.wydawnictwo_nadrzedne`) — obrona przed hard-delete; (2) guard w soft-`delete()` modeli `Autor` i `Wydawnictwo_Zwarte`, liczący dzieci przez `global_objects` (widzi też kaskadowo-skasowane autorstwa z fazy 02) i rzucający `ProtectedError`. Autor bez prac → soft-delete (husk); książka bez rozdziałów → soft-delete. `Praca_Habilitacyjna.autor` już jest `PROTECT` — bez zmian. + +**Architecture:** Warstwa 1 to migracje **state-only** (`migrations.AlterField` — Django implementuje `on_delete` w ORM, nie jako constraint DB, więc brak zmiany schematu; NIE modyfikujemy istniejących migracji). Warstwa 2 to override `delete(self, *args, user=None, reason="", **kwargs)` wołający helper `raise_if_has_protected_children(instance, relations, label)` z `src/bpp/models/soft_delete.py`. Guard MUSI liczyć przez `global_objects` (nie `objects`), bo `objects` ukrywa kaskadowo-skasowane `*_Autor` (faza 02) i autor „cały w koszu" fałszywie wyglądałby na pustego (spec §3.2, §9). Override `Autor.delete()` **nie kaskaduje** do `*_Autor` (spec §1, §10.1). Override `Wydawnictwo_Zwarte.delete()` dokłada guard **PRZED** kaskadą na `*_Autor` z fazy 02 — kaskady NIE gubimy. + +**Tech Stack:** Django, `django-soft-delete>=1.0.23` (`SoftDeleteModel`, menedżery `objects`/`global_objects`/`deleted_objects`, sygnały `post_soft_delete`/`post_restore`), `django.db.models.ProtectedError`, pytest + `model_bakery.baker`, `uv run` dla wszystkich komend Python. + +**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) — §1 (asymetria), §2.6 (self-FK rozdziały → PROTECT), §3 (autor: dwie warstwy + soft-delete husków), §9 (ryzyka), §10.1/§10.11 (decyzje). +**Plan-indeks:** [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md) — kontrakty PINNED (helper `raise_if_has_protected_children`, sygnatura `delete(self, *args, user=None, reason="", **kwargs)`). + +**Zależy od:** Faza 02 (modele `Wydawnictwo_Ciagle`/`Wydawnictwo_Zwarte`/`Praca_Doktorska`/`Praca_Habilitacyjna`/`Patent` + 3 through-modele `*_Autor` są już `SoftDeleteModel`; menedżer `global_objects` dostępny; override `Wydawnictwo_Zwarte.delete()` z wąską kaskadą na `*_Autor` już istnieje — faza 04 dokłada do niego guard). Moduł `src/bpp/models/soft_delete.py` istnieje (utworzony fazą 01/04 — tu dopisujemy helper). + +--- + +## Reguły BPP (obowiązują w każdym zadaniu) + +- **Wszystkie komendy Python przez `uv run`** (np. `uv run pytest ...`). Nigdy goły `python`. +- **NIE modyfikuj istniejących migracji** w `src/*/migrations/`. Nowe migracje tworzymy ręcznie / przez `makemigrations`. +- **Max długość linii 88 znaków** (ruff). Komentarze/komunikaty po polsku. +- **Testy:** pytest-only, standalone funkcje (bez klas `unittest.TestCase`), `@pytest.mark.django_db`, `model_bakery.baker.make`. Fixtury z `src/fixtures/` (`autor_jan_nowak`, `autor_jan_kowalski`, `jednostka`, `typy_odpowiedzialnosci`, `wydawnictwo_zwarte`, `wydawnictwo_ciagle`, `praca_doktorska`, `patent`). +- **Kontrakt z reversion (NIE łamać):** override `delete()`/`restore()` idzie per-instancja przez `self.save()` / `super().delete()` — **nigdy** bulk `queryset.update(deleted_at=...)`. Faza 04 nie ustawia `deleted_at` ręcznie; deleguje do `super().delete()` pakietu. +- **Po każdym zadaniu:** `ruff format src/bpp` + `ruff check src/bpp` (tylko zmienione), komenda testu z zadania na zielono, commit. + +## Stan zweryfikowany w kodzie (punkt wyjścia) + +- `Wydawnictwo_*_Autor.autor = ForeignKey("bpp.Autor", CASCADE)` — w abstrakcie `BazaModeluOdpowiedzialnosciAutorow` (`src/bpp/models/abstract/authors.py:22`), dziedziczą `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor`. **Bez `related_name`** → reverse default `autor.wydawnictwo_ciagle_autor_set` / `wydawnictwo_zwarte_autor_set` / `patent_autor_set`. +- `Praca_Doktorska.autor = ForeignKey(Autor, CASCADE)` (`src/bpp/models/praca_doktorska.py:136`), reverse default `autor.praca_doktorska_set`. +- `Praca_Habilitacyjna.autor = OneToOneField(Autor, PROTECT)` (`src/bpp/models/praca_habilitacyjna.py:42`) — **już PROTECT, nie ruszamy**; reverse `autor.praca_habilitacyjna`. +- `Wydawnictwo_Zwarte.wydawnictwo_nadrzedne = ForeignKey("self", CASCADE, related_name="wydawnictwa_powiazane_set")` (`src/bpp/models/wydawnictwo_zwarte.py:202`) — rozdziały → książka-matka. +- `Autor` (`src/bpp/models/autor.py:81`) dziedziczy `LinkDoPBNMixin, ModelZAdnotacjami, ModelZPBN_ID` — **brak custom `delete()`**. Faza 04 dorzuca `SoftDeleteModel` do bazy i override `delete()`. +- Merge (`src/deduplikator_autorow/utils/merge.py`) przenosi przed `autor.delete()` WSZYSTKIE 5 typów: `Wydawnictwo_Ciagle_Autor` (:191/:223), `Wydawnictwo_Zwarte_Autor` (:265/:317), `Patent_Autor` (:335), `Praca_Habilitacyjna` (:410), `Praca_Doktorska` (:430). Po transferze husk jest pusty → guard go przepuści (test w Zadaniu 6). + +**Mapowanie relacji liczonych przez guard (model docelowy → pole FK do autora):** + +| relacja (label) | model | pole FK do autora | +|---|---|---| +| autorstwo ciągłe | `Wydawnictwo_Ciagle_Autor` | `autor` | +| autorstwo zwarte | `Wydawnictwo_Zwarte_Autor` | `autor` | +| autorstwo patentu | `Patent_Autor` | `autor` | +| doktorat | `Praca_Doktorska` | `autor` | +| habilitacja | `Praca_Habilitacyjna` | `autor` (O2O) | + +Helper liczy przez `Model.global_objects.filter(=instance)`, więc operuje na **modelach docelowych**, nie reverse-managerach (reverse manager nie wystawia `global_objects`). + +--- + +## Tasks + +### Zadanie 1 — Helper `raise_if_has_protected_children` w `soft_delete.py` + +**Files:** +- `src/bpp/models/soft_delete.py` — dopisz funkcję `raise_if_has_protected_children` (po istniejących klasach menedżerów z fazy 01). +- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py` (nowy plik). + +Helper przyjmuje listę krotek `(model, pole_fk)` lub listę nazw — wg PINNED kontraktu sygnatura to `raise_if_has_protected_children(instance, relations, label)`, gdzie `relations` to lista `(Model, "pole_fk")`. Liczy sumę dzieci przez `Model.global_objects.filter(**{pole: instance})`; jeśli >0 — rzuca `django.db.models.ProtectedError` z czytelnym komunikatem PL zawierającym `label`, liczbę dzieci i nazwę instancji. + +- [ ] **Failing test** — napisz `test_raise_if_has_protected_children_blokuje_gdy_sa_dzieci` i `test_raise_if_has_protected_children_przepuszcza_gdy_brak`: + ```python + import pytest + from django.db.models import ProtectedError + from model_bakery import baker + + from bpp.models import ( + Autor, + Wydawnictwo_Ciagle_Autor, + Praca_Doktorska, + ) + from bpp.models.soft_delete import raise_if_has_protected_children + + + @pytest.mark.django_db + def test_raise_if_has_protected_children_przepuszcza_gdy_brak(): + autor = baker.make(Autor) + # Nie rzuca — brak dzieci w podanych relacjach. + raise_if_has_protected_children( + autor, + [(Wydawnictwo_Ciagle_Autor, "autor"), (Praca_Doktorska, "autor")], + label="autora", + ) + + + @pytest.mark.django_db + def test_raise_if_has_protected_children_blokuje_gdy_sa_dzieci(): + autor = baker.make(Autor) + baker.make(Wydawnictwo_Ciagle_Autor, autor=autor) + with pytest.raises(ProtectedError): + raise_if_has_protected_children( + autor, + [(Wydawnictwo_Ciagle_Autor, "autor")], + label="autora", + ) + ``` +- [ ] **Run → FAIL:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k raise_if_has_protected_children` — `ImportError` / `AttributeError` (helper nie istnieje). +- [ ] **Implementacja** — dopisz w `src/bpp/models/soft_delete.py`: + ```python + from django.db.models import ProtectedError + + + def raise_if_has_protected_children(instance, relations, label): + """Blokuje soft-delete `instance`, jeśli ma „chronione" dzieci. + + `relations` to lista krotek ``(Model, "pole_fk")`` — liczymy dzieci + przez ``Model.global_objects`` (nie ``objects``!), żeby widzieć także + rekordy soft-deletowane kaskadą z fazy 02 (autor „cały w koszu" nadal + jest chroniony — spec §3.2). Rzuca ``ProtectedError`` z czytelnym + komunikatem PL, jeśli jest co najmniej jedno dziecko. + """ + protected = [] + for model, pole in relations: + qs = model.global_objects.filter(**{pole: instance}) + protected.extend(qs) + + if protected: + raise ProtectedError( + f"Nie można usunąć {label} „{instance}" — rekord ma " + f"{len(protected)} powiązanych prac (autorstwa / doktorat / " + f"habilitacja / rozdziały). Najpierw przenieś lub usuń te " + f"powiązania.", + protected, + ) + ``` + Uwaga: `ProtectedError(msg, protected_objects)` — drugi argument to iterowalny zbiór chronionych obiektów (kontrakt Django). Importuj `ProtectedError` z `django.db.models`. +- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k raise_if_has_protected_children` +- [ ] `ruff format src/bpp/models/soft_delete.py src/bpp/tests/test_models/test_soft_delete_guards.py` + `ruff check` na tych plikach. +- [ ] **Commit:** `feat(soft-delete): helper raise_if_has_protected_children (guard PROTECT)` + +--- + +### Zadanie 2 — Flip FK `CASCADE→PROTECT` na powiązaniach autora i rozdziałów (migracja state-only) + +**Files:** +- `src/bpp/models/abstract/authors.py:22` — `autor = ForeignKey("bpp.Autor", CASCADE)` → `PROTECT`. +- `src/bpp/models/praca_doktorska.py:136` — `autor = ForeignKey(Autor, CASCADE)` → `PROTECT`. +- `src/bpp/models/wydawnictwo_zwarte.py:202` — `wydawnictwo_nadrzedne = ForeignKey("self", CASCADE, ...)` → `PROTECT` (zachowaj `blank`, `null`, `help_text`, `related_name="wydawnictwa_powiazane_set"`). +- Nowa migracja: `src/bpp/migrations/0XXX_soft_delete_protect_fk.py` (numer = następny wolny; `migrations.AlterField` × 4 — bo `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` dziedziczą pole z abstraktu, więc każdy konkretny model dostaje własny `AlterField`). +- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py`. + +- [ ] **Failing test** — `test_fk_autora_jest_protect` i `test_wydawnictwo_nadrzedne_jest_protect`, sprawdzające `on_delete` przez introspekcję pola: + ```python + from django.db.models import PROTECT + + from bpp.models import ( + Patent_Autor, + Praca_Doktorska, + Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte, + Wydawnictwo_Zwarte_Autor, + ) + + + def test_fk_autora_jest_protect(): + for model in ( + Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte_Autor, + Patent_Autor, + Praca_Doktorska, + ): + field = model._meta.get_field("autor") + assert field.remote_field.on_delete is PROTECT, model + + + def test_wydawnictwo_nadrzedne_jest_protect(): + field = Wydawnictwo_Zwarte._meta.get_field("wydawnictwo_nadrzedne") + assert field.remote_field.on_delete is PROTECT + ``` +- [ ] **Run → FAIL:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "protect"` — `on_delete` to nadal `CASCADE`. +- [ ] **Implementacja (modele):** + - `src/bpp/models/abstract/authors.py:22` — zmień `from django.db.models import CASCADE, ...` jeśli trzeba dołożyć `PROTECT`; `autor = models.ForeignKey("bpp.Autor", models.PROTECT)`. + - `src/bpp/models/praca_doktorska.py:136` — `autor = models.ForeignKey(Autor, PROTECT)` (zadbaj o import `PROTECT`). + - `src/bpp/models/wydawnictwo_zwarte.py:202` — pierwszy arg pozycyjny `CASCADE` → `PROTECT` (zachowaj resztę kwargs i `related_name`). +- [ ] **Implementacja (migracja):** `uv run python src/manage.py makemigrations bpp --name soft_delete_protect_fk`, potem otwórz wygenerowany plik i **dodaj `state_operations` wrapper**, żeby była state-only (bez DDL — `on_delete` żyje tylko w ORM): + ```python + from django.db import migrations, models + import django.db.models.deletion + + + class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0XXX_poprzednia"), # ostatnia migracja bpp + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.AlterField( + model_name="wydawnictwo_ciagle_autor", + name="autor", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="bpp.autor", + ), + ), + migrations.AlterField( + model_name="wydawnictwo_zwarte_autor", + name="autor", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="bpp.autor", + ), + ), + migrations.AlterField( + model_name="patent_autor", + name="autor", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="bpp.autor", + ), + ), + migrations.AlterField( + model_name="praca_doktorska", + name="autor", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="bpp.autor", + ), + ), + migrations.AlterField( + model_name="wydawnictwo_zwarte", + name="wydawnictwo_nadrzedne", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="wydawnictwa_powiazane_set", + to="bpp.wydawnictwo_zwarte", + help_text=( + "Jeżeli dodajesz rozdział,\n tu wybierz " + "pracę, w ramach której dany rozdział występuje." + ), + ), + ), + ], + ), + ] + ``` + Dopasuj `help_text` 1:1 do tekstu z modelu (skopiuj dokładnie, żeby `makemigrations --check` nie wykrył driftu). Jeśli auto-wygenerowany `AlterField` różni się polami od powyższego — użyj wygenerowanego (jest source-of-truth dla state), tylko owiń w `SeparateDatabaseAndState(database_operations=[], state_operations=[...])`. +- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "protect"` +- [ ] **Sanity (brak driftu schematu):** `uv run python src/manage.py makemigrations bpp --check --dry-run` → „No changes detected". +- [ ] `ruff format` + `ruff check` na zmienionych plikach modeli i migracji. +- [ ] **Commit:** `feat(soft-delete): flip FK autor/doktorat/rozdziały CASCADE→PROTECT (state-only)` + +--- + +### Zadanie 3 — `Autor` → `SoftDeleteModel` + override soft `delete()` z guardem + +**Files:** +- `src/bpp/models/autor.py:81` — dodaj `SoftDeleteModel` do bazy klasy `Autor`; override `delete(self, *args, user=None, reason="", **kwargs)`. +- Nowa migracja: `src/bpp/migrations/0XXX_autor_soft_delete.py` (`deleted_at`, `restored_at`, `transaction_id` + indeks — wg pól `SoftDeleteModel`; menedżery `objects`/`global_objects`/`deleted_objects` pakietu). +- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py`. + +Uwaga MRO: `Autor` ma własny menedżer `objects = AutorManager()` (`src/bpp/models/autor.py:200`) i własny `save()`. `SoftDeleteModel` wnosi własne menedżery. Tu **zachowujemy** semantykę: `objects` ma nadal działać jak dotąd dla widoków, ale musi ukrywać skasowane. Najprościej: dodać `SoftDeleteModel` jako bazę, a `objects = AutorManager()` zostaje jako menedżer publiczny — pod warunkiem, że `AutorManager` po fazie 02/04 przepleciony jest z filtrem `deleted_at__isnull=True`. **W tej fazie skupiamy się na guardzie**; jeśli przeplecenie menedżera `AutorManager` nie zostało zrobione w fazie 02, dodaj `global_objects`/`deleted_objects` z pakietu i upewnij się, że `objects` filtruje skasowane (przez `BppSoftDeleteManager` z fazy 01 lub przepleciony `AutorManager`). To zadanie traktuje przeplecenie menedżera jako warunek wstępny; jeśli go brak — najpierw dorób (poza-zakresowy hot-fix odnotuj w commicie). + +- [ ] **Failing test** — autor bez prac soft-deletuje się (husk): + ```python + @pytest.mark.django_db + def test_autor_bez_prac_soft_delete_ok(autor_jan_nowak): + autor_jan_nowak.delete() + autor_jan_nowak.refresh_from_db() + assert autor_jan_nowak.deleted_at is not None + assert not Autor.objects.filter(pk=autor_jan_nowak.pk).exists() + assert Autor.global_objects.filter(pk=autor_jan_nowak.pk).exists() + ``` +- [ ] **Run → FAIL:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k test_autor_bez_prac_soft_delete_ok` — `AttributeError: deleted_at` lub `global_objects` (Autor nie jest jeszcze SoftDeleteModel). +- [ ] **Implementacja (model):** + - Import: `from django_softdelete.models import SoftDeleteModel`. + - Baza klasy: `class Autor(LinkDoPBNMixin, ModelZAdnotacjami, ModelZPBN_ID, SoftDeleteModel):`. + - Override (po `save()`): + ```python + def delete(self, *args, user=None, reason="", **kwargs): + """Soft-delete autora — DOZWOLONY tylko dla autora bez prac (husk). + + Guard liczy WSZYSTKIE powiązania przez global_objects (także + kaskadowo-skasowane autorstwa z fazy 02), spec §3.2. NIE kaskaduje + do *_Autor (spec §1). Autor z jakąkolwiek pracą → ProtectedError. + """ + from bpp.models import ( + Patent_Autor, + Praca_Doktorska, + Praca_Habilitacyjna, + Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte_Autor, + ) + from bpp.models.soft_delete import raise_if_has_protected_children + + raise_if_has_protected_children( + self, + [ + (Wydawnictwo_Ciagle_Autor, "autor"), + (Wydawnictwo_Zwarte_Autor, "autor"), + (Patent_Autor, "autor"), + (Praca_Doktorska, "autor"), + (Praca_Habilitacyjna, "autor"), + ], + label="autora", + ) + return super().delete(*args, **kwargs) + ``` + `user`/`reason` przyjmujemy w sygnaturze (kontrakt PINNED dla fazy 06/07) — w tej fazie nie używamy ich dalej; `super().delete()` pakietu nie przyjmuje tych kwargs, więc NIE przekazujemy ich w `**kwargs` do super (odfiltrowane przez nazwane parametry). +- [ ] **Implementacja (migracja):** `uv run python src/manage.py makemigrations bpp --name autor_soft_delete`. Sprawdź, że dodaje `deleted_at`, `restored_at`, `transaction_id` (oraz ewentualne menedżery). Pola domyślnie `NULL` — bez backfillu (spec §9, duże tabele). +- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k test_autor_bez_prac_soft_delete_ok` +- [ ] `uv run python src/manage.py makemigrations bpp --check --dry-run` → „No changes detected". +- [ ] `ruff format` + `ruff check` na `autor.py`, migracji, teście. +- [ ] **Commit:** `feat(soft-delete): Autor → SoftDeleteModel + guard soft-delete (husk only)` + +--- + +### Zadanie 4 — Testy guarda `Autor.delete()` dla każdego typu pracy + przypadek „tylko w koszu" + +**Files:** +- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py` (dopisz funkcje). + +Pokrywamy: ciągłe / zwarte / patent / doktorat / habilitacja → `ProtectedError`; oraz krytyczny przypadek z §3.2 — praca soft-deletowana (kaskada fazy 02) nadal blokuje, bo guard liczy przez `global_objects`. + +- [ ] **Failing test** — pięć przypadków + przypadek „tylko w koszu": + ```python + @pytest.mark.django_db + def test_autor_z_praca_ciagla_protect( + wydawnictwo_ciagle, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci + ): + wydawnictwo_ciagle.dodaj_autora(autor_jan_kowalski, jednostka) + with pytest.raises(ProtectedError): + autor_jan_kowalski.delete() + + + @pytest.mark.django_db + def test_autor_z_praca_zwarta_protect( + wydawnictwo_zwarte, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci + ): + wydawnictwo_zwarte.dodaj_autora(autor_jan_kowalski, jednostka) + with pytest.raises(ProtectedError): + autor_jan_kowalski.delete() + + + @pytest.mark.django_db + def test_autor_z_patentem_protect( + patent, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci + ): + patent.dodaj_autora(autor_jan_kowalski, jednostka) + with pytest.raises(ProtectedError): + autor_jan_kowalski.delete() + + + @pytest.mark.django_db + def test_autor_z_doktoratem_protect(autor_jan_nowak): + baker.make(Praca_Doktorska, autor=autor_jan_nowak) + with pytest.raises(ProtectedError): + autor_jan_nowak.delete() + + + @pytest.mark.django_db + def test_autor_z_habilitacja_protect(autor_jan_nowak): + baker.make(Praca_Habilitacyjna, autor=autor_jan_nowak) + with pytest.raises(ProtectedError): + autor_jan_nowak.delete() + + + @pytest.mark.django_db + def test_autor_z_praca_tylko_w_koszu_nadal_protect( + wydawnictwo_ciagle, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci + ): + # Praca + jej *_Autor są soft-deletowane (kaskada fazy 02). + wydawnictwo_ciagle.dodaj_autora(autor_jan_kowalski, jednostka) + wydawnictwo_ciagle.delete() + # objects ukrywa kaskadowo-skasowane autorstwo, ale guard liczy + # przez global_objects → autor nadal chroniony (spec §3.2). + assert not Wydawnictwo_Ciagle_Autor.objects.filter( + autor=autor_jan_kowalski + ).exists() + assert Wydawnictwo_Ciagle_Autor.global_objects.filter( + autor=autor_jan_kowalski + ).exists() + with pytest.raises(ProtectedError): + autor_jan_kowalski.delete() + ``` + Dodaj brakujące importy do nagłówka pliku: `Praca_Habilitacyjna`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor`, `Wydawnictwo_Ciagle_Autor`. +- [ ] **Run → FAIL (najpierw napisz, potem uruchom):** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "protect"` — przy poprawnym guardzie z Zadania 3 powinny przejść; jeśli któryś `FAIL`, debuguj guard/relacje (np. zła nazwa pola FK, użyto `objects` zamiast `global_objects`). To zadanie jest „dowodem regresji" guarda — przy błędzie w guardzie tu pęka. +- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "protect"` +- [ ] `ruff format` + `ruff check` na teście. +- [ ] **Commit:** `test(soft-delete): guard Autor.delete() — ciągłe/zwarte/patent/doktorat/habilitacja + kosz` + +--- + +### Zadanie 5 — `Wydawnictwo_Zwarte.delete()` guard na rozdziały (PRZED kaskadą fazy 02) + +**Files:** +- `src/bpp/models/wydawnictwo_zwarte.py` — w istniejącym override `delete()` (z fazy 02, wąska kaskada na `*_Autor`) dodaj guard **na samym początku**, PRZED ustawieniem `deleted_at` i PRZED kaskadą na `*_Autor`. +- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py`. + +Guard liczy rozdziały (dzieci self-FK) przez `global_objects` (spec §2.6 — soft-deletowane rozdziały też blokują). Reverse `related_name="wydawnictwa_powiazane_set"`, ale liczymy spójnie helperem przez model docelowy: `(Wydawnictwo_Zwarte, "wydawnictwo_nadrzedne")`. + +- [ ] **Failing test** — książka z rozdziałem → `ProtectedError`; bez rozdziałów → soft-delete OK: + ```python + @pytest.mark.django_db + def test_ksiazka_matka_z_rozdzialem_protect(wydawnictwo_zwarte): + rozdzial = baker.make( + Wydawnictwo_Zwarte, wydawnictwo_nadrzedne=wydawnictwo_zwarte + ) + assert rozdzial.wydawnictwo_nadrzedne_id == wydawnictwo_zwarte.pk + with pytest.raises(ProtectedError): + wydawnictwo_zwarte.delete() + wydawnictwo_zwarte.refresh_from_db() + assert wydawnictwo_zwarte.deleted_at is None # guard zablokował + + + @pytest.mark.django_db + def test_ksiazka_bez_rozdzialow_soft_delete_ok(wydawnictwo_zwarte): + wydawnictwo_zwarte.delete() + wydawnictwo_zwarte.refresh_from_db() + assert wydawnictwo_zwarte.deleted_at is not None + + + @pytest.mark.django_db + def test_ksiazka_matka_z_rozdzialem_w_koszu_nadal_protect(wydawnictwo_zwarte): + rozdzial = baker.make( + Wydawnictwo_Zwarte, wydawnictwo_nadrzedne=wydawnictwo_zwarte + ) + rozdzial.delete() # rozdział w koszu + assert not Wydawnictwo_Zwarte.objects.filter(pk=rozdzial.pk).exists() + with pytest.raises(ProtectedError): + wydawnictwo_zwarte.delete() # soft-deletowany rozdział też blokuje + ``` +- [ ] **Run → FAIL:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "ksiazka"` — bez guarda książka-matka skasuje się mimo rozdziału (test PROTECT pęka) lub `deleted_at` zostanie ustawione. +- [ ] **Implementacja** — w `Wydawnictwo_Zwarte.delete()` (z fazy 02) dodaj na początku: + ```python + def delete(self, *args, user=None, reason="", **kwargs): + from bpp.models.soft_delete import raise_if_has_protected_children + + raise_if_has_protected_children( + self, + [(Wydawnictwo_Zwarte, "wydawnictwo_nadrzedne")], + label="książki (ma rozdziały)", + ) + # ... istniejąca logika fazy 02: ustaw deleted_at, kaskada na *_Autor ... + return super().delete(*args, **kwargs) # lub istniejący return fazy 02 + ``` + **NIE gub kaskady na `*_Autor` z fazy 02** — guard wstawiamy WYŁĄCZNIE przed nią; reszta ciała `delete()` bez zmian. +- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k "ksiazka"` +- [ ] **Regresja kaskady fazy 02:** `uv run pytest src/bpp/tests/test_models/test_wydawnictwo_zwarte.py` — kaskada na `*_Autor` nadal działa dla książki bez rozdziałów. +- [ ] `ruff format` + `ruff check` na `wydawnictwo_zwarte.py`, teście. +- [ ] **Commit:** `feat(soft-delete): guard Wydawnictwo_Zwarte.delete() blokuje gdy ma rozdziały` + +--- + +### Zadanie 6 — Symulacja merge: husk po transferze prac soft-deletuje się + +**Files:** +- Test path: `src/bpp/tests/test_models/test_soft_delete_guards.py`. + +Weryfikuje spec §3.3 / overview: merge przenosi wszystkie typy prac na autora głównego, potem woła `autor.delete()` na pustym duplikacie — guard go przepuszcza. Test symuluje to bez wołania całego merge'a (przenosi `Wydawnictwo_Ciagle_Autor` na głównego, potem usuwa husk). + +- [ ] **Failing test** — (powinien przejść od razu, bo guard z Zadania 3 już działa; pełni rolę dowodu „merge nie jest zablokowany"): + ```python + @pytest.mark.django_db + def test_husk_po_transferze_prac_soft_delete_ok( + wydawnictwo_ciagle, + autor_jan_kowalski, + autor_jan_nowak, + jednostka, + typy_odpowiedzialnosci, + ): + # duplikat ma pracę + wydawnictwo_ciagle.dodaj_autora(autor_jan_nowak, jednostka) + wca = Wydawnictwo_Ciagle_Autor.global_objects.get(autor=autor_jan_nowak) + # merge: przenieś autorstwo na autora głównego (jak utils/merge.py:223) + wca.autor = autor_jan_kowalski + wca.save() + # husk (autor_jan_nowak) jest teraz pusty → guard przepuszcza + autor_jan_nowak.delete() + autor_jan_nowak.refresh_from_db() + assert autor_jan_nowak.deleted_at is not None + ``` +- [ ] **Run → PASS:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -k test_husk_po_transferze` +- [ ] **Regresja merge (smoke):** `uv run pytest src/deduplikator_autorow/` — merge nadal przechodzi (PROTECT nie psuje, bo husk pusty w chwili `delete()`). +- [ ] `ruff format` + `ruff check` na teście. +- [ ] **Commit:** `test(soft-delete): husk autora po transferze prac soft-deletuje się (merge)` + +--- + +### Zadanie 7 — Pełny przebieg testów fazy + sanity migracji + +**Files:** brak zmian; tylko weryfikacja. + +- [ ] **Cały plik guardów:** `uv run pytest src/bpp/tests/test_models/test_soft_delete_guards.py -v` — wszystkie zielone. +- [ ] **Brak driftu migracji:** `uv run python src/manage.py makemigrations bpp --check --dry-run` → „No changes detected". +- [ ] **Regresja modeli + merge:** `uv run pytest src/bpp/tests/test_models/ src/deduplikator_autorow/` — zielone (PROTECT/guard nie psują istniejących ścieżek). +- [ ] `ruff check src/bpp` (zmienione) + `ruff format --check` na plikach fazy. +- [ ] **Commit (jeśli cokolwiek doszło):** `chore(soft-delete): zielona faza 04 — guardy PROTECT` + +--- + +## Definicja ukończenia fazy 04 + +- Helper `raise_if_has_protected_children(instance, relations, label)` w `src/bpp/models/soft_delete.py` — liczy przez `global_objects`, rzuca `django.db.models.ProtectedError` z komunikatem PL. +- FK `CASCADE→PROTECT` (migracja state-only) na: `Wydawnictwo_Ciagle_Autor.autor`, `Wydawnictwo_Zwarte_Autor.autor`, `Patent_Autor.autor`, `Praca_Doktorska.autor`, `Wydawnictwo_Zwarte.wydawnictwo_nadrzedne`. Habilitacja bez zmian (już PROTECT). +- `Autor` jest `SoftDeleteModel`; override `delete(self, *args, user=None, reason="", **kwargs)` z guardem (5 relacji przez `global_objects`), bez kaskady do `*_Autor`. Autor bez prac → husk. +- `Wydawnictwo_Zwarte.delete()` ma guard na rozdziały PRZED kaskadą fazy 02 (kaskada zachowana). +- Testy: każdy typ pracy blokuje, praca-tylko-w-koszu blokuje, husk po merge przechodzi, książka z rozdziałem (też w koszu) blokuje, książka bez rozdziałów soft-deletuje się. +- `makemigrations --check --dry-run` czysty; regresja `test_models/` + `deduplikator_autorow/` zielona. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md new file mode 100644 index 000000000..f5188c9cc --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md @@ -0,0 +1,668 @@ +# Soft-delete — Faza 05: PBN wycofanie przez kolejkę + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. TDD: każdy krok najpierw PRAWDZIWY failing test → komenda + FAIL → PRAWDZIWA implementacja → komenda + PASS → commit. + +**Goal:** Rozszerzyć `pbn_export_queue` o operację `WYCOFANIE` (obok dotychczasowej `WYSYLKA`), tak by soft-delete publikacji mógł asynchronicznie wycofać oświadczenia dyscyplin z profilu instytucji PBN przez `client.delete_all_publication_statements(pbn_uid)`, z retry/locking/błędami jak istniejąca ścieżka wysyłki. Dostarczyć publiczne funkcje zakolejkowujące (`zakolejkuj_wycofanie`, `zakolejkuj_wysylke`) wołane potem z fazy 06, oraz zaktualizować `SentData` po udanym wycofaniu (`submitted_successfully=False` + znacznik `withdrawn_at`), bez kasowania wiersza. + +**Architecture:** Nowe pole `operacja` (`TextChoices` `WYSYLKA="wysylka"`/`WYCOFANIE="wycofanie"`, default `WYSYLKA` dla kompatybilności wstecznej) na `PBN_Export_Queue`. `send_to_pbn()` rozgałęzia się na początku: `WYCOFANIE` → nowa metoda `withdraw_from_pbn()` (GET klienta jak w wysyłce, `delete_all_publication_statements` z retry, aktualizacja `SentData`, status przez istniejące `_handle_successful_send`-analog / `error()`); `WYSYLKA` → dotychczasowa ścieżka bez zmian. Gate zakolejkowania: wycofanie tylko gdy rekord ma `pbn_uid_id`. + +**Tech Stack:** Django, PostgreSQL, Celery + `pbn_export_queue`, `pbn_api` (`PBNClient`, `SentData`), pytest + model_bakery + `unittest.mock`. + +**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§4 całość) · Indeks: [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md) (kontrakt `pbn_export_queue` rozszerzenie + `SentData`). + +**Zależność:** Faza 02 (publikacje `SoftDeleteModel`). Faza 06 woła `zakolejkuj_wycofanie`/`zakolejkuj_wysylke` z receiverów sygnałów — tu budujemy mechanizm + funkcje, NIE podpinamy sygnałów. + +--- + +## Reguły BPP (obowiązują w każdym kroku) + +- Wszystkie komendy Pythona przez `uv run` (np. `uv run pytest ...`). +- Testy: pytest, standalone functions / klasy bez `unittest.TestCase`, `@pytest.mark.django_db`, `model_bakery.baker.make`, mock klienta PBN przez `unittest.mock`. +- Max długość linii 88 znaków (ruff). Komentarze/teksty po polsku. +- **NIE modyfikować istniejących migracji** w `src/*/migrations/`. Ostatnia migracja `pbn_export_queue`: `0007_reclassify_doiorwwwmissing_errors.py` → nowe to `0008_*`. +- Po każdym kroku z kodem produkcyjnym: `ruff check` + `ruff format` na dotkniętych plikach, potem commit. + +--- + +## Stan zastany (zweryfikowany w kodzie — używać tych nazw VERBATIM) + +`src/pbn_export_queue/models.py`: +- `PBN_Export_Queue`: `object_id`, `content_type`, `rekord_do_wysylki` (GFK), `zamowil` (FK user, `on_delete=CASCADE`), `zamowiono`, `wysylke_podjeto`, `wysylke_zakonczono`, `ilosc_prob`, `zakonczono_pomyslnie` (`BooleanField(null=True, default=None)`), `komunikat`, `retry_after_user_authorised`, `rodzaj_bledu` (`RodzajBledu.TECHNICZNY/MERYTORYCZNY`), `wykluczone`. +- Manager `PBN_Export_QueueManager`: `filter_rekord_do_wysylki(rekord)` (filtr `wysylke_zakonczono=None`), `sprobuj_utowrzyc_wpis(user, rekord)` (rzuca `AlreadyEnqueuedError` gdy już w kolejce). +- Metody: `send_to_pbn()` (`refresh_from_db`; jeśli `wysylke_zakonczono` → `FINISHED_OKAY`; gdy rekord zniknął → `error(...)`; inkrementuje `ilosc_prob`; woła `sprobuj_wyslac_do_pbn_celery(user=self.zamowil.get_pbn_user(), obj=self.rekord_do_wysylki, force_upload=True)`; `_handle_pbn_exception(exc)` na wyjątek; `_handle_successful_send(sent_data, notificator)` na sukces), `error(msg, rodzaj=None)` (ustawia `wysylke_zakonczono`, `zakonczono_pomyslnie=False`, zwraca `SendStatus.FINISHED_ERROR`), `dopisz_komunikat(msg)`, `check_if_record_still_exists()`, `prepare_for_resend(user, message_suffix)`, `sprobuj_wyslac_do_pbn()` (deleguje `task_sprobuj_wyslac_do_pbn.delay(self.pk)`). +- `SendStatus` (Enum): `RETRY_SOON`, `RETRY_LATER`, `RETRY_MUCH_LATER`, `RETRY_AFTER_USER_AUTHORISED`, `WYKLUCZONE`, `FINISHED_OKAY`, `FINISHED_ERROR`. + +`src/pbn_export_queue/tasks.py`: +- `task_sprobuj_wyslac_do_pbn(pk)` — lock przez `cache.add(LOCK_PREFIX+pk)`, `wait_for_object`, `p.send_to_pbn()`, `match` na `SendStatus` (RETRY_* → `apply_async(countdown=...)`, `FINISHED_OKAY` → `check_and_send_next_in_queue()`). Lock zwalniany w `finally`. **Ta sama maszyneria obsłuży WYCOFANIE bez zmian** — `send_to_pbn()` zwraca `SendStatus`. + +`src/pbn_api/client/mixins/institutions.py`: +- `delete_all_publication_statements(publicationId)` (`:87`) — DELETE; może rzucić `ResourceLockedException`, `CannotDeleteStatementsException` (gdy PBN: "nie istnieją oświadczenia"), `HttpException`. + +`src/pbn_api/client/publication_sync.py`: +- `_delete_statements_with_retry(pbn_uid_id, max_tries=5)` (`:411`) — pętla: `delete_all_publication_statements` → przy `CannotDeleteStatementsException` retry (5 prób, `sleep(0.5)`), inne wyjątki lecą w górę. Wzorzec retry dla wycofania. + +`src/pbn_api/models/sentdata.py`: +- `SentDataManager`: `get_for_rec(rec)` (rzuca `SentData.DoesNotExist`), `mark_as_successful(rec, pbn_uid_id=None, api_response_status="")` (ustawia `submitted_successfully=True`, `uploaded_okay=True`), `mark_as_failed(...)`, `create_or_update_before_upload(...)`. +- `SentData` pola: `content_type`/`object_id`/`object` (GFK), `submitted_successfully` (`BooleanField`), `submitted_at`, `api_response_status` (`TextField`), `uploaded_okay`, `pbn_uid` (FK `pbn_api.Publication`, `SET_NULL`). + +`src/bpp/admin/helpers/pbn_api/cli.py`: +- `sprobuj_wyslac_do_pbn_celery(user, obj, force_upload=False, pbn_client=None)` — buduje `pbn_client = uczelnia.pbn_client(user.pbn_token)`. Wzorzec pozyskania klienta dla wycofania. + +`src/bpp/models/abstract/pbn.py`: rekordy mają `pbn_uid = OneToOneField("pbn_api.Publication")` → `rec.pbn_uid_id` to PBN UID (string id publikacji w PBN). + +--- + +## Decyzja: znacznik wycofania w `SentData` — nowe pole `withdrawn_at` + +**Wybór: dodać nowe pole `withdrawn_at = models.DateTimeField(null=True, blank=True)` na `SentData`** (migracja `pbn_api/migrations/0XXX`). + +**Uzasadnienie (dlaczego NIE `api_response_status`):** `api_response_status` to swobodny `TextField` nadpisywany przy KAŻDEJ operacji (`mark_as_successful`/`mark_as_failed`/`create_or_update_before_upload` go czyszczą/ustawiają surową odpowiedzią API). Użycie go jako znacznika stanu byłoby kruche — pierwsza kolejna wysyłka by go skasowała, a parsowanie statusu z tekstu odpowiedzi PBN jest nieodporne. Dedykowane `withdrawn_at` (timestamp) daje: (1) jednoznaczny, kwerowalny stan "rekord wycofany w PBN dnia X", (2) audyt kiedy, (3) symetrię: restore→`WYSYLKA`→`mark_as_successful` musi je wyzerować (`withdrawn_at=None`). Wiersza `SentData` NIE kasujemy (zostaje dla re-matchingu przy restore i dla `SoftDeleteLog` w fazie 06). + +--- + +## Tasks + +### Task 05.1 — Pole `operacja` na `PBN_Export_Queue` + migracja + +**Files:** +- `src/pbn_export_queue/models.py` (klasa `PBN_Export_Queue`, dodać `Operacja` TextChoices + pole `operacja`) +- `src/pbn_export_queue/migrations/0008_pbn_export_queue_operacja.py` (NOWA) +- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` (NOWY) + +- [ ] **Failing test — pole `operacja` istnieje z defaultem `WYSYLKA`.** W nowym pliku `src/pbn_export_queue/tests/test_operacja_wycofanie.py`: + ```python + from unittest.mock import MagicMock, patch + + import pytest + from model_bakery import baker + + from pbn_export_queue.models import ( + PBN_Export_Queue, + SendStatus, + ) + + + @pytest.mark.django_db + def test_operacja_default_wysylka(wydawnictwo_ciagle, admin_user): + wpis = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + ) + wpis.refresh_from_db() + assert wpis.operacja == PBN_Export_Queue.Operacja.WYSYLKA + assert PBN_Export_Queue.Operacja.WYSYLKA == "wysylka" + assert PBN_Export_Queue.Operacja.WYCOFANIE == "wycofanie" + ``` +- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_operacja_default_wysylka -x` → `AttributeError`/`FieldError` (brak `operacja`). +- [ ] **Implementacja — TextChoices + pole.** W `src/pbn_export_queue/models.py`, w klasie `PBN_Export_Queue` (po polach, przed `objects = ...`): + ```python + class Operacja(models.TextChoices): + WYSYLKA = "wysylka", "Wysyłka" + WYCOFANIE = "wycofanie", "Wycofanie oświadczeń" + + operacja = models.CharField( + max_length=16, + choices=Operacja.choices, + default=Operacja.WYSYLKA, + db_index=True, + verbose_name="Operacja", + ) + ``` +- [ ] **Migracja:** `uv run python src/manage.py makemigrations pbn_export_queue --name pbn_export_queue_operacja`. Zweryfikuj, że plik to `0008_pbn_export_queue_operacja.py` i dodaje wyłącznie pole `operacja` (`AddField`, default `wysylka`). NIE edytować wcześniejszych migracji. +- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_operacja_default_wysylka -x` → PASS. +- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/migrations/0008_pbn_export_queue_operacja.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_export_queue): pole operacja (WYSYLKA|WYCOFANIE) + migracja"` + +--- + +### Task 05.2 — `withdrawn_at` na `SentData` + symetria w managerze + +**Files:** +- `src/pbn_api/models/sentdata.py` (pole `withdrawn_at`; manager: `mark_as_withdrawn`; reset `withdrawn_at` w `mark_as_successful`) +- `src/pbn_api/migrations/0XXX_sentdata_withdrawn_at.py` (NOWA — numer wg `makemigrations`) +- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` + +- [ ] **Failing test — `mark_as_withdrawn` ustawia stan, `mark_as_successful` go zeruje.** Dopisz do `test_operacja_wycofanie.py`: + ```python + @pytest.mark.django_db + def test_sentdata_mark_as_withdrawn(wydawnictwo_ciagle): + from pbn_api.models.sentdata import SentData + + SentData.objects.create( + object=wydawnictwo_ciagle, + data_sent={}, + submitted_successfully=True, + uploaded_okay=True, + ) + + SentData.objects.mark_as_withdrawn(wydawnictwo_ciagle) + + sd = SentData.objects.get_for_rec(wydawnictwo_ciagle) + assert sd.submitted_successfully is False + assert sd.withdrawn_at is not None + + # restore → ponowna wysyłka zeruje znacznik wycofania + SentData.objects.mark_as_successful(wydawnictwo_ciagle) + sd = SentData.objects.get_for_rec(wydawnictwo_ciagle) + assert sd.submitted_successfully is True + assert sd.withdrawn_at is None + ``` +- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_sentdata_mark_as_withdrawn -x` → `AttributeError` (`mark_as_withdrawn`/`withdrawn_at`). +- [ ] **Implementacja — pole.** W `src/pbn_api/models/sentdata.py`, w klasie `SentData` (przy polach śledzenia, po `api_url`): + ```python + withdrawn_at = models.DateTimeField( + "Data wycofania oświadczeń", + null=True, + blank=True, + db_index=True, + help_text="Ustawiane po udanym wycofaniu oświadczeń z PBN " + "(soft-delete publikacji). Zerowane przy ponownej wysyłce.", + ) + ``` +- [ ] **Implementacja — manager `mark_as_withdrawn`.** W `SentDataManager` dodaj: + ```python + def mark_as_withdrawn(self, rec, api_response_status=""): + """Oznacza rekord jako wycofany z PBN (oświadczenia usunięte). + + Wiersza SentData NIE kasujemy — zostaje dla audytu i + re-matchingu przy restore. submitted_successfully=False, bo + rekord nie jest już "wystawiony" w PBN. + """ + sd = self.get_for_rec(rec) + sd.submitted_successfully = False + sd.withdrawn_at = timezone.now() + if api_response_status: + sd.api_response_status = api_response_status + sd.save() + return sd + ``` +- [ ] **Implementacja — symetria w `mark_as_successful`.** W `SentDataManager.mark_as_successful`, po `sd.submitted_successfully = True`, dodaj `sd.withdrawn_at = None` (restore→WYSYLKA czyści znacznik wycofania). (`timezone` jest już zaimportowany w pliku.) +- [ ] **Migracja:** `uv run python src/manage.py makemigrations pbn_api --name sentdata_withdrawn_at`. Zweryfikuj, że dodaje wyłącznie pole `withdrawn_at`. +- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_sentdata_mark_as_withdrawn -x` → PASS. +- [ ] **Lint:** `uv run ruff check src/pbn_api/models/sentdata.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_api/models/sentdata.py src/pbn_api/migrations/*sentdata_withdrawn_at*.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_api): SentData.withdrawn_at + mark_as_withdrawn; reset przy mark_as_successful"` + +--- + +### Task 05.3 — `withdraw_from_pbn()` + rozgałęzienie w `send_to_pbn()` + +Gałąź `WYCOFANIE` woła `client.delete_all_publication_statements(pbn_uid)` z retry analogicznym do `_delete_statements_with_retry` (obsługa `CannotDeleteStatementsException` jako sukces "nic do wycofania"), aktualizuje `SentData` i zwraca `SendStatus`. `WYSYLKA` → ścieżka bez zmian. Lock/retry/`ilosc_prob`/`task_sprobuj_wyslac_do_pbn` działają niezmienione (zwracamy ten sam typ `SendStatus`). + +**Files:** +- `src/pbn_export_queue/models.py` (`send_to_pbn` rozgałęzienie; nowa `withdraw_from_pbn`; helper `_pozyskaj_klienta_pbn`) +- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` + +- [ ] **Failing test — WYCOFANIE woła `delete_all_publication_statements` z właściwym pbn_uid + oznacza SentData.** Dopisz: + ```python + @pytest.mark.django_db + def test_wycofanie_wola_delete_all_statements(wydawnictwo_ciagle, admin_user): + from pbn_api.models import Publication + from pbn_api.models.sentdata import SentData + + pub = baker.make(Publication, pk="PBN-UID-123") + wydawnictwo_ciagle.pbn_uid = pub + wydawnictwo_ciagle.save() + SentData.objects.create( + object=wydawnictwo_ciagle, + data_sent={}, + submitted_successfully=True, + uploaded_okay=True, + ) + + wpis = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + operacja=PBN_Export_Queue.Operacja.WYCOFANIE, + wysylke_zakonczono=None, + ) + + mock_client = MagicMock() + with patch.object( + PBN_Export_Queue, "_pozyskaj_klienta_pbn", return_value=mock_client + ): + result = wpis.send_to_pbn() + + assert result == SendStatus.FINISHED_OKAY + mock_client.delete_all_publication_statements.assert_called_once_with( + "PBN-UID-123" + ) + wpis.refresh_from_db() + assert wpis.zakonczono_pomyslnie is True + sd = SentData.objects.get_for_rec(wydawnictwo_ciagle) + assert sd.submitted_successfully is False + assert sd.withdrawn_at is not None + ``` + (Uwaga: `Publication.pk` jest stringiem — `pbn_uid_id` to ten string. Jeśli baker nie pozwoli ustawić `pk`, użyj `baker.make(Publication, mongoId="PBN-UID-123")` i odczytaj `wydawnictwo_ciagle.pbn_uid_id` w asercji zamiast literału — dostosuj po sprawdzeniu modelu `Publication`.) +- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_wola_delete_all_statements -x` → FAIL (`send_to_pbn` idzie ścieżką wysyłki / brak `_pozyskaj_klienta_pbn`). +- [ ] **Implementacja — helper klienta.** W `PBN_Export_Queue` dodaj metodę pozyskania klienta (wzorzec z `cli.py`): + ```python + def _pozyskaj_klienta_pbn(self): + """Buduje klienta PBN dla użytkownika, który zlecił operację. + + Analogicznie do sprobuj_wyslac_do_pbn_celery: token z konta PBN + zamawiającego, klient z parametrów Uczelni. + """ + from bpp.models import Uczelnia + + pbn_user = self.zamowil.get_pbn_user() + uczelnia = Uczelnia.objects.get_default() + return uczelnia.pbn_client(pbn_user.pbn_token) + ``` +- [ ] **Implementacja — `withdraw_from_pbn`.** Dodaj metodę (retry wzorowany na `_delete_statements_with_retry`, ale `CannotDeleteStatementsException` traktujemy jako sukces — nic nie ma do wycofania): + ```python + def withdraw_from_pbn(self): + """Wycofuje oświadczenia dyscyplin publikacji z profilu instytucji. + + Gałąź operacji WYCOFANIE: woła delete_all_publication_statements + z retry. CannotDeleteStatementsException = oświadczeń już nie ma + → traktujemy jako sukces (idempotencja). Po sukcesie oznacza + SentData jako wycofany. :return: SendStatus + """ + import time + + from pbn_api.exceptions import ( + CannotDeleteStatementsException, + ResourceLockedException, + ) + + rec = self.rekord_do_wysylki + pbn_uid = getattr(rec, "pbn_uid_id", None) + if not pbn_uid: + # Gate obronny: nic do wycofania (rekord nigdy nie poszedł + # do PBN). Nie powinno się zdarzyć — zakolejkuj_wycofanie + # nie tworzy takich wpisów — ale na wszelki wypadek. + self.wysylke_zakonczono = timezone.now() + self.zakonczono_pomyslnie = True + self.dopisz_komunikat( + "Wycofanie pominięte: rekord nie ma PBN UID." + ) + self.save() + return SendStatus.FINISHED_OKAY + + try: + client = self._pozyskaj_klienta_pbn() + except Exception as exc: + return self._handle_pbn_exception(exc) + + no_tries = 5 + while True: + try: + client.delete_all_publication_statements(pbn_uid) + break + except CannotDeleteStatementsException: + # Oświadczeń już nie ma w PBN — cel osiągnięty. + break + except ResourceLockedException as exc: + self.dopisz_komunikat( + f"{exc}, ponawiam wycofanie za kilka minut..." + ) + self.save() + return SendStatus.RETRY_LATER + except Exception as exc: + if no_tries <= 0: + return self._handle_pbn_exception(exc) + no_tries -= 1 + time.sleep(0.5) + + from pbn_api.models.sentdata import SentData + + try: + SentData.objects.mark_as_withdrawn(rec) + except SentData.DoesNotExist: + # Brak wiersza SentData (rekord nigdy realnie nie wysłany, + # mimo pbn_uid) — nie jest błędem wycofania. + pass + + self.wysylke_zakonczono = timezone.now() + self.zakonczono_pomyslnie = True + self.dopisz_komunikat( + f"Wycofano oświadczenia dyscyplin z PBN (UID={pbn_uid})." + ) + self.save() + return SendStatus.FINISHED_OKAY + ``` +- [ ] **Implementacja — rozgałęzienie w `send_to_pbn`.** Na początku `send_to_pbn`, PO `self.refresh_from_db()` i PO wczesnym zwrocie `if self.wysylke_zakonczono is not None: return SendStatus.FINISHED_OKAY`, ale PRZED `check_if_record_still_exists`/inkrementacją prób, dodaj: + ```python + if not self.check_if_record_still_exists(): + return self.error( + "Rekord został usunięty nim wysyłka była możliwa.", + rodzaj=RodzajBledu.TECHNICZNY, + ) + + self.wysylke_podjeto = timezone.now() + if self.retry_after_user_authorised: + self.retry_after_user_authorised = None + self.ilosc_prob += 1 + self.save() + + if self.operacja == self.Operacja.WYCOFANIE: + return self.withdraw_from_pbn() + ``` + (Przenieś istniejący blok `check_if_record_still_exists` + `wysylke_podjeto`/`ilosc_prob` tak, by gałąź WYCOFANIE następowała PO inkrementacji prób — wycofanie ma korzystać z tego samego licznika `ilosc_prob` i tego samego guardu "rekord zniknął". Reszta `send_to_pbn` — ścieżka WYSYLKA — bez zmian.) +- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_wola_delete_all_statements -x` → PASS. +- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_export_queue): withdraw_from_pbn + gałąź WYCOFANIE w send_to_pbn"` + +--- + +### Task 05.4 — WYSYLKA dalej działa (regresja rozgałęzienia) + +Upewnij się, że dodanie gałęzi WYCOFANIE nie zmieniło ścieżki WYSYLKA: wpis z domyślną operacją nadal woła `sprobuj_wyslac_do_pbn_celery`, NIE `delete_all_publication_statements`. + +**Files:** +- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` + +- [ ] **Failing test — WYSYLKA nie woła delete_all_statements.** Dopisz: + ```python + @pytest.mark.django_db + def test_wysylka_nie_wola_delete_all_statements(wydawnictwo_ciagle, admin_user): + wpis = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + operacja=PBN_Export_Queue.Operacja.WYSYLKA, + wysylke_zakonczono=None, + ) + + sent_data = MagicMock() + with patch.object(admin_user, "get_pbn_user"), patch( + "pbn_export_queue.models.PBN_Export_Queue._pozyskaj_klienta_pbn" + ) as mock_klient, patch( + "bpp.admin.helpers.pbn_api.cli.sprobuj_wyslac_do_pbn_celery", + return_value=(sent_data, ["ok"]), + ) as mock_send: + result = wpis.send_to_pbn() + + assert result == SendStatus.FINISHED_OKAY + mock_send.assert_called_once() + mock_klient.assert_not_called() + ``` +- [ ] **Komenda:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wysylka_nie_wola_delete_all_statements -x`. Jeśli przechodzi od razu — to dowód regresyjny, że ścieżka WYSYLKA jest nietknięta; zostaw test jako guard (nie wymaga zmian implementacji). Jeśli FAIL — popraw rozgałęzienie w 05.3, by WYSYLKA nie wpadała w gałąź wycofania. +- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit:** `git add -A && git commit -m "test(pbn_export_queue): guard regresyjny — WYSYLKA nie woła delete_all_statements"` + +--- + +### Task 05.5 — Funkcje zakolejkowujące + gate na `pbn_uid` + +`zakolejkuj_wycofanie(rekord, user=None)` i `zakolejkuj_wysylke(rekord, user=None)` — publiczne, wołane potem z fazy 06 (receivery sygnałów). Tworzą wpis przez manager z odpowiednią `operacja` i delegują do `task_sprobuj_wyslac_do_pbn.delay(pk)`. Gate: wycofanie tylko gdy `rekord.pbn_uid_id` ustawione (brak PBN UID → no-op, brak wpisu). Idempotencja: jeśli rekord już w kolejce (`AlreadyEnqueuedError`) → no-op. + +**Files:** +- `src/pbn_export_queue/models.py` (manager `PBN_Export_QueueManager`: metody `zakolejkuj_wycofanie`, `zakolejkuj_wysylke`; rozszerz `sprobuj_utowrzyc_wpis` o argument `operacja`) +- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` + +- [ ] **Failing test — gate + utworzenie wpisu WYCOFANIE.** Dopisz: + ```python + @pytest.mark.django_db + def test_zakolejkuj_wycofanie_gate_brak_pbn_uid(wydawnictwo_ciagle, admin_user): + assert wydawnictwo_ciagle.pbn_uid_id is None + with patch( + "pbn_export_queue.models.task_sprobuj_wyslac_do_pbn" + ) as mock_task: + wpis = PBN_Export_Queue.objects.zakolejkuj_wycofanie( + wydawnictwo_ciagle, user=admin_user + ) + assert wpis is None + assert PBN_Export_Queue.objects.filter_rekord_do_wysylki( + wydawnictwo_ciagle + ).count() == 0 + mock_task.delay.assert_not_called() + + + @pytest.mark.django_db + def test_zakolejkuj_wycofanie_tworzy_wpis(wydawnictwo_ciagle, admin_user): + from pbn_api.models import Publication + + pub = baker.make(Publication) + wydawnictwo_ciagle.pbn_uid = pub + wydawnictwo_ciagle.save() + + with patch( + "pbn_export_queue.models.task_sprobuj_wyslac_do_pbn" + ) as mock_task: + wpis = PBN_Export_Queue.objects.zakolejkuj_wycofanie( + wydawnictwo_ciagle, user=admin_user + ) + + assert wpis is not None + assert wpis.operacja == PBN_Export_Queue.Operacja.WYCOFANIE + mock_task.delay.assert_called_once_with(wpis.pk) + + + @pytest.mark.django_db + def test_zakolejkuj_wysylke_tworzy_wpis(wydawnictwo_ciagle, admin_user): + with patch( + "pbn_export_queue.models.task_sprobuj_wyslac_do_pbn" + ) as mock_task: + wpis = PBN_Export_Queue.objects.zakolejkuj_wysylke( + wydawnictwo_ciagle, user=admin_user + ) + assert wpis is not None + assert wpis.operacja == PBN_Export_Queue.Operacja.WYSYLKA + mock_task.delay.assert_called_once_with(wpis.pk) + + + @pytest.mark.django_db + def test_zakolejkuj_idempotentne(wydawnictwo_ciagle, admin_user): + from pbn_api.models import Publication + + wydawnictwo_ciagle.pbn_uid = baker.make(Publication) + wydawnictwo_ciagle.save() + with patch("pbn_export_queue.models.task_sprobuj_wyslac_do_pbn"): + first = PBN_Export_Queue.objects.zakolejkuj_wycofanie( + wydawnictwo_ciagle, user=admin_user + ) + second = PBN_Export_Queue.objects.zakolejkuj_wycofanie( + wydawnictwo_ciagle, user=admin_user + ) + assert first is not None + assert second is None + assert PBN_Export_Queue.objects.filter_rekord_do_wysylki( + wydawnictwo_ciagle + ).count() == 1 + ``` +- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -k zakolejkuj -x` → `AttributeError` (`zakolejkuj_wycofanie`). +- [ ] **Implementacja — import tasku w models.py.** Na górze `withdraw_from_pbn`/managerze potrzebny `task_sprobuj_wyslac_do_pbn`. Żeby testy mogły patchować `pbn_export_queue.models.task_sprobuj_wyslac_do_pbn`, zaimportuj go **lokalnie w funkcji** i przypisz do nazwy modułowej — najprościej: w metodach managera użyj importu modułu i odwołania, które patch przechwyci. Wzorzec (unikamy cyklicznego importu na top-level — `tasks.py` importuje `models`): + ```python + def _delay_task(self, pk): + from pbn_export_queue import tasks + + tasks.task_sprobuj_wyslac_do_pbn.delay(pk) + ``` + ORAZ w testach patchuj `pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn` zamiast `pbn_export_queue.models...` — **popraw ścieżki patcha w testach 05.5 na `pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn`** (zaktualizuj literały w testach przy pierwszym uruchomieniu, gdy zobaczysz gdzie realnie żyje symbol). Cel: brak cyklicznego importu top-level. +- [ ] **Implementacja — rozszerz `sprobuj_utowrzyc_wpis` o `operacja`.** W managerze: + ```python + def sprobuj_utowrzyc_wpis(self, user, rekord, operacja=None): + if self.filter_rekord_do_wysylki(rekord).exists(): + raise AlreadyEnqueuedError( + "ten rekord jest już w kolejce do wysyłki" + ) + kwargs = {"rekord_do_wysylki": rekord, "zamowil": user} + if operacja is not None: + kwargs["operacja"] = operacja + return self.create(**kwargs) + ``` + (Zachowuje dotychczasową sygnaturę dla istniejących wywołań — `operacja` opcjonalna, default modelu `WYSYLKA`.) +- [ ] **Implementacja — `zakolejkuj_wycofanie` / `zakolejkuj_wysylke`.** W managerze: + ```python + def zakolejkuj_wysylke(self, rekord, user=None): + """Tworzy wpis WYSYLKA i uruchamia wysyłkę w tle. + + Wołane m.in. przy restore publikacji (faza 06). Idempotentne — + gdy rekord już w kolejce, zwraca None. + """ + from pbn_api.exceptions import AlreadyEnqueuedError + + try: + wpis = self.sprobuj_utowrzyc_wpis( + user, rekord, operacja=self.model.Operacja.WYSYLKA + ) + except AlreadyEnqueuedError: + return None + self._delay_task(wpis.pk) + return wpis + + def zakolejkuj_wycofanie(self, rekord, user=None): + """Tworzy wpis WYCOFANIE i uruchamia wycofanie w tle. + + Gate: tylko gdy rekord ma PBN UID (inaczej nic nie poszło do + PBN — no-op, zwraca None). Idempotentne — gdy rekord już + w kolejce, zwraca None. + """ + from pbn_api.exceptions import AlreadyEnqueuedError + + if not getattr(rekord, "pbn_uid_id", None): + return None + try: + wpis = self.sprobuj_utowrzyc_wpis( + user, rekord, operacja=self.model.Operacja.WYCOFANIE + ) + except AlreadyEnqueuedError: + return None + self._delay_task(wpis.pk) + return wpis + ``` + (`user=None` dozwolone — operacje systemowe/celery; pole `zamowil` jest `on_delete=CASCADE` i NOT NULL, więc gdy `user is None` ścieżka wymaga konta technicznego. **Zweryfikuj w fazie 06**, czy receiver zawsze poda usera; tu zostawiamy sygnaturę `user=None` zgodną z kontraktem PINNED, a faktyczne wymaganie NOT NULL na `zamowil` rozwiązuje faza 06/07 przekazując konto. Jeśli test z `user=None` jest potrzebny — dodać dopiero gdy faza 06 ustali konto techniczne.) +- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -k zakolejkuj -x` → PASS. +- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_export_queue): zakolejkuj_wycofanie/zakolejkuj_wysylke + gate pbn_uid"` + +--- + +### Task 05.6 — Idempotencja/retry wycofania (ResourceLocked + CannotDelete) + +**Files:** +- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` + +- [ ] **Failing test — `CannotDeleteStatementsException` traktowany jak sukces.** Dopisz: + ```python + @pytest.mark.django_db + def test_wycofanie_brak_oswiadczen_to_sukces(wydawnictwo_ciagle, admin_user): + from pbn_api.exceptions import CannotDeleteStatementsException + from pbn_api.models import Publication + from pbn_api.models.sentdata import SentData + + wydawnictwo_ciagle.pbn_uid = baker.make(Publication) + wydawnictwo_ciagle.save() + SentData.objects.create( + object=wydawnictwo_ciagle, + data_sent={}, + submitted_successfully=True, + uploaded_okay=True, + ) + wpis = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + operacja=PBN_Export_Queue.Operacja.WYCOFANIE, + wysylke_zakonczono=None, + ) + + mock_client = MagicMock() + mock_client.delete_all_publication_statements.side_effect = ( + CannotDeleteStatementsException("brak oświadczeń") + ) + with patch.object( + PBN_Export_Queue, "_pozyskaj_klienta_pbn", return_value=mock_client + ): + result = wpis.send_to_pbn() + + assert result == SendStatus.FINISHED_OKAY + wpis.refresh_from_db() + assert wpis.zakonczono_pomyslnie is True + assert SentData.objects.get_for_rec( + wydawnictwo_ciagle + ).withdrawn_at is not None + ``` +- [ ] **Komenda:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_brak_oswiadczen_to_sukces -x` → powinien przejść (logika z 05.3 traktuje `CannotDeleteStatementsException` jako break/sukces). Jeśli FAIL — dopracuj `withdraw_from_pbn`. + +- [ ] **Failing test — `ResourceLockedException` → RETRY_LATER, bez oznaczenia SentData.** Dopisz: + ```python + @pytest.mark.django_db + def test_wycofanie_locked_retry_later(wydawnictwo_ciagle, admin_user): + from pbn_api.exceptions import ResourceLockedException + from pbn_api.models import Publication + from pbn_api.models.sentdata import SentData + + wydawnictwo_ciagle.pbn_uid = baker.make(Publication) + wydawnictwo_ciagle.save() + SentData.objects.create( + object=wydawnictwo_ciagle, + data_sent={}, + submitted_successfully=True, + uploaded_okay=True, + ) + wpis = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + operacja=PBN_Export_Queue.Operacja.WYCOFANIE, + wysylke_zakonczono=None, + ) + + mock_client = MagicMock() + mock_client.delete_all_publication_statements.side_effect = ( + ResourceLockedException("zablokowane") + ) + with patch.object( + PBN_Export_Queue, "_pozyskaj_klienta_pbn", return_value=mock_client + ): + result = wpis.send_to_pbn() + + assert result == SendStatus.RETRY_LATER + wpis.refresh_from_db() + # wycofanie nie zakończone — zostanie ponowione + assert wpis.wysylke_zakonczono is None + assert SentData.objects.get_for_rec( + wydawnictwo_ciagle + ).withdrawn_at is None + ``` +- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_locked_retry_later -x` → PASS (logika z 05.3 zwraca `RETRY_LATER` na `ResourceLockedException` bez ustawiania `wysylke_zakonczono`). Jeśli FAIL — popraw kolejność `except` w `withdraw_from_pbn`. +- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit:** `git add -A && git commit -m "test(pbn_export_queue): wycofanie — idempotencja CannotDelete + retry ResourceLocked"` + +--- + +### Task 05.7 — Admin: kolumna `operacja` widoczna w kolejce + +Drobne wsparcie operacyjne: pokaż operację na liście kolejki, by superuser odróżnił wpisy wycofania od wysyłki. + +**Files:** +- `src/pbn_export_queue/admin.py` (`list_display`, `list_filter`, `readonly_fields`) +- Test path: `src/pbn_export_queue/tests/test_admin.py` (dopisać 1 asercję) lub `test_operacja_wycofanie.py` + +- [ ] **Failing test — `operacja` w `list_display`.** Dopisz do `test_operacja_wycofanie.py`: + ```python + def test_admin_pokazuje_operacje(): + from pbn_export_queue.admin import PBN_Export_QueueAdmin + + assert "operacja" in PBN_Export_QueueAdmin.list_display + assert "operacja" in PBN_Export_QueueAdmin.list_filter + ``` +- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_admin_pokazuje_operacje -x` → AssertionError. +- [ ] **Implementacja — admin.** W `src/pbn_export_queue/admin.py`: dodaj `"operacja"` do `list_display` (np. zaraz po `"rekord_do_wysylki"`), do `list_filter` i do `readonly_fields`. +- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_admin_pokazuje_operacje -x` → PASS. +- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/admin.py && uv run ruff format src/pbn_export_queue/admin.py` +- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_export_queue): admin pokazuje kolumnę/filtr operacja"` + +--- + +### Task 05.8 — Pełna weryfikacja fazy + brak driftu migracji + +**Files:** — + +- [ ] **Cały plik testowy fazy:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -x` → wszystkie PASS. +- [ ] **Regresja całego app kolejki + sentdata:** `uv run pytest src/pbn_export_queue/ src/pbn_api/tests/ -q` → zielono (gałąź WYCOFANIE nie zepsuła istniejących testów wysyłki/managerów/admina). +- [ ] **Brak driftu migracji:** `uv run python src/manage.py makemigrations --check --dry-run` → "No changes detected". +- [ ] **Lint całości fazy:** `uv run ruff check src/pbn_export_queue/ src/pbn_api/models/sentdata.py` → czysto. +- [ ] **Commit (jeśli cokolwiek dopięte):** `git add -A && git commit -m "chore(soft-delete): faza 05 PBN wycofanie — weryfikacja końcowa"` + +--- + +## Podsumowanie zakresu (co ta faza dostarcza fazie 06) + +- `PBN_Export_Queue.Operacja` (`WYSYLKA`/`WYCOFANIE`) + pole `operacja` (default `WYSYLKA`). +- `PBN_Export_Queue.objects.zakolejkuj_wycofanie(rekord, user=None)` i `zakolejkuj_wysylke(rekord, user=None)` — publiczny kontrakt dla receiverów fazy 06 (`post_soft_delete`→wycofanie, `post_restore`→wysyłka). Gate wycofania na `pbn_uid_id`, idempotentne. +- Po udanym wycofaniu: `SentData.withdrawn_at` ustawione, `submitted_successfully=False`, wiersz NIE skasowany. Restore→WYSYLKA→`mark_as_successful` zeruje `withdrawn_at`. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md new file mode 100644 index 000000000..3ce7c9ebf --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md @@ -0,0 +1,838 @@ +# Soft-delete — Faza 06: SoftDeleteLog + receivery sygnałów + atrybucja usera + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Zbudować dedykowany audyt soft-delete: model `SoftDeleteLog` (kto / kiedy / dlaczego / status PBN) zasilany przez receivery sygnałów pakietu `django-soft-delete` (`post_soft_delete`, `post_restore`, `post_hard_delete`). Receiver `DELETE` dla publikacji z `pbn_uid` kolejkuje wycofanie oświadczeń PBN (`zakolejkuj_wycofanie` z fazy 05) i podpina wpis kolejki do logu; `RESTORE` kolejkuje ponowną wysyłkę (`zakolejkuj_wysylke`). Atrybucja „kto" rozwiązana przez thread-local context manager ustawiany w override `delete(user=, reason=)`/`restore(user=)` (fazy 02/04) — sygnał pakietu sam nie niesie usera. + +**Architecture:** Pakiet `django-soft-delete` wysyła `post_soft_delete`/`post_hard_delete`/`post_restore` z `sender`+`instance` (zweryfikowane: `models.py:174,85,234`), ale BEZ usera/powodu. Override `delete()`/`restore()` (fazy 02/04) ustawia thread-local przez context manager `soft_delete_context(user=, reason=)` tuż przed wywołaniem pakietowego `super().delete()`. Receivery (jeden punkt podpięcia dla WSZYSTKICH soft-deletowalnych modeli) czytają thread-local i tworzą `SoftDeleteLog`. Receiver `DELETE`/`RESTORE` woła funkcje kolejkujące PBN z fazy 05 i zapisuje `pbn_queue_entry` + `pbn_status` na logu. Rejestracja w `BppConfig.ready()` (`src/bpp/apps.py:8`). + +**Tech Stack:** Django 4.2, `django-soft-delete>=1.0.23`, `pbn_export_queue` (faza 05), pytest + model_bakery. Python przez `uv run`. Max 88 znaków (ruff). Polski w komunikatach/verbose_name. + +**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§5) +**Plan-indeks (kontrakty PINNED):** [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md) + +**Zależności:** Faza 02 (modele publikacji emitują sygnały + override `delete(user=, reason=)`/`restore(user=)`), Faza 05 (funkcje kolejkujące PBN). Patrz „Kontrakty z fazami 02 i 05" niżej. + +--- + +## Kontrakty z fazami 02 i 05 (czytaj PRZED implementacją) + +### Co ta faza DOSTARCZA fazom 02/04 (one tego importują) +- `src/bpp/models/soft_delete_context.py` — context manager + akcesory: + ```python + from contextlib import contextmanager + import threading + + _ctx = threading.local() + + @contextmanager + def soft_delete_context(user=None, reason=""): + """Ustawia thread-local user/reason na czas pakietowego + delete()/restore(). Receiver post_* czyta to przez + current_soft_delete_user()/current_soft_delete_reason().""" + prev_user = getattr(_ctx, "user", None) + prev_reason = getattr(_ctx, "reason", "") + _ctx.user = user + _ctx.reason = reason + try: + yield + finally: + _ctx.user = prev_user + _ctx.reason = prev_reason + + def current_soft_delete_user(): + return getattr(_ctx, "user", None) + + def current_soft_delete_reason(): + return getattr(_ctx, "reason", "") + ``` +- **Faza 02/04 owija** wnętrze swojego override `delete()`/`restore()`: + ```python + def delete(self, *args, user=None, reason="", **kwargs): + with soft_delete_context(user=user, reason=reason): + return super().delete(*args, **kwargs) # tu leci post_soft_delete + ``` + Dzięki temu, gdy pakiet wyśle `post_soft_delete` (wewnątrz `super().delete()`), + thread-local wciąż jest ustawiony i receiver odczyta usera. Reentrancja jest + bezpieczna (zapamiętanie prev_*), więc wąska kaskada na `*_Autor` + (faza 02) dziedziczy ten sam kontekst. +- Operacje BEZ usera (merge autorów, celery, `.delete()` w kodzie bez kontekstu) + → thread-local nieustawiony → `current_soft_delete_user()` zwraca `None`. + +> **Realny stan na dziś:** `src/bpp/models/soft_delete_context.py` NIE istnieje +> (zweryfikowano — w `src/bpp/models/` jest tylko `soft_delete.py` z fazy 01, +> managery). Tę fazę tworzy plik od zera; fazy 02/04 już go importują (są +> przed 06 w kolejce, ale plik dostarcza 06 — jeśli fazy 02/04 były robione +> wcześniej i dodały tymczasowy stub, scal go z tym kontraktem VERBATIM). + +### Czego ta faza WYMAGA od fazy 05 (PINNED — VERBATIM nazwy) +Faza 05 dostarcza w `src/pbn_export_queue/operacje.py` (lub `tasks.py`): +```python +def zakolejkuj_wycofanie(instance, user=None): + """Tworzy PBN_Export_Queue(operacja=WYCOFANIE) dla publikacji z pbn_uid. + Zwraca utworzony PBN_Export_Queue albo None (gdy brak pbn_uid).""" + +def zakolejkuj_wysylke(instance, user=None): + """Tworzy PBN_Export_Queue(operacja=WYSYLKA) dla publikacji z pbn_uid. + Zwraca utworzony PBN_Export_Queue albo None (gdy brak pbn_uid).""" +``` +- Obie przyjmują `instance` (rekord publikacji) + `user` (może być `None`). +- Obie zwracają instancję `PBN_Export_Queue` (do podpięcia w `SoftDeleteLog. + pbn_queue_entry`) albo `None` gdy gate `pbn_uid` nie spełniony. +- **Jeśli faza 05 jeszcze nie istnieje w worktree** — task 5 niżej zawiera + cienki shim, który faza 05 zastąpi pełną implementacją. Receiver woła te + funkcje przez import w środku, więc shim nie blokuje testów fazy 06. + +### Detekcja „publikacja z `pbn_uid`" (zweryfikowane) +Receiver NIE zna typu instancji. Gate uniwersalny: +`getattr(instance, "pbn_uid_id", None) is not None`. Publikacje +(`Wydawnictwo_*`) mają `pbn_uid` (FK, `pbn_uid_id`); `Autor` i `*_Autor` nie +mają → `getattr(..., None)` zwraca `None` → PBN pomijamy automatycznie. +(Zweryfikowano: `src/bpp/models/abstract/pbn.py:35` `hasattr(self, "pbn_uid")`.) + +### Sygnatury sygnałów pakietu (zweryfikowane w `django_softdelete/models.py`) +- `post_soft_delete.send(sender=cls, instance=self, using=using)` (`:174`). +- `post_hard_delete.send(sender=cls, instance=self)` (`:85`) — BEZ `using`. +- `post_restore.send(sender=cls, instance=self, transaction_id=...)` (`:234`) + — BEZ `using`, ZA TO z `transaction_id`. +- **Wniosek dla receiverów:** sygnatura `def receiver(sender, instance, + **kwargs)` — `**kwargs` połyka `using`/`transaction_id` (różnią się między + sygnałami). Nie polegaj na `using`/`transaction_id`. + +--- + +## Wspólne kontrakty (PINNED — VERBATIM z indeksu 00) + +### `SoftDeleteLog` — `src/bpp/models/soft_delete_log.py` +Pola DOKŁADNIE: +- `content_type` — `FK(ContentType, on_delete=CASCADE)`, +- `object_id` — `PositiveIntegerField(db_index=True)`, +- `content_object` — `GenericForeignKey("content_type", "object_id")`, +- `akcja` — `CharField(choices=Akcja.choices)` gdzie + `class Akcja(models.TextChoices): DELETE="delete"; RESTORE="restore"; + HARD_DELETE="hard_delete"`, +- `user` — `FK(AUTH_USER_MODEL, null=True, blank=True, on_delete=SET_NULL)`, +- `timestamp` — `DateTimeField(auto_now_add=True, db_index=True)`, +- `powod` — `TextField(blank=True, default="")`, +- `pbn_queue_entry` — `FK("pbn_export_queue.PBN_Export_Queue", null=True, + blank=True, on_delete=SET_NULL)`, +- `pbn_status` — `CharField(max_length=50, blank=True, default="")`. + +### Receivery — `src/bpp/receivers/soft_delete.py` +- `post_soft_delete` → `SoftDeleteLog(akcja=DELETE, user, powod)`; jeśli + publikacja z `pbn_uid` → `zakolejkuj_wycofanie(instance, user)` i podepnij + `pbn_queue_entry` + ustaw `pbn_status="WYCOFANIE"`. +- `post_restore` → `SoftDeleteLog(akcja=RESTORE)`; jeśli z `pbn_uid` → + `zakolejkuj_wysylke(instance, user)` + `pbn_queue_entry` + `pbn_status= + "WYSYLKA"`. +- `post_hard_delete` → `SoftDeleteLog(akcja=HARD_DELETE)` (bez PBN — rekord + fizycznie znika). +- Rejestracja w `src/bpp/apps.py` → `BppConfig.ready()`. + +### Kontrakt z reversion (NIE łamać) +Punkt wstrzyknięcia usera (`soft_delete_context`) to JEDEN hook — ten sam +moment, w którym przyszła integracja `reversion.set_user` doczepi usera. +Receivery NIE robią bulk-update i nie omijają `post_save` (tylko czytają +sygnały + tworzą wiersze logu). + +--- + +## Tasks + +### Task 1: Context manager atrybucji usera (thread-local) + +**Files:** +- Create: `src/bpp/models/soft_delete_context.py` +- Test: `src/bpp/tests/test_soft_delete/test_soft_delete_context.py` + +**Step 1 — Failing test:** +- [ ] Utwórz `src/bpp/tests/test_soft_delete/__init__.py` (pusty) jeśli katalog + nie istnieje. +- [ ] Napisz `src/bpp/tests/test_soft_delete/test_soft_delete_context.py`: +```python +from bpp.models.soft_delete_context import ( + current_soft_delete_reason, + current_soft_delete_user, + soft_delete_context, +) + + +def test_context_brak_usera_domyslnie(): + assert current_soft_delete_user() is None + assert current_soft_delete_reason() == "" + + +def test_context_ustawia_i_czysci(django_user_model, db): + u = django_user_model.objects.create(username="ktos") + with soft_delete_context(user=u, reason="literówka"): + assert current_soft_delete_user() == u + assert current_soft_delete_reason() == "literówka" + assert current_soft_delete_user() is None + assert current_soft_delete_reason() == "" + + +def test_context_zagniezdzony_przywraca_zewnetrzny(django_user_model, db): + a = django_user_model.objects.create(username="a") + b = django_user_model.objects.create(username="b") + with soft_delete_context(user=a, reason="zewn"): + with soft_delete_context(user=b, reason="wewn"): + assert current_soft_delete_user() == b + assert current_soft_delete_reason() == "wewn" + assert current_soft_delete_user() == a + assert current_soft_delete_reason() == "zewn" + assert current_soft_delete_user() is None +``` +**Step 2 — Run → FAIL:** +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_context.py` + → FAIL (ModuleNotFoundError: `bpp.models.soft_delete_context`). +**Step 3 — Implementation:** +- [ ] Utwórz `src/bpp/models/soft_delete_context.py` z treścią VERBATIM jak + w sekcji „Co ta faza DOSTARCZA fazom 02/04" wyżej (`soft_delete_context`, + `current_soft_delete_user`, `current_soft_delete_reason`, `threading.local`). +**Step 4 — Run → PASS:** +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_context.py` + → 3 passed. +- [ ] `ruff check src/bpp/models/soft_delete_context.py && ruff format + --check src/bpp/models/soft_delete_context.py` +**Step 5 — Commit:** +- [ ] `git add src/bpp/models/soft_delete_context.py + src/bpp/tests/test_soft_delete/` i commit: + `feat(soft-delete): context manager atrybucji usera (thread-local)` + +--- + +### Task 2: Model `SoftDeleteLog` + migracja + +**Files:** +- Create: `src/bpp/models/soft_delete_log.py` +- Modify: `src/bpp/models/__init__.py` (dopisz import, wzorzec `oplaty_log` + na `:52`) +- Create: `src/bpp/migrations/0421_softdeletelog.py` (NUMER: sprawdź najwyższą + istniejącą migrację `bpp` przez `ls src/bpp/migrations/ | grep -E '^04' | + sort | tail -1` i nadaj kolejny — **NIE modyfikuj istniejących migracji**) +- Test: `src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py` + +**Step 1 — Failing test:** +- [ ] Napisz `test_soft_delete_log_model.py`: +```python +import pytest +from django.contrib.contenttypes.models import ContentType +from model_bakery import baker + +from bpp.models.soft_delete_log import SoftDeleteLog + + +@pytest.mark.django_db +def test_softdeletelog_gfk_wskazuje_na_rekord(wydawnictwo_ciagle): + log = SoftDeleteLog.objects.create( + content_type=ContentType.objects.get_for_model(wydawnictwo_ciagle), + object_id=wydawnictwo_ciagle.pk, + akcja=SoftDeleteLog.Akcja.DELETE, + powod="test", + ) + assert log.content_object == wydawnictwo_ciagle + assert log.timestamp is not None + assert log.user is None + assert log.pbn_queue_entry is None + assert log.pbn_status == "" + + +@pytest.mark.django_db +def test_softdeletelog_akcja_choices(): + assert SoftDeleteLog.Akcja.DELETE == "delete" + assert SoftDeleteLog.Akcja.RESTORE == "restore" + assert SoftDeleteLog.Akcja.HARD_DELETE == "hard_delete" + + +@pytest.mark.django_db +def test_softdeletelog_user_set_null(wydawnictwo_ciagle, django_user_model): + u = baker.make(django_user_model) + log = SoftDeleteLog.objects.create( + content_type=ContentType.objects.get_for_model(wydawnictwo_ciagle), + object_id=wydawnictwo_ciagle.pk, + akcja=SoftDeleteLog.Akcja.DELETE, + user=u, + ) + u.delete() + log.refresh_from_db() + assert log.user is None +``` +**Step 2 — Run → FAIL:** +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py` + → FAIL (ImportError SoftDeleteLog). +**Step 3 — Implementation:** +- [ ] Utwórz `src/bpp/models/soft_delete_log.py` (wzorzec `oplaty_log.py`): +```python +"""Dedykowany audyt operacji soft-delete (kto / kiedy / dlaczego / PBN).""" + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from django_bpp.settings.base import AUTH_USER_MODEL + +__all__ = ["SoftDeleteLog"] + + +class SoftDeleteLog(models.Model): + """Wpis audytu pojedynczej operacji soft-delete / restore / hard-delete.""" + + class Akcja(models.TextChoices): + DELETE = "delete", "Usunięcie (kosz)" + RESTORE = "restore", "Przywrócenie" + HARD_DELETE = "hard_delete", "Usunięcie trwałe" + + content_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, verbose_name="Typ rekordu" + ) + object_id = models.PositiveIntegerField(db_index=True, verbose_name="ID obiektu") + content_object = GenericForeignKey("content_type", "object_id") + + akcja = models.CharField( + max_length=20, choices=Akcja.choices, db_index=True, verbose_name="Akcja" + ) + user = models.ForeignKey( + AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name="Użytkownik", + ) + timestamp = models.DateTimeField( + auto_now_add=True, db_index=True, verbose_name="Data operacji" + ) + powod = models.TextField(blank=True, default="", verbose_name="Powód") + + pbn_queue_entry = models.ForeignKey( + "pbn_export_queue.PBN_Export_Queue", + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name="Wpis kolejki PBN", + ) + pbn_status = models.CharField( + max_length=50, blank=True, default="", verbose_name="Status PBN" + ) + + class Meta: + verbose_name = "Log operacji soft-delete" + verbose_name_plural = "Logi operacji soft-delete" + ordering = ["-timestamp"] + indexes = [ + models.Index(fields=["content_type", "object_id"]), + models.Index(fields=["timestamp"]), + ] + + def __str__(self): + return f"{self.get_akcja_display()}: {self.content_object} ({self.timestamp})" +``` +- [ ] Dopisz w `src/bpp/models/__init__.py` (po linii z `oplaty_log`): + `from .soft_delete_log import * # noqa` +- [ ] Wygeneruj migrację: `uv run python src/manage.py makemigrations bpp` + (sprawdź, że dependency na `pbn_export_queue` jest w wygenerowanej migracji — + Django doda je automatycznie przez FK; jeśli nie, dopisz ręcznie + `("pbn_export_queue", "")` do `dependencies`). +**Step 4 — Run → PASS:** +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py` + → 3 passed. +- [ ] `uv run python src/manage.py makemigrations --check --dry-run bpp` + → „No changes detected" (migracja kompletna). +- [ ] `ruff check src/bpp/models/soft_delete_log.py` +**Step 5 — Commit:** +- [ ] Commit: `feat(soft-delete): model SoftDeleteLog + migracja` + +--- + +### Task 3: Receiver `post_hard_delete` → log HARD_DELETE + +(robiony pierwszy z receiverów — najprostszy, bez PBN, bez kolejki) + +**Files:** +- Create: `src/bpp/receivers/__init__.py` (pusty) +- Create: `src/bpp/receivers/soft_delete.py` +- Modify: `src/bpp/apps.py` (`BppConfig.ready()` — rejestracja) +- Test: `src/bpp/tests/test_soft_delete/test_receivers.py` + +**Step 1 — Failing test:** +- [ ] Napisz `test_receivers.py` (pierwszy test): +```python +import pytest +from django.contrib.contenttypes.models import ContentType + +from bpp.models.soft_delete_context import soft_delete_context +from bpp.models.soft_delete_log import SoftDeleteLog + + +def _logi(instance, akcja): + return SoftDeleteLog.objects.filter( + content_type=ContentType.objects.get_for_model(instance), + object_id=instance.pk, + akcja=akcja, + ) + + +@pytest.mark.django_db +def test_hard_delete_tworzy_log(wydawnictwo_ciagle, superuser): + pk = wydawnictwo_ciagle.pk + ct = ContentType.objects.get_for_model(wydawnictwo_ciagle) + with soft_delete_context(user=superuser, reason="trwałe"): + wydawnictwo_ciagle.hard_delete() + log = SoftDeleteLog.objects.get( + content_type=ct, object_id=pk, akcja=SoftDeleteLog.Akcja.HARD_DELETE + ) + assert log.user == superuser + assert log.powod == "trwałe" + assert log.pbn_queue_entry is None +``` +**Step 2 — Run → FAIL:** +- [ ] `uv run pytest "src/bpp/tests/test_soft_delete/test_receivers.py::test_hard_delete_tworzy_log"` + → FAIL (brak receiverów → log nie powstaje → `SoftDeleteLog.DoesNotExist`). +**Step 3 — Implementation:** +- [ ] Utwórz `src/bpp/receivers/__init__.py` (pusty). +- [ ] Utwórz `src/bpp/receivers/soft_delete.py`: +```python +"""Receivery sygnałów django-soft-delete → zasilanie SoftDeleteLog + PBN.""" + +from django.contrib.contenttypes.models import ContentType + +from bpp.models.soft_delete_context import ( + current_soft_delete_reason, + current_soft_delete_user, +) +from bpp.models.soft_delete_log import SoftDeleteLog + + +def _utworz_log(instance, akcja, pbn_queue_entry=None, pbn_status=""): + return SoftDeleteLog.objects.create( + content_type=ContentType.objects.get_for_model(instance), + object_id=instance.pk, + akcja=akcja, + user=current_soft_delete_user(), + powod=current_soft_delete_reason(), + pbn_queue_entry=pbn_queue_entry, + pbn_status=pbn_status, + ) + + +def on_post_hard_delete(sender, instance, **kwargs): + """Hard-delete: rekord fizycznie znika, więc bez operacji PBN.""" + _utworz_log(instance, SoftDeleteLog.Akcja.HARD_DELETE) + + +def register(): + from django_softdelete.signals import ( + post_hard_delete, + post_restore, + post_soft_delete, + ) + + post_hard_delete.connect( + on_post_hard_delete, dispatch_uid="bpp.soft_delete.post_hard_delete" + ) +``` +> **UWAGA `pk` przy hard-delete:** `post_hard_delete` jest wysyłany PO +> `super().delete()` (pakiet, `models.py:84-85`). Django zeruje `instance.pk` +> dopiero gdy delete idzie przez kolektor — `SoftDeleteModel.hard_delete` +> woła `models.Model.delete()`, które ustawia `pk=None` po usunięciu. Test +> wyżej zapisuje `pk = ...` PRZED `hard_delete()`. W receiverze +> `instance.pk` może być `None` → **zapisz `object_id` z `instance.pk` jeśli +> nie-None, inaczej trzeba przekazać pk inaczej.** Zweryfikuj w teście: jeśli +> `instance.pk is None` w receiverze, zmień `on_post_hard_delete` by czytał pk +> z `instance.pk or kwargs`. Jeśli pakiet zachowuje pk (bo `hard_delete` +> nie czyści atrybutu instancji) — zostaw prosto. **Dostosuj implementację do +> faktycznego zachowania potwierdzonego testem, nie zgaduj.** +- [ ] W `src/bpp/apps.py`, w `BppConfig.ready()` (po `configure_rollbar()`, + linia ~34) dopisz: +```python + # Receivery soft-delete → SoftDeleteLog + kolejka PBN + from bpp.receivers import soft_delete as soft_delete_receivers + + soft_delete_receivers.register() +``` +**Step 4 — Run → PASS:** +- [ ] `uv run pytest "src/bpp/tests/test_soft_delete/test_receivers.py::test_hard_delete_tworzy_log"` + → passed. Jeśli FAIL przez `pk is None` — popraw wg uwagi wyżej, ponów do PASS. +- [ ] `ruff check src/bpp/receivers/soft_delete.py src/bpp/apps.py` +**Step 5 — Commit:** +- [ ] Commit: `feat(soft-delete): receiver post_hard_delete → log HARD_DELETE` + +--- + +### Task 4: Receiver `post_soft_delete` → log DELETE + atrybucja usera (bez PBN) + +(PBN dochodzi w Tasku 6 — tu izolujemy log + usera/powód dla DELETE) + +**Files:** +- Modify: `src/bpp/receivers/soft_delete.py` +- Test: `src/bpp/tests/test_soft_delete/test_receivers.py` + +**Step 1 — Failing test:** +- [ ] Dopisz testy: +```python +@pytest.mark.django_db +def test_soft_delete_tworzy_log_z_userem(wydawnictwo_ciagle, superuser): + with soft_delete_context(user=superuser, reason="duplikat"): + wydawnictwo_ciagle.delete() + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get() + assert log.user == superuser + assert log.powod == "duplikat" + + +@pytest.mark.django_db +def test_soft_delete_bez_usera_loguje_none(wydawnictwo_ciagle): + wydawnictwo_ciagle.delete() + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get() + assert log.user is None + assert log.powod == "" +``` +> **Założenie:** `wydawnictwo_ciagle.delete()` emituje `post_soft_delete` +> (faza 02 wpięła `SoftDeleteModel` + override owijający `soft_delete_context`). +> Jeśli faza 02 NIE jest jeszcze w worktree, fixture `wydawnictwo_ciagle` nie +> jest `SoftDeleteModel` → `.delete()` zrobi hard delete bez sygnału. +> Wtedy w teście wyślij sygnał ręcznie przez +> `post_soft_delete.send(sender=type(wc), instance=wc)` wewnątrz +> `soft_delete_context(...)`, by testować SAM receiver w izolacji. Wybierz +> wariant zgodny ze stanem worktree i udokumentuj w docstringu testu. +**Step 2 — Run → FAIL:** +- [ ] `uv run pytest "src/bpp/tests/test_soft_delete/test_receivers.py::test_soft_delete_tworzy_log_z_userem" "src/bpp/tests/test_soft_delete/test_receivers.py::test_soft_delete_bez_usera_loguje_none"` + → FAIL (brak receivera DELETE → brak logu). +**Step 3 — Implementation:** +- [ ] Dodaj do `src/bpp/receivers/soft_delete.py`: +```python +def on_post_soft_delete(sender, instance, **kwargs): + _utworz_log(instance, SoftDeleteLog.Akcja.DELETE) +``` +- [ ] W `register()` dopisz: +```python + post_soft_delete.connect( + on_post_soft_delete, dispatch_uid="bpp.soft_delete.post_soft_delete" + ) +``` +**Step 4 — Run → PASS:** +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_receivers.py` → all passed. +- [ ] `ruff check src/bpp/receivers/soft_delete.py` +**Step 5 — Commit:** +- [ ] Commit: `feat(soft-delete): receiver post_soft_delete → log DELETE + user` + +--- + +### Task 5: Shim funkcji kolejkujących PBN (kontrakt z fazą 05) + +> Cienka warstwa, by faza 06 była testowalna niezależnie od kolejności +> wykonania faz. **Jeśli faza 05 już istnieje** (`zakolejkuj_wycofanie`/ +> `zakolejkuj_wysylke` w `src/pbn_export_queue/`) — POMIŃ ten task, użyj +> realnych funkcji. Sprawdź: `uv run python -c "from pbn_export_queue.operacje +> import zakolejkuj_wycofanie, zakolejkuj_wysylke"`. + +**Files:** +- Create (tylko jeśli brak fazy 05): `src/pbn_export_queue/operacje.py` +- Test: `src/bpp/tests/test_soft_delete/test_pbn_queue_shim.py` + +**Step 1 — Failing test:** +- [ ] Napisz test (gate `pbn_uid`): +```python +import pytest +from model_bakery import baker + +from pbn_export_queue.operacje import zakolejkuj_wycofanie, zakolejkuj_wysylke +from pbn_export_queue.models import PBN_Export_Queue + + +@pytest.mark.django_db +def test_zakolejkuj_wycofanie_bez_pbn_uid_zwraca_none(autor): + # autor nie ma pbn_uid_id → None + assert zakolejkuj_wycofanie(autor) is None + + +@pytest.mark.django_db +def test_zakolejkuj_wycofanie_z_pbn_uid_tworzy_wpis( + wydawnictwo_ciagle, superuser +): + wydawnictwo_ciagle.pbn_uid_id = "00000000-0000-0000-0000-000000000001" + wydawnictwo_ciagle.save() + wpis = zakolejkuj_wycofanie(wydawnictwo_ciagle, user=superuser) + assert isinstance(wpis, PBN_Export_Queue) + assert wpis.object_id == wydawnictwo_ciagle.pk + + +@pytest.mark.django_db +def test_zakolejkuj_wysylke_z_pbn_uid_tworzy_wpis(wydawnictwo_ciagle, superuser): + wydawnictwo_ciagle.pbn_uid_id = "00000000-0000-0000-0000-000000000002" + wydawnictwo_ciagle.save() + wpis = zakolejkuj_wysylke(wydawnictwo_ciagle, user=superuser) + assert isinstance(wpis, PBN_Export_Queue) +``` +> **Uwaga do fazy 02/05:** `pbn_uid_id` musi przyjąć wartość. Jeśli FK celuje +> w `pbn_api.Publication`, baker/`save()` z surowym UUID-em może wymagać +> istniejącego rekordu — wtedy w teście stwórz `baker.make("pbn_api. +> Publication")` i przypisz `wydawnictwo_ciagle.pbn_uid = pub`. Dostosuj do +> realnego typu FK potwierdzonego w `wydawnictwo_ciagle.py`. +**Step 2 — Run → FAIL:** +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_pbn_queue_shim.py` + → FAIL (ModuleNotFoundError `pbn_export_queue.operacje`). +**Step 3 — Implementation (shim — faza 05 nadpisze pełną logiką):** +- [ ] Utwórz `src/pbn_export_queue/operacje.py`: +```python +"""Operacje kolejkowania PBN dla soft-delete (WYCOFANIE / WYSYLKA). + +UWAGA: cienki shim z fazy 06. Faza 05 nadpisuje go pełną implementacją +(pole `operacja`, gałąź `delete_all_publication_statements`, integracja +SentData). Tu utrzymujemy wyłącznie kontrakt sygnatur + gate `pbn_uid`. +""" + +from contextlib import suppress + +from pbn_export_queue.models import PBN_Export_Queue + + +def _ma_pbn_uid(instance): + return getattr(instance, "pbn_uid_id", None) is not None + + +def _utworz_wpis(instance, user): + return PBN_Export_Queue.objects.create( + rekord_do_wysylki=instance, + zamowil=user, + ) + + +def zakolejkuj_wycofanie(instance, user=None): + """Kolejkuje wycofanie oświadczeń PBN. None gdy brak pbn_uid.""" + if not _ma_pbn_uid(instance): + return None + return _utworz_wpis(instance, user) + + +def zakolejkuj_wysylke(instance, user=None): + """Kolejkuje ponowną wysyłkę do PBN. None gdy brak pbn_uid.""" + if not _ma_pbn_uid(instance): + return None + return _utworz_wpis(instance, user) +``` +> **Niuans `zamowil`:** `PBN_Export_Queue.zamowil` to `FK(AUTH_USER_MODEL, +> on_delete=CASCADE)` BEZ `null=True` (zweryfikowano `models.py:79`). Dla +> operacji systemowych `user=None` ten FK się wywali. Faza 05 to rozwiąże +> (np. konto techniczne lub `null=True`). W teście Tasku 5 przekazuj +> `user=superuser`. W receiverze (Task 6) gate `pbn_uid` i tak zwykle idzie +> z akcji admina (user jest). Jeśli `user is None` w receiverze przy +> publikacji z `pbn_uid` — owiń wywołanie w `suppress(...)`/log, NIE wywal +> całej operacji delete. **To dług fazy 05; udokumentuj `# TODO(faza 05)`.** +> Usuń niewykorzystany import `suppress`, jeśli go nie użyjesz. +**Step 4 — Run → PASS:** +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_pbn_queue_shim.py` + → passed. +- [ ] `ruff check src/pbn_export_queue/operacje.py` +**Step 5 — Commit:** +- [ ] Commit: `feat(soft-delete): shim zakolejkuj_wycofanie/wysylke (kontrakt 05)` + +--- + +### Task 6: PBN w receiverach — DELETE→WYCOFANIE, RESTORE→WYSYLKA, podpięcie kolejki + +**Files:** +- Modify: `src/bpp/receivers/soft_delete.py` +- Test: `src/bpp/tests/test_soft_delete/test_receivers.py` + +**Step 1 — Failing test:** +- [ ] Dopisz testy (publikacja z `pbn_uid` + restore): +```python +@pytest.mark.django_db +def test_soft_delete_z_pbn_uid_kolejkuje_wycofanie( + wydawnictwo_ciagle, superuser +): + wydawnictwo_ciagle.pbn_uid_id = "00000000-0000-0000-0000-000000000010" + wydawnictwo_ciagle.save() + with soft_delete_context(user=superuser, reason="x"): + wydawnictwo_ciagle.delete() + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get() + assert log.pbn_queue_entry is not None + assert log.pbn_status == "WYCOFANIE" + + +@pytest.mark.django_db +def test_soft_delete_bez_pbn_uid_nie_kolejkuje(wydawnictwo_ciagle, superuser): + # brak pbn_uid → log bez wpisu kolejki + with soft_delete_context(user=superuser): + wydawnictwo_ciagle.delete() + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.DELETE).get() + assert log.pbn_queue_entry is None + assert log.pbn_status == "" + + +@pytest.mark.django_db +def test_restore_z_pbn_uid_kolejkuje_wysylke(wydawnictwo_ciagle, superuser): + wydawnictwo_ciagle.pbn_uid_id = "00000000-0000-0000-0000-000000000011" + wydawnictwo_ciagle.save() + with soft_delete_context(user=superuser): + wydawnictwo_ciagle.delete() + with soft_delete_context(user=superuser): + wydawnictwo_ciagle.restore() + log = _logi(wydawnictwo_ciagle, SoftDeleteLog.Akcja.RESTORE).get() + assert log.pbn_queue_entry is not None + assert log.pbn_status == "WYSYLKA" +``` +> Jeśli faza 02 nie wpięła `SoftDeleteModel`/override — testuj receiver +> przez ręczny `post_soft_delete.send(...)` / `post_restore.send(...)` +> wewnątrz `soft_delete_context`, jak w uwadze Tasku 4. +**Step 2 — Run → FAIL:** +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_receivers.py` + → FAIL (receivery nie kolejkują PBN, brak receivera RESTORE). +**Step 3 — Implementation:** +- [ ] Przebuduj receivery w `src/bpp/receivers/soft_delete.py`: +```python +def _kolejkuj_pbn(instance, operacja): + """Woła funkcję kolejkującą z fazy 05. Zwraca (wpis, status_str).""" + from pbn_export_queue.operacje import ( + zakolejkuj_wycofanie, + zakolejkuj_wysylke, + ) + + user = current_soft_delete_user() + if operacja == "WYCOFANIE": + wpis = zakolejkuj_wycofanie(instance, user=user) + return wpis, ("WYCOFANIE" if wpis is not None else "") + wpis = zakolejkuj_wysylke(instance, user=user) + return wpis, ("WYSYLKA" if wpis is not None else "") + + +def on_post_soft_delete(sender, instance, **kwargs): + wpis, status = _kolejkuj_pbn(instance, "WYCOFANIE") + _utworz_log( + instance, + SoftDeleteLog.Akcja.DELETE, + pbn_queue_entry=wpis, + pbn_status=status, + ) + + +def on_post_restore(sender, instance, **kwargs): + wpis, status = _kolejkuj_pbn(instance, "WYSYLKA") + _utworz_log( + instance, + SoftDeleteLog.Akcja.RESTORE, + pbn_queue_entry=wpis, + pbn_status=status, + ) +``` +- [ ] W `register()` dopisz `post_restore.connect(on_post_restore, + dispatch_uid="bpp.soft_delete.post_restore")`. +- [ ] Import `post_restore` jest już w lokalnym imporcie `register()`. +**Step 4 — Run → PASS:** +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/test_receivers.py` → all passed. +- [ ] `ruff check src/bpp/receivers/soft_delete.py` +**Step 5 — Commit:** +- [ ] Commit: `feat(soft-delete): receivery kolejkują PBN (WYCOFANIE/WYSYLKA)` + +--- + +### Task 7: Test integracyjny end-to-end + weryfikacja rejestracji w apps.ready + +**Files:** +- Test: `src/bpp/tests/test_soft_delete/test_receivers.py` (dopisz) + +**Step 1 — Failing test (lub regresyjny — guard rejestracji):** +- [ ] Dopisz test sprawdzający, że receivery są PODŁĄCZONE przez + `apps.ready()` (nie tylko gdy test wywoła `register()` ręcznie): +```python +@pytest.mark.django_db +def test_receivery_zarejestrowane_przez_apps_ready(): + from django_softdelete.signals import ( + post_hard_delete, + post_restore, + post_soft_delete, + ) + + def _uids(sig): + return { + r[0][0] + for r in sig.receivers + if isinstance(r[0], tuple) + } + + assert "bpp.soft_delete.post_soft_delete" in _uids(post_soft_delete) + assert "bpp.soft_delete.post_restore" in _uids(post_restore) + assert "bpp.soft_delete.post_hard_delete" in _uids(post_hard_delete) +``` +> `Signal.receivers` to lista `((dispatch_uid, sender_id), ref)`. Sprawdź +> realny kształt przez `uv run python -c "..."` jeśli asercja nie trafia — +> dostosuj ekstrakcję uid. Cel: udowodnić, że `BppConfig.ready()` faktycznie +> woła `register()`. +**Step 2 — Run:** +- [ ] `uv run pytest "src/bpp/tests/test_soft_delete/test_receivers.py::test_receivery_zarejestrowane_przez_apps_ready"` + — jeśli FAIL, popraw `register()`/`apps.py` lub ekstrakcję uid; jeśli PASS + od razu (bo Task 3/4/6 już wpięły rejestrację) — to regresyjny strażnik. +**Step 3 — Implementation:** +- [ ] Jeśli test FAIL z powodu kształtu `receivers` — popraw asercję + (NIE produkcję, chyba że rejestracja faktycznie brakuje). +**Step 4 — Run → PASS + cała faza:** +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/` → wszystkie zielone. +- [ ] `ruff check src/bpp/ src/pbn_export_queue/operacje.py` +- [ ] `ruff format --check src/bpp/models/soft_delete_log.py + src/bpp/models/soft_delete_context.py src/bpp/receivers/soft_delete.py` +- [ ] `uv run python src/manage.py makemigrations --check --dry-run` + → „No changes detected". +**Step 5 — Commit:** +- [ ] Commit: `test(soft-delete): e2e + guard rejestracji receiverów (apps.ready)` + +--- + +## Definition of Done (faza 06) + +- [ ] `SoftDeleteLog` (pola PINNED VERBATIM) + migracja; `makemigrations + --check` czyste. +- [ ] `soft_delete_context` (thread-local CM) + akcesory; reentrancja OK. +- [ ] Trzy receivery (`post_soft_delete`→DELETE, `post_restore`→RESTORE, + `post_hard_delete`→HARD_DELETE) zarejestrowane w `BppConfig.ready()`. +- [ ] DELETE publikacji z `pbn_uid` → log DELETE + `zakolejkuj_wycofanie` + + `pbn_queue_entry` podpięty + `pbn_status="WYCOFANIE"`. +- [ ] RESTORE → log RESTORE + `zakolejkuj_wysylke` + `pbn_status="WYSYLKA"`. +- [ ] HARD_DELETE → log HARD_DELETE, bez PBN. +- [ ] User poprawnie z `soft_delete_context`; brak kontekstu → `user=None`, + `powod=""`. +- [ ] Publikacja bez `pbn_uid` / `Autor` → log bez wpisu kolejki. +- [ ] `uv run pytest src/bpp/tests/test_soft_delete/` zielone; `ruff + check`/`format` czyste; commit per task. + +--- + +## Podsumowanie (3 punkty) + założenia + +**1. Co robi ta faza.** Tworzy `SoftDeleteLog` (GFK + akcja + user + powód + +podpięcie do `PBN_Export_Queue` + status PBN) i trzy receivery sygnałów +pakietu `django-soft-delete`, zarejestrowane w jednym punkcie +(`BppConfig.ready()`). Atrybucję „kto" rozwiązuje thread-local context +manager `soft_delete_context(user=, reason=)` ustawiany w override +`delete()`/`restore()` (fazy 02/04) — sygnał pakietu usera nie niesie. Receiver +DELETE kolejkuje wycofanie z PBN, RESTORE — ponowną wysyłkę (gate `pbn_uid`). + +**2. Kluczowe ustalenia z weryfikacji kodu.** (a) Sygnały mają RÓŻNE kwargs: +`post_soft_delete` niesie `using`, `post_restore` — `transaction_id`, +`post_hard_delete` — nic poza `instance`; stąd receivery używają +`**kwargs`. (b) `post_hard_delete` leci PO `Model.delete()` → `instance.pk` +może być `None` (uwaga w Tasku 3 — dostosować do faktu, nie zgadywać). +(c) `PBN_Export_Queue.zamowil` jest `NOT NULL` (`on_delete=CASCADE`) — operacje +systemowe bez usera to dług fazy 05 (oznaczone `TODO(faza 05)`). (d) Detekcja +publikacji = `getattr(instance, "pbn_uid_id", None)` (Autor/`*_Autor` nie mają +→ None → PBN pomijane). + +**3. Kontrakt z reversion zachowany.** Jeden hook usera (`soft_delete_context`) +to dokładnie ten sam moment, w który przyszły `reversion.set_user` się wepnie; +receivery tylko czytają sygnały i tworzą wiersze logu — bez bulk-update, bez +omijania `post_save`. + +**Założenia:** (i) **Faza 05 dostarcza** `zakolejkuj_wycofanie`/ +`zakolejkuj_wysylke` w `src/pbn_export_queue/operacje.py` (sygnatury PINNED) — +jeśli jeszcze nie istnieją, Task 5 daje shim, który faza 05 nadpisze; jeśli +istnieją, Task 5 pomijamy. (ii) **Faza 02 wpina** `SoftDeleteModel` + override +owijający `soft_delete_context` na publikacjach — jeśli nie ma jeszcze tego +w worktree, testy receiverów Tasków 4/6 idą przez ręczny +`post_soft_delete.send(...)` w izolacji (wariant udokumentowany w docstringu +testu). (iii) Numer migracji `0421_*` orientacyjny — wykonawca nadaje kolejny +po sprawdzeniu `ls src/bpp/migrations/`. (iv) `soft_delete_context.py` tworzy +ta faza; gdy fazy 02/04 dodały wcześniej stub — scalić VERBATIM z kontraktem. + +--- + +**Ścieżka tego planu:** +`/Users/mpasternak/Programowanie/bpp-soft-delete/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md` +`file:///Volumes/mpasternak/Programowanie/bpp-soft-delete/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md` diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-07-admin.md b/docs/superpowers/plans/2026-06-04-soft-delete-07-admin.md new file mode 100644 index 000000000..fa502d29b --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-07-admin.md @@ -0,0 +1,1260 @@ +# Soft-delete — Faza 07: Admin superuser-only (kosz / przywróć / usuń trwale / powód) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Dać superuserowi w adminie BPP odwracalny „kosz" dla 5 typów publikacji + `Autor`: „Usuń" = soft-delete (z powodem do `SoftDeleteLog`), filtr „Pokaż skasowane", akcja „Przywróć", osobna jawna akcja „Usuń trwale" (tylko superuser) — wszystko przez JEDEN punkt wstrzyknięcia `request.user`. + +**Architecture:** Nowy mixin `BppSoftDeleteAdminMixin` w `src/bpp/admin/helpers/mixins.py` komponuje się PRZED istniejącymi klasami admina (`Wydawnictwo_CiagleAdmin`, `Wydawnictwo_ZwarteAdmin`, `Patent_Admin`, `Praca_DoktorskaAdmin`, `Praca_HabilitacyjnaAdmin`, `AutorAdmin`). Mixin: (a) `get_queryset()` → `global_objects` (otwieranie/przywracanie skasowanych); (b) jeden hook usera `_soft_delete_user_context(request)` ustawiający thread-local z fazy 06, używany przez `delete_model`/`delete_queryset`/akcje; (c) akcje `przywroc_zaznaczone`, `usun_trwale_zaznaczone` (superuser-only); (d) filtr `SoftDeleteFilter` (pakiet) → „Pokaż skasowane"; (e) pole „powód" przez intermediate-page (jak Django delete confirmation). NIE używamy `GlobalObjectsModelAdmin`/`SoftDeletedModelAdmin` z pakietu — wołają `obj.delete()` bez `user=` (łamią kontrakt jednego hooka). + +**Tech Stack:** Django admin, `django-soft-delete>=1.0.23` (`global_objects`, `deleted_objects`, `SoftDeleteFilter`, `.delete()/.restore()/.hard_delete()`), pytest + model_bakery, `django.test.Client`. + +**Zależy od:** faza 04 (guardy/PROTECT + `Autor` jest `SoftDeleteModel`), faza 06 (`delete(self, *args, user=None, reason="", **kwargs)` / `restore(self, *args, user=None, **kwargs)`, `SoftDeleteLog`, thread-local `set_soft_delete_user`/`get_soft_delete_user`). + +**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) §6. Indeks: [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md). + +--- + +## Reguły BPP (obowiązują w każdym kroku) + +- Python wyłącznie przez `uv run` (`uv run pytest ...`). NIGDY gołe `python`/`pytest`. +- Max długość linii **88** znaków (ruff). Po implementacji `ruff format .` + `ruff check .` (ręcznie fixować, NIE `--fix`). +- Komentarze/komunikaty po polsku. +- Admin templates: **emoji**, NIE Foundation-Icons. (Etykiety akcji: „🗑️ Usuń do kosza", „♻️ Przywróć", „❌ Usuń trwale".) +- NIE modyfikować istniejących plików migracji. +- Komentarze Django `{# #}` — każda linia własne `{# ... #}`. + +--- + +## Kontrakty z fazą 06 (PINNED — używaj VERBATIM) + +Faza 06 dostarcza (zakładamy, że istnieją; jeśli nazwa się różni — to bug fazy 06, NIE zmieniaj go tutaj, zgłoś): + +```python +# src/bpp/models/soft_delete.py (thread-local hook usera, faza 06) +def set_soft_delete_user(user): + """Ustawia użytkownika dla bieżącego wątku; czytany przez receivery + sygnałów post_soft_delete/post_restore/post_hard_delete (faza 06).""" + +def get_soft_delete_user(): + """Zwraca usera ustawionego przez set_soft_delete_user lub None.""" + +def clear_soft_delete_user(): + """Czyści thread-local (wołać w finally).""" +``` + +Sygnatury modeli (faza 06, na 5 publikacjach + `Autor`): +```python +def delete(self, *args, user=None, reason="", **kwargs): ... +def restore(self, *args, user=None, **kwargs): ... +def hard_delete(self, *args, user=None, reason="", **kwargs): ... +``` + +`SoftDeleteLog` (`src/bpp/models/soft_delete_log.py`) — pole `powod` (TextField), `user` (FK), zasilane przez receivery sygnałów. Admin **nie zapisuje** `SoftDeleteLog` bezpośrednio — tylko przekazuje `user`/`reason` do `model.delete(...)`, receiver robi resztę. + +> **Jeden hook usera (kontrakt reversion #2).** Punkt wstrzyknięcia `request.user` +> to JEDNA metoda `BppSoftDeleteAdminMixin._soft_delete_user_context(request)` +> (context manager owijający thread-local). `delete_model`, `delete_queryset`, +> `przywroc_zaznaczone`, `usun_trwale_zaznaczone` — WSZYSTKIE wołają ten sam +> punkt. Reversion (odłożone) doczepi tu w przyszłości `reversion.set_user`. +> SZEW: w `_soft_delete_user_context` zostaw komentarz `# SZEW reversion`. + +> **Świadomość recover (kontrakt reversion #3).** Reversion „recover deleted" +> (odłożone) wskrzeszałby rekord poza przepływem soft-delete (bez `WYSYLKA`, +> bez `SoftDeleteLog`, łamiąc warunkowy unique `slug`). SZEW: w mixinie +> `get_urls()` zostaw komentarz, że recover-URL reversion ma być tu w +> przyszłości ukryty/przekierowany na `restore()`. + +--- + +## Stan zastany (zweryfikowany w kodzie — nie zgaduj) + +- **Wszystkie 5 adminów publikacji dziedziczą finalnie po `admin.ModelAdmin`:** + - `Wydawnictwo_CiagleAdmin` (`src/bpp/admin/wydawnictwo_ciagle.py:262`) — wprost `..., RestrictDeletionWhenPBNUIDSetMixin, admin.ModelAdmin`. + - `Wydawnictwo_ZwarteAdmin` (`wydawnictwo_zwarte.py:438`) → `Wydawnictwo_ZwarteAdmin_Baza` (`:76` `BaseBppAdminMixin, admin.ModelAdmin`). + - `Patent_Admin` (`patent.py:83`) → `Wydawnictwo_ZwarteAdmin_Baza`. + - `Praca_DoktorskaAdmin` (`praca_doktorska.py:197`) → `Praca_Doktorska_Habilitacyjna_Admin_Base` (`:57` `AdnotacjeZDatamiMixin, BaseBppAdminMixin, admin.ModelAdmin`). + - `Praca_HabilitacyjnaAdmin` (`praca_habilitacyjna.py:172`) → ten sam base. + - `AutorAdmin` (`autor.py:192`) — wprost `..., BaseBppAdminMixin, DynamicColumnsMixin, admin.ModelAdmin`. +- **`RestrictDeletionWhenPBNUIDSetMixin`** (`helpers/mixins.py:87`) nadpisuje `has_delete_permission`: zwraca `False` gdy `obj.pbn_uid_id is not None`. Jest na `Wydawnictwo_Ciagle/Zwarte`. **UWAGA MRO:** nasz mixin musi stać PRZED nim, ale `has_delete_permission` ma wołać `super()` — to znaczy, że dla rekordu z PBN superuser nadal nie usunie (zgodne ze spec — soft-delete z `pbn_uid` to osobny, wrażliwy przypadek; obrona PBN zostaje). Soft-delete rekordu z `pbn_uid` realizujemy mimo to, bo `has_soft_delete_permission` (nasza) jest niezależna od `has_delete_permission`. Patrz Task 4 — rozdzielamy uprawnienia. +- **Pakiet `django_softdelete.admin`** (`.venv/.../django_softdelete/admin.py`): `GlobalObjectsModelAdmin.get_queryset` → `global_objects`; `SoftDeleteFilter` (param `is_deleted`, lookupy `true`/`false`). Akcje pakietu (`soft_delete_selected`, `hard_delete_selected`, `restore_selected`) wołają `obj.delete()`/`queryset.restore()` **bez `user=`** → NIE używamy ich (łamią jeden-hook). `SoftDeleteFilter.queryset` filtruje po `deleted_at__isnull`. +- **Fixtures (`src/conftest.py`):** `superuser` (`:172`, `create_superuser`, login `user`/`foo`), `superuser_client` (`:191`), `test_user` (`:162`, zwykły user — **NIE staff**), `client` (pytest-django). Brak gotowego „staff-not-superuser" — **dodajemy** w Task 8. +- **Wzorce superuser-only:** `oplaty_log.py:58-65` (`has_*_permission` → `False`), `__init__.py:265` (`has_delete_permission` z logiką), `uczelnia.py:31`. + +--- + +## File Structure + +**Modyfikowane:** +- `src/bpp/admin/helpers/mixins.py` — NOWY `BppSoftDeleteAdminMixin` + formularz `PowodSoftDeleteForm` (intermediate page). +- `src/bpp/admin/wydawnictwo_ciagle.py:262` — wpięcie mixinu w `Wydawnictwo_CiagleAdmin`. +- `src/bpp/admin/wydawnictwo_zwarte.py:438` — wpięcie w `Wydawnictwo_ZwarteAdmin`. +- `src/bpp/admin/patent.py:83` — wpięcie w `Patent_Admin`. +- `src/bpp/admin/praca_doktorska.py:197` — wpięcie w `Praca_DoktorskaAdmin`. +- `src/bpp/admin/praca_habilitacyjna.py:172` — wpięcie w `Praca_HabilitacyjnaAdmin`. +- `src/bpp/admin/autor.py:192` — wpięcie w `AutorAdmin`. + +**Tworzone:** +- `templates/admin/bpp/soft_delete_powod.html` — intermediate page „podaj powód" (emoji). +- `src/bpp/tests/test_admin_soft_delete.py` — testy fazy. +- (Task 8) fixture `staff_user` / `staff_client` w `src/conftest.py` jeśli nie istnieje. + +> **Decyzja: jeden mixin, sześć adminów.** Mixin nie zna konkretnego modelu — +> używa `self.model.global_objects` / `self.model.deleted_objects`. Działa dla +> publikacji i `Autor` identycznie. `Autor.delete()` z guardem (faza 04) rzuca +> `ProtectedError` gdy ma prace → mixin łapie i pokazuje komunikat (Task 7). + +--- + +## Task 1: Mixin szkielet + `get_queryset` → `global_objects` + filtr „Pokaż skasowane" + +**Files:** +- Modify: `src/bpp/admin/helpers/mixins.py` (dopisz na końcu). +- Test: `src/bpp/tests/test_admin_soft_delete.py` (utwórz). + +- [ ] **Step 1: Write the failing test** + +```python +# src/bpp/tests/test_admin_soft_delete.py +import pytest +from django.urls import reverse +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle + + +@pytest.mark.django_db +def test_changelist_pokazuje_nieskasowane_domyslnie(superuser_client): + żywy = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Żywa praca") + skasowany = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Praca w koszu") + skasowany.delete(user=None, reason="test") + + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + resp = superuser_client.get(url) + content = resp.content.decode("utf-8") + + assert resp.status_code == 200 + assert "Żywa praca" in content + # global_objects pozwala otworzyć skasowany rekord po ID, ale changelist + # domyślnie filtruje (SoftDeleteFilter default = nieskasowane): + assert "Praca w koszu" not in content + assert żywy.pk is not None + + +@pytest.mark.django_db +def test_filtr_pokaz_skasowane_pokazuje_kosz(superuser_client): + baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Żywa praca") + skasowany = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Praca w koszu") + skasowany.delete(user=None, reason="test") + + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + resp = superuser_client.get(url, {"is_deleted": "true"}) + content = resp.content.decode("utf-8") + + assert resp.status_code == 200 + assert "Praca w koszu" in content + + +@pytest.mark.django_db +def test_changeform_otwiera_skasowany_rekord(superuser_client): + skasowany = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Praca w koszu") + skasowany.delete(user=None, reason="test") + + url = reverse( + "admin:bpp_wydawnictwo_ciagle_change", args=[skasowany.pk] + ) + resp = superuser_client.get(url) + # get_queryset = global_objects → da się otworzyć skasowany rekord: + assert resp.status_code == 200 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py -v` +Expected: FAIL — `test_filtr_pokaz_skasowane_pokazuje_kosz` i `test_changeform_otwiera_skasowany_rekord` padają (domyślny `objects` ukrywa skasowane, brak filtra `is_deleted`). + +- [ ] **Step 3: Write minimal implementation — mixin szkielet w `helpers/mixins.py`** + +Dopisz na końcu `src/bpp/admin/helpers/mixins.py`: + +```python +from django.contrib import admin, messages # noqa: E402 (na górze pliku) +from django.db.models import ProtectedError # noqa: E402 +from django_softdelete.filters import SoftDeleteFilter # noqa: E402 + + +class BppSoftDeleteAdminMixin: + """Admin superuser-only dla modeli SoftDeleteModel (5 publikacji + Autor). + + Zapewnia: + - get_queryset -> global_objects (otwieranie/przywracanie skasowanych), + - filtr "Pokaż skasowane" (SoftDeleteFilter, param is_deleted), + - JEDEN hook usera (_soft_delete_user_context) dla delete/restore/hard, + - akcje "Przywróć" i "Usuń trwale" (ta druga superuser-only). + + Komponuj PRZED istniejącymi klasami admina (przed admin.ModelAdmin). + """ + + def get_queryset(self, request): + # global_objects: zawiera skasowane, żeby dało się je otworzyć + # i przywrócić. SoftDeleteFilter (default) i tak ukrywa kosz na + # liście, póki użytkownik nie wybierze "Pokaż skasowane". + qs = self.model.global_objects.get_queryset() + ordering = self.get_ordering(request) + if ordering: + qs = qs.order_by(*ordering) + return qs + + def get_list_filter(self, request): + list_filter = super().get_list_filter(request) or [] + list_filter = list(list_filter) + if SoftDeleteFilter not in list_filter: + list_filter = [SoftDeleteFilter] + list_filter + return list_filter +``` + +> Uwaga: `SoftDeleteFilter.queryset` traktuje brak parametru (`None`) jak +> `'all'`... a faktycznie zwraca `'ALL'` tylko dla `'all'`; dla `None` mapuje +> na `'all'` → `'ALL'` → zwraca cały queryset. To znaczy: **bez parametru +> pokazuje wszystko** (żywe + kosz). Spec wymaga „domyślnie ukryj kosz". +> Dlatego w Step 5 nadpisujemy zachowanie własnym filtrem (Task 1b). + +- [ ] **Step 4: Run partial — sprawdź changeform i filtr** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_changeform_otwiera_skasowany_rekord -v` +Expected: PASS (po dodaniu `get_queryset` — ale dopiero gdy mixin wpięty; jeśli jeszcze nie wpięty do `Wydawnictwo_CiagleAdmin`, to nadal FAIL — wpinamy w Task 4). Tymczasowo: dopnij mixin do `Wydawnictwo_CiagleAdmin` na czas tego testu **albo** wykonaj Task 4 przed re-runem. **Decyzja porządkująca:** wpięcie do `Wydawnictwo_CiagleAdmin` robimy już teraz minimalnie (jedna linia + import), pełne 6 adminów w Task 4. + +Minimalne wpięcie teraz — `src/bpp/admin/wydawnictwo_ciagle.py`: +```python +from .helpers.mixins import ( # dołącz do istniejącego importu z .helpers.mixins + BppSoftDeleteAdminMixin, + OptionalPBNSaveMixin, + RestrictDeletionWhenPBNUIDSetMixin, +) +``` +```python +class Wydawnictwo_CiagleAdmin( + BppSoftDeleteAdminMixin, # <-- PIERWSZY + ConstanceScoringFieldsMixin, + # ... reszta bez zmian ... + RestrictDeletionWhenPBNUIDSetMixin, + admin.ModelAdmin, +): +``` + +- [ ] **Step 5: Własny filtr „Pokaż skasowane" z domyślnym ukrywaniem kosza (Task 1b)** + +Dopisz w `helpers/mixins.py` PRZED `BppSoftDeleteAdminMixin` i podmień w `get_list_filter`: + +```python +class PokazSkasowaneFilter(SoftDeleteFilter): + """Jak SoftDeleteFilter, ale DOMYŚLNIE (brak parametru) ukrywa kosz. + + Pakietowy SoftDeleteFilter bez parametru pokazuje wszystko; spec wymaga, + by changelist domyślnie pokazywał tylko żywe rekordy. + """ + + title = "Stan (kosz)" + + def lookups(self, request, model_admin): + return ( + ("false", "🗑️ Tylko skasowane"), + ("all", "Wszystkie (z koszem)"), + ) + + def queryset(self, request, queryset): + value = self.value() + if value is None: + # Domyślnie: tylko żywe. + return queryset.filter(deleted_at__isnull=True) + if value == "all": + return queryset + if value == "false": + # Etykieta "Tylko skasowane" -> deleted_at NOT NULL. + return queryset.filter(deleted_at__isnull=False) + return queryset +``` + +> **Uwaga na semantykę pakietu:** w pakietowym `SoftDeleteFilter` lookup +> `'true'`→"Deleted Softly" mapuje przez `{'true': False}` na +> `deleted_at__isnull=False`. Mylące. Dlatego pełnym własnym filtrem +> `PokazSkasowaneFilter` (powyżej) jawnie sterujemy: `value="false"` w naszym +> filtrze = pokaż kosz. Test używa `is_deleted=true`? — NIE. Poprawiamy test +> w Step 6, żeby używał naszego kontraktu. + +Podmień w `BppSoftDeleteAdminMixin.get_list_filter`: `SoftDeleteFilter` → `PokazSkasowaneFilter`. + +- [ ] **Step 6: Popraw test filtra na nasz kontrakt parametru** + +W `test_filtr_pokaz_skasowane_pokazuje_kosz` zamień `{"is_deleted": "true"}` na `{"is_deleted": "false"}` (etykieta „🗑️ Tylko skasowane"). Param `is_deleted` zachowany (dziedziczony `parameter_name`). + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py -v` +Expected: PASS (3 testy). + +- [ ] **Step 8: Lint** + +Run: `uv run ruff format src/bpp/admin/helpers/mixins.py src/bpp/admin/wydawnictwo_ciagle.py src/bpp/tests/test_admin_soft_delete.py && uv run ruff check src/bpp/admin/helpers/mixins.py src/bpp/admin/wydawnictwo_ciagle.py src/bpp/tests/test_admin_soft_delete.py` +Expected: brak błędów (przenieś importy z `noqa: E402` na górę pliku `mixins.py`; usuń `noqa` po przeniesieniu). + +- [ ] **Step 9: Commit** + +```bash +git add src/bpp/admin/helpers/mixins.py src/bpp/admin/wydawnictwo_ciagle.py \ + src/bpp/tests/test_admin_soft_delete.py +git commit -m "feat(soft-delete): admin mixin get_queryset global_objects + filtr kosza" +``` + +--- + +## Task 2: Jeden hook usera — `_soft_delete_user_context` + `delete_model`/`delete_queryset` = soft-delete + +**Files:** +- Modify: `src/bpp/admin/helpers/mixins.py` (`BppSoftDeleteAdminMixin`). +- Test: `src/bpp/tests/test_admin_soft_delete.py`. + +- [ ] **Step 1: Write the failing test** + +```python +@pytest.mark.django_db +def test_delete_w_adminie_soft_deletuje_i_zapisuje_usera(superuser, superuser_client): + from bpp.models import SoftDeleteLog + + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Do kosza") + pk = obj.pk + url = reverse("admin:bpp_wydawnictwo_ciagle_delete", args=[pk]) + + # GET = strona potwierdzenia + resp_get = superuser_client.get(url) + assert resp_get.status_code == 200 + + # POST = wykonaj soft-delete (Django delete confirmation: post=yes) + resp = superuser_client.post(url, {"post": "yes"}) + assert resp.status_code == 302 + + # Zniknął z objects, jest w global_objects z deleted_at: + assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + g = Wydawnictwo_Ciagle.global_objects.get(pk=pk) + assert g.deleted_at is not None + + # SoftDeleteLog (faza 06 receiver) ma usera = superuser: + log = SoftDeleteLog.objects.filter(object_id=pk).latest("timestamp") + assert log.user_id == superuser.pk + assert log.akcja == "delete" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_delete_w_adminie_soft_deletuje_i_zapisuje_usera -v` +Expected: FAIL — domyślny `delete_model` robi hard-delete (rekord znika z `global_objects`) i `log.user_id` to `None` (brak hooka). + +- [ ] **Step 3: Write implementation — hook usera + delete_model/delete_queryset** + +Dopisz import na górze `helpers/mixins.py`: +```python +from contextlib import contextmanager + +from bpp.models.soft_delete import ( + clear_soft_delete_user, + set_soft_delete_user, +) +``` + +W `BppSoftDeleteAdminMixin`: +```python + @contextmanager + def _soft_delete_user_context(self, request): + """JEDEN punkt wstrzyknięcia request.user dla całego przepływu + soft-delete/restore/hard-delete w adminie. + + Ustawia thread-local (faza 06) czytany przez receivery sygnałów, + które zapisują SoftDeleteLog.user. To samo miejsce w przyszłości + zasili reversion.set_user. + """ + set_soft_delete_user(request.user) + # SZEW reversion: tu w przyszłości reversion.set_user(request.user) + # (django-reversion, odłożone — patrz overview "Kontrakty z reversion"). + try: + yield + finally: + clear_soft_delete_user() + + def _powod_z_requestu(self, request): + """Powód kasowania z intermediate-page (Task 5). Domyślnie pusty.""" + return request.POST.get("powod", "") + + def delete_model(self, request, obj): + # "Usuń" w adminie = soft-delete (kosz), NIE hard-delete. + with self._soft_delete_user_context(request): + obj.delete(user=request.user, reason=self._powod_z_requestu(request)) + + def delete_queryset(self, request, queryset): + # Akcja "delete_selected" przechodzi tędy: per-instancja soft-delete. + with self._soft_delete_user_context(request): + powod = self._powod_z_requestu(request) + for obj in queryset: + obj.delete(user=request.user, reason=powod) +``` + +> **Dlaczego per-instancja w `delete_queryset`?** Kontrakt reversion #1 + +> kaskada `*_Autor` (faza 02) + `SoftDeleteLog` wymagają `post_save`/sygnałów +> per obiekt. `BppSoftDeleteQuerySet.update()` (faza 01) i tak blokuje bulk +> ustawienie `deleted_at`. NIE wołaj `queryset.delete()` zbiorczo bez usera. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_delete_w_adminie_soft_deletuje_i_zapisuje_usera -v` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff format src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +uv run ruff check src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +git add src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +git commit -m "feat(soft-delete): jeden hook usera + delete_model/delete_queryset = kosz" +``` + +--- + +## Task 3: Akcja „Przywróć" (restore) przez ten sam hook usera + +**Files:** +- Modify: `src/bpp/admin/helpers/mixins.py` (`BppSoftDeleteAdminMixin`). +- Test: `src/bpp/tests/test_admin_soft_delete.py`. + +- [ ] **Step 1: Write the failing test** + +```python +@pytest.mark.django_db +def test_akcja_przywroc_dziala_i_zapisuje_usera(superuser, superuser_client): + from bpp.models import SoftDeleteLog + + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Wraca z kosza") + pk = obj.pk + obj.delete(user=None, reason="test") + assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + resp = superuser_client.post( + url, + { + "action": "przywroc_zaznaczone", + "_selected_action": [str(pk)], + }, + ) + assert resp.status_code in (200, 302) + + # Wrócił do objects, deleted_at = NULL: + assert Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + g = Wydawnictwo_Ciagle.global_objects.get(pk=pk) + assert g.deleted_at is None + + log = SoftDeleteLog.objects.filter(object_id=pk, akcja="restore").latest( + "timestamp" + ) + assert log.user_id == superuser.pk +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_akcja_przywroc_dziala_i_zapisuje_usera -v` +Expected: FAIL — akcja `przywroc_zaznaczone` nie istnieje (`'przywroc_zaznaczone' is not a registered action`). + +- [ ] **Step 3: Write implementation — akcja restore** + +W `BppSoftDeleteAdminMixin` dopisz akcję i zarejestruj ją w `get_actions`: + +```python + @admin.action(description="♻️ Przywróć zaznaczone (z kosza)") + def przywroc_zaznaczone(self, request, queryset): + # queryset z global_objects może zawierać też nieskasowane — restore + # nieskasowanego jest no-op po stronie pakietu, więc bezpieczne. + with self._soft_delete_user_context(request): + przywrocono = 0 + for obj in queryset: + if obj.deleted_at is not None: + obj.restore(user=request.user) + przywrocono += 1 + self.message_user( + request, + f"Przywrócono z kosza: {przywrocono}.", + level=messages.SUCCESS, + ) + + def get_actions(self, request): + actions = super().get_actions(request) + actions["przywroc_zaznaczone"] = self.get_action("przywroc_zaznaczone") + return actions +``` + +> `self.get_action(name)` zwraca krotkę `(func, name, description)` wymaganą +> przez Django dla `get_actions`. Działa, bo `przywroc_zaznaczone` jest metodą +> klasy z dekoratorem `@admin.action`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_akcja_przywroc_dziala_i_zapisuje_usera -v` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff format src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +uv run ruff check src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +git add src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +git commit -m "feat(soft-delete): akcja admina Przywróć przez jeden hook usera" +``` + +--- + +## Task 4: Akcja „Usuń trwale" (hard_delete) — TYLKO superuser + +**Files:** +- Modify: `src/bpp/admin/helpers/mixins.py` (`BppSoftDeleteAdminMixin`). +- Test: `src/bpp/tests/test_admin_soft_delete.py`. + +- [ ] **Step 1: Write the failing test (superuser może; staff dostaje odmowę)** + +```python +@pytest.mark.django_db +def test_usun_trwale_dostepne_dla_superusera(superuser, superuser_client): + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Do trwałego usunięcia") + pk = obj.pk + obj.delete(user=None, reason="test") + + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + # Akcja widoczna dla superusera: + resp_list = superuser_client.get(url, {"is_deleted": "false"}) + assert b"usun_trwale_zaznaczone" in resp_list.content + + resp = superuser_client.post( + url, + { + "action": "usun_trwale_zaznaczone", + "_selected_action": [str(pk)], + }, + ) + assert resp.status_code in (200, 302) + # Zniknął z global_objects (hard-delete): + assert not Wydawnictwo_Ciagle.global_objects.filter(pk=pk).exists() + + +@pytest.mark.django_db +def test_usun_trwale_niedostepne_dla_staff(staff_client): + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Próba przez staff") + pk = obj.pk + obj.delete(user=None, reason="test") + + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + # Akcja NIE jest oferowana staffowi: + resp_list = staff_client.get(url, {"is_deleted": "false"}) + assert b"usun_trwale_zaznaczone" not in resp_list.content + + # Nawet wymuszony POST nie usuwa trwale: + resp = staff_client.post( + url, + { + "action": "usun_trwale_zaznaczone", + "_selected_action": [str(pk)], + }, + ) + # Django odrzuca nieznaną/niedozwoloną akcję (brak na liście get_actions): + assert resp.status_code in (200, 302, 403) + assert Wydawnictwo_Ciagle.global_objects.filter(pk=pk).exists() +``` + +> Fixture `staff_client` dodajemy w Task 8 (Step 0 poniżej najpierw upewnij się, +> że istnieje — jeśli nie, Task 8 musi iść PRZED tym testem; w praktyce dodaj +> fixture teraz w `conftest.py`, bo Task 4 go potrzebuje). + +- [ ] **Step 2: Dodaj fixture `staff_user`/`staff_client` (jeśli brak)** + +W `src/conftest.py` (po `superuser_client`, `:196`): + +```python +@pytest.fixture +def staff_user(db): + """Staff (dostęp do admina), ale NIE superuser.""" + u = User.objects.create_user( + username="staff", + password="staffpass", + email="staff@example.com", + ) + u.is_staff = True + u.save() + return u + + +@pytest.fixture +def staff_client(client, staff_user): + """Zalogowany staff (nie-superuser).""" + if not client.login(username="staff", password="staffpass"): + raise Exception("Cannot login staff") + return client +``` + +> Staff bez `is_superuser` i bez uprawnień modelowych nie zobaczy changelisty +> w ogóle (403/redirect). Aby test sprawdzał *akcję* a nie brak dostępu, nadaj +> staffowi uprawnienia do modelu w fixture lub w teście. Dopisz w `staff_user`: +> ```python +> from django.contrib.auth.models import Permission +> u.user_permissions.add( +> *Permission.objects.filter( +> content_type__app_label="bpp", +> content_type__model="wydawnictwo_ciagle", +> ) +> ) +> ``` +> (zapewnia `view/change/delete` na `wydawnictwo_ciagle`, więc changelist się +> renderuje, ale `usun_trwale_zaznaczone` jest superuser-only przez `get_actions`). + +- [ ] **Step 3: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_usun_trwale_dostepne_dla_superusera src/bpp/tests/test_admin_soft_delete.py::test_usun_trwale_niedostepne_dla_staff -v` +Expected: FAIL — akcja `usun_trwale_zaznaczone` nie istnieje. + +- [ ] **Step 4: Write implementation — akcja hard-delete superuser-only** + +W `BppSoftDeleteAdminMixin`: + +```python + @admin.action(description="❌ Usuń TRWALE (nieodwracalnie, tylko superuser)") + def usun_trwale_zaznaczone(self, request, queryset): + if not request.user.is_superuser: + self.message_user( + request, + "Trwałe usuwanie jest dostępne wyłącznie dla superużytkownika.", + level=messages.ERROR, + ) + return + with self._soft_delete_user_context(request): + powod = self._powod_z_requestu(request) + usunieto = 0 + for obj in queryset: + obj.hard_delete(user=request.user, reason=powod) + usunieto += 1 + self.message_user( + request, + f"Usunięto trwale: {usunieto}.", + level=messages.SUCCESS, + ) +``` + +Rozszerz `get_actions` (z Task 3) — dodaj akcję hard-delete TYLKO dla superusera: + +```python + def get_actions(self, request): + actions = super().get_actions(request) + actions["przywroc_zaznaczone"] = self.get_action("przywroc_zaznaczone") + if request.user.is_superuser: + actions["usun_trwale_zaznaczone"] = self.get_action( + "usun_trwale_zaznaczone" + ) + else: + # Staff nie dostaje ani trwałego usuwania, ani domyślnego + # delete_selected (które i tak idzie przez nasz soft-delete). + actions.pop("usun_trwale_zaznaczone", None) + return actions +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py -v` +Expected: PASS (wszystkie dotychczasowe). + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff format src/bpp/admin/helpers/mixins.py src/conftest.py \ + src/bpp/tests/test_admin_soft_delete.py +uv run ruff check src/bpp/admin/helpers/mixins.py src/conftest.py \ + src/bpp/tests/test_admin_soft_delete.py +git add src/bpp/admin/helpers/mixins.py src/conftest.py \ + src/bpp/tests/test_admin_soft_delete.py +git commit -m "feat(soft-delete): akcja Usuń trwale superuser-only + fixture staff" +``` + +--- + +## Task 5: Pole „powód" przy kasowaniu — intermediate page → `SoftDeleteLog.powod` + +**Files:** +- Modify: `src/bpp/admin/helpers/mixins.py` (formularz + nadpisany przepływ delete). +- Create: `templates/admin/bpp/soft_delete_powod.html`. +- Test: `src/bpp/tests/test_admin_soft_delete.py`. + +> **Decyzja UX.** Pojedynczy „Usuń" (changeform delete) i akcja zbiorcza +> „delete_selected" przechodzą przez stronę pośrednią pytającą o powód. Powód +> ląduje w `SoftDeleteLog.powod` (przez `reason=` → receiver fazy 06). Aby nie +> przepisywać całego Django delete-confirmation, dla pojedynczego rekordu +> czytamy `powod` z POST formularza potwierdzenia (Django renderuje własny +> `delete_confirmation.html`). Najprościej: własna akcja `usun_do_kosza` z +> intermediate page (analogicznie do pakietowego wzorca), a domyślne +> `delete_selected`/`delete_model` zostają jako soft-delete bez wymuszonego +> powodu (powód opcjonalny). + +- [ ] **Step 1: Write the failing test** + +```python +@pytest.mark.django_db +def test_akcja_usun_do_kosza_z_powodem_trafia_do_logu(superuser, superuser_client): + from bpp.models import SoftDeleteLog + + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Z powodem") + pk = obj.pk + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + + # Krok 1: wybór akcji bez 'powod_potwierdzony' -> intermediate page: + resp1 = superuser_client.post( + url, + { + "action": "usun_do_kosza", + "_selected_action": [str(pk)], + }, + ) + assert resp1.status_code == 200 + assert b"powod" in resp1.content # formularz z polem powodu + + # Krok 2: potwierdzenie z powodem: + resp2 = superuser_client.post( + url, + { + "action": "usun_do_kosza", + "_selected_action": [str(pk)], + "powod_potwierdzony": "1", + "powod": "Duplikat rekordu", + }, + ) + assert resp2.status_code in (200, 302) + + assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + log = SoftDeleteLog.objects.filter(object_id=pk, akcja="delete").latest( + "timestamp" + ) + assert log.powod == "Duplikat rekordu" + assert log.user_id == superuser.pk +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_akcja_usun_do_kosza_z_powodem_trafia_do_logu -v` +Expected: FAIL — brak akcji `usun_do_kosza` / brak template. + +- [ ] **Step 3: Write template** + +`templates/admin/bpp/soft_delete_powod.html`: + +```django +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} + +{% block content %} +{# Strona pośrednia: podaj powód usunięcia do kosza. #} +

🗑️ Zaznaczone rekordy zostaną przeniesione do kosza (soft-delete).

+

Operacja jest odwracalna (akcja „♻️ Przywróć").

+ +
    + {% for obj in obiekty %} +
  • {{ obj }}
  • + {% endfor %} +
+ +
{% csrf_token %} + {% for pk in wybrane_pk %} + + {% endfor %} + + + +

+
+ +

+ + + Anuluj +
+{% endblock %} +``` + +- [ ] **Step 4: Write implementation — akcja `usun_do_kosza` z intermediate page** + +W `helpers/mixins.py` dodaj import: +```python +from django.contrib.admin import helpers as admin_helpers +from django.template.response import TemplateResponse +``` + +W `BppSoftDeleteAdminMixin`: + +```python + @admin.action(description="🗑️ Usuń do kosza (z powodem)") + def usun_do_kosza(self, request, queryset): + if request.POST.get("powod_potwierdzony"): + with self._soft_delete_user_context(request): + powod = request.POST.get("powod", "") + usunieto = 0 + for obj in queryset: + obj.delete(user=request.user, reason=powod) + usunieto += 1 + self.message_user( + request, + f"Przeniesiono do kosza: {usunieto}.", + level=messages.SUCCESS, + ) + return None + + # Pierwszy krok: strona pośrednia z polem 'powod'. + context = { + **self.admin_site.each_context(request), + "title": "Usuń do kosza", + "obiekty": list(queryset), + "wybrane_pk": [str(o.pk) for o in queryset], + "opts": self.model._meta, + "action_checkbox_name": admin_helpers.ACTION_CHECKBOX_NAME, + } + return TemplateResponse( + request, "admin/bpp/soft_delete_powod.html", context + ) +``` + +Dodaj do `get_actions` (rozszerz istniejące): +```python + actions["usun_do_kosza"] = self.get_action("usun_do_kosza") +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_akcja_usun_do_kosza_z_powodem_trafia_do_logu -v` +Expected: PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff format src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +uv run ruff check src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +git add src/bpp/admin/helpers/mixins.py templates/admin/bpp/soft_delete_powod.html \ + src/bpp/tests/test_admin_soft_delete.py +git commit -m "feat(soft-delete): akcja Usuń do kosza z powodem -> SoftDeleteLog" +``` + +--- + +## Task 6: Wpięcie mixinu do pozostałych 5 adminów (MRO) + recover-szew + +**Files:** +- Modify: `src/bpp/admin/wydawnictwo_zwarte.py:438`, `patent.py:83`, `praca_doktorska.py:197`, `praca_habilitacyjna.py:172`, `autor.py:192`. +- Modify: `src/bpp/admin/helpers/mixins.py` (`get_urls` szew recover). +- Test: `src/bpp/tests/test_admin_soft_delete.py`. + +> **MRO — reguła:** `BppSoftDeleteAdminMixin` jest **PIERWSZY** na liście baz +> każdego admina, żeby jego `get_queryset`/`get_actions`/`delete_model`/ +> `get_list_filter` wygrywały. Wszystkie 6 adminów kończą się na +> `admin.ModelAdmin` (bezpośrednio lub przez `Wydawnictwo_ZwarteAdmin_Baza` / +> `Praca_Doktorska_Habilitacyjna_Admin_Base`), więc `super()` w naszych +> metodach trafia poprawnie w łańcuch i finalnie w `ModelAdmin`. Mixin nie +> definiuje `__init__` ani `Meta`, więc nie psuje istniejących mixinów. + +- [ ] **Step 1: Write the failing test (parametryzowany po 5 pozostałych modelach)** + +```python +import pytest +from django.urls import reverse +from model_bakery import baker + +from bpp.models import ( + Autor, + Patent, + Praca_Doktorska, + Praca_Habilitacyjna, + Wydawnictwo_Zwarte, +) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "model,admin_slug", + [ + (Wydawnictwo_Zwarte, "wydawnictwo_zwarte"), + (Patent, "patent"), + (Praca_Doktorska, "praca_doktorska"), + (Praca_Habilitacyjna, "praca_habilitacyjna"), + (Autor, "autor"), + ], +) +def test_soft_delete_w_adminie_dla_kazdego_modelu( + model, admin_slug, superuser, superuser_client +): + obj = baker.make(model) + pk = obj.pk + obj.delete(user=None, reason="test") + + # Changeform otwiera skasowany (global_objects): + url_change = reverse(f"admin:bpp_{admin_slug}_change", args=[pk]) + assert superuser_client.get(url_change).status_code == 200 + + # Filtr kosza pokazuje skasowany: + url_list = reverse(f"admin:bpp_{admin_slug}_changelist") + resp = superuser_client.get(url_list, {"is_deleted": "false"}) + assert resp.status_code == 200 + + # Restore działa: + resp_r = superuser_client.post( + url_list, + {"action": "przywroc_zaznaczone", "_selected_action": [str(pk)]}, + ) + assert resp_r.status_code in (200, 302) + assert model.objects.filter(pk=pk).exists() +``` + +> `baker.make(Autor)` daje autora **bez prac** → soft-delete dozwolony (guard +> fazy 04 nie blokuje). Test guarda autora-z-pracami jest w Task 7. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest "src/bpp/tests/test_admin_soft_delete.py::test_soft_delete_w_adminie_dla_kazdego_modelu" -v` +Expected: FAIL — pozostałe 5 adminów nie mają mixinu (`change` na skasowanym → 404, brak akcji `przywroc_zaznaczone`). + +- [ ] **Step 3: Wpięcie mixinu — `wydawnictwo_zwarte.py`** + +```python +from .helpers.mixins import ( + BppSoftDeleteAdminMixin, + OptionalPBNSaveMixin, + RestrictDeletionWhenPBNUIDSetMixin, +) +``` +```python +class Wydawnictwo_ZwarteAdmin( + BppSoftDeleteAdminMixin, # <-- PIERWSZY + ConstanceScoringFieldsMixin, + # ... reszta bez zmian ... + RestrictDeletionWhenPBNUIDSetMixin, + Wydawnictwo_ZwarteAdmin_Baza, +): +``` + +- [ ] **Step 4: Wpięcie — `patent.py`** + +```python +from .helpers.mixins import BppSoftDeleteAdminMixin +``` +```python +class Patent_Admin( + BppSoftDeleteAdminMixin, # <-- PIERWSZY + ConstanceScoringFieldsMixin, + AdnotacjeZDatamiMixin, + EksportDanychZFormatowanieMixin, + ExportActionsMixin, + Wydawnictwo_ZwarteAdmin_Baza, +): +``` + +- [ ] **Step 5: Wpięcie — `praca_doktorska.py`** + +```python +from .helpers.mixins import ( + BppSoftDeleteAdminMixin, + DomyslnyStatusKorektyMixin, + Wycinaj_W_z_InformacjiMixin, +) +``` +```python +class Praca_DoktorskaAdmin( + BppSoftDeleteAdminMixin, # <-- PIERWSZY + ConstanceScoringFieldsMixin, + EksportDanychZFormatowanieMixin, + ExportActionsMixin, + Praca_Doktorska_Habilitacyjna_Admin_Base, +): +``` + +- [ ] **Step 6: Wpięcie — `praca_habilitacyjna.py`** + +```python +from .helpers.mixins import ( + BppSoftDeleteAdminMixin, + DomyslnyStatusKorektyMixin, + Wycinaj_W_z_InformacjiMixin, +) +``` +```python +class Praca_HabilitacyjnaAdmin( + BppSoftDeleteAdminMixin, # <-- PIERWSZY + ConstanceScoringFieldsMixin, + EksportDanychZFormatowanieMixin, + ExportActionsMixin, + Praca_Doktorska_Habilitacyjna_Admin_Base, +): +``` + +- [ ] **Step 7: Wpięcie — `autor.py`** + +```python +from .core import BaseBppAdminMixin +from .helpers.mixins import BppSoftDeleteAdminMixin +``` +```python +class AutorAdmin( + BppSoftDeleteAdminMixin, # <-- PIERWSZY + DjangoQLSearchMixin, + ZapiszZAdnotacjaMixin, + EksportDanychMixin, + BaseBppAdminMixin, + DynamicColumnsMixin, + admin.ModelAdmin, +): +``` + +> `AutorAdmin.get_actions` (`autor.py:475`) nadpisuje `get_actions` i zmienia +> opis `delete_selected`. Nasz mixin też nadpisuje `get_actions`. Ponieważ +> mixin jest PIERWSZY, jego `get_actions` woła `super().get_actions()` → trafia +> w `AutorAdmin.get_actions` (które woła swój `super()` itd.). Kolejność OK: +> najpierw zostaje ustawiony opis `delete_selected` (Autor), potem mixin dokłada +> `przywroc_zaznaczone`/`usun_do_kosza`/(superuser) `usun_trwale_zaznaczone`. +> **Zweryfikuj w teście Task 6**, że akcje współistnieją. + +- [ ] **Step 8: Recover-szew w `get_urls`** + +W `BppSoftDeleteAdminMixin` dopisz: +```python + def get_urls(self): + urls = super().get_urls() + # SZEW reversion (odłożone): gdy włączymy django-reversion, jego + # "recover deleted" URL (recover/) musi tu być UKRYTY albo + # przekierowany na restore() — recover wskrzeszałby rekord poza + # przepływem soft-delete (bez WYSYLKA do PBN, bez SoftDeleteLog, + # łamiąc warunkowy unique slug). Patrz overview "Kontrakty z reversion". + return urls +``` + +- [ ] **Step 9: Run test to verify it passes** + +Run: `uv run pytest "src/bpp/tests/test_admin_soft_delete.py::test_soft_delete_w_adminie_dla_kazdego_modelu" -v` +Expected: PASS (5 parametryzacji). + +- [ ] **Step 10: Run full faza-07 suite + lint** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py -v` +Expected: PASS (wszystkie). + +```bash +uv run ruff format src/bpp/admin/wydawnictwo_zwarte.py src/bpp/admin/patent.py \ + src/bpp/admin/praca_doktorska.py src/bpp/admin/praca_habilitacyjna.py \ + src/bpp/admin/autor.py src/bpp/admin/helpers/mixins.py +uv run ruff check src/bpp/admin/wydawnictwo_zwarte.py src/bpp/admin/patent.py \ + src/bpp/admin/praca_doktorska.py src/bpp/admin/praca_habilitacyjna.py \ + src/bpp/admin/autor.py src/bpp/admin/helpers/mixins.py +``` + +- [ ] **Step 11: Commit** + +```bash +git add src/bpp/admin/wydawnictwo_zwarte.py src/bpp/admin/patent.py \ + src/bpp/admin/praca_doktorska.py src/bpp/admin/praca_habilitacyjna.py \ + src/bpp/admin/autor.py src/bpp/admin/helpers/mixins.py \ + src/bpp/tests/test_admin_soft_delete.py +git commit -m "feat(soft-delete): wpięcie BppSoftDeleteAdminMixin do 5 adminów + Autor" +``` + +--- + +## Task 7: Guard `Autor` z pracami — czytelny komunikat zamiast 500 + +**Files:** +- Modify: `src/bpp/admin/helpers/mixins.py` (`delete_queryset` / `usun_do_kosza` / `delete_model` łapią `ProtectedError`). +- Test: `src/bpp/tests/test_admin_soft_delete.py`. + +> Faza 04: `Autor.delete()` rzuca `django.db.models.ProtectedError` (lub +> `ValidationError`) gdy autor ma JAKIEKOLWIEK autorstwo/doktorat/habilitację +> (liczone przez `global_objects`). Admin musi to złapać i pokazać komunikat, +> a NIE wywalić 500. + +- [ ] **Step 1: Write the failing test** + +```python +@pytest.mark.django_db +def test_soft_delete_autora_z_pracami_pokazuje_komunikat(superuser, superuser_client): + from bpp.models import Wydawnictwo_Ciagle_Autor + + autor = baker.make(Autor) + # Autor z autorstwem -> guard fazy 04 zablokuje soft-delete: + baker.make(Wydawnictwo_Ciagle_Autor, autor=autor) + + url = reverse("admin:bpp_autor_changelist") + resp = superuser_client.post( + url, + { + "action": "usun_do_kosza", + "_selected_action": [str(autor.pk)], + "powod_potwierdzony": "1", + "powod": "próba", + }, + follow=True, + ) + assert resp.status_code == 200 + # Autor NIE został skasowany: + assert Autor.objects.filter(pk=autor.pk).exists() + # Komunikat o blokadzie: + content = resp.content.decode("utf-8") + assert "nie można usunąć" in content.lower() or "prac" in content.lower() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_soft_delete_autora_z_pracami_pokazuje_komunikat -v` +Expected: FAIL — `ProtectedError` przepada jako 500 albo wyjątek wycieka. + +- [ ] **Step 3: Write implementation — łap ProtectedError w przepływach kasowania** + +Dodaj helper w `BppSoftDeleteAdminMixin` i użyj go w `delete_model`, `delete_queryset`, `usun_do_kosza`: + +```python + def _soft_delete_jeden(self, request, obj, powod): + """Soft-delete jednego obiektu z obsługą guarda (faza 04). + + Zwraca True przy sukcesie, False gdy guard zablokował (komunikat + pokazany użytkownikowi).""" + try: + obj.delete(user=request.user, reason=powod) + return True + except ProtectedError: + self.message_user( + request, + f"Nie można usunąć „{obj}" — rekord ma powiązane prace " + "(autorstwa / doktorat / habilitacja). Najpierw usuń lub " + "przenieś powiązane prace.", + level=messages.ERROR, + ) + return False +``` + +Podmień ciało pętli w `delete_queryset` i `usun_do_kosza` na: +```python + for obj in queryset: + if self._soft_delete_jeden(request, obj, powod): + usunieto += 1 +``` + +W `delete_model` (pojedynczy „Usuń" z changeform): +```python + def delete_model(self, request, obj): + with self._soft_delete_user_context(request): + self._soft_delete_jeden(request, obj, self._powod_z_requestu(request)) +``` + +> `delete_model` przy zablokowaniu nie kasuje, ale Django i tak zrobi redirect +> z komunikatem błędu — akceptowalne (autor zostaje, error widoczny). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_soft_delete_autora_z_pracami_pokazuje_komunikat -v` +Expected: PASS. + +> Jeśli faza 04 rzuca `ValidationError` zamiast `ProtectedError` — rozszerz +> `except` o `from django.core.exceptions import ValidationError` i łap oba. +> Zweryfikuj realny typ wyjątku z fazy 04 PRZED implementacją (przeczytaj +> `Autor.delete()` w `src/bpp/models/autor.py` po fazie 04). + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff format src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +uv run ruff check src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +git add src/bpp/admin/helpers/mixins.py src/bpp/tests/test_admin_soft_delete.py +git commit -m "feat(soft-delete): admin łapie guard autora-z-pracami (czytelny komunikat)" +``` + +--- + +## Task 8: Test odmowy dla staff na pojedynczym hard-delete + domknięcie suity + +**Files:** +- Test: `src/bpp/tests/test_admin_soft_delete.py`. + +- [ ] **Step 1: Write the test — staff może soft-deletować, ale nie hard** + +```python +@pytest.mark.django_db +def test_staff_moze_soft_delete_ale_nie_widzi_usun_trwale(staff_client): + obj = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Staff soft") + pk = obj.pk + url = reverse("admin:bpp_wydawnictwo_ciagle_changelist") + + # delete_selected (domyślna) przechodzi przez nasz delete_queryset = kosz: + resp = staff_client.post( + url, + {"action": "delete_selected", "_selected_action": [str(pk)], "post": "yes"}, + follow=True, + ) + assert resp.status_code == 200 + # Soft-delete (rekord w global_objects, nie w objects): + assert not Wydawnictwo_Ciagle.objects.filter(pk=pk).exists() + assert Wydawnictwo_Ciagle.global_objects.filter(pk=pk).exists() + + # "Usuń trwale" niedostępne staffowi: + resp_list = staff_client.get(url, {"is_deleted": "false"}) + assert b"usun_trwale_zaznaczone" not in resp_list.content +``` + +> Jeśli staff nie ma uprawnienia `delete_wydawnictwo_ciagle`, `delete_selected` +> nie pojawi się. Fixture `staff_user` (Task 4) nadaje uprawnienia modelowe → +> `delete_selected` dostępne, a idzie przez nasz soft `delete_queryset`. + +- [ ] **Step 2: Run test** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py::test_staff_moze_soft_delete_ale_nie_widzi_usun_trwale -v` +Expected: PASS (cała logika już istnieje z Task 2/4). + +- [ ] **Step 3: Run CAŁĄ suitę fazy + smoke adminów** + +Run: `uv run pytest src/bpp/tests/test_admin_soft_delete.py -v` +Expected: PASS (wszystkie testy fazy 07). + +Smoke regresji adminów publikacji/autora (że MRO nic nie zepsuło): +Run: `uv run pytest src/bpp/tests/ -k "admin" -q` +Expected: PASS (brak regresji w istniejących testach adminowych). + +- [ ] **Step 4: Lint całości fazy** + +Run: `uv run ruff format . && uv run ruff check .` +Expected: brak błędów w plikach fazy. + +- [ ] **Step 5: Commit** + +```bash +git add src/bpp/tests/test_admin_soft_delete.py +git commit -m "test(soft-delete): staff soft-delete OK, hard-delete superuser-only" +``` + +--- + +## Self-Review (wykonaj po napisaniu kodu wszystkich tasków) + +**1. Spec coverage (§6):** +- „Usuń" = soft-delete → Task 2 (`delete_model`/`delete_queryset`) + Task 5 (`usun_do_kosza`). ✅ +- „Usuń trwale" superuser-only → Task 4. ✅ +- Filtr „Pokaż skasowane" → Task 1 (`PokazSkasowaneFilter`). ✅ +- Akcja „Przywróć" → Task 3. ✅ +- Pole „powód" → `SoftDeleteLog` → Task 5. ✅ +- `get_queryset` → `global_objects` → Task 1. ✅ +- Jeden hook usera (`_soft_delete_user_context`) → Task 2, używany przez wszystkie ścieżki. ✅ +- MRO/kompozycja z 6 adminami → Task 1 (Ciagle) + Task 6 (pozostałe). ✅ +- Guard autora z pracami → czytelny komunikat → Task 7. ✅ +- Szwy reversion (jeden hook + recover) → Task 2 (`# SZEW reversion`) + Task 6 (`get_urls`). ✅ + +**2. Placeholder scan:** brak „TODO/TBD"; każdy krok ma realny kod + komendę + oczekiwany wynik. + +**3. Type consistency:** `_soft_delete_user_context` (Task 2), `_soft_delete_jeden` (Task 7), `_powod_z_requestu` (Task 2), `przywroc_zaznaczone`/`usun_trwale_zaznaczone`/`usun_do_kosza` (Task 3/4/5), `PokazSkasowaneFilter` (Task 1) — nazwy spójne we wszystkich taskach. `delete(user=, reason=)`/`restore(user=)`/`hard_delete(user=, reason=)` zgodne z kontraktem fazy 06. + +--- + +## Założenia (zweryfikuj przed startem; jeśli nie zachodzą — to bug wcześniejszej fazy) + +1. **Faza 06 dostarcza** `set_soft_delete_user`/`get_soft_delete_user`/`clear_soft_delete_user` w `src/bpp/models/soft_delete.py` oraz receivery, które na podstawie thread-local zapisują `SoftDeleteLog.user`. Jeśli mechanizm „kto" jest inny (np. argument do sygnału), dostosuj `_soft_delete_user_context` — ale **JEDEN punkt** pozostaje. +2. **Faza 06 modele** mają sygnatury `delete(self, *args, user=None, reason="", **kwargs)`, `restore(self, *args, user=None, **kwargs)`, `hard_delete(self, *args, user=None, reason="", **kwargs)` na 5 publikacjach + `Autor`. `SoftDeleteLog` ma pola `user`, `powod`, `akcja` (wartości `"delete"`/`"restore"`/`"hard_delete"`), `object_id`, `timestamp`. +3. **Faza 04** sprawia, że `Autor.delete()` rzuca `ProtectedError` (lub `ValidationError`) gdy autor ma prace; `Autor` jest `SoftDeleteModel` z `global_objects`/`deleted_objects`. `baker.make(Autor)` daje autora bez prac (soft-delete dozwolony). +4. **Faza 01/02** — 5 publikacji + `Autor` mają `global_objects`/`deleted_objects` (z `BppGlobalManager`), a `objects` ukrywa skasowane. `Wydawnictwo_Ciagle.delete(user=None, reason=...)` działa w teście (kaskada `*_Autor` jest no-op bez autorów). +5. **`request.user` w adminie** to instancja `AUTH_USER_MODEL` — bezpośrednio przekazywalny do `delete(user=...)`. Operacje systemowe (merge/celery) używają `user=None` (poza tą fazą). +6. **`SoftDeleteFilter`/`PokazSkasowaneFilter`** używają `parameter_name = "is_deleted"`; testy używają `is_deleted=false` = „pokaż kosz" (nasz kontrakt, nie pakietu). diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md new file mode 100644 index 000000000..2fadca503 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md @@ -0,0 +1,1179 @@ +# Soft-delete — Faza 08: pełna suita regresji E2E Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Domknąć soft-delete suitą regresji E2E, która udowadnia, że +skasowane publikacje/autorstwa nie wyciekają do cache, ewaluacji, API, +dashboardu, importu ani PBN, a wszystkie przepływy (wycofanie/restore PBN, +merge autorów, guardy PROTECT, log audytu) działają end-to-end. + +**Architecture:** Faza testowa. Zależy od pełnej implementacji faz 01–07 +(`*_Autor` + 5 publikacji jako `SoftDeleteModel`, override +`delete()`/`restore()` z wąską kaskadą, filtr `deleted_at IS NULL` w widokach +źródłowych, audyt kat. B na `global_objects`, guardy PROTECT, operacja +`WYCOFANIE` w `pbn_export_queue`, `SoftDeleteLog` + receivery sygnałów, admin +superusera). Testy używają realnych fixture'ów z `src/conftest.py` / +`src/fixtures/`, `model_bakery.baker.make`, materializowanego cache (`Rekord`, +`Autorzy`) i mocków klienta PBN. Tam, gdzie test ujawnia lukę produkcyjną +(np. dashboard liczący przez surowy SQL omijający menedżer `objects`), +dorzucamy minimalną poprawkę produkcyjną wraz z testem. + +**Tech Stack:** pytest + `model_bakery`, Django, PostgreSQL (triggery +materializujące cache), `django-soft-delete`, `pbn_export_queue` (Celery), +`unittest.mock` dla klienta PBN. + +**Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) +(§8 fazy, §9 ryzyka), indeks [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md). + +--- + +## Uruchamianie suity + +- **Pełna suita** (do ~10 min): `uv run pytest` — wszystko, w tym Playwright. +- **Szybciej, bez przeglądarki**: `make tests-without-playwright`. +- **Sama regresja soft-delete** (te pliki): `uv run pytest -k soft_delete_regresja`. +- Pojedynczy plik: `uv run pytest src//tests/test_soft_delete_regresja.py -v`. +- Wszystkie testy poniżej są `@pytest.mark.django_db` (część wymaga + `transactional_db`, bo dotyka triggerów materializujących cache — fixture + `denorms`/`transactional_db` jak w `src/bpp/tests/test_cache/test_cache.py`). +- Reguły BPP: `uv run pytest`, `baker.make`, linie ≤88 znaków, polskie nazwy + i docstringi, funkcje testowe bez klas, `ruff format .` + `ruff check .` + po każdym tasku. + +--- + +## File Structure + +**Tworzone (pliki testowe):** +- `src/bpp/tests/test_soft_delete/__init__.py` — pakiet testów regresji cache. +- `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py` — + spójność `Rekord`/`Autorzy`/`Cache_Punktacja_*` + `verify_cache`. +- `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py` — + guardy PROTECT (autor z pracami, książka-matka z rozdziałami) + admin. +- `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_log.py` — + `SoftDeleteLog` (delete/restore/hard-delete + user). +- `src/pbn_integrator/tests/test_soft_delete_regresja.py` — PBN: wycofanie + oświadczeń, restore→WYSYLKA, sync/re-import bez duplikatów. +- `src/import_common/tests/test_soft_delete_regresja.py` — re-import matchuje + soft-deletowaną publikację (`global_objects`), nie duplikuje. +- `src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py` — + ewaluacja pomija prace w koszu (pinning/unpinning), restore przywraca. +- `src/deduplikator_autorow/tests/test_soft_delete_regresja.py` — merge + husk-duplikat soft-deletowany i odwracalny; PROTECT nie psuje merge. +- `src/api_v1/tests/test_soft_delete_regresja.py` — skasowane rekordy / + autorstwa nie wyciekają przez API. +- `src/admin_dashboard/tests/test_soft_delete_regresja.py` — liczniki + dashboardu pomijają skasowane. + +**Modyfikowane (drobne poprawki produkcyjne, jeśli test ujawni lukę):** +- `src/admin_dashboard/views/charakter_stats.py` — `_get_charakter_counts` + (jeśli liczy przez surowy SQL/agregację omijającą filtr `deleted_at`). + +--- + +## Task 1: Regresja cache — soft-delete publikacji znika z Rekord/Autorzy + +**Files:** +- Create: `src/bpp/tests/test_soft_delete/__init__.py` +- Create: `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py` + +- [ ] **Step 1: Utwórz pakiet testowy** + +```bash +touch src/bpp/tests/test_soft_delete/__init__.py +``` + +- [ ] **Step 2: Napisz failing test — soft-delete znika z Rekord i Autorzy, restore wraca** + +`src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py`: + +```python +"""Regresja E2E soft-delete: spójność materializowanego cache. + +Skasowana publikacja MUSI zniknąć z ``Rekord``/``Autorzy`` +(mat-view zasilany triggerem), a restore — przywrócić ją wraz z +``*_Autor``. Patrz spec §2.1, §9 (ryzyko cache/trigger). +""" + +import pytest + +from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor +from bpp.models.cache import Autorzy, Rekord + + +@pytest.mark.django_db +def test_soft_delete_publikacji_znika_z_rekord_i_autorzy( + wydawnictwo_ciagle_z_dwoma_autorami, denorms +): + denorms.flush() + assert Rekord.objects.count() == 1 + assert Autorzy.objects.count() == 2 + + wydawnictwo_ciagle_z_dwoma_autorami.delete() + denorms.flush() + + assert Rekord.objects.count() == 0 + assert Autorzy.objects.count() == 0 + + +@pytest.mark.django_db +def test_restore_publikacji_wraca_do_rekord_i_autorzy( + wydawnictwo_ciagle_z_dwoma_autorami, denorms +): + wydawnictwo_ciagle_z_dwoma_autorami.delete() + denorms.flush() + assert Rekord.objects.count() == 0 + + wydawnictwo_ciagle_z_dwoma_autorami.restore() + denorms.flush() + + assert Rekord.objects.count() == 1 + assert Autorzy.objects.count() == 2 + + +@pytest.mark.django_db +def test_kaskada_autor_soft_deletowany_razem_z_publikacja( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """Wąska kaskada §2.2 — *_Autor soft-deletowane razem z rodzicem, + domyślny menedżer ``objects`` je ukrywa, ``global_objects`` widzi.""" + wydawnictwo_ciagle_z_dwoma_autorami.delete() + + assert Wydawnictwo_Ciagle_Autor.objects.count() == 0 + assert Wydawnictwo_Ciagle_Autor.global_objects.count() == 2 + for wca in Wydawnictwo_Ciagle_Autor.global_objects.all(): + assert wca.deleted_at is not None +``` + +- [ ] **Step 3: Uruchom — oczekuj PASS (implementacja faz 01–02 gotowa)** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py -v` +Expected: 3 PASS. Jeśli `test_soft_delete_publikacji_znika_z_rekord_i_autorzy` +FAIL (rekord wraca do mat-view) → **luka w fazie 01**: filtr `deleted_at IS +NULL` nie pokrywa wszystkich gałęzi UNION `bpp_rekord` / `bpp_*_autorzy`. +Zadanie naprawcze: dopisz brakujący `WHERE deleted_at IS NULL` w +`src/bpp/migrations/0XXX_soft_delete_views.sql` (NOWA migracja, nie modyfikuj +istniejących) i ponów. + +- [ ] **Step 4: Commit** + +```bash +git add src/bpp/tests/test_soft_delete/ +git commit -m "test(soft-delete): regresja spójności cache Rekord/Autorzy" +``` + +--- + +## Task 2: Regresja cache — Cache_Punktacja_* + verify_cache czysty + +**Files:** +- Modify: `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py` + +- [ ] **Step 1: Sprawdź realny mechanizm weryfikacji cache** + +Run: `uv run python src/manage.py help | grep -iE "cache|rebuild|refresh"` +Run: `uv run python src/manage.py verify_cache --help 2>&1 | head -5` +Oczekiwane: ustal, czy `verify_cache` jest sprawny. **Znana luka:** +`src/bpp/management/commands/verify_cache.py` to dziś stub +(`raise NotImplementedError`, twarde `psycopg2.connect(database="b_med", +host="linux-dev")`) — NIE da się go uruchomić w teście. Weryfikację spójności +robimy przez `Rekord.objects.full_refresh()` (`src/bpp/models/cache/rekord.py:117`), +która jest realnym, testowalnym odpowiednikiem „re-projekcji ze źródła" +opisanym w spec §2.1. (Patrz „Luki wykryte" na końcu — `verify_cache` należy +naprawić osobnym zadaniem, poza zakresem soft-delete.) + +- [ ] **Step 2: Napisz failing test — full_refresh nie wskrzesza skasowanych + Cache_Punktacja_* znika** + +Dopisz do `test_soft_delete_regresja_cache.py`: + +```python +from bpp.models.cache.punktacja import Cache_Punktacja_Dyscypliny + + +@pytest.mark.django_db +def test_full_refresh_nie_wskrzesza_skasowanej_publikacji( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """Re-projekcja ze źródła (full_refresh) respektuje deleted_at — + inaczej skasowany rekord wróciłby do bpp_rekord_mat (spec §2.1).""" + wydawnictwo_ciagle_z_dwoma_autorami.delete() + assert Rekord.objects.count() == 0 + + Rekord.objects.full_refresh() + + assert Rekord.objects.count() == 0 + assert Autorzy.objects.count() == 0 + + +@pytest.mark.django_db +def test_soft_delete_usuwa_cache_punktacji_dyscyplin(zwarte_z_dyscyplinami): + """Punktacja dyscyplin skasowanej pracy znika; restore ją przywraca.""" + zwarte_z_dyscyplinami.przelicz_punkty_dyscyplin() + ct_pks = list( + Cache_Punktacja_Dyscypliny.objects.filter( + rekord_id=[zwarte_z_dyscyplinami.content_type_id, zwarte_z_dyscyplinami.pk] + ).values_list("pk", flat=True) + ) + assert len(ct_pks) > 0 + + zwarte_z_dyscyplinami.delete() + + assert ( + Cache_Punktacja_Dyscypliny.objects.filter(pk__in=ct_pks).count() == 0 + ) + + zwarte_z_dyscyplinami.restore() + zwarte_z_dyscyplinami.przelicz_punkty_dyscyplin() + + assert ( + Cache_Punktacja_Dyscypliny.objects.filter( + rekord_id=[ + zwarte_z_dyscyplinami.content_type_id, + zwarte_z_dyscyplinami.pk, + ] + ).count() + > 0 + ) +``` + +- [ ] **Step 3: Uruchom — oczekuj PASS** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py -k "full_refresh or cache_punktacji" -v` +Expected: PASS. Jeśli `Cache_Punktacja_Dyscypliny` wraca po delete → +sprawdź, czy override `delete()` (faza 02) czyści punktację dyscyplin albo +czy trigger usuwa wpisy `Cache_Punktacja_*` na podstawie `deleted_at`. +Jeśli pole `rekord_id` w `Cache_Punktacja_Dyscypliny` ma inną strukturę niż +`[content_type_id, pk]`, dostosuj filtr do realnego schematu modelu +(`src/bpp/models/cache/punktacja.py:18`) — sprawdź `uv run python +src/manage.py shell -c "from bpp.models.cache.punktacja import +Cache_Punktacja_Dyscypliny as C; print(C._meta.get_field('rekord_id'))"`. + +- [ ] **Step 4: Commit** + +```bash +git add src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py +git commit -m "test(soft-delete): regresja Cache_Punktacja + full_refresh" +``` + +--- + +## Task 3: Regresja guardy PROTECT — autor z pracami i książka-matka z rozdziałami + +**Files:** +- Create: `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py` + +- [ ] **Step 1: Napisz failing test — guard autora z pracami (soft + hard)** + +```python +"""Regresja guardów PROTECT (spec §3, §2.6). + +Autor z jakąkolwiek pracą oraz książka-matka z rozdziałami NIE mogą być +soft- ani hard-deletowane. Guard liczy przez ``global_objects`` (widzi też +kaskadowo-skasowane autorstwa) — patrz §3.2 i ryzyko w §9. +""" + +import pytest +from django.db.models import ProtectedError + +from bpp.models import ( + Autor, + Wydawnictwo_Zwarte, + Wydawnictwo_Zwarte_Autor, +) + + +@pytest.mark.django_db +def test_soft_delete_autora_z_pracami_odmowa(wydawnictwo_ciagle_z_dwoma_autorami): + autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor + + with pytest.raises(ProtectedError): + autor.delete() + + assert Autor.objects.filter(pk=autor.pk).exists() + assert autor.deleted_at is None + + +@pytest.mark.django_db +def test_guard_autora_widzi_kaskadowo_skasowane_autorstwa( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """Krytyczne §3.2: po soft-delete publikacji autorstwa są ukryte + w ``objects``, ale guard liczy przez ``global_objects`` — autor nadal + chroniony, NIE wygląda na pustego.""" + autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor + wydawnictwo_ciagle_z_dwoma_autorami.delete() + + with pytest.raises(ProtectedError): + autor.delete() + + assert Autor.objects.filter(pk=autor.pk).exists() +``` + +- [ ] **Step 2: Uruchom — oczekuj PASS** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py -v` +Expected: PASS. Jeśli `test_guard_autora_widzi_kaskadowo_skasowane_autorstwa` +FAIL (autor da się skasować) → **luka w fazie 04**: guard używa `objects` +zamiast `global_objects`. To dokładnie ryzyko z §9. Zadanie naprawcze: w +`Autor.delete()` (guard) policz autorstwa przez `*_Autor.global_objects`. + +- [ ] **Step 3: Napisz failing test — soft-delete autora-husku (bez prac) działa i jest odwracalny** + +```python +@pytest.mark.django_db +def test_soft_delete_autora_husku_dziala_i_jest_odwracalny(): + husk = baker_autor_bez_prac() + + husk.delete() + assert Autor.objects.filter(pk=husk.pk).count() == 0 + assert Autor.global_objects.filter(pk=husk.pk).count() == 1 + + husk.restore() + assert Autor.objects.filter(pk=husk.pk).count() == 1 + + +def baker_autor_bez_prac(): + from model_bakery import baker + + return baker.make(Autor, nazwisko="Husk", imiona="Pusty") +``` + +- [ ] **Step 4: Napisz failing test — książka-matka z rozdziałami chroniona** + +```python +@pytest.mark.django_db +def test_soft_delete_ksiazki_matki_z_rozdzialami_odmowa(jednostka): + from model_bakery import baker + + matka = baker.make(Wydawnictwo_Zwarte, tytul_oryginalny="Matka") + baker.make( + Wydawnictwo_Zwarte, + tytul_oryginalny="Rozdzial", + wydawnictwo_nadrzedne=matka, + ) + + with pytest.raises(ProtectedError): + matka.delete() + + assert Wydawnictwo_Zwarte.objects.filter(pk=matka.pk).exists() + assert matka.deleted_at is None +``` + +- [ ] **Step 5: Uruchom całość pliku — oczekuj PASS** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py -v` +Expected: PASS (4 testy). Jeśli książka-matka się kasuje → luka w fazie 04 +(guard `wydawnictwo_nadrzedne` liczony przez `global_objects`, §2.6). + +- [ ] **Step 6: Napisz test — guard przez admina (próba kasowania autora z pracami)** + +Dopisz do pliku: + +```python +from django.urls import reverse + + +@pytest.mark.django_db +def test_admin_nie_soft_deletuje_autora_z_pracami( + superuser_client, wydawnictwo_ciagle_z_dwoma_autorami +): + autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor + url = reverse("admin:bpp_autor_delete", args=(autor.pk,)) + + resp = superuser_client.post(url, {"post": "yes"}) + + # Admin nie kasuje (guard) — rekord nadal żywy, nie soft-deletowany. + assert Autor.objects.filter(pk=autor.pk).exists() + assert Autor.objects.get(pk=autor.pk).deleted_at is None + assert resp.status_code in (200, 302) +``` + +- [ ] **Step 7: Uruchom — oczekuj PASS** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py -k admin -v` +Expected: PASS. Jeśli admin twardo kasuje autora z pracami → luka w fazie 07 +(admin musi respektować guard z fazy 04 w `delete_model`/`delete_queryset`). + +- [ ] **Step 8: Commit** + +```bash +git add src/bpp/tests/test_soft_delete/test_soft_delete_regresja_guardy.py +git commit -m "test(soft-delete): regresja guardów PROTECT (autor, ksiazka-matka, admin)" +``` + +--- + +## Task 4: Regresja SoftDeleteLog — delete/restore/hard-delete z userem + +**Files:** +- Create: `src/bpp/tests/test_soft_delete/test_soft_delete_regresja_log.py` + +- [ ] **Step 1: Napisz failing test — delete loguje DELETE z userem, restore RESTORE, hard-delete HARD_DELETE** + +```python +"""Regresja SoftDeleteLog (spec §5). + +Każde zdarzenie soft-delete / restore / hard-delete jest logowane przez +receiver sygnału z odpowiednią akcją; user wstrzykiwany z warstwy admina +(operacje systemowe → ``user=None``). +""" + +import pytest +from django.contrib.contenttypes.models import ContentType +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle +from bpp.models.soft_delete_log import SoftDeleteLog + + +def _logi_dla(obj, akcja): + return SoftDeleteLog.objects.filter( + content_type=ContentType.objects.get_for_model(type(obj)), + object_id=obj.pk, + akcja=akcja, + ) + + +@pytest.mark.django_db +def test_soft_delete_loguje_delete(admin_user): + wc = baker.make(Wydawnictwo_Ciagle) + + wc.delete(user=admin_user, reason="testowy powod") + + log = _logi_dla(wc, SoftDeleteLog.Akcja.DELETE).get() + assert log.user == admin_user + assert log.powod == "testowy powod" + + +@pytest.mark.django_db +def test_restore_loguje_restore(admin_user): + wc = baker.make(Wydawnictwo_Ciagle) + wc.delete(user=admin_user) + + wc.restore(user=admin_user) + + assert _logi_dla(wc, SoftDeleteLog.Akcja.RESTORE).exists() + + +@pytest.mark.django_db +def test_hard_delete_loguje_hard_delete(): + wc = baker.make(Wydawnictwo_Ciagle) + pk = wc.pk + + wc.hard_delete() + + ct = ContentType.objects.get_for_model(Wydawnictwo_Ciagle) + assert SoftDeleteLog.objects.filter( + content_type=ct, object_id=pk, akcja=SoftDeleteLog.Akcja.HARD_DELETE + ).exists() + + +@pytest.mark.django_db +def test_operacja_systemowa_loguje_user_none(): + wc = baker.make(Wydawnictwo_Ciagle) + + wc.delete() + + log = _logi_dla(wc, SoftDeleteLog.Akcja.DELETE).get() + assert log.user is None +``` + +- [ ] **Step 2: Uruchom — oczekuj PASS** + +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_log.py -v` +Expected: 4 PASS. Jeśli `SoftDeleteLog.Akcja` ma inne nazwy enuma niż +`DELETE/RESTORE/HARD_DELETE` — dostosuj do realnego modelu z fazy 06 +(`src/bpp/models/soft_delete_log.py`; PINNED w overview: `DELETE="delete"`, +`RESTORE="restore"`, `HARD_DELETE="hard_delete"`). Jeśli sygnatura +`delete(user=..., reason=...)` nie istnieje → luka w fazie 06 (wstrzykiwanie +usera, PINNED kontrakt). + +- [ ] **Step 3: Commit** + +```bash +git add src/bpp/tests/test_soft_delete/test_soft_delete_regresja_log.py +git commit -m "test(soft-delete): regresja SoftDeleteLog (delete/restore/hard + user)" +``` + +--- + +## Task 5: Regresja PBN — wycofanie oświadczeń, restore→WYSYLKA, brak duplikatów + +**Files:** +- Create: `src/pbn_integrator/tests/test_soft_delete_regresja.py` + +- [ ] **Step 1: Ustal nazwy operacji i fixture publikacji z pbn_uid** + +Run: `uv run python src/manage.py shell -c "from pbn_export_queue.models import PBN_Export_Queue as Q; print([f.name for f in Q._meta.fields]); from pbn_export_queue.models import Operacja; print(list(Operacja))"` +Expected: pole `operacja` z `Operacja.WYSYLKA`/`Operacja.WYCOFANIE` (faza 05). +Fixture `pbn_wydawnictwo_ciagle_z_autorem_z_dyscyplina` (z `fixtures.pbn_api`, +użyty w `src/pbn_integrator/tests/test_statements.py`) daje publikację, której +można nadać `pbn_uid`. + +- [ ] **Step 2: Napisz failing test — soft-delete publikacji z pbn_uid kolejkuje WYCOFANIE** + +```python +"""Regresja PBN dla soft-delete (spec §4). + +Soft-delete publikacji z ``pbn_uid`` → wpis ``WYCOFANIE`` w +``pbn_export_queue`` wołający ``delete_all_publication_statements``; restore +→ ``WYSYLKA``; sync/re-import po soft-delete NIE tworzy duplikatów +(krytyczne, §9). +""" + +from unittest.mock import MagicMock + +import pytest +from model_bakery import baker + +from pbn_api.models import Publication +from pbn_export_queue.models import Operacja, PBN_Export_Queue + + +@pytest.mark.django_db +def test_soft_delete_z_pbn_uid_kolejkuje_wycofanie(wydawnictwo_ciagle, admin_user): + wydawnictwo_ciagle.pbn_uid = baker.make(Publication, mongoId="pub-wycofanie") + wydawnictwo_ciagle.save() + + wydawnictwo_ciagle.delete(user=admin_user) + + assert PBN_Export_Queue.objects.filter( + operacja=Operacja.WYCOFANIE + ).count() == 1 + + +@pytest.mark.django_db +def test_soft_delete_bez_pbn_uid_nie_kolejkuje_nic(wydawnictwo_ciagle, admin_user): + """Gate na pbn_uid — rekord, który nigdy nie poszedł do PBN, nic nie robi.""" + assert wydawnictwo_ciagle.pbn_uid is None + + wydawnictwo_ciagle.delete(user=admin_user) + + assert PBN_Export_Queue.objects.filter(operacja=Operacja.WYCOFANIE).count() == 0 + + +@pytest.mark.django_db +def test_restore_kolejkuje_wysylke(wydawnictwo_ciagle, admin_user): + wydawnictwo_ciagle.pbn_uid = baker.make(Publication, mongoId="pub-restore") + wydawnictwo_ciagle.save() + wydawnictwo_ciagle.delete(user=admin_user) + + wydawnictwo_ciagle.restore(user=admin_user) + + assert PBN_Export_Queue.objects.filter( + operacja=Operacja.WYSYLKA, rekord_do_wysylki_id=wydawnictwo_ciagle.pk + ).exists() +``` + +- [ ] **Step 3: Uruchom — oczekuj PASS** + +Run: `uv run pytest src/pbn_integrator/tests/test_soft_delete_regresja.py -k "kolejkuje or pbn_uid" -v` +Expected: PASS. Pole `rekord_do_wysylki_id` widoczne w +`test_pbn_queue_send.py` (`baker.make(PBN_Export_Queue, +rekord_do_wysylki=wydawnictwo_ciagle, ...)`). Jeśli GFK ma inną nazwę pola +ustal przez Step 1 i dostosuj filtr. + +- [ ] **Step 4: Napisz failing test — wpis WYCOFANIE woła delete_all_publication_statements** + +```python +@pytest.mark.django_db +def test_wycofanie_wola_delete_all_publication_statements( + wydawnictwo_ciagle, admin_user +): + pub = baker.make(Publication, mongoId="pub-call-check") + wydawnictwo_ciagle.pbn_uid = pub + wydawnictwo_ciagle.save() + wydawnictwo_ciagle.delete(user=admin_user) + + qi = PBN_Export_Queue.objects.get(operacja=Operacja.WYCOFANIE) + + fake_client = MagicMock() + with pytest.MonkeyPatch().context() as mp: + mp.setattr(admin_user, "get_pbn_user", lambda *a, **k: object()) + mp.setattr( + "pbn_export_queue.models.PBN_Export_Queue.get_client", + lambda self: fake_client, + raising=False, + ) + qi.send_to_pbn() + + fake_client.delete_all_publication_statements.assert_called_once_with( + str(pub.mongoId) + ) +``` + +- [ ] **Step 5: Uruchom — oczekuj PASS lub dostosuj punkt wpięcia klienta** + +Run: `uv run pytest src/pbn_integrator/tests/test_soft_delete_regresja.py -k delete_all -v` +Expected: PASS. Jeśli `send_to_pbn` pobiera klienta inaczej niż przez +`get_client` (sprawdź `src/pbn_export_queue/models.py:350`), zmień mock na +realny punkt wpięcia (wzorzec z `test_pbn_queue_send.py` — tam mockują +`admin_user.get_pbn_user` i `model_table_exists`). Argument: klient PBN +`delete_all_publication_statements(publicationId)` +(`src/pbn_api/client/mixins/institutions.py:87`). + +- [ ] **Step 6: Napisz failing test — re-sync po soft-delete NIE tworzy duplikatu (KRYTYCZNE)** + +```python +@pytest.mark.django_db +def test_resync_po_soft_delete_nie_tworzy_duplikatu(wydawnictwo_ciagle): + """Matching po pbn_uid musi iść przez global_objects (spec §2.5). + Inaczej soft-delete = generator duplikatów.""" + from bpp.models import Wydawnictwo_Ciagle + + pub = baker.make(Publication, mongoId="pub-resync") + wydawnictwo_ciagle.pbn_uid = pub + wydawnictwo_ciagle.save() + wydawnictwo_ciagle.delete() + + # Symulacja matchingu importera/synchronizatora po pbn_uid: + znaleziony = Wydawnictwo_Ciagle.global_objects.filter(pbn_uid=pub).first() + + assert znaleziony is not None + assert znaleziony.pk == wydawnictwo_ciagle.pk + # Domyślny menedżer NIE widzi skasowanej — to właśnie pułapka duplikatu: + assert Wydawnictwo_Ciagle.objects.filter(pbn_uid=pub).first() is None +``` + +- [ ] **Step 7: Uruchom — oczekuj PASS** + +Run: `uv run pytest src/pbn_integrator/tests/test_soft_delete_regresja.py -k resync -v` +Expected: PASS. Ten test dokumentuje kontrakt kat. B (faza 03). Jeśli realny +kod synchronizacji (`pbn_integrator/utils/synchronization.py`) używa `objects` +zamiast `global_objects` przy matchingu po `pbn_uid` → luka w fazie 03, +zadanie naprawcze: przełącz matching na `global_objects`. + +- [ ] **Step 8: Commit** + +```bash +git add src/pbn_integrator/tests/test_soft_delete_regresja.py +git commit -m "test(soft-delete): regresja PBN wycofanie/restore + brak duplikatow sync" +``` + +--- + +## Task 6: Regresja import — re-import matchuje soft-deletowaną publikację + +**Files:** +- Create: `src/import_common/tests/test_soft_delete_regresja.py` + +- [ ] **Step 1: Napisz failing test — matchuj_publikacje znajduje soft-deletowaną (global_objects)** + +```python +"""Regresja importu (spec §2.5, §3 fazy). + +Re-import soft-deletowanej publikacji MUSI zmatchować istniejący rekord +przez ``global_objects`` — inaczej powstaje duplikat (pułapka §9). +""" + +import pytest +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle +from import_common.core import matchuj_publikacje + + +@pytest.mark.django_db +def test_matchuj_publikacje_matchuje_soft_deletowana(jezyki, typy_kbn): + wc = baker.make( + Wydawnictwo_Ciagle, + tytul_oryginalny="Bardzo specyficzny tytul do matchowania importu", + rok=2020, + ) + wc.delete() + + znaleziony = matchuj_publikacje( + Wydawnictwo_Ciagle, + title="Bardzo specyficzny tytul do matchowania importu", + year=2020, + ) + + assert znaleziony is not None + assert znaleziony.pk == wc.pk + + +@pytest.mark.django_db +def test_reimport_nie_tworzy_duplikatu_soft_deletowanej(): + wc = baker.make( + Wydawnictwo_Ciagle, + tytul_oryginalny="Inny unikalny tytul publikacji importowanej", + rok=2021, + ) + wc.delete() + + przed = Wydawnictwo_Ciagle.global_objects.count() + znaleziony = matchuj_publikacje( + Wydawnictwo_Ciagle, + title="Inny unikalny tytul publikacji importowanej", + year=2021, + ) + # Jeśli matchuje, importer nie utworzy nowego — liczba global bez zmian. + assert znaleziony is not None + assert Wydawnictwo_Ciagle.global_objects.count() == przed +``` + +- [ ] **Step 2: Uruchom — oczekuj PASS** + +Run: `uv run pytest src/import_common/tests/test_soft_delete_regresja.py -v` +Expected: PASS. Jeśli `matchuj_publikacje` zwraca `None` dla skasowanej → +**luka w fazie 03**: `import_common/core/publikacja.py` (helpery +`_try_match_pub_by_*`) muszą szukać przez `klass.global_objects`, nie +`klass.objects`. Zadanie naprawcze: przełącz menedżer w funkcjach matchujących. +Jeśli fixture `typy_kbn` nie istnieje, usuń go z sygnatury — `baker.make` +dociągnie wymagane FK; sprawdź `uv run pytest --fixtures -k typy 2>/dev/null`. + +- [ ] **Step 3: Commit** + +```bash +git add src/import_common/tests/test_soft_delete_regresja.py +git commit -m "test(soft-delete): regresja importu matchuje soft-deletowana (bez duplikatow)" +``` + +--- + +## Task 7: Regresja ewaluacja — pomija prace w koszu, restore przywraca punktację + +**Files:** +- Create: `src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py` + +- [ ] **Step 1: Ustal realny punkt wejścia ewaluacji liczący *_Autor** + +Run: `grep -rln "Wydawnictwo_Ciagle_Autor.objects\|Wydawnictwo_Zwarte_Autor.objects" src/ewaluacja_optymalizacja/ | head` +Run: `grep -rn "def reset_pins\|def unpin_all\|def author_works" src/ewaluacja_optymalizacja/ | head` +Expected: ustal funkcję, która zlicza/iteruje autorstwa (spec §2.5 wymienia +`reset_pins`, `unpin_all_sensible`, `author_works`). Test ma dowieść, że po +soft-delete publikacji jej autorstwa znikają z liczenia (bo `*_Autor.objects` +je ukrywa). + +- [ ] **Step 2: Napisz failing test — autorstwa skasowanej pracy znikają z liczenia** + +```python +"""Regresja ewaluacji (spec §2.5, §3 fazy). + +90 miejsc czyta ``*_Autor.objects`` bezpośrednio; po wpięciu +``SoftDeleteModel`` domyślny menedżer ukrywa kaskadowo-skasowane +autorstwa, więc ewaluacja pomija prace w koszu. Restore przywraca punktację. +""" + +import pytest + +from bpp.models import Wydawnictwo_Ciagle_Autor + + +@pytest.mark.django_db +def test_ewaluacja_pomija_autorstwa_pracy_w_koszu( + wydawnictwo_ciagle_z_dwoma_autorami, +): + autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor + + assert Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor).count() == 1 + + wydawnictwo_ciagle_z_dwoma_autorami.delete() + + # Domyślny menedżer (używany przez ewaluację) NIE widzi skasowanych: + assert Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor).count() == 0 + # global_objects nadal je trzyma (odwracalność): + assert Wydawnictwo_Ciagle_Autor.global_objects.filter(autor=autor).count() == 1 + + +@pytest.mark.django_db +def test_restore_przywraca_autorstwa_do_ewaluacji( + wydawnictwo_ciagle_z_dwoma_autorami, +): + autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor + wydawnictwo_ciagle_z_dwoma_autorami.delete() + assert Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor).count() == 0 + + wydawnictwo_ciagle_z_dwoma_autorami.restore() + + assert Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor).count() == 1 +``` + +- [ ] **Step 3: Uruchom — oczekuj PASS** + +Run: `uv run pytest src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py -v` +Expected: PASS. Jeśli skasowane autorstwa nadal są liczone przez `objects` → +**luka w fazie 01/02**: `*_Autor` nie jest poprawnie `SoftDeleteModel` albo +override `delete()` nie kaskaduje na `*_Autor`. To ryzyko „silent leak" +z §1/§9. + +- [ ] **Step 4: Napisz test integracyjny — unpinning nie liczy skasowanej pracy** + +Dopisz (dostosuj nazwę funkcji do ustalonej w Step 1; przykład z +`unpinning_opportunities` widocznym w +`src/ewaluacja_optymalizacja/tests/test_unpinning_opportunities.py`): + +```python +@pytest.mark.django_db +def test_unpinning_nie_uwzglednia_pracy_w_koszu( + wydawnictwo_ciagle_z_dwoma_autorami, denorms +): + """Soft-deletowana praca nie pojawia się wśród kandydatów ewaluacji, + bo Rekord/Autorzy ją odfiltrowuje (spec §2.1) i *_Autor.objects ukrywa.""" + from bpp.models.cache import Rekord + + denorms.flush() + autor = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first().autor + assert Rekord.objects.prace_autora(autor).count() == 1 + + wydawnictwo_ciagle_z_dwoma_autorami.delete() + denorms.flush() + + assert Rekord.objects.prace_autora(autor).count() == 0 +``` + +- [ ] **Step 5: Uruchom — oczekuj PASS** + +Run: `uv run pytest src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py -k unpinning -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py +git commit -m "test(soft-delete): regresja ewaluacji pomija prace w koszu, restore przywraca" +``` + +--- + +## Task 8: Regresja merge autorów — husk soft-deletowany i odwracalny + +**Files:** +- Create: `src/deduplikator_autorow/tests/test_soft_delete_regresja.py` + +- [ ] **Step 1: Ustal funkcję merge i widok** + +Run: `grep -n "def " src/deduplikator_autorow/utils/merge.py | head` +Run: `grep -n "\.delete()" src/deduplikator_autorow/views/merge.py` +Expected: merge przenosi wszystkie typy prac, potem woła `autor.delete()` na +pustym duplikacie (`views/merge.py:155`). Po fazie 04 husk staje się soft- +deletowany (odwracalny). PROTECT nie psuje merge, bo duplikat jest już pusty. + +- [ ] **Step 2: Napisz failing test — po scaleniu duplikat soft-deletowany i odwracalny** + +```python +"""Regresja merge autorów (spec §3.3). + +Po scaleniu husk duplikata jest soft-deletowany (odwracalny, nie znika +bezpowrotnie). PROTECT nie psuje merge (duplikat jest pusty w chwili +delete). Patrz §3.3, §9 (ryzyko: merge musi przenieść WSZYSTKIE typy prac). +""" + +import pytest +from model_bakery import baker + +from bpp.models import Autor + + +@pytest.mark.django_db +def test_husk_duplikata_jest_soft_deletowany_i_odwracalny(): + """Pusty duplikat usunięty w merge → soft-delete, da się przywrócić.""" + duplikat = baker.make(Autor, nazwisko="Kowalski", imiona="Jan") + + # Husk bez prac — usuwany jak w merge (utils/merge.py kończy delete()). + duplikat.delete() + + assert Autor.objects.filter(pk=duplikat.pk).count() == 0 + assert Autor.global_objects.filter(pk=duplikat.pk).count() == 1 + + duplikat.restore() + assert Autor.objects.filter(pk=duplikat.pk).count() == 1 +``` + +- [ ] **Step 3: Napisz test E2E przez widok merge (jeśli istnieje endpoint scalania)** + +Run: `grep -rn "def merge\|name=\"" src/deduplikator_autorow/urls.py | head` +Dopisz test wołający realny przepływ scalania, jeśli jest dostępny URL. +Wzorzec z `src/deduplikator_autorow/tests/test_scal_view.py`. Jeśli przepływ +jest złożony (wymaga `DuplicateScanRun` itd.), wywołaj bezpośrednio funkcję +z `utils/merge.py`: + +```python +@pytest.mark.django_db +def test_merge_przenosi_prace_i_soft_deletuje_duplikat( + wydawnictwo_ciagle_z_dwoma_autorami, admin_user +): + from deduplikator_autorow.utils.merge import merge_authors + + autorzy = list( + a.autor for a in wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.all() + ) + glowny, duplikat = autorzy[0], autorzy[1] + + # Przenieś prace duplikata na głównego + usuń husk: + merge_authors(glowny, duplikat, user=admin_user) + + # Duplikat soft-deletowany (odwracalny), główny żyje: + assert Autor.objects.filter(pk=glowny.pk).exists() + assert Autor.objects.filter(pk=duplikat.pk).count() == 0 + assert Autor.global_objects.filter(pk=duplikat.pk).count() == 1 +``` + +- [ ] **Step 4: Uruchom — oczekuj PASS, dostosuj sygnaturę merge** + +Run: `uv run pytest src/deduplikator_autorow/tests/test_soft_delete_regresja.py -v` +Expected: PASS. Sygnatura `merge_authors` może się różnić — ustal przez Step 1 +(`grep -n "def merge" src/deduplikator_autorow/utils/merge.py`) i dostosuj +wywołanie. Jeśli merge NIE przenosi któregoś typu prac przed `delete()` → +guard/PROTECT zablokuje usunięcie husku → test FAIL z `ProtectedError`. To +dokładnie ryzyko z §3.3/§9 — zadanie naprawcze: uzupełnij transfer brakującego +typu w `utils/merge.py` (ciągłe/zwarte/patent/doktorat/habilitacja). + +- [ ] **Step 5: Commit** + +```bash +git add src/deduplikator_autorow/tests/test_soft_delete_regresja.py +git commit -m "test(soft-delete): regresja merge autorow (husk odwracalny, PROTECT nie psuje)" +``` + +--- + +## Task 9: Regresja API — skasowane rekordy/autorstwa nie wyciekają + +**Files:** +- Create: `src/api_v1/tests/test_soft_delete_regresja.py` + +- [ ] **Step 1: Napisz failing test — soft-deletowana publikacja nie wyciekła w API list/detail** + +```python +"""Regresja API v1 (spec §2.5 kat. A — wyświetlanie/eksport). + +Skasowane publikacje i autorstwa NIE mogą wyciekać przez REST API. +Domyślny menedżer ``objects`` (używany przez viewsety) je ukrywa. +""" + +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +def test_api_list_pomija_soft_deletowana_publikacje(api_client, wydawnictwo_ciagle): + res = api_client.get(reverse("api_v1:wydawnictwo_ciagle-list")) + assert res.json()["count"] == 1 + + wydawnictwo_ciagle.delete() + + res = api_client.get(reverse("api_v1:wydawnictwo_ciagle-list")) + assert res.json()["count"] == 0 + + +@pytest.mark.django_db +def test_api_detail_soft_deletowanej_daje_404(client, wydawnictwo_ciagle): + pk = wydawnictwo_ciagle.pk + url = reverse("api_v1:wydawnictwo_ciagle-detail", args=(pk,)) + assert client.get(url).status_code == 200 + + wydawnictwo_ciagle.delete() + + assert client.get(url).status_code == 404 + + +@pytest.mark.django_db +def test_api_rekord_pomija_soft_deletowana(api_client, wydawnictwo_ciagle, denorms): + denorms.flush() + res = api_client.get(reverse("api_v1:rekord-list")) + assert res.json()["count"] == 1 + + wydawnictwo_ciagle.delete() + denorms.flush() + + res = api_client.get(reverse("api_v1:rekord-list")) + assert res.json()["count"] == 0 +``` + +- [ ] **Step 2: Uruchom — oczekuj PASS (dostosuj nazwę route rekord)** + +Run: `uv run pytest src/api_v1/tests/test_soft_delete_regresja.py -v` +Expected: PASS. Nazwę route sprawdź: `grep -rn "rekord" src/api_v1/urls.py +src/api_v1/viewsets/*.py | grep -i basename`. Jeśli route nazywa się inaczej +(np. `api_v1:rekord_mat-list`), dostosuj `reverse`. Jeśli list/detail nadal +zwraca skasowaną → viewset używa `global_objects`/surowego querysetu zamiast +`objects` (kat. A) — luka, zadanie naprawcze: w +`src/api_v1/viewsets/` ustaw `queryset = Model.objects.all()`. + +- [ ] **Step 3: Commit** + +```bash +git add src/api_v1/tests/test_soft_delete_regresja.py +git commit -m "test(soft-delete): regresja API nie wycieka skasowanych rekordow" +``` + +--- + +## Task 10: Regresja dashboard — liczniki pomijają skasowane + +**Files:** +- Create: `src/admin_dashboard/tests/test_soft_delete_regresja.py` +- Modify (jeśli test ujawni lukę): `src/admin_dashboard/views/charakter_stats.py` + +- [ ] **Step 1: Sprawdź, jak dashboard liczy publikacje** + +Run: `grep -rn "objects\|raw\|connection.cursor\|Count" src/admin_dashboard/views/charakter_stats.py` +Expected: `_get_charakter_counts` (`charakter_stats.py:41`). Jeśli używa +`Wydawnictwo_Ciagle.objects` — po fazie 02 automatycznie pomija skasowane +(kat. A czysta). Jeśli używa surowego SQL na `bpp_wydawnictwo_ciagle` z +`connection.cursor()` — omija menedżer i policzy skasowane → luka do naprawy. + +- [ ] **Step 2: Napisz failing test — soft-delete zmniejsza licznik charakteru** + +```python +"""Regresja dashboardu (spec §2.5 kat. A). + +Liczniki/statystyki MUSZĄ pomijać skasowane publikacje. +""" + +import pytest +from model_bakery import baker + +from admin_dashboard.views.charakter_stats import _get_charakter_counts +from bpp.models import Charakter_Formalny, Wydawnictwo_Ciagle + + +@pytest.mark.django_db +def test_get_charakter_counts_pomija_soft_deletowana(): + cf = baker.make(Charakter_Formalny, nazwa="Artykul", skrot="AR") + baker.make(Wydawnictwo_Ciagle, charakter_formalny=cf) + wc2 = baker.make(Wydawnictwo_Ciagle, charakter_formalny=cf) + + przed = _licznik_dla(cf) + assert przed == 2 + + wc2.delete() + + assert _licznik_dla(cf) == 1 + + +def _licznik_dla(cf): + total = 0 + for row in _get_charakter_counts(): + # row: (nazwa, count, skrot, id, ciagle_count, zwarte_count) + if row[2] == cf.skrot: + total += row[4] # ciagle_count + return total +``` + +- [ ] **Step 3: Uruchom — oczekuj PASS lub napraw produkcyjnie** + +Run: `uv run pytest src/admin_dashboard/tests/test_soft_delete_regresja.py -v` +Expected: PASS, jeśli `_get_charakter_counts` liczy przez `.objects`. Jeśli +FAIL (licznik nadal 2) → poprawka produkcyjna: w `charakter_stats.py` +zamień surowy SQL/agregację na ORM przez menedżer `objects`, np.: + +```python +from django.db.models import Count + +ciagle_counts = dict( + Wydawnictwo_Ciagle.objects.values("charakter_formalny") + .annotate(c=Count("id")) + .values_list("charakter_formalny", "c") +) +``` + +albo (jeśli zostaje surowy SQL) dopisz `WHERE deleted_at IS NULL` do zapytania. +Dostosuj indeksy w `_licznik_dla` do realnego kształtu krotki zwracanej przez +`_get_charakter_counts` (sprawdź docstring funkcji w +`charakter_stats.py:41`). + +- [ ] **Step 4: Napisz test widoku database_stats (rozkład typów pomija skasowane)** + +```python +import json + +from django.urls import reverse + + +@pytest.mark.django_db +def test_database_stats_rozklad_typow_pomija_skasowane(client, staff_user): + cf = baker.make(Charakter_Formalny, nazwa="Ksiazka", skrot="KS") + baker.make(Wydawnictwo_Ciagle, charakter_formalny=cf) + wc = baker.make(Wydawnictwo_Ciagle, charakter_formalny=cf) + wc.delete() + + client.force_login(staff_user) + res = client.get(reverse("admin_dashboard:database_stats")) + data = json.loads(res.content) + + ciagle = { + e["charakter_formalny__nazwa"]: e["count"] + for e in data["type_distribution"]["ciagle"] + } + assert ciagle.get("Ksiazka") == 1 +``` + +- [ ] **Step 5: Uruchom — oczekuj PASS** + +Run: `uv run pytest src/admin_dashboard/tests/test_soft_delete_regresja.py -k database_stats -v` +Expected: PASS (widok `database_stats` w `views/base.py:51` liczy przez +`Wydawnictwo_Ciagle.objects` → automatycznie czysty). Nazwę URL i klucze JSON +sprawdź: `grep -rn "database_stats\|type_distribution" src/admin_dashboard/`. +Jeśli fixture `staff_user` nie istnieje, użyj `admin_user` (superuser też +spełnia `staff_member_required`). + +- [ ] **Step 6: Commit** + +```bash +git add src/admin_dashboard/tests/test_soft_delete_regresja.py +git add src/admin_dashboard/views/charakter_stats.py 2>/dev/null || true +git commit -m "test(soft-delete): regresja dashboard liczniki pomijaja skasowane" +``` + +--- + +## Task 11: Pełna suita + ruff + +**Files:** (brak nowych — gate jakości) + +- [ ] **Step 1: Lint i format na wszystkich nowych plikach** + +Run: `ruff format src/bpp/tests/test_soft_delete/ src/pbn_integrator/tests/test_soft_delete_regresja.py src/import_common/tests/test_soft_delete_regresja.py src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py src/deduplikator_autorow/tests/test_soft_delete_regresja.py src/api_v1/tests/test_soft_delete_regresja.py src/admin_dashboard/tests/test_soft_delete_regresja.py` +Run: `ruff check src/bpp/tests/test_soft_delete/ src/pbn_integrator/tests/test_soft_delete_regresja.py src/import_common/tests/test_soft_delete_regresja.py src/ewaluacja_optymalizacja/tests/test_soft_delete_regresja.py src/deduplikator_autorow/tests/test_soft_delete_regresja.py src/api_v1/tests/test_soft_delete_regresja.py src/admin_dashboard/tests/test_soft_delete_regresja.py` +Expected: brak błędów (linie ≤88). Napraw ręcznie (Edit), NIE `--fix`. + +- [ ] **Step 2: Uruchom całą regresję soft-delete** + +Run: `uv run pytest -k soft_delete_regresja -v` +Expected: wszystkie PASS. Każdy FAIL przeanalizuj wg odpowiadającego Tasku — +część FAIL-i to luki w fazach 01–07 (opisane w krokach „oczekuj PASS"). + +- [ ] **Step 3: Uruchom pełną suitę bez Playwrighta (regresja całego systemu)** + +Run: `make tests-without-playwright` +Expected: zielono. To gwarantuje, że wpięcie `SoftDeleteModel` nie zepsuło +istniejących testów cache/PBN/import/ewaluacja/API/dashboard. Jeśli istniejące +testy padają — to regresja wprowadzona w fazach 01–07; zlokalizuj i napraw +w odpowiedniej fazie. + +- [ ] **Step 4: Uruchom pełną suitę (do ~10 min, łącznie z Playwright)** + +Run: `uv run pytest` +Expected: zielono (timeout ≥600000 ms). Po zieleni — gotowe. + +- [ ] **Step 5: Commit (jeśli były poprawki formatowania)** + +```bash +git add -A +git commit -m "chore(soft-delete): ruff format/check suity regresji" +``` + +--- + +## Self-Review (wykonane przy pisaniu planu) + +**Spec coverage (§8 pkt 8 + §9):** +- PBN duplikaty + wycofanie + restore → Task 5. ✅ +- Cache/ewaluacja (Rekord/Autorzy/Cache_Punktacja, verify_cache, pinning) → + Task 1, 2, 7. ✅ +- Import bez duplikatów → Task 6. ✅ +- Merge autorów (husk odwracalny, PROTECT) → Task 8. ✅ +- API nie wycieka → Task 9. ✅ +- Dashboard liczniki → Task 10. ✅ +- Guardy (autor z pracami, książka-matka, przez admin) → Task 3. ✅ +- SoftDeleteLog (delete/restore/hard + user) → Task 4. ✅ + +**Type/nazewnictwo:** `global_objects`/`objects`/`deleted_objects`, +`Operacja.WYSYLKA`/`WYCOFANIE`, `SoftDeleteLog.Akcja.{DELETE,RESTORE, +HARD_DELETE}`, sygnatura `delete(user=, reason=)` — zgodne z PINNED w +overview (00). Fixture `wydawnictwo_ciagle_z_dwoma_autorami`, `denorms`, +`api_client`, `admin_user`, `zwarte_z_dyscyplinami`, +`pbn_wydawnictwo_ciagle_z_autorem_z_dyscyplina` — zweryfikowane w +`src/conftest.py` / `src/fixtures/` / istniejących testach. diff --git a/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md new file mode 100644 index 000000000..0646e70c3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md @@ -0,0 +1,315 @@ +# Spec: Soft-delete dla rekordów publikacji (Wydawnictwo_Ciagle/Zwarte, Doktorat, Habilitacja, Patent) + +> 🔁 **STATUS: ZASTĄPIONY (2026-06-04).** +> Ten dokument to wczesna analiza wykonalności (publikacje-only, „ODŁOŻONE"). +> Aktualnym, zatwierdzonym do realizacji projektem wdrożeniowym — obejmującym +> publikacje ORAZ soft-delete autora, wycofanie z PBN przez kolejkę, tabelę-log +> i admin — jest: +> [`2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](2026-06-04-soft-delete-publikacje-i-autorzy-design.md). +> Pozostawiony jako kontekst historyczny rozpoznania. + +**Cel:** Umożliwić „miękkie" kasowanie 5 typów rekordów publikacji — +zamiast fizycznego `DELETE` ustawiamy znacznik `deleted_at`, dzięki czemu +rekord znika z +widoku publicznego/ewaluacji/API, ale dane (w tym powiązania autor↔rekord) +zostają i można je przywrócić. + +**Architektura (jednozdaniowo):** Wykorzystujemy istniejący w repo pakiet +`django-soft-delete` (`SoftDeleteModel`) na 5 modelach źródłowych, a +spójność z resztą systemu osiągamy w JEDNYM punkcie — w triggerze +PostgreSQL zasilającym materializowany cache `bpp_rekord_mat`, który uczymy +traktować „skasowany" jak zdarzenie DELETE. + +**Stack:** Django, PostgreSQL (triggery `plpython3u`), `django-soft-delete` +(już w `pyproject.toml`), `django-denorm-iplweb`. + +--- + +## 1. Motywacja + +Dziś `Wydawnictwo_Ciagle/Zwarte`, `Praca_Doktorska`, `Praca_Habilitacyjna`, +`Patent` kasuje się fizycznie (`DELETE`). To pociąga kaskadowo: +- usunięcie wierszy przez-modeli `*_Autor` (powiązania autorów), +- usunięcie wpisów w `bpp_rekord_mat` / `bpp_autorzy_mat` (przez trigger), +- utratę danych bez możliwości cofnięcia. + +Soft-delete daje: odzyskiwalność, ślad audytowy, oraz — co podkreślił +użytkownik — **zachowanie powiązań `*_Autor`** nawet gdy rekord nadrzędny +„znika" z widoków. + +## 2. Modele w zakresie + +| Model | Plik | Menedżer własny? | Through-model autorów | +|---|---|---|---| +| `Wydawnictwo_Ciagle` | `src/bpp/models/wydawnictwo_ciagle.py` | TAK (`Wydawnictwo_Ciagle_Manager`) | `Wydawnictwo_Ciagle_Autor` | +| `Wydawnictwo_Zwarte` | `src/bpp/models/wydawnictwo_zwarte.py` | TAK (`Wydawnictwo_Zwarte_Manager`) | `Wydawnictwo_Zwarte_Autor` | +| `Praca_Doktorska` | `src/bpp/models/praca_doktorska.py` | NIE | (brak; `autor` FK) | +| `Praca_Habilitacyjna` | `src/bpp/models/praca_habilitacyjna.py` | NIE | (brak; `autor` O2O) | +| `Patent` | `src/bpp/models/patent.py` | NIE | `Patent_Autor` | + +Żaden z 5 modeli **nie ma** dziś własnego `delete()` ani sygnałów +`pre/post_delete` po stronie Pythona — cała logika kasowania siedzi w +triggerach DB. To upraszcza warstwę ORM. + +## 3. Kluczowa decyzja architektoniczna — „choke-point" w triggerze + +`Rekord` to UNION-view (`bpp_rekord`) nad materializowaną tabelą +`bpp_rekord_mat`, zasilaną triggerem `bpp_refresh_cache()` +(`src/bpp/migrations/107_cache_functions.sql`). Z tej tabeli czyta +**większość systemu**: publiczny frontend (`browse.py`), `multiseek`, +global search (`search_index`), ewaluacja (`Cache_Punktacja_*`), +raporty. + +**Fakt z kodu** (`107_cache_functions.sql:81-86`): na `DELETE` trigger +usuwa wiersze z `bpp_rekord_mat`/`bpp_autorzy_mat`; na `UPDATE/INSERT` +re-insertuje. Ponieważ soft-delete to technicznie `UPDATE`, **bez zmiany +triggera skasowany rekord wróciłby do mat-view** i był widoczny wszędzie. + +**Rozwiązanie:** trigger uczymy reguły (kolumna w DB to `deleted_at`, +nie boolean): +> jeśli `NEW.deleted_at IS NOT NULL` → zachowaj się jak `DELETE` +> (usuń wiersze z `bpp_rekord_mat` + `bpp_autorzy_mat`, **nie** re-insertuj). +> Przywrócenie (`deleted_at: →NULL`) to zwykły UPDATE → normalny +> re-insert. + +Skutek: **wszystko, co czyta przez `Rekord`/`Autorzy`/`Cache_*`, czyści +się jednym ruchem, bez dotykania kodu konsumentów.** + +## 4. Odwrócenie zakresu dzięki `django-soft-delete` + +`SoftDeleteModel` (zweryfikowane w +`.venv/.../django_softdelete/models.py`) udostępnia: +- pola DB: `deleted_at`, `restored_at`, `transaction_id` (DateTime/UUID; + **nie ma** boolowskiego pola — `is_deleted` to *property* nad + `deleted_at`, filtr w ORM to `deleted_at__isnull`), +- `objects` (`SoftDeleteManager`) — **domyślnie wyklucza** skasowane, +- `global_objects` (`GlobalManager`) — wszystkie (z usuniętymi), +- `deleted_objects` (`DeletedManager`) — tylko usunięte, +- `.delete()` → soft, `.hard_delete()` → fizyczne, `.restore()` → + przywrócenie. + +Konsekwencja dla naszej wcześniejszej analizy „223 miejsc / 47 przecieków": + +- **Kategoria A („leak" — wyświetlanie/eksport/liczenie):** ich kod używa + `Model.objects` → po wpięciu `SoftDeleteModel` **stają się czyste + automatycznie**. Zero zmian w tych plikach. Dotyczy m.in.: + `api_v1` (viewsety/serializery), `admin_dashboard` (statystyki, + time-series), `bpp/views/autocomplete/*`, `bpp/views/browse.py` + (strona Źródła), `bpp/admin_site.py` (liczniki), `komparator_pbn`, + `ewaluacja_optymalizacja/utils.py`, `verification.py`, + `ranking_autorow/forms.py`. +- **Kategoria B („wants-deleted" — MUSI widzieć usunięte):** te miejsca + trzeba **świadomie przełączyć** z `objects` na `global_objects`, + inaczej powstaną duplikaty / niespójny sync. To jest realny zakres + pracy. Dotyczy: + - `import_common/core/publikacja.py`, `importer_publikacji` — matching + przy imporcie (inaczej re-import odtworzy skasowaną publikację), + - `crossref_bpp/core.py:178,182` — dedup, + - `deduplikator_publikacji/tasks.py` — dedup, + - `pbn_integrator/utils/synchronization.py:42,69`, + `pbn_integrator/importer/chapters.py:64`, `pbn_api/management/*` — + sync z PBN (decyzja: skasowanie powinno raczej polecieć do PBN jako + wycofanie; matching musi widzieć usunięte po `pbn_uid`). + +> **Pułapka nadrzędna:** to właśnie kat. B jest groźna. Gdyby domyślny +> menedżer ukrywał usunięte, a importer go użył — soft-delete zamienia się +> w generator duplikatów. Audyt kat. B jest obowiązkowy. + +## 5. Punkty wymagające osobnej uwagi + +### 5.1 Własne menedżery `Wydawnictwo_*_Manager` +Dziedziczą po `ManagerModeliZOplataZaPublikacjeMixin` +(`src/bpp/models/abstract/fees.py`). Po wpięciu `SoftDeleteModel` muszą +**złączyć** zachowanie soft-delete (filtr `deleted_at__isnull=True`) z +istniejącą metodą `.rekordy_z_oplata()` / `.wydawnictwa_nadrzedne_dla_innych()`. +Nie wolno ich nadpisać — trzeba przepleść (MRO / wspólny `QuerySet`). + +### 5.2 Unikalny `slug` +Wszystkie 5 modeli ma denormalizowany `slug` z `unique=True`. Skasowany +rekord trzyma slug zajęty → konflikt przy ponownym utworzeniu. +Rozwiązanie: warunkowy constraint +`UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True))` +zamiast `unique=True`. Wymaga migracji (nie modyfikować istniejących!). + +### 5.3 Through-modele `*_Autor` i dane pochodne (Cache_Punktacja_*) +Kluczowa obserwacja: `bpp_autorzy_mat`, `Cache_Punktacja_Autora`, +`Cache_Punktacja_Dyscypliny` są **pochodne** — zasila/czyści je trigger. +Gdy rodzic znika z `bpp_rekord_mat`, znikają i one (trigger + FK cascade +`bpp_autorzy_mat`→`bpp_rekord_mat`, `0001_cache_init.sql:19`). Przy +`restore` trigger re-projektuje je ze źródła. **To dzieje się automatycznie +niezależnie od tego, czy `*_Autor` są soft-delete czy nie** — bo cache +jest pochodny. + +Pozostaje pytanie o same wiersze **źródłowe** `*_Autor`: zostawić nietknięte +(Projekt A) czy też je soft-deletować kaskadą (Projekt B). Pełna analiza i +rekomendacja — sekcja [5.5](#55-kaskada-delete--auto-undelete--co-naprawdę-robi-pakiet). +(Uwaga: trzeba zweryfikować, czy `Cache_Punktacja_*` faktycznie czyści ten +sam trigger, czy osobny mechanizm przeliczania — jeśli osobny, restore może +wymagać re-przeliczenia.) + +### 5.4 GenericForeignKey — sieroty +`Publikacja_Habilitacyjna` i `Nagroda` wskazują na te modele przez +`content_type`+`object_id`. GFK nie kaskaduje. Przy soft-delete obiekt +fizycznie istnieje, więc GFK dalej rozwiązuje się poprawnie — to akurat +**plus** soft-delete (mniej sierot niż przy hard-delete). Trzeba tylko +zdecydować, czy `nagrody`/`publikacje_habilitacyjne` skasowanego rekordu +mają być nadal pokazywane. + +### 5.5 Kaskada delete / auto-undelete — co NAPRAWDĘ robi pakiet +**Zweryfikowane w kodzie** (`django_softdelete/models.py`, `delete()` + +`restore()`): + +- `SoftDeleteModel.delete()` **domyślnie kaskaduje refleksją** po + odwrotnych relacjach (`one_to_one`, `one_to_many` = reverse FK), + pomijając `GenericRelation`. Każdemu skasowanemu obiektowi nadaje wspólny + `transaction_id`. +- `restore()` używa `transaction_id`, żeby **automatycznie odtworzyć + dokładnie tę samą grupę** → „un-delete z automatu" działa. +- **ALE** w trybie `strict=True` (domyślny) jeśli powiązany model **nie + jest** `SoftDeleteModel` → `SoftDeleteException`. A `strict=False` → + dzieci z `on_delete=CASCADE` zostają **fizycznie skasowane** (czyli + `*_Autor` przepadają, restore ich nie wskrzesi). + +**Problem dla nas:** 5 modeli ma liczne nie-soft dzieci: `*_Autor`, +`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, `Publikacja_Habilitacyjna`, +`Opi_2012_Tytul_Cache`. Goła kaskada pakietu **albo rzuci wyjątek +(strict), albo twardo skasuje dzieci (non-strict)** — oba złe. + +**Dwa spójne projekty** (rozstrzygnąć w [otwartych decyzjach](#7-otwarte-decyzje), +pkt 1): + +- **Projekt A — „cache sam to robi" (rekomendowany).** + Override `delete()` na 5 modelach tak, by **NIE** robił refleksyjnej + kaskady — tylko ustawia `deleted_at` i zapisuje. Dzieci `*_Autor` + zostają **nietknięte** w tabeli źródłowej. Trigger usuwa rodzica z + `bpp_rekord_mat` → `bpp_autorzy_mat` i `Cache_Punktacja_*` znikają + automatycznie (są pochodne). `restore()` = `deleted_at→NULL` → trigger + **re-projektuje** wszystko ze źródła (bo `*_Autor` nigdy nie zniknęły). + - Plusy: minimalny blast radius, brak wirusowego soft-delete, restore + automatyczny przez warstwę cache. + - Minus: bezpośrednie zapytania `Wydawnictwo_*_Autor.objects` (z + pominięciem rodzica) nadal widzą autorstwa skasowanych rekordów → + trzeba dodać `.filter(rekord__deleted_at__isnull=True)` w kilku + miejscach (ewaluacja `verification.py`, `komparator_pbn`). + +- **Projekt B — pełna kaskada soft-delete.** + `*_Autor` (i pozostałe dzieci, które chcemy móc przywrócić) stają się + `SoftDeleteModel`. Kaskada + `transaction_id` + auto-restore działają + „z pudełka", a bezpośrednie `*_Autor.objects` czyszczą się same. + - Plusy: spójne z grain pakietu, brak ręcznych filtrów na through-modelach. + - Minusy: efekt **wirusowy** — `*_Streszczenie`, `*_Zewnetrzna_Baza`, + `Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache` też muszą stać się + soft-delete (albo zaakceptować ich twardy CASCADE). Dużo więcej + migracji i pól; trzeba zweryfikować, że kaskada nie koliduje z + triggerem (podwójne odświeżanie). + +> **Rekomendacja:** Projekt A. Warstwa cache (`Rekord`/trigger) już +> realizuje „pochodne dane znikają i wracają", więc kaskada pakietu jest +> redundantna i tylko mnoży blast radius. Override `delete()` + kilka +> filtrów na through-modelach. + +### 5.6 Self-referencja `Wydawnictwo_Zwarte → Wydawnictwo_Zwarte` +Rozdziały wskazują na książkę-matkę (`wydawnictwo_nadrzedne`, CASCADE). +Niezależnie od projektu z 5.5 trzeba zdecydować, czy soft-delete +książki-matki pociąga soft-delete rozdziałów (patrz +[otwarte pytania](#7-otwarte-decyzje), pkt 1). + +## 6. Szkic zakresu prac (gdy wrócimy) + +Kolejność (nie pełny TDD — to spec; szczegółowy plan TDD powstanie przy +realizacji): + +1. **Trigger** `bpp_refresh_cache()` — nowa migracja SQL: obsługa + `NEW.deleted_at IS NOT NULL` jako DELETE. + testy spójności mat-view (soft-delete → + znika z `Rekord`; restore → wraca). **Najwrażliwszy, najpierw.** +2. **Modele** — wpięcie `SoftDeleteModel` w 5 modeli; migracja dodająca + pola pakietu (`deleted_at`, `restored_at`, `transaction_id`) + indeks na + `deleted_at`. `is_deleted` to property — **nie** tworzyć osobnego pola. + Nie modyfikować istniejących migracji. + - Przy **Projekcie A** (rekomendacja, sekcja 5.5): override `delete()` + tak, by **nie** robił refleksyjnej kaskady pakietu (ustaw `deleted_at` + i `save()`), inaczej `strict=True` rzuci wyjątkiem na nie-soft + dzieciach. Zweryfikować też `restore()` (analogicznie bez kaskady). +3. **Menedżery** — przeplecenie soft-delete z `Wydawnictwo_*_Manager`. +4. **`slug`** — warunkowy `UniqueConstraint` (migracja). +5. **Audyt kat. B** — przełączenie import/dedup/PBN na `global_objects`. +6. **Admin** — akcja „przenieś do kosza" (zamiast hard-delete), filtr + „pokaż skasowane", akcja „przywróć". Admin świadomie używa + `global_objects`/`deleted_objects`. +7. **Testy regresji** — PBN sync (duplikaty!), dashboard, import, + ewaluacja, API. Pełna suita (do ~10 min). + +## 7. Otwarte decyzje + +Do rozstrzygnięcia **zanim** ruszymy implementację: + +1. **Projekt kaskady (A vs B) — patrz [5.5](#55-kaskada-delete--auto-undelete--co-naprawdę-robi-pakiet).** + Rekomendacja: **Projekt A** (override `delete()`, dzieci nietknięte, + cache/trigger robi resztę). Do potwierdzenia. Powiązane: czy soft-delete + książki-matki `Wydawnictwo_Zwarte` pociąga rozdziały + (`wydawnictwo_nadrzedne`)? (Propozycja: NIE automatycznie; ostrzeżenie + w adminie.) +2. **PBN przy skasowaniu:** czy skasowana publikacja leci do PBN jako + wycofanie/oświadczenie usuwające, czy tylko przestaje się + synchronizować? +3. **Kto może kasować/przywracać** i czy potrzebny osobny perm + (`can_soft_delete` / `can_restore`). +4. **Retencja / hard-delete:** czy po N dniach „kosz" czyści się fizycznie + (zadanie celery), czy zostaje na zawsze. +5. **Widoczność `nagrody`/`publikacje_habilitacyjne`** skasowanego rekordu. +6. Czy soft-delete dotyczy też przez-modeli `*_Autor` osobno (np. usunięcie + pojedynczego współautorstwa), czy tylko rekordów nadrzędnych. + +## 8. Szacunek nakładu + +Przy tej architekturze (trigger jako choke-point + istniejący +`django-soft-delete`): + +| Obszar | Nakład | +|---|---| +| Trigger + 5 widoków + testy spójności cache | 2–3 dni | +| Modele + menedżery + migracje (`deleted_at`, `slug` constraint) | 1–2 dni | +| Audyt kat. B (`global_objects`) | 2–3 dni | +| Admin (kosz/filtr/przywracanie) | 2–3 dni | +| Testy regresji (PBN, dashboard, import, ewaluacja) | 3–5 dni | + +**Razem realnie ~2–3 tygodnie.** Najwięcej ryzyka: (1) trigger/cache, +(2) duplikaty z importu/PBN przy źle zrobionym kat. B. + +## 9. Ryzyka + +- **Cache rozjedzie się**, jeśli trigger nie obsłuży `deleted_at IS NOT + NULL` we wszystkich 5 tabelach + ścieżce UPDATE. Najgroźniejszy, + wydajnościowo wrażliwy fragment. +- **Duplikaty** z importu/PBN/dedup, jeśli kat. B nie przejdzie na + `global_objects`. +- **Denorm** (`django-denorm-iplweb`) działa na `pre_save` — soft-delete + go bezpośrednio nie psuje, ale warto zweryfikować `cached_punkty_dyscyplin` + po przywróceniu rekordu. +- Migracje dotykają 5 dużych tabel produkcyjnych — `deleted_at` domyślnie + `NULL` (brak backfillu), indeks na `deleted_at` zakładać `CONCURRENTLY` + jeśli rozmiar tego wymaga. + +## 10. Precedens w repo + +- `django-soft-delete>=1.0.23` — `pyproject.toml:122`. +- `src/zglos_publikacje/models.py:10,61` — `Zgłoszenie_Publikacji` już + dziedziczy po `SoftDeleteModel`. Wzorzec do naśladowania (menedżery, + migracja, admin). + +--- + +## Dlaczego odkładamy + +Świadoma decyzja z **2026-06-03**: temat jest dobrze rozpoznany i +wykonalny (~2–3 tyg.), ale **nie wchodzi teraz w realizację**. Powody: +inne priorytety (m.in. integracja DSpace, prace nad powiązaniami autorów). +Spec spisany, żeby rozpoznanie nie wyparowało. Gdy wrócimy: + +1. rozstrzygnąć [otwarte decyzje](#7-otwarte-decyzje), +2. zacząć od triggera (sekcja 6, krok 1) jako najwrażliwszego, +3. dopiero potem reszta. + +> Niniejszy dokument NIE jest planem TDD do wykonania. Przy starcie +> realizacji należy wygenerować szczegółowy plan implementacyjny +> (skill `superpowers:writing-plans`) na bazie tego speca. diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md new file mode 100644 index 000000000..8c6934185 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md @@ -0,0 +1,518 @@ +# Spec: Soft-delete publikacji + autorów (jedno opracowanie wdrożeniowe) + +> ✅ **STATUS: DO REALIZACJI (2026-06-04).** +> Ten dokument jest projektem wdrożeniowym (design), zatwierdzonym przez +> użytkownika. Zastępuje feasibility-spec +> [`2026-06-03-soft-delete-publikacje.md`](2026-06-03-soft-delete-publikacje.md) +> (publikacje-only, „ODŁOŻONE") i rozszerza go o: soft-delete autora, +> wycofanie z PBN przez kolejkę, tabelę-log audytu oraz wsparcie w adminie. +> Szczegółowy plan TDD powstaje na bazie tego speca (skill +> `superpowers:writing-plans`). + +**Cel.** Wprowadzić odwracalne („miękkie") kasowanie tam, gdzie ma to realny +sens, przy minimalnym blast-radiusie: + +1. **Publikacje** (5 modeli) — pełny soft-delete: `DELETE` → znacznik + `deleted_at`; rekord znika z widoku publicznego / ewaluacji / API / PBN. + Powiązania `*_Autor` są soft-deletowane razem z rekordem (wąska kaskada, + §2.2) — zachowane i odwracalne, nie tracone jak przy hard-delete. +2. **Autor** — soft-delete **wyłącznie dla autora bez prac** (odwracalny + „kosz" dla pustych/błędnych rekordów). Autor **z** pracami → `PROTECT` + (zero kasowania, soft ani hard). +3. **PBN** — soft-delete publikacji wycofuje oświadczenia dyscyplin z profilu + instytucji, asynchronicznie przez kolejkę (`pbn_export_queue`). +4. **Audyt** — dedykowana tabela `SoftDeleteLog` (kto / kiedy / dlaczego / + status PBN). +5. **Admin** (superuser-only) — „kosz" zamiast hard-delete, filtr „pokaż + skasowane", akcja „przywróć", osobna jawna akcja „usuń trwale". + +**Stack.** Django, PostgreSQL (triggery `plpython3u`), `django-soft-delete` +(`SoftDeleteModel`, już w `pyproject.toml`), `django-denorm-iplweb`, +Celery + `pbn_export_queue`. + +--- + +## 1. Decyzja architektoniczna nadrzędna — asymetria publikacja vs autor + +Połączenie obu ficzerów (soft-delete publikacji ORAZ autora) prowadzi do +celowej **asymetrii**, która drastycznie ogranicza ryzyko: + +| | **Publikacje** (5 modeli) | **Autor** | +|---|---|---| +| Mechanizm | Pełny `SoftDeleteModel` | Soft-delete **tylko gdy brak prac** | +| Autor/rekord z pracami | — | **PROTECT** (zero kasowania) | +| Autor/rekord bez prac | — | Soft-delete = odwracalny husk | +| Through-modele `*_Autor` | `SoftDeleteModel`, **wąska kaskada** z rodzica | nie kaskadują od autora | +| Doktorat / habilitacja | Soft-delete (są publikacjami) | FK do autora → `PROTECT` | + +**Konsekwencja kluczowa (autor):** soft-delete **autora** to operacja-liść na +pustych rekordach — autor z jakimkolwiek autorstwem/doktoratem/habilitacją jest +`PROTECT` (§3), więc usunięcie autora nigdy nie dotyka materializowanych +widoków, ewaluacji ani PBN. Soft-delete autora **nie kaskaduje** do `*_Autor`. + +**Konsekwencja kluczowa (publikacja):** through-modele `Wydawnictwo_*_Autor` +i `Patent_Autor` **stają się `SoftDeleteModel`** — ale wyłącznie jako cel +**wąskiej kaskady** z soft-delete publikacji (§2.2), NIE pełnego refleksyjnego +Projektu B. Pozostałe dzieci (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, +`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`) zostają nie-soft — +kaskada zatrzymuje się na `*_Autor` i **nie jest wirusowa**. Powód: 90 +bezpośrednich zapytań `*_Autor.objects` w kodzie (większość w +`ewaluacja_optymalizacja` — najwrażliwszy korekcyjnie podsystem) — domyślny +menedżer `objects` po wpięciu `SoftDeleteModel` czyni je poprawnymi +automatycznie, eliminując 90-punktowe ryzyko „silent leak" do ewaluacji. + +**Dlaczego nie kaskada autor→prace ani „guard z 50 publikacjami":** +realny przypadek użycia kasowania autora jest wąski — to wyłącznie puste / +błędne / duplikowane rekordy (literówki, dane testowe, husk po scaleniu). +Nikt nie kasuje autora z 50 pracami („Kowalski zniknął, usuńmy go" się nie +zdarza). Kaskada soft-delete autor+publikacje byłaby ogromną, rzadką operacją +i kasowałaby publikacje współautorów; „guard" wymuszający ręczną edycję 50 +publikacji przed usunięciem czyni kasowanie bezużytecznym. Wąska semantyka +„bez prac = soft-delete, z pracami = PROTECT" pokrywa 100% realnej potrzeby. + +--- + +## 2. Publikacje — fundament (Projekt A + wąska kaskada na `*_Autor`) + +5 modeli: `Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`, +`Praca_Habilitacyjna`, `Patent` ← `SoftDeleteModel`. Dodatkowo 3 through-modele +`Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` ← +`SoftDeleteModel` (cel wąskiej kaskady z rodzica, §2.2). + +### 2.1 Trigger jako choke-point (najwrażliwszy, robiony PIERWSZY) + +`Rekord` to UNION-view nad materializowaną tabelą `bpp_rekord_mat`, zasilaną +triggerem `bpp_refresh_cache()`. **Aktualna wersja funkcji to +`src/bpp/migrations/0399_fix_refresh_cache_upsert.sql`** (NIE baseline +`0001_cache_functions.sql` — historyczny; funkcja ewoluowała przez `0112`, +`0310`, `0387`, `0399`, + `0400_drop/restore_cache_triggers`). Z +`bpp_rekord_mat`/`bpp_autorzy_mat` czyta większość systemu (publiczny frontend, +multiseek, global search, ewaluacja `Cache_Punktacja_*`, raporty). + +**Fakt z kodu** (`0399_fix_refresh_cache_upsert.sql`): na `DELETE` trigger usuwa +wiersze z `_mat`; na `UPDATE/INSERT` robi **`DELETE` + upsert** +(`INSERT ... SELECT FROM ... ON CONFLICT DO UPDATE`, pod +`pg_advisory_xact_lock`). `DELETE` przed upsertem jest **bezwarunkowy** (linie +125-126), więc gdy widok źródłowy odfiltruje skasowane — upsert nic nie +re-insertuje. Soft-delete to technicznie `UPDATE` → **bez filtra w widoku +skasowany rekord wróciłby** (upsert wstawiłby go ponownie). (Uwaga: `0399` ma +drobny utajony bug — `"bpp_autorzy_mat" not in refresh_tables` sprawdza string +w liście krotek `(table, id_col)`; do poprawienia przy okazji, faza 01.) + +**Zmiana:** ścieżka `UPDATE/INSERT` triggera uczona reguły: +> jeśli `TD['new']['deleted_at'] IS NOT NULL` → zachowaj się jak `DELETE` +> (usuń z `_mat`, **nie** re-insertuj). +> `deleted_at: →NULL` (restore) → normalny re-insert. + +**Jednolitość dzięki wąskiej kaskadzie na `*_Autor` (§2.2).** Ponieważ +through-modele też stają się `SoftDeleteModel`, **każda z 8 tabel pod +triggerem ma własną kolumnę `deleted_at`** (5 publikacji + 3 `*_autor`). +Trigger czyta `deleted_at` z **własnego** wiersza (`TD['new']`) — reguła +działa identycznie niezależnie od tego, czy zadziałała tabela publikacji czy +tabela autorska. **Nie ma potrzeby JOIN-a/lookupu do rekordu nadrzędnego.** + +Skutki: +- **Mechanizm #1 — filtr `deleted_at IS NULL` w widokach źródłowych** (po + **własnej** kolumnie tabeli, bez JOIN): `bpp_*_autorzy` (selektują + `FROM bpp_*_autor`, `src/bpp/migrations/0001_widoki_autorzy.sql`) i każda + gałąź UNION-u `bpp_rekord`. **To jest nadrzędny mechanizm**, bo pokrywa + WSZYSTKIE ścieżki: re-insert triggera, bezpośredni odczyt z widoku `bpp_rekord` + (`Rekord` czyta `bpp_rekord`, `src/bpp/models/cache/rekord.py:357`), oraz + pełną re-projekcję/weryfikację cache. +- **Mechanizm #2 (optymalizacja) — trigger-skip:** w gałęzi `UPDATE/INSERT` + `if TD['new'].get('deleted_at') is not None: ` (DELETE i tak + już zaszedł). Oszczędza no-op SELECT/upsert, ale **sam nie pokrywa pełnej + re-projekcji** (ta re-selektuje z widoku). Filtr widoku (#1) jest + obowiązkowy; trigger-skip opcjonalny. +- **Weryfikacja spójności — `Rekord.objects.full_refresh()`** (re-projekcja + `_mat` ze źródła), NIE `verify_cache`. ⚠️ `src/bpp/management/commands/verify_cache.py` + to **martwy stub** (`psycopg2.connect(database="b_med", host="linux-dev")` + + `raise NotImplementedError`) — nie da się go uruchomić; jego naprawa jest + POZA zakresem soft-delete. Testy spójności robią pełen `full_refresh` i + sprawdzają, że skasowane rekordy NIE wracają do `_mat` (to weryfikuje filtr + widoku #1). +- **Przypadek brzegowy znika strukturalnie:** edycja wiersza autorstwa + skasowanej publikacji nie wskrzesi go w `bpp_autorzy_mat`, bo widok + źródłowy go odfiltruje (ma własne `deleted_at` ustawione kaskadą). +- **Koszt:** soft-delete publikacji z N autorami odpala N dodatkowych (no-op) + triggerów through. Pomijalne. + +Testy spójności mat-view obowiązkowe (soft-delete → znika z `Rekord` i +`Autorzy`; restore → wraca; po `full_refresh()` skasowane NIE wracają do `_mat`; +brak rozjazdu `Cache_Punktacja_*`). + +### 2.2 Override `delete()` — wąska, kontrolowana kaskada na `*_Autor` + +`SoftDeleteModel.delete()` domyślnie kaskaduje **refleksyjnie** po wszystkich +odwrotnych relacjach. W `strict=True` (domyślny) rzuci `SoftDeleteException` +na nie-soft dzieciach (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, +`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`), a `strict=False` twardo +skasuje je przez CASCADE. **Oba złe.** Dlatego na 5 modelach nadpisujemy +`delete()` tak, by **NIE** używał refleksyjnej kaskady pakietu, lecz: + +1. ustawił własne `deleted_at` i zapisał, +2. **jawnie soft-deletował własne wiersze `*_Autor`** (`Wydawnictwo_Ciagle_Autor` + / `Wydawnictwo_Zwarte_Autor` / `Patent_Autor`) pod **wspólnym + `transaction_id`** — kaskada wąska, kontrolowana, zatrzymana na `*_Autor`. + +Pozostałe dzieci (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, +`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`) **nie są ruszane** (nie są +`SoftDeleteModel`, czyta się je przez rodzica). `restore()` analogicznie: +przywraca rodzica i jego `*_Autor` po `transaction_id`. Trigger (§2.1) usuwa +wszystko z `_mat` na podstawie własnych `deleted_at`; przy restore +re-projektuje ze źródła. + +Po co jawna kaskada na `*_Autor`, skoro trigger i tak czyści `bpp_autorzy_mat`? +Bo **90 miejsc w kodzie czyta `*_Autor.objects` bezpośrednio** (z pominięciem +cache), głównie w `ewaluacja_optymalizacja`. Domyślny menedżer `objects` +`SoftDeleteModel` ukrywa skasowane → te 90 miejsc staje się poprawne +automatycznie, bez ręcznych filtrów `wydawnictwo_ciagle__deleted_at__isnull` +(których pominięcie = po cichu zliczona skasowana praca w ewaluacji). + +Zweryfikować, że nadpisany `delete()`/`restore()` nadal emituje sygnały +`post_soft_delete`/`post_restore` (patrz §5), oraz że ścieżka queryset +(`.delete()` na QS) również kaskaduje na `*_Autor`. + +### 2.3 `slug` — warunkowy unique + +⚠️ `slug` to **pole denormalizowane** (`@denormalized(models.SlugField, ..., +unique=True, ...)` z `django-denorm-iplweb`), zadeklarowane w 4 miejscach: +`wydawnictwo_ciagle.py:246`, `wydawnictwo_zwarte.py:325`, `patent.py:180`, +`Praca_Doktorska_Baza:105` (dzielone przez doktorat i habilitację). Skasowany +rekord trzyma slug → konflikt przy ponownym utworzeniu. Zmiana: **zdjąć +`unique=True` z kwargs denorm** i dodać w `Meta` każdej konkretnej klasy +`UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True))`. +Migracja (NIE modyfikować istniejących migracji). + +### 2.4 Menedżery `Wydawnictwo_*_Manager` + +Dziedziczą po `ManagerModeliZOplataZaPublikacjeMixin` +(`src/bpp/models/abstract/fees.py`). Po wpięciu `SoftDeleteModel` trzeba +**przepleść** filtr soft-delete (`deleted_at__isnull=True`) z istniejącymi +metodami (`rekordy_z_oplata()`, `wydawnictwa_nadrzedne_dla_innych()`) — przez +wspólny `QuerySet`/MRO, nie przez nadpisanie. + +### 2.5 Audyt kategorii B — miejsca, które MUSZĄ widzieć usunięte + +Domyślny menedżer `objects` ukrywa usunięte → kategoria A (wyświetlanie / +eksport / liczenie) staje się czysta automatycznie (zero zmian). Ale +**kategoria B** musi świadomie przejść na `global_objects`, inaczej powstaną +**duplikaty**: + +- `import_common/core/publikacja.py`, `importer_publikacji` — matching importu, +- `crossref_bpp/core.py` — dedup, +- `deduplikator_publikacji/tasks.py` — dedup, +- `pbn_integrator/utils/synchronization.py`, `pbn_integrator/importer/chapters.py`, + `pbn_api/management/*` — matching po `pbn_uid`. + +> **Pułapka nadrzędna:** jeśli importer użyje domyślnego (ukrywającego) +> menedżera, soft-delete staje się generatorem duplikatów. Audyt kat. B jest +> obowiązkowy. + +**Through-modele `*_Autor` (90 miejsc).** Po wpięciu `SoftDeleteModel` +90 bezpośrednich zapytań `*_Autor.objects` (głównie `ewaluacja_optymalizacja`: +`reset_pins`, `reset_disciplines`, `unpin_all_sensible`, `optimization`, +`author_works`, `evaluation_browser`, `verification`; oraz `api_v1`, +`przemapuj_prace_autora`, `ewaluacja_dwudyscyplinowcy`) **staje się poprawne +domyślnie** (pomijają skasowane). Audyt sprawdza wyjątki kat. B: czy +któreś z nich *musi* widzieć skasowane autorstwa (mało prawdopodobne w +ewaluacji — tam „pomiń skasowane" jest poprawnym defaultem) → wtedy +`global_objects`. Domyślny default „pomijaj" jest tu znacznie bezpieczniejszy +niż przeciwny. + +### 2.6 Self-referencja `Wydawnictwo_Zwarte` + GenericForeignKey + +**Self-FK `wydawnictwo_nadrzedne`** (`src/bpp/models/wydawnictwo_zwarte.py:202`, +rozdziały → książka-matka; denorm `@depend_on_related("self", +"wydawnictwo_nadrzedne")`). + +> **DECYZJA: PROTECT — soft-delete książki-matki jest ZABLOKOWANY, jeśli ma +> rozdziały.** Ten sam dwuwarstwowy wzorzec co guard autora (§3): +> - **warstwa 1:** flip FK `wydawnictwo_nadrzedne` `CASCADE→PROTECT` +> (obrona przed hard-delete; migracja state-only), +> - **warstwa 2:** guard w soft-`delete()` `Wydawnictwo_Zwarte` — jeśli rekord +> ma rozdziały (dzieci `wydawnictwo_nadrzedne`), odmów z czytelnym +> komunikatem; operator najpierw usuwa/przenosi rozdziały. +> +> Liczenie rozdziałów: przez `global_objects` (także soft-deletowane +> rozdziały blokują — spójnie z guardem autora §3.2). Dzięki PROTECT problem +> „rozdziały wskazujące na skasowaną książkę" w ogóle nie powstaje, a denorm +> `depend_on_related("self", ...)` nie jest wyzwalany kaskadą (rodzic nie +> może być skasowany, póki ma dzieci). + +**GenericForeignKey** (`Nagroda`, `Publikacja_Habilitacyjna` → rekord przez +`content_type`+`object_id`): przy soft-delete obiekt **fizycznie istnieje**, +więc GFK rozwiązuje się poprawnie — soft-delete jest tu *bezpieczniejszy* niż +hard-delete (mniej sierot). Do rozważenia tylko, czy `nagrody` skasowanego +rekordu mają być nadal pokazywane (domyślnie: skoro rekord w koszu, jego +podstrona i tak znika — kwestia bez realnego skutku). + +--- + +## 3. Autor — dwie warstwy ochrony + soft-delete husków + +Obecne `on_delete` (potwierdzone w kodzie): + +| Powiązanie | Plik | Dziś | Docelowo | +|---|---|---|---| +| `Wydawnictwo_*_Autor.autor` | `src/bpp/models/abstract/authors.py:22` (`CASCADE`) | hard-kasuje autorstwa | **PROTECT** | +| `Praca_Doktorska.autor` | `src/bpp/models/praca_doktorska.py:136` (`CASCADE`) | hard-kasuje doktorat | **PROTECT** | +| `Praca_Habilitacyjna.autor` | `src/bpp/models/praca_habilitacyjna.py:42` (`PROTECT`) | już blokuje | bez zmian | + +### 3.1 Warstwa 1 — flip FK `CASCADE→PROTECT` + +Migracja state-only (Django implementuje `on_delete` w ORM, nie jako +constraint DB → brak zmiany schematu). Broni przed przypadkowym hard-delete +i gołą kaskadą. Tabele atrybutów autora (jednostki, dyscypliny, funkcje, +`Cache_Punktacja_Autora`, profil) **zostają `CASCADE`** — to nie „prace", +mają znikać z autorem. + +### 3.2 Warstwa 2 — guard w soft `Autor.delete()` + +**Krytyczne:** `PROTECT` na FK łapie tylko hard-delete + kolektor kaskady +Django. Soft-delete to `UPDATE deleted_at=now()` — `on_delete` **nigdy się +nie odpala**. Dlatego `Autor.delete()` (soft) musi jawnie sprawdzić: jeśli +autor ma JAKIEKOLWIEK autorstwo (`Wydawnictwo_Ciagle_Autor`, +`Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) / doktorat / habilitację → +odmowa (`ProtectedError`/`ValidationError` z czytelnym komunikatem). + +**Definicja „bez prac":** liczą się WSZYSTKIE wiersze, także wskazujące na +*soft-deletowane* publikacje (najprościej i najbezpieczniej — autor jest +„husk" dopiero gdy naprawdę nic nie wskazuje). Autor `SoftDeleteModel`; jego +wiersze atrybutów zostają nietknięte (restore odtwarza całość). + +> **Interakcja z kaskadą §2.2 (krytyczne!):** `*_Autor` są teraz +> `SoftDeleteModel`, a soft-delete publikacji kaskadowo soft-deletuje ich +> wiersze. Domyślny `*_Autor.objects` **ukrywa** te skasowane autorstwa. +> Gdyby guard użył `objects`, autor, którego wszystkie prace są w koszu, +> wyglądałby na „pustego" i przeszedłby przez guard — łamiąc decyzję „licz +> wszystko, też kosz". **Guard musi liczyć przez `*_Autor.global_objects`** +> (i analogicznie doktorat/habilitację przez `global_objects`), żeby widzieć +> również kaskadowo-skasowane autorstwa. To samo dotyczy FK `PROTECT`: +> chroni przed hard-delete niezależnie od `deleted_at` (constraint DB widzi +> wiersz fizyczny). + +### 3.3 Synergia z `deduplikator_autorow` (merge) + +Merge najpierw przenosi wszystkie prace na autora głównego, potem woła +`autor.delete()` na pustym duplikacie (`src/deduplikator_autorow/views/merge.py:155`; +transfer through-rows w `src/deduplikator_autorow/utils/merge.py:191,284,354`). +Skutki: +- `PROTECT` **nie psuje** merge'a — duplikat jest już pusty w chwili `delete()`. +- Soft-delete sprawia, że husk po scaleniu staje się **odwracalny** (dziś + znika bezpowrotnie) — błędne scalenie da się cofnąć. Darmowy bonus. +- **Do zweryfikowania w planie TDD:** czy merge przenosi WSZYSTKIE typy prac + (ciągłe / zwarte / patent / doktorat / habilitacja) przed `delete()` — + inaczej guard/PROTECT zablokuje usunięcie husku. + +--- + +## 4. PBN — wycofanie oświadczeń przez kolejkę + +### 4.1 Co i kiedy + +Soft-delete publikacji **z `pbn_uid`** → wycofanie **oświadczeń dyscyplin z +profilu instytucji** (publikacja przestaje liczyć się do ewaluacji). Obiektu +publikacji w PBN **nie ruszamy** (jest współdzielony — pełny `DELETE` mógłby +się wywalić; wycofanie oświadczeń jest zawsze bezpieczne). Gate: jeśli rekord +nigdy nie poszedł do PBN (`pbn_uid is None`) — nic nie robimy. + +Prymityw PBN istnieje: +`src/pbn_api/client/mixins/institutions.py:87` → +`delete_all_publication_statements(publicationId)` (+ selektywne +`delete_publication_statement` w `:135`, retry w +`pbn_api/client/publication_sync.py`). + +### 4.2 Mechanizm — rozszerzenie istniejącej `pbn_export_queue` + +Nie wprowadzamy nowego mechanizmu. Kolejka eksportu PBN żyje jako dedykowana +aplikacja **`src/pbn_export_queue/`** (model `PBN_Export_Queue`: GFK +content_type+object_id, `zamowil`, `ilosc_prob`, `zakonczono_pomyslnie`, +`rodzaj_bledu`, klasyfikacja błędów, locking, „ponowna wysyłka", admin, +`send_to_pbn()`). + +Rozszerzenie: +- dodać pole `operacja: TextChoices(WYSYLKA, WYCOFANIE)` (default `WYSYLKA` + dla kompatybilności wstecznej), migracja, +- gałąź w logice wysyłki: `WYCOFANIE` → `delete_all_publication_statements`, +- status zapisywany jak dla wysyłki (`zakonczono_pomyslnie`, `komunikat`, + `ilosc_prob`) + odzwierciedlenie w `SentData` i `SoftDeleteLog`. + +⚠️ **`PBN_Export_Queue.zamowil` jest NOT NULL (`on_delete=CASCADE`).** +Zakolejkowanie z admina ma `request.user`. Ale zakolejkowanie inicjowane +sygnałem przy soft-delete bez usera (operacje programistyczne / celery) nie ma +kogo wpisać. Rozwiązanie (faza 05/06): **konto techniczne** (np. +`get_or_create` systemowego użytkownika) jako `zamowil` dla operacji +systemowych — NIE robić `zamowil` nullable (psułoby istniejące zał. kolejki). + +`SentData` (`src/pbn_api/models/sentdata.py`, GFK + `pbn_uid` + +`submitted_successfully` + `mark_as_successful`/`mark_as_failed`) trzyma stan +PBN per-rekord. **Po udanym wycofaniu:** ustawiamy `submitted_successfully = +False` (rekord nie jest już „wystawiony" w PBN) i dodajemy znacznik wycofania +(np. `withdrawn_at` — nowe pole, lub `api_response_status`); **wiersza +`SentData` NIE kasujemy** — zostaje dla audytu i re-matchingu przy restore. +Restore (`WYSYLKA`) → ponowne `mark_as_successful` po udanej wysyłce. + +### 4.3 Restore → symetria + +Restore publikacji → wpis `WYSYLKA` w `pbn_export_queue` (ponowna wysyłka +oświadczeń, dyscypliny wracają do profilu). Symetria delete↔restore. + +--- + +## 5. SoftDeleteLog — dedykowany audyt (NASZ model) + +`django-soft-delete` **nie ma** żadnej tabeli-logu — daje tylko pola +`deleted_at`/`restored_at`/`transaction_id` oraz **trzy sygnały**: +`post_soft_delete`, `post_hard_delete`, `post_restore` +(`django_softdelete/signals.py`). Audyt budujemy sami. + +**Model `SoftDeleteLog`:** `content_type`, `object_id` (GFK), `akcja` +(`DELETE`/`RESTORE`/`HARD_DELETE`), `user` (kto), `timestamp`, `powod` +(tekst), FK/link do wpisu `pbn_export_queue` + jego status. Centralny dla +wszystkich soft-deletowalnych typów; zasila widok „Kosz"; jedno miejsce +prawdy „co / kto / dlaczego zniknęło i czy PBN przyjął". + +**Zasilanie przez receivery sygnałów** (jeden punkt podpięcia dla wszystkich +modeli — odporne na pominięcie): +- `post_soft_delete` → `SoftDeleteLog(DELETE)` + (jeśli `pbn_uid`) wpis + `WYCOFANIE` w `pbn_export_queue`, +- `post_restore` → `SoftDeleteLog(RESTORE)` + wpis `WYSYLKA`, +- `post_hard_delete` → `SoftDeleteLog(HARD_DELETE)`. + +**Niuans „kto":** sygnał nie niesie użytkownika (`delete()` pakietu nie zna +requestu). `user` wstrzykujemy jawnie z warstwy admina (akcja superusera ma +`request.user` pod ręką — przekazujemy go do `delete(user=...)` / przez +kontekst). Operacje systemowe (np. merge, celery) logują `user=None` lub +konto techniczne. + +--- + +## 6. Admin (superuser-only) + +Dla 5 modeli publikacji + `Autor`: +- „Usuń" = **soft-delete** (kosz); „Usuń trwale" = osobna, jawnie oznaczona + akcja superusera (`hard_delete`), +- filtr „Pokaż skasowane" (`deleted_objects`/`global_objects`) + akcja + „Przywróć", +- pole „powód" przy kasowaniu (trafia do `SoftDeleteLog`), +- admin świadomie używa `global_objects`/`deleted_objects` (nie domyślnego + ukrywającego menedżera), +- dla `Autor`: próba soft-delete autora z pracami → czytelny komunikat + z guarda (§3.2). + +Precedens: `src/zglos_publikacje/models.py` (`Zgłoszenie_Publikacji` już jest +`SoftDeleteModel` — wzorzec menedżerów/migracji/admina). + +--- + +## 7. Retencja + +Brak automatycznego czyszczenia kosza. Soft-deletowane rekordy trwają do +ręcznego „Usuń trwale" superusera. (Auto-hard-delete po N dniach — świadomie +odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, +`src/django_bpp/settings/base.py:670`, jeśli zajdzie potrzeba.) + +--- + +## 8. Kolejność prac (fazy; szczegółowy TDD → writing-plans) + +1. **`*_Autor` + trigger + widoki** — kolejność wewnątrz fazy: (a) migracja + `SoftDeleteModel` na 3 through-modelach (`deleted_at`+indeks) — **musi być + PRZED** (b), bo trigger/widok czytają tę kolumnę; (b) filtr `deleted_at IS + NULL` w widokach źródłowych `bpp_rekord`/`bpp_*_autorzy` (mechanizm #1, po + własnej kolumnie); (c) funkcja `bpp_refresh_cache()` z regułą + `deleted_at IS NOT NULL → pomiń re-insert` (opcjonalna optymalizacja). + Testy spójności mat-view + `verify_cache`. **Najwrażliwsze, pierwsze.** +2. **Publikacje** — `SoftDeleteModel` na 5 modelach, override + `delete()`/`restore()` z **wąską kaskadą na `*_Autor`** (wspólny + `transaction_id`, bez refleksyjnej kaskady pakietu), migracje + (`deleted_at`+indeks, ew. `CONCURRENTLY`), `slug` `UniqueConstraint`, + przeplecenie menedżerów. +3. **Audyt kat. B** — przełączenie import/dedup/PBN-matching na + `global_objects`; audyt 90 miejsc `*_Autor.objects` (default „pomijaj" + poprawny, wyjątki → `global_objects`). Testy: re-import nie tworzy + duplikatów; ewaluacja pomija prace w koszu. +4. **Guardy PROTECT** (ten sam wzorzec: flip FK + guard liczący przez + `global_objects`): + - **Autor** — flip FK `CASCADE→PROTECT` (`*_Autor`, doktorat), guard w soft + `delete()` (widzi kaskadowo-skasowane autorstwa), soft-delete husku; + weryfikacja merge. + - **`Wydawnictwo_Zwarte` (rozdziały)** — flip FK `wydawnictwo_nadrzedne` + `CASCADE→PROTECT`, guard w soft `delete()` blokujący gdy ma rozdziały + (§2.6). +5. **PBN** — `operacja WYCOFANIE` w `pbn_export_queue` + restore→`WYSYLKA`; + integracja `SentData`. +6. **SoftDeleteLog** + receivery sygnałów (`post_soft_delete`/`post_restore`/ + `post_hard_delete`), wstrzykiwanie `user`. +7. **Admin** — kosz / filtr / przywróć / usuń-trwale / powód (5 modeli + + `Autor`). +8. **Testy regresji** — pełna suita: PBN (duplikaty + wycofanie), dashboard, + import, ewaluacja, merge autorów, API. Do ~10 min. + +--- + +## 9. Ryzyka + +- **Cache/trigger** — rozjazd, jeśli `deleted_at` nie obsłużone we wszystkich + 8 tabelach (5 publikacji + 3 `*_autor`) + ścieżce UPDATE + widokach + źródłowych. Najgroźniejsze, wydajnościowo wrażliwe. Mitygacja: testy + spójności jako pierwsze. +- **Guard autora przez `objects` zamiast `global_objects`** — autor z pracami + tylko-w-koszu przeszedłby przez guard (autorstwa kaskadowo skasowane są + ukryte). MUSI być `global_objects` (§3.2). +- **Duplikaty** z importu/PBN/dedup, jeśli kat. B nie przejdzie na + `global_objects`. +- **Merge autorów** — jeśli nie przenosi wszystkich typów prac przed + `delete()`, PROTECT/guard zablokuje. Zweryfikować. +- **`user` w sygnałach** — łatwo zalogować `None`; zadbać o wstrzyknięcie + z admina. +- **Denorm** (`django-denorm-iplweb`, `pre_save`) — soft-delete go wprost nie + psuje, ale zweryfikować `cached_punkty_dyscyplin` po restore. +- **Migracje na dużych tabelach produkcyjnych** — `deleted_at` domyślnie + `NULL` (bez backfillu), indeks `CONCURRENTLY` jeśli rozmiar wymaga. + +--- + +## 10. Decyzje rozstrzygnięte (z brainstormingu 2026-06-04) + +1. **Autor:** z pracami → `PROTECT`; bez prac → soft-delete (husk). Guard + liczy przez `global_objects`. Soft-delete autora **nie** kaskaduje do + `*_Autor`. Doktorat/habilitacja: FK do autora → `PROTECT`. +2. **Publikacje:** Projekt A z **wąską kaskadą na `*_Autor`** — 5 modeli + + 3 through-modele `*_Autor` stają się `SoftDeleteModel`; override `delete()` + soft-deletuje rodzica i jego `*_Autor` (wspólny `transaction_id`), bez + refleksyjnej kaskady na pozostałe dzieci. Trigger jako choke-point, + jednolity dzięki własnym `deleted_at` na wszystkich 8 tabelach (bez JOIN + do rodzica). Powód kaskady: 90 miejsc `*_Autor.objects` w ewaluacji. +3. **PBN przy soft-delete:** wycofanie oświadczeń instytucji + (`delete_all_publication_statements`), gate na `pbn_uid`; obiektu + publikacji nie kasujemy. +4. **PBN — mechanizm:** rozszerzenie `pbn_export_queue` o operację + `WYCOFANIE` (async, retry, admin — istniejąca infra). +5. **Restore → PBN:** auto-zakolejkowanie `WYSYLKA`. +6. **Log:** dedykowany `SoftDeleteLog` zasilany sygnałami pakietu. +7. **Admin:** superuser-only; soft-delete zastępuje „usuń"; hard-delete jako + osobna jawna akcja. +8. **Retencja:** brak auto-czyszczenia; tylko ręczny hard-delete. +9. **Cache — mechanizm nadrzędny:** filtr `deleted_at IS NULL` w widokach + źródłowych (pokrywa trigger, odczyt z `bpp_rekord`, `verify_cache`); + trigger-skip to opcjonalna optymalizacja. +10. **SentData przy wycofaniu:** `submitted_successfully=False` + znacznik + wycofania, wiersza nie kasujemy. +11. **Self-FK `Wydawnictwo_Zwarte` (rozdziały):** **PROTECT** — soft-delete + książki-matki zablokowany, jeśli ma rozdziały (flip FK `CASCADE→PROTECT` + + guard liczący przez `global_objects`, §2.6). Wzorzec jak guard autora. + +--- + +## 11. Precedensy w repo + +- `django-soft-delete>=1.0.23` — `pyproject.toml`. +- `src/zglos_publikacje/models.py` — `Zgłoszenie_Publikacji` już + `SoftDeleteModel` (wzorzec). +- `src/pbn_export_queue/` — dojrzała kolejka PBN (model + Celery + admin + + retry/lock), wzorzec dla operacji `WYCOFANIE`. +- `src/pbn_api/models/sentdata.py` — `SentData` (stan PBN per-rekord). +- `src/bpp/models/oplaty_log.py`, log w `deduplikator_autorow` — precedensy + tabel-logów.