From 002ee6723d9e90129c1745b86e26fe23d76056f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 22:23:28 +0200 Subject: [PATCH 1/4] =?UTF-8?q?docs(soft-delete):=20spec=20wykonalno=C5=9B?= =?UTF-8?q?ci=20soft-delete=20dla=205=20typ=C3=B3w=20publikacji=20(OD?= =?UTF-8?q?=C5=81O=C5=BBONE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analiza wprowadzenia soft-delete dla Wydawnictwo_Ciagle/Zwarte, Praca_Doktorska, Praca_Habilitacyjna, Patent. Status: świadomie odłożone — to spec/rozpoznanie, nie zlecenie implementacji. Kluczowe ustalenia: - choke-point w triggerze bpp_refresh_cache(): "deleted_at IS NOT NULL" traktowany jak DELETE → wszystko czytające przez Rekord/Cache_* czyści się jednym ruchem, - django-soft-delete już w repo (pyproject.toml), precedens w zglos_publikacje; domyślny manager ukrywa usunięte → kat. A czysta za darmo, kat. B (import/dedup/PBN) musi przejść na global_objects, - kaskada/auto-undelete pakietu zweryfikowana w kodzie: strict=True wymaga by dzieci były SoftDeleteModel → rekomendacja Projekt A (override delete(), dzieci nietknięte, cache/trigger robi resztę), - slug unique → warunkowy UniqueConstraint(deleted_at__isnull=True), - szacunek ~2-3 tygodnie; otwarte decyzje spisane. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-03-soft-delete-publikacje.md | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md diff --git a/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md new file mode 100644 index 000000000..5586b6dc5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md @@ -0,0 +1,313 @@ +# Spec: Soft-delete dla rekordów publikacji (Wydawnictwo_Ciagle/Zwarte, Doktorat, Habilitacja, Patent) + +> ⛔ **STATUS: ODŁOŻONE — na razie (2026-06-03).** +> Ten dokument to spec/analiza wykonalności, a **NIE** zlecenie do +> implementacji. Decyzja: świadomie wstrzymujemy realizację. Spec +> spisany, żeby nie tracić rozpoznania; gdy wrócimy do tematu, startujemy +> stąd. Patrz sekcja [„Dlaczego odkładamy"](#dlaczego-odkładamy). + +**Cel:** Umożliwić „miękkie" kasowanie 5 typów rekordów publikacji — +zamiast fizycznego `DELETE` ustawiamy znacznik `deleted_at`, dzięki czemu +rekord znika z +widoku publicznego/ewaluacji/API, ale dane (w tym powiązania autor↔rekord) +zostają i można je przywrócić. + +**Architektura (jednozdaniowo):** Wykorzystujemy istniejący w repo pakiet +`django-soft-delete` (`SoftDeleteModel`) na 5 modelach źródłowych, a +spójność z resztą systemu osiągamy w JEDNYM punkcie — w triggerze +PostgreSQL zasilającym materializowany cache `bpp_rekord_mat`, który uczymy +traktować „skasowany" jak zdarzenie DELETE. + +**Stack:** Django, PostgreSQL (triggery `plpython3u`), `django-soft-delete` +(już w `pyproject.toml`), `django-denorm-iplweb`. + +--- + +## 1. Motywacja + +Dziś `Wydawnictwo_Ciagle/Zwarte`, `Praca_Doktorska`, `Praca_Habilitacyjna`, +`Patent` kasuje się fizycznie (`DELETE`). To pociąga kaskadowo: +- usunięcie wierszy przez-modeli `*_Autor` (powiązania autorów), +- usunięcie wpisów w `bpp_rekord_mat` / `bpp_autorzy_mat` (przez trigger), +- utratę danych bez możliwości cofnięcia. + +Soft-delete daje: odzyskiwalność, ślad audytowy, oraz — co podkreślił +użytkownik — **zachowanie powiązań `*_Autor`** nawet gdy rekord nadrzędny +„znika" z widoków. + +## 2. Modele w zakresie + +| Model | Plik | Menedżer własny? | Through-model autorów | +|---|---|---|---| +| `Wydawnictwo_Ciagle` | `src/bpp/models/wydawnictwo_ciagle.py` | TAK (`Wydawnictwo_Ciagle_Manager`) | `Wydawnictwo_Ciagle_Autor` | +| `Wydawnictwo_Zwarte` | `src/bpp/models/wydawnictwo_zwarte.py` | TAK (`Wydawnictwo_Zwarte_Manager`) | `Wydawnictwo_Zwarte_Autor` | +| `Praca_Doktorska` | `src/bpp/models/praca_doktorska.py` | NIE | (brak; `autor` FK) | +| `Praca_Habilitacyjna` | `src/bpp/models/praca_habilitacyjna.py` | NIE | (brak; `autor` O2O) | +| `Patent` | `src/bpp/models/patent.py` | NIE | `Patent_Autor` | + +Żaden z 5 modeli **nie ma** dziś własnego `delete()` ani sygnałów +`pre/post_delete` po stronie Pythona — cała logika kasowania siedzi w +triggerach DB. To upraszcza warstwę ORM. + +## 3. Kluczowa decyzja architektoniczna — „choke-point" w triggerze + +`Rekord` to UNION-view (`bpp_rekord`) nad materializowaną tabelą +`bpp_rekord_mat`, zasilaną triggerem `bpp_refresh_cache()` +(`src/bpp/migrations/107_cache_functions.sql`). Z tej tabeli czyta +**większość systemu**: publiczny frontend (`browse.py`), `multiseek`, +global search (`search_index`), ewaluacja (`Cache_Punktacja_*`), +raporty. + +**Fakt z kodu** (`107_cache_functions.sql:81-86`): na `DELETE` trigger +usuwa wiersze z `bpp_rekord_mat`/`bpp_autorzy_mat`; na `UPDATE/INSERT` +re-insertuje. Ponieważ soft-delete to technicznie `UPDATE`, **bez zmiany +triggera skasowany rekord wróciłby do mat-view** i był widoczny wszędzie. + +**Rozwiązanie:** trigger uczymy reguły (kolumna w DB to `deleted_at`, +nie boolean): +> jeśli `NEW.deleted_at IS NOT NULL` → zachowaj się jak `DELETE` +> (usuń wiersze z `bpp_rekord_mat` + `bpp_autorzy_mat`, **nie** re-insertuj). +> Przywrócenie (`deleted_at: →NULL`) to zwykły UPDATE → normalny +> re-insert. + +Skutek: **wszystko, co czyta przez `Rekord`/`Autorzy`/`Cache_*`, czyści +się jednym ruchem, bez dotykania kodu konsumentów.** + +## 4. Odwrócenie zakresu dzięki `django-soft-delete` + +`SoftDeleteModel` (zweryfikowane w +`.venv/.../django_softdelete/models.py`) udostępnia: +- pola DB: `deleted_at`, `restored_at`, `transaction_id` (DateTime/UUID; + **nie ma** boolowskiego pola — `is_deleted` to *property* nad + `deleted_at`, filtr w ORM to `deleted_at__isnull`), +- `objects` (`SoftDeleteManager`) — **domyślnie wyklucza** skasowane, +- `global_objects` (`GlobalManager`) — wszystkie (z usuniętymi), +- `deleted_objects` (`DeletedManager`) — tylko usunięte, +- `.delete()` → soft, `.hard_delete()` → fizyczne, `.restore()` → + przywrócenie. + +Konsekwencja dla naszej wcześniejszej analizy „223 miejsc / 47 przecieków": + +- **Kategoria A („leak" — wyświetlanie/eksport/liczenie):** ich kod używa + `Model.objects` → po wpięciu `SoftDeleteModel` **stają się czyste + automatycznie**. Zero zmian w tych plikach. Dotyczy m.in.: + `api_v1` (viewsety/serializery), `admin_dashboard` (statystyki, + time-series), `bpp/views/autocomplete/*`, `bpp/views/browse.py` + (strona Źródła), `bpp/admin_site.py` (liczniki), `komparator_pbn`, + `ewaluacja_optymalizacja/utils.py`, `verification.py`, + `ranking_autorow/forms.py`. +- **Kategoria B („wants-deleted" — MUSI widzieć usunięte):** te miejsca + trzeba **świadomie przełączyć** z `objects` na `global_objects`, + inaczej powstaną duplikaty / niespójny sync. To jest realny zakres + pracy. Dotyczy: + - `import_common/core/publikacja.py`, `importer_publikacji` — matching + przy imporcie (inaczej re-import odtworzy skasowaną publikację), + - `crossref_bpp/core.py:178,182` — dedup, + - `deduplikator_publikacji/tasks.py` — dedup, + - `pbn_integrator/utils/synchronization.py:42,69`, + `pbn_integrator/importer/chapters.py:64`, `pbn_api/management/*` — + sync z PBN (decyzja: skasowanie powinno raczej polecieć do PBN jako + wycofanie; matching musi widzieć usunięte po `pbn_uid`). + +> **Pułapka nadrzędna:** to właśnie kat. B jest groźna. Gdyby domyślny +> menedżer ukrywał usunięte, a importer go użył — soft-delete zamienia się +> w generator duplikatów. Audyt kat. B jest obowiązkowy. + +## 5. Punkty wymagające osobnej uwagi + +### 5.1 Własne menedżery `Wydawnictwo_*_Manager` +Dziedziczą po `ManagerModeliZOplataZaPublikacjeMixin` +(`src/bpp/models/abstract/fees.py`). Po wpięciu `SoftDeleteModel` muszą +**złączyć** zachowanie soft-delete (filtr `deleted_at__isnull=True`) z +istniejącą metodą `.rekordy_z_oplata()` / `.wydawnictwa_nadrzedne_dla_innych()`. +Nie wolno ich nadpisać — trzeba przepleść (MRO / wspólny `QuerySet`). + +### 5.2 Unikalny `slug` +Wszystkie 5 modeli ma denormalizowany `slug` z `unique=True`. Skasowany +rekord trzyma slug zajęty → konflikt przy ponownym utworzeniu. +Rozwiązanie: warunkowy constraint +`UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True))` +zamiast `unique=True`. Wymaga migracji (nie modyfikować istniejących!). + +### 5.3 Through-modele `*_Autor` i dane pochodne (Cache_Punktacja_*) +Kluczowa obserwacja: `bpp_autorzy_mat`, `Cache_Punktacja_Autora`, +`Cache_Punktacja_Dyscypliny` są **pochodne** — zasila/czyści je trigger. +Gdy rodzic znika z `bpp_rekord_mat`, znikają i one (trigger + FK cascade +`bpp_autorzy_mat`→`bpp_rekord_mat`, `0001_cache_init.sql:19`). Przy +`restore` trigger re-projektuje je ze źródła. **To dzieje się automatycznie +niezależnie od tego, czy `*_Autor` są soft-delete czy nie** — bo cache +jest pochodny. + +Pozostaje pytanie o same wiersze **źródłowe** `*_Autor`: zostawić nietknięte +(Projekt A) czy też je soft-deletować kaskadą (Projekt B). Pełna analiza i +rekomendacja — sekcja [5.5](#55-kaskada-delete--auto-undelete--co-naprawdę-robi-pakiet). +(Uwaga: trzeba zweryfikować, czy `Cache_Punktacja_*` faktycznie czyści ten +sam trigger, czy osobny mechanizm przeliczania — jeśli osobny, restore może +wymagać re-przeliczenia.) + +### 5.4 GenericForeignKey — sieroty +`Publikacja_Habilitacyjna` i `Nagroda` wskazują na te modele przez +`content_type`+`object_id`. GFK nie kaskaduje. Przy soft-delete obiekt +fizycznie istnieje, więc GFK dalej rozwiązuje się poprawnie — to akurat +**plus** soft-delete (mniej sierot niż przy hard-delete). Trzeba tylko +zdecydować, czy `nagrody`/`publikacje_habilitacyjne` skasowanego rekordu +mają być nadal pokazywane. + +### 5.5 Kaskada delete / auto-undelete — co NAPRAWDĘ robi pakiet +**Zweryfikowane w kodzie** (`django_softdelete/models.py`, `delete()` + +`restore()`): + +- `SoftDeleteModel.delete()` **domyślnie kaskaduje refleksją** po + odwrotnych relacjach (`one_to_one`, `one_to_many` = reverse FK), + pomijając `GenericRelation`. Każdemu skasowanemu obiektowi nadaje wspólny + `transaction_id`. +- `restore()` używa `transaction_id`, żeby **automatycznie odtworzyć + dokładnie tę samą grupę** → „un-delete z automatu" działa. +- **ALE** w trybie `strict=True` (domyślny) jeśli powiązany model **nie + jest** `SoftDeleteModel` → `SoftDeleteException`. A `strict=False` → + dzieci z `on_delete=CASCADE` zostają **fizycznie skasowane** (czyli + `*_Autor` przepadają, restore ich nie wskrzesi). + +**Problem dla nas:** 5 modeli ma liczne nie-soft dzieci: `*_Autor`, +`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, `Publikacja_Habilitacyjna`, +`Opi_2012_Tytul_Cache`. Goła kaskada pakietu **albo rzuci wyjątek +(strict), albo twardo skasuje dzieci (non-strict)** — oba złe. + +**Dwa spójne projekty** (rozstrzygnąć w [otwartych decyzjach](#7-otwarte-decyzje), +pkt 1): + +- **Projekt A — „cache sam to robi" (rekomendowany).** + Override `delete()` na 5 modelach tak, by **NIE** robił refleksyjnej + kaskady — tylko ustawia `deleted_at` i zapisuje. Dzieci `*_Autor` + zostają **nietknięte** w tabeli źródłowej. Trigger usuwa rodzica z + `bpp_rekord_mat` → `bpp_autorzy_mat` i `Cache_Punktacja_*` znikają + automatycznie (są pochodne). `restore()` = `deleted_at→NULL` → trigger + **re-projektuje** wszystko ze źródła (bo `*_Autor` nigdy nie zniknęły). + - Plusy: minimalny blast radius, brak wirusowego soft-delete, restore + automatyczny przez warstwę cache. + - Minus: bezpośrednie zapytania `Wydawnictwo_*_Autor.objects` (z + pominięciem rodzica) nadal widzą autorstwa skasowanych rekordów → + trzeba dodać `.filter(rekord__deleted_at__isnull=True)` w kilku + miejscach (ewaluacja `verification.py`, `komparator_pbn`). + +- **Projekt B — pełna kaskada soft-delete.** + `*_Autor` (i pozostałe dzieci, które chcemy móc przywrócić) stają się + `SoftDeleteModel`. Kaskada + `transaction_id` + auto-restore działają + „z pudełka", a bezpośrednie `*_Autor.objects` czyszczą się same. + - Plusy: spójne z grain pakietu, brak ręcznych filtrów na through-modelach. + - Minusy: efekt **wirusowy** — `*_Streszczenie`, `*_Zewnetrzna_Baza`, + `Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache` też muszą stać się + soft-delete (albo zaakceptować ich twardy CASCADE). Dużo więcej + migracji i pól; trzeba zweryfikować, że kaskada nie koliduje z + triggerem (podwójne odświeżanie). + +> **Rekomendacja:** Projekt A. Warstwa cache (`Rekord`/trigger) już +> realizuje „pochodne dane znikają i wracają", więc kaskada pakietu jest +> redundantna i tylko mnoży blast radius. Override `delete()` + kilka +> filtrów na through-modelach. + +### 5.6 Self-referencja `Wydawnictwo_Zwarte → Wydawnictwo_Zwarte` +Rozdziały wskazują na książkę-matkę (`wydawnictwo_nadrzedne`, CASCADE). +Niezależnie od projektu z 5.5 trzeba zdecydować, czy soft-delete +książki-matki pociąga soft-delete rozdziałów (patrz +[otwarte pytania](#7-otwarte-decyzje), pkt 1). + +## 6. Szkic zakresu prac (gdy wrócimy) + +Kolejność (nie pełny TDD — to spec; szczegółowy plan TDD powstanie przy +realizacji): + +1. **Trigger** `bpp_refresh_cache()` — nowa migracja SQL: obsługa + `NEW.deleted_at IS NOT NULL` jako DELETE. + testy spójności mat-view (soft-delete → + znika z `Rekord`; restore → wraca). **Najwrażliwszy, najpierw.** +2. **Modele** — wpięcie `SoftDeleteModel` w 5 modeli; migracja dodająca + pola pakietu (`deleted_at`, `restored_at`, `transaction_id`) + indeks na + `deleted_at`. `is_deleted` to property — **nie** tworzyć osobnego pola. + Nie modyfikować istniejących migracji. + - Przy **Projekcie A** (rekomendacja, sekcja 5.5): override `delete()` + tak, by **nie** robił refleksyjnej kaskady pakietu (ustaw `deleted_at` + i `save()`), inaczej `strict=True` rzuci wyjątkiem na nie-soft + dzieciach. Zweryfikować też `restore()` (analogicznie bez kaskady). +3. **Menedżery** — przeplecenie soft-delete z `Wydawnictwo_*_Manager`. +4. **`slug`** — warunkowy `UniqueConstraint` (migracja). +5. **Audyt kat. B** — przełączenie import/dedup/PBN na `global_objects`. +6. **Admin** — akcja „przenieś do kosza" (zamiast hard-delete), filtr + „pokaż skasowane", akcja „przywróć". Admin świadomie używa + `global_objects`/`deleted_objects`. +7. **Testy regresji** — PBN sync (duplikaty!), dashboard, import, + ewaluacja, API. Pełna suita (do ~10 min). + +## 7. Otwarte decyzje + +Do rozstrzygnięcia **zanim** ruszymy implementację: + +1. **Projekt kaskady (A vs B) — patrz [5.5](#55-kaskada-delete--auto-undelete--co-naprawdę-robi-pakiet).** + Rekomendacja: **Projekt A** (override `delete()`, dzieci nietknięte, + cache/trigger robi resztę). Do potwierdzenia. Powiązane: czy soft-delete + książki-matki `Wydawnictwo_Zwarte` pociąga rozdziały + (`wydawnictwo_nadrzedne`)? (Propozycja: NIE automatycznie; ostrzeżenie + w adminie.) +2. **PBN przy skasowaniu:** czy skasowana publikacja leci do PBN jako + wycofanie/oświadczenie usuwające, czy tylko przestaje się + synchronizować? +3. **Kto może kasować/przywracać** i czy potrzebny osobny perm + (`can_soft_delete` / `can_restore`). +4. **Retencja / hard-delete:** czy po N dniach „kosz" czyści się fizycznie + (zadanie celery), czy zostaje na zawsze. +5. **Widoczność `nagrody`/`publikacje_habilitacyjne`** skasowanego rekordu. +6. Czy soft-delete dotyczy też przez-modeli `*_Autor` osobno (np. usunięcie + pojedynczego współautorstwa), czy tylko rekordów nadrzędnych. + +## 8. Szacunek nakładu + +Przy tej architekturze (trigger jako choke-point + istniejący +`django-soft-delete`): + +| Obszar | Nakład | +|---|---| +| Trigger + 5 widoków + testy spójności cache | 2–3 dni | +| Modele + menedżery + migracje (`deleted_at`, `slug` constraint) | 1–2 dni | +| Audyt kat. B (`global_objects`) | 2–3 dni | +| Admin (kosz/filtr/przywracanie) | 2–3 dni | +| Testy regresji (PBN, dashboard, import, ewaluacja) | 3–5 dni | + +**Razem realnie ~2–3 tygodnie.** Najwięcej ryzyka: (1) trigger/cache, +(2) duplikaty z importu/PBN przy źle zrobionym kat. B. + +## 9. Ryzyka + +- **Cache rozjedzie się**, jeśli trigger nie obsłuży `deleted_at IS NOT + NULL` we wszystkich 5 tabelach + ścieżce UPDATE. Najgroźniejszy, + wydajnościowo wrażliwy fragment. +- **Duplikaty** z importu/PBN/dedup, jeśli kat. B nie przejdzie na + `global_objects`. +- **Denorm** (`django-denorm-iplweb`) działa na `pre_save` — soft-delete + go bezpośrednio nie psuje, ale warto zweryfikować `cached_punkty_dyscyplin` + po przywróceniu rekordu. +- Migracje dotykają 5 dużych tabel produkcyjnych — `deleted_at` domyślnie + `NULL` (brak backfillu), indeks na `deleted_at` zakładać `CONCURRENTLY` + jeśli rozmiar tego wymaga. + +## 10. Precedens w repo + +- `django-soft-delete>=1.0.23` — `pyproject.toml:122`. +- `src/zglos_publikacje/models.py:10,61` — `Zgłoszenie_Publikacji` już + dziedziczy po `SoftDeleteModel`. Wzorzec do naśladowania (menedżery, + migracja, admin). + +--- + +## Dlaczego odkładamy + +Świadoma decyzja z **2026-06-03**: temat jest dobrze rozpoznany i +wykonalny (~2–3 tyg.), ale **nie wchodzi teraz w realizację**. Powody: +inne priorytety (m.in. integracja DSpace, prace nad powiązaniami autorów). +Spec spisany, żeby rozpoznanie nie wyparowało. Gdy wrócimy: + +1. rozstrzygnąć [otwarte decyzje](#7-otwarte-decyzje), +2. zacząć od triggera (sekcja 6, krok 1) jako najwrażliwszego, +3. dopiero potem reszta. + +> Niniejszy dokument NIE jest planem TDD do wykonania. Przy starcie +> realizacji należy wygenerować szczegółowy plan implementacyjny +> (skill `superpowers:writing-plans`) na bazie tego speca. From 50d5a4cf5226bc653f5340b4449b4290ed0fa451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 09:53:21 +0200 Subject: [PATCH 2/4] =?UTF-8?q?docs(soft-delete):=20projekt=20wdro=C5=BCen?= =?UTF-8?q?iowy=20soft-delete=20publikacji=20+=20autor=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zatwierdzony design (brainstorming 2026-06-04) rozszerzający feasibility-spec o: soft-delete autora (PROTECT z pracami / soft-delete husku bez prac), wycofanie oświadczeń z PBN przez rozszerzenie pbn_export_queue (operacja WYCOFANIE, async+retry), dedykowany SoftDeleteLog zasilany sygnałami pakietu, oraz admin superuser-only (kosz/filtr/przywróć/usuń-trwale). Kluczowe decyzje: - asymetria: pełny SoftDeleteModel dla 5 publikacji (Projekt A, trigger jako choke-point), ale autor soft-delete TYLKO bez prac → through-modele/doktorat /habilitacja NIE stają się soft-delete (mały blast radius), - flip FK autor CASCADE→PROTECT + guard w soft delete() (PROTECT nie łapie UPDATE-owego soft-delete), - synergia z deduplikator_autorow: husk po merge staje się odwracalny, - PBN: wycofanie oświadczeń instytucji (delete_all_publication_statements), obiektu publikacji nie kasujemy; restore → re-WYSYLKA, - retencja: brak auto-czyszczenia, tylko ręczny hard-delete. Stary 2026-06-03-soft-delete-publikacje.md oznaczony jako ZASTĄPIONY. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-03-soft-delete-publikacje.md | 12 +- ...soft-delete-publikacje-i-autorzy-design.md | 356 ++++++++++++++++++ 2 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md diff --git a/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md index 5586b6dc5..0646e70c3 100644 --- a/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md +++ b/docs/superpowers/specs/2026-06-03-soft-delete-publikacje.md @@ -1,10 +1,12 @@ # Spec: Soft-delete dla rekordów publikacji (Wydawnictwo_Ciagle/Zwarte, Doktorat, Habilitacja, Patent) -> ⛔ **STATUS: ODŁOŻONE — na razie (2026-06-03).** -> Ten dokument to spec/analiza wykonalności, a **NIE** zlecenie do -> implementacji. Decyzja: świadomie wstrzymujemy realizację. Spec -> spisany, żeby nie tracić rozpoznania; gdy wrócimy do tematu, startujemy -> stąd. Patrz sekcja [„Dlaczego odkładamy"](#dlaczego-odkładamy). +> 🔁 **STATUS: ZASTĄPIONY (2026-06-04).** +> Ten dokument to wczesna analiza wykonalności (publikacje-only, „ODŁOŻONE"). +> Aktualnym, zatwierdzonym do realizacji projektem wdrożeniowym — obejmującym +> publikacje ORAZ soft-delete autora, wycofanie z PBN przez kolejkę, tabelę-log +> i admin — jest: +> [`2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](2026-06-04-soft-delete-publikacje-i-autorzy-design.md). +> Pozostawiony jako kontekst historyczny rozpoznania. **Cel:** Umożliwić „miękkie" kasowanie 5 typów rekordów publikacji — zamiast fizycznego `DELETE` ustawiamy znacznik `deleted_at`, dzięki czemu diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md new file mode 100644 index 000000000..55dd9db7a --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md @@ -0,0 +1,356 @@ +# Spec: Soft-delete publikacji + autorów (jedno opracowanie wdrożeniowe) + +> ✅ **STATUS: DO REALIZACJI (2026-06-04).** +> Ten dokument jest projektem wdrożeniowym (design), zatwierdzonym przez +> użytkownika. Zastępuje feasibility-spec +> [`2026-06-03-soft-delete-publikacje.md`](2026-06-03-soft-delete-publikacje.md) +> (publikacje-only, „ODŁOŻONE") i rozszerza go o: soft-delete autora, +> wycofanie z PBN przez kolejkę, tabelę-log audytu oraz wsparcie w adminie. +> Szczegółowy plan TDD powstaje na bazie tego speca (skill +> `superpowers:writing-plans`). + +**Cel.** Wprowadzić odwracalne („miękkie") kasowanie tam, gdzie ma to realny +sens, przy minimalnym blast-radiusie: + +1. **Publikacje** (5 modeli) — pełny soft-delete: `DELETE` → znacznik + `deleted_at`; rekord znika z widoku publicznego / ewaluacji / API / PBN, + dane (w tym powiązania `*_Autor`) zostają i da się je przywrócić. +2. **Autor** — soft-delete **wyłącznie dla autora bez prac** (odwracalny + „kosz" dla pustych/błędnych rekordów). Autor **z** pracami → `PROTECT` + (zero kasowania, soft ani hard). +3. **PBN** — soft-delete publikacji wycofuje oświadczenia dyscyplin z profilu + instytucji, asynchronicznie przez kolejkę (`pbn_export_queue`). +4. **Audyt** — dedykowana tabela `SoftDeleteLog` (kto / kiedy / dlaczego / + status PBN). +5. **Admin** (superuser-only) — „kosz" zamiast hard-delete, filtr „pokaż + skasowane", akcja „przywróć", osobna jawna akcja „usuń trwale". + +**Stack.** Django, PostgreSQL (triggery `plpython3u`), `django-soft-delete` +(`SoftDeleteModel`, już w `pyproject.toml`), `django-denorm-iplweb`, +Celery + `pbn_export_queue`. + +--- + +## 1. Decyzja architektoniczna nadrzędna — asymetria publikacja vs autor + +Połączenie obu ficzerów (soft-delete publikacji ORAZ autora) prowadzi do +celowej **asymetrii**, która drastycznie ogranicza ryzyko: + +| | **Publikacje** (5 modeli) | **Autor** | +|---|---|---| +| Mechanizm | Pełny `SoftDeleteModel` | Soft-delete **tylko gdy brak prac** | +| Autor/rekord z pracami | — | **PROTECT** (zero kasowania) | +| Autor/rekord bez prac | — | Soft-delete = odwracalny husk | +| Through-modele `*_Autor` | **Nietknięte** (Projekt A) | Nie stają się soft-delete | +| Doktorat / habilitacja | Soft-delete (są publikacjami) | FK do autora → `PROTECT` | + +**Konsekwencja kluczowa:** `Wydawnictwo_*_Autor`, `Praca_Doktorska`, +`Praca_Habilitacyjna` **NIE** stają się `SoftDeleteModel` na potrzeby +soft-delete autora. Soft-delete autora to operacja-liść na pustych rekordach, +więc nie dotyka materializowanych widoków, ewaluacji ani PBN. Cała ryzykowna +robota zostaje skupiona na publikacjach. + +**Dlaczego nie kaskada autor→prace ani „guard z 50 publikacjami":** +realny przypadek użycia kasowania autora jest wąski — to wyłącznie puste / +błędne / duplikowane rekordy (literówki, dane testowe, husk po scaleniu). +Nikt nie kasuje autora z 50 pracami („Kowalski zniknął, usuńmy go" się nie +zdarza). Kaskada soft-delete autor+publikacje byłaby ogromną, rzadką operacją +i kasowałaby publikacje współautorów; „guard" wymuszający ręczną edycję 50 +publikacji przed usunięciem czyni kasowanie bezużytecznym. Wąska semantyka +„bez prac = soft-delete, z pracami = PROTECT" pokrywa 100% realnej potrzeby. + +--- + +## 2. Publikacje — fundament (Projekt A) + +5 modeli: `Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`, +`Praca_Habilitacyjna`, `Patent` ← `SoftDeleteModel`. + +### 2.1 Trigger jako choke-point (najwrażliwszy, robiony PIERWSZY) + +`Rekord` to UNION-view nad materializowaną tabelą `bpp_rekord_mat`, zasilaną +triggerem `bpp_refresh_cache()` +(**baseline: `src/bpp/migrations/0001_cache_functions.sql`** — uwaga: stary +spec referował przed-squashowy `107_cache_functions.sql`). Z `bpp_rekord_mat` +/ `bpp_autorzy_mat` czyta większość systemu (publiczny frontend, multiseek, +global search, ewaluacja `Cache_Punktacja_*`, raporty). + +**Fakt z kodu** (`0001_cache_functions.sql:78-87`): na `DELETE` trigger usuwa +wiersze z tabel `_mat`; na `UPDATE/INSERT` re-insertuje. Soft-delete to +technicznie `UPDATE` → **bez zmiany triggera skasowany rekord wróciłby do +mat-view**. + +**Zmiana:** ścieżka `UPDATE/INSERT` triggera uczona reguły: +> jeśli `NEW.deleted_at IS NOT NULL` → zachowaj się jak `DELETE` (usuń z +> `bpp_rekord_mat` + `bpp_autorzy_mat`, **nie** re-insertuj). +> `deleted_at: →NULL` (restore) → normalny re-insert. + +Trzeba to obsłużyć dla **wszystkich 5 tabel źródłowych** oraz przemyśleć +ścieżkę przez tabele autorskie (`bpp_wydawnictwo_*_autor`, `bpp_patent_autor`) +— tam `deleted_at` siedzi na rekordzie nadrzędnym, nie na wierszu autorskim, +więc warunek czytamy z rekordu rodzica (lub polegamy na tym, że trigger +rodzica już wyczyścił `bpp_autorzy_mat`). Do rozstrzygnięcia w planie TDD; +testy spójności mat-view są obowiązkowe (soft-delete → znika z `Rekord`; +restore → wraca; brak rozjazdu `Cache_Punktacja_*`). + +### 2.2 Override `delete()` — bez refleksyjnej kaskady pakietu + +`SoftDeleteModel.delete()` domyślnie kaskaduje refleksyjnie po odwrotnych +relacjach. W trybie `strict=True` (domyślny) rzuci `SoftDeleteException` na +nie-soft dzieciach (`*_Autor`, `*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, +`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`), a `strict=False` twardo +skasuje je przez CASCADE. **Oba złe.** Dlatego na 5 modelach nadpisujemy +`delete()` tak, by jedynie ustawił `deleted_at` i zapisał (bez kaskady) — +dzieci `*_Autor` zostają nietknięte, a trigger usuwa je z `bpp_autorzy_mat` +jako pochodne. `restore()` (analogicznie bez kaskady) → trigger re-projektuje +wszystko ze źródła. Zweryfikować, że nadpisany `delete()`/`restore()` nadal +emituje sygnały `post_soft_delete`/`post_restore` (patrz §5). + +### 2.3 `slug` — warunkowy unique + +Wszystkie 5 modeli ma denormalizowany `slug unique=True`. Skasowany rekord +trzyma slug → konflikt przy ponownym utworzeniu. Zamiana na +`UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True))`. +Migracja (NIE modyfikować istniejących migracji). + +### 2.4 Menedżery `Wydawnictwo_*_Manager` + +Dziedziczą po `ManagerModeliZOplataZaPublikacjeMixin` +(`src/bpp/models/abstract/fees.py`). Po wpięciu `SoftDeleteModel` trzeba +**przepleść** filtr soft-delete (`deleted_at__isnull=True`) z istniejącymi +metodami (`rekordy_z_oplata()`, `wydawnictwa_nadrzedne_dla_innych()`) — przez +wspólny `QuerySet`/MRO, nie przez nadpisanie. + +### 2.5 Audyt kategorii B — miejsca, które MUSZĄ widzieć usunięte + +Domyślny menedżer `objects` ukrywa usunięte → kategoria A (wyświetlanie / +eksport / liczenie) staje się czysta automatycznie (zero zmian). Ale +**kategoria B** musi świadomie przejść na `global_objects`, inaczej powstaną +**duplikaty**: + +- `import_common/core/publikacja.py`, `importer_publikacji` — matching importu, +- `crossref_bpp/core.py` — dedup, +- `deduplikator_publikacji/tasks.py` — dedup, +- `pbn_integrator/utils/synchronization.py`, `pbn_integrator/importer/chapters.py`, + `pbn_api/management/*` — matching po `pbn_uid`. + +> **Pułapka nadrzędna:** jeśli importer użyje domyślnego (ukrywającego) +> menedżera, soft-delete staje się generatorem duplikatów. Audyt kat. B jest +> obowiązkowy. + +--- + +## 3. Autor — dwie warstwy ochrony + soft-delete husków + +Obecne `on_delete` (potwierdzone w kodzie): + +| Powiązanie | Plik | Dziś | Docelowo | +|---|---|---|---| +| `Wydawnictwo_*_Autor.autor` | `src/bpp/models/abstract/authors.py:22` (`CASCADE`) | hard-kasuje autorstwa | **PROTECT** | +| `Praca_Doktorska.autor` | `src/bpp/models/praca_doktorska.py:136` (`CASCADE`) | hard-kasuje doktorat | **PROTECT** | +| `Praca_Habilitacyjna.autor` | `src/bpp/models/praca_habilitacyjna.py:42` (`PROTECT`) | już blokuje | bez zmian | + +### 3.1 Warstwa 1 — flip FK `CASCADE→PROTECT` + +Migracja state-only (Django implementuje `on_delete` w ORM, nie jako +constraint DB → brak zmiany schematu). Broni przed przypadkowym hard-delete +i gołą kaskadą. Tabele atrybutów autora (jednostki, dyscypliny, funkcje, +`Cache_Punktacja_Autora`, profil) **zostają `CASCADE`** — to nie „prace", +mają znikać z autorem. + +### 3.2 Warstwa 2 — guard w soft `Autor.delete()` + +**Krytyczne:** `PROTECT` na FK łapie tylko hard-delete + kolektor kaskady +Django. Soft-delete to `UPDATE deleted_at=now()` — `on_delete` **nigdy się +nie odpala**. Dlatego `Autor.delete()` (soft) musi jawnie sprawdzić: jeśli +autor ma JAKIEKOLWIEK autorstwo (`Wydawnictwo_Ciagle_Autor`, +`Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) / doktorat / habilitację → +odmowa (`ProtectedError`/`ValidationError` z czytelnym komunikatem). + +**Definicja „bez prac":** liczą się WSZYSTKIE wiersze, także wskazujące na +*soft-deletowane* publikacje (najprościej i najbezpieczniej — autor jest +„husk" dopiero gdy naprawdę nic nie wskazuje). Autor `SoftDeleteModel`; jego +wiersze atrybutów zostają nietknięte (restore odtwarza całość). + +### 3.3 Synergia z `deduplikator_autorow` (merge) + +Merge najpierw przenosi wszystkie prace na autora głównego, potem woła +`autor.delete()` na pustym duplikacie (`src/deduplikator_autorow/views/merge.py:155`; +transfer through-rows w `src/deduplikator_autorow/utils/merge.py:191,284,354`). +Skutki: +- `PROTECT` **nie psuje** merge'a — duplikat jest już pusty w chwili `delete()`. +- Soft-delete sprawia, że husk po scaleniu staje się **odwracalny** (dziś + znika bezpowrotnie) — błędne scalenie da się cofnąć. Darmowy bonus. +- **Do zweryfikowania w planie TDD:** czy merge przenosi WSZYSTKIE typy prac + (ciągłe / zwarte / patent / doktorat / habilitacja) przed `delete()` — + inaczej guard/PROTECT zablokuje usunięcie husku. + +--- + +## 4. PBN — wycofanie oświadczeń przez kolejkę + +### 4.1 Co i kiedy + +Soft-delete publikacji **z `pbn_uid`** → wycofanie **oświadczeń dyscyplin z +profilu instytucji** (publikacja przestaje liczyć się do ewaluacji). Obiektu +publikacji w PBN **nie ruszamy** (jest współdzielony — pełny `DELETE` mógłby +się wywalić; wycofanie oświadczeń jest zawsze bezpieczne). Gate: jeśli rekord +nigdy nie poszedł do PBN (`pbn_uid is None`) — nic nie robimy. + +Prymityw PBN istnieje: +`src/pbn_api/client/mixins/institutions.py:87` → +`delete_all_publication_statements(publicationId)` (+ selektywne +`delete_publication_statement` w `:135`, retry w +`pbn_api/client/publication_sync.py`). + +### 4.2 Mechanizm — rozszerzenie istniejącej `pbn_export_queue` + +Nie wprowadzamy nowego mechanizmu. Kolejka eksportu PBN żyje jako dedykowana +aplikacja **`src/pbn_export_queue/`** (model `PBN_Export_Queue`: GFK +content_type+object_id, `zamowil`, `ilosc_prob`, `zakonczono_pomyslnie`, +`rodzaj_bledu`, klasyfikacja błędów, locking, „ponowna wysyłka", admin, +`send_to_pbn()`). + +Rozszerzenie: +- dodać pole `operacja: TextChoices(WYSYLKA, WYCOFANIE)` (default `WYSYLKA` + dla kompatybilności wstecznej), migracja, +- gałąź w logice wysyłki: `WYCOFANIE` → `delete_all_publication_statements`, +- status zapisywany jak dla wysyłki (`zakonczono_pomyslnie`, `komunikat`, + `ilosc_prob`) + odzwierciedlenie w `SentData` i `SoftDeleteLog`. + +`SentData` (`src/pbn_api/models/sentdata.py`, GFK + `pbn_uid` + +`submitted_successfully` + `mark_as_successful`/`mark_as_failed`) trzyma stan +PBN per-rekord — po wycofaniu oznaczamy odpowiednio. + +### 4.3 Restore → symetria + +Restore publikacji → wpis `WYSYLKA` w `pbn_export_queue` (ponowna wysyłka +oświadczeń, dyscypliny wracają do profilu). Symetria delete↔restore. + +--- + +## 5. SoftDeleteLog — dedykowany audyt (NASZ model) + +`django-soft-delete` **nie ma** żadnej tabeli-logu — daje tylko pola +`deleted_at`/`restored_at`/`transaction_id` oraz **trzy sygnały**: +`post_soft_delete`, `post_hard_delete`, `post_restore` +(`django_softdelete/signals.py`). Audyt budujemy sami. + +**Model `SoftDeleteLog`:** `content_type`, `object_id` (GFK), `akcja` +(`DELETE`/`RESTORE`/`HARD_DELETE`), `user` (kto), `timestamp`, `powod` +(tekst), FK/link do wpisu `pbn_export_queue` + jego status. Centralny dla +wszystkich soft-deletowalnych typów; zasila widok „Kosz"; jedno miejsce +prawdy „co / kto / dlaczego zniknęło i czy PBN przyjął". + +**Zasilanie przez receivery sygnałów** (jeden punkt podpięcia dla wszystkich +modeli — odporne na pominięcie): +- `post_soft_delete` → `SoftDeleteLog(DELETE)` + (jeśli `pbn_uid`) wpis + `WYCOFANIE` w `pbn_export_queue`, +- `post_restore` → `SoftDeleteLog(RESTORE)` + wpis `WYSYLKA`, +- `post_hard_delete` → `SoftDeleteLog(HARD_DELETE)`. + +**Niuans „kto":** sygnał nie niesie użytkownika (`delete()` pakietu nie zna +requestu). `user` wstrzykujemy jawnie z warstwy admina (akcja superusera ma +`request.user` pod ręką — przekazujemy go do `delete(user=...)` / przez +kontekst). Operacje systemowe (np. merge, celery) logują `user=None` lub +konto techniczne. + +--- + +## 6. Admin (superuser-only) + +Dla 5 modeli publikacji + `Autor`: +- „Usuń" = **soft-delete** (kosz); „Usuń trwale" = osobna, jawnie oznaczona + akcja superusera (`hard_delete`), +- filtr „Pokaż skasowane" (`deleted_objects`/`global_objects`) + akcja + „Przywróć", +- pole „powód" przy kasowaniu (trafia do `SoftDeleteLog`), +- admin świadomie używa `global_objects`/`deleted_objects` (nie domyślnego + ukrywającego menedżera), +- dla `Autor`: próba soft-delete autora z pracami → czytelny komunikat + z guarda (§3.2). + +Precedens: `src/zglos_publikacje/models.py` (`Zgłoszenie_Publikacji` już jest +`SoftDeleteModel` — wzorzec menedżerów/migracji/admina). + +--- + +## 7. Retencja + +Brak automatycznego czyszczenia kosza. Soft-deletowane rekordy trwają do +ręcznego „Usuń trwale" superusera. (Auto-hard-delete po N dniach — świadomie +odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, +`src/django_bpp/settings/base.py:670`, jeśli zajdzie potrzeba.) + +--- + +## 8. Kolejność prac (fazy; szczegółowy TDD → writing-plans) + +1. **Trigger** `bpp_refresh_cache()` — nowa migracja SQL: `deleted_at IS NOT + NULL` jako DELETE dla 5 tabel + ścieżka tabel autorskich; testy spójności + cache. **Najwrażliwsze, pierwsze.** +2. **Publikacje** — `SoftDeleteModel` na 5 modelach, override + `delete()`/`restore()` (bez kaskady), migracje (`deleted_at`+indeks, + ew. `CONCURRENTLY`), `slug` `UniqueConstraint`, przeplecenie menedżerów. +3. **Audyt kat. B** — przełączenie import/dedup/PBN-matching na + `global_objects`. Testy: re-import nie tworzy duplikatów. +4. **Autor** — flip FK `CASCADE→PROTECT` (`*_Autor`, doktorat), guard w soft + `delete()`, soft-delete husku; weryfikacja merge. +5. **PBN** — `operacja WYCOFANIE` w `pbn_export_queue` + restore→`WYSYLKA`; + integracja `SentData`. +6. **SoftDeleteLog** + receivery sygnałów (`post_soft_delete`/`post_restore`/ + `post_hard_delete`), wstrzykiwanie `user`. +7. **Admin** — kosz / filtr / przywróć / usuń-trwale / powód (5 modeli + + `Autor`). +8. **Testy regresji** — pełna suita: PBN (duplikaty + wycofanie), dashboard, + import, ewaluacja, merge autorów, API. Do ~10 min. + +--- + +## 9. Ryzyka + +- **Cache/trigger** — rozjazd, jeśli `deleted_at` nie obsłużone we wszystkich + 5 tabelach + ścieżce UPDATE + tabelach autorskich. Najgroźniejsze, + wydajnościowo wrażliwe. Mitygacja: testy spójności jako pierwsze. +- **Duplikaty** z importu/PBN/dedup, jeśli kat. B nie przejdzie na + `global_objects`. +- **Merge autorów** — jeśli nie przenosi wszystkich typów prac przed + `delete()`, PROTECT/guard zablokuje. Zweryfikować. +- **`user` w sygnałach** — łatwo zalogować `None`; zadbać o wstrzyknięcie + z admina. +- **Denorm** (`django-denorm-iplweb`, `pre_save`) — soft-delete go wprost nie + psuje, ale zweryfikować `cached_punkty_dyscyplin` po restore. +- **Migracje na dużych tabelach produkcyjnych** — `deleted_at` domyślnie + `NULL` (bez backfillu), indeks `CONCURRENTLY` jeśli rozmiar wymaga. + +--- + +## 10. Decyzje rozstrzygnięte (z brainstormingu 2026-06-04) + +1. **Autor:** z pracami → `PROTECT`; bez prac → soft-delete (husk). Through- + modele/doktorat/habilitacja **nie** stają się `SoftDeleteModel`. +2. **Publikacje:** Projekt A (override `delete()`, trigger jako choke-point; + dzieci nietknięte). +3. **PBN przy soft-delete:** wycofanie oświadczeń instytucji + (`delete_all_publication_statements`), gate na `pbn_uid`; obiektu + publikacji nie kasujemy. +4. **PBN — mechanizm:** rozszerzenie `pbn_export_queue` o operację + `WYCOFANIE` (async, retry, admin — istniejąca infra). +5. **Restore → PBN:** auto-zakolejkowanie `WYSYLKA`. +6. **Log:** dedykowany `SoftDeleteLog` zasilany sygnałami pakietu. +7. **Admin:** superuser-only; soft-delete zastępuje „usuń"; hard-delete jako + osobna jawna akcja. +8. **Retencja:** brak auto-czyszczenia; tylko ręczny hard-delete. + +--- + +## 11. Precedensy w repo + +- `django-soft-delete>=1.0.23` — `pyproject.toml`. +- `src/zglos_publikacje/models.py` — `Zgłoszenie_Publikacji` już + `SoftDeleteModel` (wzorzec). +- `src/pbn_export_queue/` — dojrzała kolejka PBN (model + Celery + admin + + retry/lock), wzorzec dla operacji `WYCOFANIE`. +- `src/pbn_api/models/sentdata.py` — `SentData` (stan PBN per-rekord). +- `src/bpp/models/oplaty_log.py`, log w `deduplikator_autorow` — precedensy + tabel-logów. From f051da01a2fd131a00850d50cb3d2c01bf82a3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 10:09:55 +0200 Subject: [PATCH 3/4] docs(historia-zmian): spec wersjonowania zmian (django-reversion) + kontrakty integracyjne z soft-delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utrwala ustalenia z brainstormingu: trzymamy widoczną w adminie historię edycji rekordów (kto/kiedy/co + compare + revert) przez rozszerzenie istniejącego django-reversion na zdefiniowany zbiór modeli (zgłoszenia publikacji, wydawnictwa, Wydawca, Autor, Wydzial, Jednostka; Uczelnia już ma). Cel: rozliczalność edycji ludzkich ("kto usunął/zmienił to i to"), NIE total-audyt bazy. Odrzucono pghistory (total-audyt DB) i simple-history (duplikacja tabel, dublowanie paradygmatu). Kluczowe: 3 kontrakty integracyjne z trwającym projektem soft-delete — (1) soft-delete i restore muszą tworzyć rewizję reversion (set_user + set_comment), inaczej "kto usunął" nie pojawi się w zakładce Historia; (2) wspólny punkt wstrzyknięcia request.user dla reversion i SoftDeleteLog; (3) MRO VersionAdmin + mixin soft-delete, get_queryset->global_objects (usunięty rekord otwieralny), ukrycie reversion "recover deleted". Komplementarny do SoftDeleteLog: ten loguje zdarzenia delete/restore, reversion trzyma pełną oś edycji pól. Implementacja odłożona do po soft-delete; referencje do kodu wymagają aktualizacji po jego wdrożeniu, ustalenia zostają. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-04-historia-zmian-reversion-design.md | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-04-historia-zmian-reversion-design.md diff --git a/docs/superpowers/specs/2026-06-04-historia-zmian-reversion-design.md b/docs/superpowers/specs/2026-06-04-historia-zmian-reversion-design.md new file mode 100644 index 000000000..33b906606 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-historia-zmian-reversion-design.md @@ -0,0 +1,225 @@ +# Spec: Historia zmian rekordów (django-reversion) — z myślą o rozliczalności i soft-delete + +> ⏳ **STATUS: USTALENIA ZATWIERDZONE, IMPLEMENTACJA ODŁOŻONA do po soft-delete.** +> Ten dokument utrwala decyzje z brainstormingu (2026-06-04) o tym, jak BPP ma +> trzymać **widoczną w adminie historię zmian** rekordów — przede wszystkim po +> to, by człowiek mógł wyjaśnić „kto usunął / zmienił to i to w tym rekordzie". +> +> Wdrożenie soft-delete (osobny, aktywny projekt: +> [`2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](2026-06-04-soft-delete-publikacje-i-autorzy-design.md)) +> jest **w toku**. Część odwołań do kodu poniżej (nazwy mixinów admina, ścieżka +> `delete()/restore()`, model `SoftDeleteLog`) opisuje stan **docelowy** tego +> projektu — gdy soft-delete wyląduje, ten spec będzie wymagał aktualizacji +> referencji. **Decyzje i kontrakty integracyjne (sekcja 4) pozostają.** + +**Cel:** Audytowalność edycji ludzkich. Operator wchodzi na rekord w adminie, +otwiera „Historię" i widzi ciągłą oś: kto utworzył, kto co zmienił (z +porównaniem pól), kto usunął i kto przywrócił — z możliwością cofnięcia do +wcześniejszej wersji. To narzędzie do **diagnozy błędów ludzi**, nie total-audyt +bazy. + +**Architektura (jednozdaniowo):** Rozszerzamy istniejący w repo +`django-reversion` (`VersionAdmin` + `reversion-compare`, już używany na +`Uczelnia`) na zdefiniowany zbiór modeli najczęściej edytowanych ręcznie; +soft-delete i restore wpinamy w tę samą oś historii przez +`reversion.create_revision()`. + +**Stack:** Django admin, `django-reversion>=6.2` + `django-reversion-compare` +(oba już w `pyproject.toml`), wbudowany `django.contrib.admin.models.LogEntry` +(już aktywny). + +--- + +## 1. Motywacja i czego NIE robimy + +Główne źródła problemów edycyjnych to **zgłoszenia publikacji** oraz **rekordy +wydawnictw**; do tego dochodzi struktura (`Uczelnia`, `Jednostka`, `Wydzial`) +i `Autor`. Potrzeba: wejść w rekord i wyjaśnić „kto to zrobił i kiedy", łącznie +z usunięciami. + +Świadomie **nie** chcemy śledzić każdej zmiany w bazie na każdym poziomie +(bulk-importy, surowy SQL, triggery). To byłby total-audyt — inne narzędzie, +inny koszt, inny use-case. + +## 2. Decyzja o narzędziu + +| Narzędzie | Werdykt | Dlaczego | +|---|---|---| +| **django-reversion** (+ compare) | ✅ **wybrane** | Już w repo, już ostylowane (`templates/reversion/object_history.html`), już na `Uczelnia`. Daje dokładnie żądany UX: per-rekordowa oś wersji + diff pól + revert + „kto/kiedy/komentarz". Zakres selektywny (per-model). | +| django-pghistory | ❌ | Triggerowy total-audyt DB (łapie bulk/SQL). Rozwiązuje *inny* problem niż „historia edycji w adminie"; jego admin to log zdarzeń, nie oś wersji z revertem. | +| django-simple-history | ❌ | Duplikuje każdą tabelę (`_historical`) — zła kombinacja z 5 dużymi tabelami publikacji; dubluje paradygmat, który już mamy w reversion; nie wykorzystalibyśmy jego głównej przewagi (compare mamy w reversion-compare). | + +> Reversion „z pudełka" hookuje się w ORM (`post_save` / context rewizji), więc +> łapie **edycje przez admin** (+ jawne `create_revision`). Zmiany maszynowe +> (import, SQL) zostają niewidoczne — i to jest **akceptowalne**, bo celem jest +> rozliczalność ludzi, a nie total-audyt (sekcja 1). + +## 3. Co BPP już ma (punkt wyjścia) + +- **Warstwa 0 — wbudowany `LogEntry`.** Każda strona zmiany w adminie ma już + link „Historia" z `add/change/delete` + user. BPP buduje na tym filtry + (`src/bpp/admin/filters.py`: `OstatnioZmienionePrzezFilter`, + `UtworzonePrzezFilter`). Ograniczenie: tylko akcje z admina, tylko opis + tekstowy (które pola), bez wartości pól, bez revertu. **Zostaje jako druga + siatka bezpieczeństwa.** +- **Warstwa 1 — reversion na `Uczelnia`.** `UczelniaAdmin(... VersionAdmin)` + (`src/bpp/admin/uczelnia.py`) + `reversion-compare` + **ostylowany** + `src/django_bpp/templates/reversion/object_history.html` (Grappelli look). + Koszt integracji UX-owej jest już zapłacony — trzeba tylko podpiąć kolejne + modele. + +## 4. Zakres — modele do objęcia `VersionAdmin` + +Dziś wszystkie poniższe (poza `Uczelnia`) używają gołego `admin.ModelAdmin` — +zmiana to dorzucenie `reversion.admin.VersionAdmin` do MRO (wzór: +`uczelnia.py`). + +| Model | Admin | Baza dziś | Uwagi | +|---|---|---|---| +| `Zgloszenie_Publikacji` | `Zgloszenie_PublikacjiAdmin` (`src/zglos_publikacje/admin/zgloszenie_publikacji.py`) | `admin.ModelAdmin` | inline'y `_Autor`, `_Zalacznik` → `follow` | +| `Wydawnictwo_Ciagle` | `Wydawnictwo_CiagleAdmin` | `admin.ModelAdmin` | liczne inline'y (`_Autor`, `Zewnetrzna_Baza`…) → `follow` | +| `Wydawnictwo_Zwarte` | `Wydawnictwo_ZwarteAdmin` | `admin.ModelAdmin` | jw. | +| `Praca_Doktorska`, `Praca_Habilitacyjna`, `Patent` | (analogiczne) | `admin.ModelAdmin` | dla spójności całej rodziny publikacji | +| `Wydawca` | `WydawcaAdmin` (`src/bpp/admin/wydawca.py`) | `admin.ModelAdmin` | bez inline'ów | +| `Autor` | `AutorAdmin` (`src/bpp/admin/autor.py`) | `admin.ModelAdmin` | też w zakresie soft-delete | +| `Wydzial` | `WydzialAdmin` | `admin.ModelAdmin` | | +| `Jednostka` | `JednostkaAdmin` | `DraggableMPTTAdmin` ⚠️ | MRO `VersionAdmin`+MPTT — przetestować drzewo (drag&drop zmienia `lft/rght/level`) | +| `Uczelnia` | `UczelniaAdmin` | ✅ `VersionAdmin` | wzorzec, bez zmian | + +## 5. Relacja do `SoftDeleteLog` — komplementarność, nie duplikacja + +Aktywny design soft-delete wprowadza **`SoftDeleteLog`** (§5 tamtego dokumentu) +zasilany sygnałami `post_soft_delete`/`post_restore`/`post_hard_delete`. To dwa +różne narzędzia o różnym celu — **mają współistnieć**: + +| | `SoftDeleteLog` (soft-delete design) | reversion (ten spec) | +|---|---|---| +| Co rejestruje | **zdarzenia** delete/restore/hard-delete | **pełną historię edycji pól** (każda zmiana) | +| Zakres | wszystkie soft-deletowalne typy | zdefiniowany zbiór najczęściej edytowanych ręcznie (sekcja 4) | +| Granularność | jeden wpis na zdarzenie kasowania | migawka stanu rekordu przy każdym zapisie | +| Pyta | „co/kto/dlaczego zniknęło + status PBN" | „jak ten rekord wyglądał w punkcie X i kto go zmieniał" | +| UX | widok „Kosz" | zakładka „Historia" + compare + revert | + +`SoftDeleteLog` jest **centralnym, przekrojowym** rejestrem kasowań (i nośnikiem +statusu PBN). reversion jest **per-rekordową** osią całej edycji. Pierwszy +odpowiada „co dziś jest w koszu i czemu"; drugi — „prześledźmy ten konkretny +rekord od początku". + +## 6. Kontrakty integracyjne z soft-delete (TO MUSI WEJŚĆ DO PRAC SOFT-DELETE) + +To jest sedno utrwalone w tym spec-u. Bez tych trzech rzeczy soft-delete będzie +technicznie poprawny, ale **główny use-case („kto usunął, widoczne w Historii") +cicho nie zadziała**. + +### 6.1 Soft-delete i restore MUSZĄ tworzyć rewizję reversion + +`VersionAdmin` pokazuje w „Historii" **tylko rewizje reversion**. Soft-delete w +adminie idzie ścieżką `delete_view`→`delete_model`→`obj.delete()` (UPDATE +`deleted_at`), której reversion domyślnie **nie** owija w rewizję. Skutek bez +naprawy: usunięcie nie pojawi się w zakładce, w którą operator patrzy. + +Naprawa — w adminie modeli objętych `VersionAdmin` owijamy soft-delete i restore +w rewizję, z atrybucją usera i komentarzem: + +```python +import reversion + +def delete_model(self, request, obj): + with reversion.create_revision(): + reversion.set_user(request.user) + reversion.set_comment("Usunięto (soft-delete)") + super().delete_model(request, obj) # → obj.delete() = UPDATE deleted_at +# analogicznie delete_queryset() (akcja masowa) oraz akcja "Przywróć" → set_comment("Przywrócono") +``` + +Ponieważ soft-delete utrzymuje wiersz, oś czasu reversion pozostaje **ciągła**: +`utworzono → edycje → usunięto → przywrócono → edycje` w jednej zakładce. (To +działa lepiej niż reversion na twardym delete, gdzie obiekt znika.) + +### 6.2 Atrybucja „kto" — jeden punkt dla reversion i `SoftDeleteLog` + +Oba mechanizmy potrzebują `request.user` z warstwy admina: +- `SoftDeleteLog` — przez jawne `delete(user=...)` (§5 soft-delete design), +- reversion — przez `reversion.set_user(request.user)` w `create_revision`. + +Sygnały pakietu nie niosą requestu (§5 soft-delete design, „niuans kto"). Punkt +wstrzyknięcia usera w adminie powinien **zasilić oba** naraz — to ten sam +moment akcji superusera. + +### 6.3 `VersionAdmin` musi komponować się z mixinem soft-delete admina; ukryć „recover deleted" + +- MRO: admin łączy `VersionAdmin` z mixinami soft-delete (kosz/filtr/przywróć, + `get_queryset → global_objects`) — zweryfikować kolejność. +- **`get_queryset → global_objects`** jest już w planie soft-delete (§6 tamtego + design: admin świadomie używa `global_objects`/`deleted_objects` + filtr + „Pokaż skasowane"). To **odpowiada na pytanie „czy admin obejrzy usunięte"**: + tak — dzięki temu usunięty rekord da się otworzyć i przeczytać jego Historię. + reversion tu nic nie zmienia, tylko korzysta z tego, że wiersz jest osiągalny. +- Wbudowany przycisk reversion **„recover deleted" staje się zbędny/mylący** pod + soft-delete (przywracamy przez `restore()`, nie przez reversion-recover) — + ukryć, by nie mieć dwóch sprzecznych dróg odkasowania. + +## 7. Pozostałe punkty (mniejszej wagi, ale realne) + +- **Inline'y → `follow`.** `Zgloszenie_Publikacji` i wydawnictwa mają inline'y + (autorzy, załączniki). `VersionAdmin` domyślnie rejestruje i śledzi modele + inline z admina — **zweryfikować**, bo inaczej rewizja zapisze nagłówek bez + autorów i compare będzie mylący. +- **Backfill historii (decyzja otwarta).** Reversion zna tylko zmiany *po* + wdrożeniu. Pierwsza wersja starego rekordu powstaje przy pierwszej edycji albo + jednorazowo przez `manage.py createinitialrevisions `. Na 5 dużych + tabelach to ciężka operacja — rozstrzygnąć: backfill czy „historia od teraz". +- **Wolumen i retencja.** Gorące tabele (wydawnictwa) → `reversion_version` + rośnie. Z czasem rozważyć `deleterevisions --days=N` albo świadomie trzymać + wszystko. (Symetrycznie do retencji soft-delete: tam „brak auto-czyszczenia".) +- **MPTT (`Jednostka`).** Drag&drop zmienia `lft/rght/level`; zdecydować czy te + zmiany mają trafiać do historii i przetestować MRO `VersionAdmin`+MPTT-admin. + +## 8. Kolejność prac (gdy ruszymy — po/obok soft-delete) + +> Szczegółowy plan TDD → `superpowers:writing-plans`. To tylko szkic kolejności. + +1. **Podpięcie `VersionAdmin`** pod modele bez soft-delete i bez inline'ów + (`Wydawca`, `Wydzial`) — najprostsze, weryfikacja UX na `run-site`. +2. **Modele z inline'ami** (`Zgloszenie_Publikacji`, wydawnictwa) — z naciskiem + na `follow` (autorzy/załączniki w rewizji). +3. **Kontrakty integracyjne z soft-delete** (sekcja 6) — owinięcie + `delete_model`/restore w rewizję, wspólny punkt usera, ukrycie recover. + **Wymaga koordynacji z pracami soft-delete** (admin tych modeli powstaje tam). +4. **`Jednostka` (MPTT)** — na końcu, osobny test drzewa. +5. **Decyzja o backfillu** (`createinitialrevisions`) i retencji rewizji. +6. **Testy:** Historia pokazuje delete/restore z userem; compare; revert; + usunięty rekord otwieralny i czytelny. + +## 9. Decyzje rozstrzygnięte (brainstorming 2026-06-04) + +1. **Narzędzie:** `django-reversion` (rozszerzenie istniejącego). pghistory i + simple-history odrzucone (sekcja 2). +2. **Zakres:** edycje ludzkie przez admin na zdefiniowanym zbiorze modeli + (sekcja 4); **nie** total-audyt bazy. +3. **Soft-delete w historii:** usunięcie i restore mają być rewizjami reversion + (sekcja 6.1), żeby „kto usunął" był widoczny w zakładce Historia — obok + wpisu w `SoftDeleteLog`. +4. **Komplementarność z `SoftDeleteLog`:** współistnieją (sekcja 5). +5. **Widoczność usuniętych w adminie:** zapewnia ją `get_queryset → + global_objects` z projektu soft-delete (sekcja 6.3); recover reversion ukryty. + +## 10. Otwarte decyzje + +1. Czy `Autor` i `Jednostka` na pewno w zakresie compare/revert (oba są + „ciężkie": Autor — w zakresie soft-delete; Jednostka — MPTT). +2. Backfill `createinitialrevisions` na 5 dużych tabelach: tak / „od teraz". +3. Polityka retencji rewizji (`deleterevisions`) — czy i po ilu dniach. +4. Czy zmiany pozycji w drzewie `Jednostka` (MPTT) mają być wersjonowane. + +## 11. Precedensy w repo + +- `django-reversion>=6.2.0` + `django-reversion-compare>=0.19.2` — + `pyproject.toml`. +- `src/bpp/admin/uczelnia.py` — `VersionAdmin` w akcji (wzorzec MRO). +- `src/django_bpp/templates/reversion/object_history.html` — gotowy, ostylowany + szablon historii (nie trzeba robić od zera). +- `src/bpp/admin/filters.py` — filtry na wbudowanym `LogEntry` (warstwa 0). +- [`2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](2026-06-04-soft-delete-publikacje-i-autorzy-design.md) + — aktywny projekt soft-delete; sekcje 5 (`SoftDeleteLog`) i 6 (Admin) są + punktami styku tego spec-u. From 88b885786d674ccde258d1cf9e847457799799e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 10:27:06 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(historia-zmian):=20poprawki=20z=20self?= =?UTF-8?q?-review=20(CompareVersionAdmin,=20recover=E2=86=92hard-delete,?= =?UTF-8?q?=20warunki)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Korekty po self-review zweryfikowanym w kodzie pakietów: - compare: reversion_compare zainstalowany, ale ŻADEN admin nie używa CompareVersionAdmin (grep 0); Uczelnia ma goły VersionAdmin (oś+revert, bez diffa pól). Rekomendacja zmieniona na CompareVersionAdmin jako baza dla nowych adminów + nota o podniesieniu Uczelni. - szablon: ostylowany object_history.html nie ma selektora wersji do compare — wymaga rozszerzenia o radio-inputy (banał, ale konieczny). - recover: soft-deletowane obiekty NIE pojawiają się w reversion recover (wiersz istnieje; get_deleted patrzy na nieistniejące). Uzasadnienie ukrycia poprawione: realny konflikt to hard_delete (wskrzeszenie poza przepływem PBN/SoftDeleteLog/slug). - snippet 6.1: użycie wbudowanego self.create_revision(request) (sam robi set_user) + twardy warunek: nadpisany delete()/restore() musi wołać save() (pakiet robi save(update_fields), post_save→reversion); bulk update() po cichu wyłączyłby historię usunięć. - inline follow: jest automatyczny dla zadeklarowanych inline'ów (admin.py:145-154) — uwaga złagodzona; caveat dot. relacji spoza inline. - zgłoszenia: tworzone przez userów (front-end), edytowane w adminie → reversion pokrywa edycję; nota niskiej wagi o starcie historii. - dopisana zaleta: włączenie reversion nie wymaga migracji (kontra simple-history). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-04-historia-zmian-reversion-design.md | 85 ++++++++++++++----- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/specs/2026-06-04-historia-zmian-reversion-design.md b/docs/superpowers/specs/2026-06-04-historia-zmian-reversion-design.md index 33b906606..e4e895c0a 100644 --- a/docs/superpowers/specs/2026-06-04-historia-zmian-reversion-design.md +++ b/docs/superpowers/specs/2026-06-04-historia-zmian-reversion-design.md @@ -45,7 +45,7 @@ inny koszt, inny use-case. | Narzędzie | Werdykt | Dlaczego | |---|---|---| -| **django-reversion** (+ compare) | ✅ **wybrane** | Już w repo, już ostylowane (`templates/reversion/object_history.html`), już na `Uczelnia`. Daje dokładnie żądany UX: per-rekordowa oś wersji + diff pól + revert + „kto/kiedy/komentarz". Zakres selektywny (per-model). | +| **django-reversion** (+ `reversion-compare`) | ✅ **wybrane** | Już w repo (na `Uczelnia` jako goły `VersionAdmin` = oś wersji + revert + „kto/kiedy/komentarz"; szablon historii już ostylowany). **Diff pól** (side-by-side) wymaga `CompareVersionAdmin` z `reversion-compare` — pakiet jest zainstalowany, ale **nie jest jeszcze wpięty w żaden admin** (grep: 0 trafień); to dorobimy. Zakres selektywny (per-model). | | django-pghistory | ❌ | Triggerowy total-audyt DB (łapie bulk/SQL). Rozwiązuje *inny* problem niż „historia edycji w adminie"; jego admin to log zdarzeń, nie oś wersji z revertem. | | django-simple-history | ❌ | Duplikuje każdą tabelę (`_historical`) — zła kombinacja z 5 dużymi tabelami publikacji; dubluje paradygmat, który już mamy w reversion; nie wykorzystalibyśmy jego głównej przewagi (compare mamy w reversion-compare). | @@ -54,6 +54,11 @@ inny koszt, inny use-case. > (import, SQL) zostają niewidoczne — i to jest **akceptowalne**, bo celem jest > rozliczalność ludzi, a nie total-audyt (sekcja 1). +**Zaleta wdrożeniowa:** włączenie reversion na kolejnym modelu **nie wymaga +migracji** — rejestracja jest runtime, a tabele `Revision`/`Version` już istnieją +(reversion działa w repo). Kontrast z simple-history (migracja + osobna tabela +`_historical` na każdy model). + ## 3. Co BPP już ma (punkt wyjścia) - **Warstwa 0 — wbudowany `LogEntry`.** Każda strona zmiany w adminie ma już @@ -63,16 +68,22 @@ inny koszt, inny use-case. tekstowy (które pola), bez wartości pól, bez revertu. **Zostaje jako druga siatka bezpieczeństwa.** - **Warstwa 1 — reversion na `Uczelnia`.** `UczelniaAdmin(... VersionAdmin)` - (`src/bpp/admin/uczelnia.py`) + `reversion-compare` + **ostylowany** - `src/django_bpp/templates/reversion/object_history.html` (Grappelli look). - Koszt integracji UX-owej jest już zapłacony — trzeba tylko podpiąć kolejne - modele. + (`src/bpp/admin/uczelnia.py`, **goły `VersionAdmin`** z `reversion.admin`) — + daje oś wersji + revert + „kto/kiedy/komentarz", ale **nie** diff pól (to + `CompareVersionAdmin`, sekcja 4). Szablon historii + `src/django_bpp/templates/reversion/object_history.html` jest już ostylowany + (Grappelli). Koszt integracji częściowo zapłacony — UX historii gotowy, brakuje + wpięcia compare. ## 4. Zakres — modele do objęcia `VersionAdmin` -Dziś wszystkie poniższe (poza `Uczelnia`) używają gołego `admin.ModelAdmin` — -zmiana to dorzucenie `reversion.admin.VersionAdmin` do MRO (wzór: -`uczelnia.py`). +Dziś wszystkie poniższe (poza `Uczelnia`) używają gołego `admin.ModelAdmin`. +Zmiana to dorzucenie do MRO **`CompareVersionAdmin`** (z `reversion_compare.admin` +— daje historię + revert **oraz** side-by-side diff pól; rozszerza `VersionAdmin`). +Goły `VersionAdmin` (jak dziś na `Uczelnia`) wystarcza, gdy diff pól niepotrzebny +— ale skoro celem jest „kto **co** zmienił", `CompareVersionAdmin` jest domyślnym +wyborem. (`Uczelnia` warto przy okazji podnieść z `VersionAdmin` na +`CompareVersionAdmin`.) | Model | Admin | Baza dziś | Uwagi | |---|---|---|---| @@ -84,7 +95,15 @@ zmiana to dorzucenie `reversion.admin.VersionAdmin` do MRO (wzór: | `Autor` | `AutorAdmin` (`src/bpp/admin/autor.py`) | `admin.ModelAdmin` | też w zakresie soft-delete | | `Wydzial` | `WydzialAdmin` | `admin.ModelAdmin` | | | `Jednostka` | `JednostkaAdmin` | `DraggableMPTTAdmin` ⚠️ | MRO `VersionAdmin`+MPTT — przetestować drzewo (drag&drop zmienia `lft/rght/level`) | -| `Uczelnia` | `UczelniaAdmin` | ✅ `VersionAdmin` | wzorzec, bez zmian | +| `Uczelnia` | `UczelniaAdmin` | ✅ goły `VersionAdmin` | działa (oś+revert), bez diffa; opcjonalnie podnieść na `CompareVersionAdmin` | + +> **Zgłoszenia publikacji** są **tworzone** przez userów (front-end: +> `src/zglos_publikacje/views.py`), a **edytowane w adminie** — więc reversion +> (admin) pokrywa edycję, czyli to, co nas interesuje. Niuans niskiej wagi: samo +> userowe *utworzenie* nie jest rewizją admina, więc historia rekordu zaczyna się +> od pierwszej zmiany w adminie (lub od `createinitialrevisions`). Jeśli kiedyś +> chcemy w historii także autora pierwotnego zgłoszenia — owinąć widok tworzenia +> w `create_revision()` (opcjonalne, nie blokuje). ## 5. Relacja do `SoftDeleteLog` — komplementarność, nie duplikacja @@ -125,13 +144,21 @@ w rewizję, z atrybucją usera i komentarzem: import reversion def delete_model(self, request, obj): - with reversion.create_revision(): - reversion.set_user(request.user) + # self.create_revision(request) = helper VersionAdmin; sam robi set_user(request.user) + with self.create_revision(request): reversion.set_comment("Usunięto (soft-delete)") super().delete_model(request, obj) # → obj.delete() = UPDATE deleted_at # analogicznie delete_queryset() (akcja masowa) oraz akcja "Przywróć" → set_comment("Przywrócono") ``` +> **Twardy warunek poprawności:** nadpisany w projekcie soft-delete +> `delete()`/`restore()` (§2.2 tamtego design) **musi nadal wołać `instance.save()`**. +> Zweryfikowane: pakietowy `delete()` robi `self.save(update_fields=[...])` +> (`django_softdelete/models.py:163`), na czym wisi `post_save`, który reversion +> przechwytuje w bloku rewizji. Gdyby ktoś „zoptymalizował" override na +> `queryset.update()` (bulk), `post_save` nie odpali i historia usunięć **po cichu +> zniknie**. + Ponieważ soft-delete utrzymuje wiersz, oś czasu reversion pozostaje **ciągła**: `utworzono → edycje → usunięto → przywrócono → edycje` w jednej zakładce. (To działa lepiej niż reversion na twardym delete, gdzie obiekt znika.) @@ -155,16 +182,27 @@ moment akcji superusera. „Pokaż skasowane"). To **odpowiada na pytanie „czy admin obejrzy usunięte"**: tak — dzięki temu usunięty rekord da się otworzyć i przeczytać jego Historię. reversion tu nic nie zmienia, tylko korzysta z tego, że wiersz jest osiągalny. -- Wbudowany przycisk reversion **„recover deleted" staje się zbędny/mylący** pod - soft-delete (przywracamy przez `restore()`, nie przez reversion-recover) — - ukryć, by nie mieć dwóch sprzecznych dróg odkasowania. +- **Ukryć reversion „recover deleted".** Lista recover bierze wersje, których + wiersz już **nie istnieje** (`Version.objects.get_deleted`). Soft-deletowany + rekord wiersz **ma** (`deleted_at` ustawione) → w recover-liście się **nie + pojawi**, więc dla soft-delete jest po prostu pusta. Realny problem to + **`hard_delete`**: usuwa wiersz, więc reversion-recover mógłby go wskrzesić + *poza* przepływem (bez `WYSYLKA` w `pbn_export_queue`, bez `SoftDeleteLog`, + z ryzykiem złamania warunkowego unique na `slug`). Dlatego recover ukrywamy. ## 7. Pozostałe punkty (mniejszej wagi, ale realne) -- **Inline'y → `follow`.** `Zgloszenie_Publikacji` i wydawnictwa mają inline'y - (autorzy, załączniki). `VersionAdmin` domyślnie rejestruje i śledzi modele - inline z admina — **zweryfikować**, bo inaczej rewizja zapisze nagłówek bez - autorów i compare będzie mylący. +- **Inline'y → `follow` (automatyczne).** `VersionAdmin`/`CompareVersionAdmin` + **samo** rejestruje i `follow`-uje modele inline zadeklarowane w `self.inlines` + (`reversion/admin.py:145-154`) — autorzy/załączniki `Zgloszenie_Publikacji` i + wydawnictw wejdą do rewizji bez dodatkowej pracy. Realny caveat: relacje + edytowane **spoza** zadeklarowanych inline'ów nie są objęte — wtedy `follow` + trzeba dodać ręcznie. +- **Szablon historii a compare.** Ostylowany `object_history.html` renderuje + płaską tabelę bez kontrolek wyboru wersji do porównania. `CompareVersionAdmin` + potrzebuje w historii radio-inputów do wskazania pary wersji — szablon trzeba + rozszerzyć o ten selektor (albo użyć `compare_history` z `reversion_compare`). + Banał, ale konieczny, żeby diff był klikalny. - **Backfill historii (decyzja otwarta).** Reversion zna tylko zmiany *po* wdrożeniu. Pierwsza wersja starego rekordu powstaje przy pierwszej edycji albo jednorazowo przez `manage.py createinitialrevisions `. Na 5 dużych @@ -193,8 +231,9 @@ moment akcji superusera. ## 9. Decyzje rozstrzygnięte (brainstorming 2026-06-04) -1. **Narzędzie:** `django-reversion` (rozszerzenie istniejącego). pghistory i - simple-history odrzucone (sekcja 2). +1. **Narzędzie:** `django-reversion` + `CompareVersionAdmin` z `reversion-compare` + (rozszerzenie istniejącego; diff pól). pghistory i simple-history odrzucone + (sekcja 2). 2. **Zakres:** edycje ludzkie przez admin na zdefiniowanym zbiorze modeli (sekcja 4); **nie** total-audyt bazy. 3. **Soft-delete w historii:** usunięcie i restore mają być rewizjami reversion @@ -215,8 +254,10 @@ moment akcji superusera. ## 11. Precedensy w repo - `django-reversion>=6.2.0` + `django-reversion-compare>=0.19.2` — - `pyproject.toml`. -- `src/bpp/admin/uczelnia.py` — `VersionAdmin` w akcji (wzorzec MRO). + `pyproject.toml`. Uwaga: `reversion_compare` jest w `INSTALLED_APPS`, ale + **żaden admin nie używa `CompareVersionAdmin`** (grep: 0 trafień) — compare + jest do wpięcia od zera. +- `src/bpp/admin/uczelnia.py` — goły `VersionAdmin` w akcji (wzorzec MRO; bez diffa). - `src/django_bpp/templates/reversion/object_history.html` — gotowy, ostylowany szablon historii (nie trzeba robić od zera). - `src/bpp/admin/filters.py` — filtry na wbudowanym `LogEntry` (warstwa 0).