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

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

+

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

+ +
    + {% for obj in obiekty %} +
  • {{ obj }}
  • + {% endfor %} +
+ +
{% 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. From 66b2f4c8daa5ac341d4d85e03065d9f77ba50f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 11:25:01 +0200 Subject: [PATCH 07/75] =?UTF-8?q?docs(soft-delete):=20korekty=20spec?= =?UTF-8?q?=E2=86=94kod=20wykryte=20przy=20rozpisywaniu=20plan=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weryfikacja w realnym kodzie (przez agentów rozpisujących fazy) ujawniła: - C1: aktualny trigger to 0399_fix_refresh_cache_upsert.sql (upsert ON CONFLICT + advisory locks, DELETE-przed-upsertem bezwarunkowy), nie baseline 0001; + nota o utajonym bugu (string in lista-krotek) do poprawy w fazie 01, - C2: verify_cache.py to martwy stub (NotImplementedError + hardcoded host) — spójność weryfikujemy przez Rekord.objects.full_refresh(), nie verify_cache, - C3: slug to pole @denormalized (django-denorm-iplweb), nie proste unique=True — zdjąć unique z kwargs denorm + UniqueConstraint w Meced konkretnych klas, - C5: PBN_Export_Queue.zamowil jest NOT NULL → konto techniczne dla zakolejkowań systemowych (nie nullable), - reconcyliacja API thread-local: kanoniczne soft_delete_context(user,reason) + get_soft_delete_user/reason (faza 06 tworzy, 07 używa). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-04-soft-delete-00-overview.md | 8 ++- ...soft-delete-publikacje-i-autorzy-design.md | 63 ++++++++++++------- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md index a0bd5ce23..4bfb2559f 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md @@ -82,10 +82,12 @@ Pola PINNED: `content_type` (FK ContentType), `object_id` (PositiveIntegerField, - Nowe pole na `PBN_Export_Queue`: `operacja = models.CharField(choices=Operacja.choices, default=Operacja.WYSYLKA)` gdzie `class Operacja(models.TextChoices): WYSYLKA="wysylka"; WYCOFANIE="wycofanie"`. - Gałąź w logice wysyłki: `WYCOFANIE` → `client.delete_all_publication_statements(pbn_uid)` (`src/pbn_api/client/mixins/institutions.py:87`). -### Wstrzykiwanie `user` (PINNED, faza 06/07) +### Wstrzykiwanie `user` (PINNED — API thread-local, faza 06 tworzy, 07 używa) - Override sygnatury: `delete(self, *args, user=None, reason="", **kwargs)` i `restore(self, *args, user=None, **kwargs)`. -- W adminie **jeden hook** (`delete_model`/`delete_queryset`/akcja „Przywróć") ustawia usera; ten sam moment ma w przyszłości zasilić `reversion.set_user` (patrz „Kontrakty z reversion"). -- Sygnał nie niesie usera → przekazujemy go do receiverów przez argument akcji / thread-local ustawiony w adminie. Operacje systemowe (merge, celery): `user=None`. +- **Kanoniczne API (faza 06, `src/bpp/models/soft_delete_context.py`):** context manager `soft_delete_context(user=None, reason="")` (thread-local, reentrant — wąska kaskada `*_Autor` dziedziczy kontekst rodzica) + akcesory `get_soft_delete_user()` / `get_soft_delete_reason()`. Receivery sygnałów czytają akcesory (sygnał nie niesie usera). +- **Faza 07 (admin) używa `soft_delete_context`** — jeden hook (`delete_model`/`delete_queryset`/akcja „Przywróć") owija operację w `with soft_delete_context(user=request.user, reason=...):`. Ten sam moment ma w przyszłości zasilić `reversion.set_user` (patrz „Kontrakty z reversion"). NIE wymyślać osobnego `set/get/clear_soft_delete_user` — używać `soft_delete_context`. +- **`zamowil` w `pbn_export_queue` jest NOT NULL** — gdy `user is None` (operacje systemowe/celery), zakolejkowanie używa konta technicznego (`get_or_create`), nie `None`. +- Operacje systemowe (merge autora, celery): `user=None` w `SoftDeleteLog` (FK nullable), konto techniczne tylko dla `zamowil` kolejki. ### Punkty zaczepienia w istniejącym kodzie (zweryfikowane) - Rejestracja sygnałów: `src/bpp/apps.py` → `BppConfig.ready()` (linia 8). diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md index c8f8131f6..8c6934185 100644 --- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md +++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md @@ -82,16 +82,22 @@ publikacji przed usunięciem czyni kasowanie bezużytecznym. Wąska semantyka ### 2.1 Trigger jako choke-point (najwrażliwszy, robiony PIERWSZY) `Rekord` to UNION-view nad materializowaną tabelą `bpp_rekord_mat`, zasilaną -triggerem `bpp_refresh_cache()` -(**baseline: `src/bpp/migrations/0001_cache_functions.sql`** — uwaga: stary -spec referował przed-squashowy `107_cache_functions.sql`). Z `bpp_rekord_mat` -/ `bpp_autorzy_mat` czyta większość systemu (publiczny frontend, multiseek, -global search, ewaluacja `Cache_Punktacja_*`, raporty). - -**Fakt z kodu** (`0001_cache_functions.sql:78-87`): na `DELETE` trigger usuwa -wiersze z tabel `_mat`; na `UPDATE/INSERT` re-insertuje. Soft-delete to -technicznie `UPDATE` → **bez zmiany triggera skasowany rekord wróciłby do -mat-view**. +triggerem `bpp_refresh_cache()`. **Aktualna wersja funkcji to +`src/bpp/migrations/0399_fix_refresh_cache_upsert.sql`** (NIE baseline +`0001_cache_functions.sql` — historyczny; funkcja ewoluowała przez `0112`, +`0310`, `0387`, `0399`, + `0400_drop/restore_cache_triggers`). Z +`bpp_rekord_mat`/`bpp_autorzy_mat` czyta większość systemu (publiczny frontend, +multiseek, global search, ewaluacja `Cache_Punktacja_*`, raporty). + +**Fakt z kodu** (`0399_fix_refresh_cache_upsert.sql`): na `DELETE` trigger usuwa +wiersze z `_mat`; na `UPDATE/INSERT` robi **`DELETE` + upsert** +(`INSERT ... SELECT FROM ... ON CONFLICT DO UPDATE`, pod +`pg_advisory_xact_lock`). `DELETE` przed upsertem jest **bezwarunkowy** (linie +125-126), więc gdy widok źródłowy odfiltruje skasowane — upsert nic nie +re-insertuje. Soft-delete to technicznie `UPDATE` → **bez filtra w widoku +skasowany rekord wróciłby** (upsert wstawiłby go ponownie). (Uwaga: `0399` ma +drobny utajony bug — `"bpp_autorzy_mat" not in refresh_tables` sprawdza string +w liście krotek `(table, id_col)`; do poprawienia przy okazji, faza 01.) **Zmiana:** ścieżka `UPDATE/INSERT` triggera uczona reguły: > jeśli `TD['new']['deleted_at'] IS NOT NULL` → zachowaj się jak `DELETE` @@ -114,13 +120,17 @@ Skutki: (`Rekord` czyta `bpp_rekord`, `src/bpp/models/cache/rekord.py:357`), oraz pełną re-projekcję/weryfikację cache. - **Mechanizm #2 (optymalizacja) — trigger-skip:** w gałęzi `UPDATE/INSERT` - `if TD['new'].get('deleted_at') is not None: `. Oszczędza - no-op SELECT/INSERT, ale **sam nie wystarcza** (nie pokrywa odczytu z widoku - ani `verify_cache`). Filtr widoku (#1) jest obowiązkowy; trigger-skip - opcjonalny. -- **`verify_cache` (`src/bpp/management/commands/verify_cache.py`)** porównuje - `bpp_rekord_mat` ze źródłem — MUSI respektować `deleted_at` (przez filtr #1), - inaczej zgłosi fałszywy rozjazd dla skasowanych i spróbuje je wskrzesić. + `if TD['new'].get('deleted_at') is not None: ` (DELETE i tak + już zaszedł). Oszczędza no-op SELECT/upsert, ale **sam nie pokrywa pełnej + re-projekcji** (ta re-selektuje z widoku). Filtr widoku (#1) jest + obowiązkowy; trigger-skip opcjonalny. +- **Weryfikacja spójności — `Rekord.objects.full_refresh()`** (re-projekcja + `_mat` ze źródła), NIE `verify_cache`. ⚠️ `src/bpp/management/commands/verify_cache.py` + to **martwy stub** (`psycopg2.connect(database="b_med", host="linux-dev")` + + `raise NotImplementedError`) — nie da się go uruchomić; jego naprawa jest + POZA zakresem soft-delete. Testy spójności robią pełen `full_refresh` i + sprawdzają, że skasowane rekordy NIE wracają do `_mat` (to weryfikuje filtr + widoku #1). - **Przypadek brzegowy znika strukturalnie:** edycja wiersza autorstwa skasowanej publikacji nie wskrzesi go w `bpp_autorzy_mat`, bo widok źródłowy go odfiltruje (ma własne `deleted_at` ustawione kaskadą). @@ -128,8 +138,8 @@ Skutki: triggerów through. Pomijalne. Testy spójności mat-view obowiązkowe (soft-delete → znika z `Rekord` i -`Autorzy`; restore → wraca; `verify_cache` czysty po soft-delete; brak -rozjazdu `Cache_Punktacja_*`). +`Autorzy`; restore → wraca; po `full_refresh()` skasowane NIE wracają do `_mat`; +brak rozjazdu `Cache_Punktacja_*`). ### 2.2 Override `delete()` — wąska, kontrolowana kaskada na `*_Autor` @@ -165,8 +175,12 @@ Zweryfikować, że nadpisany `delete()`/`restore()` nadal emituje sygnały ### 2.3 `slug` — warunkowy unique -Wszystkie 5 modeli ma denormalizowany `slug unique=True`. Skasowany rekord -trzyma slug → konflikt przy ponownym utworzeniu. Zamiana na +⚠️ `slug` to **pole denormalizowane** (`@denormalized(models.SlugField, ..., +unique=True, ...)` z `django-denorm-iplweb`), zadeklarowane w 4 miejscach: +`wydawnictwo_ciagle.py:246`, `wydawnictwo_zwarte.py:325`, `patent.py:180`, +`Praca_Doktorska_Baza:105` (dzielone przez doktorat i habilitację). Skasowany +rekord trzyma slug → konflikt przy ponownym utworzeniu. Zmiana: **zdjąć +`unique=True` z kwargs denorm** i dodać w `Meta` każdej konkretnej klasy `UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True))`. Migracja (NIE modyfikować istniejących migracji). @@ -324,6 +338,13 @@ Rozszerzenie: - status zapisywany jak dla wysyłki (`zakonczono_pomyslnie`, `komunikat`, `ilosc_prob`) + odzwierciedlenie w `SentData` i `SoftDeleteLog`. +⚠️ **`PBN_Export_Queue.zamowil` jest NOT NULL (`on_delete=CASCADE`).** +Zakolejkowanie z admina ma `request.user`. Ale zakolejkowanie inicjowane +sygnałem przy soft-delete bez usera (operacje programistyczne / celery) nie ma +kogo wpisać. Rozwiązanie (faza 05/06): **konto techniczne** (np. +`get_or_create` systemowego użytkownika) jako `zamowil` dla operacji +systemowych — NIE robić `zamowil` nullable (psułoby istniejące zał. kolejki). + `SentData` (`src/pbn_api/models/sentdata.py`, GFK + `pbn_uid` + `submitted_successfully` + `mark_as_successful`/`mark_as_failed`) trzyma stan PBN per-rekord. **Po udanym wycofaniu:** ustawiamy `submitted_successfully = From 408c4424bf9b190777e5ae08bf85084948bde315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 11:30:45 +0200 Subject: [PATCH 08/75] =?UTF-8?q?docs(soft-delete):=20koordynacja=20z=20r?= =?UTF-8?q?=C3=B3wnoleg=C5=82=C4=85=20optymalizacj=C4=85=20triggera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Funkcja bpp_refresh_cache optymalizowana w osobnej gałęzi (prace użytkownika). Faza 01 = BLOKER do czasu wskazania tej gałęzi i aktualizacji feat/soft-delete. Ortogonalność: faza 01 dotyka widoków źródłowych (filtr deleted_at), optymalizacja dotyka funkcji triggera. Zapisany inwariant do weryfikacji: bezwarunkowy DELETE przed re-insertem/upsertem (na nim wisi wystarczalność filtra widoku). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-04-soft-delete-00-overview.md | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md index 4bfb2559f..6cace0329 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md @@ -16,7 +16,7 @@ | # | Plik | Zakres | Zależy od | |---|---|---|---| -| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; filtr `deleted_at` w widokach źródłowych; trigger-skip; testy spójności cache + `verify_cache` | — | +| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; **filtr `deleted_at` w widokach źródłowych (mechanizm nadrzędny)**; trigger-skip OPCJONALNY; spójność przez `full_refresh()`. ⚠️ patrz „Koordynacja: trigger" niżej | — / trigger ⚠️ | | 02 | `2026-06-04-soft-delete-02-publikacje.md` | 5 modeli → SoftDeleteModel; override `delete()`/`restore()` z wąską kaskadą na `*_Autor`; `slug` warunkowy unique; przeplecenie menedżerów | 01 | | 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 90 miejsc `*_Autor.objects` | 02 | | 04 | `2026-06-04-soft-delete-04-guardy-protect.md` | flip FK `CASCADE→PROTECT` (autor, doktorat, `wydawnictwo_nadrzedne`); guard w soft `delete()` (autor + książka-matka); soft-delete husku autora | 02 | @@ -27,6 +27,28 @@ --- +## ⚠️ Koordynacja: trigger `bpp_refresh_cache` (BLOKER fazy 01) + +Funkcja `bpp_refresh_cache()` jest **równolegle optymalizowana w osobnej gałęzi** +(prace użytkownika). **Faza 01 NIE startuje, dopóki ta gałąź nie wyląduje** i +`feat/soft-delete` nie zostanie na nią zaktualizowana (rebase na `dev` lub +merge gałęzi optymalizacji). + +Ortogonalność: faza 01 dla POPRAWNOŚCI dotyka **widoków źródłowych** +(`bpp_rekord`, `bpp_*_autorzy` — filtr `deleted_at IS NULL`), a optymalizacja +dotyka **funkcji triggera** — w dużej mierze rozłączne. Modyfikacja funkcji +triggera w fazie 01 jest **opcjonalna** (trigger-skip) i nakładana CIENKO na +wierzch zoptymalizowanej funkcji, jeśli w ogóle. + +**Inwariant, który MUSI przetrwać optymalizację** (inaczej filtr widoku przestaje +wystarczać i wraca konieczność trigger-skip): na `UPDATE/INSERT` trigger robi +**bezwarunkowy `DELETE` z `_mat` przed re-insertem/upsertem** — tzn. nie +re-insertuje wiersza, którego źródłowy widok nie zwraca. Po wskazaniu gałęzi: +zweryfikować ten inwariant + czy optymalizacja rusza widoki + status utajonego +buga (string w liście krotek `(table, id_col)`). + +--- + ## Wspólne kontrakty (PINNED — wszystkie fazy używają tych nazw VERBATIM) ### Pakiet `django-soft-delete` (punkt wyjścia, nie zmieniamy) From fdd9a15a8ff498bff7c265b39bd5d455fa4877c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 11:33:57 +0200 Subject: [PATCH 09/75] =?UTF-8?q?docs(soft-delete):=20faza=2001=20?= =?UTF-8?q?=E2=80=94=20tylko=20widoki,=20trigger=20NIE=20ruszany=20(decyzj?= =?UTF-8?q?a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decyzja użytkownika: bpp_refresh_cache() optymalizowany osobno; faza 01 zmienia WYŁĄCZNIE widoki źródłowe (filtr deleted_at IS NULL). Trigger-skip i fix utajonego buga z krotkami WYCIĘTE z fazy 01. Box AKTUALIZACJA ZAKRESU na górze planu 01 + jednoznaczna nota w indeksie. Inwariant do weryfikacji po wskazaniu gałęzi optymalizacji: bezwarunkowy DELETE przed re-insertem/upsertem. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-04-soft-delete-00-overview.md | 12 ++++++------ ...026-06-04-soft-delete-01-autor-trigger-widoki.md | 13 +++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md index 6cace0329..15eb87728 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md @@ -16,7 +16,7 @@ | # | Plik | Zakres | Zależy od | |---|---|---|---| -| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; **filtr `deleted_at` w widokach źródłowych (mechanizm nadrzędny)**; trigger-skip OPCJONALNY; spójność przez `full_refresh()`. ⚠️ patrz „Koordynacja: trigger" niżej | — / trigger ⚠️ | +| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; **filtr `deleted_at` TYLKO w widokach źródłowych** (funkcja triggera NIE ruszana); spójność przez `full_refresh()`. ⚠️ patrz „Koordynacja: trigger" niżej | — / trigger ⚠️ | | 02 | `2026-06-04-soft-delete-02-publikacje.md` | 5 modeli → SoftDeleteModel; override `delete()`/`restore()` z wąską kaskadą na `*_Autor`; `slug` warunkowy unique; przeplecenie menedżerów | 01 | | 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 90 miejsc `*_Autor.objects` | 02 | | 04 | `2026-06-04-soft-delete-04-guardy-protect.md` | flip FK `CASCADE→PROTECT` (autor, doktorat, `wydawnictwo_nadrzedne`); guard w soft `delete()` (autor + książka-matka); soft-delete husku autora | 02 | @@ -34,11 +34,11 @@ Funkcja `bpp_refresh_cache()` jest **równolegle optymalizowana w osobnej gałę `feat/soft-delete` nie zostanie na nią zaktualizowana (rebase na `dev` lub merge gałęzi optymalizacji). -Ortogonalność: faza 01 dla POPRAWNOŚCI dotyka **widoków źródłowych** -(`bpp_rekord`, `bpp_*_autorzy` — filtr `deleted_at IS NULL`), a optymalizacja -dotyka **funkcji triggera** — w dużej mierze rozłączne. Modyfikacja funkcji -triggera w fazie 01 jest **opcjonalna** (trigger-skip) i nakładana CIENKO na -wierzch zoptymalizowanej funkcji, jeśli w ogóle. +**DECYZJA: faza 01 NIE rusza funkcji `bpp_refresh_cache()` — zmieniamy WYŁĄCZNIE +widoki źródłowe** (`bpp_rekord`, `bpp_*_autorzy` — filtr `deleted_at IS NULL`). +Trigger-skip WYCIĘTY (zbędny przy zachowanym inwariancie delete-first). Utajony +bug z krotkami `(table, id_col)` — NIE naprawiamy tutaj, zostaje optymalizacji +triggera. Ortogonalność pełna: Ty = funkcja triggera, ja = widoki. **Inwariant, który MUSI przetrwać optymalizację** (inaczej filtr widoku przestaje wystarczać i wraca konieczność trigger-skip): na `UPDATE/INSERT` trigger robi diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md index 0b8e995b9..7f49fb58f 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -2,6 +2,19 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> ⚠️ **AKTUALIZACJA ZAKRESU (decyzja użytkownika 2026-06-04) — CZYTAJ PRZED STARTEM:** +> 1. **NIE ruszamy funkcji `bpp_refresh_cache()`.** Zmieniamy **wyłącznie widoki +> źródłowe** (`bpp_*_autorzy`, ew. `bpp_rekord`) — filtr `deleted_at IS NULL`. +> Każde zadanie tego planu dotyczące **trigger-skip** / modyfikacji kopii +> `0399` / fixu utajonego buga z krotkami — **POMIŃ** (zostaje równoległej +> optymalizacji triggera). +> 2. **BLOKER:** funkcja `bpp_refresh_cache()` jest równolegle optymalizowana w +> osobnej gałęzi. Tej fazy **NIE startować**, dopóki ta gałąź nie wyląduje i +> `feat/soft-delete` nie zostanie na nią zaktualizowana. Po aktualizacji +> zweryfikować inwariant: trigger na `UPDATE/INSERT` robi **bezwarunkowy +> `DELETE` z `_mat` przed re-insertem/upsertem** (na tym wisi wystarczalność +> filtra widoku). Jeśli inwariant zniknie → dopiero wtedy rozważyć trigger-skip. + **Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez wspólną bazę `BazaModeluOdpowiedzialnosciAutorow`), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, oraz wpiąć filtr `deleted_at IS NULL` do widoków źródłowych PostgreSQL (`bpp_*_autorzy` + gałęzie UNION `bpp_rekord`) tak, by soft-deletowane autorstwa znikały z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i wracały po `restore`. Opcjonalnie: trigger-skip w `bpp_refresh_cache()`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować. **Architecture:** Mechanizm nadrzędny to **filtr widoku (#1)** — każda tabela `bpp_*_autor` ma własną kolumnę `deleted_at`, a widoki źródłowe `bpp_wydawnictwo_ciagle_autorzy` / `bpp_wydawnictwo_zwarte_autorzy` / `bpp_patent_autorzy` (`0001_widoki_autorzy.sql`) dostają `AND .deleted_at IS NULL` po **własnej** kolumnie (bez JOIN do rekordu nadrzędnego). To pokrywa WSZYSTKIE ścieżki: re-insert triggera `bpp_refresh_cache()`, bezpośredni odczyt `Rekord`/`RekordView` z widoku `bpp_rekord`, oraz pełną re-projekcję cache. Gałęzie `UNION` w `bpp_rekord` (`0001_widoki_rekord.sql`) per typ publikacji NIE filtrują po `*_autor.deleted_at` (rekord publikacji żyje niezależnie od soft-delete pojedynczego autorstwa — soft-delete publikacji to faza 02), ale dla spójności kontraktu dodajemy filtr `deleted_at IS NULL` na poziomie tabeli autorskiej tylko w widokach `bpp_*_autorzy`. Trigger-skip (#2) to opcjonalna optymalizacja w gałęzi `UPDATE/INSERT` funkcji `bpp_refresh_cache()` (aktualna wersja: `0399_fix_refresh_cache_upsert.sql`): gdy `TD['new']['deleted_at'] is not None` → pomiń upsert (delete-only). Nie zastępuje #1. From 0bde1dab41f9bffbc0c38d9bb789cdb4b7e45fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 08:52:25 +0200 Subject: [PATCH 10/75] test(soft-delete): kanarki warunkow wstepnych wobec triggerow cache Spec soft-delete opieral sie na dwoch zalozeniach o warstwie cache, ktore przestaly obowiazywac po PR #363 (migracje 0432/0433): 1. ze UPDATE ustawiajacy deleted_at doleci do triggera cache -- NIE doleci, bo bramka WHEN z 0433 zna tylko kolumny zasilajace widok (z pg_depend), a django-soft-delete zapisuje przez save(update_fields=['deleted_at', 'restored_at', 'transaction_id']); 2. ze filtr "deleted_at IS NULL" w widoku zrodlowym WYSTARCZY, bo trigger robi bezwarunkowy DELETE przed upsertem ("inwariant delete-first") -- NIE robi: funkcje refresh z 0432 to czysty INSERT ... SELECT FROM widok ... ON CONFLICT DO UPDATE, wiec odfiltrowanie wiersza ze zrodla daje no-op, a stary wiersz przezywa w _mat. Oba testy sa ZIELONE na obecnym kodzie -- przypinaja stan "soft-delete by nie zadzialal". Faza 01 odwroci ich asercje razem z wprowadzeniem galezi kasujacej w funkcjach refresh i regeneracja bramki WHEN. Kolumne deleted_at i filtr widoku symulujemy DDL-em wewnatrz transakcji testowej (DDL w Postgresie jest transakcyjny). Cale DDL musi isc PRZED utworzeniem rekordu -- ALTER TABLE odmawia, gdy tabela ma zakolejkowane zdarzenia wyzwalaczy z INSERT-a w tej samej transakcji. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../test_soft_delete_preconditions.py | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/bpp/tests/test_cache/test_soft_delete_preconditions.py diff --git a/src/bpp/tests/test_cache/test_soft_delete_preconditions.py b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py new file mode 100644 index 000000000..de916f1c1 --- /dev/null +++ b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py @@ -0,0 +1,153 @@ +"""Warunki wstepne soft-delete wobec triggerow cache (PR #312, faza 01). + +Spec soft-delete (2026-06-04) zaklada dwa fakty o warstwie cache: + +1. ``deleted_at`` ustawione UPDATE-em dolecialoby do triggera cache; +2. filtr ``deleted_at IS NULL`` w widoku zrodlowym WYSTARCZY, bo trigger na + UPDATE robi bezwarunkowy DELETE z ``_mat`` przed upsertem ("inwariant, + ktory MUSI przetrwac optymalizacje" -- plan 00). + +Oba fakty przestaly obowiazywac po migracjach 0432 (port PL/pgSQL, upsert bez +DELETE) i 0433 (bramka WHEN na liscie kolumn z pg_depend). Te testy to +przypinaja: sa ZIELONE na obecnym kodzie, czyli dokumentuja stan "soft-delete +by nie zadzialal". Faza 01 ma je odwrocic (zmienic asercje na docelowe) razem +z wprowadzeniem gałęzi kasujacej w funkcjach refresh + regeneracja bramki. + +Surowy SQL, zeby izolowac sam trigger bazodanowy (bez denorm / sygnalow +Django). Kolumne ``deleted_at`` dokladamy ALTER-em wewnatrz transakcji +testowej -- DDL w Postgresie jest transakcyjny, wiec rollback ja sprzata. +""" + +import pytest +from django.db import connection + +from bpp.tests.util import any_ciagle + + +def _ct(cur, model): + cur.execute( + "SELECT id FROM django_content_type WHERE app_label='bpp' AND model=%s", + [model], + ) + return cur.fetchone()[0] + + +def _ctid(cur, ct, pk): + """Fizyczne polozenie krotki w bpp_rekord_mat; None gdy wiersza nie ma. + + ctid, nie xmin: w obrebie jednej transakcji testowej kazdy zapis ma to samo + xid, wiec xmin sie nie rusza nawet przy przepisaniu wiersza. + """ + cur.execute( + "SELECT ctid::text FROM bpp_rekord_mat WHERE id = ARRAY[%s, %s]::integer[]", + [ct, pk], + ) + row = cur.fetchone() + return row[0] if row else None + + +def _dodaj_deleted_at(cur): + cur.execute("ALTER TABLE bpp_wydawnictwo_ciagle ADD COLUMN deleted_at timestamptz") + + +def _filtruj_widok_po_deleted_at(cur): + """Owija bpp_wydawnictwo_ciagle_view filtrem deleted_at IS NULL. + + Odpowiednik "mechanizmu #1" ze specu, bez ruszania oryginalnej definicji + (CREATE OR REPLACE zachowuje liste kolumn -- bpp_rekord, ktory ten widok + UNION-uje, pozostaje wazny). + """ + cur.execute("SELECT pg_get_viewdef('bpp_wydawnictwo_ciagle_view'::regclass, true)") + orig = cur.fetchone()[0].rstrip().rstrip(";") + cur.execute( + f"CREATE OR REPLACE VIEW bpp_wydawnictwo_ciagle_view AS " + f"SELECT * FROM ({orig}) _orig " + f"WHERE _orig.object_id_raw NOT IN (" + f" SELECT id FROM bpp_wydawnictwo_ciagle WHERE deleted_at IS NOT NULL)" + ) + + +@pytest.mark.django_db +def test_update_samego_deleted_at_nie_odpala_triggera(): + """Bramka WHEN (0433) nie zna deleted_at -> UPDATE soft-delete nie wchodzi. + + django-soft-delete kasuje przez + ``save(update_fields=['deleted_at', 'restored_at', 'transaction_id'])``, + wiec UPDATE dotyka WYLACZNIE kolumn spoza bramki. Zaden atrybut zasilajacy + widok sie nie zmienia -> trigger sie nie odpala -> wiersz zostaje w + bpp_rekord_mat (ctid bez zmian). + """ + # DDL PRZED utworzeniem rekordu: ALTER TABLE nie przejdzie, gdy tabela ma + # zakolejkowane zdarzenia wyzwalaczy z INSERT-a w tej samej transakcji. + with connection.cursor() as cur: + _dodaj_deleted_at(cur) + + wc = any_ciagle(tytul_oryginalny="Bramka a soft-delete") + pk = wc.pk + + with connection.cursor() as cur: + ct = _ct(cur, "wydawnictwo_ciagle") + + przed = _ctid(cur, ct, pk) + assert przed is not None, "wiersz powinien byc w bpp_rekord_mat po INSERT" + + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s", + [pk], + ) + po = _ctid(cur, ct, pk) + + assert po == przed, ( + "UPDATE samego deleted_at przepisal bpp_rekord_mat " + f"(ctid {przed} -> {po}) -- bramka WHEN najwyrazniej zna deleted_at" + ) + + +@pytest.mark.django_db +def test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat(): + """Upsert bez DELETE (0432): odfiltrowanie z widoku NIE czysci _mat. + + Nawet gdy trigger SIE ODPALI (wymuszamy to UPDATE-em bramkowanej kolumny + ``rok``), funkcja refresh robi tylko + ``INSERT ... SELECT FROM widok ... ON CONFLICT DO UPDATE``. Widok nie + zwraca wiersza -> INSERT wybiera zero wierszy -> no-op -> stary wiersz + przezywa w bpp_rekord_mat. + + To obala "inwariant delete-first" z planu 00, na ktorym opiera sie + wystarczalnosc mechanizmu #1. + """ + # Cale DDL przed INSERT-em (patrz test wyzej). Filtr widoku jest juz + # aktywny przy tworzeniu rekordu, ale deleted_at jest wtedy NULL, wiec + # wiersz normalnie wchodzi do bpp_rekord_mat. + with connection.cursor() as cur: + _dodaj_deleted_at(cur) + _filtruj_widok_po_deleted_at(cur) + + wc = any_ciagle(tytul_oryginalny="Filtr widoku bez DELETE", rok=2020) + pk = wc.pk + + with connection.cursor() as cur: + ct = _ct(cur, "wydawnictwo_ciagle") + + assert _ctid(cur, ct, pk) is not None + + # soft-delete: wiersz wypada z widoku zrodlowego + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s", + [pk], + ) + # ... i wymuszamy odpalenie triggera zmiana kolumny Z bramki + cur.execute("UPDATE bpp_wydawnictwo_ciagle SET rok = 2021 WHERE id = %s", [pk]) + + cur.execute( + "SELECT count(*) FROM bpp_wydawnictwo_ciagle_view WHERE object_id_raw = %s", + [pk], + ) + w_widoku = cur.fetchone()[0] + po = _ctid(cur, ct, pk) + + assert w_widoku == 0, "widok zrodlowy powinien juz nie zwracac wiersza" + assert po is not None, ( + "wiersz zniknal z bpp_rekord_mat -- funkcja refresh jednak kasuje " + "przed upsertem (inwariant delete-first zyje)" + ) From 7119a766f0cfe7beb09e65a173acf79c9a853b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 09:31:47 +0200 Subject: [PATCH 11/75] docs(soft-delete): rewizja specu i planow po porcie triggera (PR #363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec powstal przed portem bpp_refresh_cache PL/Python -> PL/pgSQL. Weryfikacja na aktualnym dev obalila dwa nosne zalozenia warstwy cache i ujawnila 5 luk. Cache (§2.1 przepisana od zera, decyzja #9 uniewazniona): - funkcja bpp_refresh_cache() NIE ISTNIEJE (DROP w 0432); zastapilo ja 16 funkcji per-tabela + bramka WHEN na UPDATE (0433), - inwariant "bezwarunkowy DELETE przed upsertem", na ktorym wisiala wystarczalnosc filtra widoku, NIE ISTNIEJE -- funkcja refresh to czysty upsert, wiec odfiltrowanie wiersza ze zrodla jest no-opem, - UPDATE ustawiajacy deleted_at nie przechodzi przez bramke WHEN, bo django-soft-delete zapisuje przez save(update_fields=[...]). Zamiast jednego mechanizmu potrzebne sa TRZY, wszystkie obowiazkowe: filtr w widoku + galaz kasujaca w funkcji refresh + regeneracja bramki WHEN. Domkniete decyzje (#12-#16): - #12 full_refresh() to denorm.rebuildall, NIE re-projekcja _mat -- nie nadaje sie do weryfikacji spojnosci (test przechodzilby z falszywych powodow), - #13 unique_together na *_Autor -> warunkowy UniqueConstraint (jak slug), - #14 import trafiajacy w kosz: POMIN + ZARAPORTUJ (nie update, nie restore), - #15 Cache_Punktacja_* kasowane przy soft-delete, przeliczane przy restore -- luka nieobjeta kaskada *_Autor (brak FK do publikacji), - #16 PBN: jeden prymityw, dwa wejscia (kolejka + synchroniczne), bo rekord bywa wysylany bez kolejki. Plany: BLOKER fazy 01 zdjety; nowy Task 3 (widoki+funkcje+bramka) zamiast kopii 0399; nowy Task 2b w fazie 02 (ten sam DDL dla 5 tabel publikacji -- faza 01 dotyka wylacznie sciezki autorstwa); Task 1b w 03, 05.0/05.9 w 05, 6b w 06; przenumerowane migracje (0488+, liste 0487); 90 -> 128 miejsc *_Autor.objects; korekta strict (delete=False, restore=True). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../2026-06-04-soft-delete-00-overview.md | 76 ++- ...-04-soft-delete-01-autor-trigger-widoki.md | 542 +++++++++--------- .../2026-06-04-soft-delete-02-publikacje.md | 163 +++++- ...-06-04-soft-delete-03-audyt-kategorii-b.md | 63 +- ...2026-06-04-soft-delete-05-pbn-wycofanie.md | 82 ++- ...2026-06-04-soft-delete-06-softdeletelog.md | 76 ++- ...026-06-04-soft-delete-08-testy-regresji.md | 66 ++- ...soft-delete-publikacje-i-autorzy-design.md | 456 +++++++++++---- 8 files changed, 1086 insertions(+), 438 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md index 15eb87728..0131bed39 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md @@ -4,9 +4,9 @@ **Goal:** Wdrożyć odwracalny soft-delete dla 5 typów publikacji + wąską kaskadę na `*_Autor`, soft-delete autora bez prac (z PROTECT dla autora/książki z zależnościami), wycofanie z PBN przez kolejkę, audyt `SoftDeleteLog` i wsparcie w adminie superusera. -**Architecture:** `django-soft-delete` (`SoftDeleteModel`) na 5 modelach publikacji + 3 through-modelach `*_Autor`; spójność cache w JEDNYM punkcie — filtr `deleted_at IS NULL` w widokach źródłowych PostgreSQL (mechanizm #1) + opcjonalny trigger-skip. Override `delete()` robi wąską kaskadę na `*_Autor`. PBN-wycofanie idzie przez rozszerzoną `pbn_export_queue`. `SoftDeleteLog` zasilany sygnałami pakietu. +**Architecture:** `django-soft-delete` (`SoftDeleteModel`) na 5 modelach publikacji + 3 through-modelach `*_Autor`. Spójność cache wymaga **trzech** elementów naraz (filtr w widoku źródłowym + gałąź kasująca w funkcji refresh + regeneracja bramki `WHEN`) — patrz box „Koordynacja" niżej; wersja „jeden mechanizm wystarczy" została obalona 2026-08-06. Override `delete()` robi wąską kaskadę na `*_Autor`. PBN-wycofanie: jeden prymityw, dwa wejścia (kolejka + synchroniczne). `SoftDeleteLog` zasilany sygnałami pakietu; on też kasuje/przelicza `Cache_Punktacja_*`. -**Tech Stack:** Django, PostgreSQL (`plpython3u` triggery + widoki), `django-soft-delete>=1.0.23`, `django-denorm-iplweb`, Celery + `pbn_export_queue`, pytest + model_bakery. +**Tech Stack:** Django, PostgreSQL (triggery **PL/pgSQL** + widoki), `django-soft-delete>=1.0.23`, `django-denorm-iplweb`, Celery + `pbn_export_queue`, pytest + model_bakery. **Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) @@ -16,36 +16,47 @@ | # | Plik | Zakres | Zależy od | |---|---|---|---| -| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; **filtr `deleted_at` TYLKO w widokach źródłowych** (funkcja triggera NIE ruszana); spójność przez `full_refresh()`. ⚠️ patrz „Koordynacja: trigger" niżej | — / trigger ⚠️ | +| 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; widoki `bpp_*_autorzy` + **gałąź kasująca w funkcjach refresh** + **regeneracja bramki `WHEN`**; spójność weryfikowana surowym SQL-em (NIE `full_refresh()`) | — | | 02 | `2026-06-04-soft-delete-02-publikacje.md` | 5 modeli → SoftDeleteModel; override `delete()`/`restore()` z wąską kaskadą na `*_Autor`; `slug` warunkowy unique; przeplecenie menedżerów | 01 | -| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 90 miejsc `*_Autor.objects` | 02 | +| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 128 miejsc `*_Autor.objects` | 02 | | 04 | `2026-06-04-soft-delete-04-guardy-protect.md` | flip FK `CASCADE→PROTECT` (autor, doktorat, `wydawnictwo_nadrzedne`); guard w soft `delete()` (autor + książka-matka); soft-delete husku autora | 02 | -| 05 | `2026-06-04-soft-delete-05-pbn-wycofanie.md` | `pbn_export_queue.operacja = WYSYLKA\|WYCOFANIE`; `WYCOFANIE` → `delete_all_publication_statements`; restore → `WYSYLKA`; integracja `SentData` | 02 | -| 06 | `2026-06-04-soft-delete-06-softdeletelog.md` | model `SoftDeleteLog`; receivery `post_soft_delete`/`post_restore`/`post_hard_delete`; wstrzykiwanie `user` | 02, 05 | +| 05 | `2026-06-04-soft-delete-05-pbn-wycofanie.md` | prymityw `wycofaj_oswiadczenia()`; `pbn_export_queue.operacja = WYSYLKA\|WYCOFANIE` (wejście async) + wywołanie bezpośrednie (sync); restore → `WYSYLKA`; integracja `SentData` | 02 | +| 06 | `2026-06-04-soft-delete-06-softdeletelog.md` | model `SoftDeleteLog`; receivery `post_soft_delete`/`post_restore`/`post_hard_delete`; wstrzykiwanie `user`; **kasowanie/przeliczanie `Cache_Punktacja_*`** | 02, 05 | | 07 | `2026-06-04-soft-delete-07-admin.md` | admin superuser-only: kosz/filtr/przywróć/usuń-trwale/powód (5 modeli + Autor); jeden hook usera | 04, 06 | | 08 | `2026-06-04-soft-delete-08-testy-regresji.md` | pełna suita regresji: PBN duplikaty/wycofanie, dashboard, import, ewaluacja, merge autorów, API | 01–07 | --- -## ⚠️ Koordynacja: trigger `bpp_refresh_cache` (BLOKER fazy 01) +## ✅ Koordynacja: trigger cache — BLOKER ZDJĘTY (2026-08-06) -Funkcja `bpp_refresh_cache()` jest **równolegle optymalizowana w osobnej gałęzi** -(prace użytkownika). **Faza 01 NIE startuje, dopóki ta gałąź nie wyląduje** i -`feat/soft-delete` nie zostanie na nią zaktualizowana (rebase na `dev` lub -merge gałęzi optymalizacji). +Optymalizacja triggera **wylądowała** (PR #363: `0421_cache_trigger_pk_filter`, +`0429_cache_trigger_v3`, `0432_cache_trigger_plpgsql`, +`0433_cache_trigger_when_gate`). Gałąź `perf/cache-trigger-pk-filter` już nie +istnieje; ta gałąź jest zrebasowana na `dev`. -**DECYZJA: faza 01 NIE rusza funkcji `bpp_refresh_cache()` — zmieniamy WYŁĄCZNIE -widoki źródłowe** (`bpp_rekord`, `bpp_*_autorzy` — filtr `deleted_at IS NULL`). -Trigger-skip WYCIĘTY (zbędny przy zachowanym inwariancie delete-first). Utajony -bug z krotkami `(table, id_col)` — NIE naprawiamy tutaj, zostaje optymalizacji -triggera. Ortogonalność pełna: Ty = funkcja triggera, ja = widoki. +**Inwariant, o którego przetrwanie pytał poprzedni box, NIE przetrwał** — +i to zmienia zakres fazy 01: -**Inwariant, który MUSI przetrwać optymalizację** (inaczej filtr widoku przestaje -wystarczać i wraca konieczność trigger-skip): na `UPDATE/INSERT` trigger robi -**bezwarunkowy `DELETE` z `_mat` przed re-insertem/upsertem** — tzn. nie -re-insertuje wiersza, którego źródłowy widok nie zwraca. Po wskazaniu gałęzi: -zweryfikować ten inwariant + czy optymalizacja rusza widoki + status utajonego -buga (string w liście krotek `(table, id_col)`). +| Poprzednie założenie | Stan faktyczny (zweryfikowany testem) | +|---|---| +| trigger robi bezwarunkowy `DELETE` przed upsertem | **NIE** — funkcja refresh to czysty `INSERT ... SELECT ... ON CONFLICT DO UPDATE`; odfiltrowanie wiersza z widoku daje no-op, stary wiersz przeżywa w `_mat` | +| UPDATE ustawiający `deleted_at` doleci do triggera | **NIE** — bramka `WHEN` z `0433` zna tylko kolumny zasilające widok, a `django-soft-delete` zapisuje przez `save(update_fields=[...])` | + +Dowód: `src/bpp/tests/test_cache/test_soft_delete_preconditions.py` — dwa +kanarki, **zielone na obecnym kodzie** (przypinają stan „soft-delete by nie +zadziałał"). Faza 01 odwraca ich asercje. + +**NOWY ZAKRES FAZY 01 — trzy elementy zamiast jednego, wszystkie obowiązkowe:** + +1. filtr `deleted_at IS NULL` w widokach źródłowych `bpp_*_autorzy`, +2. **gałąź kasująca** w funkcjach refresh (`IF NEW.deleted_at IS NOT NULL THEN + DELETE ... RETURN NULL`) — wzorzec już w kodzie: gałąź doktorat/habilitacja + w `_create_rekord_function` (`0432`), +3. **regeneracja bramki `WHEN`** — `RunPython` wołający logikę `forward()` + z `0433`; `deleted_at` wejdzie do bramki sam, przez `pg_depend`, bo punkt 1 + wstawił ją do `WHERE` widoku. **Kolejność: 1 → 3.** + +Szczegóły i uzasadnienie: §2.1 specu. --- @@ -57,6 +68,8 @@ buga (string w liście krotek `(table, id_col)`). - Metody instancji: `.delete()` (soft, woła `self.save(update_fields=[...])` + `post_soft_delete`), `.hard_delete()`, `.restore()`. - `SoftDeleteQuerySet.delete()` iteruje per-instancję (`for obj in self.iterator(): obj.delete()`) — bezpieczny dla sygnałów. **NIE** robi bulk update. - Sygnały (`django_softdelete/signals.py`): `post_soft_delete`, `post_hard_delete`, `post_restore`. +- ⚠️ **`strict` jest ASYMETRYCZNE:** `delete(strict=False)`, ale `restore(strict=True)`. Czyli domyślna kaskada `delete()` **nie krzyknie** na nie-soft dzieciach — po cichu po nich przejedzie. Kolejny powód, by nie polegać na kaskadzie pakietu (§2.2 specu). +- ⚠️ **`delete()` zapisuje przez `save(update_fields=['deleted_at','restored_at','transaction_id'])`** — to jest przyczyna, dla której bramka `WHEN` triggera cache nie przepuszcza soft-delete (i dla której `ostatnio_zmieniony`/`auto_now` NIE jest bumpowany). ### Nowy moduł `src/bpp/models/soft_delete.py` (tworzy faza 01) ```python @@ -114,12 +127,17 @@ Pola PINNED: `content_type` (FK ContentType), `object_id` (PositiveIntegerField, ### Punkty zaczepienia w istniejącym kodzie (zweryfikowane) - Rejestracja sygnałów: `src/bpp/apps.py` → `BppConfig.ready()` (linia 8). - Menedżery publikacji: `src/bpp/models/wydawnictwo_ciagle.py:87` (`Wydawnictwo_Ciagle_Manager`), `wydawnictwo_zwarte.py:167` (`Wydawnictwo_Zwarte_Manager`), oba po `ManagerModeliZOplataZaPublikacjeMixin` (`src/bpp/models/abstract/fees.py`). -- Through-model FK autora: `src/bpp/models/abstract/authors.py:22` (`autor = ForeignKey("bpp.Autor", CASCADE)`). -- Doktorat FK: `src/bpp/models/praca_doktorska.py:136` (CASCADE). Habilitacja: `praca_habilitacyjna.py:42` (O2O PROTECT, bez zmian). -- Self-FK rozdziałów: `src/bpp/models/wydawnictwo_zwarte.py:202` (`wydawnictwo_nadrzedne`). -- Trigger/widoki: `src/bpp/migrations/0001_cache_functions.sql` (funkcja `bpp_refresh_cache`), `src/bpp/migrations/0001_widoki_autorzy.sql`, `0001_widoki_rekord.sql`. -- `Rekord` czyta widok `bpp_rekord`: `src/bpp/models/cache/rekord.py:357`. Mat-tabela: `:347`. -- `verify_cache`: `src/bpp/management/commands/verify_cache.py`. +- Through-model FK autora: `src/bpp/models/abstract/authors.py:25` (`autor = models.ForeignKey("bpp.Autor", CASCADE)`). +- Doktorat FK: `src/bpp/models/praca_doktorska.py:153` (CASCADE). Habilitacja: `praca_habilitacyjna.py:47` (O2O PROTECT, bez zmian). +- Self-FK rozdziałów: `src/bpp/models/wydawnictwo_zwarte.py:212` (`wydawnictwo_nadrzedne`, CASCADE). +- `unique_together` na through: `src/bpp/models/wydawnictwo_ciagle.py:73-77` (analogicznie zwarte/patent) — do zamiany na warunkowy `UniqueConstraint`, §2.2b specu. +- **Trigger (AKTUALNY):** `src/bpp/migrations/0432_cache_trigger_plpgsql.py` — generator 8 funkcji refresh + 8 delete (PL/pgSQL, upsert bez DELETE); `0433_cache_trigger_when_gate.py` — generator bramki `WHEN` z `pg_depend`. ⚠️ Funkcja `bpp_refresh_cache()` **nie istnieje** (DROP w `0432`); `0399`/`0001_cache_functions.sql` to historia. +- Widoki źródłowe: definicje odtwarzane wielokrotnie; **ostatnia wersja per-typ w `0421_cache_trigger_pk_filter.sql`** (dodaje `object_id_raw`). `bpp_rekord` = UNION 5 widoków per-typ. +- `Rekord` czyta **tabelę `bpp_rekord_mat`**: `src/bpp/models/cache/rekord.py:382-386`. Widok `bpp_rekord` → `RekordView`, `:394-396`. +- `full_refresh()` = `denorm.rebuildall`, NIE re-projekcja `_mat`: `src/bpp/models/cache/rekord.py:121-127`. +- `Cache_Punktacja_Autora`/`_Dyscypliny`: `src/bpp/models/cache/punktacja.py`; zapis w `src/bpp/models/sloty/core.py:401,439`; odczyt m.in. `ewaluacja_optymalizacja/utils.py:182`, `.../evaluation_browser/prefetch.py:41`, `oswiadczenia/views.py:353`. +- `verify_cache`: `src/bpp/management/commands/verify_cache.py` — **martwy stub**, nie używać. +- Wzorzec testów cache: `src/bpp/tests/test_cache/test_cache_plpgsql_port.py`. Kanarki soft-delete: `test_soft_delete_preconditions.py`. - Admin tych modeli: `src/bpp/admin/{wydawnictwo_ciagle,wydawnictwo_zwarte,patent,praca_doktorska,praca_habilitacyjna,autor}.py`; mixiny `src/bpp/admin/helpers/mixins.py`. - PBN klient: `src/pbn_api/client/mixins/institutions.py:87`. `SentData`: `src/pbn_api/models/sentdata.py`. Kolejka: `src/pbn_export_queue/{models,tasks,admin}.py`. - Merge autorów: `src/deduplikator_autorow/views/merge.py:155`, `utils/merge.py:191,284,354`. @@ -143,7 +161,7 @@ Równoległy spec [`../specs/2026-06-04-historia-zmian-reversion-design.md`](../ - `src/bpp/models/soft_delete.py` (queryset+gate, managery, guard helper) — faza 01/04 - `src/bpp/models/soft_delete_log.py` (model `SoftDeleteLog`) — faza 06 - `src/bpp/migrations/0XXX_*` — migracje pól soft-delete (`*_Autor`, 5 publikacji), `slug` constraint, FK flips, `SoftDeleteLog`, `pbn_export_queue.operacja` -- `src/bpp/migrations/0XXX_soft_delete_views.sql` — filtr `deleted_at` w `bpp_rekord`/`bpp_*_autorzy` + trigger-skip +- `src/bpp/migrations/0XXX_soft_delete_*_views.py` — `RunPython`: filtr `deleted_at` w widokach źródłowych + gałąź kasująca w funkcjach refresh + regeneracja bramki `WHEN` (faza 01 dla `bpp_*_autorzy`, faza 02 dla `bpp_*_view`) - `src/bpp/receivers/soft_delete.py` (lub w istniejącym module sygnałów) — receivery — faza 06 **Modyfikowane (główne):** diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md index 7f49fb58f..d8c3af128 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -2,24 +2,36 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -> ⚠️ **AKTUALIZACJA ZAKRESU (decyzja użytkownika 2026-06-04) — CZYTAJ PRZED STARTEM:** -> 1. **NIE ruszamy funkcji `bpp_refresh_cache()`.** Zmieniamy **wyłącznie widoki -> źródłowe** (`bpp_*_autorzy`, ew. `bpp_rekord`) — filtr `deleted_at IS NULL`. -> Każde zadanie tego planu dotyczące **trigger-skip** / modyfikacji kopii -> `0399` / fixu utajonego buga z krotkami — **POMIŃ** (zostaje równoległej -> optymalizacji triggera). -> 2. **BLOKER:** funkcja `bpp_refresh_cache()` jest równolegle optymalizowana w -> osobnej gałęzi. Tej fazy **NIE startować**, dopóki ta gałąź nie wyląduje i -> `feat/soft-delete` nie zostanie na nią zaktualizowana. Po aktualizacji -> zweryfikować inwariant: trigger na `UPDATE/INSERT` robi **bezwarunkowy -> `DELETE` z `_mat` przed re-insertem/upsertem** (na tym wisi wystarczalność -> filtra widoku). Jeśli inwariant zniknie → dopiero wtedy rozważyć trigger-skip. - -**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez wspólną bazę `BazaModeluOdpowiedzialnosciAutorow`), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, oraz wpiąć filtr `deleted_at IS NULL` do widoków źródłowych PostgreSQL (`bpp_*_autorzy` + gałęzie UNION `bpp_rekord`) tak, by soft-deletowane autorstwa znikały z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i wracały po `restore`. Opcjonalnie: trigger-skip w `bpp_refresh_cache()`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować. - -**Architecture:** Mechanizm nadrzędny to **filtr widoku (#1)** — każda tabela `bpp_*_autor` ma własną kolumnę `deleted_at`, a widoki źródłowe `bpp_wydawnictwo_ciagle_autorzy` / `bpp_wydawnictwo_zwarte_autorzy` / `bpp_patent_autorzy` (`0001_widoki_autorzy.sql`) dostają `AND .deleted_at IS NULL` po **własnej** kolumnie (bez JOIN do rekordu nadrzędnego). To pokrywa WSZYSTKIE ścieżki: re-insert triggera `bpp_refresh_cache()`, bezpośredni odczyt `Rekord`/`RekordView` z widoku `bpp_rekord`, oraz pełną re-projekcję cache. Gałęzie `UNION` w `bpp_rekord` (`0001_widoki_rekord.sql`) per typ publikacji NIE filtrują po `*_autor.deleted_at` (rekord publikacji żyje niezależnie od soft-delete pojedynczego autorstwa — soft-delete publikacji to faza 02), ale dla spójności kontraktu dodajemy filtr `deleted_at IS NULL` na poziomie tabeli autorskiej tylko w widokach `bpp_*_autorzy`. Trigger-skip (#2) to opcjonalna optymalizacja w gałęzi `UPDATE/INSERT` funkcji `bpp_refresh_cache()` (aktualna wersja: `0399_fix_refresh_cache_upsert.sql`): gdy `TD['new']['deleted_at'] is not None` → pomiń upsert (delete-only). Nie zastępuje #1. - -**Tech Stack:** Django 4.2, PostgreSQL (`plpython3u` trigger + widoki), `django-soft-delete>=1.0.23` (`SoftDeleteModel`, `SoftDeleteManager`/`GlobalManager`/`DeletedManager`), pytest + model_bakery, `denorm` (django-denorm-iplweb). Python wyłącznie przez `uv run`. +> 🔄 **AKTUALIZACJA ZAKRESU 2026-08-06 — CZYTAJ PRZED STARTEM. Zastępuje +> poprzedni box „decyzja użytkownika 2026-06-04".** +> +> **BLOKER ZDJĘTY.** Optymalizacja triggera wylądowała (PR #363, migracje +> `0421`/`0429`/`0432`/`0433`), gałąź jest zrebasowana na `dev`. +> +> **Ale inwariant, o który pytał poprzedni box, NIE przetrwał** — i to +> ROZSZERZA tę fazę. Zweryfikowane empirycznie +> (`src/bpp/tests/test_cache/test_soft_delete_preconditions.py`, oba kanarki +> zielone na obecnym kodzie): +> 1. funkcja refresh to **czysty upsert bez `DELETE`** → odfiltrowanie wiersza +> z widoku daje **no-op**, stary wiersz przeżywa w `_mat`; +> 2. **bramka `WHEN`** (migracja `0433`) nie zna `deleted_at`, a +> `django-soft-delete` zapisuje przez `save(update_fields=[...])` → UPDATE +> soft-delete **w ogóle nie dochodzi do funkcji triggera**. +> +> **Filtr widoku sam NIE wystarcza.** Ta faza robi trzy rzeczy, nie jedną — +> patrz „Architecture" niżej. Wszystkie obowiązkowe. + +**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez wspólną bazę `BazaModeluOdpowiedzialnosciAutorow`), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, i doprowadzić do tego, by soft-deletowane autorstwa **znikały** z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i **wracały** po `restore`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować. + +**Architecture — trzy elementy, wszystkie obowiązkowe** (żaden nie wystarcza sam; uzasadnienie: §2.1 specu): + +1. **Filtr `deleted_at IS NULL` w widokach źródłowych** `bpp_wydawnictwo_ciagle_autorzy` / `bpp_wydawnictwo_zwarte_autorzy` / `bpp_patent_autorzy` — po **własnej** kolumnie tabeli `*_autor` (bez JOIN do rekordu nadrzędnego). Rola: (a) karmi `pg_depend` dla punktu 3, (b) chroni pełne przebudowy i odczyt przez `bpp_autorzy`. **Nie sprząta `_mat`** — to robi punkt 2. +2. **Gałąź kasująca w 3 funkcjach `bpp_refresh_autor_()`** (`0432_cache_trigger_plpgsql.py`): prolog `IF NEW.deleted_at IS NOT NULL THEN DELETE FROM bpp_autorzy_mat WHERE id = ARRAY[ct, NEW.id]::integer[]; RETURN NULL; END IF;`. Wzorzec jest już w kodzie — gałąź doktorat/habilitacja w `_create_rekord_function` robi dokładnie `DELETE` + `INSERT`, bo tam wiersz też może wypaść ze źródła. Restore (`deleted_at → NULL`) przechodzi dalej do normalnego upsertu — symetria za darmo. +3. **Regeneracja bramki `WHEN`** na 3 triggerach `*_cache_upd` — `RunPython` wołający logikę `forward()` z `0433_cache_trigger_when_gate.py`. `deleted_at` wejdzie do bramki **sama**, przez `pg_depend`, bo punkt 1 wstawił ją do `WHERE` widoku. **Kolejność w migracji: punkt 1 → punkt 3** (bramka czyta definicję widoku). + +Gałęzie `UNION` w `bpp_rekord` per typ publikacji NIE filtrują po `*_autor.deleted_at` — rekord publikacji żyje niezależnie od soft-delete pojedynczego autorstwa (soft-delete publikacji to faza 02). + +**Tech Stack:** Django 4.2, PostgreSQL (triggery **PL/pgSQL** + widoki), `django-soft-delete>=1.0.23` (`SoftDeleteModel`, `SoftDeleteManager`/`GlobalManager`/`DeletedManager`), pytest + model_bakery, `denorm` (django-denorm-iplweb). Python wyłącznie przez `uv run`. **Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§1, §2.1, §2.2, §8 pkt 1). Indeks: [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md). @@ -28,13 +40,15 @@ - `Wydawnictwo_Ciagle_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_ciagle.py:52`. FK `rekord` → `Wydawnictwo_Ciagle`, `related_name="autorzy_set"`, `src/bpp/models/wydawnictwo_ciagle.py:58`. - `Wydawnictwo_Zwarte_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_zwarte.py:60`. FK `rekord`, `related_name="autorzy_set"`, `:67`. - `Patent_Autor(BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/patent.py:32`. FK `rekord`, `related_name="autorzy_set"`, `:35`. -- Wszystkie 3 mają `Meta.unique_together` (NIE ruszamy; `deleted_at` nie wchodzi w `unique_together` — autorstwa nie mają warunkowego unique w tej fazie, sług to faza 02). +- Wszystkie 3 mają `Meta.unique_together` — `("rekord","autor","typ_odpowiedzialnosci")` i `("rekord","autor","kolejnosc")`, np. `src/bpp/models/wydawnictwo_ciagle.py:73-77`. **W TEJ fazie NIE ruszamy**; zamiana na warunkowy `UniqueConstraint` (decyzja #13, §2.2b specu) idzie w fazie 02 razem ze slugiem. - `BazaModeluOdpowiedzialnosciAutorow.objects` NIE jest jawnie zdefiniowany → po wpięciu `SoftDeleteModel` domyślne `objects` = `SoftDeleteManager` (z pakietu). Nadpiszemy je naszymi `Bpp*` z `src/bpp/models/soft_delete.py`. -- `SoftDeleteModel.delete()` (pakiet, `django_softdelete/models.py`) robi **refleksyjną kaskadę** po reverse relacjach — dla `*_Autor` reverse relacji do soft-delete dzieci NIE ma (ich dzieci to nie-soft `Autor`/`Jednostka` przez FK forward), więc kaskada jest no-op. `delete()` woła `self.save(update_fields=['deleted_at','restored_at','transaction_id'])` → odpala trigger `bpp_*_autor_cache_trigger` jako `UPDATE`. To jest pożądane. -- Widok `bpp_autorzy_mat` (model `Autorzy`, `src/bpp/models/cache/autorzy.py:39`, `db_table="bpp_autorzy_mat"`) zasilany triggerem z `bpp_autorzy` (UNION `bpp_*_autorzy`). -- Aktualna funkcja triggera to `0399_fix_refresh_cache_upsert.sql` (NIE `0001_cache_functions.sql` — ta jest baseline, nadpisana przez 0399). Trigger-skip dopisujemy do **kopii treści 0399** w nowym pliku SQL. +- `SoftDeleteModel.delete()` (pakiet, `django_softdelete/models.py`) robi **refleksyjną kaskadę** po reverse relacjach — dla `*_Autor` reverse relacji do soft-delete dzieci NIE ma (ich dzieci to nie-soft `Autor`/`Jednostka` przez FK forward), więc kaskada jest no-op. `delete()` woła `self.save(update_fields=['deleted_at','restored_at','transaction_id'])`. ⚠️ **Ten UPDATE dotyka WYŁĄCZNIE tych 3 kolumn** — dlatego bramka `WHEN` musi znać `deleted_at` (punkt 3 „Architecture"), inaczej trigger się nie odpali. `ostatnio_zmieniony` (`auto_now`) też NIE jest bumpowany (`update_fields` filtruje `pre_save`). +- ⚠️ `strict` w pakiecie jest **asymetryczne**: `delete(strict=False)`, `restore(strict=True)`. +- Tabela `bpp_autorzy_mat` (model `Autorzy`, `src/bpp/models/cache/autorzy.py:39`, `db_table="bpp_autorzy_mat"`) zasilana triggerami z widoków `bpp_*_autorzy`. +- **Trigger (AKTUALNY, po PR #363):** `0432_cache_trigger_plpgsql.py` generuje 3 funkcje `bpp_refresh_autor_()` (upsert **bez** DELETE, `_create_through_function`) + 3 `bpp_delete_autor_()`, oraz triggery `_cache_ins` / `_cache_del` / `_cache_upd`. `0433_cache_trigger_when_gate.py` nakłada bramkę `WHEN` na `_cache_upd`, z listą kolumn wyliczoną z `pg_depend`. ⚠️ **Funkcja `bpp_refresh_cache()` NIE ISTNIEJE** — `DROP` w `0432`. Nie kopiować `0399` ani `0001_cache_functions.sql`. +- Widoki `bpp_*_autorzy`: ostatnia wersja definicji w `0421_cache_trigger_pk_filter.sql` (dodaje `object_id_raw`). Odtwarzając widok, wychodź z `pg_get_viewdef()`, nie z `0001_widoki_autorzy.sql`. - `transactional_db` fixture wymagany dla testów dotykających trigger/cache (trigger działa tylko z prawdziwym commitem). Fixture `denorms` (`src/fixtures/conftest_system.py:193`) daje `denorms.flush()`. Fixtury: `wydawnictwo_ciagle_z_dwoma_autorami`, `wydawnictwo_ciagle_z_autorem`, `autor_jan_kowalski`, `jednostka`, `standard_data`, `typy_odpowiedzialnosci`. -- Jedyny liść migracji `bpp`: `0420_autor_pokazuj_siec_powiazan_and_more`. Nowe migracje od niego zależą i są łańcuchowane: `0421 → 0422 (SQL)`. +- ⚠️ **Numeracja migracji (stan 2026-08-06):** liść to `0487_api_v1_przelaczniki`. Nowe migracje tej fazy: `0488_autor_soft_delete_fields` → `0489_soft_delete_autorzy_views` (SQL + regeneracja bramki). **Przed startem zweryfikuj liść ponownie** (`ls src/bpp/migrations/*.py | tail -3`) — `dev` żyje, numery mogły się przesunąć. Wszystkie numery w tym planie są orientacyjne; kanoniczna jest kolejność, nie cyfra. **Kontrakt z reversion (PINNED):** soft-delete idzie WYŁĄCZNIE per-instancja przez `.delete()`/`.save()` (nigdy `queryset.update(deleted_at=...)`). `BppSoftDeleteQuerySet.update()` to egzekwuje fail-fast (gate). W tej fazie testujemy gate i kaskadę queryset-ową. @@ -167,7 +181,7 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r **Files:** - Modify: `src/bpp/models/abstract/authors.py:16` (deklaracja klasy + managery), import `:1-13`. -- Create: `src/bpp/migrations/0421_autor_soft_delete_fields.py` +- Create: `src/bpp/migrations/0488_autor_soft_delete_fields.py` - Test (create): `src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py` **Steps:** @@ -270,11 +284,11 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r ```bash uv run python src/manage.py makemigrations bpp --name autor_soft_delete_fields ``` - (Spodziewany plik: `src/bpp/migrations/0421_autor_soft_delete_fields.py`, 3 pola × 3 modele = 9 `AddField`. Manager-y są `use_in_migrations=False` domyślnie, więc nie pojawią się w migracji.) + (Spodziewany plik: `src/bpp/migrations/0488_autor_soft_delete_fields.py`, 3 pola × 3 modele = 9 `AddField`. Manager-y są `use_in_migrations=False` domyślnie, więc nie pojawią się w migracji.) -- [ ] Zweryfikuj treść wygenerowanej migracji — musi zawierać `AddField` `deleted_at`/`restored_at`/`transaction_id` dla `wydawnictwo_ciagle_autor`, `wydawnictwo_zwarte_autor`, `patent_autor`. Jeśli Django dorzuciło `AlterModelManagers` — usuń tę operację ręcznie (Edit), bo managery soft-delete nie idą do schematu. Dependency MUSI być `("bpp", "0420_autor_pokazuj_siec_powiazan_and_more")`. +- [ ] Zweryfikuj treść wygenerowanej migracji — musi zawierać `AddField` `deleted_at`/`restored_at`/`transaction_id` dla `wydawnictwo_ciagle_autor`, `wydawnictwo_zwarte_autor`, `patent_autor`. Jeśli Django dorzuciło `AlterModelManagers` — usuń tę operację ręcznie (Edit), bo managery soft-delete nie idą do schematu. Dependency MUSI wskazywać na **aktualny liść** migracji `bpp` (na 2026-08-06: `("bpp", "0487_api_v1_przelaczniki")`) — zweryfikuj `ls src/bpp/migrations/*.py | tail -3` przed commitem. -- [ ] Dodaj indeks na `deleted_at` do każdej z 3 tabel. Dopisz do `operations` w `0421_autor_soft_delete_fields.py` (po `AddField`-ach), używając `AddIndex`: +- [ ] Dodaj indeks na `deleted_at` do każdej z 3 tabel. Dopisz do `operations` w `0488_autor_soft_delete_fields.py` (po `AddField`-ach), używając `AddIndex`: ```python migrations.AddIndex( model_name="wydawnictwo_ciagle_autor", @@ -318,7 +332,7 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r - [ ] Commit: ```bash - git add src/bpp/models/abstract/authors.py src/bpp/migrations/0421_autor_soft_delete_fields.py src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py + git add src/bpp/models/abstract/authors.py src/bpp/migrations/0488_autor_soft_delete_fields.py src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py git commit -m "feat(soft-delete): *_Autor → SoftDeleteModel + migracja pól deleted_at + indeks Co-Authored-By: Claude Opus 4.8 (1M context) " @@ -326,284 +340,266 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " --- -## Task 3 — Migracja SQL: filtr `deleted_at IS NULL` w widokach `bpp_*_autorzy` + trigger-skip +## Task 3 — Widoki + gałąź kasująca w funkcjach refresh + regeneracja bramki + +> 🔄 **Task przepisany 2026-08-06.** Poprzednia wersja tworzyła kopię +> `bpp_refresh_cache()` z `0399` z „trigger-skipem". **Tamta funkcja już nie +> istnieje** (`DROP` w `0432`), a sam filtr widoku nie sprząta `_mat`. Nowa +> wersja robi trzy rzeczy naraz — patrz „Architecture" na górze planu. -Przedefiniowanie 3 widoków źródłowych (`bpp_wydawnictwo_ciagle_autorzy`, `bpp_wydawnictwo_zwarte_autorzy`, `bpp_patent_autorzy`) z filtrem po **własnej** kolumnie `deleted_at` tabeli `*_autor` (mechanizm #1, obowiązkowy). Po `DROP ... CASCADE` widoku `bpp_*_autorzy` trzeba odtworzyć też zależny `bpp_autorzy` (UNION). Dodatkowo trigger-skip (#2, opcjonalny) — przedefiniowanie `bpp_refresh_cache()` na bazie 0399 z regułą „deleted_at NOT NULL → delete-only". Migracja ładuje plik `.sql` wzorcem `0399`. +Jedna migracja, trzy zmiany, w **wymuszonej kolejności**: + +1. przedefiniowanie 3 widoków `bpp_*_autorzy` z filtrem `deleted_at IS NULL` + po własnej kolumnie tabeli `*_autor` (+ odtworzenie zależnego `bpp_autorzy`, + bo `DROP ... CASCADE` go skasuje), +2. przedefiniowanie 3 funkcji `bpp_refresh_autor_()` z prologiem + kasującym, +3. regeneracja bramki `WHEN` na 3 triggerach `*_cache_upd` — **musi być po + kroku 1**, bo bramka czyta kolumny z `pg_depend` po definicji widoku. **Files:** -- Create: `src/bpp/migrations/0422_soft_delete_views.sql` -- Create: `src/bpp/migrations/0422_soft_delete_views.py` -- Test: pokrycie w Task 4 (testy spójności cache) — tu tylko migracja stosuje się czysto. +- Create: `src/bpp/migrations/0489_soft_delete_autorzy_views.py` (numer + zweryfikuj — patrz „Fakty z kodu") +- Test: `src/bpp/tests/test_soft_delete/test_views_sql.py` (nowy), + plus odwrócenie kanarków w + `src/bpp/tests/test_cache/test_soft_delete_preconditions.py` (Task 4) + +**Dlaczego migracja jest `RunPython`, a nie plik `.sql`:** definicje widoków +i funkcji są **generowane z introspekcji** (`information_schema.columns`, +`pg_depend`), a nie zapisane na sztywno. Kopiowanie ich do `.sql` odtworzyłoby +dokładnie ten problem, który `0432`/`0433` rozwiązały — patrz +`docs/superpowers/specs/2026-06-13-materialized-union-rekord-autorzy-design.md`. **Steps:** -- [ ] Napisz failing test smoke — dopisz do `src/bpp/tests/test_soft_delete/test_views_sql.py`: +- [ ] Napisz failing testy — `src/bpp/tests/test_soft_delete/test_views_sql.py`: ```python - """Widoki źródłowe bpp_*_autorzy filtrują po własnym deleted_at.""" + """Kontrakt DDL po fazie 01: widok filtruje, funkcja kasuje, bramka przepuszcza.""" import pytest from django.db import connection - WIDOKI = [ "bpp_wydawnictwo_ciagle_autorzy", "bpp_wydawnictwo_zwarte_autorzy", "bpp_patent_autorzy", ] + FUNKCJE = [ + "bpp_refresh_autor_wydawnictwo_ciagle", + "bpp_refresh_autor_wydawnictwo_zwarte", + "bpp_refresh_autor_patent", + ] + TRIGGERY = [ + ("bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_ciagle_autor_cache_upd"), + ("bpp_wydawnictwo_zwarte_autor", "bpp_wydawnictwo_zwarte_autor_cache_upd"), + ("bpp_patent_autor", "bpp_patent_autor_cache_upd"), + ] @pytest.mark.django_db @pytest.mark.parametrize("widok", WIDOKI) - def test_widok_zrodlowy_ma_filtr_deleted_at(widok): - """Definicja widoku w pg_get_viewdef musi zawierać 'deleted_at' - (filtr po własnej kolumnie tabeli autorskiej).""" + def test_widok_zrodlowy_filtruje_po_deleted_at(widok): with connection.cursor() as cur: cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok]) defn = cur.fetchone()[0] assert "deleted_at" in defn, f"{widok} nie filtruje po deleted_at" + + + @pytest.mark.django_db + @pytest.mark.parametrize("fn", FUNKCJE) + def test_funkcja_refresh_ma_galaz_kasujaca(fn): + """Bez DELETE odfiltrowanie z widoku jest no-opem (upsert nic nie usuwa).""" + with connection.cursor() as cur: + cur.execute("SELECT pg_get_functiondef(%s::regproc)", [fn]) + src = cur.fetchone()[0] + assert "NEW.deleted_at IS NOT NULL" in src, f"{fn}: brak gałęzi kasującej" + assert "DELETE FROM bpp_autorzy_mat" in src, f"{fn}: brak DELETE" + + + @pytest.mark.django_db + @pytest.mark.parametrize("tabela,trigger", TRIGGERY) + def test_bramka_when_zna_deleted_at(tabela, trigger): + """Bez deleted_at w bramce UPDATE soft-delete nie dochodzi do funkcji.""" + with connection.cursor() as cur: + cur.execute( + "SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t " + "WHERE t.tgrelid = %s::regclass AND t.tgname = %s", + [tabela, trigger], + ) + row = cur.fetchone() + assert row is not None, f"brak triggera {trigger}" + assert "deleted_at" in row[0], f"{trigger}: bramka WHEN nie zna deleted_at" ``` -- [ ] Uruchom (oczekiwany FAIL — widoki jeszcze bez `deleted_at`): +- [ ] Uruchom (oczekiwany FAIL — wszystkie 9 przypadków): ```bash uv run pytest src/bpp/tests/test_soft_delete/test_views_sql.py -q ``` -- [ ] Utwórz `src/bpp/migrations/0422_soft_delete_views.sql`. Treść = 3 widoki `bpp_*_autorzy` z dodanym `AND .deleted_at IS NULL`, odtworzenie `bpp_autorzy` (UNION, bo `DROP CASCADE` go skasuje), oraz przedefiniowanie `bpp_refresh_cache()` skopiowane z `0399_fix_refresh_cache_upsert.sql` z dopisanym trigger-skip w gałęzi `UPDATE/INSERT`: - ```sql - BEGIN; - - -- ── Mechanizm #1 (OBOWIĄZKOWY): filtr deleted_at w widokach źródłowych ── - -- Po DROP ... CASCADE widoku bpp_*_autorzy znika też zależny bpp_autorzy, - -- więc odtwarzamy go niżej. Filtr po WŁASNEJ kolumnie deleted_at tabeli - -- autorskiej (bez JOIN do rekordu nadrzędnego) — patrz spec §2.1. - - DROP VIEW IF EXISTS bpp_wydawnictwo_ciagle_autorzy CASCADE; - CREATE OR REPLACE VIEW bpp_wydawnictwo_ciagle_autorzy AS - select - django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id, - django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id, - django_content_type.id AS content_type_id, - rekord_id as object_id, - autor_id, - jednostka_id, - kolejnosc, - typ_odpowiedzialnosci_id, - zapisany_jako - from bpp_wydawnictwo_ciagle_autor, django_content_type - WHERE django_content_type.model = 'wydawnictwo_ciagle' - AND django_content_type.app_label = 'bpp' - AND bpp_wydawnictwo_ciagle_autor.deleted_at IS NULL; - - DROP VIEW IF EXISTS bpp_wydawnictwo_zwarte_autorzy CASCADE; - CREATE OR REPLACE VIEW bpp_wydawnictwo_zwarte_autorzy AS - select - django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id, - django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id, - django_content_type.id AS content_type_id, - rekord_id as object_id, - autor_id, - jednostka_id, - kolejnosc, - typ_odpowiedzialnosci_id, - zapisany_jako - from bpp_wydawnictwo_zwarte_autor, django_content_type - WHERE django_content_type.model = 'wydawnictwo_zwarte' - AND django_content_type.app_label = 'bpp' - AND bpp_wydawnictwo_zwarte_autor.deleted_at IS NULL; - - DROP VIEW IF EXISTS bpp_patent_autorzy CASCADE; - CREATE OR REPLACE VIEW bpp_patent_autorzy AS - select - django_content_type.id::text || '_' || rekord_id::text || '_' || autor_id::text || '_' || typ_odpowiedzialnosci_id::text || '_' || kolejnosc::text AS fake_id, - django_content_type.id::text || '_' || rekord_id::text AS fake_rekord_id, - django_content_type.id AS content_type_id, - rekord_id as object_id, - autor_id, - jednostka_id, - kolejnosc, - typ_odpowiedzialnosci_id, - zapisany_jako - from bpp_patent_autor, django_content_type - WHERE django_content_type.model = 'patent' - AND django_content_type.app_label = 'bpp' - AND bpp_patent_autor.deleted_at IS NULL; - - -- Odtworzenie UNION bpp_autorzy (skasowany przez DROP ... CASCADE powyżej). - -- bpp_praca_doktorska_autorzy / bpp_praca_habilitacyjna_autorzy NIE były - -- ruszane (autorstwo doktoratu/habilitacji nie jest *_Autor SoftDeleteModel - -- w tej fazie) — wciąż istnieją, więc UNION je dociągnie. - DROP VIEW IF EXISTS bpp_autorzy; - CREATE VIEW bpp_autorzy AS - SELECT * FROM bpp_wydawnictwo_ciagle_autorzy - UNION - SELECT * FROM bpp_wydawnictwo_zwarte_autorzy - UNION - SELECT * FROM bpp_patent_autorzy - UNION - SELECT * FROM bpp_praca_doktorska_autorzy - UNION - SELECT * FROM bpp_praca_habilitacyjna_autorzy; - - -- ── Mechanizm #2 (OPCJONALNY): trigger-skip w bpp_refresh_cache() ── - -- Kopia 0399_fix_refresh_cache_upsert.sql z jedną zmianą: w gałęzi - -- UPDATE/INSERT, gdy nowy wiersz ma deleted_at IS NOT NULL, pomijamy upsert - -- (zostaje samo DELETE z _mat). Filtr widoku #1 i tak pokrywa odczyt, ale to - -- oszczędza no-op SELECT/INSERT przy kaskadzie soft-delete na *_Autor. - CREATE OR REPLACE FUNCTION bpp_refresh_cache() - RETURNS TRIGGER - LANGUAGE plpython3u - AS $$ - cache_key = "django_content_type_ver_1" - columns_cache_key = "table_columns_ver_1" - table_name = TD["table_name"] - app_name, model_name = table_name.split("_", 1) - - refresh_rekord = True - refresh_autor = False - - trigger_field_name = "new" - if TD['event'] in ["DELETE", "UPDATE"]: - trigger_field_name = "old" - - TABELE_AUTORSKIE = ['bpp_wydawnictwo_ciagle_autor', 'bpp_wydawnictwo_zwarte_autor', 'bpp_patent_autor'] - id_field_name = 'id' - extra_where = '' - if table_name in TABELE_AUTORSKIE: - id_field_name = 'rekord_id' - model_name = model_name.replace("_autor", "") - refresh_autor = True - refresh_rekord = False - extra_where = ' AND autor_id = %s' % TD[trigger_field_name]['autor_id'] - - object_id = TD[trigger_field_name][id_field_name] - - if GD.get(cache_key) is None: - GD[cache_key] = {} - - if GD.get(columns_cache_key) is None: - GD[columns_cache_key] = {} - - try: - content_type_id = GD[cache_key][table_name] - except KeyError: - query = "SELECT id FROM django_content_type WHERE app_label = '%s' AND model = '%s'" % (app_name, model_name) - res = plpy.execute(query) - GD[cache_key][table_name] = res[0]['id'] - content_type_id = GD[cache_key][table_name] - - if TD["table_name"] in ["bpp_praca_doktorska", "bpp_praca_habilitacyjna"]: - refresh_autor = True - - where = "WHERE %%s = ARRAY[%s, %s]::INTEGER[2]" % (content_type_id, object_id) - where += extra_where - - # ── trigger-skip: soft-delete (UPDATE z deleted_at IS NOT NULL) ── - # zachowuje się jak DELETE (samo wyczyszczenie _mat, bez re-insertu). - skip_reinsert = ( - TD["event"] in ["UPDATE", "INSERT"] - and TD["new"] is not None - and TD["new"].get("deleted_at") is not None - ) +- [ ] Zweryfikuj liść migracji i ustal numer: + ```bash + ls src/bpp/migrations/*.py | tail -3 + ``` + +- [ ] Utwórz `src/bpp/migrations/0489_soft_delete_autorzy_views.py`. + **Reużyj generatorów z `0432`/`0433`** zamiast przepisywać SQL — nazwy + modułów zaczynają się od cyfry, więc zwykły `import` nie zadziała; użyj + `importlib.import_module` (moduły mają na poziomie modułu wyłącznie stałe + i funkcje, więc import jest bezpieczny): - refresh_tables = [] - if refresh_rekord: - refresh_tables.append(("bpp_rekord_mat", "id")) - refresh_tables.append(("bpp_autorzy_mat", "rekord_id")) - if refresh_autor: - if "bpp_autorzy_mat" not in [t for t, _ in refresh_tables]: - refresh_tables.append(("bpp_autorzy_mat", "rekord_id")) - - def get_table_columns(mat_table): - if mat_table not in GD[columns_cache_key]: - query = """ - SELECT column_name - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = '%s' - ORDER BY ordinal_position - """ % mat_table - res = plpy.execute(query) - GD[columns_cache_key][mat_table] = [row['column_name'] for row in res] - return GD[columns_cache_key][mat_table] - - def get_unique_constraint_column(mat_table): - return "id" - - with plpy.subtransaction(): - for table, id_col in refresh_tables: - lock_key = hash(f"{table}_{content_type_id}_{object_id}") % (2**31) - plpy.execute(f"SELECT pg_advisory_xact_lock({lock_key})") - - if TD["event"] == "DELETE" or skip_reinsert: - query = "DELETE FROM " + table + " " + (where % id_col) - plpy.execute(query) - elif TD["event"] in ["UPDATE", "INSERT"]: - source_view = table.replace("_mat", "") - columns = get_table_columns(table) - conflict_col = get_unique_constraint_column(table) - columns_str = ", ".join(columns) - update_columns = [col for col in columns if col != conflict_col] - set_clause = ", ".join([f"{col} = EXCLUDED.{col}" for col in update_columns]) - delete_query = "DELETE FROM " + table + " " + (where % id_col) - plpy.execute(delete_query) - select_query = f"SELECT {columns_str} FROM {source_view} " + (where % id_col) - upsert_query = f""" - INSERT INTO {table} ({columns_str}) - {select_query} - ON CONFLICT ({conflict_col}) DO UPDATE SET {set_clause} - """ - plpy.execute(upsert_query) - $$; - - COMMIT; - ``` - (UWAGA: `refresh_tables` w 0399 to lista krotek `(table, id_col)`, więc sprawdzenie `"bpp_autorzy_mat" not in refresh_tables` z 0399 było błędne dla krotek — tu poprawiamy na `not in [t for t, _ in refresh_tables]`. Reszta logiki 1:1 z 0399.) - -- [ ] Utwórz `src/bpp/migrations/0422_soft_delete_views.py` (wzorzec `0399`): ```python - from pathlib import Path + """Soft-delete autorstw: filtr w widokach + gałąź kasująca + bramka WHEN. + + Kolejność operacji jest WYMUSZONA: + 1) widoki (dodają deleted_at do WHERE) -> + 2) funkcje refresh (gałąź kasująca) -> + 3) regeneracja bramki WHEN (czyta pg_depend po definicji widoku z kroku 1) + + Odwrócenie 1<->3 daje bramkę bez deleted_at, czyli cichy staleness: + soft-deletowane autorstwo zostaje w bpp_autorzy_mat. + """ + + import importlib from django.db import connection, migrations + _p0432 = importlib.import_module("bpp.migrations.0432_cache_trigger_plpgsql") + _p0433 = importlib.import_module("bpp.migrations.0433_cache_trigger_when_gate") + + THROUGH_SITES = _p0432.THROUGH_SITES # [(tabela, model), ...] - def load_sql(apps, schema_editor): - sql_file = Path(__file__).parent / "0422_soft_delete_views.sql" - with open(sql_file) as f: - sql = f.read() - # connection.cursor() zamiast schema_editor.execute(): schema_editor - # interpretuje %s jako placeholdery parametrów (a w plpython3u są %s - # w stringach SQL budowanych ręcznie). - with connection.cursor() as cursor: - cursor.execute(sql) + def _viewdef(cur, widok): + cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok]) + return cur.fetchone()[0].rstrip().rstrip(";") - class Migration(migrations.Migration): - dependencies = [ - ("bpp", "0421_autor_soft_delete_fields"), - ] + def _filtruj_widok(cur, tabela, widok): + """Dokłada 'AND .deleted_at IS NULL' do widoku źródłowego. - operations = [ - migrations.RunPython(load_sql, migrations.RunPython.noop), - ] + Owijamy istniejącą definicję zamiast ją przepisywać: definicja jest + generowana (0421) i przepisanie jej ręcznie rozjechałoby się przy + następnej zmianie kolumn. + """ + orig = _viewdef(cur, widok) + cur.execute( + f"CREATE OR REPLACE VIEW {widok} AS " + f"SELECT * FROM ({orig}) _orig " + f"WHERE _orig.object_id_raw NOT IN (" + f" SELECT id FROM {tabela} WHERE deleted_at IS NOT NULL)" + ) + ``` + + ⚠️ **Do rozstrzygnięcia przy implementacji (nie zgaduj — zmierz):** czy + owijanie widoku (`SELECT * FROM (orig) WHERE object_id_raw NOT IN ...`) + zachowuje plan wykonania i czy `pg_depend` zarejestruje `deleted_at` jako + kolumnę bazową. Jeśli którekolwiek nie — wygeneruj definicję widoku od nowa + z listą kolumn i dopisz `AND .deleted_at IS NULL` do `WHERE` + wewnętrznego. **Test `test_bramka_when_zna_deleted_at` jest tu wyrocznią**: + jeśli po regeneracji bramka nie zna `deleted_at`, to znaczy że `pg_depend` + nie zobaczył kolumny przez owijkę. + +- [ ] Dopisz gałąź kasującą do funkcji refresh. Ciało generujemy tak jak + `_p0432._create_through_function`, ale z prologiem: + + ```python + def _funkcja_z_galezia_kasujaca(cur, tabela, model): + autorzy_view = tabela[: -len("_autor")] + "_autorzy" + upsert = _p0432._upsert_sql( + cur, "bpp_autorzy_mat", autorzy_view, + "object_id_raw = NEW.rekord_id AND autor_id = NEW.autor_id", + ) + ct_lookup = _p0432._ct_lookup(model) + return f""" + CREATE OR REPLACE FUNCTION bpp_refresh_autor_{model}() RETURNS trigger + LANGUAGE plpgsql AS $bpp_body$ + DECLARE ct integer; + BEGIN + {ct_lookup} + PERFORM pg_advisory_xact_lock(ct, NEW.rekord_id); + IF NEW.deleted_at IS NOT NULL THEN + DELETE FROM bpp_autorzy_mat WHERE id = ARRAY[ct, NEW.id]::integer[]; + RETURN NULL; + END IF; + {upsert}; + RETURN NULL; + END $bpp_body$; + """ + ``` + + Uwagi: + - klucz `ARRAY[ct, NEW.id]` jest identyczny jak w + `_create_delete_through_function` (`0432`) — tam `OLD.id`, tu `NEW.id`; + przy UPDATE to ten sam wiersz; + - `pg_advisory_xact_lock` **przed** rozgałęzieniem — kasowanie musi brać ten + sam lock co upsert, inaczej wraca wyścig z #309; + - restore (`deleted_at` → NULL) leci normalną ścieżką upsertu — nic + dodatkowego nie trzeba. + +- [ ] Złóż `forward()` w wymuszonej kolejności i `backward()` przywracający + stan sprzed migracji: + + ```python + def forward(apps, schema_editor): + with connection.cursor() as cur: + for tabela, model in THROUGH_SITES: # 1) widoki + _filtruj_widok(cur, tabela, tabela[: -len("_autor")] + "_autorzy") + for tabela, model in THROUGH_SITES: # 2) funkcje + cur.execute(_funkcja_z_galezia_kasujaca(cur, tabela, model)) + _regeneruj_bramke() # 3) bramka + + + def _regeneruj_bramke(): + """Ta sama logika co 0433.forward -- po zmianie widoku pg_depend zna + juz deleted_at, wiec bramka wciagnie ja sama.""" + with connection.cursor() as cur: + for tabela, refresh_fn, widoki in _p0433.GATED: + if not tabela.endswith("_autor"): + continue # publikacje to faza 02 + kolumny = _p0433._gate_columns(cur, tabela, widoki) + when = _p0433._when_clause(kolumny) + cur.execute(f"DROP TRIGGER IF EXISTS {tabela}_cache_upd ON {tabela};") + cur.execute( + f"CREATE TRIGGER {tabela}_cache_upd AFTER UPDATE ON {tabela} " + f"FOR EACH ROW WHEN ({when}) " + f"EXECUTE PROCEDURE {refresh_fn}();" + ) ``` -- [ ] Uruchom test smoke (oczekiwany PASS — migracja zastosuje się przy starcie testowej bazy, widoki będą miały `deleted_at`): + ⚠️ `_gate_columns` z `0433` **rzuca `RuntimeError`, gdy `pg_depend` nie + zwróci kolumn** — to celowy bezpiecznik (nie tworzy niebramkowanego UPDATE). + Nie obchodź go; jeśli wystąpi, znaczy że krok 1 nie zadziałał. + +- [ ] `backward`: odtwórz widoki bez filtra (`pg_get_viewdef` sprzed owijki nie + jest dostępny — wygeneruj z `_p0432`/`0421` albo zapisz oryginał w migracji), + funkcje przez `_p0432._create_through_function`, bramkę przez ponowne + `_regeneruj_bramke()` (po cofnięciu widoku `deleted_at` zniknie z `pg_depend` + samo). **Migracja MUSI być odwracalna** — testy migracji w CI to sprawdzają. + +- [ ] Uruchom testy kontraktu DDL (oczekiwany PASS — 9/9): ```bash uv run pytest src/bpp/tests/test_soft_delete/test_views_sql.py -q ``` -- [ ] Zweryfikuj brak driftu migracji i czystość modeli: +- [ ] Sprawdź brak driftu migracji: ```bash - uv run python src/manage.py makemigrations bpp --check --dry-run + DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run ``` - [ ] Commit: ```bash - git add src/bpp/migrations/0422_soft_delete_views.sql src/bpp/migrations/0422_soft_delete_views.py src/bpp/tests/test_soft_delete/test_views_sql.py - git commit -m "feat(soft-delete): filtr deleted_at w widokach bpp_*_autorzy + trigger-skip + git add src/bpp/migrations/0489_soft_delete_autorzy_views.py src/bpp/tests/test_soft_delete/test_views_sql.py + git commit -m "feat(soft-delete): widoki + galaz kasujaca + bramka WHEN dla *_Autor -Co-Authored-By: Claude Opus 4.8 (1M context) " +Trzy zmiany w jednej migracji, w wymuszonej kolejnosci: filtr deleted_at +w widokach bpp_*_autorzy -> galaz kasujaca w funkcjach bpp_refresh_autor_* +-> regeneracja bramki WHEN (pg_depend zna juz deleted_at). + +Zadna z nich nie wystarcza sama: filtr widoku nie sprzata _mat (upsert bez +DELETE = no-op), a bez deleted_at w bramce UPDATE soft-delete w ogole nie +dochodzi do funkcji triggera. + +Co-Authored-By: Claude Opus 5 (1M context) " ``` --- @@ -614,15 +610,34 @@ Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` **Files:** - Test (create): `src/bpp/tests/test_soft_delete/test_cache_consistency.py` +- Test (modify): `src/bpp/tests/test_cache/test_soft_delete_preconditions.py` — **odwrócenie kanarków** - Modify (jeśli testy ujawnią drift): brak planowanych — testy mają przejść na implementacji z Task 2-3. **Steps:** +- [ ] **Odwróć kanarki warunków wstępnych.** `test_soft_delete_preconditions.py` + przypina stan „soft-delete by nie zadziałał" i jest zielony PRZED tą fazą. + Po Task 3 musi asertować stan docelowy: + - `test_update_samego_deleted_at_nie_odpala_triggera` → **zmień na** + `test_update_samego_deleted_at_odpala_trigger`: `ctid` ma zniknąć + (wiersz usunięty z `_mat`), nie pozostać bez zmian; + - `test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat` → **zmień na** + `test_soft_delete_usuwa_wiersz_z_mat`: po `UPDATE ... SET deleted_at` + `_ctid(...)` ma być `None`. + + Testy operują na `bpp_wydawnictwo_ciagle` (publikacja), więc **naprawdę + zazielenią się dopiero po fazie 02**. W fazie 01 napisz ich odpowiedniki + dla `bpp_wydawnictwo_ciagle_autor` / `bpp_autorzy_mat` (kolumna `deleted_at` + już istnieje po Task 2, więc pomocnicze `_dodaj_deleted_at` znika), a + oryginalne zostaw jako czerwone-oczekiwane z `@pytest.mark.xfail(reason= + "faza 02 — soft-delete publikacji")`. **NIE kasuj ich** — to jedyny + regresyjny dowód, że bramka i gałąź kasująca działają. + - [ ] Napisz testy spójności — `src/bpp/tests/test_soft_delete/test_cache_consistency.py`: ```python """Spójność materializowanego cache (bpp_autorzy_mat / model Autorzy) po - soft-delete wierszy *_Autor. Wymaga transactional_db — trigger plpython3u - odpala się dopiero przy realnym commicie.""" + soft-delete wierszy *_Autor. Wymaga transactional_db — trigger odpala się + dopiero przy realnym commicie.""" import pytest @@ -728,7 +743,12 @@ Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` ```bash uv run pytest src/bpp/tests/test_soft_delete/test_cache_consistency.py -q ``` - Jeśli `test_edycja_skasowanego_autorstwa_nie_wskrzesza_w_mat` FAIL → znaczy, że trigger-skip lub filtr widoku nie działa. Diagnoza: sprawdź `pg_get_viewdef('bpp_wydawnictwo_ciagle_autorzy')` (czy `deleted_at IS NULL` obecne) — to obowiązkowy mechanizm #1; trigger-skip sam nie wystarcza dla tej ścieżki (potwierdza spec §2.1). Użyj superpowers:systematic-debugging, NIE łataj testu. + Jeśli którykolwiek FAIL → **zdiagnozuj który z trzech elementów nie zadziałał**, w tej kolejności (każdy warunkuje następny): + 1. `SELECT pg_get_triggerdef(...)` — czy bramka `WHEN` zna `deleted_at`? Jeśli nie, trigger w ogóle się nie odpalił i reszta diagnostyki jest bez sensu (to najczęstsza przyczyna: krok 1 migracji nie wstawił kolumny do `pg_depend`). + 2. `SELECT pg_get_functiondef('bpp_refresh_autor_wydawnictwo_ciagle'::regproc)` — czy jest gałąź `IF NEW.deleted_at IS NOT NULL ... DELETE`? Bez niej upsert jest no-opem i wiersz zostaje. + 3. `SELECT pg_get_viewdef('bpp_wydawnictwo_ciagle_autorzy'::regclass, true)` — czy filtr `deleted_at` obecny? + + Użyj superpowers:systematic-debugging, NIE łataj testu. - [ ] Commit: ```bash @@ -776,8 +796,10 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " ## Założenia i ostrzeżenia między-fazowe (dla faz 02+) -1. **Domyślny manager `*_Autor.objects` zmienił klasę** na `BppSoftDeleteManager` (filtruje `deleted_at__isnull=True`). Faza 03 (audyt kat. B) MUSI przejść 90 miejsc `*_Autor.objects` — w fazie 01 nic nie jest skasowane, więc filtr jest no-op, ale od fazy 02 (kaskada soft-delete publikacji) zacznie ukrywać. Guard autora (faza 04) MUSI liczyć przez `global_objects` (spec §3.2). -2. **Trigger-skip oparty na 0399**, nie 0001. Każda przyszła zmiana `bpp_refresh_cache()` musi wychodzić od `0422_soft_delete_views.sql` (nie od 0399 ani 0001). Naprawiono przy okazji błąd `"bpp_autorzy_mat" not in refresh_tables` (lista krotek) z 0399 — zweryfikować, czy 0399 faktycznie nie dublował `bpp_autorzy_mat` (jeśli dublował, to drobny regres wydajności, nie poprawności). +1. **Domyślny manager `*_Autor.objects` zmienił klasę** na `BppSoftDeleteManager` (filtruje `deleted_at__isnull=True`). Faza 03 (audyt kat. B) MUSI przejść **128** miejsc `*_Autor.objects` (stan 2026-08-06; spec mówił „90" — przelicz przed startem fazy 03: `grep -rn --include='*.py' -E "(Wydawnictwo_Ciagle_Autor|Wydawnictwo_Zwarte_Autor|Patent_Autor)\.objects" src/ | wc -l`). W fazie 01 nic nie jest skasowane, więc filtr jest no-op, ale od fazy 02 (kaskada soft-delete publikacji) zacznie ukrywać. Guard autora (faza 04) MUSI liczyć przez `global_objects` (spec §3.2). +2. **Faza 02 powtarza ten sam trójskładnikowy wzorzec dla 5 tabel publikacji**: filtr `deleted_at` w `bpp_*_view` → gałąź kasująca w `bpp_refresh_rekord_()` (`DELETE FROM bpp_rekord_mat`) → regeneracja bramki `WHEN`. Uwaga na doktorat/habilitację: ich funkcje refresh dotykają **obu** tabel `_mat` (`bpp_rekord_mat` i `bpp_autorzy_mat`, bo autor leży na wierszu publikacji) — gałąź kasująca musi czyścić obie. +3. **Każda przyszła zmiana definicji widoku źródłowego wymaga regeneracji bramki `WHEN`.** Bramka jest wypiekana z `pg_depend` w momencie migracji, więc nie zaktualizuje się sama. Pominięcie = cichy staleness. Testy `test_views_sql.py` i kanarki `test_soft_delete_preconditions.py` to wyłapią. 3. **Widoki `bpp_praca_doktorska_autorzy` / `bpp_praca_habilitacyjna_autorzy` NIE filtrowane** — autorstwo doktoratu/habilitacji nie jest `*_Autor` SoftDeleteModel (autor doktoratu to FK `Praca_Doktorska.autor`, nie through). Faza 02 (soft-delete publikacji doktorat/habilitacja) musi zadbać o ich zniknięcie z `bpp_rekord` przez własne `deleted_at` na tabeli publikacji — to NIE jest pokryte tą fazą. 4. **Gałęzie UNION `bpp_rekord` NIE dotknięte** w fazie 01 — soft-delete publikacji (kolumna `deleted_at` na `bpp_wydawnictwo_ciagle` itd.) to faza 02; dopiero ona doda filtr `deleted_at IS NULL` do `bpp_*_view`. Faza 01 dotyka wyłącznie ścieżki autorstwa. -5. **`unique_together` na `*_Autor` zachowane bez `deleted_at`** — w tej fazie autorstwa nie mają warunkowego unique. Jeśli przyszła faza pozwoli na re-add tego samego autora po soft-delete (kolizja `(rekord, autor, typ_odpowiedzialnosci)`), trzeba będzie przejść na `UniqueConstraint(condition=Q(deleted_at__isnull=True))` — odłożone, poza zakresem 01. +6. **`unique_together` na `*_Autor` zachowane bez `deleted_at`** — w tej fazie autorstwa nie mają warunkowego unique. **Faza 02 MUSI to zmienić** (decyzja #13, spec §2.2b): soft-deletowany wiersz nadal zajmuje slot w `(rekord, autor, typ_odpowiedzialnosci)` / `(rekord, autor, kolejnosc)`, a `deduplikator_autorow` przenoszący autorstwa trafi wtedy w `IntegrityError` o niewidoczny rekord. Przejście na `UniqueConstraint(condition=Q(deleted_at__isnull=True))` — razem ze slugiem. +7. **`Cache_Punktacja_*` NIE są dotknięte** żadnym mechanizmem tej fazy (nie mają FK do publikacji ani triggerów cache). Domknięcie tej luki to faza 06 (decyzja #15, spec §2.5b) — do tego czasu soft-deletowana praca nadal liczyłaby się do ewaluacji. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md index a88c879f3..10e16114a 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md @@ -4,7 +4,7 @@ **Goal:** Uczynić 5 modeli publikacji (`Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`, `Praca_Habilitacyjna`, `Patent`) `SoftDeleteModel`-ami z **wąską, kontrolowaną kaskadą** soft-delete na własne wiersze `*_Autor` (`Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) pod wspólnym `transaction_id`, **bez** refleksyjnej kaskady pakietu (która ruszyłaby `*_Streszczenie` itd.). Zamienić `slug unique=True` na warunkowy `UniqueConstraint` (reuse slug po soft-delete). Przepleść filtr soft-delete z istniejącymi menedżerami `Wydawnictwo_*_Manager` (mixin opłat) przez wspólny QuerySet/MRO, bez nadpisywania metod fees. -**Architecture:** `django-soft-delete` daje `SoftDeleteModel` (pola `deleted_at`/`restored_at`/`transaction_id`, menedżery `objects`/`global_objects`/`deleted_objects`, sygnały `post_soft_delete`/`post_restore`/`post_hard_delete`). Faza 01 utworzyła `src/bpp/models/soft_delete.py` z `BppSoftDeleteQuerySet` (gate na bulk `update(deleted_at=...)`), `BppSoftDeleteManager`, `BppGlobalManager` oraz uczyniła 3 modele `*_Autor` SoftDeleteModel-ami (+ filtr `deleted_at` w widokach źródłowych). **Ta faza zależy od 01.** Tu nadpisujemy `delete()`/`restore()` na 5 modelach: per-instancja `save()` (NIGDY bulk `update`), jawna wąska kaskada na `autorzy_set` (related_name `*_Autor`→publikacja) przez `.delete(transaction_id=...)`/`.restore(transaction_id=...)` na każdym wierszu (kontrakt z reversion: zawsze per-instancja). +**Architecture:** `django-soft-delete` daje `SoftDeleteModel` (pola `deleted_at`/`restored_at`/`transaction_id`, menedżery `objects`/`global_objects`/`deleted_objects`, sygnały `post_soft_delete`/`post_restore`/`post_hard_delete`). Faza 01 utworzyła `src/bpp/models/soft_delete.py` z `BppSoftDeleteQuerySet` (gate na bulk `update(deleted_at=...)`), `BppSoftDeleteManager`, `BppGlobalManager` oraz uczyniła 3 modele `*_Autor` SoftDeleteModel-ami — wraz z DDL-em dla **ścieżki autorstwa** (widoki `bpp_*_autorzy` + gałąź kasująca w `bpp_refresh_autor_*` + bramka `WHEN`). ⚠️ **Dla 5 tabel publikacji ten sam DDL trzeba zrobić w TEJ fazie** — Task 2b. **Ta faza zależy od 01.** Tu nadpisujemy `delete()`/`restore()` na 5 modelach: per-instancja `save()` (NIGDY bulk `update`), jawna wąska kaskada na `autorzy_set` (related_name `*_Autor`→publikacja) przez `.delete(transaction_id=...)`/`.restore(transaction_id=...)` na każdym wierszu (kontrakt z reversion: zawsze per-instancja). **Tech Stack:** Django, PostgreSQL, `django-soft-delete>=1.0.23`, `django-denorm-iplweb` (slug jest polem `@denormalized`!), pytest + `model_bakery.baker`. Python wyłącznie przez `uv run`. Linia ≤88 znaków (ruff). Komentarze/komunikaty po polsku. @@ -28,7 +28,7 @@ - `Praca_Doktorska.autor` FK CASCADE (`praca_doktorska.py:136`), `Praca_Habilitacyjna.autor` O2O PROTECT (`praca_habilitacyjna.py:42`). Te FK to **faza 04** — NIE ruszamy tu. - **`slug` jest polem `@denormalized(models.SlugField, max_length=400, unique=True, db_index=True, null=True, blank=True)`** (denorm z `django-denorm-iplweb`), w: `wydawnictwo_ciagle.py:246`, `wydawnictwo_zwarte.py:325` (w `Wydawnictwo_Zwarte`), `patent.py:180`, `praca_doktorska.py:105` (w `Praca_Doktorska_Baza` → dziedziczone przez `Praca_Doktorska` **i** `Praca_Habilitacyjna`). Denorm field jest fizyczną kolumną w DB → migracja zmiany `unique=True`→`UniqueConstraint` jest realną migracją schematu. - `Praca_Habilitacyjna` i `Praca_Doktorska` dziedziczą slug z `Praca_Doktorska_Baza` (abstract) — zmiana atrybutu pola w abstrakcie dotyka OBU modeli; migracje per model (każdy ma własną kolumnę `slug`). -- Następny numer migracji: `0421` (ostatnia: `0420_autor_pokazuj_siec_powiazan_and_more.py`). NIE modyfikuj istniejących migracji. +- ⚠️ **Numeracja migracji (stan 2026-08-06):** faza 01 zajmuje `0488`/`0489`, więc ta faza startuje od `0490`. **Zweryfikuj liść przed startem** (`ls src/bpp/migrations/*.py | tail -3`) — `dev` żyje. Numery w tym planie są orientacyjne; kanoniczna jest kolejność. NIE modyfikuj istniejących migracji. - `Zgloszenie_Publikacji` (`src/zglos_publikacje/models.py:60`) — precedens: po prostu dziedziczy `SoftDeleteModel` bez własnego menedżera. ## Kontrakt z reversion (PINNED — NIE łamać) @@ -91,11 +91,13 @@ Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-insta """Wąska, kontrolowana kaskada soft-delete: rodzic + własne wiersze `*_Autor` (related_name `autorzy_set`) pod wspólnym `transaction_id`. - NIE używa refleksyjnej kaskady pakietu (rzuciłaby SoftDeleteException - na `*_Streszczenie`/`*_Zewnetrzna_Baza_Danych`/`Publikacja_Habilitacyjna` - przy strict=True, albo twardo skasowała je przy strict=False). Kaskada - zatrzymuje się na `*_Autor`. Kontrakt z reversion: zawsze per-instancja - save()/delete(), NIGDY bulk update(deleted_at=...). + NIE uzywa refleksyjnej kaskady pakietu. UWAGA: delete() pakietu ma + DOMYSLNIE strict=False (a restore() -- strict=True), wiec kaskada NIE + krzyknelaby SoftDeleteException na *_Streszczenie / + *_Zewnetrzna_Baza_Danych / Publikacja_Habilitacyjna -- po cichu by po + nich przejechala. Kaskada zatrzymuje sie na *_Autor. Kontrakt z + reversion: zawsze per-instancja save()/delete(), NIGDY bulk + update(deleted_at=...). """ class Meta: @@ -161,7 +163,7 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " - Modify: `src/bpp/models/patent.py:62` (klasa `Patent`) - Modify: `src/bpp/models/praca_doktorska.py:135` (klasa `Praca_Doktorska`) - Modify: `src/bpp/models/praca_habilitacyjna.py:41` (klasa `Praca_Habilitacyjna`) -- Create: `src/bpp/migrations/0421_publikacje_soft_delete_fields.py` +- Create: `src/bpp/migrations/0490_publikacje_soft_delete_fields.py` - Test path: `src/bpp/tests/test_soft_delete_publikacje.py` > **Kolejność MRO:** mixin dopisujemy jako **pierwszą** bazę (przed pozostałymi mixinami modelu), żeby jego `delete()`/`restore()` wygrały w MRO nad `models.Model.delete()`. NIE jako ostatnią. `BppPublikacjaSoftDeleteMixin(SoftDeleteModel)` wnosi też pola `deleted_at`/`restored_at`/`transaction_id` i menedżery — ale menedżery dla `Wydawnictwo_*` nadpiszemy w Task 4 (interleaving fees); dla `Patent`/`Praca_*` zostaną menedżery z `SoftDeleteModel`. @@ -184,7 +186,7 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " ```bash uv run python src/manage.py makemigrations bpp --name publikacje_soft_delete_fields ``` - Oczekiwane: nowa migracja `0421_publikacje_soft_delete_fields.py` z `AddField` `deleted_at`/`restored_at`/`transaction_id` dla 5 modeli. Zweryfikuj nazwę pliku (`0421_`); jeśli numer inny — użyj faktycznego. + Oczekiwane: nowa migracja `0490_publikacje_soft_delete_fields.py` z `AddField` `deleted_at`/`restored_at`/`transaction_id` dla 5 modeli. Zweryfikuj nazwę pliku (`0490_`); jeśli numer inny — użyj faktycznego. - [ ] **Krok 2.3 — dopisz indeks per model na `deleted_at`.** Do wygenerowanej migracji dołóż operacje `AddIndex` (lub edytuj `Meta.indexes` modeli i przegeneruj). Ręcznie w migracji, po `AddField`-ach: ```python from django.db import migrations, models @@ -212,7 +214,7 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " Oczekiwane: PASS. - [ ] **Krok 2.6 — commit:** ```bash - git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0421_publikacje_soft_delete_fields.py + git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0490_publikacje_soft_delete_fields.py git commit -m "feat(soft-delete): 5 modeli publikacji -> SoftDeleteModel + migracje pol/indeksow Co-Authored-By: Claude Opus 4.8 (1M context) " @@ -227,7 +229,7 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " - Modify: `src/bpp/models/wydawnictwo_zwarte.py:325` - Modify: `src/bpp/models/patent.py:180` - Modify: `src/bpp/models/praca_doktorska.py:105` (w `Praca_Doktorska_Baza`) -- Create: `src/bpp/migrations/0422_publikacje_slug_warunkowy_unique.py` +- Create: `src/bpp/migrations/0491_publikacje_slug_warunkowy_unique.py` - Test path: `src/bpp/tests/test_soft_delete_publikacje.py` Zamiana `unique=True` na `models.UniqueConstraint(fields=["slug"], condition=Q(deleted_at__isnull=True), name="...")` per model. Skasowany rekord trzyma slug → nowy rekord z tym samym slug-iem nie koliduje (constraint pomija `deleted_at IS NOT NULL`). @@ -274,7 +276,7 @@ Zamiana `unique=True` na `models.UniqueConstraint(fields=["slug"], condition=Q(d ```bash uv run python src/manage.py makemigrations bpp --name publikacje_slug_warunkowy_unique ``` - Oczekiwane: `RemoveField`/`AlterField` (zdjęcie `unique`) + `AddConstraint` dla 5 modeli. Zweryfikuj numer `0422_`. + Oczekiwane: `RemoveField`/`AlterField` (zdjęcie `unique`) + `AddConstraint` dla 5 modeli. Zweryfikuj numer `0491_`. - [ ] **Krok 3.5 — uruchom test (PASS) + check migracji:** ```bash uv run pytest src/bpp/tests/test_soft_delete_publikacje.py::test_reuse_slug_po_soft_delete -x @@ -283,7 +285,7 @@ Zamiana `unique=True` na `models.UniqueConstraint(fields=["slug"], condition=Q(d Oczekiwane: PASS + `No changes detected`. - [ ] **Krok 3.6 — commit:** ```bash - git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0422_publikacje_slug_warunkowy_unique.py + git add src/bpp/models/wydawnictwo_ciagle.py src/bpp/models/wydawnictwo_zwarte.py src/bpp/models/patent.py src/bpp/models/praca_doktorska.py src/bpp/models/praca_habilitacyjna.py src/bpp/migrations/0491_publikacje_slug_warunkowy_unique.py git commit -m "feat(soft-delete): slug -> warunkowy UniqueConstraint (reuse po soft-delete) Co-Authored-By: Claude Opus 4.8 (1M context) " @@ -291,6 +293,137 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " --- +## Task 2b: Widoki + gałąź kasująca + bramka `WHEN` dla 5 tabel publikacji (LUKA) + +> 🔄 **Dodane 2026-08-06.** Poprzednia wersja planu zakładała, że fazа 01 +> „dodała filtr `deleted_at` w widokach `bpp_rekord`/`bpp_*_autorzy`" i że +> ta faza nie musi ruszać DDL-a. **To nieprawda:** faza 01 dotyka wyłącznie +> ścieżki autorstwa (3 tabele `*_autor`). Dla 5 tabel publikacji ten sam +> trójskładnikowy wzorzec trzeba powtórzyć TUTAJ — inaczej soft-delete +> publikacji nie usunie jej z `bpp_rekord_mat`. + +Powtórz wzorzec z fazy 01, Task 3 (tam jest pełny opis mechanizmu i pułapek), +dla `REKORD_SITES` z `0432_cache_trigger_plpgsql.py`: + +1. filtr `deleted_at IS NULL` w 5 widokach `bpp__view`, +2. gałąź kasująca w 5 funkcjach `bpp_refresh_rekord_()`, +3. regeneracja bramki `WHEN` na 5 triggerach `_cache_upd`. + +**Files:** +- Create: `src/bpp/migrations/0493_soft_delete_rekord_views.py` +- Test: `src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py` +- Test (modify): `src/bpp/tests/test_cache/test_soft_delete_preconditions.py` — + **zdejmij `xfail`** z oryginalnych kanarków i odwróć ich asercje (operują na + `bpp_wydawnictwo_ciagle`, więc dopiero ta faza je zazieleni). + +⚠️ **Różnica wobec fazy 01 — doktorat i habilitacja dotykają OBU tabel `_mat`.** +`_create_rekord_function` z `0432` generuje dla nich (`autor_na_wierszu=True`) +dodatkowy blok na `bpp_autorzy_mat`, bo autor leży na wierszu publikacji. +Gałąź kasująca musi wyczyścić **obie**: + +```sql +IF NEW.deleted_at IS NOT NULL THEN + DELETE FROM bpp_rekord_mat WHERE id = ARRAY[ct, NEW.id]::integer[]; + DELETE FROM bpp_autorzy_mat WHERE rekord_id = ARRAY[ct, NEW.id]::integer[]; + RETURN NULL; +END IF; +``` + +(dla `wydawnictwo_ciagle`/`wydawnictwo_zwarte`/`patent` — tylko `bpp_rekord_mat`; +ich autorstwa czyści kaskada na `*_Autor` z Task 1 + gałąź z fazy 01). + +⚠️ **Zwróć uwagę na klucz:** w `bpp_rekord_mat` kolumna nazywa się `id`, +w `bpp_autorzy_mat` — `rekord_id` (patrz `_create_rekord_function` +i `_create_delete_rekord_function` w `0432`). + +- [ ] Testy kontraktu DDL (wzorzec z fazy 01, Task 3) dla 5 widoków, 5 funkcji, + 5 triggerów — oczekiwany FAIL przed migracją. +- [ ] Migracja `RunPython` w kolejności widoki → funkcje → bramka. +- [ ] Odwrócone kanarki `test_soft_delete_preconditions.py` — PASS. +- [ ] `makemigrations --check --dry-run` — brak driftu. + +--- + +## Task 3b: `unique_together` na `*_Autor` → warunkowy `UniqueConstraint` (decyzja #13) + +> Dodane 2026-08-06 (spec §2.2b). Ten sam problem co ze slugiem, przeoczony +> w pierwszej wersji planu. + +Soft-deletowany wiersz `*_Autor` **nadal zajmuje slot w unique**, będąc +niewidocznym dla operatora. Od Task 1 tej fazy kaskada soft-deletuje wiersze +`*_Autor` masowo, więc kolizja przestaje być teoretyczna: `deduplikator_autorow` +przenosi autorstwa z duplikatu na autora głównego +(`src/deduplikator_autorow/utils/merge.py:191,284,354`) i trafia w +soft-deletowany wiersz o tej samej trójce → `IntegrityError` o rekord, +którego nie widać. + +**Files:** +- Modify: `src/bpp/models/wydawnictwo_ciagle.py:73-77` (`Wydawnictwo_Ciagle_Autor.Meta`) +- Modify: `src/bpp/models/wydawnictwo_zwarte.py` (`Wydawnictwo_Zwarte_Autor.Meta`) +- Modify: `src/bpp/models/patent.py` (`Patent_Autor.Meta`) +- Create: `src/bpp/migrations/0492_autor_warunkowy_unique.py` +- Test: `src/bpp/tests/test_soft_delete/test_autor_unique.py` + +- [ ] **Krok 3b.1 — padający test: re-add autorstwa po soft-delete.** + ```python + @pytest.mark.django_db + def test_readd_autorstwa_po_soft_delete(wydawnictwo_ciagle_z_autorem, autor_jan_kowalski): + """Po soft-delete autorstwa da się dodać to samo powiązanie ponownie.""" + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + rekord, autor, typ, kolejnosc = ( + wca.rekord, wca.autor, wca.typ_odpowiedzialnosci, wca.kolejnosc, + ) + wca.delete() + + # bez warunkowego constraintu: IntegrityError na (rekord, autor, typ) + Wydawnictwo_Ciagle_Autor.objects.create( + rekord=rekord, autor=autor, jednostka=wca.jednostka, + typ_odpowiedzialnosci=typ, kolejnosc=kolejnosc, + ) + assert Wydawnictwo_Ciagle_Autor.objects.filter(rekord=rekord).count() == 1 + assert Wydawnictwo_Ciagle_Autor.global_objects.filter(rekord=rekord).count() == 2 + ``` + +- [ ] **Krok 3b.2 — zamiana w `Meta` 3 modeli.** Usuń `unique_together`, dodaj: + ```python + constraints = [ + models.UniqueConstraint( + fields=["rekord", "autor", "typ_odpowiedzialnosci"], + condition=Q(deleted_at__isnull=True), + name="wc_autor_uniq_rekord_autor_typ", + ), + models.UniqueConstraint( + fields=["rekord", "autor", "kolejnosc"], + condition=Q(deleted_at__isnull=True), + name="wc_autor_uniq_rekord_autor_kolejnosc", + ), + ] + ``` + Nazwy per model (`wc_`/`wz_`/`pat_`) — muszą być unikalne w całej bazie. + +- [ ] **Krok 3b.3 — ⚠️ zweryfikuj admin.** Komentarz przy drugiej krotce + („Tu musi być autor, inaczej admin nie pozwoli wyedytować") sugeruje, że ten + constraint istnieje **ze względu na walidację formularzy**. + `Model.validate_unique()` honoruje `unique_together`, ale **`UniqueConstraint` + z `condition` pomija** (Django waliduje tylko constrainty bezwarunkowe). + Ryzyko: zamiast czytelnego błędu formularza operator dostanie `IntegrityError` + (HTTP 500). Test: + ```bash + uv run pytest src/bpp/tests/test_admin/ -k "autor and (inline or duplikat)" -v + ``` + Jeśli admin regresuje — dodaj jawną walidację w formularzu inline + (`clean()` sprawdzający kolizję przez `objects`), NIE wracaj do + `unique_together` (nie da się go pogodzić z soft-delete). + +- [ ] **Krok 3b.4 — migracja + brak driftu:** + ```bash + DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations bpp + DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run + uv run pytest src/bpp/tests/test_soft_delete/test_autor_unique.py -q + ``` + +--- + ## Task 4: Przeplecenie menedżerów `Wydawnictwo_*_Manager` z filtrem soft-delete (wspólny QuerySet/MRO) **Files:** @@ -539,10 +672,10 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " ## Definition of Done (faza 02) -- [ ] 5 modeli publikacji to `SoftDeleteModel` (przez `BppPublikacjaSoftDeleteMixin`); migracje `deleted_at`/`restored_at`/`transaction_id` + indeks per model (`0421_`). +- [ ] 5 modeli publikacji to `SoftDeleteModel` (przez `BppPublikacjaSoftDeleteMixin`); migracje `deleted_at`/`restored_at`/`transaction_id` + indeks per model (`0490_`). - [ ] `delete(self, *args, user=None, reason="", **kwargs)` / `restore(self, *args, user=None, **kwargs)` — per-instancja `save()`, wąska kaskada na `autorzy_set` pod wspólnym `transaction_id`, BEZ refleksyjnej kaskady pakietu, BEZ bulk `update(deleted_at=)`. - [ ] `*_Streszczenie` (i pozostałe nie-soft dzieci) nietknięte; `delete()` nie rzuca `SoftDeleteException`. -- [ ] `slug` → warunkowy `UniqueConstraint(condition=Q(deleted_at__isnull=True))` (`0422_`); reuse slug po soft-delete działa. +- [ ] `slug` → warunkowy `UniqueConstraint(condition=Q(deleted_at__isnull=True))` (`0491_`); reuse slug po soft-delete działa. - [ ] `Wydawnictwo_*_Manager` przeplecione: `objects` filtruje `deleted_at` ORAZ ma `rekordy_z_oplata()`/`wydawnictwa_nadrzedne_dla_innych()`; `global_objects`/`deleted_objects` dostępne na wszystkich 5 modelach. - [ ] Testy: kaskada wspólny txid, znika z Rekord/Autorzy + restore, restore `*_Autor`, `post_soft_delete`, `*_Streszczenie` nietknięte, gate bulk-update — zielone. - [ ] `makemigrations --check --dry-run bpp` → `No changes detected`. Istniejące migracje NIE modyfikowane. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md index f94b48e8a..e798d0c35 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md @@ -61,7 +61,7 @@ - `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py` **Bez zmian (decyzja audytu udokumentowana w planie):** -- 90 miejsc `*_Autor.objects` w ewaluacji / API / przemapuj — patrz Task 7. +- 128 miejsc `*_Autor.objects` w ewaluacji / API / przemapuj — patrz Task 7. - `src/komparator_pbn/views.py`, `src/snapshot_odpiec/tasks.py`, `src/ewaluacja_dwudyscyplinowcy/core.py` — patrz Task 7. @@ -131,6 +131,63 @@ git commit -m "test(soft-delete): re-import po pbn_uid widzi soft-deletowany rek --- +## Task 1b: Polityka „trafiłem w kosz" — POMIŃ + ZARAPORTUJ (decyzja #14) + +> 🔄 **Dodane 2026-08-06.** Przełączenie na `global_objects` (Taski 2-5) +> rozwiązuje **duplikaty**, ale zostawia otwarte drugie pytanie: importer +> dopasował rekord, który operator świadomie skasował — **i co teraz?** +> Bez jawnej decyzji domyślne zachowanie to „zaktualizuj po cichu", czyli +> import nadpisuje zawartość kosza. + +**DECYZJA: pomiń rekord w koszu, zaraportuj trafienie.** Nie tykamy go, nie +przywracamy, nie tworzymy obok nowego. Uzasadnienie: soft-delete to jawna +deklaracja „tego tu nie ma"; cichy update ją podważa, auto-restore pozwoliłby +importowi wskrzeszać rzeczy skasowane celowo, a nowy rekord obok odtwarzałby +problem duplikatów. + +**Konsekwencja dla Tasków 2-5:** samo `global_objects` NIE wystarcza — każde +przełączone miejsce musi po dopasowaniu **sprawdzić `deleted_at`** i odciąć +przetwarzanie z raportem. Kolejność: najpierw ten task (kontrakt + helper), +potem 2-5 go używają. + +**Files:** +- Create: helper w `src/import_common/` (np. `pomin_skasowane()` / + `WynikDopasowania`) — miejsce ustal tak, by mogły go importować zarówno + `import_common`, jak i `pbn_integrator` bez cyklu +- Test: `src/import_common/tests/test_pomijanie_kosza.py` + +- [ ] **Krok 1b.1 — padający test kontraktu:** + ```python + @pytest.mark.django_db + def test_import_pomija_rekord_w_koszu_i_raportuje(wydawnictwo_ciagle_z_pbn_uid): + wc = wydawnictwo_ciagle_z_pbn_uid + pbn_uid, tytul_przed = wc.pbn_uid_id, wc.tytul_oryginalny + wc.delete() + + wynik = <ścieżka importu>(pbn_uid=pbn_uid, dane={"tytul_oryginalny": "NOWY"}) + + # 1) nie utworzono duplikatu + assert Wydawnictwo_Ciagle.global_objects.filter(pbn_uid_id=pbn_uid).count() == 1 + # 2) rekord w koszu NIE został nadpisany + wc.refresh_from_db() + assert wc.tytul_oryginalny == tytul_przed + assert wc.deleted_at is not None + # 3) fakt został zaraportowany (nie połknięty) + assert wynik.pominieto_bo_w_koszu + ``` + +- [ ] **Krok 1b.2 — implementacja helpera + raportowanie.** ⚠️ **Nie połykaj + po cichu** — to byłoby złamanie reguły „żadnych cichych porażek" z CLAUDE.md. + Tam gdzie ścieżka importu ma strukturę raportu (`import_common`) — dodaj + osobną kategorię (nie „błąd", nie „utworzono"). Tam gdzie jej nie ma + (management commands) — log + licznik na końcu przebiegu. + +- [ ] **Krok 1b.3 — inwentaryzacja miejsc.** Wypisz wszystkie ścieżki + z Tasków 2-5 i przy każdej zapisz, GDZIE ląduje raport. Miejsce bez + odbiorcy raportu = niedokończony task, nie „szczegół". + +--- + ## Task 2: `get_bpp_publication` matchuje po `pbn_uid` przez `global_objects` modeli źródłowych `Rekord` to widok (`managed=False`), NIE `SoftDeleteModel` — nie ma @@ -604,7 +661,7 @@ git commit -m "fix(soft-delete): pbn_import czysci publikacje przez hard_delete --- -## Task 7: Audyt 90 miejsc `*_Autor.objects` — decyzje (zostaw `objects` / zmień na `global_objects`) +## Task 7: Audyt 128 miejsc `*_Autor.objects` — decyzje (zostaw `objects` / zmień na `global_objects`) Po fazie 02 `*_Autor.objects` ukrywa kaskadowo soft-deletowane autorstwa (kaskada §2.2). To jest **poprawny default dla ewaluacji** (praca w koszu nie @@ -890,7 +947,7 @@ git commit -m "chore(soft-delete): faza 03 audyt kat. B — format/lint" rekordach, nie matching tworzący duplikaty; **bez zmian**. - `pbn_import/utils/publication_import.py:115-116` jawny `.hard_delete()` → Task 6. ✓ -- Audyt 90 miejsc `*_Autor.objects` → Task 7 (decyzje per-miejsce; jedyna +- Audyt 128 miejsc `*_Autor.objects` → Task 7 (decyzje per-miejsce; jedyna zmiana: merge → `global_objects`). ✓ - Testy wymagane przez zlecenie: re-import nie tworzy duplikatu (Task 1-4), matching po `pbn_uid` znajduje soft-deletowaną (Task 1-3), ewaluacja pomija diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md index f5188c9cc..bcc3aaf14 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md @@ -1,9 +1,11 @@ -# Soft-delete — Faza 05: PBN wycofanie przez kolejkę +# Soft-delete — Faza 05: PBN wycofanie (kolejka + ścieżka synchroniczna) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. TDD: każdy krok najpierw PRAWDZIWY failing test → komenda + FAIL → PRAWDZIWA implementacja → komenda + PASS → commit. **Goal:** Rozszerzyć `pbn_export_queue` o operację `WYCOFANIE` (obok dotychczasowej `WYSYLKA`), tak by soft-delete publikacji mógł asynchronicznie wycofać oświadczenia dyscyplin z profilu instytucji PBN przez `client.delete_all_publication_statements(pbn_uid)`, z retry/locking/błędami jak istniejąca ścieżka wysyłki. Dostarczyć publiczne funkcje zakolejkowujące (`zakolejkuj_wycofanie`, `zakolejkuj_wysylke`) wołane potem z fazy 06, oraz zaktualizować `SentData` po udanym wycofaniu (`submitted_successfully=False` + znacznik `withdrawn_at`), bez kasowania wiersza. +> 🔄 **Zmiana zakresu 2026-08-06 (decyzja #16).** Wycofanie musi działać **dwoma wejściami**, nie jednym: rekord bywa wysyłany do PBN także **synchronicznie, bez kolejki** (`synchronizuj_publikacje`, `src/pbn_integrator/utils/synchronization.py:180`). Dlatego logika wycofania ma mieszkać w **wolnostojącej funkcji-prymitywie**, a `withdraw_from_pbn()` na modelu kolejki jest tylko jej cienkim wywołaniem. Patrz Task 05.0 i 05.9. + **Architecture:** Nowe pole `operacja` (`TextChoices` `WYSYLKA="wysylka"`/`WYCOFANIE="wycofanie"`, default `WYSYLKA` dla kompatybilności wstecznej) na `PBN_Export_Queue`. `send_to_pbn()` rozgałęzia się na początku: `WYCOFANIE` → nowa metoda `withdraw_from_pbn()` (GET klienta jak w wysyłce, `delete_all_publication_statements` z retry, aktualizacja `SentData`, status przez istniejące `_handle_successful_send`-analog / `error()`); `WYSYLKA` → dotychczasowa ścieżka bez zmian. Gate zakolejkowania: wycofanie tylko gdy rekord ma `pbn_uid_id`. **Tech Stack:** Django, PostgreSQL, Celery + `pbn_export_queue`, `pbn_api` (`PBNClient`, `SentData`), pytest + model_bakery + `unittest.mock`. @@ -62,6 +64,48 @@ ## Tasks +### Task 05.0 — Prymityw `wycofaj_oswiadczenia()` (jedno miejsce prawdy) + +> Dodane 2026-08-06 (decyzja #16). **Rób to PRZED 05.3** — `withdraw_from_pbn()` +> ma być cienkim wywołaniem tego prymitywu, nie własną implementacją. + +Cała logika wycofania mieszka w wolnostojącej funkcji, żeby wejście +asynchroniczne (kolejka) i synchroniczne (`synchronizuj_publikacje`) +zostawiały **identyczny** stan `SentData` i identyczną klasyfikację błędów. + +**Files:** +- Create: `src/pbn_api/wycofanie.py` (lub `src/pbn_export_queue/wycofanie.py` — + wybierz tak, by NIE powstał cykl importów: prymityw nie może importować + modelu kolejki) +- Test: `src/pbn_api/tests/test_wycofanie.py` + +Kontrakt: + +```python +def wycofaj_oswiadczenia(publikacja, client) -> WynikWycofania: + """Wycofuje oświadczenia dyscyplin publikacji z profilu instytucji PBN. + + Gate: publikacja bez pbn_uid -> zwraca wynik POMINIETO (nie błąd). + Obiektu publikacji w PBN NIE kasujemy (jest współdzielony). + Aktualizuje SentData: submitted_successfully=False + withdrawn_at. + """ +``` + +- [ ] **Krok 05.0.1 — testy obsługi wyjątków (wzorzec do przejęcia: + `src/pbn_wysylka_oswiadczen/tasks.py:54-76`).** Trzy przypadki, każdy + z innym wynikiem: + - `CannotDeleteStatementsException` → **SUKCES** (oświadczeń nie było — + stan docelowy osiągnięty). To NIE jest błąd; zaległa pułapka. + - `PraceSerwisoweException` → propaguj (retry ma sens, PBN w oknie + serwisowym). + - `HttpException` → błąd zaklasyfikowany, z treścią odpowiedzi. + - `pbn_uid is None` → POMINIETO, bez wołania klienta. +- [ ] **Krok 05.0.2 — implementacja** + aktualizacja `SentData` wewnątrz + prymitywu (NIE u wywołującego). +- [ ] **Krok 05.0.3 — PASS** + commit. + +--- + ### Task 05.1 — Pole `operacja` na `PBN_Export_Queue` + migracja **Files:** @@ -649,6 +693,42 @@ Drobne wsparcie operacyjne: pokaż operację na liście kolejki, by superuser od --- +### Task 05.9 — Wejście synchroniczne (poza kolejką) + +> Dodane 2026-08-06 (decyzja #16). + +Rekord bywa wysyłany do PBN bez kolejki — ta sama ścieżka musi umieć wycofać. + +**Files:** +- Modify: `src/pbn_integrator/utils/synchronization.py` (i/lub miejsce, które + faktycznie robi synchroniczną wysyłkę — **zweryfikuj wywołujących**: + `grep -rn --include='*.py' "synchronizuj_publikacje" src/`) +- Test: `src/pbn_integrator/tests/test_wycofanie_sync.py` + +- [ ] **Krok 05.9.1 — ustal realny zbiór ścieżek synchronicznych.** Nie zgaduj; + wypisz wywołujących i rozstrzygnij, które z nich mogą wystąpić w kontekście + soft-delete (management command? admin action? import?). +- [ ] **Krok 05.9.2 — test: ścieżka synchroniczna woła prymityw i zostawia + `SentData` w tym samym stanie co kolejka.** Kluczowa asercja — **równoważność + obu wejść**: + ```python + # po wycofaniu synchronicznym i po wycofaniu przez kolejkę + # SentData ma być nieodróżnialne (submitted_successfully, withdrawn_at) + ``` +- [ ] **Krok 05.9.3 — implementacja: wywołanie `wycofaj_oswiadczenia()`.** + ⚠️ **NIE** wołaj `client.delete_all_publication_statements()` bezpośrednio — + to złamałoby niezmiennik z §4.2 specu (rozjazd `SentData`/`SoftDeleteLog` + między wejściami). +- [ ] **Krok 05.9.4 — grep kontrolny:** poza prymitywem i testami nie ma + wywołań `delete_all_publication_statements`, z wyjątkiem + `pbn_wysylka_oswiadczen` i `pbn_api/management/` (istniejące, wsadowe, + poza zakresem soft-delete): + ```bash + grep -rn --include='*.py' "delete_all_publication_statements" src/ | grep -v tests + ``` + +--- + ### Task 05.8 — Pełna weryfikacja fazy + brak driftu migracji **Files:** — diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md index 3ce7c9ebf..e1dfa3a29 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md @@ -207,7 +207,7 @@ def test_context_zagniezdzony_przywraca_zewnetrzny(django_user_model, db): - Create: `src/bpp/models/soft_delete_log.py` - Modify: `src/bpp/models/__init__.py` (dopisz import, wzorzec `oplaty_log` na `:52`) -- Create: `src/bpp/migrations/0421_softdeletelog.py` (NUMER: sprawdź najwyższą +- Create: `src/bpp/migrations/0494_softdeletelog.py` (NUMER: sprawdź najwyższą istniejącą migrację `bpp` przez `ls src/bpp/migrations/ | grep -E '^04' | sort | tail -1` i nadaj kolejny — **NIE modyfikuj istniejących migracji**) - Test: `src/bpp/tests/test_soft_delete/test_soft_delete_log_model.py` @@ -725,6 +725,78 @@ def on_post_restore(sender, instance, **kwargs): --- +### Task 6b: Receivery `Cache_Punktacja_*` — praca w koszu nie liczy się do ewaluacji + +> 🔄 **Dodane 2026-08-06 (decyzja #15, spec §2.5b).** Luka nieobjęta żadnym +> innym mechanizmem: `Cache_Punktacja_Autora` i `Cache_Punktacja_Dyscypliny` +> **nie mają FK do publikacji** (klucz to tablica `rekord_id = +> [content_type_id, pk]`), więc nie rusza ich ani kaskada Django, ani kaskada +> `*_Autor` z fazy 02, ani triggery cache. Bez tego zadania soft-deletowana +> praca **nadal wnosi sloty i punkty do ewaluacji**. + +**Files:** +- Modify: `src/bpp/receivers/soft_delete.py` (receivery z Task 4/6) +- Test: `src/bpp/tests/test_soft_delete/test_cache_punktacji.py` + +**Fakty z kodu (zweryfikowane 2026-08-06):** +- modele: `src/bpp/models/cache/punktacja.py` (`Cache_Punktacja_Autora:63`, + `Cache_Punktacja_Dyscypliny`), +- zapis: `src/bpp/models/sloty/core.py:401` (`_Dyscypliny`), `:439` (`_Autora`), +- przeliczenie: `przelicz_punkty_dyscyplin()` + (`src/bpp/models/abstract/disciplines.py:12`), +- odczyt (te miejsca zobaczą różnicę): `ewaluacja_optymalizacja/utils.py:182`, + `ewaluacja_optymalizacja/views/evaluation_browser/prefetch.py:41`, + `oswiadczenia/views.py:353`. + +- [ ] **Krok 6b.1 — padający test: soft-delete kasuje punktację, restore ją + przywraca.** + ```python + @pytest.mark.django_db + def test_soft_delete_kasuje_cache_punktacji(zwarte_z_dyscyplinami): + zw = zwarte_z_dyscyplinami + zw.przelicz_punkty_dyscyplin() + klucz = [zw.content_type_id, zw.pk] + assert Cache_Punktacja_Autora.objects.filter(rekord_id=klucz).exists() + assert Cache_Punktacja_Dyscypliny.objects.filter(rekord_id=klucz).exists() + + zw.delete() + + assert not Cache_Punktacja_Autora.objects.filter(rekord_id=klucz).exists() + assert not Cache_Punktacja_Dyscypliny.objects.filter(rekord_id=klucz).exists() + + zw.restore() + + assert Cache_Punktacja_Autora.objects.filter(rekord_id=klucz).exists() + ``` + +- [ ] **Krok 6b.2 — implementacja w receiverach.** + - `post_soft_delete` → skasuj wiersze obu modeli dla `rekord_id` + `[content_type_id, pk]`; + - `post_restore` → przelicz (`przelicz_punkty_dyscyplin()` lub równoważna + ścieżka z `sloty/core.py`). + + ⚠️ **Gate na typ nadawcy.** Receivery są globalne (podpięte bez `sender=`), + a `Cache_Punktacja_*` dotyczy tylko 5 modeli publikacji. Dla `Autor` + i `*_Autor` ten kod **nie może** się odpalić — sprawdź `sender` przed + czymkolwiek. + + ⚠️ **Kaskada `*_Autor` NIE może tego wyzwalać drugi raz.** Soft-delete + publikacji z N autorami emituje 1 + N sygnałów `post_soft_delete`. Kasowanie + punktacji ma zajść **raz**, przy sygnale rodzica — inaczej N-1 zbędnych + zapytań na każde kasowanie. + +- [ ] **Krok 6b.3 — ⚠️ zmierz koszt `restore()`.** Przeliczenie punktacji jest + operacją liczącą, nie `UPDATE`-em. Jeśli dla realnego rekordu trwa zauważalnie + (>1 s), zgłoś to do fazy 07 (admin) — akcja „Przywróć" na queryset-cie + N rekordów może potrzebować zadania w tle zamiast żądania HTTP. + ```bash + uv run pytest src/bpp/tests/test_soft_delete/test_cache_punktacji.py -q --durations=5 + ``` + +- [ ] **Krok 6b.4 — PASS + commit.** + +--- + ### Task 7: Test integracyjny end-to-end + weryfikacja rejestracji w apps.ready **Files:** @@ -827,7 +899,7 @@ istnieją, Task 5 pomijamy. (ii) **Faza 02 wpina** `SoftDeleteModel` + override owijający `soft_delete_context` na publikacjach — jeśli nie ma jeszcze tego w worktree, testy receiverów Tasków 4/6 idą przez ręczny `post_soft_delete.send(...)` w izolacji (wariant udokumentowany w docstringu -testu). (iii) Numer migracji `0421_*` orientacyjny — wykonawca nadaje kolejny +testu). (iii) Numer migracji `0494_*` orientacyjny — wykonawca nadaje kolejny po sprawdzeniu `ls src/bpp/migrations/`. (iv) `soft_delete_context.py` tworzy ta faza; gdy fazy 02/04 dodały wcześniej stub — scalić VERBATIM z kontraktem. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md index 2fadca503..ec610aad4 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md @@ -149,11 +149,11 @@ def test_kaskada_autor_soft_deletowany_razem_z_publikacja( Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py -v` Expected: 3 PASS. Jeśli `test_soft_delete_publikacji_znika_z_rekord_i_autorzy` -FAIL (rekord wraca do mat-view) → **luka w fazie 01**: filtr `deleted_at IS -NULL` nie pokrywa wszystkich gałęzi UNION `bpp_rekord` / `bpp_*_autorzy`. -Zadanie naprawcze: dopisz brakujący `WHERE deleted_at IS NULL` w -`src/bpp/migrations/0XXX_soft_delete_views.sql` (NOWA migracja, nie modyfikuj -istniejących) i ponów. +FAIL (rekord wraca lub zostaje w mat-view) → **luka w fazie 01 lub 02**. +Diagnozuj w kolejności: (1) czy bramka `WHEN` triggera zna `deleted_at` +(`pg_get_triggerdef`), (2) czy funkcja refresh ma gałąź kasującą +(`pg_get_functiondef`), (3) czy widok źródłowy filtruje (`pg_get_viewdef`). +Naprawa idzie NOWĄ migracją `RunPython` (nie modyfikuj istniejących). - [ ] **Step 4: Commit** @@ -177,29 +177,51 @@ Oczekiwane: ustal, czy `verify_cache` jest sprawny. **Znana luka:** `src/bpp/management/commands/verify_cache.py` to dziś stub (`raise NotImplementedError`, twarde `psycopg2.connect(database="b_med", host="linux-dev")`) — NIE da się go uruchomić w teście. Weryfikację spójności -robimy przez `Rekord.objects.full_refresh()` (`src/bpp/models/cache/rekord.py:117`), -która jest realnym, testowalnym odpowiednikiem „re-projekcji ze źródła" -opisanym w spec §2.1. (Patrz „Luki wykryte" na końcu — `verify_cache` należy -naprawić osobnym zadaniem, poza zakresem soft-delete.) +robimy **wprost, surowym SQL-em** (wzorzec: +`src/bpp/tests/test_cache/test_cache_plpgsql_port.py`). -- [ ] **Step 2: Napisz failing test — full_refresh nie wskrzesza skasowanych + Cache_Punktacja_* znika** +> ⚠️ **Korekta 2026-08-06.** Poprzednia wersja tego kroku używała +> `Rekord.objects.full_refresh()` jako „re-projekcji ze źródła". **To nie jest +> re-projekcja** — `full_refresh()` (`src/bpp/models/cache/rekord.py:121-127`) +> woła `denorm.rebuildall(...)`, czyli przebudowuje pola denormalizowane i +> **nigdy niczego nie usuwa ani nie wstawia do `_mat`**. Test „po +> `full_refresh()` skasowane nie wracają" przechodziłby z fałszywych powodów. +> Patrz spec §2.1 + decyzja #12. + +- [ ] **Step 2: Napisz testy — re-projekcja nie wskrzesza skasowanych + Cache_Punktacja_* znika** Dopisz do `test_soft_delete_regresja_cache.py`: ```python +from django.db import connection + from bpp.models.cache.punktacja import Cache_Punktacja_Dyscypliny @pytest.mark.django_db -def test_full_refresh_nie_wskrzesza_skasowanej_publikacji( - wydawnictwo_ciagle_z_dwoma_autorami, +def test_reprojekcja_ze_zrodla_nie_wskrzesza_skasowanej_publikacji( + transactional_db, wydawnictwo_ciagle_z_dwoma_autorami, ): - """Re-projekcja ze źródła (full_refresh) respektuje deleted_at — - inaczej skasowany rekord wróciłby do bpp_rekord_mat (spec §2.1).""" - wydawnictwo_ciagle_z_dwoma_autorami.delete() + """Wymuszone przejście triggera nie przywraca skasowanego rekordu. + + NIE używamy full_refresh() — to denorm.rebuildall, nie re-projekcja _mat + (decyzja #12). Zamiast tego wymuszamy wejście triggera UPDATE-em + bramkowanej kolumny i sprawdzamy, ze wiersz NIE wrocil z widoku. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + pk, ct = wc.pk, wc.content_type_id + wc.delete() assert Rekord.objects.count() == 0 - Rekord.objects.full_refresh() + with connection.cursor() as cur: + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle SET rok = rok + 1 WHERE id = %s", [pk] + ) + cur.execute( + "SELECT count(*) FROM bpp_rekord_mat WHERE id = ARRAY[%s, %s]::integer[]", + [ct, pk], + ) + assert cur.fetchone()[0] == 0, "skasowany rekord wrocil do _mat" assert Rekord.objects.count() == 0 assert Autorzy.objects.count() == 0 @@ -238,10 +260,14 @@ def test_soft_delete_usuwa_cache_punktacji_dyscyplin(zwarte_z_dyscyplinami): - [ ] **Step 3: Uruchom — oczekuj PASS** -Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py -k "full_refresh or cache_punktacji" -v` +Run: `uv run pytest src/bpp/tests/test_soft_delete/test_soft_delete_regresja_cache.py -k "reprojekcja or cache_punktacji" -v` Expected: PASS. Jeśli `Cache_Punktacja_Dyscypliny` wraca po delete → -sprawdź, czy override `delete()` (faza 02) czyści punktację dyscyplin albo -czy trigger usuwa wpisy `Cache_Punktacja_*` na podstawie `deleted_at`. +sprawdź **receivery z fazy 06** (decyzja #15, spec §2.5b): `post_soft_delete` +ma kasować wiersze `Cache_Punktacja_Autora`/`_Dyscypliny` dla tego +`rekord_id`, `post_restore` — przeliczać je z powrotem. ⚠️ Tych tabel **nie +rusza** ani kaskada `*_Autor`, ani trigger cache (nie mają FK do publikacji, +klucz to tablica `[content_type_id, pk]`) — jeśli faza 06 tego nie wdrożyła, +ten test jest jedynym miejscem, gdzie luka wyjdzie. Jeśli pole `rekord_id` w `Cache_Punktacja_Dyscypliny` ma inną strukturę niż `[content_type_id, pk]`, dostosuj filtr do realnego schematu modelu (`src/bpp/models/cache/punktacja.py:18`) — sprawdź `uv run python @@ -750,7 +776,7 @@ je ukrywa). ```python """Regresja ewaluacji (spec §2.5, §3 fazy). -90 miejsc czyta ``*_Autor.objects`` bezpośrednio; po wpięciu +128 miejsc czyta ``*_Autor.objects`` bezpośrednio; po wpięciu ``SoftDeleteModel`` domyślny menedżer ukrywa kaskadowo-skasowane autorstwa, więc ewaluacja pomija prace w koszu. Restore przywraca punktację. """ diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md index 8c6934185..d8aeed0cc 100644 --- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md +++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md @@ -1,6 +1,16 @@ # Spec: Soft-delete publikacji + autorów (jedno opracowanie wdrożeniowe) -> ✅ **STATUS: DO REALIZACJI (2026-06-04).** +> ✅ **STATUS: DO REALIZACJI (2026-06-04; zrewidowany 2026-08-06).** +> +> 🔄 **Rewizja 2026-08-06.** Spec powstał przed PR #363 (port +> `bpp_refresh_cache` PL/Python → PL/pgSQL + bramka `WHEN`, migracje +> `0432`/`0433`). Weryfikacja na aktualnym `dev` obaliła **dwa nośne +> założenia** o warstwie cache — §2.1 przepisana od zera, decyzja #9 +> unieważniona. Domknięto też 5 luk/decyzji (#12–#16): `Cache_Punktacja_*`, +> warunkowy unique na `*_Autor`, polityka importu wobec kosza, dwuwejściowe +> wycofanie z PBN, semantyka `full_refresh()`. Dowód empiryczny obalonych +> założeń: `src/bpp/tests/test_cache/test_soft_delete_preconditions.py`. +> > Ten dokument jest projektem wdrożeniowym (design), zatwierdzonym przez > użytkownika. Zastępuje feasibility-spec > [`2026-06-03-soft-delete-publikacje.md`](2026-06-03-soft-delete-publikacje.md) @@ -20,13 +30,15 @@ sens, przy minimalnym blast-radiusie: „kosz" dla pustych/błędnych rekordów). Autor **z** pracami → `PROTECT` (zero kasowania, soft ani hard). 3. **PBN** — soft-delete publikacji wycofuje oświadczenia dyscyplin z profilu - instytucji, asynchronicznie przez kolejkę (`pbn_export_queue`). + instytucji: asynchronicznie przez kolejkę (`pbn_export_queue`) lub + synchronicznie, gdy rekord szedł do PBN bez kolejki (§4.2). 4. **Audyt** — dedykowana tabela `SoftDeleteLog` (kto / kiedy / dlaczego / status PBN). 5. **Admin** (superuser-only) — „kosz" zamiast hard-delete, filtr „pokaż skasowane", akcja „przywróć", osobna jawna akcja „usuń trwale". -**Stack.** Django, PostgreSQL (triggery `plpython3u`), `django-soft-delete` +**Stack.** Django, PostgreSQL (triggery **PL/pgSQL** — po porcie z `plpython3u`, +PR #363), `django-soft-delete` (`SoftDeleteModel`, już w `pyproject.toml`), `django-denorm-iplweb`, Celery + `pbn_export_queue`. @@ -55,11 +67,11 @@ i `Patent_Autor` **stają się `SoftDeleteModel`** — ale wyłącznie jako cel **wąskiej kaskady** z soft-delete publikacji (§2.2), NIE pełnego refleksyjnego Projektu B. Pozostałe dzieci (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, `Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`) zostają nie-soft — -kaskada zatrzymuje się na `*_Autor` i **nie jest wirusowa**. Powód: 90 +kaskada zatrzymuje się na `*_Autor` i **nie jest wirusowa**. Powód: 128 bezpośrednich zapytań `*_Autor.objects` w kodzie (większość w `ewaluacja_optymalizacja` — najwrażliwszy korekcyjnie podsystem) — domyślny menedżer `objects` po wpięciu `SoftDeleteModel` czyni je poprawnymi -automatycznie, eliminując 90-punktowe ryzyko „silent leak" do ewaluacji. +automatycznie, eliminując 128-punktowe ryzyko „silent leak" do ewaluacji. **Dlaczego nie kaskada autor→prace ani „guard z 50 publikacjami":** realny przypadek użycia kasowania autora jest wąski — to wyłącznie puste / @@ -81,74 +93,134 @@ publikacji przed usunięciem czyni kasowanie bezużytecznym. Wąska semantyka ### 2.1 Trigger jako choke-point (najwrażliwszy, robiony PIERWSZY) -`Rekord` to UNION-view nad materializowaną tabelą `bpp_rekord_mat`, zasilaną -triggerem `bpp_refresh_cache()`. **Aktualna wersja funkcji to -`src/bpp/migrations/0399_fix_refresh_cache_upsert.sql`** (NIE baseline -`0001_cache_functions.sql` — historyczny; funkcja ewoluowała przez `0112`, -`0310`, `0387`, `0399`, + `0400_drop/restore_cache_triggers`). Z -`bpp_rekord_mat`/`bpp_autorzy_mat` czyta większość systemu (publiczny frontend, -multiseek, global search, ewaluacja `Cache_Punktacja_*`, raporty). - -**Fakt z kodu** (`0399_fix_refresh_cache_upsert.sql`): na `DELETE` trigger usuwa -wiersze z `_mat`; na `UPDATE/INSERT` robi **`DELETE` + upsert** -(`INSERT ... SELECT FROM ... ON CONFLICT DO UPDATE`, pod -`pg_advisory_xact_lock`). `DELETE` przed upsertem jest **bezwarunkowy** (linie -125-126), więc gdy widok źródłowy odfiltruje skasowane — upsert nic nie -re-insertuje. Soft-delete to technicznie `UPDATE` → **bez filtra w widoku -skasowany rekord wróciłby** (upsert wstawiłby go ponownie). (Uwaga: `0399` ma -drobny utajony bug — `"bpp_autorzy_mat" not in refresh_tables` sprawdza string -w liście krotek `(table, id_col)`; do poprawienia przy okazji, faza 01.) - -**Zmiana:** ścieżka `UPDATE/INSERT` triggera uczona reguły: -> jeśli `TD['new']['deleted_at'] IS NOT NULL` → zachowaj się jak `DELETE` -> (usuń z `_mat`, **nie** re-insertuj). -> `deleted_at: →NULL` (restore) → normalny re-insert. +> 🔄 **Sekcja przepisana 2026-08-06** po weryfikacji na aktualnym `dev`. +> Poprzednia wersja opisywała funkcję `bpp_refresh_cache()` z migracji +> `0399_fix_refresh_cache_upsert.sql` (PL/Python). **Ta funkcja już nie +> istnieje** — PR #363 (migracje `0432`/`0433`) zastąpił ją 16 statycznymi +> funkcjami PL/pgSQL i bramką `WHEN` na triggerach UPDATE. Oba założenia, +> na których stała poprzednia wersja, są **fałszywe**; dowód w kanarkach +> `src/bpp/tests/test_cache/test_soft_delete_preconditions.py`. + +`Rekord` to model nad **tabelą materializowaną `bpp_rekord_mat`** +(`src/bpp/models/cache/rekord.py:382-386` — `db_table = "bpp_rekord_mat"`; +widok `bpp_rekord` obsługuje osobna, marginalna klasa `RekordView`, `:394-396`). +Z `bpp_rekord_mat`/`bpp_autorzy_mat` czyta większość systemu (publiczny +frontend, multiseek, global search, raporty). **Tabele `_mat` utrzymywane są +WYŁĄCZNIE triggerami** — nie ma innej ścieżki zapisu. + +**Stan faktyczny po PR #363** (`0432_cache_trigger_plpgsql.py`, +`0433_cache_trigger_when_gate.py`): + +- 8 tabel bazowych (5 publikacji + 3 `*_autor`), każda z **trzema** triggerami: + `_cache_ins` (AFTER INSERT, bezwarunkowy), `_cache_del` (AFTER DELETE, + bezwarunkowy), `_cache_upd` (AFTER UPDATE, **bramkowany**); +- funkcja refresh dla publikacji to **czysty upsert, BEZ `DELETE`**: + ```sql + PERFORM pg_advisory_xact_lock(ct, NEW.id); + INSERT INTO bpp_rekord_mat (...) SELECT ... FROM bpp__view + WHERE object_id_raw = NEW.id + ON CONFLICT (id) DO UPDATE SET ...; + ``` +- bramka na UPDATE to `WHEN (OLD."kol" IS DISTINCT FROM NEW."kol" OR ...)`, + gdzie lista kolumn jest **wyliczana z `pg_depend`** (kolumny bazowe, które + widok faktycznie czyta) **w momencie migracji** i wklejona na sztywno + w definicję triggera. + +**Dwa fałszywe założenia poprzedniej wersji (potwierdzone testem):** + +| Założenie | Rzeczywistość | +|---|---| +| „UPDATE ustawiający `deleted_at` doleci do triggera" | **Nie doleci.** `django-soft-delete` zapisuje przez `save(update_fields=['deleted_at','restored_at','transaction_id'])`. Żadna z tych kolumn nie jest w bramce (i być nie może — nie zasilają widoku), więc `WHEN` jest fałszywe. Efekt uboczny `update_fields`: `ostatnio_zmieniony` (`auto_now`) też **nie** jest bumpowany, więc nie ma przypadkowego ratunku. | +| „Filtr `deleted_at IS NULL` w widoku wystarczy, bo trigger robi bezwarunkowy `DELETE` przed upsertem" | **Nie robi.** `INSERT ... SELECT` z widoku, który odfiltrował wiersz, wybiera zero wierszy → **no-op** → stary wiersz przeżywa w `_mat`. Inwariant „delete-first" nie istnieje dla `bpp_rekord_mat`. | + +**Wzorzec do naśladowania jest już w kodzie.** Gałąź doktorat/habilitacja +w `_create_rekord_function` robi dokładnie to, czego potrzebujemy — bo tam +wiersz **może wypaść ze źródła** (widok `*_autorzy` ma INNER JOIN do +`bpp_autor`): + +```sql +DELETE FROM bpp_autorzy_mat WHERE rekord_id = ARRAY[ct, NEW.id]::integer[]; +INSERT INTO bpp_autorzy_mat (...) SELECT ... ; +``` + +Soft-delete to uogólnienie tej samej sytuacji na wszystkie 8 tabel. + +**Zmiana — trzy elementy, wszystkie OBOWIĄZKOWE** (żaden nie wystarcza sam): + +1. **Gałąź kasująca w 8 funkcjach refresh.** Prolog przed upsertem: + ```sql + IF NEW.deleted_at IS NOT NULL THEN + DELETE FROM bpp_rekord_mat WHERE id = ARRAY[ct, NEW.id]::integer[]; + RETURN NULL; + END IF; + ``` + (dla `*_autor` analogicznie na `bpp_autorzy_mat`). Restore + (`deleted_at: data→NULL`) przechodzi dalej do normalnego upsertu — symetria + za darmo. +2. **Regeneracja bramki `WHEN`** — żeby UPDATE w ogóle doszedł do funkcji. + Nie hardkodujemy `deleted_at`: bramka wylicza kolumny z `pg_depend` po + definicji widoku, więc **gdy punkt 3 wstawi `deleted_at` do `WHERE` widoku, + ponowne uruchomienie logiki `forward()` z `0433` samo ją wciągnie**. Nowa + migracja = `RunPython` wołający tę samą funkcję (kolejność: najpierw widok, + potem regeneracja bramki). +3. **Filtr `deleted_at IS NULL` w widokach źródłowych** — po **własnej** + kolumnie tabeli, bez JOIN do rodzica (każda z 8 tabel ma własne + `deleted_at`, patrz §2.2). Rola tego filtra jest **inna niż zakładano**: + nie sprząta `_mat` (to robi punkt 1), tylko (a) karmi `pg_depend` dla + punktu 2 i (b) gwarantuje, że pełne przebudowy i odczyty przez `bpp_rekord` + nie wskrzeszą kosza. **Jednolitość dzięki wąskiej kaskadzie na `*_Autor` (§2.2).** Ponieważ -through-modele też stają się `SoftDeleteModel`, **każda z 8 tabel pod -triggerem ma własną kolumnę `deleted_at`** (5 publikacji + 3 `*_autor`). -Trigger czyta `deleted_at` z **własnego** wiersza (`TD['new']`) — reguła -działa identycznie niezależnie od tego, czy zadziałała tabela publikacji czy -tabela autorska. **Nie ma potrzeby JOIN-a/lookupu do rekordu nadrzędnego.** - -Skutki: -- **Mechanizm #1 — filtr `deleted_at IS NULL` w widokach źródłowych** (po - **własnej** kolumnie tabeli, bez JOIN): `bpp_*_autorzy` (selektują - `FROM bpp_*_autor`, `src/bpp/migrations/0001_widoki_autorzy.sql`) i każda - gałąź UNION-u `bpp_rekord`. **To jest nadrzędny mechanizm**, bo pokrywa - WSZYSTKIE ścieżki: re-insert triggera, bezpośredni odczyt z widoku `bpp_rekord` - (`Rekord` czyta `bpp_rekord`, `src/bpp/models/cache/rekord.py:357`), oraz - pełną re-projekcję/weryfikację cache. -- **Mechanizm #2 (optymalizacja) — trigger-skip:** w gałęzi `UPDATE/INSERT` - `if TD['new'].get('deleted_at') is not None: ` (DELETE i tak - już zaszedł). Oszczędza no-op SELECT/upsert, ale **sam nie pokrywa pełnej - re-projekcji** (ta re-selektuje z widoku). Filtr widoku (#1) jest - obowiązkowy; trigger-skip opcjonalny. -- **Weryfikacja spójności — `Rekord.objects.full_refresh()`** (re-projekcja - `_mat` ze źródła), NIE `verify_cache`. ⚠️ `src/bpp/management/commands/verify_cache.py` - to **martwy stub** (`psycopg2.connect(database="b_med", host="linux-dev")` + - `raise NotImplementedError`) — nie da się go uruchomić; jego naprawa jest - POZA zakresem soft-delete. Testy spójności robią pełen `full_refresh` i - sprawdzają, że skasowane rekordy NIE wracają do `_mat` (to weryfikuje filtr - widoku #1). -- **Przypadek brzegowy znika strukturalnie:** edycja wiersza autorstwa - skasowanej publikacji nie wskrzesi go w `bpp_autorzy_mat`, bo widok - źródłowy go odfiltruje (ma własne `deleted_at` ustawione kaskadą). -- **Koszt:** soft-delete publikacji z N autorami odpala N dodatkowych (no-op) - triggerów through. Pomijalne. - -Testy spójności mat-view obowiązkowe (soft-delete → znika z `Rekord` i -`Autorzy`; restore → wraca; po `full_refresh()` skasowane NIE wracają do `_mat`; -brak rozjazdu `Cache_Punktacja_*`). +through-modele też stają się `SoftDeleteModel`, każda z 8 tabel pod triggerem +ma własną kolumnę `deleted_at`. Funkcja czyta `deleted_at` z **własnego** +wiersza (`NEW`) — reguła działa identycznie dla tabeli publikacji i autorskiej. +**Nie ma potrzeby JOIN-a/lookupu do rekordu nadrzędnego.** + +**Przypadek brzegowy znika strukturalnie:** edycja wiersza autorstwa +skasowanej publikacji nie wskrzesi go w `bpp_autorzy_mat` — jego własne +`deleted_at` jest ustawione kaskadą, więc gałąź z punktu 1 zadziała. + +**Koszt:** soft-delete publikacji z N autorami odpala N dodatkowych triggerów +through (każdy = jeden `DELETE` po indeksie). Pomijalne. + +⚠️ **`full_refresh()` NIE jest re-projekcją `_mat`.** +`Rekord.objects.full_refresh()` (`src/bpp/models/cache/rekord.py:121-127`) to +`denorm.rebuildall(...)` — przebudowa pól denormalizowanych, nie odtworzenie +`_mat` z widoków. Nie nadaje się jako weryfikacja spójności soft-delete (nigdy +nic z `_mat` nie usuwa, więc test „po `full_refresh()` skasowane nie wracają" +przeszedłby z fałszywych powodów). Dodatkowo po wpięciu `SoftDeleteModel` +`rebuildall` iteruje po domyślnym managerze, więc **pominie rekordy +skasowane** — to jest pożądane, ale zapisujemy jako świadomą decyzję (#12), +nie odkrycie. + +⚠️ `src/bpp/management/commands/verify_cache.py` to **martwy stub** +(`psycopg2.connect(database="b_med", host="linux-dev")` + `raise +NotImplementedError`) — nie da się go uruchomić; naprawa POZA zakresem. + +**Weryfikacja spójności robimy wprost, surowym SQL-em** (wzorzec: +`src/bpp/tests/test_cache/test_cache_plpgsql_port.py`): soft-delete → wiersza +nie ma w `bpp_rekord_mat`/`bpp_autorzy_mat`; restore → wraca i zgadza się +kolumna-po-kolumnie ze świeżo policzonym widokiem; `ctid` do wykrywania +jałowych przepisań. ### 2.2 Override `delete()` — wąska, kontrolowana kaskada na `*_Autor` `SoftDeleteModel.delete()` domyślnie kaskaduje **refleksyjnie** po wszystkich -odwrotnych relacjach. W `strict=True` (domyślny) rzuci `SoftDeleteException` -na nie-soft dzieciach (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, -`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`), a `strict=False` twardo -skasuje je przez CASCADE. **Oba złe.** Dlatego na 5 modelach nadpisujemy -`delete()` tak, by **NIE** używał refleksyjnej kaskady pakietu, lecz: +odwrotnych relacjach. + +⚠️ **Korekta 2026-08-06:** poprzednia wersja tej sekcji twierdziła, że +`strict=True` jest domyślne. W zainstalowanej wersji pakietu jest **odwrotnie** +(`django_softdelete/models.py`): `delete(self, strict: bool = False, ...)`, +ale `restore(self, strict: bool = True, ...)`. To czyni argument za własnym +override'em **mocniejszym**, nie słabszym: przy `strict=False` kaskada nie +krzyknie `SoftDeleteException` na nie-soft dzieciach — **po cichu przejedzie +po wszystkich odwrotnych relacjach**. Asymetria `delete`/`restore` to dodatkowy +powód, by nie polegać na domyślnym zachowaniu pakietu w żadną stronę. + +Nie-soft dzieci (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, +`Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`) nie mają być ruszane +w żadnym trybie. Dlatego na 5 modelach nadpisujemy `delete()` tak, by **NIE** +używał refleksyjnej kaskady pakietu, lecz: 1. ustawił własne `deleted_at` i zapisał, 2. **jawnie soft-deletował własne wiersze `*_Autor`** (`Wydawnictwo_Ciagle_Autor` @@ -163,9 +235,9 @@ wszystko z `_mat` na podstawie własnych `deleted_at`; przy restore re-projektuje ze źródła. Po co jawna kaskada na `*_Autor`, skoro trigger i tak czyści `bpp_autorzy_mat`? -Bo **90 miejsc w kodzie czyta `*_Autor.objects` bezpośrednio** (z pominięciem +Bo **128 miejsc w kodzie czyta `*_Autor.objects` bezpośrednio** (z pominięciem cache), głównie w `ewaluacja_optymalizacja`. Domyślny menedżer `objects` -`SoftDeleteModel` ukrywa skasowane → te 90 miejsc staje się poprawne +`SoftDeleteModel` ukrywa skasowane → te 128 miejsc staje się poprawnych automatycznie, bez ręcznych filtrów `wydawnictwo_ciagle__deleted_at__isnull` (których pominięcie = po cichu zliczona skasowana praca w ewaluacji). @@ -173,6 +245,51 @@ Zweryfikować, że nadpisany `delete()`/`restore()` nadal emituje sygnały `post_soft_delete`/`post_restore` (patrz §5), oraz że ścieżka queryset (`.delete()` na QS) również kaskaduje na `*_Autor`. +### 2.2b `unique_together` na `*_Autor` — też warunkowy (decyzja #13) + +Ten sam problem co ze slugiem (§2.3), przeoczony w pierwszej wersji specu. +`BazaModeluOdpowiedzialnosciAutorow` ma w konkretnych klasach (np. +`src/bpp/models/wydawnictwo_ciagle.py:73-77`): + +```python +unique_together = [ + ("rekord", "autor", "typ_odpowiedzialnosci"), + ("rekord", "autor", "kolejnosc"), +] +``` + +Soft-deletowany wiersz `*_Autor` **nadal zajmuje slot w unique**, będąc +niewidocznym dla operatora. Realny scenariusz kolizji: `deduplikator_autorow` +przenosi autorstwa z duplikatu na autora głównego +(`src/deduplikator_autorow/utils/merge.py:191,284,354`) i trafia w +soft-deletowany wiersz o tej samej trójce → `IntegrityError` o rekord, którego +nie widać. + +**DECYZJA: zamienić na warunkowy `UniqueConstraint`** — spójnie ze slugiem: + +```python +constraints = [ + models.UniqueConstraint( + fields=["rekord", "autor", "typ_odpowiedzialnosci"], + condition=Q(deleted_at__isnull=True), + name="...", + ), + models.UniqueConstraint( + fields=["rekord", "autor", "kolejnosc"], + condition=Q(deleted_at__isnull=True), + name="...", + ), +] +``` + +⚠️ Uwaga wykonawcza: `unique_together` jest walidowane także przez +`Model.validate_unique()` (formularze adminu), a `UniqueConstraint` z +`condition` **nie jest** brane pod uwagę przez `validate_unique` w starszym +Django. Zweryfikować w fazie 02, czy admin inline'ów autorstwa nadal daje +czytelny komunikat zamiast `IntegrityError` — komentarz przy drugiej krotce +(„Tu musi być autor, inaczej admin nie pozwoli wyedytować") sugeruje, że ten +constraint istnieje właśnie ze względu na admina. + ### 2.3 `slug` — warunkowy unique ⚠️ `slug` to **pole denormalizowane** (`@denormalized(models.SlugField, ..., @@ -209,8 +326,54 @@ eksport / liczenie) staje się czysta automatycznie (zero zmian). Ale > menedżera, soft-delete staje się generatorem duplikatów. Audyt kat. B jest > obowiązkowy. -**Through-modele `*_Autor` (90 miejsc).** Po wpięciu `SoftDeleteModel` -90 bezpośrednich zapytań `*_Autor.objects` (głównie `ewaluacja_optymalizacja`: +**Co po dopasowaniu do rekordu w koszu (decyzja #14).** Samo przejście na +`global_objects` usuwa duplikaty, ale otwiera drugie pytanie: importer +dopasował po `pbn_uid`/DOI rekord, który operator świadomie skasował — i co +teraz? Bez rozstrzygnięcia domyślnie „zaktualizowałby go po cichu", czyli +import nadpisywałby zawartość kosza. + +> **DECYZJA: POMIŃ + ZARAPORTUJ.** Rekord w koszu nie jest tykany; trafienie +> ląduje w raporcie importu jako osobna kategoria (nie „błąd", nie „utworzono"). +> Uzasadnienie: soft-delete to jawna deklaracja operatora „tego tu nie ma"; +> cichy update kosza ją podważa, a auto-restore pozwoliłby importowi wskrzeszać +> rzeczy skasowane celowo. Nowy rekord obok też odpada — odtwarzałby dokładnie +> ten problem duplikatów, przed którym broni `global_objects`. +> +> Wymaganie wykonawcze: **każda** ścieżka kat. B musi mieć gdzie ten fakt +> zaraportować. Tam, gdzie importer nie ma struktury raportu, wystarczy log +> + licznik; nie wolno pominąć milcząco. + +### 2.5b `Cache_Punktacja_*` — praca w koszu nie może liczyć się do ewaluacji + +⚠️ **Luka wykryta 2026-08-06** — nie pokrywa jej wąska kaskada na `*_Autor`. + +`Cache_Punktacja_Autora` i `Cache_Punktacja_Dyscypliny` +(`src/bpp/models/cache/punktacja.py`) to **osobne tabele**, kluczowane tablicą +`rekord_id = [content_type_id, pk]`. Nie mają FK do publikacji, więc **żadna +kaskada Django ich nie ruszy**, a triggery cache ich nie dotyczą. Zapisywane +są z `src/bpp/models/sloty/core.py:401,439`. + +Czytane bezpośrednio, z pominięciem `Rekord`, m.in. w: +- `src/ewaluacja_optymalizacja/utils.py:182`, +- `src/ewaluacja_optymalizacja/views/evaluation_browser/prefetch.py:41`, +- `src/oswiadczenia/views.py:353`. + +Bez osobnego mechanizmu **soft-deletowana praca dalej wnosi sloty i punkty do +ewaluacji** — czyli dokładnie ten „silent leak", przed którym broni §2.2, tyle +że innym kanałem. + +> **DECYZJA: kasować przy soft-delete, przeliczać przy restore (#15).** +> - `post_soft_delete` publikacji → usuń wiersze `Cache_Punktacja_Autora` +> i `Cache_Punktacja_Dyscypliny` dla tego `rekord_id`; +> - `post_restore` → przelicz je z powrotem (maszyneria istnieje — +> `src/bpp/models/sloty/core.py`). +> +> Wybrane zamiast „filtrować przy odczycie", bo nie wymaga tknięcia ~10 miejsc +> odczytu ani pilnowania każdego nowego. Koszt: restore staje się operacją +> liczącą (nie samym `UPDATE`), co trzeba uwzględnić w adminie (może trwać). + +**Through-modele `*_Autor` (128 miejsc).** Po wpięciu `SoftDeleteModel` +128 bezpośrednich zapytań `*_Autor.objects` (głównie `ewaluacja_optymalizacja`: `reset_pins`, `reset_disciplines`, `unpin_all_sensible`, `optimization`, `author_works`, `evaluation_browser`, `verification`; oraz `api_v1`, `przemapuj_prace_autora`, `ewaluacja_dwudyscyplinowcy`) **staje się poprawne @@ -307,7 +470,7 @@ Skutki: --- -## 4. PBN — wycofanie oświadczeń przez kolejkę +## 4. PBN — wycofanie oświadczeń (kolejka + ścieżka synchroniczna) ### 4.1 Co i kiedy @@ -323,20 +486,40 @@ Prymityw PBN istnieje: `delete_publication_statement` w `:135`, retry w `pbn_api/client/publication_sync.py`). -### 4.2 Mechanizm — rozszerzenie istniejącej `pbn_export_queue` - -Nie wprowadzamy nowego mechanizmu. Kolejka eksportu PBN żyje jako dedykowana -aplikacja **`src/pbn_export_queue/`** (model `PBN_Export_Queue`: GFK -content_type+object_id, `zamowil`, `ilosc_prob`, `zakonczono_pomyslnie`, -`rodzaj_bledu`, klasyfikacja błędów, locking, „ponowna wysyłka", admin, -`send_to_pbn()`). - -Rozszerzenie: -- dodać pole `operacja: TextChoices(WYSYLKA, WYCOFANIE)` (default `WYSYLKA` - dla kompatybilności wstecznej), migracja, -- gałąź w logice wysyłki: `WYCOFANIE` → `delete_all_publication_statements`, -- status zapisywany jak dla wysyłki (`zakonczono_pomyslnie`, `komunikat`, - `ilosc_prob`) + odzwierciedlenie w `SentData` i `SoftDeleteLog`. +### 4.2 Mechanizm — jeden prymityw, DWA wejścia (decyzja #16) + +⚠️ **Zmiana 2026-08-06.** Poprzednia wersja zakładała jedno wejście +(`pbn_export_queue`). Rekord może jednak trafić do PBN **synchronicznie, bez +kolejki** (`synchronizuj_publikacje`, `src/pbn_integrator/utils/synchronization.py:180`, +wołane m.in. z `pbn_uploader` / `pbn_integrator`), a od czasu pisania specu +doszła aplikacja `src/pbn_wysylka_oswiadczen/`, która już woła +`delete_all_publication_statements` (`tasks.py:68`) z dopracowaną obsługą +wyjątków PBN. Wycofanie musi więc działać w obu trybach. + +**Architektura: wspólna funkcja-prymityw + dwa wywołujące ją wejścia.** + +1. **Prymityw** (nowy, jedno miejsce prawdy) — np. + `wycofaj_oswiadczenia(publikacja, client) -> wynik`: + - gate na `pbn_uid` (brak → no-op, jawnie zaraportowany), + - `client.delete_all_publication_statements(pbn_uid)`, + - obsługa `CannotDeleteStatementsException` (brak oświadczeń = sukces, + nie błąd), `PraceSerwisoweException`, `HttpException` — wzorzec + do przejęcia z `pbn_wysylka_oswiadczen/tasks.py:54-76`, + - aktualizacja `SentData` (§4.2b) — zawsze, niezależnie od wejścia. +2. **Wejście asynchroniczne** — `pbn_export_queue` rozszerzona o pole + `operacja: TextChoices(WYSYLKA, WYCOFANIE)` (default `WYSYLKA` dla + kompatybilności wstecznej). Gałąź w `send_to_pbn()` + (`src/pbn_export_queue/models.py:456`): `WYCOFANIE` → prymityw. Status + zapisywany jak dla wysyłki (`zakonczono_pomyslnie`, `komunikat`, + `ilosc_prob`). To jest **ścieżka domyślna** dla soft-delete z admina — + ma retry, locking i `UniqueConstraint` na jednym aktywnym wpisie na rekord. +3. **Wejście synchroniczne** — kod, który wysyła rekord bez kolejki, woła + prymityw bezpośrednio. Nie kolejkuje, nie ma retry; odpowiada za obsługę + wyniku na miejscu. + +> **Niezmiennik:** żadne z wejść nie woła `delete_all_publication_statements` +> samodzielnie — wyłącznie przez prymityw. Inaczej aktualizacja `SentData` +> i wpis w `SoftDeleteLog` rozjadą się między ścieżkami. ⚠️ **`PBN_Export_Queue.zamowil` jest NOT NULL (`on_delete=CASCADE`).** Zakolejkowanie z admina ma `request.user`. Ale zakolejkowanie inicjowane @@ -345,6 +528,8 @@ kogo wpisać. Rozwiązanie (faza 05/06): **konto techniczne** (np. `get_or_create` systemowego użytkownika) jako `zamowil` dla operacji systemowych — NIE robić `zamowil` nullable (psułoby istniejące zał. kolejki). +### 4.2b `SentData` — stan PBN per-rekord + `SentData` (`src/pbn_api/models/sentdata.py`, GFK + `pbn_uid` + `submitted_successfully` + `mark_as_successful`/`mark_as_failed`) trzyma stan PBN per-rekord. **Po udanym wycofaniu:** ustawiamy `submitted_successfully = @@ -353,10 +538,15 @@ False` (rekord nie jest już „wystawiony" w PBN) i dodajemy znacznik wycofania `SentData` NIE kasujemy** — zostaje dla audytu i re-matchingu przy restore. Restore (`WYSYLKA`) → ponowne `mark_as_successful` po udanej wysyłce. +Aktualizację `SentData` robi **prymityw** (§4.2 pkt 1), nie wywołujący — dzięki +temu ścieżka asynchroniczna i synchroniczna zostawiają identyczny stan. + ### 4.3 Restore → symetria -Restore publikacji → wpis `WYSYLKA` w `pbn_export_queue` (ponowna wysyłka -oświadczeń, dyscypliny wracają do profilu). Symetria delete↔restore. +Restore publikacji → ponowna wysyłka oświadczeń (dyscypliny wracają do +profilu instytucji), tym samym dwuwejściowym wzorcem co wycofanie: domyślnie +wpis `WYSYLKA` w `pbn_export_queue`, a w kontekście synchronicznym — +bezpośrednio. Symetria delete↔restore. --- @@ -417,20 +607,27 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, ## 8. Kolejność prac (fazy; szczegółowy TDD → writing-plans) -1. **`*_Autor` + trigger + widoki** — kolejność wewnątrz fazy: (a) migracja - `SoftDeleteModel` na 3 through-modelach (`deleted_at`+indeks) — **musi być - PRZED** (b), bo trigger/widok czytają tę kolumnę; (b) filtr `deleted_at IS - NULL` w widokach źródłowych `bpp_rekord`/`bpp_*_autorzy` (mechanizm #1, po - własnej kolumnie); (c) funkcja `bpp_refresh_cache()` z regułą - `deleted_at IS NOT NULL → pomiń re-insert` (opcjonalna optymalizacja). - Testy spójności mat-view + `verify_cache`. **Najwrażliwsze, pierwsze.** +1. **`*_Autor` + widoki + funkcje triggera + bramka** — kolejność wewnątrz + fazy jest wymuszona zależnościami (§2.1): + (a) migracja `SoftDeleteModel` na 3 through-modelach (`deleted_at`+indeks) — + **musi być PRZED** (b), bo widok czyta tę kolumnę; + (b) filtr `deleted_at IS NULL` w widokach źródłowych `bpp_*_autorzy` + (po własnej kolumnie, bez JOIN); + (c) **gałąź kasująca** w funkcjach refresh (`IF NEW.deleted_at IS NOT NULL + THEN DELETE ... RETURN NULL`) — **obowiązkowa**, nie optymalizacja; + (d) **regeneracja bramki `WHEN`** (logika `forward()` z `0433`) — musi być + PO (b), żeby `pg_depend` już znało `deleted_at`. + Weryfikacja: odwrócenie asercji w + `src/bpp/tests/test_cache/test_soft_delete_preconditions.py` + testy + spójności surowym SQL-em. **Najwrażliwsze, pierwsze.** 2. **Publikacje** — `SoftDeleteModel` na 5 modelach, override `delete()`/`restore()` z **wąską kaskadą na `*_Autor`** (wspólny `transaction_id`, bez refleksyjnej kaskady pakietu), migracje (`deleted_at`+indeks, ew. `CONCURRENTLY`), `slug` `UniqueConstraint`, - przeplecenie menedżerów. + warunkowe `UniqueConstraint` na `*_Autor` (§2.2b), przeplecenie menedżerów, + powtórzenie kroków 1(b)–1(d) dla 5 widoków publikacji i ich funkcji. 3. **Audyt kat. B** — przełączenie import/dedup/PBN-matching na - `global_objects`; audyt 90 miejsc `*_Autor.objects` (default „pomijaj" + `global_objects`; audyt 128 miejsc `*_Autor.objects` (default „pomijaj" poprawny, wyjątki → `global_objects`). Testy: re-import nie tworzy duplikatów; ewaluacja pomija prace w koszu. 4. **Guardy PROTECT** (ten sam wzorzec: flip FK + guard liczący przez @@ -441,10 +638,12 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, - **`Wydawnictwo_Zwarte` (rozdziały)** — flip FK `wydawnictwo_nadrzedne` `CASCADE→PROTECT`, guard w soft `delete()` blokujący gdy ma rozdziały (§2.6). -5. **PBN** — `operacja WYCOFANIE` w `pbn_export_queue` + restore→`WYSYLKA`; - integracja `SentData`. +5. **PBN** — prymityw `wycofaj_oswiadczenia()` + dwa wejścia: `operacja + WYCOFANIE` w `pbn_export_queue` (async) i wywołanie bezpośrednie + (synchroniczne); restore→`WYSYLKA`; integracja `SentData` (§4.2). 6. **SoftDeleteLog** + receivery sygnałów (`post_soft_delete`/`post_restore`/ - `post_hard_delete`), wstrzykiwanie `user`. + `post_hard_delete`), wstrzykiwanie `user`, **kasowanie/przeliczanie + `Cache_Punktacja_*`** (§2.5b). 7. **Admin** — kosz / filtr / przywróć / usuń-trwale / powód (5 modeli + `Autor`). 8. **Testy regresji** — pełna suita: PBN (duplikaty + wycofanie), dashboard, @@ -455,9 +654,20 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, ## 9. Ryzyka - **Cache/trigger** — rozjazd, jeśli `deleted_at` nie obsłużone we wszystkich - 8 tabelach (5 publikacji + 3 `*_autor`) + ścieżce UPDATE + widokach - źródłowych. Najgroźniejsze, wydajnościowo wrażliwe. Mitygacja: testy - spójności jako pierwsze. + 8 tabelach (5 publikacji + 3 `*_autor`) × trzech elementach (widok, gałąź + kasująca, bramka `WHEN`). Najgroźniejsze, wydajnościowo wrażliwe. + Mitygacja: kanarki `test_soft_delete_preconditions.py` odwrócone na + docelowe asercje jako PIERWSZY krok fazy 01. +- **Bramka `WHEN` zapomniana przy kolejnej zmianie widoku** — bramka jest + wypiekana w migracji z `pg_depend`, więc **każda przyszła zmiana definicji + widoku źródłowego wymaga jej regeneracji**. Pominięcie = cichy staleness + (rekord w koszu widoczny dalej). Mitygacja: kanarki + nota w + `docs/deweloper/spec-bpp-refresh-cache-plpgsql-2026-06.md`. +- **`Cache_Punktacja_*` nietknięte przy soft-delete** — praca w koszu nadal + liczy się do ewaluacji (§2.5b). Osobny kanał, którego kaskada `*_Autor` + NIE zamyka. +- **Restore staje się operacją liczącą** — przeliczenie `Cache_Punktacja_*` + (§2.5b) może trwać; admin musi to znieść (komunikat / zadanie w tle). - **Guard autora przez `objects` zamiast `global_objects`** — autor z pracami tylko-w-koszu przeszedłby przez guard (autorstwa kaskadowo skasowane są ukryte). MUSI być `global_objects` (§3.2). @@ -484,7 +694,7 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, soft-deletuje rodzica i jego `*_Autor` (wspólny `transaction_id`), bez refleksyjnej kaskady na pozostałe dzieci. Trigger jako choke-point, jednolity dzięki własnym `deleted_at` na wszystkich 8 tabelach (bez JOIN - do rodzica). Powód kaskady: 90 miejsc `*_Autor.objects` w ewaluacji. + do rodzica). Powód kaskady: 128 miejsc `*_Autor.objects` w ewaluacji. 3. **PBN przy soft-delete:** wycofanie oświadczeń instytucji (`delete_all_publication_statements`), gate na `pbn_uid`; obiektu publikacji nie kasujemy. @@ -495,15 +705,36 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, 7. **Admin:** superuser-only; soft-delete zastępuje „usuń"; hard-delete jako osobna jawna akcja. 8. **Retencja:** brak auto-czyszczenia; tylko ręczny hard-delete. -9. **Cache — mechanizm nadrzędny:** filtr `deleted_at IS NULL` w widokach - źródłowych (pokrywa trigger, odczyt z `bpp_rekord`, `verify_cache`); - trigger-skip to opcjonalna optymalizacja. +9. ~~**Cache — mechanizm nadrzędny:** filtr `deleted_at IS NULL` w widokach + źródłowych; trigger-skip to opcjonalna optymalizacja.~~ + **UNIEWAŻNIONE 2026-08-06.** Zastąpione przez: **trzy elementy, wszystkie + obowiązkowe** — (a) filtr w widoku, (b) gałąź kasująca w funkcji refresh, + (c) regeneracja bramki `WHEN`. Żaden nie wystarcza sam (§2.1). 10. **SentData przy wycofaniu:** `submitted_successfully=False` + znacznik wycofania, wiersza nie kasujemy. 11. **Self-FK `Wydawnictwo_Zwarte` (rozdziały):** **PROTECT** — soft-delete książki-matki zablokowany, jeśli ma rozdziały (flip FK `CASCADE→PROTECT` + guard liczący przez `global_objects`, §2.6). Wzorzec jak guard autora. +### Decyzje domknięte 2026-08-06 (po weryfikacji na `dev`) + +12. **`full_refresh()` pomija skasowane** — `denorm.rebuildall` iteruje po + domyślnym managerze. Świadomie akceptowane (przebudowa opisów + bibliograficznych rekordu w koszu jest bez wartości). NIE używamy + `full_refresh()` jako weryfikacji spójności soft-delete (§2.1). +13. **`unique_together` na `*_Autor` → warunkowy `UniqueConstraint`** + z `condition=Q(deleted_at__isnull=True)`, spójnie ze slugiem (§2.2b). +14. **Import trafiający w kosz: POMIŃ + ZARAPORTUJ.** Nie tykamy rekordu + w koszu; trafienie idzie do raportu importu jako osobna kategoria. + Nie auto-restore, nie nowy rekord (§2.5). +15. **`Cache_Punktacja_*`: kasować przy soft-delete, przeliczać przy restore.** + Zamiast filtrowania w ~10 miejscach odczytu (§2.5b). +16. **PBN: jeden prymityw, dwa wejścia** — asynchroniczne przez + `pbn_export_queue` (`operacja=WYCOFANIE`, ścieżka domyślna) i + synchroniczne wywołanie bezpośrednie, bo rekord bywa wysyłany bez kolejki. + Żadne wejście nie woła `delete_all_publication_statements` samodzielnie + (§4.2). + --- ## 11. Precedensy w repo @@ -512,7 +743,16 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, - `src/zglos_publikacje/models.py` — `Zgłoszenie_Publikacji` już `SoftDeleteModel` (wzorzec). - `src/pbn_export_queue/` — dojrzała kolejka PBN (model + Celery + admin + - retry/lock), wzorzec dla operacji `WYCOFANIE`. + retry/lock), wzorzec dla operacji `WYCOFANIE` (wejście asynchroniczne). +- `src/pbn_wysylka_oswiadczen/tasks.py:54-76` — wzorcowa obsługa wyjątków PBN + przy `delete_all_publication_statements` (`CannotDeleteStatementsException` + = brak oświadczeń = sukces, nie błąd). Do przejęcia przez prymityw §4.2. - `src/pbn_api/models/sentdata.py` — `SentData` (stan PBN per-rekord). - `src/bpp/models/oplaty_log.py`, log w `deduplikator_autorow` — precedensy tabel-logów. +- `src/bpp/migrations/0432_cache_trigger_plpgsql.py` / + `0433_cache_trigger_when_gate.py` — generatory funkcji triggera i bramki. + **Faza 01 wprost korzysta z ich logiki**, nie pisze SQL-a od zera. +- `src/bpp/tests/test_cache/test_cache_plpgsql_port.py` — wzorzec testów + spójności `_mat` surowym SQL-em (porównanie kolumna-po-kolumnie, `ctid` + do wykrywania jałowych przepisań). From 5b32b347c9d0c9adb12a4c99b460221b031594b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 14:05:55 +0200 Subject: [PATCH 12/75] docs(soft-delete): faza 01 Task 1 -- test managerow zamiast tautologii Pre-flight scan SDD wykryl plan-mandated defekt: test_managery_sa_wlasciwych_klas mial asercje prawdziwe zawsze (issubclass(X.__mro__[0], object), isinstance(__qualname__, str)) -- nie weryfikowal niczego. Zastapiony testem sprawdzajacym rzecz, na ktorej naprawde zalezy: czy oba managery zwracaja BppSoftDeleteQuerySet (inaczej gate na update() nie dziala, bo pakietowy QuerySet go nie ma). Plus nota, ze na tym etapie nie wolno iterowac po queryset-cie -- kolumny deleted_at jeszcze nie ma (Task 2). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- ...-04-soft-delete-01-autor-trigger-widoki.md | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md index d8c3af128..3947b8455 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -108,13 +108,26 @@ Tworzy współdzielony fundament menedżerów dla całego wdrożenia. Guard zale assert qs.update(kolejnosc=5) == 0 # pusty QS, ale nie rzuca - def test_managery_sa_wlasciwych_klas(): - assert issubclass(BppSoftDeleteManager.__bases__[0].__mro__[0], object) - assert isinstance( - BppSoftDeleteManager().get_queryset.__func__.__qualname__, str - ) + def test_managery_zwracaja_bpp_queryset(): + """Oba managery MUSZĄ zwracać BppSoftDeleteQuerySet — inaczej gate + na update() nie zadziała (pakietowy QuerySet go nie ma).""" + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + + for manager_cls in (BppSoftDeleteManager, BppGlobalManager): + manager = manager_cls() + manager.model = Wydawnictwo_Ciagle_Autor + manager._db = None + assert isinstance(manager.get_queryset(), BppSoftDeleteQuerySet), ( + f"{manager_cls.__name__} nie zwraca BppSoftDeleteQuerySet" + ) ``` + ⚠️ **Uwaga:** `BppSoftDeleteManager.get_queryset()` filtruje po + `deleted_at__isnull=True`, a na tym etapie `Wydawnictwo_Ciagle_Autor` + **nie ma jeszcze** kolumny `deleted_at` (dodaje ją Task 2). Budowa + queryset-u jest leniwa (filtr nie odpala SQL-a), więc `isinstance` przejdzie + — ale **nie iteruj** po tym queryset-cie w tym teście. + - [ ] Uruchom (oczekiwany FAIL — `ModuleNotFoundError: bpp.models.soft_delete`): ```bash uv run pytest src/bpp/tests/test_soft_delete/test_managers.py -q From 6cc4b3262167c78e393d7b049ed484a7a176513f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 14:28:58 +0200 Subject: [PATCH 13/75] docs(soft-delete): naprawa blokerow z self-review przed startem fazy 01 Adwersaryjny self-review (Fable 5) znalazl 4 blokery; wszystkie zweryfikowane w kodzie przed naprawa. B1 (blad wprowadzony przy rewizji 2026-08-06): filtr widokow bpp_*_autorzy uzywal object_id_raw, ktory w TYCH widokach jest id PUBLIKACJI (0421_cache_trigger_pk_filter.sql:336), a nie pk wiersza through. Porownywal wiec id publikacji z id autorstwa. Dwa realne skutki: skasowane autorstwo zostaje w widoku, a przy zbieznosci numerow wycinane sa autorstwa CUDZEJ publikacji. Poprawione na NOT EXISTS po (_orig.id)[2]. Dodany test SEMANTYCZNY -- planowane testy (substring "deleted_at" w viewdef, bramka zna deleted_at) przechodza takze dla blednego klucza, bo pg_depend widzi kolumne przez podzapytanie niezaleznie od sensu porownania. B3: BazaModeluOdpowiedzialnosciAutorow ma CZWARTEGO potomka -- Zgloszenie_Publikacji_Autor (zglos_publikacje/models.py:315). Wpiecie SoftDeleteModel w abstrakt objeloby aplikacje zgloszen (nieplanowana migracja, podmieniony objects, dryf makemigrations --check). Zamiast tego mixin BppAutorstwoSoftDeleteMixin wpinany w 3 KONKRETNE modele + test negatywny. B2 (faza 02): Praca_Doktorska_Baza.autorzy_set to property zwracajaca FakeSet (podklasa list) z atrapami bez .delete() (praca_doktorska.py:30-70). Kaskada mixinu wywalilaby sie AttributeError na doktoracie i habilitacji. Dodane _model_through()/_autorstwa_do_kaskady(). P8: content_type_id nie istnieje na modelach publikacji -- to property na RekordBase (rekord.py:288). Testy faz 01/02/06/08 padalyby AttributeError z falszywego powodu. Zamienione na ContentType.objects.get_for_model. P12: widoki bpp_praca_{doktorska,habilitacyjna}_autorzy dopisane do fazy 02. D3: "trigger dziala tylko z prawdziwym commitem" to falsz -- kanarki chodza pod django_db; transactional_db usuniety jako zbedny. D4: sprzecznosc DROP CASCADE vs CREATE OR REPLACE. Dlugi dla faz 03-06 (nieaktualny stan pbn_export_queue, rozjechany kontrakt zakolejkuj_*, konto techniczne zamowil, MetrykaAutora, AutorManager, i in.) spisane w ledgerze SDD do splaty przed odpowiednimi fazami. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- ...-04-soft-delete-01-autor-trigger-widoki.md | 170 ++++++++++++++---- .../2026-06-04-soft-delete-02-publikacje.md | 80 +++++++-- ...2026-06-04-soft-delete-06-softdeletelog.md | 6 +- ...026-06-04-soft-delete-08-testy-regresji.md | 13 +- 4 files changed, 219 insertions(+), 50 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md index 3947b8455..186b61df7 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -21,7 +21,7 @@ > **Filtr widoku sam NIE wystarcza.** Ta faza robi trzy rzeczy, nie jedną — > patrz „Architecture" niżej. Wszystkie obowiązkowe. -**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez wspólną bazę `BazaModeluOdpowiedzialnosciAutorow`), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, i doprowadzić do tego, by soft-deletowane autorstwa **znikały** z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i **wracały** po `restore`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować. +**Goal:** Uczynić 3 through-modele `Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor` modelami `SoftDeleteModel` (przez mixin `BppAutorstwoSoftDeleteMixin` wpinany w **3 KONKRETNE** klasy — NIE w abstrakt `BazaModeluOdpowiedzialnosciAutorow`, patrz Task 2), dodać im pola `deleted_at`/`restored_at`/`transaction_id` + indeks na `deleted_at`, i doprowadzić do tego, by soft-deletowane autorstwa **znikały** z materializowanego cache (`bpp_autorzy_mat`, model `Autorzy`) i **wracały** po `restore`. Faza najwrażliwsza — robiona pierwsza; gwarantuje spójność cache zanim cokolwiek innego (publikacje, admin) zacznie soft-deletować. **Architecture — trzy elementy, wszystkie obowiązkowe** (żaden nie wystarcza sam; uzasadnienie: §2.1 specu): @@ -36,18 +36,18 @@ Gałęzie `UNION` w `bpp_rekord` per typ publikacji NIE filtrują po `*_autor.de **Spec źródłowy:** [`../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md`](../specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md) (§1, §2.1, §2.2, §8 pkt 1). Indeks: [`2026-06-04-soft-delete-00-overview.md`](2026-06-04-soft-delete-00-overview.md). **Fakty z kodu (zweryfikowane, NIE zmieniać bez ponownej weryfikacji):** -- `BazaModeluOdpowiedzialnosciAutorow` jest `models.Model` (abstract), `src/bpp/models/abstract/authors.py:16`. Po niej dziedziczą wszystkie 3 through-modele. -- `Wydawnictwo_Ciagle_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_ciagle.py:52`. FK `rekord` → `Wydawnictwo_Ciagle`, `related_name="autorzy_set"`, `src/bpp/models/wydawnictwo_ciagle.py:58`. +- `BazaModeluOdpowiedzialnosciAutorow` jest `models.Model` (abstract), `src/bpp/models/abstract/authors.py:19`. ⚠️ Dziedziczą po niej **CZTERY** modele: 3 through-modele publikacji **oraz** `Zgloszenie_Publikacji_Autor` (`src/zglos_publikacje/models.py:315`) — ten ostatni jest POZA zakresem soft-delete. Dlatego NIE ruszamy abstraktu (Task 2). +- `Wydawnictwo_Ciagle_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_ciagle.py:53`. FK `rekord` → `Wydawnictwo_Ciagle`, `related_name="autorzy_set"`, `src/bpp/models/wydawnictwo_ciagle.py:59`. - `Wydawnictwo_Zwarte_Autor(DirtyFieldsMixin, BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/wydawnictwo_zwarte.py:60`. FK `rekord`, `related_name="autorzy_set"`, `:67`. - `Patent_Autor(BazaModeluOdpowiedzialnosciAutorow)` — `src/bpp/models/patent.py:32`. FK `rekord`, `related_name="autorzy_set"`, `:35`. - Wszystkie 3 mają `Meta.unique_together` — `("rekord","autor","typ_odpowiedzialnosci")` i `("rekord","autor","kolejnosc")`, np. `src/bpp/models/wydawnictwo_ciagle.py:73-77`. **W TEJ fazie NIE ruszamy**; zamiana na warunkowy `UniqueConstraint` (decyzja #13, §2.2b specu) idzie w fazie 02 razem ze slugiem. -- `BazaModeluOdpowiedzialnosciAutorow.objects` NIE jest jawnie zdefiniowany → po wpięciu `SoftDeleteModel` domyślne `objects` = `SoftDeleteManager` (z pakietu). Nadpiszemy je naszymi `Bpp*` z `src/bpp/models/soft_delete.py`. +- `BazaModeluOdpowiedzialnosciAutorow.objects` NIE jest jawnie zdefiniowany. Managery `Bpp*` wnosi mixin `BppAutorstwoSoftDeleteMixin` (Task 2); w MRO stoi PRZED bazą modelu, więc jego `objects` wygrywa. - `SoftDeleteModel.delete()` (pakiet, `django_softdelete/models.py`) robi **refleksyjną kaskadę** po reverse relacjach — dla `*_Autor` reverse relacji do soft-delete dzieci NIE ma (ich dzieci to nie-soft `Autor`/`Jednostka` przez FK forward), więc kaskada jest no-op. `delete()` woła `self.save(update_fields=['deleted_at','restored_at','transaction_id'])`. ⚠️ **Ten UPDATE dotyka WYŁĄCZNIE tych 3 kolumn** — dlatego bramka `WHEN` musi znać `deleted_at` (punkt 3 „Architecture"), inaczej trigger się nie odpali. `ostatnio_zmieniony` (`auto_now`) też NIE jest bumpowany (`update_fields` filtruje `pre_save`). - ⚠️ `strict` w pakiecie jest **asymetryczne**: `delete(strict=False)`, `restore(strict=True)`. - Tabela `bpp_autorzy_mat` (model `Autorzy`, `src/bpp/models/cache/autorzy.py:39`, `db_table="bpp_autorzy_mat"`) zasilana triggerami z widoków `bpp_*_autorzy`. - **Trigger (AKTUALNY, po PR #363):** `0432_cache_trigger_plpgsql.py` generuje 3 funkcje `bpp_refresh_autor_()` (upsert **bez** DELETE, `_create_through_function`) + 3 `bpp_delete_autor_()`, oraz triggery `_cache_ins` / `_cache_del` / `_cache_upd`. `0433_cache_trigger_when_gate.py` nakłada bramkę `WHEN` na `_cache_upd`, z listą kolumn wyliczoną z `pg_depend`. ⚠️ **Funkcja `bpp_refresh_cache()` NIE ISTNIEJE** — `DROP` w `0432`. Nie kopiować `0399` ani `0001_cache_functions.sql`. - Widoki `bpp_*_autorzy`: ostatnia wersja definicji w `0421_cache_trigger_pk_filter.sql` (dodaje `object_id_raw`). Odtwarzając widok, wychodź z `pg_get_viewdef()`, nie z `0001_widoki_autorzy.sql`. -- `transactional_db` fixture wymagany dla testów dotykających trigger/cache (trigger działa tylko z prawdziwym commitem). Fixture `denorms` (`src/fixtures/conftest_system.py:193`) daje `denorms.flush()`. Fixtury: `wydawnictwo_ciagle_z_dwoma_autorami`, `wydawnictwo_ciagle_z_autorem`, `autor_jan_kowalski`, `jednostka`, `standard_data`, `typy_odpowiedzialnosci`. +- ⚠️ **`transactional_db` NIE jest wymagany** do oglądania efektów triggera — triggery bazodanowe działają wewnątrz transakcji testowej (dowód: kanarki `test_soft_delete_preconditions.py` chodzą pod zwykłym `django_db`). Używaj `django_db`; `transactional_db` tylko spowalnia. Fixture `denorms` (`src/fixtures/conftest_system.py:255`) daje `denorms.flush()`. Fixtury: `wydawnictwo_ciagle_z_dwoma_autorami`, `wydawnictwo_ciagle_z_autorem`, `autor_jan_kowalski`, `jednostka`, `standard_data`, `typy_odpowiedzialnosci`. - ⚠️ **Numeracja migracji (stan 2026-08-06):** liść to `0487_api_v1_przelaczniki`. Nowe migracje tej fazy: `0488_autor_soft_delete_fields` → `0489_soft_delete_autorzy_views` (SQL + regeneracja bramki). **Przed startem zweryfikuj liść ponownie** (`ls src/bpp/migrations/*.py | tail -3`) — `dev` żyje, numery mogły się przesunąć. Wszystkie numery w tym planie są orientacyjne; kanoniczna jest kolejność, nie cyfra. **Kontrakt z reversion (PINNED):** soft-delete idzie WYŁĄCZNIE per-instancja przez `.delete()`/`.save()` (nigdy `queryset.update(deleted_at=...)`). `BppSoftDeleteQuerySet.update()` to egzekwuje fail-fast (gate). W tej fazie testujemy gate i kaskadę queryset-ową. @@ -193,7 +193,9 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`restored_at`/`transaction_id` do WSZYSTKICH 3 konkretnych tabel `*_autor`. Nadpisujemy managery (`objects`/`global_objects`/`deleted_objects`) naszymi `Bpp*` z Task 1, żeby gate był aktywny. Migracja dodaje 3 pola × 3 tabele + indeks na `deleted_at` × 3. **Files:** -- Modify: `src/bpp/models/abstract/authors.py:16` (deklaracja klasy + managery), import `:1-13`. +- Modify: `src/bpp/models/soft_delete.py` (dopisz `BppAutorstwoSoftDeleteMixin`) +- Modify: `src/bpp/models/wydawnictwo_ciagle.py:53`, `wydawnictwo_zwarte.py` (`Wydawnictwo_Zwarte_Autor`), `patent.py:32` — wepnij mixin w bazy klas +- ⚠️ **NIE modyfikuj** `src/bpp/models/abstract/authors.py` — abstrakt ma czwartego potomka poza zakresem - Create: `src/bpp/migrations/0488_autor_soft_delete_fields.py` - Test (create): `src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py` @@ -262,35 +264,59 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r uv run pytest src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py -q ``` -- [ ] Zmodyfikuj import w `src/bpp/models/abstract/authors.py` — dodaj po linii `from django.db.models import CASCADE, SET_NULL, Q, Sum` (`:10`): +> 🩹 **Poprawka 2026-08-06 (self-review) — NIE ruszaj abstraktu.** +> Pierwsza wersja kazała zmienić `BazaModeluOdpowiedzialnosciAutorow(models.Model)` +> na `(SoftDeleteModel)`. To wciągnęłoby **czwarty** model, spoza zakresu +> soft-delete: +> ``` +> src/zglos_publikacje/models.py:315: +> class Zgloszenie_Publikacji_Autor(BazaModeluOdpowiedzialnosciAutorow): +> ``` +> Skutki: nieplanowana migracja w aplikacji `zglos_publikacje`, podmieniony +> `objects` w module zgłoszeń (nieaudytowana zmiana zachowania) i dryf +> w `makemigrations --check`. **Wpinamy `SoftDeleteModel` w 3 konkretne +> modele**, dokładnie te z §2.2 specu. + +- [ ] Dodaj wspólny mixin na końcu `src/bpp/models/soft_delete.py` (obok managerów z Task 1) — żeby nie powtarzać deklaracji managerów trzy razy: ```python - from django_softdelete.models import SoftDeleteModel - - from bpp.models.soft_delete import ( - BppGlobalManager, - BppSoftDeleteManager, - ) - from django_softdelete.managers import DeletedManager - ``` - (UWAGA na cykl importów: `soft_delete.py` nie importuje modeli BPP, więc bezpieczne. `authors.py` już importuje z `bpp.models.dyscyplina_naukowa` — kolejność OK.) + class BppAutorstwoSoftDeleteMixin(SoftDeleteModel): + """SoftDeleteModel + nasze managery dla through-modeli *_Autor. -- [ ] Zmień deklarację klasy `src/bpp/models/abstract/authors.py:16` z: - ```python - class BazaModeluOdpowiedzialnosciAutorow(models.Model): - ``` - na: - ```python - class BazaModeluOdpowiedzialnosciAutorow(SoftDeleteModel): - ``` + Wpinany w 3 KONKRETNE modele (Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte_Autor, Patent_Autor), NIE w abstrakt + BazaModeluOdpowiedzialnosciAutorow — ten ma czwartego potomka, + Zgloszenie_Publikacji_Autor, który jest poza zakresem soft-delete. + """ -- [ ] Dodaj jawne managery w ciele klasy `BazaModeluOdpowiedzialnosciAutorow`, tuż przed `class Meta:` (`:92`). Wstaw przed linią ` class Meta:`: - ```python # Nadpisujemy managery pakietu naszymi (gate na update()). # Kolejność: pierwszy zdefiniowany manager = _default_manager. objects = BppSoftDeleteManager() global_objects = BppGlobalManager() deleted_objects = DeletedManager() + class Meta: + abstract = True + ``` + Import w `soft_delete.py`: `from django_softdelete.models import SoftDeleteModel` oraz `from django_softdelete.managers import DeletedManager`. + +- [ ] Wepnij mixin do **3 konkretnych** klas (kolejność baz: mixin PRZED bazą modelu, żeby jego managery wygrały MRO): + - `src/bpp/models/wydawnictwo_ciagle.py:53` — + `class Wydawnictwo_Ciagle_Autor(DirtyFieldsMixin, BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutorow):` + - `src/bpp/models/wydawnictwo_zwarte.py` (klasa `Wydawnictwo_Zwarte_Autor`) — analogicznie + - `src/bpp/models/patent.py:32` — + `class Patent_Autor(BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutorow):` + +- [ ] **Sanity: `Zgloszenie_Publikacji_Autor` NIE został ruszony.** Dopisz do + `test_autor_softdelete_model.py`: + ```python + def test_zgloszenie_publikacji_autor_nie_jest_soft_delete(): + """Czwarty potomek abstraktu jest POZA zakresem soft-delete.""" + from django_softdelete.models import SoftDeleteModel + + from zglos_publikacje.models import Zgloszenie_Publikacji_Autor + + assert not issubclass(Zgloszenie_Publikacji_Autor, SoftDeleteModel) + assert not hasattr(Zgloszenie_Publikacji_Autor, "global_objects") ``` - [ ] Uruchom `makemigrations` — wygeneruje migrację dla 3 konkretnych modeli: @@ -362,9 +388,10 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " Jedna migracja, trzy zmiany, w **wymuszonej kolejności**: -1. przedefiniowanie 3 widoków `bpp_*_autorzy` z filtrem `deleted_at IS NULL` - po własnej kolumnie tabeli `*_autor` (+ odtworzenie zależnego `bpp_autorzy`, - bo `DROP ... CASCADE` go skasuje), +1. przedefiniowanie 3 widoków `bpp_*_autorzy` z filtrem odcinającym + soft-deletowane autorstwa. Używamy `CREATE OR REPLACE VIEW` (zachowuje + listę i typy kolumn), więc zależny `bpp_autorzy` **NIE** jest kasowany + i nie trzeba go odtwarzać — żadnego `DROP ... CASCADE`, 2. przedefiniowanie 3 funkcji `bpp_refresh_autor_()` z prologiem kasującym, 3. regeneracja bramki `WHEN` na 3 triggerach `*_cache_upd` — **musi być po @@ -442,8 +469,48 @@ dokładnie ten problem, który `0432`/`0433` rozwiązały — patrz row = cur.fetchone() assert row is not None, f"brak triggera {trigger}" assert "deleted_at" in row[0], f"{trigger}: bramka WHEN nie zna deleted_at" + + + @pytest.mark.django_db + def test_widok_odcina_WLASCIWY_wiersz_a_nie_cudzy( + wydawnictwo_ciagle_z_dwoma_autorami, wydawnictwo_ciagle_z_autorem + ): + """Test SEMANTYCZNY klucza filtra — nie sam fakt obecności `deleted_at`. + + Łapie pomyłkę `object_id_raw` (id publikacji) vs `(id)[2]` (pk wiersza + through): przy złym kluczu skasowane autorstwo zostaje w widoku, a + wycięte zostają autorstwa INNEJ publikacji o zbieżnym numerze. + """ + wca = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first() + obcy = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + wca.delete() + + with connection.cursor() as cur: + cur.execute( + "SELECT count(*) FROM bpp_wydawnictwo_ciagle_autorzy " + "WHERE (id)[2] = %s", + [wca.pk], + ) + assert cur.fetchone()[0] == 0, ( + "skasowane autorstwo NADAL w widoku — filtr używa złego klucza" + ) + cur.execute( + "SELECT count(*) FROM bpp_wydawnictwo_ciagle_autorzy " + "WHERE (id)[2] = %s", + [obcy.pk], + ) + assert cur.fetchone()[0] == 1, ( + "filtr wyciął autorstwo INNEJ publikacji — klucz porównuje " + "id publikacji z id wiersza through" + ) ``` + ⚠️ Test `test_widok_zrodlowy_filtruje_po_deleted_at` (substring w + `pg_get_viewdef`) i `test_bramka_when_zna_deleted_at` **przejdą także dla + błędnego klucza** — `pg_depend` widzi `deleted_at` przez podzapytanie + niezależnie od tego, czy porównanie ma sens. Powyższy test semantyczny jest + jedyną realną wyrocznią tego kroku; **nie pomijaj go**. + - [ ] Uruchom (oczekiwany FAIL — wszystkie 9 przypadków): ```bash uv run pytest src/bpp/tests/test_soft_delete/test_views_sql.py -q @@ -488,21 +555,46 @@ dokładnie ten problem, który `0432`/`0433` rozwiązały — patrz def _filtruj_widok(cur, tabela, widok): - """Dokłada 'AND .deleted_at IS NULL' do widoku źródłowego. + """Odcina z widoku *_autorzy wiersze soft-deletowanych autorstw. Owijamy istniejącą definicję zamiast ją przepisywać: definicja jest generowana (0421) i przepisanie jej ręcznie rozjechałoby się przy następnej zmianie kolumn. + + ⚠️ KLUCZ: w widokach *_autorzy `object_id_raw` to `rekord_id`, czyli + **id PUBLIKACJI**, a nie pk wiersza through. Pk wiersza through siedzi + w drugim elemencie tablicy `id` (`ARRAY[ct, ]`), stąd + `(_orig.id)[2]`. Filtrowanie po `object_id_raw` porównywałoby id + publikacji z id autorstwa — patrz komentarz niżej. """ orig = _viewdef(cur, widok) cur.execute( f"CREATE OR REPLACE VIEW {widok} AS " f"SELECT * FROM ({orig}) _orig " - f"WHERE _orig.object_id_raw NOT IN (" - f" SELECT id FROM {tabela} WHERE deleted_at IS NOT NULL)" + f"WHERE NOT EXISTS (" + f" SELECT 1 FROM {tabela} _t " + f" WHERE _t.id = (_orig.id)[2] AND _t.deleted_at IS NOT NULL)" ) ``` + > 🩹 **Poprawka 2026-08-06 (self-review).** Pierwsza wersja tego kroku + > filtrowała `WHERE _orig.object_id_raw NOT IN (SELECT id FROM + > WHERE deleted_at IS NOT NULL)` — **błędnie**. Dowód z kodu + > (`0421_cache_trigger_pk_filter.sql:315-321,336`): + > ```sql + > CREATE OR REPLACE VIEW bpp_wydawnictwo_ciagle_autorzy AS + > SELECT ARRAY[(...'wydawnictwo_ciagle'...), rekord_id] AS rekord_id, + > ARRAY[(...'wydawnictwo_ciagle'...), id] AS id, -- ← through pk + > ... + > , bpp_wydawnictwo_ciagle_autor.rekord_id AS object_id_raw -- ← id PUBLIKACJI + > ``` + > Skutki błędnej wersji (obie realne): soft-delete autorstwa **nie + > odfiltrowałby** właściwego wiersza, a przy zbieżności numerów + > **wyciąłby autorstwa cudzej publikacji**. Uwaga na przyszłość: w widokach + > `bpp__view` (faza 02) `object_id_raw` to id publikacji i tam jest + > kluczem **poprawnym** — te dwa zestawy widoków mają różną semantykę + > `object_id_raw`. + ⚠️ **Do rozstrzygnięcia przy implementacji (nie zgaduj — zmierz):** czy owijanie widoku (`SELECT * FROM (orig) WHERE object_id_raw NOT IN ...`) zachowuje plan wykonania i czy `pg_depend` zarejestruje `deleted_at` jako @@ -649,8 +741,11 @@ Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` - [ ] Napisz testy spójności — `src/bpp/tests/test_soft_delete/test_cache_consistency.py`: ```python """Spójność materializowanego cache (bpp_autorzy_mat / model Autorzy) po - soft-delete wierszy *_Autor. Wymaga transactional_db — trigger odpala się - dopiero przy realnym commicie.""" + soft-delete wierszy *_Autor. + + UWAGA: zwykły django_db WYSTARCZA — triggery bazodanowe dzialaja wewnatrz + transakcji testowej (kanarki test_soft_delete_preconditions.py to + pokazuja). transactional_db jest tu zbedny i tylko spowalnia.""" import pytest @@ -661,9 +756,12 @@ Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` def _autorzy_mat_dla(wca): """Wiersze bpp_autorzy_mat (model Autorzy) wskazujące na danego autora w danym rekordzie.""" + from django.contrib.contenttypes.models import ContentType + + ct = ContentType.objects.get_for_model(type(wca.rekord)).pk return Autorzy.objects.filter( autor_id=wca.autor_id, - rekord_id=[wca.rekord.content_type_id, wca.rekord_id], + rekord_id=[ct, wca.rekord_id], ) @@ -746,7 +844,7 @@ Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` # Wszystkie autorstwa tej pracy zniknęły z mat-view assert not Autorzy.objects.filter( - rekord_id=[wc.content_type_id, wc.pk] + rekord_id=[ContentType.objects.get_for_model(type(wc)).pk, wc.pk] ).exists() # ... ale wiersze fizycznie żyją (soft, nie hard) assert Wydawnictwo_Ciagle_Autor.global_objects.filter(rekord=wc).count() >= 2 diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md index 10e16114a..0905d536f 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md @@ -44,7 +44,31 @@ - Modify: `src/bpp/models/soft_delete.py` (dodaj klasę mixinu na końcu) - Test path: `src/bpp/tests/test_soft_delete_publikacje.py` (nowy plik) -Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-instancja `save()` rodzica, jawna wąska kaskada na `autorzy_set` (każdy wiersz `*_Autor` przez `.delete(transaction_id=...)`), bez refleksyjnej kaskady pakietu (`super().delete()` nie wołamy — sami ustawiamy `deleted_at` + emitujemy sygnał, żeby NIE ruszać `*_Streszczenie`). Wszystkie 5 modeli mają `autorzy_set` (potwierdzone), więc kaskada jest jednolita. +Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-instancja `save()` rodzica, jawna wąska kaskada na `autorzy_set` (każdy wiersz `*_Autor` przez `.delete(transaction_id=...)`), bez refleksyjnej kaskady pakietu (`super().delete()` nie wołamy — sami ustawiamy `deleted_at` + emitujemy sygnał, żeby NIE ruszać `*_Streszczenie`). + +> 🩹 **Poprawka 2026-08-06 (self-review) — kaskada NIE jest jednolita dla 5 modeli.** +> Pierwsza wersja twierdziła „wszystkie 5 modeli mają `autorzy_set` (potwierdzone)". +> **Nieprawda.** Dla doktoratu i habilitacji `autorzy_set` to **property** +> zwracająca atrapy (`src/bpp/models/praca_doktorska.py:30-70`): +> ```python +> @property +> def autorzy_set(self): +> class FakeAutorDoktoratuHabilitacji: +> autor = self.autor +> ... +> class FakeSet(list): +> def all(self): +> return self +> ... +> return FakeSet([ret]) +> ``` +> `FakeSet` to podklasa `list` — nie ma `.model`, a `FakeAutorDoktoratuHabilitacji` +> nie ma `.delete()`. Kaskada verbatim wywaliłaby się `AttributeError` na +> obu modelach (`delete()` przy `autorstwo.delete(...)`, `restore()` przy +> `self.autorzy_set.model`). Dlatego mixin ma `_model_through()` / +> `_autorstwa_do_kaskady()`, które zwracają `None`/`[]` dla publikacji bez +> through-modelu. Autor doktoratu/habilitacji leży na wierszu publikacji, więc +> kaskada nie jest tam potrzebna — sam soft-delete rekordu wystarcza. - [ ] **Krok 1.1 — padający test: soft-delete publikacji kaskaduje na `*_Autor` tym samym `transaction_id`.** Dopisz do `src/bpp/tests/test_soft_delete_publikacje.py`: @@ -103,12 +127,31 @@ Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-insta class Meta: abstract = True + def _model_through(self): + """Model *_Autor tej publikacji, albo None (doktorat/habilitacja). + + Praca_Doktorska/Praca_Habilitacyjna NIE mają through-modelu — autor + leży na wierszu publikacji, a `autorzy_set` jest tam PROPERTY + zwracającą FakeSet z atrapami (patrz box pod kodem). + """ + if isinstance(type(self).__dict__.get("autorzy_set"), property): + return None + return self.autorzy_set.model + + def _autorstwa_do_kaskady(self): + """Wiersze *_Autor do soft-delete; pusto dla doktorat/habilitacja.""" + if self._model_through() is None: + return [] + return list(self.autorzy_set.all()) + def delete(self, *args, user=None, reason="", **kwargs): now = timezone.now() txid = kwargs.pop("transaction_id", None) or uuid.uuid4() with transaction.atomic(): # 1. wąska kaskada na własne *_Autor (per-instancja!) - for autorstwo in self.autorzy_set.all(): + # ⚠️ TYLKO gdy autorzy_set to prawdziwy related manager — + # patrz _przez_through() i box-ostrzeżenie pod kodem. + for autorstwo in self._autorstwa_do_kaskady(): autorstwo.delete(transaction_id=txid) # 2. własne deleted_at + save (NIGDY bulk update) self.deleted_at = now @@ -126,8 +169,9 @@ Mixin dziedziczy `SoftDeleteModel` i nadpisuje `delete()`/`restore()`: per-insta txid = self.transaction_id with transaction.atomic(): # przywróć własne *_Autor skasowane tym samym transaction_id - if txid is not None: - for autorstwo in self.autorzy_set.model.deleted_objects.filter( + through = self._model_through() + if txid is not None and through is not None: + for autorstwo in through.deleted_objects.filter( rekord=self, transaction_id=txid ): autorstwo.restore(transaction_id=txid) @@ -305,10 +349,22 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " Powtórz wzorzec z fazy 01, Task 3 (tam jest pełny opis mechanizmu i pułapek), dla `REKORD_SITES` z `0432_cache_trigger_plpgsql.py`: -1. filtr `deleted_at IS NULL` w 5 widokach `bpp__view`, +1. filtr `deleted_at IS NULL` w 5 widokach `bpp__view` **oraz w 2 widokach + `bpp_praca_doktorska_autorzy` / `bpp_praca_habilitacyjna_autorzy`** — te + ostatnie selektują z tabeli PUBLIKACJI (autor leży na jej wierszu), więc + filtr jest tam „po własnej kolumnie" i klucz `object_id_raw` = id + publikacji jest POPRAWNY. Bez nich `bpp_autorzy` przecieka autorów + skasowanego doktoratu (faza 01 ich nie dotyka — przypis 3 tamtego planu), 2. gałąź kasująca w 5 funkcjach `bpp_refresh_rekord_()`, 3. regeneracja bramki `WHEN` na 5 triggerach `_cache_upd`. +⚠️ **Klucz filtra zależy od rodzaju widoku.** W `bpp__view` i w +`bpp_praca_{doktorska,habilitacyjna}_autorzy` `object_id_raw` to id +publikacji — filtruj po nim. W widokach `bpp_*_autorzy` trzech typów +z through-modelem (faza 01) `object_id_raw` to też id publikacji, ale +filtrowaliśmy tam po pk wiersza through (`(id)[2]`). Nie kopiuj klucza +między fazami bez sprawdzenia, co dana kolumna znaczy. + **Files:** - Create: `src/bpp/migrations/0493_soft_delete_rekord_views.py` - Test: `src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py` @@ -546,25 +602,29 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " wc = baker.make(Wydawnictwo_Ciagle) baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc) wc.refresh_from_db() - ct_pk = wc.content_type_id if hasattr(wc, "content_type_id") else None + from django.contrib.contenttypes.models import ContentType + + # UWAGA: content_type NIE istnieje na modelach publikacji -- to + # property na RekordBase (rekord.py:288). Bierzemy z ContentType. + ct_pk = ContentType.objects.get_for_model(type(wc)).pk assert Rekord.objects.filter( - id=(wc.content_type_id, wc.pk) + id=(ct_pk, wc.pk) ).exists() or Rekord.objects.filter(tytul_oryginalny=wc.tytul_oryginalny).exists() wc.delete() assert not Rekord.objects.filter( tytul_oryginalny=wc.tytul_oryginalny ).exists(), "skasowany rekord wciąż w Rekord" - assert not Autorzy.objects.filter(rekord_id=(wc.content_type_id, wc.pk)).exists() + assert not Autorzy.objects.filter(rekord_id=(ct_pk, wc.pk)).exists() wc.restore() assert Rekord.objects.filter( tytul_oryginalny=wc.tytul_oryginalny ).exists(), "po restore rekord nie wrócił do Rekord" - assert Autorzy.objects.filter(rekord_id=(wc.content_type_id, wc.pk)).exists() + assert Autorzy.objects.filter(rekord_id=(ct_pk, wc.pk)).exists() ``` - > `Rekord.id` to tuple `(content_type_id, object_id)`. Jeśli `Rekord`/`Autorzy` API różni się — dostosuj filtr po realnym kontrakcie `src/bpp/models/cache/`. Fixture `denorms` (z `src/conftest.py`) odpala denorm flush — sprawdź czy istnieje; jeśli nie, użyj właściwej fixtury cache z repo (`flush_denorm`/`denorm_rebuild`). + > `Rekord.id` to tuple `(content_type_id, object_id)`. ⚠️ `content_type_id` bierz z `ContentType.objects.get_for_model(...)` — modele publikacji tego atrybutu NIE mają. Jeśli `Rekord`/`Autorzy` API różni się — dostosuj filtr po realnym kontrakcie `src/bpp/models/cache/`. Fixture `denorms` (z `src/conftest.py`) odpala denorm flush — sprawdź czy istnieje; jeśli nie, użyj właściwej fixtury cache z repo (`flush_denorm`/`denorm_rebuild`). - [ ] **Krok 5.2 — test: restore przywraca `*_Autor` po tym samym transaction_id.** ```python @pytest.mark.django_db diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md index e1dfa3a29..ed9edcc13 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-06-softdeletelog.md @@ -755,7 +755,11 @@ def on_post_restore(sender, instance, **kwargs): def test_soft_delete_kasuje_cache_punktacji(zwarte_z_dyscyplinami): zw = zwarte_z_dyscyplinami zw.przelicz_punkty_dyscyplin() - klucz = [zw.content_type_id, zw.pk] + # content_type NIE jest atrybutem modeli publikacji (property na + # RekordBase) -- bierzemy przez ContentType. + from django.contrib.contenttypes.models import ContentType + + klucz = [ContentType.objects.get_for_model(type(zw)).pk, zw.pk] assert Cache_Punktacja_Autora.objects.filter(rekord_id=klucz).exists() assert Cache_Punktacja_Dyscypliny.objects.filter(rekord_id=klucz).exists() diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md index ec610aad4..63dc408b4 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md @@ -193,6 +193,7 @@ robimy **wprost, surowym SQL-em** (wzorzec: Dopisz do `test_soft_delete_regresja_cache.py`: ```python +from django.contrib.contenttypes.models import ContentType from django.db import connection from bpp.models.cache.punktacja import Cache_Punktacja_Dyscypliny @@ -209,7 +210,10 @@ def test_reprojekcja_ze_zrodla_nie_wskrzesza_skasowanej_publikacji( bramkowanej kolumny i sprawdzamy, ze wiersz NIE wrocil z widoku. """ wc = wydawnictwo_ciagle_z_dwoma_autorami - pk, ct = wc.pk, wc.content_type_id + # content_type NIE jest atrybutem modeli publikacji (property na + # RekordBase, rekord.py:288) -- bierzemy przez ContentType. + pk = wc.pk + ct = ContentType.objects.get_for_model(type(wc)).pk wc.delete() assert Rekord.objects.count() == 0 @@ -233,7 +237,10 @@ def test_soft_delete_usuwa_cache_punktacji_dyscyplin(zwarte_z_dyscyplinami): zwarte_z_dyscyplinami.przelicz_punkty_dyscyplin() ct_pks = list( Cache_Punktacja_Dyscypliny.objects.filter( - rekord_id=[zwarte_z_dyscyplinami.content_type_id, zwarte_z_dyscyplinami.pk] + rekord_id=[ + ContentType.objects.get_for_model(type(zwarte_z_dyscyplinami)).pk, + zwarte_z_dyscyplinami.pk, + ] ).values_list("pk", flat=True) ) assert len(ct_pks) > 0 @@ -250,7 +257,7 @@ def test_soft_delete_usuwa_cache_punktacji_dyscyplin(zwarte_z_dyscyplinami): assert ( Cache_Punktacja_Dyscypliny.objects.filter( rekord_id=[ - zwarte_z_dyscyplinami.content_type_id, + ContentType.objects.get_for_model(type(zwarte_z_dyscyplinami)).pk, zwarte_z_dyscyplinami.pk, ] ).count() From ce6682e8554c54cc6367ca4417c4657e24744ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 14:37:19 +0200 Subject: [PATCH 14/75] =?UTF-8?q?feat(soft-delete):=20modu=C5=82=20soft=5F?= =?UTF-8?q?delete.py=20=E2=80=94=20gate=20na=20update()=20+=20managery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 fazy 01 wdrożenia soft-delete. Tworzy współdzielony fundament menedżerów dla całego wdrożenia (8 faz): - BppSoftDeleteQuerySet — gate blokujący bulk .update(deleted_at=...) / .update(restored_at=...), bo omijałoby post_save, kaskadę *_Autor, SoftDeleteLog i reversion. - BppSoftDeleteManager — domyślny manager, filtruje deleted_at__isnull=True. - BppGlobalManager — widzi wszystkie obiekty (usunięte i nie). Guard zależności (raise_if_has_protected_children) NIE jest częścią tego taska — dopisze go faza 04. Test test_managery_zwracaja_bpp_queryset użył Element_Repozytorium zamiast Wydawnictwo_Ciagle_Autor z brief-u — patrz task-1-report.md po uzasadnienie (Django resoluje pola przy .filter(), nie dopiero przy SQL). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- src/bpp/models/soft_delete.py | 36 +++++++++++ src/bpp/tests/test_soft_delete/__init__.py | 0 .../tests/test_soft_delete/test_managers.py | 63 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/bpp/models/soft_delete.py create mode 100644 src/bpp/tests/test_soft_delete/__init__.py create mode 100644 src/bpp/tests/test_soft_delete/test_managers.py diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py new file mode 100644 index 000000000..44913c249 --- /dev/null +++ b/src/bpp/models/soft_delete.py @@ -0,0 +1,36 @@ +"""Wspólny fundament soft-delete dla BPP: queryset-gate blokujący bulk +ustawienie deleted_at/restored_at + managery przepleciające filtr soft-delete +z naszą podklasą queryset (gate). Guard zależności (PROTECT) dokłada faza 04. +""" + +from django_softdelete.managers import ( + GlobalManager, + SoftDeleteManager, + SoftDeleteQuerySet, +) + + +class BppSoftDeleteQuerySet(SoftDeleteQuerySet): + """Gate: blokuje bulk-ustawienie deleted_at/restored_at przez .update() + (omijałoby post_save, kaskadę *_Autor, SoftDeleteLog i reversion).""" + + def update(self, **kwargs): + if "deleted_at" in kwargs or "restored_at" in kwargs: + raise RuntimeError( + "Nie ustawiaj deleted_at/restored_at przez .update() — " + "użyj .delete()/.restore(). Bulk update omija post_save, " + "kaskadę *_Autor, SoftDeleteLog i reversion." + ) + return super().update(**kwargs) + + +class BppSoftDeleteManager(SoftDeleteManager): + def get_queryset(self): + return BppSoftDeleteQuerySet(self.model, using=self._db).filter( + deleted_at__isnull=True + ) + + +class BppGlobalManager(GlobalManager): + def get_queryset(self): + return BppSoftDeleteQuerySet(self.model, using=self._db) diff --git a/src/bpp/tests/test_soft_delete/__init__.py b/src/bpp/tests/test_soft_delete/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/bpp/tests/test_soft_delete/test_managers.py b/src/bpp/tests/test_soft_delete/test_managers.py new file mode 100644 index 000000000..6e26bcd19 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_managers.py @@ -0,0 +1,63 @@ +"""Testy menedżerów i queryset-gate'a soft-delete.""" + +import pytest + +from bpp.models.soft_delete import ( + BppGlobalManager, + BppSoftDeleteManager, + BppSoftDeleteQuerySet, +) + + +def test_queryset_gate_blokuje_deleted_at(): + """update(deleted_at=...) musi rzucić RuntimeError (omija post_save, + kaskadę *_Autor, SoftDeleteLog i reversion).""" + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + + qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor) + with pytest.raises(RuntimeError, match="Nie ustawiaj deleted_at"): + qs.update(deleted_at="2026-06-04") + + +def test_queryset_gate_blokuje_restored_at(): + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + + qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor) + with pytest.raises(RuntimeError, match="Nie ustawiaj deleted_at"): + qs.update(restored_at="2026-06-04") + + +def test_queryset_gate_przepuszcza_inne_pola(): + """update() na zwykłym polu działa normalnie (nie rzuca).""" + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + + qs = BppSoftDeleteQuerySet(Wydawnictwo_Ciagle_Autor).none() + assert qs.update(kolejnosc=5) == 0 # pusty QS, ale nie rzuca + + +def test_managery_zwracaja_bpp_queryset(): + """Oba managery MUSZĄ zwracać BppSoftDeleteQuerySet — inaczej gate + na update() nie zadziała (pakietowy QuerySet go nie ma). + + Uwaga: brief zakładał użycie ``Wydawnictwo_Ciagle_Autor`` (model bez + kolumny ``deleted_at`` na tym etapie — dodaje ją dopiero Task 2) na + założeniu, że budowa queryset-u jest w pełni leniwa i nie odpali się + aż do iteracji. W praktyce Django resoluje nazwy pól już przy + wywołaniu ``.filter()`` (budowa drzewa WHERE), nie dopiero przy + wykonaniu SQL-a — więc ``BppSoftDeleteManager.get_queryset()`` (który + filtruje po ``deleted_at__isnull=True``) rzucał ``FieldError`` zanim + zdążył cokolwiek zwrócić. Używamy więc ``Element_Repozytorium`` — + istniejącego modelu, który już dziedziczy po + ``django_softdelete.models.SoftDeleteModel`` i naprawdę ma kolumny + ``deleted_at``/``restored_at`` w ``_meta`` — dzięki czemu ``.filter()`` + rozwiązuje się poprawnie bez dotykania bazy danych (nadal nie + iterujemy po wyniku, więc SQL się nie wykonuje).""" + from bpp.models.repozytorium import Element_Repozytorium + + for manager_cls in (BppSoftDeleteManager, BppGlobalManager): + manager = manager_cls() + manager.model = Element_Repozytorium + manager._db = None + assert isinstance(manager.get_queryset(), BppSoftDeleteQuerySet), ( + f"{manager_cls.__name__} nie zwraca BppSoftDeleteQuerySet" + ) From b4cf758dd616be1cb2395897a6f92cea49c8b166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 14:44:18 +0200 Subject: [PATCH 15/75] docs(soft-delete): korekta przeslanki o leniwym .filter() + precedens Element_Repozytorium Wykryte przy wykonaniu Task 1 fazy 01. Plan twierdzil, ze budowa queryset-u jest leniwa, wiec isinstance przejdzie na modelu bez kolumny deleted_at. Nieprawda: Django resolwuje nazwy pol juz w Query.build_filter(), przy wywolaniu .filter() -- odroczone jest tylko WYKONANIE zapytania, nie jego walidacja. Test na Wydawnictwo_Ciagle_Autor padalby FieldError, i to nie z powodu wadliwej implementacji. Test korzysta teraz z Element_Repozytorium (repozytorium.py:18) -- trzeciego precedensu SoftDeleteModel w repo, o ktorym spec nie wiedzial. Dopisany do sekcji precedensow razem z ostrzezeniem o Zgloszenie_Publikacji_Autor. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- ...-04-soft-delete-01-autor-trigger-widoki.md | 26 ++++++++++++++----- ...soft-delete-publikacje-i-autorzy-design.md | 10 ++++++- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md index 186b61df7..16f747717 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -111,22 +111,34 @@ Tworzy współdzielony fundament menedżerów dla całego wdrożenia. Guard zale def test_managery_zwracaja_bpp_queryset(): """Oba managery MUSZĄ zwracać BppSoftDeleteQuerySet — inaczej gate na update() nie zadziała (pakietowy QuerySet go nie ma).""" - from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + from bpp.models.repozytorium import Element_Repozytorium for manager_cls in (BppSoftDeleteManager, BppGlobalManager): manager = manager_cls() - manager.model = Wydawnictwo_Ciagle_Autor + manager.model = Element_Repozytorium manager._db = None assert isinstance(manager.get_queryset(), BppSoftDeleteQuerySet), ( f"{manager_cls.__name__} nie zwraca BppSoftDeleteQuerySet" ) ``` - ⚠️ **Uwaga:** `BppSoftDeleteManager.get_queryset()` filtruje po - `deleted_at__isnull=True`, a na tym etapie `Wydawnictwo_Ciagle_Autor` - **nie ma jeszcze** kolumny `deleted_at` (dodaje ją Task 2). Budowa - queryset-u jest leniwa (filtr nie odpala SQL-a), więc `isinstance` przejdzie - — ale **nie iteruj** po tym queryset-cie w tym teście. + > 🩹 **Poprawka 2026-08-06 (wykryta przy wykonaniu Task 1).** Pierwsza wersja + > używała tu `Wydawnictwo_Ciagle_Autor` z notatką „budowa queryset-u jest + > leniwa, więc `isinstance` przejdzie". **To nieprawda:** Django resolwuje + > nazwy pól już w `Query.build_filter()`, przy wywołaniu `.filter()`, a nie + > dopiero przy wykonaniu SQL-a. `BppSoftDeleteManager.get_queryset()` filtruje + > po `deleted_at__isnull=True`, więc na modelu bez tej kolumny rzuca + > `FieldError` **natychmiast** — test padłby, i to nie z powodu wadliwej + > implementacji. + > + > Dlatego bierzemy model, który JUŻ jest `SoftDeleteModel`: + > `Element_Repozytorium` (`src/bpp/models/repozytorium.py:18`) — trzeci + > precedens soft-delete w repo, obok `Zgłoszenie_Publikacji`. Test dalej + > weryfikuje dokładnie to, co ma: klasę zwracanego queryset-u. + > + > Ogólna zasada dla dalszych faz: **nie zakładaj, że `.filter()` jest + > odroczone** — walidacja pól jest natychmiastowa, odroczone jest tylko + > wykonanie zapytania. - [ ] Uruchom (oczekiwany FAIL — `ModuleNotFoundError: bpp.models.soft_delete`): ```bash diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md index d8aeed0cc..151f47662 100644 --- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md +++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md @@ -740,8 +740,16 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, ## 11. Precedensy w repo - `django-soft-delete>=1.0.23` — `pyproject.toml`. -- `src/zglos_publikacje/models.py` — `Zgłoszenie_Publikacji` już +- `src/zglos_publikacje/models.py:60` — `Zgłoszenie_Publikacji` już `SoftDeleteModel` (wzorzec). +- `src/bpp/models/repozytorium.py:18` — `Element_Repozytorium` też jest + `SoftDeleteModel` (drugi precedens, wykryty przy wykonaniu fazy 01). + Model bez własnych managerów — przydatny w testach jako gotowy nośnik + kolumn `deleted_at`/`restored_at`. +- ⚠️ `src/zglos_publikacje/models.py:315` — `Zgloszenie_Publikacji_Autor` + dziedziczy po `BazaModeluOdpowiedzialnosciAutorow`, ale jest **POZA + zakresem** soft-delete. Dlatego `SoftDeleteModel` wpinamy w 3 konkretne + through-modele, NIE w abstrakt (§2.2). - `src/pbn_export_queue/` — dojrzała kolejka PBN (model + Celery + admin + retry/lock), wzorzec dla operacji `WYCOFANIE` (wejście asynchroniczne). - `src/pbn_wysylka_oswiadczen/tasks.py:54-76` — wzorcowa obsługa wyjątków PBN From 6abf41438082f4d33e4423a17b7e4e7cd24b62e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 15:01:53 +0200 Subject: [PATCH 16/75] feat(soft-delete): *_Autor -> SoftDeleteModel + migracja pol deleted_at + indeks Wpiecie BppAutorstwoSoftDeleteMixin (SoftDeleteModel + nasze managery z Task 1) w 3 KONKRETNE through-modele: Wydawnictwo_Ciagle_Autor, Wydawnictwo_Zwarte_Autor, Patent_Autor. Abstrakt BazaModeluOdpowiedzialnosciAutorow celowo NIE ruszony -- ma czwartego potomka (Zgloszenie_Publikacji_Autor) poza zakresem soft-delete. Migracja 0488 dodaje deleted_at/restored_at/transaction_id (9 AddField) + indeks na deleted_at (3 AddIndex) dla 3 tabel. Indeksy zadeklarowane tez w Meta.indexes kazdego konkretnego modelu (nie w abstrakcyjnym mixinie, zeby uniknac kolizji nazw) -- inaczej makemigrations --check chcialby je co chwile usuwac. Dodatkowo: nadpisany restore() w mixinie z domyslnym strict=False. django-softdelete ma asymetryczne defaulty -- delete() domyslnie strict=False, restore() strict=True -- a strict=True wywala SoftDeleteException dla kazdej relacji (rowniez zwyklego forward FK) do modelu spoza SoftDeleteModel. *_Autor ma FK do Autor/Jednostka/ rekordu, ktore nie sa soft-delete w tej fazie, wiec bez tej poprawki kazde .restore() by padalo. Znany, oczekiwany efekt przejsciowy: 4 testy w test_cache/ (delete() na *_Autor nie czysci juz bpp_autorzy_mat/bpp_rekord_mat, bo DB-owe triggery/widoki jeszcze nie znaja deleted_at) -- to domena Task 3 (widoki + galaz kasujaca w funkcjach refresh + bramka WHEN), potwierdzona przez test_cache/test_soft_delete_preconditions.py i task-3-brief.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../0488_autor_soft_delete_fields.py | 78 +++++++++++++++++++ src/bpp/models/patent.py | 6 +- src/bpp/models/soft_delete.py | 33 ++++++++ src/bpp/models/wydawnictwo_ciagle.py | 5 ++ src/bpp/models/wydawnictwo_zwarte.py | 5 ++ .../test_autor_softdelete_model.py | 64 +++++++++++++++ 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/bpp/migrations/0488_autor_soft_delete_fields.py create mode 100644 src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py diff --git a/src/bpp/migrations/0488_autor_soft_delete_fields.py b/src/bpp/migrations/0488_autor_soft_delete_fields.py new file mode 100644 index 000000000..31263d767 --- /dev/null +++ b/src/bpp/migrations/0488_autor_soft_delete_fields.py @@ -0,0 +1,78 @@ +# Generated by Django 5.2.16 on 2026-08-06 12:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0487_api_v1_przelaczniki"), + ] + + operations = [ + migrations.AddField( + model_name="patent_autor", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="patent_autor", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="patent_autor", + name="transaction_id", + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name="wydawnictwo_ciagle_autor", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="wydawnictwo_ciagle_autor", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="wydawnictwo_ciagle_autor", + name="transaction_id", + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name="wydawnictwo_zwarte_autor", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="wydawnictwo_zwarte_autor", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="wydawnictwo_zwarte_autor", + name="transaction_id", + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddIndex( + model_name="wydawnictwo_ciagle_autor", + index=models.Index( + fields=["deleted_at"], + name="wc_autor_deleted_at_idx", + ), + ), + migrations.AddIndex( + model_name="wydawnictwo_zwarte_autor", + index=models.Index( + fields=["deleted_at"], + name="wz_autor_deleted_at_idx", + ), + ), + migrations.AddIndex( + model_name="patent_autor", + index=models.Index( + fields=["deleted_at"], + name="patent_autor_deleted_at_idx", + ), + ), + ] diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py index 49cfd1831..1d9013145 100644 --- a/src/bpp/models/patent.py +++ b/src/bpp/models/patent.py @@ -25,11 +25,12 @@ RekordBPPBaza, ) from bpp.models.autor import Autor +from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin from bpp.models.system import Charakter_Formalny, Jezyk from bpp.util import safe_tytul_html -class Patent_Autor(BazaModeluOdpowiedzialnosciAutorow): +class Patent_Autor(BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutorow): """Powiązanie autora do patentu.""" rekord = models.ForeignKey( @@ -51,6 +52,9 @@ class Meta: # Tu musi być autor, inaczej admin nie pozwoli wyedytować ("rekord", "autor", "kolejnosc"), ] + indexes = [ + models.Index(fields=["deleted_at"], name="patent_autor_deleted_at_idx"), + ] class _Patent_PropertyCache: diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py index 44913c249..0b618ad99 100644 --- a/src/bpp/models/soft_delete.py +++ b/src/bpp/models/soft_delete.py @@ -4,10 +4,12 @@ """ from django_softdelete.managers import ( + DeletedManager, GlobalManager, SoftDeleteManager, SoftDeleteQuerySet, ) +from django_softdelete.models import SoftDeleteModel class BppSoftDeleteQuerySet(SoftDeleteQuerySet): @@ -34,3 +36,34 @@ def get_queryset(self): class BppGlobalManager(GlobalManager): def get_queryset(self): return BppSoftDeleteQuerySet(self.model, using=self._db) + + +class BppAutorstwoSoftDeleteMixin(SoftDeleteModel): + """SoftDeleteModel + nasze managery dla through-modeli *_Autor. + + Wpinany w 3 KONKRETNE modele (Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte_Autor, Patent_Autor), NIE w abstrakt + BazaModeluOdpowiedzialnosciAutorow — ten ma czwartego potomka, + Zgloszenie_Publikacji_Autor, który jest poza zakresem soft-delete. + """ + + # Nadpisujemy managery pakietu naszymi (gate na update()). + # Kolejność: pierwszy zdefiniowany manager = _default_manager. + objects = BppSoftDeleteManager() + global_objects = BppGlobalManager() + deleted_objects = DeletedManager() + + class Meta: + abstract = True + + def restore(self, strict: bool = False, *args, **kwargs): + """`SoftDeleteModel.restore()` domyślnie ma `strict=True` — dla + KAŻDEJ relacji (nie tylko kaskadowanej) wymaga, żeby powiązany + model też był `SoftDeleteModel`, inaczej rzuca + `SoftDeleteException`. `*_Autor` ma zwykłe FK do `Autor`, + `Jednostka` i rekordu (żaden z nich nie jest soft-delete w tej + fazie), więc strict=True wywaliłoby każde `.restore()`. + `delete()` z tego samego pakietu domyślnie ma `strict=False` — + ujednolicamy `restore()` do tego samego domyślnego zachowania. + """ + return super().restore(*args, strict=strict, **kwargs) diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py index 9584b4eab..abd895bde 100644 --- a/src/bpp/models/wydawnictwo_ciagle.py +++ b/src/bpp/models/wydawnictwo_ciagle.py @@ -46,12 +46,14 @@ ModelZWWW, Wydawnictwo_Baza, ) +from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin from bpp.models.system import Zewnetrzna_Baza_Danych from bpp.models.util import ZapobiegajNiewlasciwymCharakterom class Wydawnictwo_Ciagle_Autor( DirtyFieldsMixin, + BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutorow, ): """Powiązanie autora do wydawnictwa ciągłego.""" @@ -75,6 +77,9 @@ class Meta: # Tu musi być autor, inaczej admin nie pozwoli wyedytować ("rekord", "autor", "kolejnosc"), ] + indexes = [ + models.Index(fields=["deleted_at"], name="wc_autor_deleted_at_idx"), + ] class ModelZOpenAccessWydawnictwoCiagle(ModelZOpenAccess): diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py index f0ffac5ea..e636ec980 100644 --- a/src/bpp/models/wydawnictwo_zwarte.py +++ b/src/bpp/models/wydawnictwo_zwarte.py @@ -53,6 +53,7 @@ ) from bpp.models.autor import Autor from bpp.models.nagroda import Nagroda +from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin from bpp.models.system import Zewnetrzna_Baza_Danych from bpp.models.util import ZapobiegajNiewlasciwymCharakterom from bpp.models.wydawca import Wydawca @@ -60,6 +61,7 @@ class Wydawnictwo_Zwarte_Autor( DirtyFieldsMixin, + BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutorow, ): """Model zawierający informację o przywiązaniu autorów do wydawnictwa @@ -84,6 +86,9 @@ class Meta: # Tu musi być autor, inaczej admin nie pozwoli wyedytować ("rekord", "autor", "kolejnosc"), ] + indexes = [ + models.Index(fields=["deleted_at"], name="wz_autor_deleted_at_idx"), + ] MIEJSCE_I_ROK_MAX_LENGTH = 256 diff --git a/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py b/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py new file mode 100644 index 000000000..e8829b9bd --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py @@ -0,0 +1,64 @@ +"""*_Autor jako SoftDeleteModel: pola, managery, soft-delete/restore per +instancja (bez sprawdzania cache — to Task 4).""" + +import pytest +from django_softdelete.models import SoftDeleteModel + +from bpp.models.patent import Patent_Autor +from bpp.models.soft_delete import BppGlobalManager, BppSoftDeleteManager +from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor +from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte_Autor + +THROUGH_MODELE = [ + Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte_Autor, + Patent_Autor, +] + + +@pytest.mark.parametrize("klass", THROUGH_MODELE) +def test_through_jest_softdeletemodel(klass): + assert issubclass(klass, SoftDeleteModel) + + +@pytest.mark.parametrize("klass", THROUGH_MODELE) +def test_through_ma_pola_soft_delete(klass): + nazwy = {f.name for f in klass._meta.get_fields()} + assert {"deleted_at", "restored_at", "transaction_id"} <= nazwy + + +@pytest.mark.parametrize("klass", THROUGH_MODELE) +def test_through_ma_nasze_managery(klass): + assert isinstance(klass.objects, BppSoftDeleteManager) + assert isinstance(klass.global_objects, BppGlobalManager) + + +@pytest.mark.django_db +def test_soft_delete_ukrywa_w_objects_widoczne_w_global( + wydawnictwo_ciagle_z_autorem, +): + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + pk = wca.pk + wca.delete() + assert not Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists() + assert Wydawnictwo_Ciagle_Autor.global_objects.filter(pk=pk).exists() + assert Wydawnictwo_Ciagle_Autor.deleted_objects.filter(pk=pk).exists() + + +@pytest.mark.django_db +def test_restore_przywraca_do_objects(wydawnictwo_ciagle_z_autorem): + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + pk = wca.pk + wca.delete() + Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore() + assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists() + + +def test_zgloszenie_publikacji_autor_nie_jest_soft_delete(): + """Czwarty potomek abstraktu jest POZA zakresem soft-delete.""" + from django_softdelete.models import SoftDeleteModel + + from zglos_publikacje.models import Zgloszenie_Publikacji_Autor + + assert not issubclass(Zgloszenie_Publikacji_Autor, SoftDeleteModel) + assert not hasattr(Zgloszenie_Publikacji_Autor, "global_objects") From 126214cbf5ac77acb989510ffb824c686e32f380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 15:19:19 +0200 Subject: [PATCH 17/75] fix(soft-delete): test_wca_delete_cache wolal filter_rekord() ze zla instancja filter_rekord() oczekuje obiektu Rekord (pk = krotka (content_type_id, object_id) obslugiwana przez TupleField), nie Wydawnictwo_Ciagle_Autor (pk = zwykly int autoinkrementowany). Test podawal 'aca' (through-row) zamiast juz dostepnego 'r' (Rekord). Przed Taskiem 2 .delete() na *_Autor bylo hard-delete, wiec Django zerowalo aca.pk -> filter(rekord_id=None) -> "IS NULL" -> 0 wierszy -> test przechodzil PRZYPADKIEM, mimo zlego wywolania. Po wpieciu SoftDeleteModel .delete() jest miekkie, aca.pk zostaje liczba -> porownanie integer[] = integer w Postgresie -> UndefinedFunction, zanim tresc bpp_autorzy_mat zaczela miec jakiekolwiek znaczenie. Po poprawce test pada na wlasciwej asercji o liczbie wierszy w cache (2 == 0) -- oczekiwane do czasu Taska 3 (galaz kasujaca w funkcjach refresh + widoki znajace deleted_at), nie na bledzie SQL. Zgloszone przez coordinatora w rundzie poprawek 1/5 do Taska 2. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- src/bpp/tests/test_cache/test_cache.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bpp/tests/test_cache/test_cache.py b/src/bpp/tests/test_cache/test_cache.py index d5ca392d9..a878f9223 100644 --- a/src/bpp/tests/test_cache/test_cache.py +++ b/src/bpp/tests/test_cache/test_cache.py @@ -194,7 +194,9 @@ def test_wca_delete_cache(wydawnictwo_ciagle_z_dwoma_autorami, denorms): denorms.flush() - assert Autorzy.objects.filter_rekord(aca).count() == 0 + # filter_rekord() oczekuje obiektu Rekord (pk = krotka (ct, object_id)), + # nie Wydawnictwo_Ciagle_Autor (pk = zwykły int) — stąd `r`, nie `aca`. + assert Autorzy.objects.filter_rekord(r).count() == 0 assert Rekord.objects.all().count() == 1 r = Rekord.objects.all()[0] From b1e18af13805c066fa549fa2cf9983da255c7f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 15:25:46 +0200 Subject: [PATCH 18/75] docs(soft-delete): restore(strict=False) jako kontrakt PINNED Wykryte przy wykonaniu fazy 01 Task 2. SoftDeleteModel.restore() ma domyslnie strict=True i sprawdza KAZDE pole z related_model -- takze zwykle FK w przod -- zanim rozroznii typ relacji. Poniewaz *_Autor ma FK do Autor i Jednostka (nie-soft), gole .restore() rzuca SoftDeleteException i nie da sie przywrocic niczego. Kontrakt: kazdy model soft-delete w BPP nadpisuje restore() ze strict=False. Ostrzezenie dopisane takze do fazy 04 -- Autor ma FK do Tytul itd., wiec trafilby na dokladnie ten sam blad przy przywracaniu husku, i test padlby nie z powodu wadliwego guardu. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../plans/2026-06-04-soft-delete-00-overview.md | 17 +++++++++++++++++ .../2026-06-04-soft-delete-04-guardy-protect.md | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md index 0131bed39..f9dedf8c7 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md @@ -69,6 +69,23 @@ Szczegóły i uzasadnienie: §2.1 specu. - `SoftDeleteQuerySet.delete()` iteruje per-instancję (`for obj in self.iterator(): obj.delete()`) — bezpieczny dla sygnałów. **NIE** robi bulk update. - Sygnały (`django_softdelete/signals.py`): `post_soft_delete`, `post_hard_delete`, `post_restore`. - ⚠️ **`strict` jest ASYMETRYCZNE:** `delete(strict=False)`, ale `restore(strict=True)`. Czyli domyślna kaskada `delete()` **nie krzyknie** na nie-soft dzieciach — po cichu po nich przejedzie. Kolejny powód, by nie polegać na kaskadzie pakietu (§2.2 specu). +- 🔴 **KONSEKWENCJA `restore(strict=True)` — wykryta przy wykonaniu fazy 01, Task 2.** + Pakiet sprawdza `strict` w pętli po **WSZYSTKICH** polach z `related_model`, + zanim rozróżni typ relacji — więc także po **zwykłych FK w przód** + (`django_softdelete/models.py`, pętla w `restore()`): + ```python + for field in self._meta.get_fields(): + RelatedModel = field.related_model + if not RelatedModel: continue + if strict and not issubclass(RelatedModel, SoftDeleteModel): + raise SoftDeleteException(...) + ``` + `*_Autor` ma FK do `Autor` i `Jednostka` (nie-soft), więc **gołe + `.restore()` rzuca `SoftDeleteException` i nie da się przywrócić niczego.** + **Kontrakt PINNED: każdy model soft-delete w BPP nadpisuje `restore()` + z `strict=False`** (albo pisze własne, jak mixin publikacji w fazie 02). + Dotyczy fazy 01 (`BppAutorstwoSoftDeleteMixin`) i fazy 04 (`Autor` — ma FK + do `Tytul`, `Funkcja_Autora` itd., więc trafi na to samo). - ⚠️ **`delete()` zapisuje przez `save(update_fields=['deleted_at','restored_at','transaction_id'])`** — to jest przyczyna, dla której bramka `WHEN` triggera cache nie przepuszcza soft-delete (i dla której `ostatnio_zmieniony`/`auto_now` NIE jest bumpowany). ### Nowy moduł `src/bpp/models/soft_delete.py` (tworzy faza 01) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md b/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md index 8b2042427..5a242148d 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md @@ -22,6 +22,12 @@ - **Max długość linii 88 znaków** (ruff). Komentarze/komunikaty po polsku. - **Testy:** pytest-only, standalone funkcje (bez klas `unittest.TestCase`), `@pytest.mark.django_db`, `model_bakery.baker.make`. Fixtury z `src/fixtures/` (`autor_jan_nowak`, `autor_jan_kowalski`, `jednostka`, `typy_odpowiedzialnosci`, `wydawnictwo_zwarte`, `wydawnictwo_ciagle`, `praca_doktorska`, `patent`). - **Kontrakt z reversion (NIE łamać):** override `delete()`/`restore()` idzie per-instancja przez `self.save()` / `super().delete()` — **nigdy** bulk `queryset.update(deleted_at=...)`. Faza 04 nie ustawia `deleted_at` ręcznie; deleguje do `super().delete()` pakietu. +- 🔴 **`Autor.restore()` MUSI nadpisać `strict` na `False`** (wykryte przy wykonaniu fazy 01, Task 2 — patrz kontrakty PINNED w indeksie 00). `SoftDeleteModel.restore()` ma domyślnie `strict=True` i sprawdza **każde** pole z `related_model` — także zwykłe FK w przód — zanim rozróżni typ relacji. `Autor` ma FK m.in. do `Tytul`, więc gołe `Autor.restore()` rzuci `SoftDeleteException` i **przywrócenie husku będzie niemożliwe**. Wzorzec z fazy 01: + ```python + def restore(self, strict: bool = False, *args, **kwargs): + return super().restore(*args, strict=strict, **kwargs) + ``` + Bez tego test przywrócenia husku autora NIE przejdzie — i to nie z powodu wadliwego guardu. - **Po każdym zadaniu:** `ruff format src/bpp` + `ruff check src/bpp` (tylko zmienione), komenda testu z zadania na zielono, commit. ## Stan zweryfikowany w kodzie (punkt wyjścia) From c312cfa8a50fba00b4b01b7f77f71bef5f1c69c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 15:43:54 +0200 Subject: [PATCH 19/75] docs(soft-delete): Task 3b (faza 01) i 2c (faza 02) -- bramka denorm Recenzja Taska 2 fazy 01 wykryla Critical: BPP ma DWA niezalezne systemy triggerow na tych samych tabelach, oba z bramka po liscie kolumn. 1. nasz cache _mat: _cache_upd, bramka z pg_depend (0433) -- Task 3; 2. django-denorm: d_aft_row_upd_on_*, bramka z list only= w depend_on_related (denorm/db/triggers.py:102-118) -- NIE byla objeta zadnym taskiem. Zadna z 15 zaleznosci celujacych w *_Autor nie wymienia deleted_at, wiec UPDATE soft-delete nie odpala triggera denorm, a stary AFTER DELETE nie ma juz czego lapac. Skutek: opis_bibliograficzny_cache, slug i cached_punkty_dyscyplin zostaja nieswieze NA STALE -- publiczna strona pokazuje usunietego autora, a punkty dyscyplin dalej go licza. Task 3b (faza 01) dodaje denorm_always_only na 3 modelach *_Autor, z obowiazkowa weryfikacja wstepna: laczenie listy only z denorm_always_only, w polaczeniu z galezia "brak only -> obserwuj wszystkie pola", ZAWEZILOBY zaleznosc pozbawiona only do samego deleted_at. Zweryfikowane: wszystkie 15 ma jawne only, wiec dodanie jest addytywne -- ale krok kaze to powtorzyc. Task 2c (faza 02) to samo dla 5 modeli publikacji, z pytaniem o self-FK wydawnictwo_nadrzedne (byc moze nieosiagalne przez guard PROTECT z fazy 04). Dodana tez jawna tabela kolejnosci wykonania fazy 02 -- taski dopisywane po rewizjach wyladowaly poza numeracja dokumentu. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- ...-04-soft-delete-01-autor-trigger-widoki.md | 117 ++++++++++++++++++ .../2026-06-04-soft-delete-02-publikacje.md | 56 +++++++++ 2 files changed, 173 insertions(+) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md index 16f747717..edd46c0d7 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -721,6 +721,123 @@ Co-Authored-By: Claude Opus 5 (1M context) " --- +## Task 3b — Bramka denorm: `deleted_at` w `depend_on_related` (DRUGI system triggerów) + +> 🔴 **Dodane 2026-08-06** po recenzji Taska 2. Luka w zakresie, nie defekt +> implementacji — żaden task faz 01-05 tego nie obejmował. + +**BPP ma DWA niezależne systemy triggerów na tych samych tabelach**, oba +z bramką po liście kolumn: + +| System | Trigger | Bramka budowana z | Naprawia | +|---|---|---|---| +| cache `_mat` (nasz) | `_cache_upd` | `pg_depend` (migracja `0433`) | Task 3 | +| `django-denorm` | `d_aft_row_upd_on__` | listy `only=` w `@depend_on_related` | **ten task** | + +Task 3 naprawia wyłącznie pierwszy. Drugi zostaje ślepy na `deleted_at`, więc +**pola denormalizowane rodzica pozostają nieświeże NA STAŁE** po soft-delete +autorstwa (aż do ręcznego `denorm.rebuildall`). + +**Dowód (zweryfikowany w źródle pakietu i empirycznie):** +`denorm/db/triggers.py:102-118` buduje `WHEN (OLD.f IS DISTINCT FROM NEW.f OR …)` +z `self.fields`, a te pochodzą z `only=`: +```python +# denorm/db/base.py:136,167 +only = (only or ()) + getattr(self.model, "denorm_always_only", ()) +... +self.fields = [... for k, v in fields_with_model + if not v and k.attname not in skip and k.attname in only] +``` +Żadna z 15 zależności na `*_Autor` nie wymienia `deleted_at`, więc UPDATE +ruszający tylko `deleted_at`/`restored_at`/`transaction_id` **nie odpala +żadnego triggera denorm**, a stary `AFTER DELETE` nie ma już czego łapać. + +**Co zostaje nieświeże:** `opis_bibliograficzny_cache`, +`opis_bibliograficzny_autorzy_cache`, +`opis_bibliograficzny_zapisani_autorzy_cache`, `slug` oraz +**`cached_punkty_dyscyplin`** — czyli publiczna strona pokazuje usuniętego +autora, a punkty dyscyplin dalej go liczą. + +**Files:** +- Modify: `src/bpp/models/wydawnictwo_ciagle.py`, `wydawnictwo_zwarte.py`, `patent.py` + (3 klasy `*_Autor` — dodanie `denorm_always_only`) +- Test: `src/bpp/tests/test_soft_delete/test_denorm_gate.py` + +**Steps:** + +- [ ] **Krok 3b.1 — padający test.** Sprawdza mechanizm END-TO-END, nie samą + obecność stringu w DDL: + ```python + @pytest.mark.django_db + def test_soft_delete_autorstwa_odswieza_opis_biblio( + wydawnictwo_ciagle_z_autorem, denorms + ): + """Soft-delete autorstwa MUSI unieważnić denorm-cache rodzica. + + Drugi system triggerów (django-denorm) ma własną bramkę WHEN po liście + `only=` — bez `deleted_at` UPDATE soft-delete jej nie przechodzi i opis + zostaje nieświeży NA STAŁE. + """ + wc = wydawnictwo_ciagle_z_autorem + denorms.flush() + wc.refresh_from_db() + assert "KOWALSKI" in wc.opis_bibliograficzny_cache + + wc.autorzy_set.first().delete() + denorms.flush() + wc.refresh_from_db() + + assert "KOWALSKI" not in wc.opis_bibliograficzny_cache, ( + "denorm-cache nieświeży — bramka WHEN triggera denorm nie zna " + "deleted_at (denorm_always_only)" + ) + ``` + (nazwisko dopasuj do realnej fixtury) + +- [ ] **Krok 3b.2 — implementacja.** Dodaj do KAŻDEJ z 3 klas `*_Autor` + atrybut klasowy: + ```python + # django-denorm buduje bramkę WHEN triggera z listy `only=` w + # @depend_on_related. Bez deleted_at soft-delete autorstwa nie + # unieważniłby denorm-cache rodzica (opis bibliograficzny, slug, + # cached_punkty_dyscyplin) — zostałby nieświeży na stałe. + denorm_always_only = ("deleted_at",) + ``` + + ⚠️ **Dlaczego to bezpieczne akurat tutaj — SPRAWDŹ PRZED ZMIANĄ.** + `only = (only or ()) + denorm_always_only` w połączeniu z późniejszym + `if only: … else: only = ` znaczy, że dodanie + `denorm_always_only` do modelu, którego zależność **nie ma** `only=`, + **ZAWĘZI** ją z „wszystkie kolumny" do „tylko `deleted_at`" — czyli + wyłączy istniejące odświeżanie. Zweryfikowano 2026-08-06: wszystkie **15** + zależności celujących w `*_Autor` mają jawne `only=`, więc dodanie jest + czysto addytywne. **Powtórz tę weryfikację**, zanim zmienisz kod: + ```bash + grep -rn --include='*.py' -A4 "depend_on_related" src/ | grep -B1 -A4 "_Autor\"" + ``` + Jeśli znajdziesz choć jedną bez `only=` — NIE używaj `denorm_always_only`; + zamiast tego dopisz `"deleted_at"` do każdej listy `only=` ręcznie. + +- [ ] **Krok 3b.3 — przeinstaluj triggery denorm.** Zmiana list `only=` nie + zmienia DDL sama z siebie. Ustal, jak repo instaluje triggery denorm + (`grep -rn "denorm_init\|denorm_rebuild\|install_triggers" src/ Makefile`) + i wykonaj właściwy krok; jeśli triggery są zakładane migracją — dodaj nową + migrację, **nie modyfikuj istniejącej**. Zweryfikuj DDL: + ```sql + SELECT tgname, pg_get_triggerdef(oid) FROM pg_trigger + WHERE tgrelid='bpp_wydawnictwo_ciagle_autor'::regclass AND NOT tgisinternal; + ``` + Bramka `d_aft_row_upd_on_*` MUSI zawierać `deleted_at`. + +- [ ] **Krok 3b.4 — PASS + regresja.** `uv run pytest src/bpp/tests/test_cache/ + src/bpp/tests/test_soft_delete/ -q`. Test `test_opis_bibliograficzny_dependent` + i denorm-owe asercje w `test_wca_delete_cache` powinny się zazielenić + właśnie tutaj (Task 3 ich NIE naprawia — recenzja Taska 2 to potwierdziła). + +- [ ] **Krok 3b.5 — commit** + newsfragment jeśli jeszcze nie ma. + +--- + ## Task 4 — Testy spójności cache (mat-view) po soft-delete `*_Autor` Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` (model `Autorzy`) i z `bpp_autorzy` (model `AutorzyView`); restore → wraca; edycja autorstwa skasowanej publikacji nie wskrzesza wiersza w cache; kaskada queryset-owa (`.delete()` na QS) działa per-instancja. Testy wymagają `transactional_db` (trigger działa tylko z prawdziwym commitem). diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md index 0905d536f..804667812 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md @@ -12,6 +12,28 @@ --- +## ⚠️ KOLEJNOŚĆ WYKONANIA (taski NIE stoją w dokumencie w kolejności wykonania) + +Taski dopisywane po rewizjach wylądowały poza numeracją. Wykonuj w TEJ kolejności: + +| # | Task | Dlaczego tu | +|---|---|---| +| 1 | Task 1 — mixin `delete()`/`restore()` | fundament | +| 2 | Task 2 — 5 modeli + migracja pól | kolumny muszą istnieć przed DDL | +| 3 | **Task 2b** — widoki + gałąź kasująca + bramka `WHEN` | czyta `deleted_at` z kroku 2 | +| 4 | **Task 2c** — bramka denorm | drugi system triggerów | +| 5 | Task 3 — `slug` warunkowy unique | niezależny | +| 6 | **Task 3b** — `unique_together` na `*_Autor` | niezależny | +| 7 | Task 4 — przeplecenie menedżerów | — | +| 8 | Task 5 — testy integracyjne | wymaga 1-7 | +| 9 | Task 6 — weryfikacja fazy | ostatni | + +**Numery migracji nadawaj sekwencyjnie w KOLEJNOŚCI WYKONANIA**, nie wg numerów +z nagłówków tasków (te są orientacyjne i już się rozjechały). Przed każdą +migracją: `ls src/bpp/migrations/*.py | tail -3`. + +--- + ## Założenia wejściowe (z fazy 01 — VERBATIM, nie zmieniać) - `src/bpp/models/soft_delete.py` istnieje i eksportuje `BppSoftDeleteQuerySet`, `BppSoftDeleteManager`, `BppGlobalManager` (kod w indeksie §"Nowy moduł"). @@ -400,6 +422,40 @@ i `_create_delete_rekord_function` w `0432`). --- +## Task 2c: Bramka denorm dla 5 modeli publikacji (DRUGI system triggerów) + +> 🔴 **Dodane 2026-08-06** po recenzji fazy 01 Task 2. Analogia do Taska 3b +> fazy 01 — tam dla `*_Autor`, tu dla samych publikacji. + +`django-denorm` ma własną bramkę `WHEN` budowaną z list `only=` w +`@depend_on_related` (`denorm/db/triggers.py:102-118`, `denorm/db/base.py:136`). +Jest to system NIEZALEŻNY od naszych triggerów `_cache_upd` z `0432`/`0433`. + +Dla publikacji dochodzi drugi wariant zależności — `@depend_on_related("self", +"wydawnictwo_nadrzedne")` (rozdziały → książka-matka, `wydawnictwo_zwarte.py`). +Soft-delete książki-matki musi unieważnić denorm-cache rozdziałów. + +**Steps:** + +- [ ] Wypisz WSZYSTKIE `@depend_on_related` celujące w 5 modeli publikacji + (także `"self"`) i sprawdź, które mają `only=`, a które nie: + ```bash + grep -rn --include='*.py' -A5 "depend_on_related" src/ | grep -B2 -A5 "self\|Wydawnictwo_\|Patent\|Praca_" + ``` + ⚠️ Zależność **bez** `only=` + `denorm_always_only` = ZAWĘŻENIE do samego + `deleted_at` (patrz ostrzeżenie w fazie 01 Task 3b). Jeśli choć jedna taka + jest — dopisz `"deleted_at"` do list `only=` ręcznie zamiast używać + `denorm_always_only`. +- [ ] Test end-to-end: soft-delete książki-matki → denorm-cache rozdziału + (`opis_bibliograficzny_cache` zawierający tytuł nadrzędny) przestaje być + aktualny? Rozstrzygnij, czy to w ogóle pożądane — guard PROTECT z fazy 04 + i tak zablokuje soft-delete książki z rozdziałami, więc być może ten + przypadek jest nieosiągalny. **Jeśli nieosiągalny — zapisz to i pomiń**, + nie dokładaj martwego kodu. +- [ ] Przeinstaluj triggery denorm i zweryfikuj DDL (`pg_get_triggerdef`). + +--- + ## Task 3b: `unique_together` na `*_Autor` → warunkowy `UniqueConstraint` (decyzja #13) > Dodane 2026-08-06 (spec §2.2b). Ten sam problem co ze slugiem, przeoczony From 5ec77ca3b163c74ecbd4d5c58f303b5654ce074c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 15:57:54 +0200 Subject: [PATCH 20/75] fix(soft-delete): restore() na 3 sciezkach (instancja/deleted_objects/global_objects) Runda poprawek 2/5 do Taska 2 (recenzja koordynatora, Important). PROBLEM: BppAutorstwoSoftDeleteMixin.restore() mial strict=False jako DOMYSLNY parametr, ale django_softdelete wola go z JAWNYM strict=True z poziomu querysetow: - DeletedQuerySet.restore() (deleted_objects) -> obj.restore(strict=True) - GlobalQuerySet.restore() -- global_objects w BPP w ogole nie mial tej metody (BppGlobalManager zwraca BppSoftDeleteQuerySet, ktory dziedziczy z SoftDeleteQuerySet bez restore()) -> AttributeError Efekt: Wydawnictwo_Ciagle_Autor.deleted_objects.restore() i .global_objects.restore() padaly, mimo poprawki na poziomie instancji. ROZWIAZANIE (wariant b z dwoch zaproponowanych przez koordynatora -- czysciejszy architektonicznie): rozszerzenie PINNED kontraktu o BppDeletedQuerySet/BppDeletedManager (nowa czwarta/piata klasa, jawnie udokumentowana w docstringu modulu jako czesc kontraktu) + restore() dopisane do juz-PINNED BppSoftDeleteQuerySet (ta sama klasa, ktora BppGlobalManager juz zwracal dla gate'u update() -- test Taska 1 test_managery_zwracaja_bpp_queryset sprawdza dokladnie to i pozostaje zielony bez zmian). Oba nowe restore() maja strict=False domyslnie, zgodnie z inwariantem zapisanym teraz w docstringu modulu. Skorygowana tez sygnatura instancyjnego restore(): odwzorowuje rodzica (strict, transaction_id, *args, **kwargs) i przekazuje pozycyjnie do super() -- poprzednia wersja (super().restore(*args, strict=strict, **kwargs)) dawala TypeError: got multiple values for argument 'strict' przy wywolaniu pozycyjnym typu obj.restore(False, txid). Dodane testy (8, wszystkie PASS) pokrywajace: - restore() z kazdej z 3 sciezek nie rzuca SoftDeleteException, - wc.autorzy_set.all() faktycznie ukrywa soft-deletowane (reverse FK manager dziedziczy z _default_manager), - deleted_objects jest DeletedManager i zwraca tylko skasowane, - gate .update(deleted_at=...) rzuca RuntimeError na realnym queryset-cie konkretnego modelu (Task 1 testowal to tylko w izolacji klasy). Dodany tez newsfragment (brakowal w commicie feature -- wymog CLAUDE.md). Weryfikacja: uv run pytest src/bpp/tests/test_soft_delete/ src/bpp/tests/test_cache/ -q -> 24 passed w test_soft_delete/ (bylo 16), 97 passed / 4 failed (te same znane, zakres Task 3) / 1 skipped w sumie obu katalogow. uv run ruff check src/bpp/models/soft_delete.py src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py -> czyste. Szerszy sweep (test_models/test_wydawnictwo_autor.py, test_models_legacy.py, test_admin.py) -> 209 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- src/bpp/models/soft_delete.py | 77 ++++++++++++++++++- .../soft-delete-autorstwo.feature.rst | 6 ++ .../test_autor_softdelete_model.py | 70 +++++++++++++++++ 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/bpp/newsfragments/soft-delete-autorstwo.feature.rst diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py index 0b618ad99..47be9d8b4 100644 --- a/src/bpp/models/soft_delete.py +++ b/src/bpp/models/soft_delete.py @@ -1,10 +1,33 @@ """Wspólny fundament soft-delete dla BPP: queryset-gate blokujący bulk ustawienie deleted_at/restored_at + managery przepleciające filtr soft-delete z naszą podklasą queryset (gate). Guard zależności (PROTECT) dokłada faza 04. + +INWARIANT (dot. ``restore()``): żadna ścieżka przywracania w BPP — instancja +(``obj.restore()``), ``deleted_objects`` ani ``global_objects`` — nie może +rzucić ``SoftDeleteException`` tylko dlatego, że model powiązany (np. +``Autor``, ``Jednostka``, rekord) nie jest sam ``SoftDeleteModel``. Pakiet +``django_softdelete`` ma tu domyślnie ``strict=True`` na wszystkich trzech +ścieżkach (asymetrycznie względem ``delete()``, który domyślnie ma +``strict=False``) — w BPP wymuszamy domyślne ``strict=False`` wszędzie: + +- instancja: nadpisany ``BppAutorstwoSoftDeleteMixin.restore()``, +- ``global_objects``: ``BppSoftDeleteQuerySet.restore()`` (ta sama klasa + queryset, którą już zwracał ``BppGlobalManager`` dla gate'u ``update()``), +- ``deleted_objects``: KONTRAKT ROZSZERZONY o czwartą klasę, + ``BppDeletedQuerySet``/``BppDeletedManager`` (pakietowy ``DeletedManager`` + zwracał ``DeletedQuerySet`` bez naszego nadpisania — to samo + ``strict=True`` co wyżej). + +Wszystkie cztery klasy (``BppSoftDeleteQuerySet``, ``BppSoftDeleteManager``, +``BppGlobalManager``, ``BppDeletedQuerySet``/``BppDeletedManager``) razem z +``BppAutorstwoSoftDeleteMixin`` są PINNED — Task 1 zdefiniował pierwszą +trójkę, Task 2 (runda poprawek 2) dopisał ``BppDeletedQuerySet``/ +``BppDeletedManager`` jako część tego samego kontraktu. """ from django_softdelete.managers import ( DeletedManager, + DeletedQuerySet, GlobalManager, SoftDeleteManager, SoftDeleteQuerySet, @@ -25,6 +48,22 @@ def update(self, **kwargs): ) return super().update(**kwargs) + def restore(self, strict: bool = False, *args, **kwargs): + """Przywraca wiersze querysetu (ścieżka ``global_objects``). + + Bazowy ``SoftDeleteQuerySet`` (pakiet) w ogóle nie ma ``restore()`` + — dodajemy go tutaj, żeby ``global_objects.restore()`` (via + ``BppGlobalManager``, który zwraca tę właśnie klasę) nie rzucał + ``AttributeError``. Domyślne ``strict=False`` — patrz inwariant w + docstringu modułu. + """ + qs = self.filter(*args, **kwargs) + for obj in qs: + obj.restore(strict=strict) + return + + restore.alters_data = True + class BppSoftDeleteManager(SoftDeleteManager): def get_queryset(self): @@ -38,6 +77,30 @@ def get_queryset(self): return BppSoftDeleteQuerySet(self.model, using=self._db) +class BppDeletedQuerySet(DeletedQuerySet): + """``DeletedQuerySet`` (pakiet) z domyślnym ``strict=False`` w + ``restore()`` — patrz inwariant w docstringu modułu. Bez tego + ``deleted_objects.restore()`` woła jawne ``strict=True`` (kod pakietu), + co nadpisywałoby domyślną wartość z ``BppAutorstwoSoftDeleteMixin. + restore()`` i rzucało ``SoftDeleteException``. + """ + + def restore(self, strict: bool = False, *args, **kwargs): + qs = self.filter(*args, **kwargs) + for obj in qs: + obj.restore(strict=strict) + return + + restore.alters_data = True + + +class BppDeletedManager(DeletedManager): + def get_queryset(self): + return BppDeletedQuerySet(self.model, using=self._db).filter( + deleted_at__isnull=False + ) + + class BppAutorstwoSoftDeleteMixin(SoftDeleteModel): """SoftDeleteModel + nasze managery dla through-modeli *_Autor. @@ -51,12 +114,14 @@ class BppAutorstwoSoftDeleteMixin(SoftDeleteModel): # Kolejność: pierwszy zdefiniowany manager = _default_manager. objects = BppSoftDeleteManager() global_objects = BppGlobalManager() - deleted_objects = DeletedManager() + deleted_objects = BppDeletedManager() class Meta: abstract = True - def restore(self, strict: bool = False, *args, **kwargs): + def restore( + self, strict: bool = False, transaction_id: str = None, *args, **kwargs + ): """`SoftDeleteModel.restore()` domyślnie ma `strict=True` — dla KAŻDEJ relacji (nie tylko kaskadowanej) wymaga, żeby powiązany model też był `SoftDeleteModel`, inaczej rzuca @@ -65,5 +130,11 @@ def restore(self, strict: bool = False, *args, **kwargs): fazie), więc strict=True wywaliłoby każde `.restore()`. `delete()` z tego samego pakietu domyślnie ma `strict=False` — ujednolicamy `restore()` do tego samego domyślnego zachowania. + + Sygnatura celowo odwzorowuje rodzica (``strict``, ``transaction_id`` + jako pierwsze dwa parametry) — przekazywanie ich pozycyjnie do + ``super().restore()`` unika ``TypeError: got multiple values for + argument 'strict'`` przy wywołaniu pozycyjnym typu + ``obj.restore(False, txid)``. """ - return super().restore(*args, strict=strict, **kwargs) + return super().restore(strict, transaction_id, *args, **kwargs) diff --git a/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst new file mode 100644 index 000000000..60dc1048c --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst @@ -0,0 +1,6 @@ +Wewnętrzny fundament pod usuwanie „miękkie" (soft-delete) powiązań autor- +-publikacja: trzy tabele przypisań autorstwa (wydawnictwa ciągłe, zwarte, +patenty) zyskały znaczniki kasowania/przywracania zamiast trwałego +usuwania wiersza. Zmiana jest na razie wewnętrzna i niewidoczna dla +użytkowników — pełne wsparcie (widoki, cache, panel administracyjny) +wdrażają kolejne etapy. diff --git a/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py b/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py index e8829b9bd..157318b1d 100644 --- a/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py +++ b/src/bpp/tests/test_soft_delete/test_autor_softdelete_model.py @@ -2,6 +2,7 @@ instancja (bez sprawdzania cache — to Task 4).""" import pytest +from django_softdelete.managers import DeletedManager from django_softdelete.models import SoftDeleteModel from bpp.models.patent import Patent_Autor @@ -54,6 +55,75 @@ def test_restore_przywraca_do_objects(wydawnictwo_ciagle_z_autorem): assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists() +@pytest.mark.django_db +def test_restore_z_deleted_objects_nie_rzuca(wydawnictwo_ciagle_z_autorem): + """`deleted_objects.restore()` (ścieżka querysetowa) nie może rzucić + SoftDeleteException tylko dlatego, że Autor/Jednostka/rekord nie są + same SoftDeleteModel — patrz inwariant w docstringu soft_delete.py.""" + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + pk = wca.pk + wca.delete() + Wydawnictwo_Ciagle_Autor.deleted_objects.filter(pk=pk).restore() + assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists() + + +@pytest.mark.django_db +def test_restore_z_global_objects_nie_rzuca(wydawnictwo_ciagle_z_autorem): + """`global_objects.restore()` (druga ścieżka querysetowa) — to samo.""" + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + pk = wca.pk + wca.delete() + Wydawnictwo_Ciagle_Autor.global_objects.filter(pk=pk).restore() + assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=pk).exists() + + +@pytest.mark.django_db +def test_autorzy_set_ukrywa_soft_deleted_widoczne_w_global( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """`_default_manager` podmieniony na `BppSoftDeleteManager` realnie + ukrywa soft-deletowany wiersz również przez odwrotną relację + `rekord.autorzy_set` (reverse FK manager dziedziczy z + `_default_manager` modelu).""" + wca = wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.first() + pk = wca.pk + wca.delete() + assert not wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.filter(pk=pk).exists() + assert Wydawnictwo_Ciagle_Autor.global_objects.filter(pk=pk).exists() + + +@pytest.mark.parametrize("klass", THROUGH_MODELE) +def test_deleted_objects_jest_deletedmanager(klass): + assert isinstance(klass.deleted_objects, DeletedManager) + + +@pytest.mark.django_db +def test_deleted_objects_zwraca_tylko_skasowane(wydawnictwo_ciagle_z_dwoma_autorami): + wca_pozostaje, wca_kasowany = list( + wydawnictwo_ciagle_z_dwoma_autorami.autorzy_set.all() + ) + wca_kasowany.delete() + + skasowane_pk = set( + Wydawnictwo_Ciagle_Autor.deleted_objects.values_list("pk", flat=True) + ) + assert skasowane_pk == {wca_kasowany.pk} + assert wca_pozostaje.pk not in skasowane_pk + + +@pytest.mark.django_db +def test_gate_update_deleted_at_rzuca_na_konkretnym_modelu( + wydawnictwo_ciagle_z_autorem, +): + """Gate z Taska 1 był testowany tylko na `BppSoftDeleteQuerySet` w + izolacji (`test_managers.py`) — tu sprawdzamy, że faktycznie działa na + realnym queryset-cie konkretnego modelu, przez `objects`.""" + with pytest.raises(RuntimeError, match="Nie ustawiaj deleted_at"): + Wydawnictwo_Ciagle_Autor.objects.filter( + pk=wydawnictwo_ciagle_z_autorem.autorzy_set.first().pk + ).update(deleted_at="2026-06-04") + + def test_zgloszenie_publikacji_autor_nie_jest_soft_delete(): """Czwarty potomek abstraktu jest POZA zakresem soft-delete.""" from django_softdelete.models import SoftDeleteModel From e142b8a050376661f916900aeccb2ce912930da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 16:56:25 +0200 Subject: [PATCH 21/75] feat(soft-delete): widoki + galaz kasujaca + bramka WHEN dla *_Autor Trzy zmiany w jednej migracji (0489), w wymuszonej kolejnosci: filtr deleted_at w widokach bpp_*_autorzy -> galaz kasujaca w funkcjach bpp_refresh_autor_* -> regeneracja bramki WHEN (pg_depend zna juz deleted_at). Odwrocenie 1<->3 dalo by bramke bez deleted_at, czyli cichy staleness. Zadna z nich nie wystarcza sama: filtr widoku nie sprzata _mat (upsert po 0432 nie kasuje, wiec wypadniecie wiersza ze zrodla jest no-opem), a bez deleted_at w bramce UPDATE soft-delete (save(update_fields=['deleted_at','restored_at','transaction_id'])) w ogole nie dochodzi do funkcji triggera. Wariant filtra: WHERE wewnetrzny (`... FROM WHERE .deleted_at IS NULL`), nie owijka `SELECT * FROM (orig) _orig WHERE NOT EXISTS (...)` z planu. Plan kazal ZMIERZYC, czy owijka zachowuje plan wykonania -- nie zachowuje: hot path triggera schodzi z Index Scan (7.35) na Nested Loop Anti Join (12.21), a pelny skan z 11.50 na 26.84. Filtr wewnetrzny daje plan identyczny z oryginalem (7.35), a pelny skan przyspiesza do 2.36 (indeks na deleted_at z 0488). Dodatkowo jest odporny z konstrukcji na pulapke klucza opisana w planie: nie porownuje zadnych kluczy, wiec pomylka object_id_raw (id PUBLIKACJI) vs (id)[2] (pk wiersza through) jest tu niemozliwa. Definicje widokow i funkcji generowane z introspekcji (reuzycie generatorow z 0432/0433 przez importlib), nie kopiowane do .sql. backward() odtwarza widoki bez filtra z tekstu 0421 (ostatnia migracja, ktora je definiowala), funkcje przez _p0432._create_through_function, bramke przez ponowne przeliczenie pg_depend. Testy: src/bpp/tests/test_soft_delete/test_views_sql.py -- 3 kontrakty DDL (viewdef / functiondef / triggerdef) plus test SEMANTYCZNY klucza filtra (scenariusz z pk wiersza through rownym id publikacji-pulapki, zeby zly klucz wywalil sie na obu asercjach). test_migracja_0489_rewers.py pilnuje odwracalnosci. Zazielenilo test_cache_pk_filter::test_dodanie_i_usuniecie_autora_odswieza_ autorzy_mat oraz test_cache_trigger_v3::test_usuniecie_jednej_z_dwoch_rol_ autora_zostawia_druga. test_opis_bibliograficzny_dependent i test_wca_delete_cache pozostaja czerwone -- to Task 3b (bramka django-denorm, drugi, niezalezny system triggerow). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../0489_soft_delete_autorzy_views.py | 220 ++++++++++++++++++ .../soft-delete-autorstwo.feature.rst | 7 +- .../test_migracja_0489_rewers.py | 91 ++++++++ .../tests/test_soft_delete/test_views_sql.py | 173 ++++++++++++++ 4 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 src/bpp/migrations/0489_soft_delete_autorzy_views.py create mode 100644 src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py create mode 100644 src/bpp/tests/test_soft_delete/test_views_sql.py diff --git a/src/bpp/migrations/0489_soft_delete_autorzy_views.py b/src/bpp/migrations/0489_soft_delete_autorzy_views.py new file mode 100644 index 000000000..fe8249b75 --- /dev/null +++ b/src/bpp/migrations/0489_soft_delete_autorzy_views.py @@ -0,0 +1,220 @@ +"""Soft-delete autorstw: filtr w widokach + gałąź kasująca + bramka WHEN. + +Kolejność operacji jest WYMUSZONA: + + 1) widoki ``bpp_*_autorzy`` (dodają ``deleted_at`` do drzewa zapytania) -> + 2) funkcje ``bpp_refresh_autor_*`` (gałąź kasująca) -> + 3) regeneracja bramki WHEN (czyta ``pg_depend`` PO definicji widoku z 1) + +Kolumny bramki są wyliczane z ``pg_depend``, więc ``deleted_at`` wchodzi do niej +wyłącznie dlatego, że krok 1 wstawił tę kolumnę do definicji widoku. + +Odwrócenie 1<->3 daje bramkę bez ``deleted_at``, czyli cichy staleness: +soft-deletowane autorstwo zostaje w ``bpp_autorzy_mat``. + +Żadna z trzech zmian nie wystarcza sama: + +- sam filtr widoku nie sprząta ``bpp_autorzy_mat`` — funkcja refresh po 0432 + robi wyłącznie ``INSERT ... ON CONFLICT DO UPDATE``, więc wypadnięcie wiersza + ze źródła jest dla niej no-opem (patrz + ``test_cache/test_soft_delete_preconditions.py``); +- bez ``deleted_at`` w bramce WHEN (0433) UPDATE soft-delete + (``save(update_fields=['deleted_at', 'restored_at', 'transaction_id'])``) + w ogóle nie dochodzi do funkcji triggera. + +Definicje widoków i funkcji są GENEROWANE z introspekcji (tak jak w 0432/0433), +a nie przepisane do ``.sql`` — kopia rozjechałaby się przy najbliższej zmianie +kolumn. Stąd ``RunPython`` i ``importlib`` (nazwy modułów migracji zaczynają się +od cyfry, więc zwykły ``import`` nie przejdzie). +""" + +import importlib +from pathlib import Path + +from django.db import connection, migrations + +_p0432 = importlib.import_module("bpp.migrations.0432_cache_trigger_plpgsql") +_p0433 = importlib.import_module("bpp.migrations.0433_cache_trigger_when_gate") + +THROUGH_SITES = _p0432.THROUGH_SITES # [(tabela through, model content_type), ...] + +# Ostatnia migracja definiująca widoki bpp_*_autorzy. 0443 i 0458 też zawierają +# CREATE VIEW, ale dotyczą zupełnie innych widoków (bpp_kronika_*, bpp_nowe_sumy_*) +# — sprawdzone przy pisaniu tej migracji. Używane WYŁĄCZNIE w backward(). +ZRODLO_ORYGINALNYCH_WIDOKOW = "0421_cache_trigger_pk_filter.sql" + + +def _widok(tabela): + """``bpp_patent_autor`` -> ``bpp_patent_autorzy``.""" + return tabela[: -len("_autor")] + "_autorzy" + + +def _viewdef(cur, widok): + cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok]) + return cur.fetchone()[0].rstrip().rstrip(";") + + +def _filtruj_widok(cur, tabela, widok): + """Odcina z widoku ``*_autorzy`` wiersze soft-deletowanych autorstw. + + Dopisujemy ``WHERE`` do istniejącej, INTROSPEKOWANEJ definicji zamiast + przepisywać ją ręcznie: definicja jest generowana (0421) i kopia + rozjechałaby się przy następnej zmianie kolumn. ``CREATE OR REPLACE VIEW`` + zachowuje listę i typy kolumn, więc zależny ``bpp_autorzy`` (UNION po + pięciu widokach) NIE jest kasowany — żadnego ``DROP ... CASCADE``. + + **Dlaczego filtr WEWNĘTRZNY, a nie owijka.** Plan zakładał owinięcie + (``SELECT * FROM () _orig WHERE NOT EXISTS (...)``) i kazał ZMIERZYĆ, + czy owijka zachowuje plan wykonania. Zmierzone (PostgreSQL 16, EXPLAIN na + bazie testowej): + + =========================================== ========== ============== + zapytanie oryginał owijka + =========================================== ========== ============== + hot path triggera (``object_id_raw`` + Index Scan Nested Loop + ``autor_id``) 7.35 Anti Join 12.21 + pełny skan (przebudowa ``bpp_autorzy``) 11.50 26.84 + =========================================== ========== ============== + + Owijka NIE zachowuje planu (dokłada anti-join i sondę po pkey na każdy + wiersz), więc — zgodnie z instrukcją planu na taki właśnie wypadek — + dopisujemy ``deleted_at IS NULL`` do ``WHERE`` wewnętrznego. Ten wariant + daje plan **identyczny** z oryginałem na hot-pathie (Index Scan, 7.35), a + pełny skan wręcz przyspiesza (2.36) — trafia w indeks na ``deleted_at`` + założony w 0488. + + ⚠️ KLUCZ (pułapka, na której przejechał się pierwotny plan): w widokach + ``*_autorzy`` kolumna ``object_id_raw`` to ``rekord_id``, czyli **id + PUBLIKACJI**, a NIE pk wiersza through (ten siedzi w drugim elemencie + kolumny-tablicy ``id``). Filtr po ``object_id_raw`` porównywałby id + publikacji z id autorstwa: skasowane autorstwo zostawałoby w widoku, a przy + zbieżności numerów wycinane byłyby autorstwa CUDZEJ publikacji. Filtr + wewnętrzny nie porównuje ŻADNYCH kluczy — patrzy wprost na kolumnę wiersza, + więc jest na tę pomyłkę odporny z konstrukcji. Regresji pilnuje + ``test_widok_odcina_WLASCIWY_wiersz_a_nie_cudzy`` w + ``src/bpp/tests/test_soft_delete/test_views_sql.py`` — testy substringowe + (``deleted_at`` w ``pg_get_viewdef``) tego NIE łapią. + """ + orig = _viewdef(cur, widok) + if not orig.endswith(f"FROM {tabela}"): + # Dopisanie WHERE jest poprawne tylko dla definicji kończącej się gołym + # `FROM `: bez aliasu (inaczej kwalifikacja kolumny nie + # zadziała) i bez własnego WHERE (inaczej powstałby drugi WHERE). + raise RuntimeError( + f"{widok}: definicja nie konczy sie na 'FROM {tabela}' -- " + f"dopisanie WHERE bylo by niepoprawne. Definicja: ...{orig[-120:]!r}" + ) + cur.execute( + f"CREATE OR REPLACE VIEW {widok} AS {orig} WHERE {tabela}.deleted_at IS NULL" + ) + + +def _funkcja_z_galezia_kasujaca(cur, tabela, model): + """``bpp_refresh_autor_()`` z 0432 + prolog kasujący. + + Ciało generowane identycznie jak ``_p0432._create_through_function`` (ten sam + upsert z pozycyjnym mapowaniem kolumn), z jedną różnicą: gdy wiersz jest + soft-deletowany, kasujemy go z ``bpp_autorzy_mat`` i wychodzimy. Klucz + ``ARRAY[ct, NEW.id]`` jest ten sam co w ``_create_delete_through_function`` + (tam ``OLD.id``, tu ``NEW.id`` — przy UPDATE to ten sam wiersz). + + ``pg_advisory_xact_lock`` stoi PRZED rozgałęzieniem: kasowanie musi brać ten + sam lock co upsert, inaczej wraca wyścig z #309. + + Restore (``deleted_at`` -> NULL) leci normalną ścieżką upsertu — wiersz + wraca do widoku źródłowego, więc nic dodatkowego nie trzeba. + """ + upsert = _p0432._upsert_sql( + cur, + "bpp_autorzy_mat", + _widok(tabela), + "object_id_raw = NEW.rekord_id AND autor_id = NEW.autor_id", + ) + return f""" +CREATE OR REPLACE FUNCTION bpp_refresh_autor_{model}() RETURNS trigger +LANGUAGE plpgsql AS $bpp_body$ +DECLARE ct integer; +BEGIN + {_p0432._ct_lookup(model)} + PERFORM pg_advisory_xact_lock(ct, NEW.rekord_id); + IF NEW.deleted_at IS NOT NULL THEN + DELETE FROM bpp_autorzy_mat WHERE id = ARRAY[ct, NEW.id]::integer[]; + RETURN NULL; + END IF; + {upsert}; + RETURN NULL; +END $bpp_body$; +""" + + +def _oryginalna_definicja_widoku(widok): + """Instrukcja ``CREATE OR REPLACE VIEW `` sprzed tej migracji. + + ``pg_get_viewdef`` sprzed dopisania filtra nie jest w backward dostępny (w + bazie stoi już definicja z ``WHERE deleted_at IS NULL``), więc bierzemy + tekst z ostatniej migracji definiującej te widoki (patrz + ``ZRODLO_ORYGINALNYCH_WIDOKOW``). ``str.index`` rzuca ``ValueError``, gdy + markera nie ma — świadomie głośno, zamiast po cichu zostawić widok z filtrem. + """ + sql = (Path(__file__).parent / ZRODLO_ORYGINALNYCH_WIDOKOW).read_text() + poczatek = sql.index(f"CREATE OR REPLACE VIEW {widok} AS") + # Instrukcja kończy się średnikiem na końcu linii FROM, po niej idzie linia + # z samym ";" — pierwsze wystąpienie "\n;" po `poczatek` to właśnie ona. + return sql[poczatek : sql.index("\n;", poczatek)] + + +def _regeneruj_bramke(): + """Ta sama logika co ``0433.forward``, ale tylko dla tabel through. + + Bramka jest wyliczana z ``pg_depend``, więc po zmianie definicji widoku + (krok 1) sama wciągnie ``deleted_at``; po cofnięciu widoku (backward) sama + ją zgubi. Tabele publikacji (``bpp_wydawnictwo_ciagle`` itd.) to faza 02 — + tu ich nie ruszamy. + """ + with connection.cursor() as cur: + for tabela, refresh_fn, widoki in _p0433.GATED: + if not tabela.endswith("_autor"): + continue + kolumny = _p0433._gate_columns(cur, tabela, widoki) + if not kolumny: + # Bezpiecznik z 0433: bez kolumn powstałby niebramkowany + # UPDATE. Jeśli tu jesteśmy, krok 1 nie zadziałał. + raise RuntimeError( + f"bramka dla {tabela}: pg_depend nie zwrocil zadnej kolumny " + f"(widoki={widoki}) -- nie tworze niezbramkowanego UPDATE" + ) + when = _p0433._when_clause(kolumny) + cur.execute(f"DROP TRIGGER IF EXISTS {tabela}_cache_upd ON {tabela};") + cur.execute( + f"CREATE TRIGGER {tabela}_cache_upd AFTER UPDATE ON {tabela} " + f"FOR EACH ROW WHEN ({when}) " + f"EXECUTE PROCEDURE {refresh_fn}();" + ) + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + for tabela, _model in THROUGH_SITES: # 1) widoki + _filtruj_widok(cur, tabela, _widok(tabela)) + for tabela, model in THROUGH_SITES: # 2) funkcje + cur.execute(_funkcja_z_galezia_kasujaca(cur, tabela, model)) + _regeneruj_bramke() # 3) bramka + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + for tabela, _model in THROUGH_SITES: # 1) widoki bez filtra + cur.execute(_oryginalna_definicja_widoku(_widok(tabela))) + for tabela, model in THROUGH_SITES: # 2) funkcje bez gałęzi kasującej + cur.execute(_p0432._create_through_function(cur, tabela, model)) + _regeneruj_bramke() # 3) bramka (deleted_at zniknie z pg_depend samo) + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0488_autor_soft_delete_fields"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst index 60dc1048c..716c08362 100644 --- a/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst +++ b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst @@ -1,6 +1,7 @@ Wewnętrzny fundament pod usuwanie „miękkie" (soft-delete) powiązań autor- -publikacja: trzy tabele przypisań autorstwa (wydawnictwa ciągłe, zwarte, patenty) zyskały znaczniki kasowania/przywracania zamiast trwałego -usuwania wiersza. Zmiana jest na razie wewnętrzna i niewidoczna dla -użytkowników — pełne wsparcie (widoki, cache, panel administracyjny) -wdrażają kolejne etapy. +usuwania wiersza. Skasowane przypisania znikają też z widoków źródłowych i +z materializowanego cache'u bibliografii, więc nie pokazują się na stronach +publikacji. Zmiana jest na razie wewnętrzna i niewidoczna dla użytkowników — +pełne wsparcie (panel administracyjny, przywracanie) wdrażają kolejne etapy. diff --git a/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py b/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py new file mode 100644 index 000000000..94d6c6d67 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py @@ -0,0 +1,91 @@ +"""Odwracalność migracji 0489 (widoki + gałąź kasująca + bramka WHEN). + +``backward`` musi zdjąć WSZYSTKIE trzy zmiany — inaczej rollback zostawia +niespójny stan: np. widok bez filtra, ale funkcja z gałęzią kasującą (kasowałaby +wiersze, których widok i tak nie odfiltrował) albo bramka znająca ``deleted_at`` +przy funkcji, która nic z tym nie robi. + +Test siedzi w zwykłej transakcji testowej (``django_db`` bez +``transaction=True``): DDL w Postgresie jest transakcyjny, więc obie zmiany +(unapply + reapply) i wpisy w ``django_migrations`` cofną się razem z nią. +""" + +import pytest +from django.core.management import call_command +from django.db import connection + +WIDOKI = [ + "bpp_wydawnictwo_ciagle_autorzy", + "bpp_wydawnictwo_zwarte_autorzy", + "bpp_patent_autorzy", +] +FUNKCJE = [ + "bpp_refresh_autor_wydawnictwo_ciagle", + "bpp_refresh_autor_wydawnictwo_zwarte", + "bpp_refresh_autor_patent", +] +TRIGGERY = [ + ("bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_ciagle_autor_cache_upd"), + ("bpp_wydawnictwo_zwarte_autor", "bpp_wydawnictwo_zwarte_autor_cache_upd"), + ("bpp_patent_autor", "bpp_patent_autor_cache_upd"), +] + + +def _viewdef(cur, widok): + cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok]) + return cur.fetchone()[0] + + +def _funcdef(cur, fn): + cur.execute("SELECT pg_get_functiondef(%s::regproc)", [fn]) + return cur.fetchone()[0] + + +def _triggerdef(cur, tabela, trigger): + cur.execute( + "SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t " + "WHERE t.tgrelid = %s::regclass AND t.tgname = %s", + [tabela, trigger], + ) + return cur.fetchone()[0] + + +def _stan_ddl(cur): + return ( + [_viewdef(cur, w) for w in WIDOKI], + [_funcdef(cur, f) for f in FUNKCJE], + [_triggerdef(cur, t, g) for t, g in TRIGGERY], + ) + + +@pytest.mark.django_db +def test_migracja_0489_odwracalna(): + with connection.cursor() as cur: + przed = _stan_ddl(cur) + + call_command("migrate", "bpp", "0488_autor_soft_delete_fields", verbosity=0) + + with connection.cursor() as cur: + for widok in WIDOKI: + assert "deleted_at" not in _viewdef(cur, widok), ( + f"{widok}: backward nie zdjął filtra" + ) + for fn in FUNKCJE: + assert "NEW.deleted_at" not in _funcdef(cur, fn), ( + f"{fn}: backward nie zdjął gałęzi kasującej" + ) + for tabela, trigger in TRIGGERY: + assert "deleted_at" not in _triggerdef(cur, tabela, trigger), ( + f"{trigger}: backward nie przeliczył bramki WHEN" + ) + # Widoki muszą zachować listę i typy kolumn — inaczej UNION bpp_autorzy + # (zależny od całej trójki) rozsypałby się przy pierwszym odczycie. + cur.execute("SELECT count(*) FROM bpp_autorzy") + cur.fetchone() + + call_command("migrate", "bpp", verbosity=0) + + with connection.cursor() as cur: + assert _stan_ddl(cur) == przed, ( + "forward po backward nie odtworzył identycznego DDL" + ) diff --git a/src/bpp/tests/test_soft_delete/test_views_sql.py b/src/bpp/tests/test_soft_delete/test_views_sql.py new file mode 100644 index 000000000..74699d6fa --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_views_sql.py @@ -0,0 +1,173 @@ +"""Kontrakt DDL po fazie 01: widok filtruje, funkcja kasuje, bramka przepuszcza. + +Trzy pierwsze testy są „substringowe" (``pg_get_viewdef`` / ``pg_get_functiondef`` +/ ``pg_get_triggerdef``) — pilnują, że migracja w ogóle się odbyła. Czwarty jest +SEMANTYCZNY i jest jedyną realną wyrocznią KLUCZA filtra: trzy pierwsze +przechodzą także dla klucza BŁĘDNEGO (``object_id_raw``, czyli id publikacji, +zamiast ``(id)[2]``, czyli pk wiersza through), bo ``pg_depend`` widzi kolumnę +użytą w podzapytaniu niezależnie od tego, czy porównanie ma jakikolwiek sens. +""" + +import pytest +from django.db import connection +from model_bakery import baker + +from bpp.models import Autor, Jednostka, Wydawnictwo_Ciagle + +WIDOKI = [ + "bpp_wydawnictwo_ciagle_autorzy", + "bpp_wydawnictwo_zwarte_autorzy", + "bpp_patent_autorzy", +] +FUNKCJE = [ + "bpp_refresh_autor_wydawnictwo_ciagle", + "bpp_refresh_autor_wydawnictwo_zwarte", + "bpp_refresh_autor_patent", +] +TRIGGERY = [ + ("bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_ciagle_autor_cache_upd"), + ("bpp_wydawnictwo_zwarte_autor", "bpp_wydawnictwo_zwarte_autor_cache_upd"), + ("bpp_patent_autor", "bpp_patent_autor_cache_upd"), +] + + +@pytest.mark.django_db +@pytest.mark.parametrize("widok", WIDOKI) +def test_widok_zrodlowy_filtruje_po_deleted_at(widok): + with connection.cursor() as cur: + cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok]) + defn = cur.fetchone()[0] + assert "deleted_at" in defn, f"{widok} nie filtruje po deleted_at" + + +@pytest.mark.django_db +@pytest.mark.parametrize("fn", FUNKCJE) +def test_funkcja_refresh_ma_galaz_kasujaca(fn): + """Bez DELETE odfiltrowanie z widoku jest no-opem (upsert nic nie usuwa).""" + with connection.cursor() as cur: + cur.execute("SELECT pg_get_functiondef(%s::regproc)", [fn]) + src = cur.fetchone()[0] + assert "NEW.deleted_at IS NOT NULL" in src, f"{fn}: brak gałęzi kasującej" + assert "DELETE FROM bpp_autorzy_mat" in src, f"{fn}: brak DELETE" + + +@pytest.mark.django_db +@pytest.mark.parametrize("tabela,trigger", TRIGGERY) +def test_bramka_when_zna_deleted_at(tabela, trigger): + """Bez deleted_at w bramce UPDATE soft-delete nie dochodzi do funkcji.""" + with connection.cursor() as cur: + cur.execute( + "SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t " + "WHERE t.tgrelid = %s::regclass AND t.tgname = %s", + [tabela, trigger], + ) + row = cur.fetchone() + assert row is not None, f"brak triggera {trigger}" + assert "deleted_at" in row[0], f"{trigger}: bramka WHEN nie zna deleted_at" + + +def _przesun_sekwencje_through_ponad_id_publikacji(cur): + """Podnosi sekwencję pk tabeli through ponad max(id) obu tabel. + + Dzięki temu pk NASTĘPNEGO wiersza ``bpp_wydawnictwo_ciagle_autor`` jest + gwarantowanie WOLNYM id publikacji — możemy założyć publikację-pułapkę o + dokładnie takim id i sprawdzić, czy błędny klucz filtra (``object_id_raw`` + = id publikacji) wycina JEJ autorstwa. + + Sekwencje są poza transakcją, więc ten skok nie cofnie się po teście — + zostawia tylko lukę w numeracji, co jest nieszkodliwe. + """ + cur.execute( + "SELECT setval(" + " pg_get_serial_sequence('bpp_wydawnictwo_ciagle_autor', 'id')," + " GREATEST(" + " (SELECT COALESCE(MAX(id), 0) FROM bpp_wydawnictwo_ciagle_autor)," + " (SELECT COALESCE(MAX(id), 0) FROM bpp_wydawnictwo_ciagle)" + " ) + 1000," + " true)" + ) + + +def _ct_wydawnictwo_ciagle(cur): + cur.execute( + "SELECT id FROM django_content_type " + "WHERE app_label='bpp' AND model='wydawnictwo_ciagle'" + ) + return cur.fetchone()[0] + + +@pytest.mark.django_db +def test_widok_odcina_WLASCIWY_wiersz_a_nie_cudzy(standard_data): + """Test SEMANTYCZNY klucza filtra — nie sam fakt obecności ``deleted_at``. + + Scenariusz jest tak dobrany, żeby ZŁY klucz wywalił się na OBU asercjach + naraz: pk kasowanego wiersza through jest równy id publikacji-pułapki. + Przy filtrze po ``object_id_raw`` skasowane autorstwo zostałoby w widoku, + a wycięte zostałyby autorstwa publikacji-pułapki. + """ + with connection.cursor() as cur: + _przesun_sekwencje_through_ponad_id_publikacji(cur) + + jednostka = baker.make(Jednostka) + kasowany = baker.make(Autor, imiona="Jan", nazwisko="Kasowany") + obcy_autor = baker.make(Autor, imiona="Jan", nazwisko="Obcy") + + wc = baker.make( + Wydawnictwo_Ciagle, + tytul_oryginalny="Publikacja z kasowanym autorstwem", + szczegoly="sz", + uwagi="u", + ) + wca = wc.dodaj_autora(kasowany, jednostka) + + # Publikacja-pułapka: jej id jest RÓWNE pk kasowanego wiersza through. + wc_pulapka = baker.make( + Wydawnictwo_Ciagle, + id=wca.pk, + tytul_oryginalny="Publikacja-pulapka", + szczegoly="sz", + uwagi="u", + ) + assert wc_pulapka.pk == wca.pk, "setup pułapki nie zadziałał" + obcy = wc_pulapka.dodaj_autora(obcy_autor, jednostka) + + wca.delete() + + with connection.cursor() as cur: + ct = _ct_wydawnictwo_ciagle(cur) + + cur.execute( + "SELECT count(*) FROM bpp_wydawnictwo_ciagle_autorzy WHERE (id)[2] = %s", + [wca.pk], + ) + assert cur.fetchone()[0] == 0, ( + "skasowane autorstwo NADAL w widoku — filtr używa złego klucza" + ) + + cur.execute( + "SELECT count(*) FROM bpp_wydawnictwo_ciagle_autorzy WHERE (id)[2] = %s", + [obcy.pk], + ) + assert cur.fetchone()[0] == 1, ( + "filtr wyciął autorstwo INNEJ publikacji — klucz porównuje " + "id publikacji z id wiersza through" + ) + + # Sam filtr widoku nie sprząta _mat — to robi gałąź kasująca w funkcji + # refresh, przepuszczona przez bramkę WHEN. Sprawdzamy cały łańcuch. + cur.execute( + "SELECT count(*) FROM bpp_autorzy_mat WHERE id = ARRAY[%s, %s]::integer[]", + [ct, wca.pk], + ) + assert cur.fetchone()[0] == 0, ( + "skasowane autorstwo zostało w bpp_autorzy_mat — bramka WHEN nie " + "przepuściła UPDATE-u albo funkcja refresh nie ma gałęzi kasującej" + ) + + cur.execute( + "SELECT count(*) FROM bpp_autorzy_mat WHERE id = ARRAY[%s, %s]::integer[]", + [ct, obcy.pk], + ) + assert cur.fetchone()[0] == 1, ( + "z bpp_autorzy_mat zniknęło autorstwo INNEJ publikacji" + ) From 3f495f54c2a9c75f76a36db501b9748be6edc32e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 17:02:53 +0200 Subject: [PATCH 22/75] docs(soft-delete): warunkowy unique na *_Autor przeniesiony do fazy 01 Blad kolejnosci w planie, wykryty przy wykonaniu Taska 3 fazy 01. Decyzja #13 (unique_together -> warunkowy UniqueConstraint z condition deleted_at IS NULL) byla zaplanowana w fazie 02, razem ze slugiem. Ale *_Autor staje sie soft-delete juz w fazie 01 (Task 2), wiec unique_together blokuje re-insert od TAMTEJ fazy, nie od nastepnej. Regresja zweryfikowana na realnym kodzie: import_sqlite/tests/test_patent_apply.py::test_apply_idempotent_update -> UniqueViolation na bpp_patent_autor_rekord_id_autor_id_kolejnosc_uniq Zrodlo: import_sqlite/handlers/patent.py:192 robi autorzy_set.all().delete() i wstawia od nowa; po Tasku 2 delete() jest miekki, wiec stary wiersz fizycznie istnieje i constraint go widzi. Wzorzec jest ogolny, nie dotyczy jednego importera -- kazdy przeplyw "skasuj autorstwa i wstaw od nowa" (re-import, admin inline, korekta kolejnosci) uderzy w to samo, a skasowany wiersz jest przy tym niewidoczny dla operatora. Nowy Task 3c w fazie 01 zawiera test odtwarzajacy regresje oraz obowiazkowa weryfikacje adminu: validate_unique() honoruje unique_together, ale POMIJA UniqueConstraint z condition, wiec inline autorstwa moze zaczac zwracac IntegrityError zamiast czytelnego bledu formularza. W fazie 02 odpowiedni task oznaczony jako przeniesiony (tresc zwinieta jako kontekst historyczny). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- ...-04-soft-delete-01-autor-trigger-widoki.md | 98 +++++++++++++++++++ .../2026-06-04-soft-delete-02-publikacje.md | 14 ++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md index edd46c0d7..121b9fc43 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -838,6 +838,104 @@ autora, a punkty dyscyplin dalej go liczą. --- +## Task 3c — Warunkowy `UniqueConstraint` na `*_Autor` (PRZENIESIONE z fazy 02) + +> 🔴 **Przeniesione tutaj 2026-08-06** po wykryciu regresji przy wykonaniu Taska 3. +> Pierwotnie decyzja #13 była zaplanowana w fazie 02 (razem ze slugiem) — +> **błąd kolejności**: `*_Autor` staje się soft-delete już w fazie 01 (Task 2), +> więc `unique_together` blokuje re-insert od TEJ fazy, nie od następnej. + +**Regresja (zweryfikowana, realna):** +``` +$ uv run pytest src/import_sqlite/tests/test_patent_apply.py::test_apply_idempotent_update +E psycopg2.errors.UniqueViolation: podwójna wartość klucza narusza ograniczenie + unikalności "bpp_patent_autor_rekord_id_autor_id_kolejnosc_96bdce4c_uniq" +E DETAIL: Klucz (rekord_id, autor_id, kolejnosc)=(288874, 483391, 0) już istnieje. +``` +`src/import_sqlite/handlers/patent.py:192` robi `patent.autorzy_set.all().delete()` +i wstawia od nowa. Po Tasku 2 `delete()` jest miękki → stary wiersz fizycznie +istnieje → `unique_together` go widzi → `IntegrityError`. + +**Wzorzec jest ogólny**, nie dotyczy jednego importera: **każdy** przepływ +„skasuj autorstwa i wstaw od nowa" (re-import, admin inline, korekta kolejności) +uderzy w to samo. Skasowany wiersz jest przy tym **niewidoczny dla operatora**, +więc komunikat błędu mówi o rekordzie, którego nie widać. + +**Files:** +- Modify: `src/bpp/models/wydawnictwo_ciagle.py` (`Wydawnictwo_Ciagle_Autor.Meta`), + `wydawnictwo_zwarte.py` (`Wydawnictwo_Zwarte_Autor.Meta`), `patent.py` (`Patent_Autor.Meta`) +- Create: `src/bpp/migrations/0490_autor_warunkowy_unique.py` (zweryfikuj liść!) +- Test: `src/bpp/tests/test_soft_delete/test_autor_unique.py` + +**Steps:** + +- [ ] **Krok 3c.1 — padający test odtwarzający regresję** (na `Patent_Autor`, + bo tam wyszła realnie, plus jeden na `Wydawnictwo_Ciagle_Autor`): + ```python + @pytest.mark.django_db + def test_reinsert_autorstwa_po_soft_delete(patent, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci): + """Wzorzec "skasuj autorstwa i wstaw od nowa" MUSI działać po soft-delete.""" + pa = Patent_Autor.objects.create( + rekord=patent, autor=autor_jan_kowalski, jednostka=jednostka, + typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."), + kolejnosc=0, zapisany_jako="Kowalski Jan", + ) + patent.autorzy_set.all().delete() # soft + Patent_Autor.objects.create( # ten sam (rekord, autor, kolejnosc) + rekord=patent, autor=autor_jan_kowalski, jednostka=jednostka, + typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."), + kolejnosc=0, zapisany_jako="Kowalski Jan", + ) + assert Patent_Autor.objects.filter(rekord=patent).count() == 1 + assert Patent_Autor.global_objects.filter(rekord=patent).count() == 2 + ``` + Oraz **test regresyjny na realnym przepływie**: + `uv run pytest src/import_sqlite/tests/test_patent_apply.py::test_apply_idempotent_update` + — ma przejść po tym tasku. + +- [ ] **Krok 3c.2 — zamiana w `Meta` 3 konkretnych klas.** Usuń `unique_together`, + dodaj warunkowe constrainty (nazwy MUSZĄ być unikalne w całej bazie): + ```python + constraints = [ + models.UniqueConstraint( + fields=["rekord", "autor", "typ_odpowiedzialnosci"], + condition=Q(deleted_at__isnull=True), + name="wc_autor_uniq_rekord_autor_typ", + ), + models.UniqueConstraint( + fields=["rekord", "autor", "kolejnosc"], + condition=Q(deleted_at__isnull=True), + name="wc_autor_uniq_rekord_autor_kolejnosc", + ), + ] + ``` + (prefiksy per model: `wc_` / `wz_` / `pat_`) + +- [ ] **Krok 3c.3 — ⚠️ ZWERYFIKUJ ADMIN.** Komentarz przy drugiej krotce w kodzie + („Tu musi być autor, inaczej admin nie pozwoli wyedytować") sugeruje, że ten + constraint istnieje ze względu na **walidację formularzy**. + `Model.validate_unique()` honoruje `unique_together`, ale `UniqueConstraint` + z `condition` **pomija** (Django waliduje tylko constrainty bezwarunkowe). + Ryzyko: zamiast czytelnego błędu formularza operator dostanie `IntegrityError` + (HTTP 500) przy inline'ach autorstwa. + ```bash + uv run pytest src/bpp/tests/test_admin/ -k "autor" -q + ``` + Jeśli admin regresuje — dodaj walidację w formularzu/inline (`clean()` + sprawdzający kolizję przez `objects`), **NIE wracaj do `unique_together`** + (nie da się go pogodzić z soft-delete). + +- [ ] **Krok 3c.4 — migracja + brak driftu + regresja:** + ```bash + DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations bpp --name autor_warunkowy_unique + DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run + uv run pytest src/bpp/tests/test_soft_delete/ src/import_sqlite/ -q + ``` + +- [ ] **Krok 3c.5 — commit** (użyj `git commit -F `, nie `-m` z backtickami). + +--- + ## Task 4 — Testy spójności cache (mat-view) po soft-delete `*_Autor` Główny gejt fazy: soft-delete wiersza `*_Autor` → znika z `bpp_autorzy_mat` (model `Autorzy`) i z `bpp_autorzy` (model `AutorzyView`); restore → wraca; edycja autorstwa skasowanej publikacji nie wskrzesza wiersza w cache; kaskada queryset-owa (`.delete()` na QS) działa per-instancja. Testy wymagają `transactional_db` (trigger działa tylko z prawdziwym commitem). diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md index 804667812..8d01ae9a5 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md @@ -456,7 +456,17 @@ Soft-delete książki-matki musi unieważnić denorm-cache rozdziałów. --- -## Task 3b: `unique_together` na `*_Autor` → warunkowy `UniqueConstraint` (decyzja #13) +## ~~Task 3b: `unique_together` na `*_Autor`~~ → PRZENIESIONE DO FAZY 01 (Task 3c) + +> 🔀 **Przeniesione 2026-08-06.** Umieszczenie tego w fazie 02 było błędem +> kolejności: `*_Autor` staje się soft-delete już w fazie 01 (Task 2), więc +> `unique_together` blokuje re-insert od tamtej fazy. Regresja wyszła realnie +> (`import_sqlite/handlers/patent.py:192` → `UniqueViolation`) i jest naprawiana +> w fazie 01, Task 3c. **W tej fazie NIE rób nic z `*_Autor` unique** — +> poniższa treść zostaje wyłącznie jako kontekst historyczny. + +
oryginalna treść tasku (nieaktualna) + > Dodane 2026-08-06 (spec §2.2b). Ten sam problem co ze slugiem, przeoczony > w pierwszej wersji planu. @@ -536,6 +546,8 @@ którego nie widać. --- +
+ ## Task 4: Przeplecenie menedżerów `Wydawnictwo_*_Manager` z filtrem soft-delete (wspólny QuerySet/MRO) **Files:** From b2bd36df7f6469de0c7c64f1b47ca086ff8deda3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 17:29:39 +0200 Subject: [PATCH 23/75] =?UTF-8?q?test(soft-delete):=20padaj=C4=85cy=20test?= =?UTF-8?q?=20e2e=20na=20bramk=C4=99=20denorm=20dla=20deleted=5Fat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3b (faza 01): BPP ma dwa niezależne systemy triggerów na tabelach *_Autor. Task 3 naprawił bramkę WHEN naszego cache _mat. Bramka django-denorm (budowana z list only= w @depend_on_related) zostaje ślepa na deleted_at, więc soft-delete autorstwa nie odświeża pól denormalizowanych rodzica (opis_bibliograficzny_cache i pochodne). Dodaje test end-to-end weryfikujący cały mechanizm (nie samą obecność stringu w DDL): soft-delete autorstwa + denorms.flush() musi usunąć nazwisko autora z opis_bibliograficzny_cache rodzica. Test na razie czerwony — implementacja w kolejnym commicie. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../test_soft_delete/test_denorm_gate.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/bpp/tests/test_soft_delete/test_denorm_gate.py diff --git a/src/bpp/tests/test_soft_delete/test_denorm_gate.py b/src/bpp/tests/test_soft_delete/test_denorm_gate.py new file mode 100644 index 000000000..33ccff871 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_denorm_gate.py @@ -0,0 +1,40 @@ +"""Task 3b: bramka WHEN triggera django-denorm musi znać `deleted_at`. + +BPP ma DWA niezależne systemy triggerów na tabelach `*_Autor`: + +1. cache ``_mat`` (nasz) — naprawiony w Tasku 3 (migracja 0489). +2. ``django-denorm`` — bramka WHEN budowana z list ``only=`` w + ``@depend_on_related``. Naprawiany właśnie tutaj (Task 3b). + +Bez ``deleted_at`` w ``only=``/``denorm_always_only`` UPDATE, który tylko +soft-kasuje wiersz ``*_Autor`` (ustawia ``deleted_at``), nie odpala triggera +denorm — pola denormalizowane rodzica (``opis_bibliograficzny_cache`` i +pochodne) zostają nieświeże NA STAŁE. +""" + +import pytest + + +@pytest.mark.django_db +def test_soft_delete_autorstwa_odswieza_opis_biblio( + wydawnictwo_ciagle_z_autorem, denorms +): + """Soft-delete autorstwa MUSI unieważnić denorm-cache rodzica. + + Drugi system triggerów (django-denorm) ma własną bramkę WHEN po liście + ``only=`` — bez ``deleted_at`` UPDATE soft-delete jej nie przechodzi i + opis zostaje nieświeży NA STAŁE. + """ + wc = wydawnictwo_ciagle_z_autorem + denorms.flush() + wc.refresh_from_db() + assert "KOWALSKI" in wc.opis_bibliograficzny_cache + + wc.autorzy_set.first().delete() + denorms.flush() + wc.refresh_from_db() + + assert "KOWALSKI" not in wc.opis_bibliograficzny_cache, ( + "denorm-cache nieświeży — bramka WHEN triggera denorm nie zna " + "deleted_at (denorm_always_only)" + ) From cf0fca39475790343a87985871d7823302735184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 17:30:00 +0200 Subject: [PATCH 24/75] fix(soft-delete): bramka WHEN triggera denorm zna deleted_at (Task 3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BPP ma dwa niezależne systemy triggerów na tabelach *_Autor: 1. cache _mat (nasz) — bramka WHEN naprawiona w migracji 0489 (Task 3). 2. django-denorm — bramka WHEN budowana z list only= w @depend_on_related. Bez deleted_at w tej liście UPDATE, który tylko soft-kasuje wiersz *_Autor, nie odpalał triggera denorm — pola denormalizowane rodzica (opis_bibliograficzny_cache, opis_bibliograficzny_autorzy_cache, opis_bibliograficzny_zapisani_autorzy_cache, slug, cached_punkty_dyscyplin) zostawały nieświeże na stałe. Weryfikacja pułapki z brief-a: `only = (only or ()) + denorm_always_only` w połączeniu z `if only: ... else: only = ` w denorm/db/base.py oznacza, że dodanie denorm_always_only do modelu, którego zależność nie ma only=, zawęziłoby ją z "wszystkie kolumny" do "tylko deleted_at" — cicha regresja. Sprawdzono ręcznie wszystkie 15 zależności @depend_on_related celujących w *_Autor (5 na każdy z 3 modeli, we wszystkich 3 plikach) — każda ma jawne only=. Dodanie denorm_always_only jest więc czysto addytywne. Dodano do Wydawnictwo_Ciagle_Autor, Wydawnictwo_Zwarte_Autor i Patent_Autor: denorm_always_only = ("deleted_at",) Triggery denorm są instalowane automatycznie przez post_migrate (denorm/apps.py -> denorms.install_triggers(), DROP TRIGGER IF EXISTS + CREATE TRIGGER, idempotentne) — żadna nowa migracja Django nie jest potrzebna. Zweryfikowano na żywej bazie testowej: bramki d_aft_row_upd_on_bpp_wydawnictwo_ciagle_autor_* zawierają teraz "deleted_at IS DISTINCT FROM" w klauzuli WHEN. Zazieleniło dwa ostatnie czerwone testy w test_cache/: test_opis_bibliograficzny_dependent i test_wca_delete_cache. Cały src/bpp/tests/test_cache/ oraz src/bpp/tests/test_soft_delete/ zielone (113 passed, 1 skipped). makemigrations --check --dry-run: brak zmian w bpp. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- src/bpp/models/patent.py | 6 ++++++ src/bpp/models/wydawnictwo_ciagle.py | 6 ++++++ src/bpp/models/wydawnictwo_zwarte.py | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py index 1d9013145..cd06ccd49 100644 --- a/src/bpp/models/patent.py +++ b/src/bpp/models/patent.py @@ -56,6 +56,12 @@ class Meta: models.Index(fields=["deleted_at"], name="patent_autor_deleted_at_idx"), ] + # django-denorm buduje bramkę WHEN triggera z listy `only=` w + # @depend_on_related. Bez deleted_at soft-delete autorstwa nie + # unieważniłby denorm-cache rodzica (opis bibliograficzny, slug, + # cached_punkty_dyscyplin) — zostałby nieświeży na stałe. + denorm_always_only = ("deleted_at",) + class _Patent_PropertyCache: @cached_property diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py index abd895bde..681d63f2b 100644 --- a/src/bpp/models/wydawnictwo_ciagle.py +++ b/src/bpp/models/wydawnictwo_ciagle.py @@ -81,6 +81,12 @@ class Meta: models.Index(fields=["deleted_at"], name="wc_autor_deleted_at_idx"), ] + # django-denorm buduje bramkę WHEN triggera z listy `only=` w + # @depend_on_related. Bez deleted_at soft-delete autorstwa nie + # unieważniłby denorm-cache rodzica (opis bibliograficzny, slug, + # cached_punkty_dyscyplin) — zostałby nieświeży na stałe. + denorm_always_only = ("deleted_at",) + class ModelZOpenAccessWydawnictwoCiagle(ModelZOpenAccess): openaccess_tryb_dostepu = models.ForeignKey( diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py index e636ec980..260bef535 100644 --- a/src/bpp/models/wydawnictwo_zwarte.py +++ b/src/bpp/models/wydawnictwo_zwarte.py @@ -90,6 +90,12 @@ class Meta: models.Index(fields=["deleted_at"], name="wz_autor_deleted_at_idx"), ] + # django-denorm buduje bramkę WHEN triggera z listy `only=` w + # @depend_on_related. Bez deleted_at soft-delete autorstwa nie + # unieważniłby denorm-cache rodzica (opis bibliograficzny, slug, + # cached_punkty_dyscyplin) — zostałby nieświeży na stałe. + denorm_always_only = ("deleted_at",) + MIEJSCE_I_ROK_MAX_LENGTH = 256 From 2b274858acbf9df84952aff8f22eccacc21186fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 18:47:57 +0200 Subject: [PATCH 25/75] =?UTF-8?q?test(soft-delete):=20padaj=C4=85ce=20test?= =?UTF-8?q?y=20re-insertu=20autorstwa=20po=20soft-delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3c: od Taska 2 modele *_Autor są soft-delete, wiec .delete() zostawia wiersz fizycznie w bazie. unique_together w Meta wciąż go widzi, przez co wzorzec "skasuj autorstwa i wstaw od nowa" (re-import, korekta kolejności, edycja inline) wywala się na IntegrityError. Regresja jest realna i zweryfikowana: src/import_sqlite/tests/test_patent_apply.py:: test_apply_idempotent_update pada z tym samym błędem. Nowy plik src/bpp/tests/test_soft_delete/test_autor_unique.py odtwarza regresję na Patent_Autor, Wydawnictwo_Ciagle_Autor i Wydawnictwo_Zwarte_Autor oraz dodaje test kontrolny potwierdzający, że kolizja dwóch ŻYWYCH wierszy nadal jest blokowana (nie chodzi o zniesienie unikalności, tylko o uwzględnienie soft-delete). Stan: RED — 4 testy failują (3 nowe + realny test_apply_idempotent_update), naprawa w kolejnym commicie. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../test_soft_delete/test_autor_unique.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/bpp/tests/test_soft_delete/test_autor_unique.py diff --git a/src/bpp/tests/test_soft_delete/test_autor_unique.py b/src/bpp/tests/test_soft_delete/test_autor_unique.py new file mode 100644 index 000000000..4ec0b8265 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_autor_unique.py @@ -0,0 +1,126 @@ +"""Task 3c: warunkowy ``UniqueConstraint`` (``condition=deleted_at__isnull``) +na ``*_Autor`` zamiast ``unique_together``. + +Regresja (Task 2->3): odkąd ``*_Autor`` jest soft-delete, ``.delete()`` +zostawia wiersz fizycznie w bazie. Klasyczny wzorzec "skasuj autorstwa i +wstaw od nowa" (re-import, korekta kolejności, edycja inline w adminie) +wywala się na ``unique_together``, bo widzi skasowany-miękko wiersz jako +kolizję. Constraint musi więc ignorować wiersze z ``deleted_at`` ustawionym. +""" + +import pytest + +from bpp.models import Typ_Odpowiedzialnosci +from bpp.models.patent import Patent_Autor +from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor +from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte_Autor + + +@pytest.mark.django_db +def test_reinsert_autorstwa_po_soft_delete_patent( + patent, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci +): + """Wzorzec "skasuj autorstwa i wstaw od nowa" MUSI działać po soft-delete.""" + Patent_Autor.objects.create( + rekord=patent, + autor=autor_jan_kowalski, + jednostka=jednostka, + typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."), + kolejnosc=0, + zapisany_jako="Kowalski Jan", + ) + patent.autorzy_set.all().delete() # soft + Patent_Autor.objects.create( # ten sam (rekord, autor, kolejnosc) + rekord=patent, + autor=autor_jan_kowalski, + jednostka=jednostka, + typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."), + kolejnosc=0, + zapisany_jako="Kowalski Jan", + ) + assert Patent_Autor.objects.filter(rekord=patent).count() == 1 + assert Patent_Autor.global_objects.filter(rekord=patent).count() == 2 + + +@pytest.mark.django_db +def test_reinsert_autorstwa_po_soft_delete_wydawnictwo_ciagle( + wydawnictwo_ciagle, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci +): + Wydawnictwo_Ciagle_Autor.objects.create( + rekord=wydawnictwo_ciagle, + autor=autor_jan_kowalski, + jednostka=jednostka, + typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."), + kolejnosc=0, + zapisany_jako="Kowalski Jan", + ) + wydawnictwo_ciagle.autorzy_set.all().delete() # soft + Wydawnictwo_Ciagle_Autor.objects.create( # ten sam (rekord, autor, kolejnosc) + rekord=wydawnictwo_ciagle, + autor=autor_jan_kowalski, + jednostka=jednostka, + typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."), + kolejnosc=0, + zapisany_jako="Kowalski Jan", + ) + qs = Wydawnictwo_Ciagle_Autor.objects.filter(rekord=wydawnictwo_ciagle) + assert qs.count() == 1 + global_manager = Wydawnictwo_Ciagle_Autor.global_objects + assert global_manager.filter(rekord=wydawnictwo_ciagle).count() == 2 + + +@pytest.mark.django_db +def test_reinsert_autorstwa_po_soft_delete_wydawnictwo_zwarte( + wydawnictwo_zwarte, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci +): + Wydawnictwo_Zwarte_Autor.objects.create( + rekord=wydawnictwo_zwarte, + autor=autor_jan_kowalski, + jednostka=jednostka, + typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."), + kolejnosc=0, + zapisany_jako="Kowalski Jan", + ) + wydawnictwo_zwarte.autorzy_set.all().delete() # soft + Wydawnictwo_Zwarte_Autor.objects.create( # ten sam (rekord, autor, kolejnosc) + rekord=wydawnictwo_zwarte, + autor=autor_jan_kowalski, + jednostka=jednostka, + typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."), + kolejnosc=0, + zapisany_jako="Kowalski Jan", + ) + qs = Wydawnictwo_Zwarte_Autor.objects.filter(rekord=wydawnictwo_zwarte) + assert qs.count() == 1 + global_manager = Wydawnictwo_Zwarte_Autor.global_objects + assert global_manager.filter(rekord=wydawnictwo_zwarte).count() == 2 + + +@pytest.mark.django_db +def test_wciaz_blokuje_prawdziwa_kolizje_niekasowanych_wierszy( + patent, autor_jan_kowalski, jednostka, typy_odpowiedzialnosci +): + """Constraint dalej musi łapać kolizję DWÓCH ŻYWYCH wierszy — to nie jest + zniesienie unikalności, tylko uwzględnienie soft-delete. Ten sam autor, + ten sam typ odpowiedzialności, dwa różne wiersze -> kolizja na + (rekord, autor, typ_odpowiedzialnosci).""" + from django.db import IntegrityError, transaction + + Patent_Autor.objects.create( + rekord=patent, + autor=autor_jan_kowalski, + jednostka=jednostka, + typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."), + kolejnosc=0, + zapisany_jako="Kowalski Jan", + ) + with pytest.raises(IntegrityError): + with transaction.atomic(): + Patent_Autor.objects.create( + rekord=patent, + autor=autor_jan_kowalski, + jednostka=jednostka, + typ_odpowiedzialnosci=Typ_Odpowiedzialnosci.objects.get(skrot="aut."), + kolejnosc=1, # inna kolejnosc, ale ten sam (rekord, autor, typ) + zapisany_jako="Kowalski Jan", + ) From 77c7d417ecdc42fb325733f4fae9ce83feace8a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 18:48:40 +0200 Subject: [PATCH 26/75] fix(soft-delete): warunkowy UniqueConstraint/ExclusionConstraint na *_Autor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3c. Zamienia unique_together (widzi soft-deleted wiersze) na warunkowe constrainty ograniczone do żywych wierszy (condition=Q(deleted_at__isnull)) w trzech modelach Wydawnictwo_Ciagle_Autor, Wydawnictwo_Zwarte_Autor, Patent_Autor. Migracja 0490. Po drodze wykryto DRUGĄ, niezależną przyczynę tej samej regresji: legacy migracja 0132 dokłada surowym SQL-em dodatkowy UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED (poza Django ORM, więc niewidoczny dla makemigrations) - też blokuje re-insert po soft-delete, tylko odroczony do commitu/zwolnienia savepointu. UniqueConstraint nie może łączyć condition z deferrable (Django to blokuje), a deferrable jest tu wymagany przez drag&drop reorder autorów w adminie (adminsortable2). Zastąpiony ExclusionConstraint (GiST + btree_gist, rozszerzenie już włączone w bazie od migracji 0056) - jedyny typ ograniczenia w Postgresie łączący WHERE z DEFERRABLE. Migracja 0490 usuwa legacy constraint i dodaje odpowiednik przez ORM. Weryfikacja admina (obowiązkowa wg brief-u): Model.validate_unique() w ogóle nie sprawdza Meta.constraints - to osobny krok Django >=4.1, validate_constraints(), wołany automatycznie przez ModelForm._post_clean(). Ten krok DZIAŁA dla warunkowych constraintów, ale tylko gdy pole użyte w condition (deleted_at) nie jest wykluczone z walidacji - a pole spoza Meta.fields formularza Django automatycznie wyklucza. Skutek bez naprawy: UniqueConstraint cicho pomija walidację (łapie FieldError, kolizja przechodzi formularz), ExclusionConstraint w ogóle nie łapie FieldError (is_valid() wywala się wyjątkiem, HTTP 500, przy KAŻDYM zapisie - nie tylko przy kolizji). Naprawa: deleted_at jest teraz jawnym, ukrytym/wyłączonym polem w generuj_formularz_dla_autorow (zawsze None - formularz operuje tylko na żywych wierszach). Druga, niezależna luka: formset inline (generuj_inline_dla_autorow) nie łapał kolizji NOWEGO wiersza z ISTNIEJĄCYM widocznym w tym samym formsecie - dla nowego wiersza rekord nie jest jeszcze ustawiony w momencie walidacji (przypisuje go dopiero save_new(), po walidacji), więc DB-owy UniqueConstraint.validate() pomija sprawdzenie (NULL != NULL), a formsetowe validate_unique() Django sprawdza pary formularzy tylko dla constraintów bezwarunkowych. Naprawa: ręczne porównanie par formularzy w formset.clean() (_waliduj_kolizje_autorstwa_w_formsecie), bez zapytań do bazy. Testy: - src/bpp/tests/test_admin/test_autor_inline_unique.py - standalone Wydawnictwo_Ciagle_Autor_Admin i inline formset, obie pułapki wyżej. - src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py - odwracalność migracji (forward/backward/forward), w tym legacy constraintu. Wynik: src/import_sqlite/tests/test_patent_apply.py:: test_apply_idempotent_update - zielony. test_soft_delete/, test_cache/, test_admin/ - bez regresji (1300+ passed). makemigrations --check --dry-run (bpp) - brak zmian. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- src/bpp/admin/core.py | 76 +++++ .../migrations/0490_autor_warunkowy_unique.py | 146 ++++++++++ src/bpp/models/patent.py | 40 ++- src/bpp/models/wydawnictwo_ciagle.py | 51 +++- src/bpp/models/wydawnictwo_zwarte.py | 40 ++- .../test_admin/test_autor_inline_unique.py | 264 ++++++++++++++++++ .../test_migracja_0490_rewers.py | 86 ++++++ 7 files changed, 685 insertions(+), 18 deletions(-) create mode 100644 src/bpp/migrations/0490_autor_warunkowy_unique.py create mode 100644 src/bpp/tests/test_admin/test_autor_inline_unique.py create mode 100644 src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py diff --git a/src/bpp/admin/core.py b/src/bpp/admin/core.py index 78330aaa2..3a2ef1eeb 100644 --- a/src/bpp/admin/core.py +++ b/src/bpp/admin/core.py @@ -152,6 +152,25 @@ class baseModel_AutorForm(forms.ModelForm): widget=HiddenInput, queryset=baseModel.rekord.get_queryset() ) + # `deleted_at` MUSI być polem formularza (choć ukrytym/wyłączonym) — + # inaczej Django wyklucza je z walidacji modelu (nie ma go w + # Meta.fields => `_get_validation_exclusions()` dorzuca je do + # `exclude`), a `Meta.constraints` (warunkowe UniqueConstraint / + # ExclusionConstraint z Taska 3c, `condition=Q(deleted_at__isnull=…)`) + # odwołują się właśnie do tego pola. Skutek bez tego pola: dla + # UniqueConstraint Django CICHO POMIJA walidację (łapie FieldError i + # nie zgłasza błędu — kolizja przechodzi, IntegrityError wyskakuje + # dopiero przy zapisie), a dla ExclusionConstraint Django W OGÓLE NIE + # ŁAPIE tego FieldError — `is_valid()` wywala się niekontrolowanym + # wyjątkiem (HTTP 500) przy KAŻDYM zapisie, nie tylko przy kolizji. + # Formularz zawsze dotyczy ŻYWEGO wiersza (managery `objects`/ + # `autorzy_set` już odfiltrowują soft-deleted), więc `disabled=True` + # + `initial=None` jest zawsze poprawną wartością — pole nigdy nie + # jest edytowalne przez usera. + deleted_at = forms.DateTimeField( + required=False, disabled=True, widget=HiddenInput + ) + autor = forms.ModelChoiceField( queryset=Autor.objects.all(), widget=autocomplete.ModelSelect2(url="bpp:autor-autocomplete"), @@ -317,6 +336,7 @@ class Meta: "profil_orcid", DATA_OSWIADCZENIA, "kolejnosc", + "deleted_at", ] if include_dyscyplina: @@ -341,6 +361,60 @@ class Meta: return baseModel_AutorForm +def _waliduj_kolizje_autorstwa_w_formsecie(formset): + """Task 3c: warunkowy `UniqueConstraint`/`ExclusionConstraint` + (`condition=Q(deleted_at__isnull=True)`) na *_Autor NIE łapie kolizji + NOWEGO wiersza formsetu z ISTNIEJĄCYM (widocznym w tym samym formsecie) + wierszem. Powód: dla nowego wiersza `rekord` nie jest jeszcze ustawiony + w momencie walidacji (przypisuje go dopiero + `BaseInlineFormSet.save_new()`, PO walidacji) — DB-owy + `UniqueConstraint.validate()` po prostu pomija sprawdzenie, gdy pole + złożonego klucza jest `None` (`NULL != NULL` w SQL). A formsetowe + `validate_unique()` (Django) sprawdza pary formularzy tylko dla + constraintów BEZWARUNKOWYCH (`Meta.total_unique_constraints`, + `condition is None`) — nasze, warunkowe, są tam pomijane. Stąd ręczne + porównanie par formularzy widocznych w formsecie (bez zapytań do bazy — + `formset.instance`, czyli rodzic/rekord, jest wspólny dla wszystkich + wierszy formsetu z definicji inline).""" + forms_to_delete = formset.deleted_forms + aktywne = [ + f + for f in formset.forms + if getattr(f, "cleaned_data", None) and f not in forms_to_delete + ] + + widziane_typ = {} + widziane_kolejnosc = {} + for form in aktywne: + cd = form.cleaned_data + autor = cd.get("autor") + if autor is None: + continue + + typ = cd.get("typ_odpowiedzialnosci") + if typ is not None: + klucz = (autor.pk, typ.pk) + if klucz in widziane_typ: + form.add_error( + None, + "To powiązanie autora z tym typem odpowiedzialności " + "już istnieje w tym rekordzie.", + ) + else: + widziane_typ[klucz] = form + + kolejnosc = cd.get("kolejnosc") + if kolejnosc is not None: + klucz = (autor.pk, kolejnosc) + if klucz in widziane_kolejnosc: + form.add_error( + None, + "Ten autor ma już powiązanie z tą kolejnością.", + ) + else: + widziane_kolejnosc[klucz] = form + + def generuj_inline_dla_autorow(baseModel, include_dyscyplina=True): MAKSYMALNA_ILOSC_AUTOROW_W_FORMULARZU = 25 @@ -367,6 +441,8 @@ def clean(self): "Liczba podanych procent odpowiedzialności przekracza 100.0" ) + _waliduj_kolizje_autorstwa_w_formsecie(self) + baseClass = admin.StackedInline extraRows = 0 diff --git a/src/bpp/migrations/0490_autor_warunkowy_unique.py b/src/bpp/migrations/0490_autor_warunkowy_unique.py new file mode 100644 index 000000000..b2c5ecd90 --- /dev/null +++ b/src/bpp/migrations/0490_autor_warunkowy_unique.py @@ -0,0 +1,146 @@ +# Generated by Django 5.2.16 on 2026-08-06 16:00 + +import django.contrib.postgres.constraints +import django.db.models.constraints +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0489_soft_delete_autorzy_views"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="patent_autor", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="wydawnictwo_ciagle_autor", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="wydawnictwo_zwarte_autor", + unique_together=set(), + ), + migrations.AddConstraint( + model_name="patent_autor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("rekord", "autor", "typ_odpowiedzialnosci"), + name="pat_autor_uniq_rekord_autor_typ", + ), + ), + migrations.AddConstraint( + model_name="patent_autor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("rekord", "autor", "kolejnosc"), + name="pat_autor_uniq_rekord_autor_kolejnosc", + ), + ), + migrations.AddConstraint( + model_name="patent_autor", + constraint=django.contrib.postgres.constraints.ExclusionConstraint( + condition=models.Q(("deleted_at__isnull", True)), + deferrable=django.db.models.constraints.Deferrable["DEFERRED"], + expressions=[("rekord", "="), ("kolejnosc", "=")], + name="pat_autor_excl_rekord_kolejnosc", + ), + ), + migrations.AddConstraint( + model_name="wydawnictwo_ciagle_autor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("rekord", "autor", "typ_odpowiedzialnosci"), + name="wc_autor_uniq_rekord_autor_typ", + ), + ), + migrations.AddConstraint( + model_name="wydawnictwo_ciagle_autor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("rekord", "autor", "kolejnosc"), + name="wc_autor_uniq_rekord_autor_kolejnosc", + ), + ), + migrations.AddConstraint( + model_name="wydawnictwo_ciagle_autor", + constraint=django.contrib.postgres.constraints.ExclusionConstraint( + condition=models.Q(("deleted_at__isnull", True)), + deferrable=django.db.models.constraints.Deferrable["DEFERRED"], + expressions=[("rekord", "="), ("kolejnosc", "=")], + name="wc_autor_excl_rekord_kolejnosc", + ), + ), + migrations.AddConstraint( + model_name="wydawnictwo_zwarte_autor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("rekord", "autor", "typ_odpowiedzialnosci"), + name="wz_autor_uniq_rekord_autor_typ", + ), + ), + migrations.AddConstraint( + model_name="wydawnictwo_zwarte_autor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("rekord", "autor", "kolejnosc"), + name="wz_autor_uniq_rekord_autor_kolejnosc", + ), + ), + migrations.AddConstraint( + model_name="wydawnictwo_zwarte_autor", + constraint=django.contrib.postgres.constraints.ExclusionConstraint( + condition=models.Q(("deleted_at__isnull", True)), + deferrable=django.db.models.constraints.Deferrable["DEFERRED"], + expressions=[("rekord", "="), ("kolejnosc", "=")], + name="wz_autor_excl_rekord_kolejnosc", + ), + ), + # Drugi kawałek Taska 3c: legacy migracja 0132 dołożyła surowym SQL-em + # DODATKOWY, poza Django ORM, `UNIQUE (rekord_id, kolejnosc) + # DEFERRABLE INITIALLY DEFERRED` na wszystkich 3 tabelach — Django + # nigdy o nim nie wiedział (nie ma go w Meta), więc `makemigrations` + # go nie widzi i nie usunie. Ten constraint SAM blokował dokładnie tę + # samą regresję (re-insert po soft-delete), tylko odroczony do + # `COMMIT`/zwolnienia savepointu, więc test padał dopiero w + # teardown/`SET CONSTRAINTS ALL IMMEDIATE`, nie na samym INSERT. + # Zastępujemy go `ExclusionConstraint`-em dodanym wyżej (ten sam, + # który właśnie stworzyliśmy przez ORM) — jedynym typem ograniczenia + # w Postgresie łączącym `WHERE` (warunek soft-delete) z + # `DEFERRABLE` (wymagane przez drag&drop reorder adminsortable2). + migrations.RunSQL( + sql=( + "ALTER TABLE bpp_patent_autor " + "DROP CONSTRAINT bpp_patent_autor_unique_rekord_id_kolejnosc;" + ), + reverse_sql=( + "ALTER TABLE bpp_patent_autor " + "ADD CONSTRAINT bpp_patent_autor_unique_rekord_id_kolejnosc " + "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;" + ), + ), + migrations.RunSQL( + sql=( + "ALTER TABLE bpp_wydawnictwo_ciagle_autor DROP CONSTRAINT " + "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc;" + ), + reverse_sql=( + "ALTER TABLE bpp_wydawnictwo_ciagle_autor ADD CONSTRAINT " + "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc " + "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;" + ), + ), + migrations.RunSQL( + sql=( + "ALTER TABLE bpp_wydawnictwo_zwarte_autor DROP CONSTRAINT " + "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc;" + ), + reverse_sql=( + "ALTER TABLE bpp_wydawnictwo_zwarte_autor ADD CONSTRAINT " + "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc " + "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;" + ), + ), + ] diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py index cd06ccd49..5a7722ce3 100644 --- a/src/bpp/models/patent.py +++ b/src/bpp/models/patent.py @@ -1,7 +1,8 @@ from denorm import denormalized, depend_on_fields, depend_on_related -from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.constraints import ExclusionConstraint +from django.contrib.postgres.fields import ArrayField, RangeOperators from django.db import models -from django.db.models import CASCADE, SET_NULL, JSONField +from django.db.models import CASCADE, SET_NULL, Deferrable, JSONField, Q from django.utils.functional import cached_property from bpp.models import ( @@ -47,10 +48,37 @@ class Meta: verbose_name_plural = "powiązania autorów z patentami" app_label = "bpp" ordering = ("kolejnosc",) - unique_together = [ - ("rekord", "autor", "typ_odpowiedzialnosci"), - # Tu musi być autor, inaczej admin nie pozwoli wyedytować - ("rekord", "autor", "kolejnosc"), + # `unique_together` widziałby też wiersze soft-deleted (fizycznie + # wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa" + # (re-import, korekta kolejności, edycja inline). Warunkowy + # UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności + # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django + # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest + # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow` + # / `generuj_inline_dla_autorow`) — patrz Task 3c. + constraints = [ + models.UniqueConstraint( + fields=["rekord", "autor", "typ_odpowiedzialnosci"], + condition=Q(deleted_at__isnull=True), + name="pat_autor_uniq_rekord_autor_typ", + ), + models.UniqueConstraint( + fields=["rekord", "autor", "kolejnosc"], + condition=Q(deleted_at__isnull=True), + name="pat_autor_uniq_rekord_autor_kolejnosc", + ), + # Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id, + # kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 — + # patrz analogiczny komentarz w Wydawnictwo_Ciagle_Autor.Meta. + ExclusionConstraint( + name="pat_autor_excl_rekord_kolejnosc", + expressions=[ + ("rekord", RangeOperators.EQUAL), + ("kolejnosc", RangeOperators.EQUAL), + ], + condition=Q(deleted_at__isnull=True), + deferrable=Deferrable.DEFERRED, + ), ] indexes = [ models.Index(fields=["deleted_at"], name="patent_autor_deleted_at_idx"), diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py index 681d63f2b..54ea4312a 100644 --- a/src/bpp/models/wydawnictwo_ciagle.py +++ b/src/bpp/models/wydawnictwo_ciagle.py @@ -1,8 +1,9 @@ from denorm import denormalized, depend_on_fields, depend_on_related from dirtyfields.dirtyfields import DirtyFieldsMixin -from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.constraints import ExclusionConstraint +from django.contrib.postgres.fields import ArrayField, RangeOperators from django.db import models -from django.db.models import CASCADE, SET_NULL, JSONField +from django.db.models import CASCADE, SET_NULL, Deferrable, JSONField, Q from bpp.models import ( BazaModeluStreszczen, @@ -72,10 +73,48 @@ class Meta: verbose_name_plural = "powiązania autorów z wyd. ciągłymi" app_label = "bpp" ordering = ("kolejnosc",) - unique_together = [ - ("rekord", "autor", "typ_odpowiedzialnosci"), - # Tu musi być autor, inaczej admin nie pozwoli wyedytować - ("rekord", "autor", "kolejnosc"), + # `unique_together` widziałby też wiersze soft-deleted (fizycznie + # wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa" + # (re-import, korekta kolejności, edycja inline). Warunkowy + # UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności + # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django + # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest + # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow` + # / `generuj_inline_dla_autorow`) — patrz Task 3c. + constraints = [ + models.UniqueConstraint( + fields=["rekord", "autor", "typ_odpowiedzialnosci"], + condition=Q(deleted_at__isnull=True), + name="wc_autor_uniq_rekord_autor_typ", + ), + models.UniqueConstraint( + fields=["rekord", "autor", "kolejnosc"], + condition=Q(deleted_at__isnull=True), + name="wc_autor_uniq_rekord_autor_kolejnosc", + ), + # Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id, + # kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 — + # gwarantuje, że DWÓCH RÓŻNYCH autorów nie dzieli tej samej + # pozycji w obrębie rekordu. `UniqueConstraint` nie umie + # łączyć `condition` z `deferrable` (Django to blokuje — + # `condition` i `deferrable` się wykluczają), a `deferrable` + # jest tu wymagane przez drag&drop reorder w adminie + # (adminsortable2, patrz `sortable_field_name = "kolejnosc"`) + # — zamiana kolejności dwóch wierszy przejściowo dubluje + # wartość `kolejnosc` w obrębie jednej transakcji, co bez + # DEFERRED wywaliłoby się na pierwszym UPDATE. Stąd + # `ExclusionConstraint` (GiST + btree_gist, rozszerzenie już + # włączone w bazie) — jedyny typ ograniczenia w Postgresie, + # który łączy `WHERE` (warunek) z `DEFERRABLE`. + ExclusionConstraint( + name="wc_autor_excl_rekord_kolejnosc", + expressions=[ + ("rekord", RangeOperators.EQUAL), + ("kolejnosc", RangeOperators.EQUAL), + ], + condition=Q(deleted_at__isnull=True), + deferrable=Deferrable.DEFERRED, + ), ] indexes = [ models.Index(fields=["deleted_at"], name="wc_autor_deleted_at_idx"), diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py index 260bef535..40228b750 100644 --- a/src/bpp/models/wydawnictwo_zwarte.py +++ b/src/bpp/models/wydawnictwo_zwarte.py @@ -4,10 +4,11 @@ from denorm import denormalized, depend_on_fields, depend_on_related from dirtyfields.dirtyfields import DirtyFieldsMixin from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.constraints import ExclusionConstraint +from django.contrib.postgres.fields import ArrayField, RangeOperators from django.core.exceptions import ValidationError from django.db import models -from django.db.models import CASCADE, PROTECT, JSONField +from django.db.models import CASCADE, PROTECT, Deferrable, JSONField, Q from django.db.models.expressions import RawSQL from bpp import const @@ -81,10 +82,37 @@ class Meta: verbose_name_plural = "powiązania autorów z wyd. zwartymi" app_label = "bpp" ordering = ("kolejnosc",) - unique_together = [ - ("rekord", "autor", "typ_odpowiedzialnosci"), - # Tu musi być autor, inaczej admin nie pozwoli wyedytować - ("rekord", "autor", "kolejnosc"), + # `unique_together` widziałby też wiersze soft-deleted (fizycznie + # wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa" + # (re-import, korekta kolejności, edycja inline). Warunkowy + # UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności + # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django + # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest + # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow` + # / `generuj_inline_dla_autorow`) — patrz Task 3c. + constraints = [ + models.UniqueConstraint( + fields=["rekord", "autor", "typ_odpowiedzialnosci"], + condition=Q(deleted_at__isnull=True), + name="wz_autor_uniq_rekord_autor_typ", + ), + models.UniqueConstraint( + fields=["rekord", "autor", "kolejnosc"], + condition=Q(deleted_at__isnull=True), + name="wz_autor_uniq_rekord_autor_kolejnosc", + ), + # Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id, + # kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 — + # patrz analogiczny komentarz w Wydawnictwo_Ciagle_Autor.Meta. + ExclusionConstraint( + name="wz_autor_excl_rekord_kolejnosc", + expressions=[ + ("rekord", RangeOperators.EQUAL), + ("kolejnosc", RangeOperators.EQUAL), + ], + condition=Q(deleted_at__isnull=True), + deferrable=Deferrable.DEFERRED, + ), ] indexes = [ models.Index(fields=["deleted_at"], name="wz_autor_deleted_at_idx"), diff --git a/src/bpp/tests/test_admin/test_autor_inline_unique.py b/src/bpp/tests/test_admin/test_autor_inline_unique.py new file mode 100644 index 000000000..f71df042d --- /dev/null +++ b/src/bpp/tests/test_admin/test_autor_inline_unique.py @@ -0,0 +1,264 @@ +"""Task 3c — czy zamiana `unique_together` na warunkowy `UniqueConstraint`/ +`ExclusionConstraint` (``condition=deleted_at__isnull``) NIE zregresowała +walidacji formularza w adminie. + +Dwie osobne pułapki, obie związane z tym, że ``deleted_at`` NIE jest polem +formularza (``generuj_formularz_dla_autorow`` go nie miało): + +1. ``Model.validate_unique()`` w ogóle NIE sprawdza ``Meta.constraints`` + (ani warunkowych, ani bezwarunkowych) — to osobny krok, + ``Model.validate_constraints()`` (Django >=4.1), wołany automatycznie + przez ``ModelForm._post_clean()`` -> ``instance.full_clean(...)``. +2. ``validate_constraints()`` DZIAŁA dla warunkowych constraintów, ALE tylko + jeśli pole użyte w ``condition`` (tu: ``deleted_at``) nie jest wykluczone + z walidacji. Pole spoza ``Meta.fields`` formularza Django automatycznie + wrzuca do ``exclude`` (``_get_validation_exclusions()``). Skutek: + - dla ``UniqueConstraint`` — Django CICHO POMIJA walidację (łapie + ``FieldError`` i nic nie zgłasza — kolizja przechodzi formularz, + ``IntegrityError`` wyskakuje dopiero przy zapisie do bazy), + - dla ``ExclusionConstraint`` — Django W OGÓLE NIE ŁAPIE tego + ``FieldError`` — ``is_valid()`` wywala się niekontrolowanym wyjątkiem + (HTTP 500) przy KAŻDYM zapisie, nawet bez żadnej kolizji. + +Naprawa: ``deleted_at`` jest teraz jawnym, ukrytym/wyłączonym polem +formularza (zawsze ``None`` — formularz operuje tylko na żywych wierszach), +więc oba constrainty widzą je poprawnie i walidują się jak należy, bez +ręcznego ``clean()``. +""" + +import pytest +from django.contrib.admin.sites import AdminSite + +from bpp.admin.core import generuj_formularz_dla_autorow, generuj_inline_dla_autorow +from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor + +PREFIX = "autorzy_set" + + +def _form_class(): + return generuj_formularz_dla_autorow(Wydawnictwo_Ciagle_Autor, include_rekord=True) + + +def _inline_formset(rf, admin_user, wc, data): + inline_cls = generuj_inline_dla_autorow(Wydawnictwo_Ciagle_Autor) + inline = inline_cls(Wydawnictwo_Ciagle, AdminSite()) + request = rf.post("/") + request.user = admin_user + formset_cls = inline.get_formset(request, wc) + return formset_cls(data, instance=wc, prefix=PREFIX) + + +def _wca_row(idx, wca): + return { + f"{PREFIX}-{idx}-id": str(wca.pk), + f"{PREFIX}-{idx}-autor": str(wca.autor_id), + f"{PREFIX}-{idx}-jednostka": str(wca.jednostka_id), + f"{PREFIX}-{idx}-typ_odpowiedzialnosci": str(wca.typ_odpowiedzialnosci_id), + f"{PREFIX}-{idx}-zapisany_jako": wca.zapisany_jako, + f"{PREFIX}-{idx}-kolejnosc": str(wca.kolejnosc), + } + + +def _nowy_wiersz( + idx, *, autor, jednostka, typ_odpowiedzialnosci, zapisany_jako, kolejnosc +): + return { + f"{PREFIX}-{idx}-id": "", + f"{PREFIX}-{idx}-autor": str(autor.pk), + f"{PREFIX}-{idx}-jednostka": str(jednostka.pk), + f"{PREFIX}-{idx}-typ_odpowiedzialnosci": str(typ_odpowiedzialnosci.pk), + f"{PREFIX}-{idx}-zapisany_jako": zapisany_jako, + f"{PREFIX}-{idx}-kolejnosc": str(kolejnosc), + } + + +def _management(total, initial): + return { + f"{PREFIX}-TOTAL_FORMS": str(total), + f"{PREFIX}-INITIAL_FORMS": str(initial), + f"{PREFIX}-MIN_NUM_FORMS": "0", + f"{PREFIX}-MAX_NUM_FORMS": "1000", + } + + +def _data(wc, wca, **overrides): + data = { + "rekord": str(wc.pk), + "autor": str(wca.autor_id), + "jednostka": str(wca.jednostka_id), + "typ_odpowiedzialnosci": str(wca.typ_odpowiedzialnosci_id), + "zapisany_jako": wca.zapisany_jako, + "kolejnosc": "1", + } + data.update(overrides) + return data + + +@pytest.mark.django_db +def test_standalone_admin_form_lapie_kolizje_rekord_autor_typ( + wydawnictwo_ciagle_z_autorem, +): + """Nowe powiązanie z tym samym (rekord, autor, typ_odpowiedzialnosci), co + już istniejący ŻYWY wiersz, MUSI dać błąd formularza — nie + IntegrityError przy zapisie.""" + wc = wydawnictwo_ciagle_z_autorem + existing = wc.autorzy_set.get() + + # Pozycyjnie, jak robi to prawdziwy Django admin (`ModelForm(request.POST, + # request.FILES, instance=obj)`) — jako kwarg `data=` włącza w kodzie + # formularza gałąź oczekującą `kwargs["prefix"]` (dotyczy TYLKO formsetów + # inline, nie samodzielnego admina). + form = _form_class()(_data(wc, existing, kolejnosc="1"), None) + + assert not form.is_valid(), "kolizja (rekord, autor, typ) powinna być błędem" + + +@pytest.mark.django_db +def test_standalone_admin_form_lapie_kolizje_rekord_autor_kolejnosc( + wydawnictwo_ciagle_z_autorem, autor_jan_nowak +): + """Nowe powiązanie INNEGO typu odpowiedzialności, ale z tą samą + (rekord, autor, kolejnosc) — też musi być błędem formularza.""" + wc = wydawnictwo_ciagle_z_autorem + existing = wc.autorzy_set.get() + + from bpp.models import Typ_Odpowiedzialnosci + + inny_typ = Typ_Odpowiedzialnosci.objects.exclude( + pk=existing.typ_odpowiedzialnosci_id + ).first() + assert inny_typ is not None + + form = _form_class()( + _data( + wc, + existing, + kolejnosc=str(existing.kolejnosc), + typ_odpowiedzialnosci=str(inny_typ.pk), + ), + None, + ) + + assert not form.is_valid(), "kolizja (rekord, autor, kolejnosc) to błąd" + + +@pytest.mark.django_db +def test_standalone_admin_form_po_soft_delete_nie_koliduje( + wydawnictwo_ciagle_z_autorem, +): + """Po soft-delete starego powiązania to samo (rekord, autor, typ) musi + dać się zapisać ponownie — formularz nie może uznać skasowanego wiersza + za kolizję (wzorzec "usuń i wstaw od nowa" w adminie).""" + wc = wydawnictwo_ciagle_z_autorem + existing = wc.autorzy_set.get() + dane = _data(wc, existing, kolejnosc="1") + existing.delete() # soft + + form = _form_class()(dane, None) + + assert form.is_valid(), form.errors + + +@pytest.mark.django_db +def test_standalone_admin_form_edycja_wlasnego_wiersza_nie_koliduje_sama_ze_soba( + wydawnictwo_ciagle_z_autorem, +): + """Edycja ISTNIEJĄCEGO wiersza (te same wartości) nie może wykryć + kolizji z samym sobą.""" + wc = wydawnictwo_ciagle_z_autorem + existing = wc.autorzy_set.get() + + form = _form_class()( + _data(wc, existing, kolejnosc=str(existing.kolejnosc)), + None, + instance=existing, + ) + + assert form.is_valid(), form.errors + + +# --- Formset inline (`generuj_inline_dla_autorow`), realny widok Wydawnictwo_Ciagle --- + + +@pytest.mark.django_db +def test_inline_nowy_wiersz_koliduje_z_istniejacym_typ( + wydawnictwo_ciagle_z_autorem, rf, admin_user +): + """Dodanie NOWEGO wiersza kolidującego (rekord, autor, typ) z ISTNIEJĄCYM + (widocznym w tym samym formsecie) wierszem musi dać błąd formsetu.""" + wc = wydawnictwo_ciagle_z_autorem + existing = wc.autorzy_set.get() + + data = _management(total=2, initial=1) + data.update(_wca_row(0, existing)) + data.update( + _nowy_wiersz( + 1, + autor=existing.autor, + jednostka=existing.jednostka, + typ_odpowiedzialnosci=existing.typ_odpowiedzialnosci, + zapisany_jako=existing.zapisany_jako, + kolejnosc=existing.kolejnosc + 1, + ) + ) + + formset = _inline_formset(rf, admin_user, wc, data) + + assert not formset.is_valid(), formset.errors + + +@pytest.mark.django_db +def test_inline_nowy_wiersz_koliduje_z_istniejacym_kolejnosc( + wydawnictwo_ciagle_z_autorem, rf, admin_user, typy_odpowiedzialnosci +): + """Dodanie NOWEGO wiersza z tą samą (rekord, autor, kolejnosc), co + ISTNIEJĄCY wiersz (inny typ odpowiedzialności) — też błąd formsetu.""" + wc = wydawnictwo_ciagle_z_autorem + existing = wc.autorzy_set.get() + inny_typ = typy_odpowiedzialnosci["red."] + + data = _management(total=2, initial=1) + data.update(_wca_row(0, existing)) + data.update( + _nowy_wiersz( + 1, + autor=existing.autor, + jednostka=existing.jednostka, + typ_odpowiedzialnosci=inny_typ, + zapisany_jako=existing.zapisany_jako, + kolejnosc=existing.kolejnosc, + ) + ) + + formset = _inline_formset(rf, admin_user, wc, data) + + assert not formset.is_valid(), formset.errors + + +@pytest.mark.django_db +def test_inline_po_soft_delete_reinsert_przechodzi( + wydawnictwo_ciagle_z_autorem, rf, admin_user +): + """Formset MUSI przepuścić wzorzec "usuń stary wiersz (miękko) + dodaj + nowy o tych samych wartościach" — dokładnie regresja z Taska 3c.""" + wc = wydawnictwo_ciagle_z_autorem + existing = wc.autorzy_set.get() + + data = _management(total=2, initial=1) + row0 = _wca_row(0, existing) + row0[f"{PREFIX}-0-DELETE"] = "on" + data.update(row0) + data.update( + _nowy_wiersz( + 1, + autor=existing.autor, + jednostka=existing.jednostka, + typ_odpowiedzialnosci=existing.typ_odpowiedzialnosci, + zapisany_jako=existing.zapisany_jako, + kolejnosc=existing.kolejnosc, + ) + ) + + formset = _inline_formset(rf, admin_user, wc, data) + + assert formset.is_valid(), formset.errors diff --git a/src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py b/src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py new file mode 100644 index 000000000..b62b247e7 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py @@ -0,0 +1,86 @@ +"""Odwracalność migracji 0490 (warunkowy UniqueConstraint/ExclusionConstraint +na *_Autor + zastąpienie legacy `RunSQL` constraintu z migracji 0132). + +``backward`` musi przywrócić DOKŁADNIE stan sprzed migracji: bezwarunkowy +`unique_together` ORAZ legacy `UNIQUE (rekord_id, kolejnosc) DEFERRABLE +INITIALLY DEFERRED` — inaczej rollback zostawia bazę bez żadnego z dwóch +mechanizmów pilnujących unikalności kolejności autorów. +""" + +import pytest +from django.core.management import call_command +from django.db import connection + +TABELE = [ + "bpp_patent_autor", + "bpp_wydawnictwo_ciagle_autor", + "bpp_wydawnictwo_zwarte_autor", +] + +LEGACY_CONSTRAINT = { + "bpp_patent_autor": "bpp_patent_autor_unique_rekord_id_kolejnosc", + "bpp_wydawnictwo_ciagle_autor": ( + "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc" + ), + "bpp_wydawnictwo_zwarte_autor": ( + "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc" + ), +} + +PREFIX = { + "bpp_patent_autor": "pat", + "bpp_wydawnictwo_ciagle_autor": "wc", + "bpp_wydawnictwo_zwarte_autor": "wz", +} + + +def _constraint_names(cur, tabela): + """Nazwy ograniczeń (`pg_constraint`) i indeksów (`pg_indexes`) na + tabeli. Warunkowy `UniqueConstraint` (`condition=...`) w Postgresie NIE + jest zapisany w `pg_constraint` — Postgres nie ma składni `ADD + CONSTRAINT ... UNIQUE ... WHERE`, więc Django realizuje go jako zwykły + `CREATE UNIQUE INDEX ... WHERE ...` (widoczny tylko w `pg_indexes`). + `ExclusionConstraint` i zwykłe `UNIQUE` SĄ pełnoprawnymi constraintami + (`pg_constraint`, `contype='x'`/`'u'`). Stąd suma obu źródeł.""" + cur.execute( + "SELECT conname FROM pg_constraint WHERE conrelid = %s::regclass", + [tabela], + ) + nazwy = {row[0] for row in cur.fetchall()} + cur.execute("SELECT indexname FROM pg_indexes WHERE tablename = %s", [tabela]) + nazwy |= {row[0] for row in cur.fetchall()} + return nazwy + + +@pytest.mark.django_db +def test_migracja_0490_odwracalna(): + with connection.cursor() as cur: + for tabela in TABELE: + nazwy = _constraint_names(cur, tabela) + prefix = PREFIX[tabela] + assert f"{prefix}_autor_uniq_rekord_autor_typ" in nazwy + assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" in nazwy + assert f"{prefix}_autor_excl_rekord_kolejnosc" in nazwy + assert LEGACY_CONSTRAINT[tabela] not in nazwy + + call_command("migrate", "bpp", "0489", verbosity=0) + + with connection.cursor() as cur: + for tabela in TABELE: + nazwy = _constraint_names(cur, tabela) + prefix = PREFIX[tabela] + assert f"{prefix}_autor_uniq_rekord_autor_typ" not in nazwy + assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" not in nazwy + assert f"{prefix}_autor_excl_rekord_kolejnosc" not in nazwy + assert LEGACY_CONSTRAINT[tabela] in nazwy + + call_command("migrate", "bpp", "0490", verbosity=0) + + with connection.cursor() as cur: + for tabela in TABELE: + nazwy = _constraint_names(cur, tabela) + prefix = PREFIX[tabela] + assert f"{prefix}_autor_uniq_rekord_autor_typ" in nazwy + assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" in nazwy + assert f"{prefix}_autor_excl_rekord_kolejnosc" in nazwy + assert LEGACY_CONSTRAINT[tabela] not in nazwy From ce7c9bde9da5fe01acc0b73fca084aca47ef946e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 19:42:19 +0200 Subject: [PATCH 27/75] fix(soft-delete): runda poprawek 1 na Task 3c (recenzja: 2 Important + 4 Minor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Important 1 — utrata pokrycia indeksem na FK rekord. Warunkowe UniqueConstraint/ExclusionConstraint z pierwszej rundy sa CZESCIOWE (WHERE deleted_at IS NULL), wiec db_index=False na FK rekord (uzasadnione, dopoki unique_together dawalo PELNY indeks) przestalo byc uzasadnione - RI-check Postgresa przy DELETE rodzica, kolektor kaskady Django i global_objects/deleted_objects.filter(rekord=...) robilyby seq scan. Usunieto db_index=False z 3 modeli, zregenerowano migracje 0490 (dodane AlterField przywracajace indeks). Nowy test test_soft_delete/test_autor_rekord_index.py sprawdza wprost w pg_indexes. Important 2 — falszywy pozytyw walidatora formsetu. Uzycie formset.deleted_forms w _waliduj_kolizje_autorstwa_w_formsecie bylo bledne: ta property sama zaczyna od "if not self.is_valid(): return []", wiec gdy INNY wiersz formsetu mial blad walidacji pola, wiersze zaznaczone do usuniecia wracaly jako pusta lista i dostawaly falszywy komunikat o kolizji z wierszem, ktory je zastepuje. Naprawa: formset._should_delete_form (f) zamiast f not in formset.deleted_forms - patrzy tylko na dane TEGO wiersza. Nowy test test_inline_falszywy_duplikat_gdy_inny_wiersz_ma_blad, zweryfikowany jako faktycznie lapiacy regresje (failuje na starym kodzie). Minor 3 — walidator formsetu nie pokrywal ExclusionConstraint (rekord, kolejnosc) miedzy ROZNYMI autorami. Uproszczone: gola kolejnosc (bez autora) jest scislejszym nadzbiorem "ten sam autor, ta sama kolejnosc", wiec zastapila oba klucze jednym. Nowy test test_inline_dwaj_rozni_autorzy_ta_sama_kolejnosc. Minor 4 — komentarze w Meta 3 modeli powielaly blad briefu (twierdzily, ze validate_unique() ignoruje UniqueConstraint z condition; w rzeczywistosci validate_unique() w ogole nie patrzy na Meta.constraints, mechanizmem jest osobny validate_constraints(), Django >=4.1). Poprawione na zgodne z tym, co raport Taska 3c juz ustalil. Minor 5 — test re-insertu inline konczyl sie na is_valid(), nie dowodzil naprawy regresji IntegrityError PRZY ZAPISIE. Dodano formset.save() i asercje na stan bazy (1 zywy wiersz, inny pk, stary wiersz w global_objects z deleted_at ustawionym). Minor 6 — RunSQL DROP CONSTRAINT bez IF EXISTS wywalilby migracje na instancji, gdzie constraint z 2018 zdjeto juz kiedys recznie. Dodano IF EXISTS do wszystkich 3 DROP. Udokumentowano w komentarzu na gorze migracji, ze reverse_sql nie przejdzie, jesli w tabeli sa juz soft-deletowane wiersze dublujace (rekord_id, kolejnosc) z zywymi - rollback przestaje dzialac dokladnie wtedy, gdy funkcja soft-delete byla juz uzywana. Migracja 0490 zamendowana in-place (nie wypchnieta do origin, brief-owa zasada "nigdy nie modyfikuj migracji" dotyczy migracji, na ktorych ktos inny moglby juz polegac). Weryfikacja: uv run pytest src/bpp/tests/test_soft_delete/ src/bpp/tests/test_admin/ src/import_sqlite/ -q -> 714 passed. uv run ruff check src/bpp -> 36 bledow, wszystkie przedistniejace w plikach spoza zakresu tego taska (zero w plikach dotknietych). makemigrations --check --dry-run (bpp) -> brak zmian. pre-commit run -> wszystkie hooki Passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- src/bpp/admin/core.py | 60 +++++--- .../migrations/0490_autor_warunkowy_unique.py | 84 +++++++++-- src/bpp/models/patent.py | 28 +++- src/bpp/models/wydawnictwo_ciagle.py | 28 +++- src/bpp/models/wydawnictwo_zwarte.py | 28 +++- .../test_admin/test_autor_inline_unique.py | 137 ++++++++++++++++-- .../test_autor_rekord_index.py | 43 ++++++ 7 files changed, 338 insertions(+), 70 deletions(-) create mode 100644 src/bpp/tests/test_soft_delete/test_autor_rekord_index.py diff --git a/src/bpp/admin/core.py b/src/bpp/admin/core.py index 3a2ef1eeb..0f9e5a5c4 100644 --- a/src/bpp/admin/core.py +++ b/src/bpp/admin/core.py @@ -368,19 +368,33 @@ def _waliduj_kolizje_autorstwa_w_formsecie(formset): wierszem. Powód: dla nowego wiersza `rekord` nie jest jeszcze ustawiony w momencie walidacji (przypisuje go dopiero `BaseInlineFormSet.save_new()`, PO walidacji) — DB-owy - `UniqueConstraint.validate()` po prostu pomija sprawdzenie, gdy pole - złożonego klucza jest `None` (`NULL != NULL` w SQL). A formsetowe - `validate_unique()` (Django) sprawdza pary formularzy tylko dla - constraintów BEZWARUNKOWYCH (`Meta.total_unique_constraints`, - `condition is None`) — nasze, warunkowe, są tam pomijane. Stąd ręczne - porównanie par formularzy widocznych w formsecie (bez zapytań do bazy — - `formset.instance`, czyli rodzic/rekord, jest wspólny dla wszystkich - wierszy formsetu z definicji inline).""" - forms_to_delete = formset.deleted_forms + `UniqueConstraint.validate()`/`ExclusionConstraint.validate()` po prostu + pomijają sprawdzenie, gdy pole złożonego klucza jest `None` (`NULL != + NULL` w SQL). A formsetowe `BaseModelFormSet.validate_unique()` (Django) + sprawdza pary formularzy tylko dla constraintów BEZWARUNKOWYCH + (`Meta.total_unique_constraints`, `condition is None`) — nasze, + warunkowe, są tam pomijane. Stąd ręczne porównanie par formularzy + widocznych w formsecie (bez zapytań do bazy — `formset.instance`, czyli + rodzic/rekord, jest wspólny dla wszystkich wierszy formsetu z definicji + inline). Dwa klucze pokrywają WSZYSTKIE TRZY constrainty z Meta: + (autor, typ) ↔ `..._uniq_rekord_autor_typ`; goła `kolejnosc` (bez + autora) ↔ zarówno `..._uniq_rekord_autor_kolejnosc` (ten sam autor z + dwoma wpisami o tej samej kolejności), jak i `..._excl_rekord_kolejnosc` + (DWÓCH RÓŻNYCH autorów na tej samej pozycji) — goła `kolejnosc` jest + ściślejsza (autor nieistotny), więc pokrywa oba naraz.""" + # UWAGA: `formset.deleted_forms` NIE nadaje się tu do użytku — jego + # własna implementacja zaczyna od `if not self.is_valid(): return []`. + # Wołane z wnętrza `clean()` (a więc z wnętrza `full_clean()`), gdy + # JAKIKOLWIEK INNY wiersz formsetu ma błąd walidacji pola, zwraca pustą + # listę — wiersze faktycznie zaznaczone do usunięcia trafiłyby wtedy do + # `aktywne` i dostały FAŁSZYWY komunikat o kolizji z wierszem, który + # dopiero je zastępuje (wzorzec „usuń + wstaw od nowa" w JEDNYM + # submicie). `_should_delete_form()` sprawdza tylko dane TEGO wiersza, + # bez wywoływania `is_valid()` na całym formsecie. aktywne = [ f for f in formset.forms - if getattr(f, "cleaned_data", None) and f not in forms_to_delete + if getattr(f, "cleaned_data", None) and not formset._should_delete_form(f) ] widziane_typ = {} @@ -388,6 +402,21 @@ def _waliduj_kolizje_autorstwa_w_formsecie(formset): for form in aktywne: cd = form.cleaned_data autor = cd.get("autor") + kolejnosc = cd.get("kolejnosc") + + # Goła `kolejnosc` (bez autora) — pokrywa zarówno „ten sam autor + # dwa razy na tej samej pozycji" jak i „dwóch różnych autorów na + # tej samej pozycji" (`..._excl_rekord_kolejnosc` w Meta nie patrzy + # na autora wcale). + if kolejnosc is not None: + if kolejnosc in widziane_kolejnosc: + form.add_error( + None, + "To miejsce w kolejności autorów jest już zajęte.", + ) + else: + widziane_kolejnosc[kolejnosc] = form + if autor is None: continue @@ -403,17 +432,6 @@ def _waliduj_kolizje_autorstwa_w_formsecie(formset): else: widziane_typ[klucz] = form - kolejnosc = cd.get("kolejnosc") - if kolejnosc is not None: - klucz = (autor.pk, kolejnosc) - if klucz in widziane_kolejnosc: - form.add_error( - None, - "Ten autor ma już powiązanie z tą kolejnością.", - ) - else: - widziane_kolejnosc[klucz] = form - def generuj_inline_dla_autorow(baseModel, include_dyscyplina=True): MAKSYMALNA_ILOSC_AUTOROW_W_FORMULARZU = 25 diff --git a/src/bpp/migrations/0490_autor_warunkowy_unique.py b/src/bpp/migrations/0490_autor_warunkowy_unique.py index b2c5ecd90..c42219087 100644 --- a/src/bpp/migrations/0490_autor_warunkowy_unique.py +++ b/src/bpp/migrations/0490_autor_warunkowy_unique.py @@ -1,7 +1,38 @@ -# Generated by Django 5.2.16 on 2026-08-06 16:00 +# Generated by Django 5.2.16 on 2026-08-06 17:16 +# +# Task 3c. Trzy powiązane zmiany na Wydawnictwo_Ciagle_Autor, +# Wydawnictwo_Zwarte_Autor, Patent_Autor: +# +# 1. `unique_together` (widziałby też wiersze soft-deleted) -> dwa warunkowe +# `UniqueConstraint` (`condition=deleted_at__isnull`). +# 2. Przywrócenie zwykłego indeksu na FK `rekord` (`AlterField`, kasuje +# wcześniejsze `db_index=False`). Powód: dopóki `unique_together` dawało +# PEŁNY (nie częściowy) indeks btree z `rekord` jako kolumną wiodącą, +# `db_index=False` na FK było uzasadnione — auto-indeks byłby redundantny. +# Warunkowe constrainty z (1) są CZĘŚCIOWE (`WHERE deleted_at IS NULL`) — +# nie pokrywają już zapytań po samym `rekord` bez tego predykatu +# (RI-check Postgresa przy DELETE rodzica, kolektor kaskady Django, +# `global_objects`/`deleted_objects`.filter(rekord=…)). +# 3. Zastąpienie legacy `RunSQL` z migracji 0132 (2018) — DODATKOWEGO, +# poza Django ORM, `UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY +# DEFERRED` (nigdy nie było w żadnym `Meta`, więc `makemigrations` go nie +# widziało) — `ExclusionConstraint`-em (GiST + btree_gist, rozszerzenie +# już włączone od migracji 0056). `UniqueConstraint` nie umie łączyć +# `condition` z `deferrable` (Django to blokuje), a `deferrable` jest +# wymagany przez drag&drop reorder autorów w adminie (adminsortable2, +# `sortable_field_name = "kolejnosc"`) — zamiana kolejności dwóch wierszy +# przejściowo dubluje `kolejnosc` w obrębie jednej transakcji. +# +# UWAGA przy rollbacku (`reverse_sql` niżej): przywraca legacy `UNIQUE +# (rekord_id, kolejnosc) DEFERRABLE`, ale to NIE zadziała, jeśli w tabeli są +# już soft-deletowane wiersze dublujące `(rekord_id, kolejnosc)` z żywymi — +# taki stan jest dozwolony PO tej migracji (to właśnie ona to umożliwia), +# ale narusza bezwarunkowy constraint SPRZED niej. Rollback padnie +# dokładnie wtedy, gdy funkcja soft-delete była już używana. import django.contrib.postgres.constraints import django.db.models.constraints +import django.db.models.deletion from django.db import migrations, models @@ -23,6 +54,33 @@ class Migration(migrations.Migration): name="wydawnictwo_zwarte_autor", unique_together=set(), ), + migrations.AlterField( + model_name="patent_autor", + name="rekord", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="autorzy_set", + to="bpp.patent", + ), + ), + migrations.AlterField( + model_name="wydawnictwo_ciagle_autor", + name="rekord", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="autorzy_set", + to="bpp.wydawnictwo_ciagle", + ), + ), + migrations.AlterField( + model_name="wydawnictwo_zwarte_autor", + name="rekord", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="autorzy_set", + to="bpp.wydawnictwo_zwarte", + ), + ), migrations.AddConstraint( model_name="patent_autor", constraint=models.UniqueConstraint( @@ -98,22 +156,18 @@ class Migration(migrations.Migration): name="wz_autor_excl_rekord_kolejnosc", ), ), - # Drugi kawałek Taska 3c: legacy migracja 0132 dołożyła surowym SQL-em - # DODATKOWY, poza Django ORM, `UNIQUE (rekord_id, kolejnosc) - # DEFERRABLE INITIALLY DEFERRED` na wszystkich 3 tabelach — Django - # nigdy o nim nie wiedział (nie ma go w Meta), więc `makemigrations` - # go nie widzi i nie usunie. Ten constraint SAM blokował dokładnie tę - # samą regresję (re-insert po soft-delete), tylko odroczony do - # `COMMIT`/zwolnienia savepointu, więc test padał dopiero w - # teardown/`SET CONSTRAINTS ALL IMMEDIATE`, nie na samym INSERT. - # Zastępujemy go `ExclusionConstraint`-em dodanym wyżej (ten sam, - # który właśnie stworzyliśmy przez ORM) — jedynym typem ograniczenia - # w Postgresie łączącym `WHERE` (warunek soft-delete) z - # `DEFERRABLE` (wymagane przez drag&drop reorder adminsortable2). + # Trzeci kawałek Taska 3c (patrz komentarz na górze pliku): usuń + # legacy constraint z migracji 0132, zastąpiony ExclusionConstraint- + # em dodanym wyżej. `IF EXISTS` na DROP — instancja, na której ten + # constraint zdjęto już kiedyś ręcznie (spoza Django), nie może + # wywalić migracji. `reverse_sql` przywraca oryginalne DDL z 0132 + # bez `IF EXISTS` (ADD CONSTRAINT tego nie ma w składni) — patrz + # zastrzeżenie o rollbacku na górze pliku. migrations.RunSQL( sql=( "ALTER TABLE bpp_patent_autor " - "DROP CONSTRAINT bpp_patent_autor_unique_rekord_id_kolejnosc;" + "DROP CONSTRAINT IF EXISTS " + "bpp_patent_autor_unique_rekord_id_kolejnosc;" ), reverse_sql=( "ALTER TABLE bpp_patent_autor " @@ -124,6 +178,7 @@ class Migration(migrations.Migration): migrations.RunSQL( sql=( "ALTER TABLE bpp_wydawnictwo_ciagle_autor DROP CONSTRAINT " + "IF EXISTS " "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc;" ), reverse_sql=( @@ -135,6 +190,7 @@ class Migration(migrations.Migration): migrations.RunSQL( sql=( "ALTER TABLE bpp_wydawnictwo_zwarte_autor DROP CONSTRAINT " + "IF EXISTS " "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc;" ), reverse_sql=( diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py index 5a7722ce3..ab3a26203 100644 --- a/src/bpp/models/patent.py +++ b/src/bpp/models/patent.py @@ -38,9 +38,17 @@ class Patent_Autor(BppAutorstwoSoftDeleteMixin, BazaModeluOdpowiedzialnosciAutor "Patent", CASCADE, related_name="autorzy_set", - # Auto-indeks FK redundantny: unique_together ma rekord jako kolumnę - # wiodącą — pokrywa lookup po rekord i kaskadę usuwania. - db_index=False, + # Task 3c: `db_index=False` było tu uzasadnione, dopóki + # `unique_together` dawało pełny (nie częściowy) indeks btree z + # `rekord` jako kolumną wiodącą. Warunkowe UniqueConstraint/ + # ExclusionConstraint z Taska 3c są CZĘŚCIOWE + # (`WHERE deleted_at IS NULL`) — nie pokrywają zapytań po samym + # `rekord` bez tego predykatu: RI-check Postgresa przy DELETE + # rodzica, kolektor kaskady Django (`_base_manager`, bez filtra + # soft-delete) i `global_objects`/`deleted_objects`.filter(rekord=…) + # (czyli dokładnie zapytania, po które soft-delete istnieje). + # Zwykły indeks FK (domyślny dla ForeignKey) jest więc znów + # potrzebny — NIE ustawiaj tu `db_index=False`. ) class Meta: @@ -52,10 +60,16 @@ class Meta: # wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa" # (re-import, korekta kolejności, edycja inline). Warunkowy # UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności - # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django - # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest - # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow` - # / `generuj_inline_dla_autorow`) — patrz Task 3c. + # TYLKO wśród żywych wierszy. Django `validate_unique()` W OGÓLE nie + # patrzy na `Meta.constraints` (mechanizmem jest osobny + # `Model.validate_constraints()`, Django >=4.1) — a TEN wymaga, żeby + # pole użyte w `condition` (`deleted_at`) nie było wykluczone z + # walidacji formularza, inaczej cicho pomija sprawdzenie (albo, dla + # ExclusionConstraint, rzuca gołym FieldError). Stąd `deleted_at` + # jest jawnym, ukrytym polem w `generuj_formularz_dla_autorow` + # (`bpp.admin.core`). Formset inline ma DODATKOWO ręczną walidację + # kolizji nowy-wiersz-vs-istniejący-w-tym-samym-submicie + # (`generuj_inline_dla_autorow`) — patrz Task 3c, raport. constraints = [ models.UniqueConstraint( fields=["rekord", "autor", "typ_odpowiedzialnosci"], diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py index 54ea4312a..8995240fd 100644 --- a/src/bpp/models/wydawnictwo_ciagle.py +++ b/src/bpp/models/wydawnictwo_ciagle.py @@ -63,9 +63,17 @@ class Wydawnictwo_Ciagle_Autor( "Wydawnictwo_Ciagle", CASCADE, related_name="autorzy_set", - # Auto-indeks FK redundantny: unique_together ma rekord jako kolumnę - # wiodącą w obu krotkach — pokrywa lookup po rekord i kaskadę usuwania. - db_index=False, + # Task 3c: `db_index=False` było tu uzasadnione, dopóki + # `unique_together` dawało pełny (nie częściowy) indeks btree z + # `rekord` jako kolumną wiodącą. Warunkowe UniqueConstraint/ + # ExclusionConstraint z Taska 3c są CZĘŚCIOWE + # (`WHERE deleted_at IS NULL`) — nie pokrywają zapytań po samym + # `rekord` bez tego predykatu: RI-check Postgresa przy DELETE + # rodzica, kolektor kaskady Django (`_base_manager`, bez filtra + # soft-delete) i `global_objects`/`deleted_objects`.filter(rekord=…) + # (czyli dokładnie zapytania, po które soft-delete istnieje). + # Zwykły indeks FK (domyślny dla ForeignKey) jest więc znów + # potrzebny — NIE ustawiaj tu `db_index=False`. ) class Meta: @@ -77,10 +85,16 @@ class Meta: # wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa" # (re-import, korekta kolejności, edycja inline). Warunkowy # UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności - # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django - # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest - # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow` - # / `generuj_inline_dla_autorow`) — patrz Task 3c. + # TYLKO wśród żywych wierszy. Django `validate_unique()` W OGÓLE nie + # patrzy na `Meta.constraints` (mechanizmem jest osobny + # `Model.validate_constraints()`, Django >=4.1) — a TEN wymaga, żeby + # pole użyte w `condition` (`deleted_at`) nie było wykluczone z + # walidacji formularza, inaczej cicho pomija sprawdzenie (albo, dla + # ExclusionConstraint, rzuca gołym FieldError). Stąd `deleted_at` + # jest jawnym, ukrytym polem w `generuj_formularz_dla_autorow` + # (`bpp.admin.core`). Formset inline ma DODATKOWO ręczną walidację + # kolizji nowy-wiersz-vs-istniejący-w-tym-samym-submicie + # (`generuj_inline_dla_autorow`) — patrz Task 3c, raport. constraints = [ models.UniqueConstraint( fields=["rekord", "autor", "typ_odpowiedzialnosci"], diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py index 40228b750..f4c9084e9 100644 --- a/src/bpp/models/wydawnictwo_zwarte.py +++ b/src/bpp/models/wydawnictwo_zwarte.py @@ -72,9 +72,17 @@ class Wydawnictwo_Zwarte_Autor( "Wydawnictwo_Zwarte", CASCADE, related_name="autorzy_set", - # Auto-indeks FK redundantny: unique_together ma rekord jako kolumnę - # wiodącą — pokrywa lookup po rekord i kaskadę usuwania. - db_index=False, + # Task 3c: `db_index=False` było tu uzasadnione, dopóki + # `unique_together` dawało pełny (nie częściowy) indeks btree z + # `rekord` jako kolumną wiodącą. Warunkowe UniqueConstraint/ + # ExclusionConstraint z Taska 3c są CZĘŚCIOWE + # (`WHERE deleted_at IS NULL`) — nie pokrywają zapytań po samym + # `rekord` bez tego predykatu: RI-check Postgresa przy DELETE + # rodzica, kolektor kaskady Django (`_base_manager`, bez filtra + # soft-delete) i `global_objects`/`deleted_objects`.filter(rekord=…) + # (czyli dokładnie zapytania, po które soft-delete istnieje). + # Zwykły indeks FK (domyślny dla ForeignKey) jest więc znów + # potrzebny — NIE ustawiaj tu `db_index=False`. ) class Meta: @@ -86,10 +94,16 @@ class Meta: # wciąż są w tabeli) i blokowałby wzorzec "skasuj i wstaw od nowa" # (re-import, korekta kolejności, edycja inline). Warunkowy # UniqueConstraint (condition=deleted_at__isnull) pilnuje unikalności - # TYLKO wśród żywych wierszy. Walidacja formularza admina (Django - # `validate_unique()` ignoruje `UniqueConstraint` z `condition`) jest - # dopięta ręcznie w `bpp.admin.core` (`generuj_formularz_dla_autorow` - # / `generuj_inline_dla_autorow`) — patrz Task 3c. + # TYLKO wśród żywych wierszy. Django `validate_unique()` W OGÓLE nie + # patrzy na `Meta.constraints` (mechanizmem jest osobny + # `Model.validate_constraints()`, Django >=4.1) — a TEN wymaga, żeby + # pole użyte w `condition` (`deleted_at`) nie było wykluczone z + # walidacji formularza, inaczej cicho pomija sprawdzenie (albo, dla + # ExclusionConstraint, rzuca gołym FieldError). Stąd `deleted_at` + # jest jawnym, ukrytym polem w `generuj_formularz_dla_autorow` + # (`bpp.admin.core`). Formset inline ma DODATKOWO ręczną walidację + # kolizji nowy-wiersz-vs-istniejący-w-tym-samym-submicie + # (`generuj_inline_dla_autorow`) — patrz Task 3c, raport. constraints = [ models.UniqueConstraint( fields=["rekord", "autor", "typ_odpowiedzialnosci"], diff --git a/src/bpp/tests/test_admin/test_autor_inline_unique.py b/src/bpp/tests/test_admin/test_autor_inline_unique.py index f71df042d..917a07521 100644 --- a/src/bpp/tests/test_admin/test_autor_inline_unique.py +++ b/src/bpp/tests/test_admin/test_autor_inline_unique.py @@ -2,28 +2,40 @@ `ExclusionConstraint` (``condition=deleted_at__isnull``) NIE zregresowała walidacji formularza w adminie. -Dwie osobne pułapki, obie związane z tym, że ``deleted_at`` NIE jest polem -formularza (``generuj_formularz_dla_autorow`` go nie miało): - -1. ``Model.validate_unique()`` w ogóle NIE sprawdza ``Meta.constraints`` - (ani warunkowych, ani bezwarunkowych) — to osobny krok, - ``Model.validate_constraints()`` (Django >=4.1), wołany automatycznie - przez ``ModelForm._post_clean()`` -> ``instance.full_clean(...)``. +Trzy osobne pułapki: + +1. ``Model.validate_unique()`` w ogóle NIE patrzy na ``Meta.constraints`` + (ani warunkowe, ani bezwarunkowe — ``_get_unique_checks()`` woła z + ``include_meta_constraints=False``). Mechanizmem, który TO sprawdza, jest + osobny krok, ``Model.validate_constraints()`` (Django >=4.1), wołany + automatycznie przez ``ModelForm._post_clean()`` -> ``instance.full_clean( + ...)``. 2. ``validate_constraints()`` DZIAŁA dla warunkowych constraintów, ALE tylko jeśli pole użyte w ``condition`` (tu: ``deleted_at``) nie jest wykluczone z walidacji. Pole spoza ``Meta.fields`` formularza Django automatycznie - wrzuca do ``exclude`` (``_get_validation_exclusions()``). Skutek: + wrzuca do ``exclude`` (``_get_validation_exclusions()``), a + ``generuj_formularz_dla_autorow`` nigdy nie miało ``deleted_at`` jako + pola. Skutek: - dla ``UniqueConstraint`` — Django CICHO POMIJA walidację (łapie ``FieldError`` i nic nie zgłasza — kolizja przechodzi formularz, ``IntegrityError`` wyskakuje dopiero przy zapisie do bazy), - dla ``ExclusionConstraint`` — Django W OGÓLE NIE ŁAPIE tego ``FieldError`` — ``is_valid()`` wywala się niekontrolowanym wyjątkiem (HTTP 500) przy KAŻDYM zapisie, nawet bez żadnej kolizji. - -Naprawa: ``deleted_at`` jest teraz jawnym, ukrytym/wyłączonym polem -formularza (zawsze ``None`` — formularz operuje tylko na żywych wierszach), -więc oba constrainty widzą je poprawnie i walidują się jak należy, bez -ręcznego ``clean()``. + Naprawa: ``deleted_at`` jest teraz jawnym, ukrytym/wyłączonym polem + formularza (zawsze ``None`` — formularz operuje tylko na żywych + wierszach). +3. Formset inline nie łapał kolizji NOWEGO wiersza z ISTNIEJĄCYM (rekord + nieznany dla nowego wiersza w momencie walidacji, formsetowe + ``validate_unique()`` pomija constrainty warunkowe). Naprawa: ręczna + ``_waliduj_kolizje_autorstwa_w_formsecie`` w ``bpp.admin.core``. + Runda poprawek 1: ta funkcja pierwotnie używała ``formset.deleted_forms`` + do wykluczenia wierszy zaznaczonych do usunięcia — ale ``deleted_forms`` + samo zaczyna od ``if not self.is_valid(): return []``, więc gdy INNY + wiersz formsetu miał błąd walidacji pola, wiersze DO USUNIĘCIA wracały + jako pusta lista i dostawały FAŁSZYWY komunikat o kolizji z wierszem, + który je zastępuje. Naprawione przez ``formset._should_delete_form(f)`` + (patrz ``test_inline_falszywy_duplikat_gdy_inny_wiersz_ma_blad`` niżej). """ import pytest @@ -235,14 +247,47 @@ def test_inline_nowy_wiersz_koliduje_z_istniejacym_kolejnosc( assert not formset.is_valid(), formset.errors +@pytest.mark.django_db +def test_inline_dwaj_rozni_autorzy_ta_sama_kolejnosc( + wydawnictwo_ciagle_z_autorem, autor_jan_nowak, rf, admin_user +): + """Minor 3, runda poprawek 1: DWÓCH RÓŻNYCH autorów na tej samej + pozycji łamie `..._excl_rekord_kolejnosc` (ta nie patrzy na autora + wcale) — walidator formsetu musi to złapać, nie tylko kolizję TEGO + SAMEGO autora.""" + wc = wydawnictwo_ciagle_z_autorem + existing = wc.autorzy_set.get() + + data = _management(total=2, initial=1) + data.update(_wca_row(0, existing)) + data.update( + _nowy_wiersz( + 1, + autor=autor_jan_nowak, # INNY autor niż `existing` + jednostka=existing.jednostka, + typ_odpowiedzialnosci=existing.typ_odpowiedzialnosci, + zapisany_jako="Nowak Jan", + kolejnosc=existing.kolejnosc, # ta sama pozycja + ) + ) + + formset = _inline_formset(rf, admin_user, wc, data) + + assert not formset.is_valid(), formset.errors + + @pytest.mark.django_db def test_inline_po_soft_delete_reinsert_przechodzi( wydawnictwo_ciagle_z_autorem, rf, admin_user ): """Formset MUSI przepuścić wzorzec "usuń stary wiersz (miękko) + dodaj - nowy o tych samych wartościach" — dokładnie regresja z Taska 3c.""" + nowy o tych samych wartościach" — dokładnie regresja z Taska 3c. Woła + `formset.save()` (nie tylko `is_valid()`) — regresja z brief-u to + `IntegrityError` PRZY ZAPISIE, `is_valid()`==True samo w sobie tego nie + dowodzi (Minor 5, runda poprawek 1).""" wc = wydawnictwo_ciagle_z_autorem existing = wc.autorzy_set.get() + stary_pk = existing.pk data = _management(total=2, initial=1) row0 = _wca_row(0, existing) @@ -262,3 +307,67 @@ def test_inline_po_soft_delete_reinsert_przechodzi( formset = _inline_formset(rf, admin_user, wc, data) assert formset.is_valid(), formset.errors + formset.save() + + zywe = list(Wydawnictwo_Ciagle_Autor.objects.filter(rekord=wc)) + assert len(zywe) == 1 + assert zywe[0].pk != stary_pk + assert zywe[0].autor_id == existing.autor_id + assert zywe[0].kolejnosc == existing.kolejnosc + + stary = Wydawnictwo_Ciagle_Autor.global_objects.get(pk=stary_pk) + assert stary.deleted_at is not None + + +@pytest.mark.django_db +def test_inline_falszywy_duplikat_gdy_inny_wiersz_ma_blad( + wydawnictwo_ciagle_z_autorem, autor_jan_nowak, rf, admin_user +): + """Important 2, runda poprawek 1: `formset.deleted_forms` zaczyna od + `if not self.is_valid(): return []` — gdy INNY wiersz formsetu ma błąd + walidacji pola, wiersz DO USUNIĘCIA (wiersz 0) był wcześniej błędnie + traktowany jako "aktywny" i dostawał fałszywy komunikat o kolizji z + wierszem, który go zastępuje (wiersz 1). Wiersz 2 ma osobny, PRAWDZIWY + błąd (brak `zapisany_jako`) — formset i tak jest niepoprawny, ale wiersz + 0 i 1 nie mogą dostać nieprawdziwego komunikatu o duplikacie.""" + wc = wydawnictwo_ciagle_z_autorem + existing = wc.autorzy_set.get() + + data = _management(total=3, initial=1) + row0 = _wca_row(0, existing) + row0[f"{PREFIX}-0-DELETE"] = "on" + data.update(row0) + data.update( + _nowy_wiersz( + 1, + autor=existing.autor, + jednostka=existing.jednostka, + typ_odpowiedzialnosci=existing.typ_odpowiedzialnosci, + zapisany_jako=existing.zapisany_jako, + kolejnosc=existing.kolejnosc, + ) + ) + trzeci = _nowy_wiersz( + 2, + autor=autor_jan_nowak, + jednostka=existing.jednostka, + typ_odpowiedzialnosci=existing.typ_odpowiedzialnosci, + zapisany_jako="", # celowo puste -> prawdziwy błąd tego wiersza + kolejnosc=existing.kolejnosc + 1, + ) + data.update(trzeci) + + formset = _inline_formset(rf, admin_user, wc, data) + + assert not formset.is_valid() + # UWAGA: `formset.errors` (lista) POMIJA wiersze zaznaczone do usunięcia + # (`BaseFormSet.full_clean()`: `if ... self._should_delete_form(form): + # continue` PRZED dopisaniem do `self._errors`) — nie jest indeksowana + # 1:1 z `self.forms`. Odwołujemy się więc wprost do `formset.forms[i]`. + # + # Wiersz 2 ma swój prawdziwy błąd (brak zapisany_jako). + assert formset.forms[2].errors.get("zapisany_jako") + # Wiersze 0 (usuwany) i 1 (re-insert) NIE mogą dostać komunikatu + # o kolizji — to fałszywy pozytyw, który naprawia _should_delete_form. + assert not formset.forms[0].errors.get("__all__") + assert not formset.forms[1].errors.get("__all__") diff --git a/src/bpp/tests/test_soft_delete/test_autor_rekord_index.py b/src/bpp/tests/test_soft_delete/test_autor_rekord_index.py new file mode 100644 index 000000000..00da7bef3 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_autor_rekord_index.py @@ -0,0 +1,43 @@ +"""Task 3c, runda poprawek 1 (Important 1): warunkowe UniqueConstraint/ +ExclusionConstraint na *_Autor są CZĘŚCIOWE (``WHERE deleted_at IS NULL``) — +nie pokrywają już zapytań po samym ``rekord`` bez tego predykatu (RI-check +Postgresa przy DELETE rodzica, kolektor kaskady Django, `global_objects`/ +`deleted_objects`.filter(rekord=…)). FK musi więc mieć z powrotem zwykły, +BEZWARUNKOWY indeks z ``rekord_id`` jako kolumną wiodącą.""" + +import pytest +from django.db import connection + +TABELE = [ + "bpp_patent_autor", + "bpp_wydawnictwo_ciagle_autor", + "bpp_wydawnictwo_zwarte_autor", +] + + +def _ma_bezwarunkowy_indeks_na_rekord_id(cur, tabela): + """Czy tabela ma indeks btree z `rekord_id` jako PIERWSZĄ kolumną i BEZ + klauzuli WHERE (częściowe indeksy z Taska 3c się nie liczą — nie + przyspieszają zapytań bez predykatu `deleted_at IS NULL`).""" + cur.execute( + "SELECT indexdef FROM pg_indexes WHERE tablename = %s", + [tabela], + ) + for (indexdef,) in cur.fetchall(): + if "WHERE" in indexdef: + continue + # np. "... USING btree (rekord_id)" albo "(rekord_id, ...)" + if "(rekord_id)" in indexdef or "(rekord_id," in indexdef: + return True + return False + + +@pytest.mark.django_db +@pytest.mark.parametrize("tabela", TABELE) +def test_rekord_ma_bezwarunkowy_indeks(tabela): + with connection.cursor() as cur: + assert _ma_bezwarunkowy_indeks_na_rekord_id(cur, tabela), ( + f"{tabela}: brak bezwarunkowego indeksu na rekord_id — " + "RI-check/kaskada/global_objects.filter(rekord=…) robiłyby " + "seq scan" + ) From 48bf4cbda7c9c0700d0b50c08f193db38c598ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 20:32:13 +0200 Subject: [PATCH 28/75] =?UTF-8?q?test(soft-delete):=20sp=C3=B3jno=C5=9B?= =?UTF-8?q?=C4=87=20bpp=5Fautorzy=5Fmat=20+=20odwr=C3=B3cenie=20kanark?= =?UTF-8?q?=C3=B3w=20(Task=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nowy plik test_cache_consistency.py: 4 testy spójności bpp_autorzy_mat (model Autorzy) / bpp_autorzy (AutorzyView) po soft-delete, restore, edycji skasowanego wiersza i kaskadzie queryset-owej *_Autor. Odwrócenie kanarków w test_soft_delete_preconditions.py: oryginalne dwa testy (dot. bpp_wydawnictwo_ciagle, faza 02) przepisane na docelowe asercje i oznaczone xfail(strict=True) — jedyny regresyjny dowód, że mechanizm dla publikacji wciąż nie działa (poza zakresem fazy 01). Dopisane dwa odpowiedniki dla bpp_wydawnictwo_ciagle_autor / bpp_autorzy_mat, które już działają — zielone. Weryfikacja mutacyjna: usunięcie gałęzi kasującej z funkcji refresh faktycznie psuje test_soft_delete_autorstwa_znika_z_mat (wiersz przeżywa delete()) — testy nie są tautologiami. uv run pytest src/bpp/tests/test_cache/ src/bpp/tests/test_soft_delete/ -q 125 passed, 1 skipped, 2 xfailed Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../test_soft_delete_preconditions.py | 199 ++++++++++++++---- .../test_cache_consistency.py | 144 +++++++++++++ 2 files changed, 306 insertions(+), 37 deletions(-) create mode 100644 src/bpp/tests/test_soft_delete/test_cache_consistency.py diff --git a/src/bpp/tests/test_cache/test_soft_delete_preconditions.py b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py index de916f1c1..2033494bb 100644 --- a/src/bpp/tests/test_cache/test_soft_delete_preconditions.py +++ b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py @@ -1,4 +1,4 @@ -"""Warunki wstepne soft-delete wobec triggerow cache (PR #312, faza 01). +"""Warunki wstepne soft-delete wobec triggerow cache (PR #312, faza 01/02). Spec soft-delete (2026-06-04) zaklada dwa fakty o warstwie cache: @@ -8,14 +8,30 @@ ktory MUSI przetrwac optymalizacje" -- plan 00). Oba fakty przestaly obowiazywac po migracjach 0432 (port PL/pgSQL, upsert bez -DELETE) i 0433 (bramka WHEN na liscie kolumn z pg_depend). Te testy to -przypinaja: sa ZIELONE na obecnym kodzie, czyli dokumentuja stan "soft-delete -by nie zadzialal". Faza 01 ma je odwrocic (zmienic asercje na docelowe) razem -z wprowadzeniem gałęzi kasujacej w funkcjach refresh + regeneracja bramki. +DELETE) i 0433 (bramka WHEN na liscie kolumn z pg_depend). Poczatkowo (przed +faza 01) ponizsze dwa testy to PRZYPINALY -- byly ZIELONE na ówczesnym +kodzie, dokumentujac stan "soft-delete by nie zadzialal". + +Faza 01 objela WYLACZNIE tabele ``*_Autor`` (migracja 0489: widok filtruje, +funkcja refresh ma galaz kasujaca, bramka WHEN zna ``deleted_at`` -- patrz +``test_soft_delete/test_views_sql.py``). Tabele publikacji (np. +``bpp_wydawnictwo_ciagle``) sa poza zakresem -- to faza 02. Dlatego: + +- oryginalne dwa testy ponizej sa ODWROCONE na docelowe asercje (to co + soft-delete MA robic), ale zostawione jako ``xfail`` -- to JEDYNY + regresyjny dowod, ze bramka i galaz kasujaca dzialaja poprawnie DOPIERO + po fazie 02 (gdy przestana byc xfail, to znak, ze ktos wdrozyl mechanizm + dla publikacji i NIE zaktualizowal tego markera -- patrz uwaga przy + ``xfail`` nizej); +- ponizej dopisane sa ich ODPOWIEDNIKI dla ``bpp_wydawnictwo_ciagle_autor`` + / ``bpp_autorzy_mat``, ktore juz DZIALAJA (faza 01) -- to one sa realnym + dowodem regresyjnym na CO DZIEN, nie oryginaly. Surowy SQL, zeby izolowac sam trigger bazodanowy (bez denorm / sygnalow -Django). Kolumne ``deleted_at`` dokladamy ALTER-em wewnatrz transakcji -testowej -- DDL w Postgresie jest transakcyjny, wiec rollback ja sprzata. +Django). Dla oryginalnych (publikacja) testow kolumne ``deleted_at`` +dokladamy ALTER-em wewnatrz transakcji testowej -- DDL w Postgresie jest +transakcyjny, wiec rollback ja sprzata. Dla nowych (``*_Autor``) testow +ALTER nie jest potrzebny -- kolumna jest realna od migracji 0488. """ import pytest @@ -67,15 +83,21 @@ def _filtruj_widok_po_deleted_at(cur): ) +@pytest.mark.xfail( + reason="faza 02 -- soft-delete publikacji (bpp_wydawnictwo_ciagle)", + strict=True, +) @pytest.mark.django_db -def test_update_samego_deleted_at_nie_odpala_triggera(): - """Bramka WHEN (0433) nie zna deleted_at -> UPDATE soft-delete nie wchodzi. - - django-soft-delete kasuje przez - ``save(update_fields=['deleted_at', 'restored_at', 'transaction_id'])``, - wiec UPDATE dotyka WYLACZNIE kolumn spoza bramki. Zaden atrybut zasilajacy - widok sie nie zmienia -> trigger sie nie odpala -> wiersz zostaje w - bpp_rekord_mat (ctid bez zmian). +def test_update_samego_deleted_at_odpala_trigger(): + """Docelowo: bramka WHEN MA znac deleted_at -> UPDATE soft-delete MA + odpalac trigger, ktory kasuje wiersz z bpp_rekord_mat (ctid znika). + + Odwrocenie ``test_update_samego_deleted_at_nie_odpala_triggera`` (nazwa + i asercja sprzed fazy 01). Dla ``bpp_wydawnictwo_ciagle`` samej to + dalej NIE dziala -- faza 01 dotknela wylacznie tabel ``*_Autor`` + (migracja 0489). Ten test ma pozostac xfail az do fazy 02; gdy + zazieleni sie SAM (bez zmiany kodu tego pliku), oznacza to niezamierzona + regresje zakresu -- zbadaj, co dotknelo bramki ``bpp_wydawnictwo_ciagle``. """ # DDL PRZED utworzeniem rekordu: ALTER TABLE nie przejdzie, gdy tabela ma # zakolejkowane zdarzenia wyzwalaczy z INSERT-a w tej samej transakcji. @@ -97,24 +119,25 @@ def test_update_samego_deleted_at_nie_odpala_triggera(): ) po = _ctid(cur, ct, pk) - assert po == przed, ( - "UPDATE samego deleted_at przepisal bpp_rekord_mat " - f"(ctid {przed} -> {po}) -- bramka WHEN najwyrazniej zna deleted_at" + assert po is None, ( + "UPDATE samego deleted_at NIE usunal wiersza z bpp_rekord_mat " + f"(ctid {przed} -> {po}) -- bramka WHEN nie zna deleted_at" ) +@pytest.mark.xfail( + reason="faza 02 -- soft-delete publikacji (bpp_wydawnictwo_ciagle)", + strict=True, +) @pytest.mark.django_db -def test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat(): - """Upsert bez DELETE (0432): odfiltrowanie z widoku NIE czysci _mat. - - Nawet gdy trigger SIE ODPALI (wymuszamy to UPDATE-em bramkowanej kolumny - ``rok``), funkcja refresh robi tylko - ``INSERT ... SELECT FROM widok ... ON CONFLICT DO UPDATE``. Widok nie - zwraca wiersza -> INSERT wybiera zero wierszy -> no-op -> stary wiersz - przezywa w bpp_rekord_mat. - - To obala "inwariant delete-first" z planu 00, na ktorym opiera sie - wystarczalnosc mechanizmu #1. +def test_soft_delete_usuwa_wiersz_z_mat(): + """Docelowo: goly UPDATE ... SET deleted_at MA usunac wiersz z + bpp_rekord_mat (mechanizm #1 -- filtr widoku -- wystarcza, bo funkcja + refresh ma galaz kasujaca uruchamiana PRZED upsertem). + + Odwrocenie ``test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat`` (nazwa i + asercja sprzed fazy 01). Jak wyzej: dla publikacji to faza 02, ten test + ma zostac xfail do tego czasu. """ # Cale DDL przed INSERT-em (patrz test wyzej). Filtr widoku jest juz # aktywny przy tworzeniu rekordu, ale deleted_at jest wtedy NULL, wiec @@ -136,18 +159,120 @@ def test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat(): "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s", [pk], ) - # ... i wymuszamy odpalenie triggera zmiana kolumny Z bramki - cur.execute("UPDATE bpp_wydawnictwo_ciagle SET rok = 2021 WHERE id = %s", [pk]) + po = _ctid(cur, ct, pk) + + assert po is None, ( + "wiersz zostal w bpp_rekord_mat po UPDATE ... SET deleted_at -- " + f"funkcja refresh nie kasuje przed upsertem (ctid: {po})" + ) + + +def _ctid_autor(cur, ct, wca_pk): + """Fizyczne polozenie krotki w bpp_autorzy_mat; None gdy wiersza nie ma. + + Klucz PK w bpp_autorzy_mat to ARRAY[ct, wca_pk], gdzie ``ct`` to content + type modelu PUBLIKACJI (np. wydawnictwo_ciagle), a ``wca_pk`` to pk + wiersza through (``*_Autor``), NIE pk publikacji ani autora -- patrz + migracje 0432 (``_create_rekord_function``/``bpp_refresh_autor_*``) i + 0489 (``_funkcja_z_galezia_kasujaca``: ``DELETE ... WHERE id = + ARRAY[ct, NEW.id]``). + """ + cur.execute( + "SELECT ctid::text FROM bpp_autorzy_mat WHERE id = ARRAY[%s, %s]::integer[]", + [ct, wca_pk], + ) + row = cur.fetchone() + return row[0] if row else None + + +@pytest.mark.django_db +def test_update_samego_deleted_at_odpala_trigger_autor(wydawnictwo_ciagle_z_autorem): + """Odpowiednik ``test_update_samego_deleted_at_odpala_trigger`` (wyzej, + xfail) dla ``*_Autor`` -- TU juz dziala (faza 01, migracja 0489). + + W przeciwienstwie do ``bpp_wydawnictwo_ciagle``, bramka WHEN triggera + ``bpp_wydawnictwo_ciagle_autor_cache_upd`` ZNA ``deleted_at`` (dopisana + do widoku ``bpp_wydawnictwo_ciagle_autorzy`` w kroku 1 migracji 0489, + wciagnieta do bramki w kroku 3 przez ``pg_depend``). Goly UPDATE samego + ``deleted_at`` odpala trigger i usuwa wiersz z ``bpp_autorzy_mat``. + + Wyrocznia: gdyby bramka regenerowana w 0489 nie objela ``deleted_at`` + (np. ktos odwrocil krok 3 migracji), UPDATE ponizej w ogole nie + dotarlby do funkcji triggera -- ``po`` zostaloby rowne ``przed``. + """ + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + pk = wca.pk + + with connection.cursor() as cur: + ct = _ct(cur, "wydawnictwo_ciagle") + + przed = _ctid_autor(cur, ct, pk) + assert przed is not None, "wiersz powinien byc w bpp_autorzy_mat po INSERT" + + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle_autor SET deleted_at = now() WHERE id = %s", + [pk], + ) + po = _ctid_autor(cur, ct, pk) + + assert po is None, ( + "UPDATE samego deleted_at NIE usunal wiersza z bpp_autorzy_mat " + f"(ctid {przed} -> {po}) -- bramka WHEN nie zna deleted_at dla *_Autor" + ) + + +@pytest.mark.django_db +def test_soft_delete_autor_usuwa_wiersz_z_mat_mimo_reedycji( + wydawnictwo_ciagle_z_autorem, +): + """Odpowiednik ``test_soft_delete_usuwa_wiersz_z_mat`` (wyzej, xfail) + dla ``*_Autor`` -- TU juz dziala (faza 01, migracja 0489). + + Rozszerzony o krok, ktorego oryginal (dla publikacji) nie mial szans + wykonac: PO soft-delete wymuszamy PONOWNE odpalenie triggera zmiana + INNEJ, bramkowanej kolumny (``kolejnosc``). Galaz kasujaca w funkcji + ``bpp_refresh_autor_wydawnictwo_ciagle`` (0489) jest bezwarunkowa -- + ``IF NEW.deleted_at IS NOT NULL THEN DELETE ...; RETURN NULL; END IF;`` + -- wiec kazde kolejne odpalenie triggera na juz skasowanym wierszu + tylko PONAWIA DELETE (no-op), nigdy nie dochodzi do upsertu. + + Wyrocznia: gdyby galaz kasujaca nie konczyla sie ``RETURN NULL`` (czyli + leciala dalej do upsertu tak jak w mutancie sprzed 0489), drugi UPDATE + ponizej przywrocilby wiersz do ``bpp_autorzy_mat`` -- to dokladnie + "inwariant delete-first", ktorego brak dla publikacji dokumentuje + xfail-owany test wyzej. + """ + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + pk = wca.pk + + with connection.cursor() as cur: + ct = _ct(cur, "wydawnictwo_ciagle") + assert _ctid_autor(cur, ct, pk) is not None + + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle_autor SET deleted_at = now() WHERE id = %s", + [pk], + ) + assert _ctid_autor(cur, ct, pk) is None, "goly UPDATE deleted_at nie skasowal" + + # Wymuszamy PONOWNE odpalenie triggera zmiana INNEJ, bramkowanej + # kolumny -- deleted_at nadal NOT NULL. cur.execute( - "SELECT count(*) FROM bpp_wydawnictwo_ciagle_view WHERE object_id_raw = %s", + "UPDATE bpp_wydawnictwo_ciagle_autor SET kolejnosc = kolejnosc + 100 " + "WHERE id = %s", + [pk], + ) + + cur.execute( + "SELECT count(*) FROM bpp_wydawnictwo_ciagle_autorzy WHERE (id)[2] = %s", [pk], ) w_widoku = cur.fetchone()[0] - po = _ctid(cur, ct, pk) + po = _ctid_autor(cur, ct, pk) - assert w_widoku == 0, "widok zrodlowy powinien juz nie zwracac wiersza" - assert po is not None, ( - "wiersz zniknal z bpp_rekord_mat -- funkcja refresh jednak kasuje " - "przed upsertem (inwariant delete-first zyje)" + assert w_widoku == 0, "widok zrodlowy nie powinien zwracac skasowanego wiersza" + assert po is None, ( + "skasowany wiersz WROCIL do bpp_autorzy_mat po edycji innej kolumny " + f"-- galaz kasujaca nie jest bezwarunkowa (ctid: {po})" ) diff --git a/src/bpp/tests/test_soft_delete/test_cache_consistency.py b/src/bpp/tests/test_soft_delete/test_cache_consistency.py new file mode 100644 index 000000000..ba097956c --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_cache_consistency.py @@ -0,0 +1,144 @@ +"""Spójność materializowanego cache (bpp_autorzy_mat / model Autorzy) po +soft-delete wierszy *_Autor (Task 4, faza 01). + +UWAGA: zwykły ``django_db`` WYSTARCZA — triggery bazodanowe działają +wewnątrz transakcji testowej (kanarki ``test_soft_delete_preconditions.py`` +to pokazują: chodzą pod zwykłym ``django_db``, nie ``transactional_db``). +``transactional_db`` jest tu zbędny i tylko spowalnia (osobny kontener/ +commit na test zamiast rollbacku transakcji). +""" + +import pytest +from django.contrib.contenttypes.models import ContentType + +from bpp.models.cache import Autorzy, AutorzyView +from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + + +def _autorzy_mat_dla(wca): + """Wiersze bpp_autorzy_mat (model Autorzy) wskazujące na danego autora + w danym rekordzie (kolumna rekord_id = [content_type publikacji, pk + publikacji] -- patrz AutorzyManager.filter_rekord i migracja 0432).""" + ct = ContentType.objects.get_for_model(type(wca.rekord)).pk + return Autorzy.objects.filter( + autor_id=wca.autor_id, + rekord_id=[ct, wca.rekord_id], + ) + + +@pytest.mark.django_db +def test_soft_delete_autorstwa_znika_z_mat( + denorms, wydawnictwo_ciagle_z_dwoma_autorami +): + """Soft-delete jednego autorstwa: znika WYŁĄCZNIE ten wiersz, z OBU + warstw (widok źródłowy i mat-view), drugi autor pracy zostaje. + + Wyrocznia: gdyby gałąź kasująca w bpp_refresh_autor_wydawnictwo_ciagle + zniknęła (albo bramka WHEN przestała znać deleted_at), pierwsza asercja + po delete() by padła. Gdyby filtr widoku źródłowego zniknął, druga by + padła. Gdyby kaskada usuwała OBA wiersze zamiast jednego (zły klucz), + trzecia by padła. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + denorms.flush() + wca = wc.autorzy_set.first() + autor_id = wca.autor_id + + # Przed: autor jest w bpp_autorzy_mat ... + assert _autorzy_mat_dla(wca).exists() + # ... i w bpp_autorzy (widok źródłowy) + assert AutorzyView.objects.filter(autor_id=autor_id).exists() + + drugi_autor_id = wc.autorzy_set.exclude(pk=wca.pk).first().autor_id + + wca.delete() # soft-delete per instancja + + # Po: znika z mat-view (trigger + filtr widoku) ... + assert not _autorzy_mat_dla(wca).exists() + # ... i z widoku źródłowego (mechanizm #1) + assert not AutorzyView.objects.filter(autor_id=autor_id).exists() + # Drugi autor pracy NIE zniknął (kaskada jest per-instancja, nie per-rekord) + assert Autorzy.objects.filter(autor_id=drugi_autor_id).exists() + + +@pytest.mark.django_db +def test_restore_autorstwa_wraca_do_mat(denorms, wydawnictwo_ciagle_z_autorem): + """restore() na skasowanym autorstwie musi ponownie wstawić wiersz do + mat-view. Wyrocznia: gdyby restore() nie odpalał tego samego triggera + UPDATE (np. hipotetyczny bulk .update() z pominięciem save()), druga + para asercji by padła mimo że deleted_at wróciło do NULL w bazie.""" + wc = wydawnictwo_ciagle_z_autorem + denorms.flush() + wca = wc.autorzy_set.first() + autor_id = wca.autor_id + pk = wca.pk + + wca.delete() + assert not Autorzy.objects.filter(autor_id=autor_id).exists() + + Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore() + + # Restore → re-insert do mat-view + assert Autorzy.objects.filter(autor_id=autor_id).exists() + assert AutorzyView.objects.filter(autor_id=autor_id).exists() + + +@pytest.mark.django_db +def test_edycja_skasowanego_autorstwa_nie_wskrzesza_w_mat( + denorms, wydawnictwo_ciagle_z_autorem +): + """Zapis skasowanego wiersza *_Autor (np. zmiana kolejności) NIE wraca + do bpp_autorzy_mat -- widok źródłowy go odfiltrowuje po własnym + deleted_at (mechanizm #1), niezależnie od tego, KTÓRA kolumna + bramkowana zmieniła się przy tym UPDATE-cie. + + Wyrocznia: gdyby gałąź kasująca w funkcji refresh nie kończyła się + ``RETURN NULL`` (czyli leciała dalej do upsertu), ten test by padł -- + to dokładnie inwariant "delete-first", którego brak dokumentują + kanarki w test_soft_delete_preconditions.py dla warstwy publikacji. + """ + wc = wydawnictwo_ciagle_z_autorem + denorms.flush() + wca = wc.autorzy_set.first() + autor_id = wca.autor_id + pk = wca.pk + + wca.delete() + assert not Autorzy.objects.filter(autor_id=autor_id).exists() + + # Edycja skasowanego wiersza (przez global_objects, bo objects ukrywa) + skasowany = Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk) + skasowany.kolejnosc = 99 + skasowany.save() # odpala trigger jako UPDATE z deleted_at NOT NULL + + # Nadal nie ma go w mat-view (kluczowy przypadek brzegowy ze spec §2.1) + assert not Autorzy.objects.filter(autor_id=autor_id).exists() + + +@pytest.mark.django_db +def test_queryset_delete_kaskaduje_per_instancja( + denorms, wydawnictwo_ciagle_z_dwoma_autorami +): + """.delete() na QuerySet soft-deletuje per instancję (iterator) -- + wszystkie wiersze znikają z mat-view, gate na .update() nie blokuje + QuerySet.delete() (django-softdelete: SoftDeleteQuerySet.delete() + woła obj.delete() w pętli, nie bulk .update()). + + Wyrocznia: gdyby .delete() na queryset trafiał w nasz gate z + soft_delete.py (blokadę bulk .update() na deleted_at/restored_at), + ten test rzuciłby RuntimeError zamiast przejść. Gdyby kaskada NIE + była per-instancja (np. bulk UPDATE z pominięciem triggera), pierwsza + asercja po delete() by padła, a druga (fizyczne przetrwanie wierszy) + odróżnia to od hard-delete. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + denorms.flush() + ct = ContentType.objects.get_for_model(type(wc)).pk + assert Autorzy.objects.filter(rekord_id=[ct, wc.pk]).count() == 2 + + Wydawnictwo_Ciagle_Autor.objects.filter(rekord=wc).delete() + + # Wszystkie autorstwa tej pracy zniknęły z mat-view + assert not Autorzy.objects.filter(rekord_id=[ct, wc.pk]).exists() + # ... ale wiersze fizycznie żyją (soft, nie hard) + assert Wydawnictwo_Ciagle_Autor.global_objects.filter(rekord=wc).count() == 2 From 13622945b8a4eacd33e728a2640e535b32a44c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 21:10:44 +0200 Subject: [PATCH 29/75] docs(soft-delete): 89 miejsc produkcyjnych zamiast 128 lacznie Weryfikacja fazy 01 (Task 5) pokazala 146 wystapien *_Autor.objects, ale 57 z nich jest w testach -- w tym w testach dolozonych przez sama faze 01. Realny cel audytu fazy 03 to 89 miejsc w kodzie PRODUKCYJNYM. Poprzednia liczba (najpierw "90" ze specu, potem "128" po moim przeliczeniu) mieszala kod produkcyjny z testowym, wiec audytor dostalby zawyzony zakres i mogl zmarnowac czas na pliki testowe. Dopisana komenda kontrolna z filtrem na testy, zeby liczba dala sie odtworzyc w momencie startu fazy 03 (bedzie rosla wraz z kolejnymi fazami, ktore dokladaja wlasne testy). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../2026-06-04-soft-delete-00-overview.md | 2 +- ...6-04-soft-delete-01-autor-trigger-widoki.md | 6 +++++- ...6-06-04-soft-delete-03-audyt-kategorii-b.md | 6 +++--- ...2026-06-04-soft-delete-08-testy-regresji.md | 3 ++- ...-soft-delete-publikacje-i-autorzy-design.md | 18 +++++++++--------- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md index f9dedf8c7..76468a60f 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md @@ -18,7 +18,7 @@ |---|---|---|---| | 01 | `2026-06-04-soft-delete-01-autor-trigger-widoki.md` | `*_Autor` → SoftDeleteModel; widoki `bpp_*_autorzy` + **gałąź kasująca w funkcjach refresh** + **regeneracja bramki `WHEN`**; spójność weryfikowana surowym SQL-em (NIE `full_refresh()`) | — | | 02 | `2026-06-04-soft-delete-02-publikacje.md` | 5 modeli → SoftDeleteModel; override `delete()`/`restore()` z wąską kaskadą na `*_Autor`; `slug` warunkowy unique; przeplecenie menedżerów | 01 | -| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 128 miejsc `*_Autor.objects` | 02 | +| 03 | `2026-06-04-soft-delete-03-audyt-kategorii-b.md` | przełączenie import/dedup/PBN-matching na `global_objects`; jawny `.hard_delete()` w `pbn_import`; audyt 89 miejsc produkcyjnych `*_Autor.objects` (146 wystąpień łącznie, 57 w testach) | 02 | | 04 | `2026-06-04-soft-delete-04-guardy-protect.md` | flip FK `CASCADE→PROTECT` (autor, doktorat, `wydawnictwo_nadrzedne`); guard w soft `delete()` (autor + książka-matka); soft-delete husku autora | 02 | | 05 | `2026-06-04-soft-delete-05-pbn-wycofanie.md` | prymityw `wycofaj_oswiadczenia()`; `pbn_export_queue.operacja = WYSYLKA\|WYCOFANIE` (wejście async) + wywołanie bezpośrednie (sync); restore → `WYSYLKA`; integracja `SentData` | 02 | | 06 | `2026-06-04-soft-delete-06-softdeletelog.md` | model `SoftDeleteLog`; receivery `post_soft_delete`/`post_restore`/`post_hard_delete`; wstrzykiwanie `user`; **kasowanie/przeliczanie `Cache_Punktacja_*`** | 02, 05 | diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md index 121b9fc43..c510d7d25 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -1134,7 +1134,11 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " ## Założenia i ostrzeżenia między-fazowe (dla faz 02+) -1. **Domyślny manager `*_Autor.objects` zmienił klasę** na `BppSoftDeleteManager` (filtruje `deleted_at__isnull=True`). Faza 03 (audyt kat. B) MUSI przejść **128** miejsc `*_Autor.objects` (stan 2026-08-06; spec mówił „90" — przelicz przed startem fazy 03: `grep -rn --include='*.py' -E "(Wydawnictwo_Ciagle_Autor|Wydawnictwo_Zwarte_Autor|Patent_Autor)\.objects" src/ | wc -l`). W fazie 01 nic nie jest skasowane, więc filtr jest no-op, ale od fazy 02 (kaskada soft-delete publikacji) zacznie ukrywać. Guard autora (faza 04) MUSI liczyć przez `global_objects` (spec §3.2). +1. **Domyślny manager `*_Autor.objects` zmienił klasę** na `BppSoftDeleteManager` (filtruje `deleted_at__isnull=True`). Faza 03 (audyt kat. B) MUSI przejść **89 miejsc w kodzie produkcyjnym** czyta `*_Autor.objects` bezpośrednio (stan 2026-08-06; łącznie 146 wystąpień, z czego 57 w testach — audyt fazy 03 dotyczy wyłącznie kodu produkcyjnego). Komenda kontrolna: +```bash +P='(Wydawnictwo_Ciagle_Autor|Wydawnictwo_Zwarte_Autor|Patent_Autor)\.objects' +grep -rEn --include='*.py' "$P" src/ | grep -vcE '/tests?/|test_' +```. W fazie 01 nic nie jest skasowane, więc filtr jest no-op, ale od fazy 02 (kaskada soft-delete publikacji) zacznie ukrywać. Guard autora (faza 04) MUSI liczyć przez `global_objects` (spec §3.2). 2. **Faza 02 powtarza ten sam trójskładnikowy wzorzec dla 5 tabel publikacji**: filtr `deleted_at` w `bpp_*_view` → gałąź kasująca w `bpp_refresh_rekord_()` (`DELETE FROM bpp_rekord_mat`) → regeneracja bramki `WHEN`. Uwaga na doktorat/habilitację: ich funkcje refresh dotykają **obu** tabel `_mat` (`bpp_rekord_mat` i `bpp_autorzy_mat`, bo autor leży na wierszu publikacji) — gałąź kasująca musi czyścić obie. 3. **Każda przyszła zmiana definicji widoku źródłowego wymaga regeneracji bramki `WHEN`.** Bramka jest wypiekana z `pg_depend` w momencie migracji, więc nie zaktualizuje się sama. Pominięcie = cichy staleness. Testy `test_views_sql.py` i kanarki `test_soft_delete_preconditions.py` to wyłapią. 3. **Widoki `bpp_praca_doktorska_autorzy` / `bpp_praca_habilitacyjna_autorzy` NIE filtrowane** — autorstwo doktoratu/habilitacji nie jest `*_Autor` SoftDeleteModel (autor doktoratu to FK `Praca_Doktorska.autor`, nie through). Faza 02 (soft-delete publikacji doktorat/habilitacja) musi zadbać o ich zniknięcie z `bpp_rekord` przez własne `deleted_at` na tabeli publikacji — to NIE jest pokryte tą fazą. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md index e798d0c35..44549aa97 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md @@ -61,7 +61,7 @@ - `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py` **Bez zmian (decyzja audytu udokumentowana w planie):** -- 128 miejsc `*_Autor.objects` w ewaluacji / API / przemapuj — patrz Task 7. +- 89 miejsc produkcyjnych `*_Autor.objects` w ewaluacji / API / przemapuj — patrz Task 7. - `src/komparator_pbn/views.py`, `src/snapshot_odpiec/tasks.py`, `src/ewaluacja_dwudyscyplinowcy/core.py` — patrz Task 7. @@ -661,7 +661,7 @@ git commit -m "fix(soft-delete): pbn_import czysci publikacje przez hard_delete --- -## Task 7: Audyt 128 miejsc `*_Autor.objects` — decyzje (zostaw `objects` / zmień na `global_objects`) +## Task 7: Audyt 89 miejsc produkcyjnych `*_Autor.objects` — decyzje (zostaw `objects` / zmień na `global_objects`) Po fazie 02 `*_Autor.objects` ukrywa kaskadowo soft-deletowane autorstwa (kaskada §2.2). To jest **poprawny default dla ewaluacji** (praca w koszu nie @@ -947,7 +947,7 @@ git commit -m "chore(soft-delete): faza 03 audyt kat. B — format/lint" rekordach, nie matching tworzący duplikaty; **bez zmian**. - `pbn_import/utils/publication_import.py:115-116` jawny `.hard_delete()` → Task 6. ✓ -- Audyt 128 miejsc `*_Autor.objects` → Task 7 (decyzje per-miejsce; jedyna +- Audyt 89 miejsc produkcyjnych `*_Autor.objects` → Task 7 (decyzje per-miejsce; jedyna zmiana: merge → `global_objects`). ✓ - Testy wymagane przez zlecenie: re-import nie tworzy duplikatu (Task 1-4), matching po `pbn_uid` znajduje soft-deletowaną (Task 1-3), ewaluacja pomija diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md index 63dc408b4..491bd2d55 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-08-testy-regresji.md @@ -783,7 +783,8 @@ je ukrywa). ```python """Regresja ewaluacji (spec §2.5, §3 fazy). -128 miejsc czyta ``*_Autor.objects`` bezpośrednio; po wpięciu +89 miejsc w kodzie produkcyjnym czyta ``*_Autor.objects`` +bezpośrednio (146 wystąpień łącznie, 57 w testach); po wpięciu ``SoftDeleteModel`` domyślny menedżer ukrywa kaskadowo-skasowane autorstwa, więc ewaluacja pomija prace w koszu. Restore przywraca punktację. """ diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md index 151f47662..1061bf2d6 100644 --- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md +++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md @@ -67,11 +67,11 @@ i `Patent_Autor` **stają się `SoftDeleteModel`** — ale wyłącznie jako cel **wąskiej kaskady** z soft-delete publikacji (§2.2), NIE pełnego refleksyjnego Projektu B. Pozostałe dzieci (`*_Streszczenie`, `*_Zewnetrzna_Baza_Danych`, `Publikacja_Habilitacyjna`, `Opi_2012_Tytul_Cache`) zostają nie-soft — -kaskada zatrzymuje się na `*_Autor` i **nie jest wirusowa**. Powód: 128 -bezpośrednich zapytań `*_Autor.objects` w kodzie (większość w +kaskada zatrzymuje się na `*_Autor` i **nie jest wirusowa**. Powód: 89 +bezpośrednich, produkcyjnych zapytań `*_Autor.objects` w kodzie (większość w `ewaluacja_optymalizacja` — najwrażliwszy korekcyjnie podsystem) — domyślny menedżer `objects` po wpięciu `SoftDeleteModel` czyni je poprawnymi -automatycznie, eliminując 128-punktowe ryzyko „silent leak" do ewaluacji. +automatycznie, eliminując 89-punktowe ryzyko „silent leak" do ewaluacji. **Dlaczego nie kaskada autor→prace ani „guard z 50 publikacjami":** realny przypadek użycia kasowania autora jest wąski — to wyłącznie puste / @@ -235,9 +235,9 @@ wszystko z `_mat` na podstawie własnych `deleted_at`; przy restore re-projektuje ze źródła. Po co jawna kaskada na `*_Autor`, skoro trigger i tak czyści `bpp_autorzy_mat`? -Bo **128 miejsc w kodzie czyta `*_Autor.objects` bezpośrednio** (z pominięciem +Bo **89 miejsc w kodzie produkcyjnym czyta `*_Autor.objects` bezpośrednio** (z pominięciem cache), głównie w `ewaluacja_optymalizacja`. Domyślny menedżer `objects` -`SoftDeleteModel` ukrywa skasowane → te 128 miejsc staje się poprawnych +`SoftDeleteModel` ukrywa skasowane → te 89 miejsc staje się poprawnych automatycznie, bez ręcznych filtrów `wydawnictwo_ciagle__deleted_at__isnull` (których pominięcie = po cichu zliczona skasowana praca w ewaluacji). @@ -372,8 +372,8 @@ ewaluacji** — czyli dokładnie ten „silent leak", przed którym broni §2.2, > odczytu ani pilnowania każdego nowego. Koszt: restore staje się operacją > liczącą (nie samym `UPDATE`), co trzeba uwzględnić w adminie (może trwać). -**Through-modele `*_Autor` (128 miejsc).** Po wpięciu `SoftDeleteModel` -128 bezpośrednich zapytań `*_Autor.objects` (głównie `ewaluacja_optymalizacja`: +**Through-modele `*_Autor` (89 miejsc produkcyjnych).** Po wpięciu `SoftDeleteModel` +89 bezpośrednich, produkcyjnych zapytań `*_Autor.objects` (głównie `ewaluacja_optymalizacja`: `reset_pins`, `reset_disciplines`, `unpin_all_sensible`, `optimization`, `author_works`, `evaluation_browser`, `verification`; oraz `api_v1`, `przemapuj_prace_autora`, `ewaluacja_dwudyscyplinowcy`) **staje się poprawne @@ -627,7 +627,7 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, warunkowe `UniqueConstraint` na `*_Autor` (§2.2b), przeplecenie menedżerów, powtórzenie kroków 1(b)–1(d) dla 5 widoków publikacji i ich funkcji. 3. **Audyt kat. B** — przełączenie import/dedup/PBN-matching na - `global_objects`; audyt 128 miejsc `*_Autor.objects` (default „pomijaj" + `global_objects`; audyt 89 miejsc produkcyjnych `*_Autor.objects` (146 wystąpień łącznie, 57 w testach) (default „pomijaj" poprawny, wyjątki → `global_objects`). Testy: re-import nie tworzy duplikatów; ewaluacja pomija prace w koszu. 4. **Guardy PROTECT** (ten sam wzorzec: flip FK + guard liczący przez @@ -694,7 +694,7 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, soft-deletuje rodzica i jego `*_Autor` (wspólny `transaction_id`), bez refleksyjnej kaskady na pozostałe dzieci. Trigger jako choke-point, jednolity dzięki własnym `deleted_at` na wszystkich 8 tabelach (bez JOIN - do rodzica). Powód kaskady: 128 miejsc `*_Autor.objects` w ewaluacji. + do rodzica). Powód kaskady: 89 miejsc produkcyjnych `*_Autor.objects` w ewaluacji. 3. **PBN przy soft-delete:** wycofanie oświadczeń instytucji (`delete_all_publication_statements`), gate na `pbn_uid`; obiektu publikacji nie kasujemy. From 00e8173c886476ce415427ccdd170969b7f4c115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 21:59:20 +0200 Subject: [PATCH 30/75] test(soft-delete): padajace testy 3 blokerow widokow pochodnych Recenzja finalna galezi fazy 01 znalazla trzy rodziny widokow SQL, ktore po 0489 wciaz czytaja SUROWA tabele *_autor (bez `deleted_at IS NULL`). `delete()` jest miekki od pierwszej sekundy po wdrozeniu -- nie ma feature flagi -- wiec kazdy z tych wyciekow jest aktywny od dnia 1. Testy (na razie CZERWONE, poprawki w kolejnych commitach): 1. `bpp__view` (0421) -- `count(*_autor.autor_id) AS liczba_autorow` po surowej tabeli. `Rekord.liczba_autorow` = 2 przy jednym zywym autorstwie. Skutek: multiseek "Ostatnie nazwisko i imie" filtruje `kolejnosc IN [liczba_autorow-1, liczba_autorow)` -- przy zawyzonej wartosci celuje w pozycje, na ktorej nikogo nie ma i cicho zwraca pustke. 2. `bpp_nowe_sumy_*_view` (0458) -- zrodlo rankingu autorow. Skasowane autorstwo dalej wnosi punkty; deduplikator autorow (kasuje zrodlowe autorstwa) zostawialby duplikat z punktami w rankingu na zawsze. 3. `rozbieznosci_dyscyplin_rozbieznoscizrodelview` (0017, przeladowany przez 0018/0019/0020) -- raport pokazuje rozbieznosci dla autorstw, ktorych nie ma juz w interfejsie (nie da sie ich "naprawic"). --- src/bpp/tests/test_audyt_konsumentow.py | 23 +++++ .../test_soft_delete/test_widoki_pochodne.py | 94 +++++++++++++++++++ .../tests/test_soft_delete.py | 49 ++++++++++ 3 files changed, 166 insertions(+) create mode 100644 src/bpp/tests/test_audyt_konsumentow.py create mode 100644 src/bpp/tests/test_soft_delete/test_widoki_pochodne.py create mode 100644 src/rozbieznosci_dyscyplin/tests/test_soft_delete.py diff --git a/src/bpp/tests/test_audyt_konsumentow.py b/src/bpp/tests/test_audyt_konsumentow.py new file mode 100644 index 000000000..026dae7cc --- /dev/null +++ b/src/bpp/tests/test_audyt_konsumentow.py @@ -0,0 +1,23 @@ +import re +import pytest +from django.db import connection + +TABELE = ("bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_zwarte_autor", "bpp_patent_autor") + + +def _czyta(t): + return any(re.search(rf"\b{x}\b(?!zy)", t) for x in TABELE) + + +@pytest.mark.django_db +def test_audyt(): + with connection.cursor() as cur: + cur.execute("SELECT viewname, definition FROM pg_views WHERE schemaname='public'") + widoki = cur.fetchall() + brudne = [(n, "deleted_at" in d) for n, d in widoki if _czyta(d)] + bez = [n for n, f in brudne if not f] + linie = [f"WIDOKOW OGOLEM: {len(widoki)}", f"CZYTA SUROWA *_autor: {len(brudne)}", f"BEZ FILTRA: {len(bez)}"] + linie += [f" BRAK: {n}" for n in sorted(bez)] + linie += [f" ok: {n}" for n, f in sorted(brudne) if f] + open("/tmp/audyt.txt", "w").write("\n".join(linie)) + assert True diff --git a/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py b/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py new file mode 100644 index 000000000..a2764f40a --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py @@ -0,0 +1,94 @@ +"""Widoki POCHODNE (spoza rodziny ``bpp_*_autorzy``) po soft-delete autorstwa. + +Faza 01 przefiltrowała widoki ``bpp_*_autorzy`` (migracja 0489) — ale to nie +jedyne miejsce, gdzie SQL czyta SUROWĄ tabelę ``*_autor``. Ponieważ +``delete()`` jest miękki od pierwszej sekundy po wdrożeniu (żadnej feature +flagi), każdy taki widok wycieka skasowane autorstwa od dnia 1. + +Ten moduł pilnuje dwóch takich rodzin widoków: + +1. ``bpp__view`` (migracja 0421) — ``count(*_autor.autor_id) AS + liczba_autorow``; karmi ``bpp_rekord_mat`` (model ``Rekord``). Zawyżona + ``liczba_autorow`` cicho psuje multiseek: kryterium „Ostatnie nazwisko i + imię" filtruje ``kolejnosc ∈ [liczba_autorow-1, liczba_autorow)``, więc + przy zawyżonej wartości celuje w pozycję, na której nikogo nie ma + (``bpp/multiseek_registry/fields/author_fields.py``). +2. ``bpp_nowe_sumy_*_view`` (migracja 0458) — sumy punktów per autor, + źródło rankingu autorów (``ranking_autorow.views``). Wyciek = zdublowany + (i skasowany) autor trzyma punkty w rankingu na zawsze. +""" + +import pytest + +from bpp.models.cache import Rekord +from bpp.models.sumy_views import Sumy + + +@pytest.mark.django_db +def test_liczba_autorow_nie_liczy_soft_deletowanych( + denorms, wydawnictwo_ciagle_z_dwoma_autorami +): + """``Rekord.liczba_autorow`` po soft-delete = liczba ŻYWYCH autorstw. + + Wyrocznia: gdyby ``bpp_wydawnictwo_ciagle_view`` liczyło + ``count(bpp_wydawnictwo_ciagle_autor.autor_id)`` po surowej tabeli (stan + sprzed poprawki), ostatnia asercja zwróciłaby 2 przy jednym żywym + autorstwie. + + ``denorms.flush()`` po ``delete()`` jest KONIECZNY: trigger na tabeli + ``*_autor`` odświeża tylko ``bpp_autorzy_mat``; ``liczba_autorow`` w + ``bpp_rekord_mat`` przelicza się dopiero, gdy flush denorm dotknie + wiersza publikacji (patrz komentarz przy polu ``Rekord.liczba_autorow``). + Tak samo działa to przy TWARDYM kasowaniu autorstwa — to nie jest + regresja soft-delete. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + denorms.flush() + + assert wc.autorzy_set.count() == 2 + assert Rekord.objects.get_for_model(wc).liczba_autorow == 2 + + wc.autorzy_set.first().delete() # soft-delete + denorms.flush() + + assert wc.autorzy_set.count() == 1 + assert Rekord.objects.get_for_model(wc).liczba_autorow == 1 + + +@pytest.mark.django_db +def test_sumy_rankingu_nie_licza_soft_deletowanych(wydawnictwo_ciagle_z_autorem): + """``Sumy`` (``bpp_nowe_sumy_view``) nie widzą skasowanego autorstwa. + + Wyrocznia: gdyby ``bpp_nowe_sumy_wydawnictwo_ciagle_view`` joinowało + surową ``bpp_wydawnictwo_ciagle_autor`` (stan sprzed poprawki), druga + asercja by padła — a to dokładnie ten wiersz, po którym ranking autorów + sumuje punkty. Deduplikator autorów przenosi autorstwa i kasuje źródłowe + (od fazy 01 — miękko), więc bez tego filtra zduplikowany autor trzymałby + punkty w rankingu bezterminowo. + """ + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + autor_id = wca.autor_id + + assert Sumy.objects.filter(autor_id=autor_id).exists() + + wca.delete() # soft-delete + + assert not Sumy.objects.filter(autor_id=autor_id).exists() + + +@pytest.mark.django_db +def test_sumy_rankingu_wracaja_po_restore(wydawnictwo_ciagle_z_autorem): + """Filtr w ``bpp_nowe_sumy_*`` jest odwracalny: ``restore()`` przywraca + punkty do rankingu. Wyrocznia dla poprawki nadgorliwej (np. filtr po + ``restored_at`` zamiast ``deleted_at``).""" + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle_Autor + + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + autor_id, pk = wca.autor_id, wca.pk + + wca.delete() + assert not Sumy.objects.filter(autor_id=autor_id).exists() + + Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore() + + assert Sumy.objects.filter(autor_id=autor_id).exists() diff --git a/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py b/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py new file mode 100644 index 000000000..5854c9f0f --- /dev/null +++ b/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py @@ -0,0 +1,49 @@ +"""Raport rozbieżności dyscyplin źródeł a soft-delete autorstw. + +``rozbieznosci_dyscyplin_rozbieznoscizrodelview`` (migracja 0017, przeładowana +przez 0018/0019/0020) czyta SUROWĄ tabelę ``bpp_wydawnictwo_ciagle_autor``. +Bez filtra ``deleted_at IS NULL`` raport pokazuje rozbieżności dyscyplin dla +autorstw, których w systemie już nie ma — a użytkownik nie ma jak ich +„naprawić", bo w interfejsie nie istnieją. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Dyscyplina_Zrodla, Wydawnictwo_Ciagle +from rozbieznosci_dyscyplin.models import RozbieznosciZrodelView + + +@pytest.fixture +def rozbieznosc_zrodla( + autor_z_dyscyplina, + rok, + zrodlo, + dyscyplina1, + dyscyplina2, + jednostka, + typy_odpowiedzialnosci, +): + """Autorstwo z dyscypliną SPOZA listy dyscyplin źródła — czyli dokładnie + to, co raportuje ``RozbieznosciZrodelView``.""" + Dyscyplina_Zrodla.objects.create(rok=rok, zrodlo=zrodlo, dyscyplina=dyscyplina2) + wc = baker.make(Wydawnictwo_Ciagle, rok=rok, zrodlo=zrodlo) + return wc.dodaj_autora( + autor_z_dyscyplina.autor, jednostka, dyscyplina_naukowa=dyscyplina1 + ) + + +@pytest.mark.django_db +def test_rozbieznosci_zrodel_pomijaja_soft_deletowane(rozbieznosc_zrodla): + """Wyrocznia: bez ``deleted_at IS NULL`` w definicji widoku druga + asercja by padła — skasowane autorstwo dalej generowałoby wiersz + raportu.""" + assert RozbieznosciZrodelView.objects.filter( + autor_id=rozbieznosc_zrodla.autor_id + ).exists() + + rozbieznosc_zrodla.delete() # soft-delete + + assert not RozbieznosciZrodelView.objects.filter( + autor_id=rozbieznosc_zrodla.autor_id + ).exists() From 0e8bff7bc1ab21d1e46ecd21307a0f30a0de17cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 22:05:00 +0200 Subject: [PATCH 31/75] chore: usun plik tymczasowy audytu, ktory wpadl do commita test_audyt_konsumentow.py byl jednorazowa sonda kontrolera (inwentaryzacja widokow czytajacych surowe tabele *_autor), tworzona w tym samym worktree, w ktorym rownolegle pracowal agent naprawczy. Agent zgarnal go swoim git add do commita 00e8173c8. Sonda nie jest testem regresyjnym i nie ma czego pilnowac -- jej wynik jest zapisany w ledgerze SDD. Usuwam. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- src/bpp/tests/test_audyt_konsumentow.py | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 src/bpp/tests/test_audyt_konsumentow.py diff --git a/src/bpp/tests/test_audyt_konsumentow.py b/src/bpp/tests/test_audyt_konsumentow.py deleted file mode 100644 index 026dae7cc..000000000 --- a/src/bpp/tests/test_audyt_konsumentow.py +++ /dev/null @@ -1,23 +0,0 @@ -import re -import pytest -from django.db import connection - -TABELE = ("bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_zwarte_autor", "bpp_patent_autor") - - -def _czyta(t): - return any(re.search(rf"\b{x}\b(?!zy)", t) for x in TABELE) - - -@pytest.mark.django_db -def test_audyt(): - with connection.cursor() as cur: - cur.execute("SELECT viewname, definition FROM pg_views WHERE schemaname='public'") - widoki = cur.fetchall() - brudne = [(n, "deleted_at" in d) for n, d in widoki if _czyta(d)] - bez = [n for n, f in brudne if not f] - linie = [f"WIDOKOW OGOLEM: {len(widoki)}", f"CZYTA SUROWA *_autor: {len(brudne)}", f"BEZ FILTRA: {len(bez)}"] - linie += [f" BRAK: {n}" for n in sorted(bez)] - linie += [f" ok: {n}" for n, f in sorted(brudne) if f] - open("/tmp/audyt.txt", "w").write("\n".join(linie)) - assert True From 686ccf5aed38ab4c42da31c60e1b50d2aefaaf18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 22:11:27 +0200 Subject: [PATCH 32/75] refactor(soft-delete): czesciowy indeks, redundantny unique, rozbicie 0490 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trzy poprawki "przy okazji" z recenzji finalnej. Wszystkie dotykaja migracji 0488/0490, ktore sa NIEWYPCHNIETE (remote stoi na 7119a766f) -- wolno je poprawiac w miejscu. 1. Indeks `deleted_at` -> CZESCIOWY (`WHERE deleted_at IS NOT NULL`). Pelny btree byl martwym kosztem: predykat `deleted_at IS NULL` pasuje do ~100% wierszy, wiec planner i tak nigdy by go nie wybral, a indeks zostawal narzutem przy kazdym INSERT/UPDATE autorstwa. Selektywne jest zapytanie ODWROTNE (`deleted_objects`) -- i to ono dostaje mikroskopijny indeks. Poprawiony tez mylacy fragment docstringa 0489 ("pelny skan przyspiesza dzieki indeksowi" -- artefakt pomiaru na pustej bazie). 2. Usuniety `*_uniq_rekord_autor_kolejnosc` (3 modele + migracja). `ExclusionConstraint(rekord, kolejnosc)` nie patrzy na autora, wiec jest sciśle silniejszy -- unique po (rekord, autor, kolejnosc) byl w 100% redundantny: trzeci build indeksu w oknie serwisowym i staly narzut na najgoretszej sciezce zapisu. Zaktualizowany docstring walidacji formsetu w `bpp/admin/core.py` (pokrywa juz DWA, nie trzy constrainty). 3. Migracja 0490 rozbita na 0490-0493, kazda z `atomic = False`. `ADD CONSTRAINT ... EXCLUDE USING GIST` bierze ACCESS EXCLUSIVE (blokuje takze ODCZYTY) i nie ma wariantu wspolbieznego; w jednej transakcji blokady z trzech tabel wisialyby az do COMMIT. Kolejnosc krokow dobrana tak, ze NIE MA okna bez ochrony unikalnosci: najpierw wszystko dodajace (indeks FK / unique po typie / exclusion po kolejnosci), na koncu zdjecie starego `unique_together` i legacy constraintu z 0132. Test odwracalnosci przemianowany na `test_migracje_0490_0493_rewers.py`; wraca teraz do NAJNOWSZEJ migracji `bpp` zamiast do konkretnego numeru (baza testowa jest wspoldzielona przez caly przebieg -- `--reuse-db`). --- src/bpp/admin/core.py | 11 +- .../0488_autor_soft_delete_fields.py | 12 ++ .../0489_soft_delete_autorzy_views.py | 13 +- .../migrations/0490_autor_indeks_fk_rekord.py | 78 +++++++ .../migrations/0490_autor_warunkowy_unique.py | 202 ------------------ .../0491_autor_unique_rekord_autor_typ.py | 54 +++++ .../0492_autor_excl_rekord_kolejnosc.py | 62 ++++++ .../0493_autor_zdjecie_starych_unique.py | 87 ++++++++ src/bpp/models/patent.py | 17 +- src/bpp/models/wydawnictwo_ciagle.py | 27 ++- src/bpp/models/wydawnictwo_zwarte.py | 17 +- ...s.py => test_migracje_0490_0493_rewers.py} | 53 +++-- 12 files changed, 381 insertions(+), 252 deletions(-) create mode 100644 src/bpp/migrations/0490_autor_indeks_fk_rekord.py delete mode 100644 src/bpp/migrations/0490_autor_warunkowy_unique.py create mode 100644 src/bpp/migrations/0491_autor_unique_rekord_autor_typ.py create mode 100644 src/bpp/migrations/0492_autor_excl_rekord_kolejnosc.py create mode 100644 src/bpp/migrations/0493_autor_zdjecie_starych_unique.py rename src/bpp/tests/test_soft_delete/{test_migracja_0490_rewers.py => test_migracje_0490_0493_rewers.py} (56%) diff --git a/src/bpp/admin/core.py b/src/bpp/admin/core.py index 0f9e5a5c4..a59ed1990 100644 --- a/src/bpp/admin/core.py +++ b/src/bpp/admin/core.py @@ -376,12 +376,11 @@ def _waliduj_kolizje_autorstwa_w_formsecie(formset): warunkowe, są tam pomijane. Stąd ręczne porównanie par formularzy widocznych w formsecie (bez zapytań do bazy — `formset.instance`, czyli rodzic/rekord, jest wspólny dla wszystkich wierszy formsetu z definicji - inline). Dwa klucze pokrywają WSZYSTKIE TRZY constrainty z Meta: - (autor, typ) ↔ `..._uniq_rekord_autor_typ`; goła `kolejnosc` (bez - autora) ↔ zarówno `..._uniq_rekord_autor_kolejnosc` (ten sam autor z - dwoma wpisami o tej samej kolejności), jak i `..._excl_rekord_kolejnosc` - (DWÓCH RÓŻNYCH autorów na tej samej pozycji) — goła `kolejnosc` jest - ściślejsza (autor nieistotny), więc pokrywa oba naraz.""" + inline). Dwa klucze pokrywają OBA constrainty z Meta: (autor, typ) ↔ + `..._uniq_rekord_autor_typ`; goła `kolejnosc` (bez autora) ↔ + `..._excl_rekord_kolejnosc`, który tak samo nie patrzy na autora — łapie + więc zarówno „ten sam autor dwa razy na tej samej pozycji", jak i „dwóch + RÓŻNYCH autorów na tej samej pozycji".""" # UWAGA: `formset.deleted_forms` NIE nadaje się tu do użytku — jego # własna implementacja zaczyna od `if not self.is_valid(): return []`. # Wołane z wnętrza `clean()` (a więc z wnętrza `full_clean()`), gdy diff --git a/src/bpp/migrations/0488_autor_soft_delete_fields.py b/src/bpp/migrations/0488_autor_soft_delete_fields.py index 31263d767..3a1cfc20d 100644 --- a/src/bpp/migrations/0488_autor_soft_delete_fields.py +++ b/src/bpp/migrations/0488_autor_soft_delete_fields.py @@ -1,6 +1,15 @@ # Generated by Django 5.2.16 on 2026-08-06 12:48 +# +# Indeksy na `deleted_at` są CZĘŚCIOWE (`WHERE deleted_at IS NOT NULL`). +# Pełny btree byłby tu martwym kosztem: `deleted_at IS NULL` pasuje do ~100% +# wierszy, więc planner pod ten predykat i tak wybierze seq scan, a indeks +# zostawałby wyłącznie narzutem (rozmiar rzędu tabeli + wpis przy każdym +# INSERT/UPDATE autorstwa). Selektywne jest zapytanie ODWROTNE — +# `deleted_objects` (`deleted_at IS NOT NULL`) — i to jemu służy ten +# mikroskopijny indeks. Uzasadnienie w `Meta.indexes` modeli. from django.db import migrations, models +from django.db.models import Q class Migration(migrations.Migration): @@ -57,6 +66,7 @@ class Migration(migrations.Migration): migrations.AddIndex( model_name="wydawnictwo_ciagle_autor", index=models.Index( + condition=Q(("deleted_at__isnull", False)), fields=["deleted_at"], name="wc_autor_deleted_at_idx", ), @@ -64,6 +74,7 @@ class Migration(migrations.Migration): migrations.AddIndex( model_name="wydawnictwo_zwarte_autor", index=models.Index( + condition=Q(("deleted_at__isnull", False)), fields=["deleted_at"], name="wz_autor_deleted_at_idx", ), @@ -71,6 +82,7 @@ class Migration(migrations.Migration): migrations.AddIndex( model_name="patent_autor", index=models.Index( + condition=Q(("deleted_at__isnull", False)), fields=["deleted_at"], name="patent_autor_deleted_at_idx", ), diff --git a/src/bpp/migrations/0489_soft_delete_autorzy_views.py b/src/bpp/migrations/0489_soft_delete_autorzy_views.py index fe8249b75..b0f0ae286 100644 --- a/src/bpp/migrations/0489_soft_delete_autorzy_views.py +++ b/src/bpp/migrations/0489_soft_delete_autorzy_views.py @@ -79,9 +79,16 @@ def _filtruj_widok(cur, tabela, widok): Owijka NIE zachowuje planu (dokłada anti-join i sondę po pkey na każdy wiersz), więc — zgodnie z instrukcją planu na taki właśnie wypadek — dopisujemy ``deleted_at IS NULL`` do ``WHERE`` wewnętrznego. Ten wariant - daje plan **identyczny** z oryginałem na hot-pathie (Index Scan, 7.35), a - pełny skan wręcz przyspiesza (2.36) — trafia w indeks na ``deleted_at`` - założony w 0488. + daje plan **identyczny** z oryginałem na hot-pathie (Index Scan, 7.35). + + ⚠️ Pomiar pełnego skanu z tabeli wyżej został zebrany na PUSTEJ bazie + testowej i nie uogólnia się: pierwotnie zanotowano tam „2.36 — trafia w + indeks na ``deleted_at``". Na produkcji tak NIE będzie. ``deleted_at IS + NULL`` pasuje do ~100% wierszy, więc planner pod ten predykat wybierze + seq scan, a nie indeks — i właśnie dlatego indeks z 0488 jest CZĘŚCIOWY + (``WHERE deleted_at IS NOT NULL``, pod ``deleted_objects``). Filtr + wewnętrzny wybieramy z powodu hot-pathu triggera, nie z powodu pełnego + skanu. ⚠️ KLUCZ (pułapka, na której przejechał się pierwotny plan): w widokach ``*_autorzy`` kolumna ``object_id_raw`` to ``rekord_id``, czyli **id diff --git a/src/bpp/migrations/0490_autor_indeks_fk_rekord.py b/src/bpp/migrations/0490_autor_indeks_fk_rekord.py new file mode 100644 index 000000000..f6fe648a6 --- /dev/null +++ b/src/bpp/migrations/0490_autor_indeks_fk_rekord.py @@ -0,0 +1,78 @@ +"""Task 3c, krok 1/4: zwykły indeks na FK ``rekord`` w trzech ``*_Autor``. + +Task 3c był pierwotnie JEDNĄ migracją (0490). Rozbity jest na cztery +(0490-0493), a każda ma ``atomic = False``. Powód: + +- ``ADD CONSTRAINT ... EXCLUDE USING GIST`` bierze ``ACCESS EXCLUSIVE`` na + tabeli (blokuje także ODCZYTY) i nie ma wariantu współbieżnego. W jednej + transakcji WSZYSTKIE takie blokady — z trzech największych tabel through — + wisiałyby aż do ``COMMIT``, czyli przez czas budowy wszystkich dziewięciu + indeksów naraz. ``atomic = False`` sprawia, że każde ``ALTER TABLE`` + commituje się osobno i natychmiast oddaje blokadę. +- Rozbicie na pliki daje dodatkowo granulację odzyskiwania: przerwana + migracja zostawia zapisane w ``django_migrations`` te kroki, które się + udały. + +KOLEJNOŚĆ jest dobrana tak, żeby NIE BYŁO OKNA bez ochrony unikalności: +najpierw wszystko, co DODAJE (0490 indeks FK, 0491 unique po typie, +0492 exclusion po kolejności), a dopiero na końcu (0493) to, co ZDEJMUJE +stare ``unique_together`` i legacy constraint z 0132. Przez chwilę +obowiązują oba komplety naraz — to bezpieczne, bo stare są ostrzejsze +(bezwarunkowe). + +CENA ``atomic = False``: migracja przerwana w połowie NIE jest zapisana w +``django_migrations``, a część DDL już się wykonała — ponowny ``migrate`` +wywali się na istniejącym obiekcie. Stąd rozbicie na małe kroki, każdy z +własnym wpisem. + +Ten krok: przywrócenie zwykłego indeksu na FK ``rekord`` (``AlterField`` +kasuje wcześniejsze ``db_index=False``). Dopóki ``unique_together`` dawało +PEŁNY (nie częściowy) indeks btree z ``rekord`` jako kolumną wiodącą, +``db_index=False`` na FK było uzasadnione — auto-indeks byłby redundantny. +Constrainty z 0491/0492 są CZĘŚCIOWE (``WHERE deleted_at IS NULL``), więc +nie pokrywają już zapytań po samym ``rekord`` bez tego predykatu: RI-check +Postgresa przy DELETE rodzica, kolektor kaskady Django (``_base_manager``, +bez filtra soft-delete) i ``global_objects``/``deleted_objects`` +``.filter(rekord=…)``. +""" + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("bpp", "0489_soft_delete_autorzy_views"), + ] + + operations = [ + migrations.AlterField( + model_name="patent_autor", + name="rekord", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="autorzy_set", + to="bpp.patent", + ), + ), + migrations.AlterField( + model_name="wydawnictwo_ciagle_autor", + name="rekord", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="autorzy_set", + to="bpp.wydawnictwo_ciagle", + ), + ), + migrations.AlterField( + model_name="wydawnictwo_zwarte_autor", + name="rekord", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="autorzy_set", + to="bpp.wydawnictwo_zwarte", + ), + ), + ] diff --git a/src/bpp/migrations/0490_autor_warunkowy_unique.py b/src/bpp/migrations/0490_autor_warunkowy_unique.py deleted file mode 100644 index c42219087..000000000 --- a/src/bpp/migrations/0490_autor_warunkowy_unique.py +++ /dev/null @@ -1,202 +0,0 @@ -# Generated by Django 5.2.16 on 2026-08-06 17:16 -# -# Task 3c. Trzy powiązane zmiany na Wydawnictwo_Ciagle_Autor, -# Wydawnictwo_Zwarte_Autor, Patent_Autor: -# -# 1. `unique_together` (widziałby też wiersze soft-deleted) -> dwa warunkowe -# `UniqueConstraint` (`condition=deleted_at__isnull`). -# 2. Przywrócenie zwykłego indeksu na FK `rekord` (`AlterField`, kasuje -# wcześniejsze `db_index=False`). Powód: dopóki `unique_together` dawało -# PEŁNY (nie częściowy) indeks btree z `rekord` jako kolumną wiodącą, -# `db_index=False` na FK było uzasadnione — auto-indeks byłby redundantny. -# Warunkowe constrainty z (1) są CZĘŚCIOWE (`WHERE deleted_at IS NULL`) — -# nie pokrywają już zapytań po samym `rekord` bez tego predykatu -# (RI-check Postgresa przy DELETE rodzica, kolektor kaskady Django, -# `global_objects`/`deleted_objects`.filter(rekord=…)). -# 3. Zastąpienie legacy `RunSQL` z migracji 0132 (2018) — DODATKOWEGO, -# poza Django ORM, `UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY -# DEFERRED` (nigdy nie było w żadnym `Meta`, więc `makemigrations` go nie -# widziało) — `ExclusionConstraint`-em (GiST + btree_gist, rozszerzenie -# już włączone od migracji 0056). `UniqueConstraint` nie umie łączyć -# `condition` z `deferrable` (Django to blokuje), a `deferrable` jest -# wymagany przez drag&drop reorder autorów w adminie (adminsortable2, -# `sortable_field_name = "kolejnosc"`) — zamiana kolejności dwóch wierszy -# przejściowo dubluje `kolejnosc` w obrębie jednej transakcji. -# -# UWAGA przy rollbacku (`reverse_sql` niżej): przywraca legacy `UNIQUE -# (rekord_id, kolejnosc) DEFERRABLE`, ale to NIE zadziała, jeśli w tabeli są -# już soft-deletowane wiersze dublujące `(rekord_id, kolejnosc)` z żywymi — -# taki stan jest dozwolony PO tej migracji (to właśnie ona to umożliwia), -# ale narusza bezwarunkowy constraint SPRZED niej. Rollback padnie -# dokładnie wtedy, gdy funkcja soft-delete była już używana. - -import django.contrib.postgres.constraints -import django.db.models.constraints -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("bpp", "0489_soft_delete_autorzy_views"), - ] - - operations = [ - migrations.AlterUniqueTogether( - name="patent_autor", - unique_together=set(), - ), - migrations.AlterUniqueTogether( - name="wydawnictwo_ciagle_autor", - unique_together=set(), - ), - migrations.AlterUniqueTogether( - name="wydawnictwo_zwarte_autor", - unique_together=set(), - ), - migrations.AlterField( - model_name="patent_autor", - name="rekord", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="autorzy_set", - to="bpp.patent", - ), - ), - migrations.AlterField( - model_name="wydawnictwo_ciagle_autor", - name="rekord", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="autorzy_set", - to="bpp.wydawnictwo_ciagle", - ), - ), - migrations.AlterField( - model_name="wydawnictwo_zwarte_autor", - name="rekord", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="autorzy_set", - to="bpp.wydawnictwo_zwarte", - ), - ), - migrations.AddConstraint( - model_name="patent_autor", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("rekord", "autor", "typ_odpowiedzialnosci"), - name="pat_autor_uniq_rekord_autor_typ", - ), - ), - migrations.AddConstraint( - model_name="patent_autor", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("rekord", "autor", "kolejnosc"), - name="pat_autor_uniq_rekord_autor_kolejnosc", - ), - ), - migrations.AddConstraint( - model_name="patent_autor", - constraint=django.contrib.postgres.constraints.ExclusionConstraint( - condition=models.Q(("deleted_at__isnull", True)), - deferrable=django.db.models.constraints.Deferrable["DEFERRED"], - expressions=[("rekord", "="), ("kolejnosc", "=")], - name="pat_autor_excl_rekord_kolejnosc", - ), - ), - migrations.AddConstraint( - model_name="wydawnictwo_ciagle_autor", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("rekord", "autor", "typ_odpowiedzialnosci"), - name="wc_autor_uniq_rekord_autor_typ", - ), - ), - migrations.AddConstraint( - model_name="wydawnictwo_ciagle_autor", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("rekord", "autor", "kolejnosc"), - name="wc_autor_uniq_rekord_autor_kolejnosc", - ), - ), - migrations.AddConstraint( - model_name="wydawnictwo_ciagle_autor", - constraint=django.contrib.postgres.constraints.ExclusionConstraint( - condition=models.Q(("deleted_at__isnull", True)), - deferrable=django.db.models.constraints.Deferrable["DEFERRED"], - expressions=[("rekord", "="), ("kolejnosc", "=")], - name="wc_autor_excl_rekord_kolejnosc", - ), - ), - migrations.AddConstraint( - model_name="wydawnictwo_zwarte_autor", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("rekord", "autor", "typ_odpowiedzialnosci"), - name="wz_autor_uniq_rekord_autor_typ", - ), - ), - migrations.AddConstraint( - model_name="wydawnictwo_zwarte_autor", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("rekord", "autor", "kolejnosc"), - name="wz_autor_uniq_rekord_autor_kolejnosc", - ), - ), - migrations.AddConstraint( - model_name="wydawnictwo_zwarte_autor", - constraint=django.contrib.postgres.constraints.ExclusionConstraint( - condition=models.Q(("deleted_at__isnull", True)), - deferrable=django.db.models.constraints.Deferrable["DEFERRED"], - expressions=[("rekord", "="), ("kolejnosc", "=")], - name="wz_autor_excl_rekord_kolejnosc", - ), - ), - # Trzeci kawałek Taska 3c (patrz komentarz na górze pliku): usuń - # legacy constraint z migracji 0132, zastąpiony ExclusionConstraint- - # em dodanym wyżej. `IF EXISTS` na DROP — instancja, na której ten - # constraint zdjęto już kiedyś ręcznie (spoza Django), nie może - # wywalić migracji. `reverse_sql` przywraca oryginalne DDL z 0132 - # bez `IF EXISTS` (ADD CONSTRAINT tego nie ma w składni) — patrz - # zastrzeżenie o rollbacku na górze pliku. - migrations.RunSQL( - sql=( - "ALTER TABLE bpp_patent_autor " - "DROP CONSTRAINT IF EXISTS " - "bpp_patent_autor_unique_rekord_id_kolejnosc;" - ), - reverse_sql=( - "ALTER TABLE bpp_patent_autor " - "ADD CONSTRAINT bpp_patent_autor_unique_rekord_id_kolejnosc " - "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;" - ), - ), - migrations.RunSQL( - sql=( - "ALTER TABLE bpp_wydawnictwo_ciagle_autor DROP CONSTRAINT " - "IF EXISTS " - "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc;" - ), - reverse_sql=( - "ALTER TABLE bpp_wydawnictwo_ciagle_autor ADD CONSTRAINT " - "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc " - "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;" - ), - ), - migrations.RunSQL( - sql=( - "ALTER TABLE bpp_wydawnictwo_zwarte_autor DROP CONSTRAINT " - "IF EXISTS " - "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc;" - ), - reverse_sql=( - "ALTER TABLE bpp_wydawnictwo_zwarte_autor ADD CONSTRAINT " - "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc " - "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;" - ), - ), - ] diff --git a/src/bpp/migrations/0491_autor_unique_rekord_autor_typ.py b/src/bpp/migrations/0491_autor_unique_rekord_autor_typ.py new file mode 100644 index 000000000..60606d1f6 --- /dev/null +++ b/src/bpp/migrations/0491_autor_unique_rekord_autor_typ.py @@ -0,0 +1,54 @@ +"""Task 3c, krok 2/4: warunkowy ``UniqueConstraint`` (rekord, autor, typ). + +Rozbicie migracji i ``atomic = False`` — patrz docstring 0490. + +``unique_together`` widziałby też wiersze soft-deleted (fizycznie wciąż są w +tabeli) i blokowałby wzorzec „skasuj i wstaw od nowa" (re-import, korekta +kolejności, edycja inline). Warunkowy ``UniqueConstraint`` +(``condition=deleted_at__isnull``) pilnuje unikalności TYLKO wśród żywych +wierszy. Stare ``unique_together`` zdejmuje dopiero 0493 — tu jeszcze +obowiązuje, więc nie ma okna bez ochrony. + +Drugiej pary z ``unique_together`` — ``(rekord, autor, kolejnosc)`` — NIE +odtwarzamy: ``ExclusionConstraint`` po ``(rekord, kolejnosc)`` z 0492 jest +ściśle silniejszy (nie patrzy na autora), więc taki unique byłby w 100% +redundantny — trzeci indeks do zbudowania w oknie serwisowym i stały narzut +na najgorętszej ścieżce zapisu. +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("bpp", "0490_autor_indeks_fk_rekord"), + ] + + operations = [ + migrations.AddConstraint( + model_name="patent_autor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("rekord", "autor", "typ_odpowiedzialnosci"), + name="pat_autor_uniq_rekord_autor_typ", + ), + ), + migrations.AddConstraint( + model_name="wydawnictwo_ciagle_autor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("rekord", "autor", "typ_odpowiedzialnosci"), + name="wc_autor_uniq_rekord_autor_typ", + ), + ), + migrations.AddConstraint( + model_name="wydawnictwo_zwarte_autor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("rekord", "autor", "typ_odpowiedzialnosci"), + name="wz_autor_uniq_rekord_autor_typ", + ), + ), + ] diff --git a/src/bpp/migrations/0492_autor_excl_rekord_kolejnosc.py b/src/bpp/migrations/0492_autor_excl_rekord_kolejnosc.py new file mode 100644 index 000000000..a2baae8ae --- /dev/null +++ b/src/bpp/migrations/0492_autor_excl_rekord_kolejnosc.py @@ -0,0 +1,62 @@ +"""Task 3c, krok 3/4: ``ExclusionConstraint`` (rekord, kolejnosc), DEFERRED. + +Rozbicie migracji i ``atomic = False`` — patrz docstring 0490. To najdroższy +krok całej serii: ``ADD CONSTRAINT ... EXCLUDE USING GIST`` buduje indeks +GiST pod ``ACCESS EXCLUSIVE`` (blokuje również ODCZYTY) i nie ma wariantu +współbieżnego. Dzięki ``atomic = False`` blokada każdej z trzech tabel jest +zwalniana od razu po jej ``ALTER TABLE``, a nie dopiero na ``COMMIT`` całej +migracji. + +Zastępuje legacy ``RunSQL`` z migracji 0132 (2018) — DODATKOWY, poza Django +ORM, ``UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED`` (nigdy +nie było go w żadnym ``Meta``, więc ``makemigrations`` go nie widziało); +legacy constraint zdejmuje 0493. ``UniqueConstraint`` nie umie połączyć +``condition`` z ``deferrable`` (Django to blokuje), a ``deferrable`` jest +wymagany przez drag&drop reorder autorów w adminie (adminsortable2, +``sortable_field_name = "kolejnosc"``) — zamiana kolejności dwóch wierszy +przejściowo dubluje ``kolejnosc`` w obrębie jednej transakcji. +``ExclusionConstraint`` (GiST + btree_gist, rozszerzenie włączone od 0056) +to jedyny typ ograniczenia w Postgresie łączący ``WHERE`` z ``DEFERRABLE``. +""" + +import django.contrib.postgres.constraints +import django.db.models.constraints +from django.db import migrations, models + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("bpp", "0491_autor_unique_rekord_autor_typ"), + ] + + operations = [ + migrations.AddConstraint( + model_name="patent_autor", + constraint=django.contrib.postgres.constraints.ExclusionConstraint( + condition=models.Q(("deleted_at__isnull", True)), + deferrable=django.db.models.constraints.Deferrable["DEFERRED"], + expressions=[("rekord", "="), ("kolejnosc", "=")], + name="pat_autor_excl_rekord_kolejnosc", + ), + ), + migrations.AddConstraint( + model_name="wydawnictwo_ciagle_autor", + constraint=django.contrib.postgres.constraints.ExclusionConstraint( + condition=models.Q(("deleted_at__isnull", True)), + deferrable=django.db.models.constraints.Deferrable["DEFERRED"], + expressions=[("rekord", "="), ("kolejnosc", "=")], + name="wc_autor_excl_rekord_kolejnosc", + ), + ), + migrations.AddConstraint( + model_name="wydawnictwo_zwarte_autor", + constraint=django.contrib.postgres.constraints.ExclusionConstraint( + condition=models.Q(("deleted_at__isnull", True)), + deferrable=django.db.models.constraints.Deferrable["DEFERRED"], + expressions=[("rekord", "="), ("kolejnosc", "=")], + name="wz_autor_excl_rekord_kolejnosc", + ), + ), + ] diff --git a/src/bpp/migrations/0493_autor_zdjecie_starych_unique.py b/src/bpp/migrations/0493_autor_zdjecie_starych_unique.py new file mode 100644 index 000000000..d60e1c2c7 --- /dev/null +++ b/src/bpp/migrations/0493_autor_zdjecie_starych_unique.py @@ -0,0 +1,87 @@ +"""Task 3c, krok 4/4: zdjęcie bezwarunkowych ograniczeń unikalności. + +Rozbicie migracji i ``atomic = False`` — patrz docstring 0490. Ten krok jest +wyłącznie ODEJMUJĄCY i idzie na końcu, żeby przez całą serię 0490-0493 +tabela miała ochronę unikalności (najpierw stare, potem stare + nowe, na +końcu same nowe). + +Zdejmujemy: + +1. ``unique_together`` — zastąpione warunkowymi ``UniqueConstraint``/ + ``ExclusionConstraint`` z 0491/0492; +2. legacy ``UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED`` + dołożone gołym ``RunSQL`` w migracji 0132 (2018) — zastąpione + ``ExclusionConstraint`` z 0492. ``IF EXISTS`` na DROP: instancja, na + której ten constraint zdjęto już kiedyś ręcznie (spoza Django), nie może + wywalić migracji. + +UWAGA przy rollbacku: ``reverse_sql`` przywraca legacy ``UNIQUE (rekord_id, +kolejnosc) DEFERRABLE`` (a ``AlterUniqueTogether`` — bezwarunkowe indeksy), +ale to NIE zadziała, jeśli w tabeli są już soft-deletowane wiersze dublujące +``(rekord_id, kolejnosc)`` z żywymi. Taki stan jest dozwolony PO tej +migracji (to właśnie ona go umożliwia), ale narusza bezwarunkowe ograniczenia +SPRZED niej. Rollback padnie dokładnie wtedy, gdy funkcja soft-delete była +już używana. ``ADD CONSTRAINT`` nie ma składni ``IF EXISTS``, więc +``reverse_sql`` jest bezwarunkowe. +""" + +from django.db import migrations + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("bpp", "0492_autor_excl_rekord_kolejnosc"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="patent_autor", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="wydawnictwo_ciagle_autor", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="wydawnictwo_zwarte_autor", + unique_together=set(), + ), + migrations.RunSQL( + sql=( + "ALTER TABLE bpp_patent_autor " + "DROP CONSTRAINT IF EXISTS " + "bpp_patent_autor_unique_rekord_id_kolejnosc;" + ), + reverse_sql=( + "ALTER TABLE bpp_patent_autor " + "ADD CONSTRAINT bpp_patent_autor_unique_rekord_id_kolejnosc " + "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;" + ), + ), + migrations.RunSQL( + sql=( + "ALTER TABLE bpp_wydawnictwo_ciagle_autor DROP CONSTRAINT " + "IF EXISTS " + "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc;" + ), + reverse_sql=( + "ALTER TABLE bpp_wydawnictwo_ciagle_autor ADD CONSTRAINT " + "bpp_wydawnictwo_ciagle_autor_unique_rekord_id_kolejnosc " + "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;" + ), + ), + migrations.RunSQL( + sql=( + "ALTER TABLE bpp_wydawnictwo_zwarte_autor DROP CONSTRAINT " + "IF EXISTS " + "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc;" + ), + reverse_sql=( + "ALTER TABLE bpp_wydawnictwo_zwarte_autor ADD CONSTRAINT " + "bpp_wydawnictwo_zwarte_autor_unique_rekord_id_kolejnosc " + "UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY DEFERRED;" + ), + ), + ] diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py index ab3a26203..859536b9e 100644 --- a/src/bpp/models/patent.py +++ b/src/bpp/models/patent.py @@ -76,11 +76,10 @@ class Meta: condition=Q(deleted_at__isnull=True), name="pat_autor_uniq_rekord_autor_typ", ), - models.UniqueConstraint( - fields=["rekord", "autor", "kolejnosc"], - condition=Q(deleted_at__isnull=True), - name="pat_autor_uniq_rekord_autor_kolejnosc", - ), + # NIE MA tu `UniqueConstraint(rekord, autor, kolejnosc)` — + # `pat_autor_excl_rekord_kolejnosc` niżej jest ściśle silniejszy + # (nie patrzy na autora); patrz komentarz w + # `Wydawnictwo_Ciagle_Autor.Meta`. # Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id, # kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 — # patrz analogiczny komentarz w Wydawnictwo_Ciagle_Autor.Meta. @@ -95,7 +94,13 @@ class Meta: ), ] indexes = [ - models.Index(fields=["deleted_at"], name="patent_autor_deleted_at_idx"), + # Indeks CZĘŚCIOWY — patrz uzasadnienie w + # `Wydawnictwo_Ciagle_Autor.Meta.indexes`. + models.Index( + fields=["deleted_at"], + name="patent_autor_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), ] # django-denorm buduje bramkę WHEN triggera z listy `only=` w diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py index 8995240fd..de559857a 100644 --- a/src/bpp/models/wydawnictwo_ciagle.py +++ b/src/bpp/models/wydawnictwo_ciagle.py @@ -101,11 +101,14 @@ class Meta: condition=Q(deleted_at__isnull=True), name="wc_autor_uniq_rekord_autor_typ", ), - models.UniqueConstraint( - fields=["rekord", "autor", "kolejnosc"], - condition=Q(deleted_at__isnull=True), - name="wc_autor_uniq_rekord_autor_kolejnosc", - ), + # NIE MA tu `UniqueConstraint(rekord, autor, kolejnosc)` — byłby + # w 100% redundantny wobec `wc_autor_excl_rekord_kolejnosc` + # niżej. Ten pilnuje pary (rekord, kolejnosc) NIE PATRZĄC na + # autora, a więc jest ściśle silniejszy: skoro w obrębie rekordu + # żadna pozycja nie może się powtórzyć, to tym bardziej nie może + # się powtórzyć w obrębie (rekord, autor). Kosztowałby wyłącznie + # trzeci indeks: build w oknie serwisowym + stały narzut na + # każdym zapisie autorstwa (najgorętsza ścieżka zapisu w BPP). # Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id, # kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 — # gwarantuje, że DWÓCH RÓŻNYCH autorów nie dzieli tej samej @@ -131,7 +134,19 @@ class Meta: ), ] indexes = [ - models.Index(fields=["deleted_at"], name="wc_autor_deleted_at_idx"), + # Indeks CZĘŚCIOWY (`WHERE deleted_at IS NOT NULL`), nie pełny. + # Predykat `deleted_at IS NULL` pasuje do ~100% wierszy, więc + # planner i tak nigdy nie wybrałby pod niego indeksu (seq scan + # jest tańszy) — pełny btree byłby wyłącznie kosztem: rozmiar + # rzędu tabeli + wpis przy każdym INSERT/UPDATE autorstwa. + # Realnie selektywne jest zapytanie ODWROTNE — `deleted_objects` + # (`deleted_at IS NOT NULL`), czyli kosz/audyt — i to ono + # dostaje tu mikroskopijny indeks. + models.Index( + fields=["deleted_at"], + name="wc_autor_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), ] # django-denorm buduje bramkę WHEN triggera z listy `only=` w diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py index f4c9084e9..6bc800b4b 100644 --- a/src/bpp/models/wydawnictwo_zwarte.py +++ b/src/bpp/models/wydawnictwo_zwarte.py @@ -110,11 +110,10 @@ class Meta: condition=Q(deleted_at__isnull=True), name="wz_autor_uniq_rekord_autor_typ", ), - models.UniqueConstraint( - fields=["rekord", "autor", "kolejnosc"], - condition=Q(deleted_at__isnull=True), - name="wz_autor_uniq_rekord_autor_kolejnosc", - ), + # NIE MA tu `UniqueConstraint(rekord, autor, kolejnosc)` — + # `wz_autor_excl_rekord_kolejnosc` niżej jest ściśle silniejszy + # (nie patrzy na autora); patrz komentarz w + # `Wydawnictwo_Ciagle_Autor.Meta`. # Odpowiednik legacy `ALTER TABLE ... UNIQUE (rekord_id, # kolejnosc) DEFERRABLE INITIALLY DEFERRED` z migracji 0132 — # patrz analogiczny komentarz w Wydawnictwo_Ciagle_Autor.Meta. @@ -129,7 +128,13 @@ class Meta: ), ] indexes = [ - models.Index(fields=["deleted_at"], name="wz_autor_deleted_at_idx"), + # Indeks CZĘŚCIOWY — patrz uzasadnienie w + # `Wydawnictwo_Ciagle_Autor.Meta.indexes`. + models.Index( + fields=["deleted_at"], + name="wz_autor_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), ] # django-denorm buduje bramkę WHEN triggera z listy `only=` w diff --git a/src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py b/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py similarity index 56% rename from src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py rename to src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py index b62b247e7..dbef53e48 100644 --- a/src/bpp/tests/test_soft_delete/test_migracja_0490_rewers.py +++ b/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py @@ -1,10 +1,18 @@ -"""Odwracalność migracji 0490 (warunkowy UniqueConstraint/ExclusionConstraint -na *_Autor + zastąpienie legacy `RunSQL` constraintu z migracji 0132). +"""Odwracalność serii 0490-0493 (Task 3c): warunkowe ograniczenia na *_Autor. -``backward`` musi przywrócić DOKŁADNIE stan sprzed migracji: bezwarunkowy -`unique_together` ORAZ legacy `UNIQUE (rekord_id, kolejnosc) DEFERRABLE -INITIALLY DEFERRED` — inaczej rollback zostawia bazę bez żadnego z dwóch -mechanizmów pilnujących unikalności kolejności autorów. +Task 3c był pierwotnie jedną migracją (0490); jest rozbity na cztery +(indeks FK / unique po typie / exclusion po kolejności / zdjęcie starych), +każda z ``atomic = False`` — patrz docstring ``0490_autor_indeks_fk_rekord``. + +``backward`` całej serii musi przywrócić DOKŁADNIE stan sprzed niej: +bezwarunkowy ``unique_together`` ORAZ legacy ``UNIQUE (rekord_id, kolejnosc) +DEFERRABLE INITIALLY DEFERRED`` — inaczej rollback zostawia bazę bez żadnego +z dwóch mechanizmów pilnujących unikalności kolejności autorów. + +Test zjeżdża do 0489 i wraca do NAJNOWSZEJ migracji ``bpp`` (``migrate bpp`` +bez numeru). Powrót „do 0490" byłby błędem: baza testowa jest współdzielona +przez cały przebieg (``--reuse-db``), więc pozostawienie migracji późniejszych +niż cel jako niezastosowanych psułoby kolejne testy. """ import pytest @@ -52,16 +60,22 @@ def _constraint_names(cur, tabela): return nazwy +def _sprawdz_stan_po(cur): + for tabela in TABELE: + nazwy = _constraint_names(cur, tabela) + prefix = PREFIX[tabela] + assert f"{prefix}_autor_uniq_rekord_autor_typ" in nazwy + assert f"{prefix}_autor_excl_rekord_kolejnosc" in nazwy + # Redundantny wobec `..._excl_rekord_kolejnosc` (ten nie patrzy na + # autora, więc jest ściśle silniejszy) — świadomie NIE tworzony. + assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" not in nazwy + assert LEGACY_CONSTRAINT[tabela] not in nazwy + + @pytest.mark.django_db -def test_migracja_0490_odwracalna(): +def test_migracje_0490_0493_odwracalne(): with connection.cursor() as cur: - for tabela in TABELE: - nazwy = _constraint_names(cur, tabela) - prefix = PREFIX[tabela] - assert f"{prefix}_autor_uniq_rekord_autor_typ" in nazwy - assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" in nazwy - assert f"{prefix}_autor_excl_rekord_kolejnosc" in nazwy - assert LEGACY_CONSTRAINT[tabela] not in nazwy + _sprawdz_stan_po(cur) call_command("migrate", "bpp", "0489", verbosity=0) @@ -70,17 +84,10 @@ def test_migracja_0490_odwracalna(): nazwy = _constraint_names(cur, tabela) prefix = PREFIX[tabela] assert f"{prefix}_autor_uniq_rekord_autor_typ" not in nazwy - assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" not in nazwy assert f"{prefix}_autor_excl_rekord_kolejnosc" not in nazwy assert LEGACY_CONSTRAINT[tabela] in nazwy - call_command("migrate", "bpp", "0490", verbosity=0) + call_command("migrate", "bpp", verbosity=0) with connection.cursor() as cur: - for tabela in TABELE: - nazwy = _constraint_names(cur, tabela) - prefix = PREFIX[tabela] - assert f"{prefix}_autor_uniq_rekord_autor_typ" in nazwy - assert f"{prefix}_autor_uniq_rekord_autor_kolejnosc" in nazwy - assert f"{prefix}_autor_excl_rekord_kolejnosc" in nazwy - assert LEGACY_CONSTRAINT[tabela] not in nazwy + _sprawdz_stan_po(cur) From 848dd13363e22e4066cb17eda3a248730e3b466e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 22:11:53 +0200 Subject: [PATCH 33/75] fix(soft-delete): 3 widoki pochodne przestaja czytac skasowane autorstwa Domyka trzy blokery z recenzji finalnej fazy 01. Wszystkie byly aktywne od dnia 1 po wdrozeniu -- `delete()` jest miekki od pierwszej sekundy, nie ma feature flagi. BLOKER 1 (0494) -- `liczba_autorow` w `bpp__view` (0421) liczyla SUROWA tabele `*_autor`. Zawyzona `Rekord.liczba_autorow` cicho psula multiseekowe "Ostatnie nazwisko i imie" (filtr `kolejnosc IN [liczba_autorow-1, liczba_autorow)` celowal w pusta pozycje), sortowanie i pole "Liczba autorow". Poprawka: `count(...) FILTER (WHERE deleted_at IS NULL)`, a NIE warunek w JOIN/WHERE -- ten drugi wywalilby z widoku (a wiec z `bpp_rekord_mat` i z calego serwisu) publikacje, ktorym soft-deletowano WSZYSTKICH autorow, bo LEFT JOIN + WHERE na prawej stronie = INNER JOIN. Po zmianie definicji regenerowana jest bramka WHEN triggerow UPDATE tabel publikacji (logika 0433) -- dzis z konstrukcji no-op, ale niezmiennik "ruszyles widok, przelicz bramke" zostaje domkniety. BLOKER 2 (0495) -- `bpp_nowe_sumy_*_view` (0458) joinowaly surowe `*_autor`, wiec skasowane autorstwo dalej wnosilo punkty do rankingu autorow. Najgorszy scenariusz: deduplikator autorow przenosi autorstwa i kasuje zrodlowe (od fazy 01 -- miekko), a duplikat zostaje w rankingu z pelna punktacja bezterminowo. BLOKER 3 (rozbieznosci_dyscyplin/0022) -- `..._rozbieznoscizrodelview` raportowal rozbieznosci dyscyplin dla autorstw, ktorych nie ma juz w interfejsie (nie da sie ich "naprawic"). Zywa definicja pochodzi z pliku 0017*.sql, przeladowywanego przez 0018/0019/0020 -- ten lancuch jest w origin/dev, wiec pliku .sql nie ruszamy; warunek dokladamy osobna migracja. Wszystkie trzy wychodza z INTROSPEKCJI (`pg_get_viewdef`), nie z tekstu migracji, ktora widok stworzyla -- wzorzec z 0489. Wspolny helper (`widok_dopisz_warunek` / `widok_usun_warunek` w `bpp/migration_util.py`, uzywany takze przez migracje spoza `bpp`) ma twarde bezpieczniki: odmawia dopisania `AND ...` gdy definicja zawiera OR / GROUP BY / UNION / ... albo gdy WHERE najwyzszego poziomu nie jest dokladnie jeden. Ustalone przy okazji (NIE naprawione, patrz raport): widoki `bpp_kronika_*` tez czytaja surowe `*_autor`, ale sa martwe -- zaden model ani kod ich nie uzywa. --- src/bpp/migration_util.py | 81 +++++++++- .../0494_liczba_autorow_bez_skasowanych.py | 147 ++++++++++++++++++ .../0495_nowe_sumy_bez_skasowanych.py | 54 +++++++ ...022_rozbieznosci_zrodel_bez_skasowanych.py | 45 ++++++ 4 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 src/bpp/migrations/0494_liczba_autorow_bez_skasowanych.py create mode 100644 src/bpp/migrations/0495_nowe_sumy_bez_skasowanych.py create mode 100644 src/rozbieznosci_dyscyplin/migrations/0022_rozbieznosci_zrodel_bez_skasowanych.py diff --git a/src/bpp/migration_util.py b/src/bpp/migration_util.py index 59ca883e4..543827aa2 100644 --- a/src/bpp/migration_util.py +++ b/src/bpp/migration_util.py @@ -20,6 +20,85 @@ def load_custom_sql(mig_name, app_name="bpp", charset="utf-8", *args, **kw): # print "done!" +# Konstrukcje, przy których DOPISANIE `AND ` na koniec definicji +# widoku NIE jest równoważne dołożeniu warunku do WHERE: +# - " OR " -> AND wiąże mocniej, więc `... OR x AND nowy` zmienia sens; +# - GROUP BY / HAVING / WINDOW / ORDER BY / LIMIT / OFFSET / UNION / ... +# -> definicja nie kończy się na WHERE, więc `AND` wylądowałby +# w cudzej klauzuli (albo dał błąd składni). +_ZAKAZANE_W_WIDOKU = ( + " OR ", + "GROUP BY", + "HAVING", + "WINDOW", + "ORDER BY", + "LIMIT", + "OFFSET", + "UNION", + "INTERSECT", + "EXCEPT", +) + +# pg_get_viewdef(pretty=true) renderuje klauzulę WHERE najwyższego poziomu +# jako linię zaczynającą się od DWÓCH spacji; WHERE podzapytań jest wcięty +# głębiej. Liczba wystąpień tego wzorca odróżnia więc widok z jednym WHERE +# od widoku z UNION-em (gdzie dopisanie AND trafiłoby tylko w ostatnią gałąź). +_TOP_LEVEL_WHERE = "\n WHERE " + + +def viewdef(cur, widok): + """``pg_get_viewdef`` (pretty) bez końcowego średnika i białych znaków.""" + cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok]) + return cur.fetchone()[0].rstrip().rstrip(";") + + +def _sprawdz_ze_mozna_dopisac_and(widok, definicja): + for zakazane in _ZAKAZANE_W_WIDOKU: + if zakazane in definicja: + raise RuntimeError( + f"{widok}: definicja zawiera {zakazane!r} -- dopisanie " + f"'AND ' na koncu nie byloby rownowazne dolozeniu " + f"warunku do WHERE. Przepisz migracje recznie." + ) + ile = definicja.count(_TOP_LEVEL_WHERE) + if ile != 1: + raise RuntimeError( + f"{widok}: oczekiwano DOKLADNIE jednej klauzuli WHERE najwyzszego " + f"poziomu, znaleziono {ile}. Definicja: ...{definicja[-160:]!r}" + ) + + +def widok_dopisz_warunek(cur, widok, warunek): + """Dokłada ``AND `` do WHERE istniejącego widoku. + + Wychodzi z INTROSPEKCJI (``pg_get_viewdef``), nie z tekstu migracji, która + widok stworzyła — kopia SQL-a rozjechałaby się przy najbliższej zmianie + kolumn. ``CREATE OR REPLACE VIEW`` zachowuje listę i typy kolumn, więc + widoki zależne (np. ``bpp_nowe_sumy_view`` — UNION ALL po pięciu widokach) + NIE są kasowane; nie ma tu żadnego ``DROP ... CASCADE``. + + Idempotentna: gdy warunek już jest w definicji, nie robi nic. + """ + definicja = viewdef(cur, widok) + if warunek in definicja: + return + _sprawdz_ze_mozna_dopisac_and(widok, definicja) + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {definicja} AND {warunek}") + + +def widok_usun_warunek(cur, widok, warunek): + """Odwrotność ``widok_dopisz_warunek`` (dla ``backward()``). + + Wycina DOKŁADNIE dopisany sufiks ``AND ``; gdy go nie ma — no-op + (migracja cofana dwa razy albo widok w międzyczasie przebudowany). + """ + definicja = viewdef(cur, widok) + sufiks = f" AND {warunek}" + if not definicja.endswith(sufiks): + return + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {definicja[: -len(sufiks)]}") + + def load_fixture_as_json(fixture_name): return json.loads(open(str(find_fixture_path(fixture_name))).read()) @@ -43,5 +122,5 @@ def load_historic_fixture(apps, fixture_name, klass, app_name="bpp"): cur = connection.cursor() for klassobj, cnt in max_id_map.items(): - qry = f"ALTER SEQUENCE { klassobj._meta.db_table }_id_seq RESTART WITH { cnt + 1 }" + qry = f"ALTER SEQUENCE {klassobj._meta.db_table}_id_seq RESTART WITH {cnt + 1}" cur.execute(qry) diff --git a/src/bpp/migrations/0494_liczba_autorow_bez_skasowanych.py b/src/bpp/migrations/0494_liczba_autorow_bez_skasowanych.py new file mode 100644 index 000000000..1b6cf48da --- /dev/null +++ b/src/bpp/migrations/0494_liczba_autorow_bez_skasowanych.py @@ -0,0 +1,147 @@ +"""``liczba_autorow`` w ``bpp__view`` przestaje liczyć soft-deletowane. + +Migracja 0489 przefiltrowała widoki ``bpp_*_autorzy`` (te karmią +``bpp_autorzy_mat``), ale ``liczba_autorow`` bierze się z INNEJ rodziny +widoków — ``bpp_wydawnictwo_ciagle_view`` / ``bpp_wydawnictwo_zwarte_view`` / +``bpp_patent_view`` (migracja 0421) — i tam agregat liczył SUROWĄ tabelę +``*_autor``: + + count(bpp_wydawnictwo_ciagle_autor.autor_id) AS liczba_autorow + +Skutki zawyżonej ``Rekord.liczba_autorow`` (kolumna ``bpp_rekord_mat``): + +- multiseek, kryterium „Ostatnie nazwisko i imię": filtr + ``kolejnosc ∈ [liczba_autorow-1, liczba_autorow)`` + (``bpp/multiseek_registry/fields/author_fields.py``) celuje w pozycję, na + której po soft-delete nikogo nie ma — zapytanie CICHO zwraca pustkę; +- sortowanie multiseeka po „liczbie autorów" i samo pole „Liczba autorów". + +**Dlaczego ``FILTER``, a nie warunek w ``JOIN``/``WHERE``.** Widok robi +``LEFT JOIN`` do ``*_autor`` i grupuje po id publikacji. Dopisanie +``deleted_at IS NULL`` do ``WHERE`` skasowałoby Z WIDOKU całe publikacje, +którym soft-deletowano WSZYSTKICH autorów (LEFT JOIN + WHERE na kolumnie +prawej strony = INNER JOIN), a więc wywaliłoby je z ``bpp_rekord_mat`` +i z całego serwisu. Agregat ``count(...) FILTER (WHERE ...)`` rusza +WYŁĄCZNIE licznik: publikacja bez żywych autorów zostaje w widoku z +``liczba_autorow = 0``, dokładnie jak publikacja, której autorów nigdy nie +było. Poza licznikiem żadna kolumna tych widoków nie sięga do ``*_autor``, +więc to jedyne miejsce wymagające zmiany. + +Definicje są GENEROWANE z introspekcji (``pg_get_viewdef``), nie przepisane +do ``.sql`` — patrz docstring 0489. ``CREATE OR REPLACE VIEW`` zachowuje +listę kolumn (``count()`` to ``bigint`` w obu wariantach), więc zależny +``bpp_rekord`` (UNION po pięciu widokach) nie jest kasowany. + +UWAGA (eventual consistency, NIE regresja tej migracji): trigger na tabeli +``*_autor`` odświeża tylko ``bpp_autorzy_mat``. ``liczba_autorow`` w +``bpp_rekord_mat`` przelicza się dopiero, gdy flush denorm dotknie wiersza +publikacji (opis bibliograficzny zależy od autorów) — tak samo po TWARDYM +kasowaniu autorstwa. Patrz komentarz przy polu ``Rekord.liczba_autorow``. +""" + +import importlib + +from django.db import connection, migrations + +from bpp.migration_util import viewdef + +_p0433 = importlib.import_module("bpp.migrations.0433_cache_trigger_when_gate") + +# (widok bpp__view, tabela through *_autor, którą liczy) +LICZNIKI = [ + ("bpp_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle_autor"), + ("bpp_wydawnictwo_zwarte_view", "bpp_wydawnictwo_zwarte_autor"), + ("bpp_patent_view", "bpp_patent_autor"), +] + +# Tabele publikacji, których bramkę WHEN regenerujemy po zmianie widoku. +TABELE_PUBLIKACJI = {"bpp_wydawnictwo_ciagle", "bpp_wydawnictwo_zwarte", "bpp_patent"} + + +def _bez_filtra(tabela): + return f"count({tabela}.autor_id) AS liczba_autorow" + + +def _z_filtrem(tabela): + return ( + f"count({tabela}.autor_id) FILTER " + f"(WHERE {tabela}.deleted_at IS NULL) AS liczba_autorow" + ) + + +def _przepisz_agregat(cur, widok, stary, nowy): + """``CREATE OR REPLACE VIEW`` z podmienionym JEDNYM wystąpieniem agregatu. + + ``!= 1`` (a nie ``== 0``) jest celowe: gdyby przyszła zmiana dołożyła drugi + ``count(...)`` po tej samej tabeli, ślepy ``replace`` podmieniłby oba. + Głośny błąd migracji jest lepszy niż cicha zmiana semantyki widoku. + """ + definicja = viewdef(cur, widok) + if definicja.count(nowy) == 1 and stary not in definicja: + return # już przepisane (idempotencja) + ile = definicja.count(stary) + if ile != 1: + raise RuntimeError( + f"{widok}: oczekiwano DOKLADNIE jednego wystapienia {stary!r}, " + f"znaleziono {ile} -- nie podmieniam na slepo." + ) + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {definicja.replace(stary, nowy)}") + + +def _regeneruj_bramke(): + """Bramka WHEN triggerów UPDATE tabel PUBLIKACJI (logika ``0433.forward``). + + Bramka jest wyliczana z ``pg_depend`` PO definicji widoku, więc każda + zmiana definicji musi być domknięta jej regeneracją — inaczej wraca błąd, + który ta faza już raz naprawiała (0489, krok 3). + + Tu regeneracja jest z konstrukcji NO-OPEM: ``_gate_columns`` pyta o + kolumny TABELI PUBLIKACJI referowane przez widok, a ``FILTER (WHERE + *_autor.deleted_at ...)`` dokłada zależność od kolumny tabeli THROUGH. + Zostawiamy ją mimo to — jako niezmiennik „ruszyłeś widok, przelicz + bramkę", którego nie chcemy uzależniać od tego, czy akurat dziś wychodzi + ta sama lista kolumn. + """ + with connection.cursor() as cur: + for tabela, refresh_fn, widoki in _p0433.GATED: + if tabela not in TABELE_PUBLIKACJI: + continue + kolumny = _p0433._gate_columns(cur, tabela, widoki) + if not kolumny: + # Bezpiecznik z 0433: bez kolumn powstałby niebramkowany + # UPDATE (trigger na każdą zmianę wiersza publikacji). + raise RuntimeError( + f"bramka dla {tabela}: pg_depend nie zwrocil zadnej kolumny " + f"(widoki={widoki}) -- nie tworze niezbramkowanego UPDATE" + ) + when = _p0433._when_clause(kolumny) + cur.execute(f"DROP TRIGGER IF EXISTS {tabela}_cache_upd ON {tabela};") + cur.execute( + f"CREATE TRIGGER {tabela}_cache_upd AFTER UPDATE ON {tabela} " + f"FOR EACH ROW WHEN ({when}) " + f"EXECUTE PROCEDURE {refresh_fn}();" + ) + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + for widok, tabela in LICZNIKI: + _przepisz_agregat(cur, widok, _bez_filtra(tabela), _z_filtrem(tabela)) + _regeneruj_bramke() + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + for widok, tabela in LICZNIKI: + _przepisz_agregat(cur, widok, _z_filtrem(tabela), _bez_filtra(tabela)) + _regeneruj_bramke() + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0493_autor_zdjecie_starych_unique"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/migrations/0495_nowe_sumy_bez_skasowanych.py b/src/bpp/migrations/0495_nowe_sumy_bez_skasowanych.py new file mode 100644 index 000000000..f0cd22c45 --- /dev/null +++ b/src/bpp/migrations/0495_nowe_sumy_bez_skasowanych.py @@ -0,0 +1,54 @@ +"""Ranking autorów przestaje punktować soft-deletowane autorstwa. + +``bpp_nowe_sumy_*_view`` (migracja 0458) joinują SUROWE tabele ``*_autor``, +więc po soft-delete autorstwo dalej wnosi punkty do sum. Ścieżka: +``bpp_nowe_sumy_{patent,wydawnictwo_ciagle,wydawnictwo_zwarte}_view`` +-> UNION ALL ``bpp_nowe_sumy_view`` -> modele ``Nowe_Sumy_View``/``Sumy`` +(``bpp/models/sumy_views.py``) -> ``ranking_autorow/views.py``. + +Najgorszy scenariusz: deduplikator autorów przenosi autorstwa do autora +docelowego i kasuje źródłowe — od fazy 01 MIĘKKO. Bez tego filtra duplikat +zostaje w rankingu z pełną punktacją bezterminowo (a praca jest liczona +podwójnie: raz na autorze docelowym, raz na skasowanym duplikacie). + +Dwa pozostałe widoki unii — ``bpp_nowe_sumy_praca_doktorska_view`` i +``..._praca_habilitacyjna_view`` — NIE mają tabeli through (autor leży na +wierszu publikacji) i faza 01 nie robi ich soft-delete; zostają bez zmian. +""" + +from django.db import connection, migrations + +from bpp.migration_util import widok_dopisz_warunek, widok_usun_warunek + +# (widok sum, tabela through *_autor, którą joinuje) +SUMY = [ + ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle_autor"), + ("bpp_nowe_sumy_wydawnictwo_zwarte_view", "bpp_wydawnictwo_zwarte_autor"), + ("bpp_nowe_sumy_patent_view", "bpp_patent_autor"), +] + + +def _warunek(tabela): + return f"{tabela}.deleted_at IS NULL" + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + for widok, tabela in SUMY: + widok_dopisz_warunek(cur, widok, _warunek(tabela)) + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + for widok, tabela in SUMY: + widok_usun_warunek(cur, widok, _warunek(tabela)) + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0494_liczba_autorow_bez_skasowanych"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/rozbieznosci_dyscyplin/migrations/0022_rozbieznosci_zrodel_bez_skasowanych.py b/src/rozbieznosci_dyscyplin/migrations/0022_rozbieznosci_zrodel_bez_skasowanych.py new file mode 100644 index 000000000..add892a5e --- /dev/null +++ b/src/rozbieznosci_dyscyplin/migrations/0022_rozbieznosci_zrodel_bez_skasowanych.py @@ -0,0 +1,45 @@ +"""Raport rozbieżności dyscyplin źródeł pomija soft-deletowane autorstwa. + +``rozbieznosci_dyscyplin_rozbieznoscizrodelview`` czyta SUROWĄ +``bpp_wydawnictwo_ciagle_autor``. Definicja żyjąca w bazie pochodzi z pliku +``0017_add_punkty_kbn_and_charakter_formalny.sql``, PRZEŁADOWYWANEGO przez +migracje 0018 / 0019 / 0020 (każda woła ``load_custom_sql`` na TYM SAMYM +pliku). Ten łańcuch jest już w ``origin/dev``, więc pliku ``.sql`` nie wolno +ruszyć — dokładamy warunek osobną migracją, przez introspekcję. + +Pozostałe widoki tej aplikacji (``rozbieznosciview``, +``brakprzypisaniaview``, ``rozbiezneprzypisaniaview``) czytają widok +``bpp_autorzy``, przefiltrowany już w migracji ``bpp.0489`` — sprawdzone przez +``pg_depend`` (żaden z nich nie zależy od surowej tabeli ``*_autor``). +""" + +from django.db import connection, migrations + +from bpp.migration_util import widok_dopisz_warunek, widok_usun_warunek + +WIDOK = "rozbieznosci_dyscyplin_rozbieznoscizrodelview" +WARUNEK = "bpp_wydawnictwo_ciagle_autor.deleted_at IS NULL" + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + widok_dopisz_warunek(cur, WIDOK, WARUNEK) + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + widok_usun_warunek(cur, WIDOK, WARUNEK) + + +class Migration(migrations.Migration): + dependencies = [ + ("rozbieznosci_dyscyplin", "0021_alter_rozbieznosciview_options"), + # Kolumna `deleted_at` na *_autor powstaje w bpp.0488; bez tej + # zależności migracja mogłaby pójść przed nią i wywalić się na + # nieznanej kolumnie. + ("bpp", "0488_autor_soft_delete_fields"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] From 0181f5ccbf8a56619a6f77587aa5e21b33d5bdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 22:50:57 +0200 Subject: [PATCH 34/75] test(soft-delete): kanarek katalogowy pilnuje deleted_at w widokach pg_views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faza 01 odkrywała konsumentów surowych tabel *_autor pojedynczo, przez awarie (bramka cache'u, bramka denorma, unique_together, legacy constraint z 2018, trzy widoki pochodne w finalnej recenzji). Ten test pilnuje niezmiennika automatycznie: każdy widok w pg_views (schemat public) czytający tabelę objętą soft-delete musi filtrować po deleted_at, chyba że jest na jawnej liście wyjątków z uzasadnieniem. - TABELE_SOFT_DELETE jako stała modułowa — faza 02 rozszerza ją o 5 tabel publikacji jednolinijkową zmianą. - WYJATKI = trzy martwe widoki bpp_kronika_* (zweryfikowane: zero konsumentów w kodzie/szablonach/modelach), z uzasadnieniem i datą. - Matcher po granicy słowa (\b) odpiera pułapkę prefiksu (bpp_wydawnictwo_ciagle_autor vs _autorzy, oraz odwrotnie wydawnictwo_ciagle vs wydawnictwo_ciagle_autor pod fazę 02) — przykryte dedykowanymi testami parametryzowanymi. - Test mutacyjny: podmiana bpp_wydawnictwo_ciagle_autorzy na wersję bez filtra (CREATE OR REPLACE VIEW w transakcji testowej) potwierdza, że kanarek pada, po czym widok jest przywracany i ponownie zielony. Źródło prawdy: pg_views.definition na żywej bazie, nie pliki migracji (późniejsze nadpisują wcześniejsze) ani baseline.sql (snapshot sprzed części migracji fazy). 13 nowych testów, wszystkie zielone. Zero dodatkowych widoków znalezionych poza już udokumentowaną trójką kronika. Co-Authored-By: Claude Opus 5 (1M context) --- .../test_kanarek_katalogowy.py | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py new file mode 100644 index 000000000..a5072a539 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py @@ -0,0 +1,353 @@ +"""Kanarek katalogowy: KAŻDY widok czytający tabelę objętą soft-delete +MUSI filtrować po ``deleted_at`` — albo być na jawnej, uzasadnionej liście +wyjątków. + +## Dlaczego ten test istnieje + +Faza 01 odkrywała konsumentów surowych tabel ``*_autor`` **pojedynczo, +przez awarie**: najpierw bramka cache'u, potem bramka denorma, +``unique_together``, legacy constraint z 2018, a na końcu finalna recenzja +znalazła jeszcze trzy widoki pochodne (``liczba_autorow``, ranking, +rozbieżności) czytające surową tabelę zamiast już przefiltrowanego widoku +źródłowego. Każdy z nich to ta sama klasa błędu: coś czyta surową tabelę, +nie wiedząc o ``deleted_at``. + +Faza 02 (soft-delete PUBLIKACJI) trafi na dokładnie ten sam problem dla +tabel ``bpp_wydawnictwo_ciagle`` / ``_zwarte`` / ``bpp_patent`` / +``bpp_praca_doktorska`` / ``bpp_praca_habilitacyjna``. Ten kanarek pilnuje +niezmiennika automatycznie, zamiast czekać na kolejną rundę odkryć przez +awarie. + +## Źródło prawdy + +``pg_views`` (schemat ``public``), na ŻYWEJ bazie — NIE pliki migracji. +Późniejsze migracje nadpisują wcześniejsze definicje widoków ( +``CREATE OR REPLACE VIEW``), więc treść ``.sql`` w katalogu migracji nie +musi być tym, co faktycznie stoi w bazie. ``baseline-sql/baseline.sql`` też +zawodzi jako źródło — to snapshot sprzed części migracji (sprawdzone: +``baseline.sql`` ma ``NULL::bigint AS liczba_autorow`` zamiast realnego +``count()`` z migracji ``0494``). + +## Pułapka dopasowania (naiwny ``in``/``LIKE``) + +``bpp_wydawnictwo_ciagle_autor`` jest PREFIKSEM +``bpp_wydawnictwo_ciagle_autorzy`` (widok źródłowy already-filtered) — i, +symetrycznie w drugą stronę wobec fazy 02, ``bpp_wydawnictwo_ciagle`` +(tabela publikacji) jest PREFIKSEM ``bpp_wydawnictwo_ciagle_autor``. Naiwne +dopasowanie substringiem dałoby fałszywe trafienia w obie strony. Matcher +niżej używa granicy słowa (``\\b``) na obu końcach nazwy tabeli — w Pythonie +``re`` podkreślnik ``_`` jest znakiem ``\\w``, więc ``\\b`` NIE tworzy +granicy między ``ciagle`` a następującym po nim ``_autor``, ani między +``autor`` a następującym po nim ``zy`` — obie „doklejki" są więc już z +konstrukcji odrzucane. ``test_matcher_...`` niżej to udowadnia i pilnuje, +żeby refaktor tego nie zepsuł. + +## Wzorzec naprawy + +- widok źródłowy (filtr ``WHERE deleted_at IS NULL`` po WŁASNEJ kolumnie + tabeli) — ``src/bpp/migrations/0489_soft_delete_autorzy_views.py``; +- widok pochodny z agregatem po ``LEFT JOIN`` (``count()`` itp.) — UŻYJ + ``FILTER (WHERE deleted_at IS NULL)``, NIE warunku w ``WHERE``/``JOIN``: + ten drugi zamienia ``LEFT JOIN`` w efektywny ``INNER JOIN`` i wywala z + widoku cały wiersz nadrzędny, któremu soft-deletowano WSZYSTKICH + potomków — ``src/bpp/migrations/0494_liczba_autorow_bez_skasowanych.py``; +- widok pochodny z prostym ``WHERE``/``JOIN ... ON`` (bez agregatu) — + dopisz warunek do ``WHERE`` — + ``src/bpp/migrations/0495_nowe_sumy_bez_skasowanych.py``; +- helper do bezpiecznego dopisania warunku przez introspekcję (zamiast + przepisywania SQL-a ręcznie, co rozjeżdża się przy zmianie kolumn) — + ``src/bpp/migration_util.py`` (``viewdef``, ``widok_dopisz_warunek``, + ``widok_usun_warunek``). +""" + +import re + +import pytest +from django.db import connection + +# Tabele objęte soft-delete (django-soft-delete SoftDeleteModel) — jedyne +# źródło niezmiennika, świadomie utrzymywane jako lista, nie regex-owa +# heurystyka po nazwie. +# +# Faza 01 (2026-08-06): trzy tabele through autorstwa. +# +# ⚠️ FAZA 02 MUSI DOPISAĆ TU 5 TABEL PUBLIKACJI — jednolinijkowa zmiana: +# "bpp_wydawnictwo_ciagle", +# "bpp_wydawnictwo_zwarte", +# "bpp_patent", +# "bpp_praca_doktorska", +# "bpp_praca_habilitacyjna", +TABELE_SOFT_DELETE = [ + "bpp_wydawnictwo_ciagle_autor", + "bpp_wydawnictwo_zwarte_autor", + "bpp_patent_autor", +] + +# Widoki-wyjątki: nazwa widoku -> uzasadnienie + data weryfikacji. +# +# Każdy wpis tutaj to ŚWIADOMA decyzja, nie cichy skip. Jeśli kanarek +# znajdzie NOWY widok bez filtra, którego nie ma na tej liście — to jest +# FAIL, a nie sygnał, żeby dopisać go tu po cichu. Zgłoś w recenzji/raporcie +# i zapytaj, zanim dopiszesz wyjątek. +# +# Zweryfikowane 2026-08-06 (naprawa-finalna-report.md, faza 01, punkt +# "Czego NIE zrobiłem" #1): trzy widoki ``bpp_kronika_*_view`` +# (migracja ``0001_widoki_kronika.sql``) są MARTWE — zero konsumentów w +# kodzie produkcyjnym, szablonach i modelach Django. Jedyne trafienia +# "kronika" w repo poza katalogami migracji to `SOURCES.txt` +# (metadane pakietowania, nie kod). Naprawianie widoku, którego nikt nie +# czyta, byłoby czystym churnem — zostawione świadomie, do ewentualnego +# DROP-u przy innej okazji. +WYJATKI = { + "bpp_kronika_wydawnictwo_ciagle_view": ( + "martwy widok (migracja 0001_widoki_kronika.sql) — zero " + "konsumentów w kodzie/szablonach/modelach; jedyne trafienia " + "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane " + "2026-08-06, naprawa-finalna-report.md faza 01." + ), + "bpp_kronika_wydawnictwo_zwarte_view": ( + "martwy widok (migracja 0001_widoki_kronika.sql) — zero " + "konsumentów w kodzie/szablonach/modelach; jedyne trafienia " + "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane " + "2026-08-06, naprawa-finalna-report.md faza 01." + ), + "bpp_kronika_patent_view": ( + "martwy widok (migracja 0001_widoki_kronika.sql) — zero " + "konsumentów w kodzie/szablonach/modelach; jedyne trafienia " + "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane " + "2026-08-06, naprawa-finalna-report.md faza 01." + ), +} + + +def _regex_dla_tabeli(tabela): + """Granica słowa po obu stronach — patrz docstring modułu, sekcja + "Pułapka dopasowania".""" + return re.compile(r"\b" + re.escape(tabela) + r"\b") + + +def widok_uzywa_tabeli(definicja, tabela): + """Czy ``definicja`` widoku odwołuje się do ``tabela`` (jako całe słowo, + nie substring cudzej, dłuższej nazwy).""" + return bool(_regex_dla_tabeli(tabela).search(definicja)) + + +def _pobierz_widoki_publiczne(cur): + """``(nazwa widoku, definicja)`` dla wszystkich widoków w schemacie + ``public`` — ``pg_views.definition``, nie pliki migracji.""" + cur.execute("SELECT viewname, definition FROM pg_views WHERE schemaname = 'public'") + return cur.fetchall() + + +def znajdz_winowajcow(widoki): + """Rdzeń kanarka: ``widoki`` to lista ``(nazwa, definicja)`` (jak z + ``_pobierz_widoki_publiczne``). Zwraca listę ``(widok, tabela)`` dla + par, gdzie widok czyta tabelę objętą soft-delete BEZ ``deleted_at`` w + definicji i nie jest na liście ``WYJATKI``. + + Wydzielone z testu, żeby test mutacyjny mógł wywołać dokładnie tę samą + logikę na spreparowanym stanie bazy, zamiast duplikować pętlę. + """ + winowajcy = [] + for widok, definicja in widoki: + if widok in WYJATKI: + continue + for tabela in TABELE_SOFT_DELETE: + if not widok_uzywa_tabeli(definicja, tabela): + continue + if "deleted_at" not in definicja: + winowajcy.append((widok, tabela)) + return winowajcy + + +def _komunikat_naprawy(winowajcy): + linie = "\n".join(f" - {widok} (czyta {tabela})" for widok, tabela in winowajcy) + return ( + "Kanarek katalogowy: widoki poniżej czytają tabelę objętą " + "soft-delete, ale ich definicja w pg_views NIE zawiera " + "'deleted_at' — soft-deletowane wiersze wyciekają do wyniku " + "widoku.\n\n" + f"{linie}\n\n" + "NAPRAW jednym z wzorców (src/bpp/migration_util.py + przykłady):\n" + " - widok źródłowy / prosty WHERE-JOIN: widok_dopisz_warunek()\n" + " (patrz migracje 0489_soft_delete_autorzy_views.py,\n" + " 0495_nowe_sumy_bez_skasowanych.py),\n" + " - widok z agregatem (count() itp.) po LEFT JOIN: użyj\n" + " 'FILTER (WHERE ... deleted_at IS NULL)', NIE warunku w\n" + " WHERE/JOIN — inaczej LEFT JOIN staje się efektywnym INNER\n" + " JOIN i wywala z widoku cały wiersz nadrzędny, któremu\n" + " soft-deletowano WSZYSTKICH potomków (patrz\n" + " 0494_liczba_autorow_bez_skasowanych.py).\n\n" + "Jeśli widok jest MARTWY i naprawa byłaby czystym churnem — " + "dopisz go do WYJATKI w tym pliku z uzasadnieniem i datą " + "weryfikacji (nie grep-iem po jednym słowie kluczowym — sprawdź " + "pg_depend / realnych konsumentów), a NIE zgłaszaj tego po cichu." + ) + + +@pytest.mark.django_db +def test_kanarek_widoki_soft_delete_filtruja_po_deleted_at(): + """Rdzeń kanarka fazy 01/02+: widok czytający tabelę soft-delete musi + znać ``deleted_at``, chyba że jest jawnie wyjątkiem.""" + with connection.cursor() as cur: + widoki = _pobierz_widoki_publiczne(cur) + winowajcy = znajdz_winowajcow(widoki) + assert not winowajcy, _komunikat_naprawy(winowajcy) + + +@pytest.mark.django_db +def test_kanarek_widzi_przynajmniej_widoki_zrodlowe(): + """Sanity kanarka: gdyby zapytanie do ``pg_views`` zwróciło pustą listę + (zła nazwa kolumny, zły schemat, migracje niezastosowane), poprzedni + test zazieleniłby się FAŁSZYWIE (``not []`` == ``True``). Pilnujemy, że + kanarek realnie coś widzi.""" + with connection.cursor() as cur: + widoki = _pobierz_widoki_publiczne(cur) + nazwy = {w for w, _ in widoki} + oczekiwane = { + "bpp_wydawnictwo_ciagle_autorzy", + "bpp_wydawnictwo_zwarte_autorzy", + "bpp_patent_autorzy", + } + assert oczekiwane <= nazwy, ( + f"Oczekiwane widoki źródłowe nieznalezione w pg_views: " + f"{oczekiwane - nazwy}. Kanarek patrzy na pustą/złą bazę." + ) + + +@pytest.mark.django_db +def test_kanarek_lapie_swiezy_wyjatek_ktorego_nie_ma_na_liscie(): + """Kanarek NIE ma cichej furtki: widok bez filtra i BEZ wpisu w + ``WYJATKI`` musi wywalić test, nawet jeśli akurat jest to jeden z + już-znanych, zweryfikowanych martwych widoków ``kronika``.""" + with connection.cursor() as cur: + widoki = _pobierz_widoki_publiczne(cur) + # Celowo NIE wykluczamy widoków z WYJATKI tutaj (na odwrót niż + # znajdz_winowajcow) — symulujemy dokładnie sytuację "WYJATKI jest + # pusta/nie wie o tym widoku", żeby sprawdzić, czy SAMA logika + # dopasowania (bez listy wyjątków) łapie znane martwe widoki kronika. + winowajcy = [ + (widok, tabela) + for widok, definicja in widoki + for tabela in TABELE_SOFT_DELETE + if widok_uzywa_tabeli(definicja, tabela) and "deleted_at" not in definicja + ] + # Znane martwe widoki kronika MUSZĄ się tu pojawić — inaczej ta lista + # wyjątków przestała być testowalna (np. ktoś je usunął z bazy albo + # dopisał im filtr, więc WYJATKI trzeba by odchudzić). + zlapane = {w for w, _ in winowajcy} + oczekiwane_bez_wyjatku = set(WYJATKI) & { + "bpp_kronika_wydawnictwo_ciagle_view", + "bpp_kronika_wydawnictwo_zwarte_view", + "bpp_kronika_patent_view", + } + assert oczekiwane_bez_wyjatku <= zlapane, ( + "Kanarek przestał wykrywać znane widoki bez filtra po zdjęciu " + "wyjątku — matcher/logika się zepsuły." + ) + + +# --- Testy SAMEGO matchera (§ "Pułapka dopasowania") ------------------ + + +@pytest.mark.parametrize( + "tekst,oczekiwane", + [ + ("SELECT * FROM bpp_wydawnictwo_ciagle_autor", True), + ("SELECT * FROM bpp_wydawnictwo_ciagle_autor WHERE x", True), + ('"bpp_wydawnictwo_ciagle_autor".autor_id', True), + # Pułapka z brief-u: prefiks widoku źródłowego already-filtered. + ("SELECT * FROM bpp_wydawnictwo_ciagle_autorzy", False), + # Kolumna złożona z nazwy tabeli + "_id" — inny, dłuższy identyfikator. + ("SELECT bpp_wydawnictwo_ciagle_autor_id FROM x", False), + # Pusty/niepowiązany tekst. + ("SELECT 1", False), + ], +) +def test_matcher_nie_myli_autor_z_autorzy(tekst, oczekiwane): + """``bpp_wydawnictwo_ciagle_autor`` jest PREFIKSEM + ``bpp_wydawnictwo_ciagle_autorzy`` — dokładnie ta pułapka z briefu. + Naiwny ``in``/``LIKE`` dałby ``True`` dla drugiego i czwartego + przypadku. Ten test jest wyrocznią matchera — jeśli ktoś refaktorem + zdejmie granicę słowa, ten test ma czerwienieć pierwszy.""" + assert widok_uzywa_tabeli(tekst, "bpp_wydawnictwo_ciagle_autor") is oczekiwane + + +@pytest.mark.parametrize( + "tekst,oczekiwane", + [ + # Odwrotna pułapka (aktualna od razu, na wyrost pod fazę 02): + # "bpp_wydawnictwo_ciagle" jest PREFIKSEM + # "bpp_wydawnictwo_ciagle_autor" — matcher szukający krótszej nazwy + # (tabela publikacji) nie może się nabrać na dłuższą (tabela + # autorstwa). + ("SELECT * FROM bpp_wydawnictwo_ciagle", True), + ("SELECT * FROM bpp_wydawnictwo_ciagle_autor", False), + ("SELECT * FROM bpp_wydawnictwo_ciagle_view", False), + ], +) +def test_matcher_prefiks_tabeli_publikacji_kontra_tabela_autorstwa(tekst, oczekiwane): + """Ta sama klasa pułapki co ``test_matcher_nie_myli_autor_z_autorzy``, + tylko w drugą stronę — istotna od momentu, gdy faza 02 dopisze + ``bpp_wydawnictwo_ciagle`` (bez ``_autor``) do + ``TABELE_SOFT_DELETE``.""" + assert widok_uzywa_tabeli(tekst, "bpp_wydawnictwo_ciagle") is oczekiwane + + +@pytest.mark.django_db +def test_mutacja_widoku_bez_filtra_kanarek_pada(): + """Weryfikacja MUTACYJNA: podmieniamy naprawiony widok źródłowy na + wersję BEZ filtra (surowe ``CREATE OR REPLACE VIEW`` w transakcji + testowej — Postgres DDL jest transakcyjny, więc to i tak by się cofnęło + wraz z rollbackiem testu; mimo to przywracamy jawnie, żeby test + dowodził całego cyklu, nie polegał tylko na tym, że pytest posprząta). + + Bez tego testu nie wiadomo, czy kanarek COKOLWIEK pilnuje — testy + „substringowe" (czy widok zawiera ``deleted_at``) mogłyby przechodzić + fałszywie pozytywnie z powodów niezwiązanych z logiką (np. literówka w + nazwie kolumny w komentarzu SQL). + """ + widok = "bpp_wydawnictwo_ciagle_autorzy" + with connection.cursor() as cur: + cur.execute("SELECT pg_get_viewdef(%s::regclass)", [widok]) + oryginal = cur.fetchone()[0].rstrip().rstrip(";") + + # Format zgodny z pg_get_viewdef(regclass) BEZ jawnego pretty=true + # (czyli tym samym, co zwraca pg_views.definition, którego używa + # znajdz_winowajcow) — Postgres owija tu warunek w nawias. + sufiks = "\n WHERE (deleted_at IS NULL)" + assert oryginal.endswith(sufiks), ( + f"{widok}: definicja nie kończy się oczekiwanym filtrem — " + "test mutacyjny wymaga aktualizacji po zmianie SQL-a widoku. " + f"Definicja: ...{oryginal[-120:]!r}" + ) + zepsuta = oryginal[: -len(sufiks)] + assert "deleted_at" not in zepsuta, ( + "zdjęcie sufiksu nie usunęło deleted_at z definicji — " + "widok ma filtr gdzieś indziej niż w ostatnim WHERE" + ) + + try: + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {zepsuta}") + + widoki_po_mutacji = _pobierz_widoki_publiczne(cur) + winowajcy = znajdz_winowajcow(widoki_po_mutacji) + zlapani = {w for w, _ in winowajcy} + assert widok in zlapani, ( + "MUTACJA NIE ZOSTAŁA WYKRYTA — kanarek nie pilnuje niczego. " + f"Winowajcy po mutacji: {winowajcy}" + ) + finally: + # Przywracamy NIEZALEŻNIE od wyniku asercji powyżej — inaczej + # test padający zostawiłby popsuty widok do końca transakcji + # (nieszkodliwe przez rollback, ale przywracamy jawnie, patrz + # docstring). + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {oryginal}") + + widoki_po_przywroceniu = _pobierz_widoki_publiczne(cur) + winowajcy_po = znajdz_winowajcow(widoki_po_przywroceniu) + zlapani_po = {w for w, _ in winowajcy_po} + assert widok not in zlapani_po, ( + "widok nie wrócił do stanu naprawionego po przywróceniu " + "oryginalnej definicji" + ) From 904927c064082955abf9433aa378db3f5f97fe25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 22:51:06 +0200 Subject: [PATCH 35/75] =?UTF-8?q?docs(soft-delete):=20synchronizacja=20pla?= =?UTF-8?q?n=C3=B3w/spec=20ze=20stanem=20po=20fazie=2001?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naprawa blokerów finalnej recenzji fazy 01 zmieniła stan opisany w planach i specu — dokumenty dogonione: - plan 01: usunięty *_uniq_rekord_autor_kolejnosc zdjęty z przykładów kodu (redundantny wobec ExclusionConstraint, patrz nowy krok 3c.2b); numeracja migracji rozszerzona na pełny łańcuch 0488-0495 + rozbieznosci_dyscyplin/0022; indeks deleted_at pokazany jako częściowy (condition=Q(deleted_at__isnull=False)). - plan 02: dopisane ostrzeżenie na górze dokumentu — faza 02 musi (a) rozszerzyć stałą kanarka katalogowego o 5 tabel publikacji, (b) użyć FILTER zamiast warunku w JOIN/WHERE dla agregatów pochodnych (inaczej publikacja bez żywych autorów zniknęłaby z bpp_rekord_mat), (c) przejrzeć bpp_nowe_sumy_* i rozbieznosci_dyscyplin pod kątem deleted_at PUBLIKACJI (nie tylko autora, już zrobione w 0495/0022); numeracja startu migracji poprawiona na 0496. - spec §2.1: mechanizmów jest CZTERY, nie trzy — dopisany punkt 4 "widoki i agregaty POCHODNE", z odniesieniem do kanarka katalogowego jako stałego mechanizmu pilnującego tego niezmiennika. Co-Authored-By: Claude Opus 5 (1M context) --- ...-04-soft-delete-01-autor-trigger-widoki.md | 96 +++++++++++++++++-- .../2026-06-04-soft-delete-02-publikacje.md | 56 ++++++++++- ...soft-delete-publikacje-i-autorzy-design.md | 39 +++++++- 3 files changed, 176 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md index c510d7d25..d8a1c2ba4 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-01-autor-trigger-widoki.md @@ -48,7 +48,22 @@ Gałęzie `UNION` w `bpp_rekord` per typ publikacji NIE filtrują po `*_autor.de - **Trigger (AKTUALNY, po PR #363):** `0432_cache_trigger_plpgsql.py` generuje 3 funkcje `bpp_refresh_autor_()` (upsert **bez** DELETE, `_create_through_function`) + 3 `bpp_delete_autor_()`, oraz triggery `_cache_ins` / `_cache_del` / `_cache_upd`. `0433_cache_trigger_when_gate.py` nakłada bramkę `WHEN` na `_cache_upd`, z listą kolumn wyliczoną z `pg_depend`. ⚠️ **Funkcja `bpp_refresh_cache()` NIE ISTNIEJE** — `DROP` w `0432`. Nie kopiować `0399` ani `0001_cache_functions.sql`. - Widoki `bpp_*_autorzy`: ostatnia wersja definicji w `0421_cache_trigger_pk_filter.sql` (dodaje `object_id_raw`). Odtwarzając widok, wychodź z `pg_get_viewdef()`, nie z `0001_widoki_autorzy.sql`. - ⚠️ **`transactional_db` NIE jest wymagany** do oglądania efektów triggera — triggery bazodanowe działają wewnątrz transakcji testowej (dowód: kanarki `test_soft_delete_preconditions.py` chodzą pod zwykłym `django_db`). Używaj `django_db`; `transactional_db` tylko spowalnia. Fixture `denorms` (`src/fixtures/conftest_system.py:255`) daje `denorms.flush()`. Fixtury: `wydawnictwo_ciagle_z_dwoma_autorami`, `wydawnictwo_ciagle_z_autorem`, `autor_jan_kowalski`, `jednostka`, `standard_data`, `typy_odpowiedzialnosci`. -- ⚠️ **Numeracja migracji (stan 2026-08-06):** liść to `0487_api_v1_przelaczniki`. Nowe migracje tej fazy: `0488_autor_soft_delete_fields` → `0489_soft_delete_autorzy_views` (SQL + regeneracja bramki). **Przed startem zweryfikuj liść ponownie** (`ls src/bpp/migrations/*.py | tail -3`) — `dev` żyje, numery mogły się przesunąć. Wszystkie numery w tym planie są orientacyjne; kanoniczna jest kolejność, nie cyfra. +- ⚠️ **Numeracja migracji (stan po zakończeniu fazy, 2026-08-06):** liść przed + fazą to był `0487_api_v1_przelaczniki`. Faza 01 dołożyła w praktyce OSIEM + migracji `bpp` — `0488_autor_soft_delete_fields` → + `0489_soft_delete_autorzy_views` → `0490_autor_indeks_fk_rekord` → + `0491_autor_unique_rekord_autor_typ` → `0492_autor_excl_rekord_kolejnosc` → + `0493_autor_zdjecie_starych_unique` → `0494_liczba_autorow_bez_skasowanych` + → `0495_nowe_sumy_bez_skasowanych` — plus + `rozbieznosci_dyscyplin/0022_rozbieznosci_zrodel_bez_skasowanych.py` w + osobnej aplikacji. `0490`-`0493` to rozbite (jedna migracja na `ALTER + TABLE`, każda `atomic = False`) to, co ten plan opisuje niżej jako + jednolite `0490_autor_warunkowy_unique` (Task 3c) — patrz `progress.md` i + `naprawa-finalna-report.md` w katalogu SDD tej fazy po uzasadnienie + rozbicia. `0494`/`0495` naprawiają blokery znalezione dopiero w finalnej + recenzji (widoki pochodne `liczba_autorow` i `bpp_nowe_sumy_*`, poza + pierwotnym zakresem tego planu). Wszystkie numery w tym planie są + orientacyjne; kanoniczna jest kolejność, nie cyfra. **Kontrakt z reversion (PINNED):** soft-delete idzie WYŁĄCZNIE per-instancja przez `.delete()`/`.save()` (nigdy `queryset.update(deleted_at=...)`). `BppSoftDeleteQuerySet.update()` to egzekwuje fail-fast (gate). W tej fazie testujemy gate i kaskadę queryset-ową. @@ -339,13 +354,23 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r - [ ] Zweryfikuj treść wygenerowanej migracji — musi zawierać `AddField` `deleted_at`/`restored_at`/`transaction_id` dla `wydawnictwo_ciagle_autor`, `wydawnictwo_zwarte_autor`, `patent_autor`. Jeśli Django dorzuciło `AlterModelManagers` — usuń tę operację ręcznie (Edit), bo managery soft-delete nie idą do schematu. Dependency MUSI wskazywać na **aktualny liść** migracji `bpp` (na 2026-08-06: `("bpp", "0487_api_v1_przelaczniki")`) — zweryfikuj `ls src/bpp/migrations/*.py | tail -3` przed commitem. -- [ ] Dodaj indeks na `deleted_at` do każdej z 3 tabel. Dopisz do `operations` w `0488_autor_soft_delete_fields.py` (po `AddField`-ach), używając `AddIndex`: +- [ ] Dodaj indeks na `deleted_at` do każdej z 3 tabel. ⚠️ **Stan po + zakończeniu fazy: indeks jest CZĘŚCIOWY** + (`condition=Q(deleted_at__isnull=False)`), nie pełny — zdecydowane w + finalnej recenzji: `deleted_at IS NULL` pasuje do ~100% wierszy, więc + planner pod ten predykat i tak wybiera seq scan, a pełny btree byłby + wyłącznie kosztem (rozmiar + wpis przy każdym zapisie autorstwa). + Selektywne jest zapytanie ODWROTNE (`deleted_objects`, + `deleted_at IS NOT NULL`) i to jemu służy indeks. Dopisz do `operations` + w `0488_autor_soft_delete_fields.py` (po `AddField`-ach), używając + `AddIndex`: ```python migrations.AddIndex( model_name="wydawnictwo_ciagle_autor", index=models.Index( fields=["deleted_at"], name="wc_autor_deleted_at_idx", + condition=models.Q(deleted_at__isnull=False), ), ), migrations.AddIndex( @@ -353,6 +378,7 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r index=models.Index( fields=["deleted_at"], name="wz_autor_deleted_at_idx", + condition=models.Q(deleted_at__isnull=False), ), ), migrations.AddIndex( @@ -360,10 +386,17 @@ Wpięcie `SoftDeleteModel` w abstrakcyjną bazę → Django doda `deleted_at`/`r index=models.Index( fields=["deleted_at"], name="patent_autor_deleted_at_idx", + condition=models.Q(deleted_at__isnull=False), ), ), ``` - (Nazwy indeksów ≤ 30 znaków — wymóg PostgreSQL/Django. Jeśli `makemigrations` samo dodało `Meta.indexes` przez zmianę modelu — nie dublować; w tej fazie indeks definiujemy WYŁĄCZNIE w migracji, bo `Meta.indexes` w abstrakcyjnej bazie dałby kolizję nazw między 3 tabelami.) + (Nazwy indeksów ≤ 30 znaków — wymóg PostgreSQL/Django. W finalnym + stanie te same indeksy MUSZĄ być odzwierciedlone też w `Meta.indexes` + każdego z 3 konkretnych modeli — patrz `wydawnictwo_ciagle.py`, + `wydawnictwo_zwarte.py`, `patent.py` — inaczej `makemigrations --check` + wykrywa drift; `Meta.indexes` w ABSTRAKCYJNEJ bazie dałoby kolizję nazw + między 3 tabelami, dlatego indeks jest w `Meta` konkretnych klas, nie w + `BazaModeluOdpowiedzialnosciAutorow`.) - [ ] Uruchom `makemigrations --check` (oczekiwane: brak nowych zmian — model i migracja zgodne): ```bash @@ -894,7 +927,8 @@ więc komunikat błędu mówi o rekordzie, którego nie widać. — ma przejść po tym tasku. - [ ] **Krok 3c.2 — zamiana w `Meta` 3 konkretnych klas.** Usuń `unique_together`, - dodaj warunkowe constrainty (nazwy MUSZĄ być unikalne w całej bazie): + dodaj warunkowy `UniqueConstraint` na `(rekord, autor, typ_odpowiedzialnosci)` + (nazwy MUSZĄ być unikalne w całej bazie): ```python constraints = [ models.UniqueConstraint( @@ -902,15 +936,48 @@ więc komunikat błędu mówi o rekordzie, którego nie widać. condition=Q(deleted_at__isnull=True), name="wc_autor_uniq_rekord_autor_typ", ), - models.UniqueConstraint( - fields=["rekord", "autor", "kolejnosc"], - condition=Q(deleted_at__isnull=True), - name="wc_autor_uniq_rekord_autor_kolejnosc", - ), ] ``` (prefiksy per model: `wc_` / `wz_` / `pat_`) + ⚠️ **Stan po zakończeniu fazy: BEZ `UniqueConstraint(rekord, autor, + kolejnosc)`.** Naiwna wersja powyżej (i ta pierwotnie w tym planie) + dokładała jeszcze drugi warunkowy `UniqueConstraint` na + `(rekord, autor, kolejnosc)` (`..._uniq_rekord_autor_kolejnosc`) — recenzja + finalna go USUNĘŁA jako w 100% redundantny wobec `ExclusionConstraint` + `..._excl_rekord_kolejnosc` niżej (krok 3c.2b): ten pilnuje pary + `(rekord, kolejnosc)` NIE patrząc na autora, więc jest ściśle silniejszy — + skoro w obrębie rekordu żadna pozycja się nie powtarza, to tym bardziej + nie powtórzy się w obrębie `(rekord, autor)`. Jeśli implementujesz ten + plan od zera — pomiń `..._uniq_rekord_autor_kolejnosc` w ogóle, żeby nie + budować trzeciego, martwego indeksu na najgorętszej ścieżce zapisu. + +- [ ] **Krok 3c.2b — `ExclusionConstraint` na `(rekord, kolejnosc)`.** + Legacy raw-SQL `UNIQUE (rekord_id, kolejnosc) DEFERRABLE INITIALLY + DEFERRED` z migracji `0132` (2018) jest DRUGĄ, niezależną od ORM + przyczyną tej samej klasy regresji (niewidoczny dla `makemigrations`). + Zastąp go, per model, przez: + ```python + ExclusionConstraint( + name="wc_autor_excl_rekord_kolejnosc", + expressions=[ + ("rekord", RangeOperators.EQUAL), + ("kolejnosc", RangeOperators.EQUAL), + ], + condition=Q(deleted_at__isnull=True), + deferrable=Deferrable.DEFERRED, + ), + ``` + `deferrable` jest tu wymagane przez drag&drop reorder w adminie + (adminsortable2, `sortable_field_name = "kolejnosc"`) — zamiana + kolejności dwóch wierszy przejściowo dubluje `kolejnosc` w obrębie jednej + transakcji. `UniqueConstraint` nie umie łączyć `condition` z `deferrable` + (Django to blokuje), stąd `ExclusionConstraint` (wymaga `btree_gist`, + patrz migracja `0056`) — jedyny typ ograniczenia w Postgresie, który + łączy `WHERE` z `DEFERRABLE`. Skasuj stary legacy `UNIQUE` z `0132` osobną + migracją `RunSQL` (`DROP CONSTRAINT IF EXISTS ...`), w kroku PO dodaniu + nowych constraintów — zob. „Kolejność" niżej. + - [ ] **Krok 3c.3 — ⚠️ ZWERYFIKUJ ADMIN.** Komentarz przy drugiej krotce w kodzie („Tu musi być autor, inaczej admin nie pozwoli wyedytować") sugeruje, że ten constraint istnieje ze względu na **walidację formularzy**. @@ -1143,5 +1210,14 @@ grep -rEn --include='*.py' "$P" src/ | grep -vcE '/tests?/|test_' 3. **Każda przyszła zmiana definicji widoku źródłowego wymaga regeneracji bramki `WHEN`.** Bramka jest wypiekana z `pg_depend` w momencie migracji, więc nie zaktualizuje się sama. Pominięcie = cichy staleness. Testy `test_views_sql.py` i kanarki `test_soft_delete_preconditions.py` to wyłapią. 3. **Widoki `bpp_praca_doktorska_autorzy` / `bpp_praca_habilitacyjna_autorzy` NIE filtrowane** — autorstwo doktoratu/habilitacji nie jest `*_Autor` SoftDeleteModel (autor doktoratu to FK `Praca_Doktorska.autor`, nie through). Faza 02 (soft-delete publikacji doktorat/habilitacja) musi zadbać o ich zniknięcie z `bpp_rekord` przez własne `deleted_at` na tabeli publikacji — to NIE jest pokryte tą fazą. 4. **Gałęzie UNION `bpp_rekord` NIE dotknięte** w fazie 01 — soft-delete publikacji (kolumna `deleted_at` na `bpp_wydawnictwo_ciagle` itd.) to faza 02; dopiero ona doda filtr `deleted_at IS NULL` do `bpp_*_view`. Faza 01 dotyka wyłącznie ścieżki autorstwa. -6. **`unique_together` na `*_Autor` zachowane bez `deleted_at`** — w tej fazie autorstwa nie mają warunkowego unique. **Faza 02 MUSI to zmienić** (decyzja #13, spec §2.2b): soft-deletowany wiersz nadal zajmuje slot w `(rekord, autor, typ_odpowiedzialnosci)` / `(rekord, autor, kolejnosc)`, a `deduplikator_autorow` przenoszący autorstwa trafi wtedy w `IntegrityError` o niewidoczny rekord. Przejście na `UniqueConstraint(condition=Q(deleted_at__isnull=True))` — razem ze slugiem. +6. ~~`unique_together` na `*_Autor` zachowane bez `deleted_at` — Faza 02 MUSI + to zmienić~~ **NIEAKTUALNE, zrobione w tej fazie (Task 3c, patrz wyżej).** + Pierwotnie planowane na fazę 02, ale finalna recenzja fazy 01 wykryła + regresję na żywym przepływie (`import_sqlite`) już na HEAD Taska 2, więc + zamiana `unique_together` → warunkowy `UniqueConstraint` + + `ExclusionConstraint` (plus zdjęcie legacy `UNIQUE` z migracji `0132`) + wylądowała migracjami `0490`-`0493` W TEJ fazie. Faza 02 dziedziczy + gotowy wzorzec — nie musi go powtarzać dla `*_Autor` (tylko ewentualnie + dla analogicznych `unique_together` na samych tabelach publikacji, jeśli + takie istnieją). 7. **`Cache_Punktacja_*` NIE są dotknięte** żadnym mechanizmem tej fazy (nie mają FK do publikacji ani triggerów cache). Domknięcie tej luki to faza 06 (decyzja #15, spec §2.5b) — do tego czasu soft-deletowana praca nadal liczyłaby się do ewaluacji. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md index 8d01ae9a5..c6588b223 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md @@ -2,6 +2,54 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Każdy krok TDD: napisz padający test → uruchom (oczekiwany FAIL) → minimalna implementacja → uruchom (PASS) → commit. +> ⚠️ **OSTRZEŻENIE — CZYTAJ PRZED STARTEM (dopisane po naprawie blokerów +> fazy 01, 2026-08-06).** Faza 01 odkrywała konsumentów surowych tabel +> `*_autor` **pojedynczo, przez awarie** — najpierw bramka cache'u, potem +> bramka denorma, `unique_together`, legacy constraint z 2018, a na końcu +> finalna recenzja znalazła jeszcze trzy widoki pochodne czytające surowe +> tabele bez filtra na `deleted_at`. Faza 02 (soft-delete PUBLIKACJI: +> `bpp_wydawnictwo_ciagle`, `bpp_wydawnictwo_zwarte`, `bpp_patent`, +> `bpp_praca_doktorska`, `bpp_praca_habilitacyjna`) trafi na dokładnie ten +> sam problem, jeśli nie zaadresuje go z góry. Trzy obowiązkowe rzeczy: +> +> **(a) Rozszerz kanarka katalogowego o 5 tabel publikacji.** Test +> `src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py` (dodany razem +> z tym ostrzeżeniem) trzyma listę tabel objętych soft-delete jako stałą +> modułową `TABELE_SOFT_DELETE` — dopisanie 5 tabel publikacji ma być +> jednolinijkową zmianą (skonstruowaną tak celowo). Kanarek odpytuje żywy +> `pg_views` i pilnuje, że KAŻDY widok czytający którąkolwiek z tych tabel +> filtruje po `deleted_at`, chyba że jest na jawnej liście wyjątków z +> uzasadnieniem. Rozszerz listę na starcie fazy 02, ZANIM zaczniesz pisać +> DDL — kanarek od razu zacznie łapać widoki, które trzeba naprawić, zamiast +> odkrywać je po raz drugi przez awarie na produkcji. +> +> **(b) Sprawdź `count()`/agregaty w widokach pochodnych pod kątem +> `FILTER` vs warunek w `JOIN`/`WHERE`.** Wzorzec z migracji `0494` +> (`bpp/migrations/0494_liczba_autorow_bez_skasowanych.py`, faza 01): +> `bpp__view` liczy `count(_autor.autor_id) AS liczba_autorow` +> przez `LEFT JOIN` do tabeli `*_autor`. Dopisanie `deleted_at IS NULL` do +> `WHERE`/`JOIN ... ON` zamieniłoby `LEFT JOIN` w efektywny `INNER JOIN` dla +> publikacji, którym soft-deletowano WSZYSTKICH autorów — cała publikacja +> wypadłaby z widoku, więc z `bpp_rekord_mat` i z całego serwisu (dokładnie +> ten błąd, gdyby ktoś naiwnie dopisał warunek do `WHERE`). Właściwy wzorzec +> to `count(...) FILTER (WHERE ... deleted_at IS NULL)` — rusza wyłącznie +> licznik, nie krotność joina. Dla soft-delete PUBLIKACJI analogiczne +> ryzyko dotyczy każdego agregatu w `bpp_*_view`/`bpp_rekord` liczącego coś +> przez `LEFT JOIN` do tabeli publikacji. +> +> **(c) Przejrzyj `bpp_nowe_sumy_*` i `rozbieznosci_dyscyplin` pod kątem +> `deleted_at` PUBLIKACJI** (nie tylko autora — to już zrobione w fazie 01, +> migracje `0495_nowe_sumy_bez_skasowanych` i +> `rozbieznosci_dyscyplin/0022_rozbieznosci_zrodel_bez_skasowanych`). Te +> same widoki (i prawdopodobnie inne w `ranking_autorow`) czytają też +> kolumny/joiny do tabel publikacji — sprawdź `pg_depend`, nie tylko grep, +> czy po dodaniu `deleted_at` na `bpp_wydawnictwo_ciagle` itd. te widoki +> wymagają analogicznej poprawki. +> +> Raport z naprawy blokerów fazy 01 (metoda, mutation test kanarka, pełna +> lista sprawdzonych widoków): +> `.superpowers/sdd/2026-06-04-soft-delete-01-autor-trigger-widoki/kanarek-report.md`. + **Goal:** Uczynić 5 modeli publikacji (`Wydawnictwo_Ciagle`, `Wydawnictwo_Zwarte`, `Praca_Doktorska`, `Praca_Habilitacyjna`, `Patent`) `SoftDeleteModel`-ami z **wąską, kontrolowaną kaskadą** soft-delete na własne wiersze `*_Autor` (`Wydawnictwo_Ciagle_Autor`, `Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) pod wspólnym `transaction_id`, **bez** refleksyjnej kaskady pakietu (która ruszyłaby `*_Streszczenie` itd.). Zamienić `slug unique=True` na warunkowy `UniqueConstraint` (reuse slug po soft-delete). Przepleść filtr soft-delete z istniejącymi menedżerami `Wydawnictwo_*_Manager` (mixin opłat) przez wspólny QuerySet/MRO, bez nadpisywania metod fees. **Architecture:** `django-soft-delete` daje `SoftDeleteModel` (pola `deleted_at`/`restored_at`/`transaction_id`, menedżery `objects`/`global_objects`/`deleted_objects`, sygnały `post_soft_delete`/`post_restore`/`post_hard_delete`). Faza 01 utworzyła `src/bpp/models/soft_delete.py` z `BppSoftDeleteQuerySet` (gate na bulk `update(deleted_at=...)`), `BppSoftDeleteManager`, `BppGlobalManager` oraz uczyniła 3 modele `*_Autor` SoftDeleteModel-ami — wraz z DDL-em dla **ścieżki autorstwa** (widoki `bpp_*_autorzy` + gałąź kasująca w `bpp_refresh_autor_*` + bramka `WHEN`). ⚠️ **Dla 5 tabel publikacji ten sam DDL trzeba zrobić w TEJ fazie** — Task 2b. **Ta faza zależy od 01.** Tu nadpisujemy `delete()`/`restore()` na 5 modelach: per-instancja `save()` (NIGDY bulk `update`), jawna wąska kaskada na `autorzy_set` (related_name `*_Autor`→publikacja) przez `.delete(transaction_id=...)`/`.restore(transaction_id=...)` na każdym wierszu (kontrakt z reversion: zawsze per-instancja). @@ -50,7 +98,13 @@ migracją: `ls src/bpp/migrations/*.py | tail -3`. - `Praca_Doktorska.autor` FK CASCADE (`praca_doktorska.py:136`), `Praca_Habilitacyjna.autor` O2O PROTECT (`praca_habilitacyjna.py:42`). Te FK to **faza 04** — NIE ruszamy tu. - **`slug` jest polem `@denormalized(models.SlugField, max_length=400, unique=True, db_index=True, null=True, blank=True)`** (denorm z `django-denorm-iplweb`), w: `wydawnictwo_ciagle.py:246`, `wydawnictwo_zwarte.py:325` (w `Wydawnictwo_Zwarte`), `patent.py:180`, `praca_doktorska.py:105` (w `Praca_Doktorska_Baza` → dziedziczone przez `Praca_Doktorska` **i** `Praca_Habilitacyjna`). Denorm field jest fizyczną kolumną w DB → migracja zmiany `unique=True`→`UniqueConstraint` jest realną migracją schematu. - `Praca_Habilitacyjna` i `Praca_Doktorska` dziedziczą slug z `Praca_Doktorska_Baza` (abstract) — zmiana atrybutu pola w abstrakcie dotyka OBU modeli; migracje per model (każdy ma własną kolumnę `slug`). -- ⚠️ **Numeracja migracji (stan 2026-08-06):** faza 01 zajmuje `0488`/`0489`, więc ta faza startuje od `0490`. **Zweryfikuj liść przed startem** (`ls src/bpp/migrations/*.py | tail -3`) — `dev` żyje. Numery w tym planie są orientacyjne; kanoniczna jest kolejność. NIE modyfikuj istniejących migracji. +- ⚠️ **Numeracja migracji (stan po zakończeniu fazy 01, 2026-08-06):** faza + 01 zajęła ostatecznie `0488`-`0495` w `bpp` (nie tylko `0488`/`0489` — + patrz naprawa blokerów finalnej recenzji) plus + `rozbieznosci_dyscyplin/0022`, więc ta faza startuje od `0496` w `bpp`. + **Zweryfikuj liść przed startem** (`ls src/bpp/migrations/*.py | tail -3`) + — `dev` żyje. Numery w tym planie są orientacyjne; kanoniczna jest + kolejność. NIE modyfikuj istniejących migracji. - `Zgloszenie_Publikacji` (`src/zglos_publikacje/models.py:60`) — precedens: po prostu dziedziczy `SoftDeleteModel` bez własnego menedżera. ## Kontrakt z reversion (PINNED — NIE łamać) diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md index 1061bf2d6..d02634f6d 100644 --- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md +++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md @@ -145,7 +145,16 @@ INSERT INTO bpp_autorzy_mat (...) SELECT ... ; Soft-delete to uogólnienie tej samej sytuacji na wszystkie 8 tabel. -**Zmiana — trzy elementy, wszystkie OBOWIĄZKOWE** (żaden nie wystarcza sam): +**Zmiana — CZTERY elementy, wszystkie OBOWIĄZKOWE** (żaden nie wystarcza sam; +> 🔄 **liczba poprawiona 2026-08-06** po finalnej recenzji fazy 01: pierwotna +> wersja tej sekcji zatrzymywała się na trzech i faza 01 dokładnie w to +> trafiła — widok źródłowy + gałąź kasująca + bramka `WHEN` przeszły bez +> problemu, ale finalna recenzja osobno znalazła TRZY widoki POCHODNE +> (`bpp__view.liczba_autorow`, `bpp_nowe_sumy_*_view`, +> `rozbieznosci_dyscyplin_zrodel`), które czytały surową tabelę `*_autor` +> niezależnie od widoku źródłowego z punktu 3 i wymagały osobnych migracji +> `0494`/`0495`/`rozbieznosci_dyscyplin/0022`. Punkt 4 poniżej domyka ten +> wzorzec jako STAŁY element checklisty, nie jednorazową łatkę): 1. **Gałąź kasująca w 8 funkcjach refresh.** Prolog przed upsertem: ```sql @@ -169,6 +178,26 @@ Soft-delete to uogólnienie tej samej sytuacji na wszystkie 8 tabel. nie sprząta `_mat` (to robi punkt 1), tylko (a) karmi `pg_depend` dla punktu 2 i (b) gwarantuje, że pełne przebudowy i odczyty przez `bpp_rekord` nie wskrzeszą kosza. +4. **Widoki i agregaty POCHODNE** — każdy widok SQL poza `bpp_*_view`/ + `bpp_*_autorzy` (punkt 3), który JOIN-uje lub podzapytuje którąkolwiek z + 8 tabel objętych soft-delete, musi być znaleziony i naprawiony osobno — + filtr w widoku źródłowym NIE propaguje się automatycznie do widoków, + które czytają surową tabelę bazową zamiast widoku już przefiltrowanego. + Znalezione (i naprawione) w fazie 01: `bpp__view.liczba_autorow` + (`count()` po surowej `*_autor`), `bpp_nowe_sumy_*_view` (ranking + autorów), `rozbieznosci_dyscyplin_zrodel`. Dla `count()`/agregatów: użyj + `FILTER (WHERE deleted_at IS NULL)`, NIE warunku w `JOIN`/`WHERE` — ten + drugi zamienia `LEFT JOIN` w efektywny `INNER JOIN` i wywala z widoku + całą publikację, której WSZYSTKIM autorom soft-deletowano wiersz + (patrz `bpp/migrations/0494_liczba_autorow_bez_skasowanych.py`). + **Źródłem prawdy o tym, co jeszcze nie jest naprawione, jest żywy + `pg_views` — nie pliki migracji** (późniejsze migracje nadpisują + wcześniejsze definicje widoków, a `baseline.sql` bywa snapshotem sprzed + części migracji). Faza 01 dodała kanarka katalogowego + (`src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py`), który + odpytuje `pg_views` i pilnuje tego niezmiennika automatycznie dla całej + listy tabel objętych soft-delete — faza 02 rozszerza jego stałą + modułową o 5 tabel publikacji zamiast odkrywać te widoki ręcznie. **Jednolitość dzięki wąskiej kaskadzie na `*_Autor` (§2.2).** Ponieważ through-modele też stają się `SoftDeleteModel`, każda z 8 tabel pod triggerem @@ -707,9 +736,11 @@ odłożone, YAGNI; można dorobić jako zadanie `CELERYBEAT_SCHEDULE`, 8. **Retencja:** brak auto-czyszczenia; tylko ręczny hard-delete. 9. ~~**Cache — mechanizm nadrzędny:** filtr `deleted_at IS NULL` w widokach źródłowych; trigger-skip to opcjonalna optymalizacja.~~ - **UNIEWAŻNIONE 2026-08-06.** Zastąpione przez: **trzy elementy, wszystkie - obowiązkowe** — (a) filtr w widoku, (b) gałąź kasująca w funkcji refresh, - (c) regeneracja bramki `WHEN`. Żaden nie wystarcza sam (§2.1). + **UNIEWAŻNIONE 2026-08-06.** Zastąpione przez: **cztery elementy, wszystkie + obowiązkowe** — (a) filtr w widoku źródłowym, (b) gałąź kasująca w + funkcji refresh, (c) regeneracja bramki `WHEN`, (d) widoki i agregaty + POCHODNE (dopisany po finalnej recenzji fazy 01 — patrz §2.1 punkt 4). + Żaden nie wystarcza sam (§2.1). 10. **SentData przy wycofaniu:** `submitted_successfully=False` + znacznik wycofania, wiersza nie kasujemy. 11. **Self-FK `Wydawnictwo_Zwarte` (rozdziały):** **PROTECT** — soft-delete From 84ce5c7beac79fd7809d79ece91968e77ea7226e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 23:32:44 +0200 Subject: [PATCH 36/75] =?UTF-8?q?fix(soft-delete):=20kanarek=20katalogowy?= =?UTF-8?q?=20u=C5=BCywa=20pg=5Fdepend=20zamiast=20substringa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rdzeń kanarka sprawdzał tylko "deleted_at" not in definicja - substring niekwalifikowany tabelą. Dla obecnych 3 tabel *_autor to wystarczało, ale symulacja fazy 02 (dopisanie 5 tabel publikacji do TABELE_SOFT_DELETE) pokazała fałszywą zieleń: widok czytający np. bpp_wydawnictwo_ciagle w tym samym JOIN co już-naprawioną bpp_wydawnictwo_ciagle_autor dziedziczył tekstowo "deleted_at" z filtra po tabeli through, mimo braku filtra po WŁASNYM deleted_at tabeli publikacji. Zweryfikowane na żywym katalogu: 3 z 4 widoków czytających bpp_wydawnictwo_ciagle przechodziły starym testem na zielono bez faktycznego filtra. Naprawa: rdzeń pyta katalog pg_depend (ten sam mechanizm, którego migracje 0433/0489/0494 używają do bramek WHEN) o dwie relacje - "widok czyta tabelę" i "widok zależy od kolumny tabela.deleted_at" - zamiast o tekst definicji. Matcher tekstowy (widok_uzywa_tabeli) zostaje w module wyłącznie dla własnych testów regresyjnych i do odtworzenia starego algorytmu w teście dowodowym. Nowy trwały test dowodowy (test_symulacja_fazy_02_kanarek_lapie_widoki_ktore_stara_wersja_przepuszczala) symuluje TABELE_SOFT_DELETE + 5 tabel publikacji i asertuje dwustronnie: stary algorytm przepuszczał 3 konkretne widoki, nowy je łapie. Sabotaż regresyjny (surowi = set()) potwierdził, że ten test i test mutacyjny faktycznie czerwienieją przy zepsutym rdzeniu. TABELE_SOFT_DELETE nadal jest kilkulinijkową listą do dopisania w fazie 02 - rdzeń pg_depend jest generyczny względem listy. Ale po dopisaniu 5 tabel publikacji kanarek złapie też dwa dodatkowe widoki kronika (praca_doktorska/habilitacyjna), które nie są jeszcze na liście WYJATKI - opisane wprost w komentarzu w kodzie. Raport z tabelą przed/po (poza gitem, .superpowers/ jest w .gitignore): .superpowers/sdd/2026-06-04-soft-delete-01-autor-trigger-widoki/kanarek-fix-report.md uv run pytest src/bpp/tests/test_soft_delete/ -q -> 66 passed. ruff check / ruff format --check czyste. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../test_kanarek_katalogowy.py | 398 ++++++++++++++---- 1 file changed, 321 insertions(+), 77 deletions(-) diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py index a5072a539..7ea033aff 100644 --- a/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py +++ b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py @@ -1,6 +1,6 @@ """Kanarek katalogowy: KAŻDY widok czytający tabelę objętą soft-delete -MUSI filtrować po ``deleted_at`` — albo być na jawnej, uzasadnionej liście -wyjątków. +MUSI zależeć od jej kolumny ``deleted_at`` — albo być na jawnej, +uzasadnionej liście wyjątków. ## Dlaczego ten test istnieje @@ -18,29 +18,47 @@ niezmiennika automatycznie, zamiast czekać na kolejną rundę odkryć przez awarie. -## Źródło prawdy - -``pg_views`` (schemat ``public``), na ŻYWEJ bazie — NIE pliki migracji. -Późniejsze migracje nadpisują wcześniejsze definicje widoków ( -``CREATE OR REPLACE VIEW``), więc treść ``.sql`` w katalogu migracji nie -musi być tym, co faktycznie stoi w bazie. ``baseline-sql/baseline.sql`` też -zawodzi jako źródło — to snapshot sprzed części migracji (sprawdzone: -``baseline.sql`` ma ``NULL::bigint AS liczba_autorow`` zamiast realnego -``count()`` z migracji ``0494``). - -## Pułapka dopasowania (naiwny ``in``/``LIKE``) - -``bpp_wydawnictwo_ciagle_autor`` jest PREFIKSEM -``bpp_wydawnictwo_ciagle_autorzy`` (widok źródłowy already-filtered) — i, -symetrycznie w drugą stronę wobec fazy 02, ``bpp_wydawnictwo_ciagle`` -(tabela publikacji) jest PREFIKSEM ``bpp_wydawnictwo_ciagle_autor``. Naiwne -dopasowanie substringiem dałoby fałszywe trafienia w obie strony. Matcher -niżej używa granicy słowa (``\\b``) na obu końcach nazwy tabeli — w Pythonie -``re`` podkreślnik ``_`` jest znakiem ``\\w``, więc ``\\b`` NIE tworzy -granicy między ``ciagle`` a następującym po nim ``_autor``, ani między -``autor`` a następującym po nim ``zy`` — obie „doklejki" są więc już z -konstrukcji odrzucane. ``test_matcher_...`` niżej to udowadnia i pilnuje, -żeby refaktor tego nie zepsuł. +## Źródło prawdy: ``pg_depend``, nie tekst definicji + +Pierwsza wersja tego kanarka (patrz historia gita) sprawdzała obecność +podłańcucha ``"deleted_at" in definicja`` — GOŁY, NIEKWALIFIKOWANY +tabelą. Recenzja fazy 02 wykazała, że to daje FAŁSZYWĄ ZIELEŃ dokładnie +na tej klasie błędu, przed którą kanarek ma chronić: widok czytający +``bpp_wydawnictwo_ciagle`` (tabela publikacji, faza 02) ODZIEDZICZA +podłańcuch ``deleted_at`` z filtra po ``bpp_wydawnictwo_ciagle_autor`` +(tabela through, faza 01, w tym samym ``JOIN``), mimo że wcale nie filtruje +po WŁASNYM ``deleted_at`` tabeli publikacji. Zweryfikowane na żywym +katalogu 2026-08-06: **trzy widoki na cztery** czytające +``bpp_wydawnictwo_ciagle`` przechodziłyby starym testem na zielono, mimo +braku filtra po tej tabeli (patrz ``kanarek-fix-report.md`` — tabela +przed/po). + +Naprawa: zamiast szukać substringa w tekście, pytamy katalog Postgresa +``pg_depend``, czy widok FAKTYCZNIE zależy od KOLUMNY ``deleted_at`` +KONKRETNEJ tabeli — dokładnie to samo narzędzie, którego migracje +``0433``/``0489``/``0494`` używają do budowy bramek ``WHEN`` (kolumny +bazowe referowane przez widok, na poziomie ``pg_rewrite`` → ``pg_depend`` +→ ``pg_attribute``). Tej samej techniki (bez filtra po nazwie kolumny) +używamy też do wykrycia relacji „widok W czyta tabelę T" — ``pg_depend`` +zamiast tekstu, więc znika też klasa pułapek tekstowych, których stary +matcher musiał unikać ręcznie: ``deleted_at`` w komentarzu SQL, w aliasie +kolumny, w cudzej kolumnie o tej samej nazwie. + +``pg_views`` (schemat ``public``), na ŻYWEJ bazie, pozostaje źródłem +prawdy TYLKO dla testów sanity/mutacyjnego i dla symulacji „starego +algorytmu" (patrz niżej) — NIE dla rdzenia kanarka. Późniejsze migracje +nadpisują wcześniejsze definicje widoków (``CREATE OR REPLACE VIEW``), +więc treść ``.sql`` w katalogu migracji nie musi być tym, co faktycznie +stoi w bazie; ``baseline-sql/baseline.sql`` też zawodzi jako źródło — to +snapshot sprzed części migracji (sprawdzone: ``baseline.sql`` ma +``NULL::bigint AS liczba_autorow`` zamiast realnego ``count()`` z migracji +``0494``). + +## Niezmiennik + +Dla każdej pary (widok W, tabela T) takiej, że W czyta T (wg ``pg_depend``), +a T jest w ``TABELE_SOFT_DELETE`` — musi istnieć zależność W od kolumny +``T.deleted_at`` (wg ``pg_depend``), albo W jest na liście ``WYJATKI``. ## Wzorzec naprawy @@ -58,6 +76,21 @@ przepisywania SQL-a ręcznie, co rozjeżdża się przy zmianie kolumn) — ``src/bpp/migration_util.py`` (``viewdef``, ``widok_dopisz_warunek``, ``widok_usun_warunek``). + +## Pułapka dopasowania tekstowego (funkcje ``widok_uzywa_tabeli`` niżej) + +``widok_uzywa_tabeli``/``_regex_dla_tabeli`` (dopasowanie z granicą słowa) +NIE są już częścią rdzenia kanarka — ale zostają w module, bo: + +1. ich własne testy (``test_matcher_...`` niżej) pilnują regresji samej + klasy pułapki tekstowej (``bpp_wydawnictwo_ciagle_autor`` jest + PREFIKSEM ``bpp_wydawnictwo_ciagle_autorzy``, i odwrotnie + ``bpp_wydawnictwo_ciagle`` jest PREFIKSEM ``bpp_wydawnictwo_ciagle_autor``) + — to dokumentacja tego, DLACZEGO w ogóle przesiedliśmy się na + ``pg_depend``; +2. ``test_symulacja_fazy_02_...`` niżej używa ich do odtworzenia + DOKŁADNIE starego (przed naprawą) algorytmu, żeby udowodnić, że + naprawa faktycznie łapie to, co stary kod przepuszczał. """ import re @@ -71,12 +104,26 @@ # # Faza 01 (2026-08-06): trzy tabele through autorstwa. # -# ⚠️ FAZA 02 MUSI DOPISAĆ TU 5 TABEL PUBLIKACJI — jednolinijkowa zmiana: +# Faza 02 MUSI DOPISAĆ TU 5 TABEL PUBLIKACJI: # "bpp_wydawnictwo_ciagle", # "bpp_wydawnictwo_zwarte", # "bpp_patent", # "bpp_praca_doktorska", # "bpp_praca_habilitacyjna", +# To WCIĄŻ jest jednolinijkowa (a ściślej: pięciolinijkowa) zmiana W TYM +# MIEJSCU — rdzeń kanarka (pg_depend) jest generyczny względem listy i nie +# wymaga żadnych innych zmian kodu. ALE: po dopisaniu tych 5 tabel kanarek +# realnie zaczerwieni się na WIĘCEJ niż na 3 znane winowajców z raportu — +# złapie też ``bpp_kronika_praca_doktorska_view`` i +# ``bpp_kronika_praca_habilitacyjna_view`` (czytają swoje tabele bez +# żadnego filtra i, w odróżnieniu od pozostałych trzech ``bpp_kronika_*``, +# NIE są jeszcze zweryfikowane jako martwe ani wpisane do ``WYJATKI`` — +# tego akurat nikt jeszcze nie sprawdzał, bo dotąd nie było powodu). To +# NIE jest fałszywy alarm do wyciszenia odruchowo — to dokładnie ta klasa +# odkrycia, dla której ten kanarek istnieje. Faza 02 ma: +# a) albo zweryfikować, że są martwe (jak pozostałe trzy) i dopisać je +# do ``WYJATKI`` z uzasadnieniem i datą, +# b) albo je naprawić wzorcem z sekcji „Wzorzec naprawy" wyżej. TABELE_SOFT_DELETE = [ "bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_zwarte_autor", @@ -128,45 +175,122 @@ def _regex_dla_tabeli(tabela): def widok_uzywa_tabeli(definicja, tabela): """Czy ``definicja`` widoku odwołuje się do ``tabela`` (jako całe słowo, - nie substring cudzej, dłuższej nazwy).""" + nie substring cudzej, dłuższej nazwy). + + UWAGA: to jest matcher TEKSTOWY, zachowany wyłącznie dla własnych + testów regresyjnych i dla symulacji starego (przed-naprawą) algorytmu + w ``test_symulacja_fazy_02_...``. Rdzeń kanarka (``znajdz_winowajcow``) + go NIE używa — patrz docstring modułu, sekcja "Źródło prawdy".""" return bool(_regex_dla_tabeli(tabela).search(definicja)) def _pobierz_widoki_publiczne(cur): """``(nazwa widoku, definicja)`` dla wszystkich widoków w schemacie - ``public`` — ``pg_views.definition``, nie pliki migracji.""" + ``public`` — ``pg_views.definition``, nie pliki migracji. + + Używane TYLKO przez testy sanity/mutacyjny/symulacyjny — nie przez + rdzeń kanarka (patrz ``_widoki_czytajace_tabele`` / + ``_widoki_zalezne_od_deleted_at`` niżej, oparte o ``pg_depend``).""" cur.execute("SELECT viewname, definition FROM pg_views WHERE schemaname = 'public'") return cur.fetchall() -def znajdz_winowajcow(widoki): - """Rdzeń kanarka: ``widoki`` to lista ``(nazwa, definicja)`` (jak z - ``_pobierz_widoki_publiczne``). Zwraca listę ``(widok, tabela)`` dla - par, gdzie widok czyta tabelę objętą soft-delete BEZ ``deleted_at`` w - definicji i nie jest na liście ``WYJATKI``. - - Wydzielone z testu, żeby test mutacyjny mógł wywołać dokładnie tę samą - logikę na spreparowanym stanie bazy, zamiast duplikować pętlę. - """ - winowajcy = [] - for widok, definicja in widoki: - if widok in WYJATKI: - continue - for tabela in TABELE_SOFT_DELETE: - if not widok_uzywa_tabeli(definicja, tabela): - continue - if "deleted_at" not in definicja: - winowajcy.append((widok, tabela)) - return winowajcy +def _widoki_czytajace_tabele(cur, tabele): + """``{(widok, tabela)}`` dla par, gdzie ``widok`` FAKTYCZNIE zależy od + ``tabela`` wg katalogu (``pg_depend``) — czyli „W czyta T", ustalone z + tego samego źródła co zależność kolumnowa niżej, nie z tekstu. + + Zawiera zarówno zależności na poziomie kolumny (widok odwołuje się do + konkretnej kolumny tabeli), jak i na poziomie całej relacji (widok + używa tabeli w ``FROM``/``JOIN`` bez odwołania do żadnej jej kolumny — + rzadkie, np. ``COUNT(*)``) — stąd brak filtra po ``refobjsubid``.""" + cur.execute( + """ + SELECT DISTINCT v.relname, t.relname + FROM pg_depend d + JOIN pg_rewrite r ON r.oid = d.objid + JOIN pg_class v ON v.oid = r.ev_class + JOIN pg_namespace vn ON vn.oid = v.relnamespace + JOIN pg_class t ON t.oid = d.refobjid + JOIN pg_namespace tn ON tn.oid = t.relnamespace + WHERE d.classid = 'pg_rewrite'::regclass + AND d.refclassid = 'pg_class'::regclass + AND vn.nspname = 'public' + AND tn.nspname = 'public' + AND t.relname = ANY(%s) + """, + [list(tabele)], + ) + return set(cur.fetchall()) + + +def _widoki_zalezne_od_deleted_at(cur, tabele): + """``{(widok, tabela)}`` dla par, gdzie ``widok`` ma zależność + KOLUMNOWĄ (``pg_depend`` → ``pg_attribute``) na ``tabela.deleted_at``. + + Dokładnie to samo narzędzie katalogowe, którego migracje + ``0433``/``0489``/``0494`` używają do wyliczenia kolumn bramki ``WHEN`` + — patrz docstring modułu.""" + cur.execute( + """ + SELECT DISTINCT v.relname, t.relname + FROM pg_depend d + JOIN pg_rewrite r ON r.oid = d.objid + JOIN pg_class v ON v.oid = r.ev_class + JOIN pg_namespace vn ON vn.oid = v.relnamespace + JOIN pg_class t ON t.oid = d.refobjid + JOIN pg_namespace tn ON tn.oid = t.relnamespace + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid + WHERE d.classid = 'pg_rewrite'::regclass + AND d.refclassid = 'pg_class'::regclass + AND vn.nspname = 'public' + AND tn.nspname = 'public' + AND t.relname = ANY(%s) + AND a.attname = 'deleted_at' + AND NOT a.attisdropped + """, + [list(tabele)], + ) + return set(cur.fetchall()) + + +def _winowajcy_bez_wyjatkow(cur, tabele): + """Rdzeń bez filtrowania ``WYJATKI`` — pary (widok, tabela), gdzie widok + czyta tabelę (``pg_depend``), ale nie zależy od jej ``deleted_at`` + (``pg_depend``). Wydzielone, żeby test dowodzący testowalność listy + wyjątków (``test_kanarek_lapie_swiezy_wyjatek_...``) mógł zawołać + dokładnie tę samą logikę bez powielania zapytań.""" + czytajace = _widoki_czytajace_tabele(cur, tabele) + zalezne = _widoki_zalezne_od_deleted_at(cur, tabele) + return czytajace - zalezne + + +def znajdz_winowajcow(cur, tabele=None, wyjatki=None): + """Rdzeń kanarka: dla ``tabele`` (domyślnie ``TABELE_SOFT_DELETE``) + zwraca listę ``(widok, tabela)``, gdzie widok czyta tabelę objętą + soft-delete BEZ zależności od jej kolumny ``deleted_at`` (wg + ``pg_depend``) i nie jest na liście ``wyjatki`` (domyślnie + ``WYJATKI``). + + Przyjmuje kursor (nie listę widoków) — pyta katalog bezpośrednio, więc + działa poprawnie także wewnątrz transakcji testowej PO mutacji widoku + (``CREATE OR REPLACE VIEW`` w tej samej transakcji aktualizuje + ``pg_depend`` od razu, katalogi Postgresa są transakcyjne).""" + if tabele is None: + tabele = TABELE_SOFT_DELETE + if wyjatki is None: + wyjatki = WYJATKI + surowi = _winowajcy_bez_wyjatkow(cur, tabele) + return sorted((widok, tabela) for widok, tabela in surowi if widok not in wyjatki) def _komunikat_naprawy(winowajcy): linie = "\n".join(f" - {widok} (czyta {tabela})" for widok, tabela in winowajcy) return ( "Kanarek katalogowy: widoki poniżej czytają tabelę objętą " - "soft-delete, ale ich definicja w pg_views NIE zawiera " - "'deleted_at' — soft-deletowane wiersze wyciekają do wyniku " - "widoku.\n\n" + "soft-delete (wg pg_depend), ale NIE zależą od jej kolumny " + "'deleted_at' (wg pg_depend) — soft-deletowane wiersze wyciekają " + "do wyniku widoku.\n\n" f"{linie}\n\n" "NAPRAW jednym z wzorców (src/bpp/migration_util.py + przykłady):\n" " - widok źródłowy / prosty WHERE-JOIN: widok_dopisz_warunek()\n" @@ -180,18 +304,19 @@ def _komunikat_naprawy(winowajcy): " 0494_liczba_autorow_bez_skasowanych.py).\n\n" "Jeśli widok jest MARTWY i naprawa byłaby czystym churnem — " "dopisz go do WYJATKI w tym pliku z uzasadnieniem i datą " - "weryfikacji (nie grep-iem po jednym słowie kluczowym — sprawdź " - "pg_depend / realnych konsumentów), a NIE zgłaszaj tego po cichu." + "weryfikacji (sprawdź pg_depend / realnych konsumentów w " + "kodzie/szablonach — NIE grep-iem po jednym słowie kluczowym), " + "a NIE zgłaszaj tego po cichu." ) @pytest.mark.django_db def test_kanarek_widoki_soft_delete_filtruja_po_deleted_at(): """Rdzeń kanarka fazy 01/02+: widok czytający tabelę soft-delete musi - znać ``deleted_at``, chyba że jest jawnie wyjątkiem.""" + zależeć (wg pg_depend) od jej kolumny ``deleted_at``, chyba że jest + jawnie wyjątkiem.""" with connection.cursor() as cur: - widoki = _pobierz_widoki_publiczne(cur) - winowajcy = znajdz_winowajcow(widoki) + winowajcy = znajdz_winowajcow(cur) assert not winowajcy, _komunikat_naprawy(winowajcy) @@ -215,27 +340,42 @@ def test_kanarek_widzi_przynajmniej_widoki_zrodlowe(): ) +@pytest.mark.django_db +def test_kanarek_widzi_zaleznosci_pg_depend(): + """Sanity RDZENIA kanarka (pg_depend), analogiczne do testu wyżej dla + pg_views: gdyby zapytania ``pg_depend`` zwracały pustą listę (zła + nazwa kolumny/klasy w JOIN-ie, literówka w warunku), rdzeń zazieleniłby + się FAŁSZYWIE — winowajcy byliby zawsze puści, bo obie strony różnicy + ``czytajace - zalezne`` byłyby puste. Pilnujemy, że obie zależności + katalogowe realnie coś widzą, na znanej, ustabilizowanej parze.""" + with connection.cursor() as cur: + czytajace = _widoki_czytajace_tabele(cur, TABELE_SOFT_DELETE) + zalezne = _widoki_zalezne_od_deleted_at(cur, TABELE_SOFT_DELETE) + assert ( + "bpp_wydawnictwo_ciagle_autorzy", + "bpp_wydawnictwo_ciagle_autor", + ) in czytajace, "pg_depend nie widzi znanej relacji widok->tabela" + assert ( + "bpp_wydawnictwo_ciagle_autorzy", + "bpp_wydawnictwo_ciagle_autor", + ) in zalezne, "pg_depend nie widzi znanej zależności widok->deleted_at" + + @pytest.mark.django_db def test_kanarek_lapie_swiezy_wyjatek_ktorego_nie_ma_na_liscie(): """Kanarek NIE ma cichej furtki: widok bez filtra i BEZ wpisu w ``WYJATKI`` musi wywalić test, nawet jeśli akurat jest to jeden z już-znanych, zweryfikowanych martwych widoków ``kronika``.""" with connection.cursor() as cur: - widoki = _pobierz_widoki_publiczne(cur) - # Celowo NIE wykluczamy widoków z WYJATKI tutaj (na odwrót niż - # znajdz_winowajcow) — symulujemy dokładnie sytuację "WYJATKI jest - # pusta/nie wie o tym widoku", żeby sprawdzić, czy SAMA logika - # dopasowania (bez listy wyjątków) łapie znane martwe widoki kronika. - winowajcy = [ - (widok, tabela) - for widok, definicja in widoki - for tabela in TABELE_SOFT_DELETE - if widok_uzywa_tabeli(definicja, tabela) and "deleted_at" not in definicja - ] + # Celowo wołamy rdzeń BEZ filtrowania WYJATKI (_winowajcy_bez_wy- + # jatkow) — symulujemy dokładnie sytuację "WYJATKI jest pusta/nie + # wie o tym widoku", żeby sprawdzić, czy SAMA logika dopasowania + # (bez listy wyjątków) łapie znane martwe widoki kronika. + winowajcy = _winowajcy_bez_wyjatkow(cur, TABELE_SOFT_DELETE) + zlapane = {w for w, _ in winowajcy} # Znane martwe widoki kronika MUSZĄ się tu pojawić — inaczej ta lista # wyjątków przestała być testowalna (np. ktoś je usunął z bazy albo # dopisał im filtr, więc WYJATKI trzeba by odchudzić). - zlapane = {w for w, _ in winowajcy} oczekiwane_bez_wyjatku = set(WYJATKI) & { "bpp_kronika_wydawnictwo_ciagle_view", "bpp_kronika_wydawnictwo_zwarte_view", @@ -247,7 +387,12 @@ def test_kanarek_lapie_swiezy_wyjatek_ktorego_nie_ma_na_liscie(): ) -# --- Testy SAMEGO matchera (§ "Pułapka dopasowania") ------------------ +# --- Testy SAMEGO matchera tekstowego (§ "Pułapka dopasowania") ------- +# +# widok_uzywa_tabeli już nie jest częścią rdzenia kanarka (patrz docstring +# modułu), ale zostaje: 1) jako dokumentacja klasy błędu, przez którą rdzeń +# przesiadł się na pg_depend, 2) jako narzędzie symulacji starego +# algorytmu w teście dowodowym niżej. @pytest.mark.parametrize( @@ -276,11 +421,11 @@ def test_matcher_nie_myli_autor_z_autorzy(tekst, oczekiwane): @pytest.mark.parametrize( "tekst,oczekiwane", [ - # Odwrotna pułapka (aktualna od razu, na wyrost pod fazę 02): - # "bpp_wydawnictwo_ciagle" jest PREFIKSEM + # Odwrotna pułapka: "bpp_wydawnictwo_ciagle" jest PREFIKSEM # "bpp_wydawnictwo_ciagle_autor" — matcher szukający krótszej nazwy # (tabela publikacji) nie może się nabrać na dłuższą (tabela - # autorstwa). + # autorstwa). Dokładnie ten mechanizm psuł stary (tekstowy) + # algorytm w fazie 02 — patrz test symulacji niżej. ("SELECT * FROM bpp_wydawnictwo_ciagle", True), ("SELECT * FROM bpp_wydawnictwo_ciagle_autor", False), ("SELECT * FROM bpp_wydawnictwo_ciagle_view", False), @@ -294,6 +439,104 @@ def test_matcher_prefiks_tabeli_publikacji_kontra_tabela_autorstwa(tekst, oczeki assert widok_uzywa_tabeli(tekst, "bpp_wydawnictwo_ciagle") is oczekiwane +# --- Test dowodowy: symulacja fazy 02 ----------------------------------- + +# Pięć tabel publikacji, które faza 02 dopisze do TABELE_SOFT_DELETE. +# Zduplikowane tu ŚWIADOMIE (a nie zaimportowane) — ten test ma przetrwać +# NIEZMIENIONY moment, w którym faza 02 faktycznie dopisze je do stałej +# modułowej; do tego czasu musi budować symulację sam, niezależnie od +# TABELE_SOFT_DELETE. +_TABELE_PUBLIKACJI_FAZA_02 = [ + "bpp_wydawnictwo_ciagle", + "bpp_wydawnictwo_zwarte", + "bpp_patent", + "bpp_praca_doktorska", + "bpp_praca_habilitacyjna", +] + + +def _winowajcy_starym_algorytmem(widoki, tabele, wyjatki): + """Odtworzenie DOKŁADNIE starego (przed naprawą 2026-08-06) rdzenia + kanarka: substring ``"deleted_at" in definicja``, NIEKWALIFIKOWANY + tabelą. Używane wyłącznie w teście dowodowym niżej — świadomie żywy + kod błędu, nie refaktor do wspólnej funkcji, żeby test nie mógł + przypadkiem zacząć wołać naprawionej wersji.""" + winowajcy = [] + for widok, definicja in widoki: + if widok in wyjatki: + continue + for tabela in tabele: + if not widok_uzywa_tabeli(definicja, tabela): + continue + if "deleted_at" not in definicja: + winowajcy.append((widok, tabela)) + return winowajcy + + +@pytest.mark.django_db +def test_symulacja_fazy_02_kanarek_lapie_widoki_ktore_stara_wersja_przepuszczala(): + """DOWÓD, nie test dzisiejszego stanu bazy: symuluje przyszły stan po + fazie 02 (soft-delete PUBLIKACJI), gdzie ``TABELE_SOFT_DELETE`` rośnie + o 5 tabel publikacji, i udowadnia, że naprawiony (pg_depend) rdzeń + kanarka łapie DOKŁADNIE to, co stary (tekstowy) rdzeń przepuszczał. + + ``TABELE_SOFT_DELETE`` w tym pliku NIE jest tu modyfikowane — budujemy + lokalną, rozszerzoną listę i wołamy oba warianty algorytmu na tych + samych, żywych danych z ``pg_views``/``pg_depend``. Ten test ma + zostać na stałe (nie jest tymczasowym scratchem): to on dowodzi, że + faza 02 dostanie od kanarka prawdziwą, kompletną listę winowajców + jednym przebiegiem, zamiast — jak faza 01 — odkrywać ich pojedynczo + przez awarie produkcyjne. + + Trzy konkretne pary (widok, tabela) niżej pochodzą z recenzji + ``kanarek-fix-report.md`` (żywy katalog, 2026-08-06): widoki + odziedziczyły tekstowo ``deleted_at`` z filtra po + ``bpp_wydawnictwo_ciagle_autor`` (ten sam JOIN), mimo że nie filtrują + wcale po WŁASNYM ``deleted_at`` tabeli ``bpp_wydawnictwo_ciagle``. + """ + tabele_symulowane = TABELE_SOFT_DELETE + _TABELE_PUBLIKACJI_FAZA_02 + + oczekiwane_falszywa_zielen = { + ("bpp_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), + ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), + ( + "rozbieznosci_dyscyplin_rozbieznoscizrodelview", + "bpp_wydawnictwo_ciagle", + ), + } + + with connection.cursor() as cur: + widoki = _pobierz_widoki_publiczne(cur) + stary_winowajcy = set( + _winowajcy_starym_algorytmem(widoki, tabele_symulowane, WYJATKI) + ) + nowy_winowajcy = set(znajdz_winowajcow(cur, tabele=tabele_symulowane)) + + # Dowód #1 (ISTNIENIE DZIURY): stary algorytm PRZEPUSZCZAŁ te 3 pary — + # nie ma ich wśród jego winowajców, mimo że widoki naprawdę czytają + # bpp_wydawnictwo_ciagle bez filtra po jej deleted_at. To jest + # regression-proof samego buga opisanego w brief-ie/raporcie: jeśli + # ten assert kiedyś zacznie padać, znaczy że któryś z tych widoków się + # zmienił (np. ktoś usunął z jego tekstu literalne "deleted_at") i + # test wymaga aktualizacji dowodu, nie że naprawa przestała działać. + zlapane_przez_stary = stary_winowajcy & oczekiwane_falszywa_zielen + assert not zlapane_przez_stary, ( + "Stary (tekstowy) algorytm złapał widoki, które wg raportu miał " + f"PRZEPUSZCZAĆ (fałszywa zieleń): {zlapane_przez_stary}. Dane " + "widoki się zmieniły — dowód wymaga aktualizacji, sprawdź " + "kanarek-fix-report.md." + ) + + # Dowód #2 (NAPRAWA DZIAŁA): nowy (pg_depend) algorytm łapie wszystkie + # 3 pary, których stary nie widział. + niezlapane_przez_nowy = oczekiwane_falszywa_zielen - nowy_winowajcy + assert not niezlapane_przez_nowy, ( + "Nowy (pg_depend) algorytm NIE złapał widoków z symulacji fazy " + f"02, które miał złapać: {niezlapane_przez_nowy}. Naprawa nie " + "działa albo się cofnęła." + ) + + @pytest.mark.django_db def test_mutacja_widoku_bez_filtra_kanarek_pada(): """Weryfikacja MUTACYJNA: podmieniamy naprawiony widok źródłowy na @@ -305,7 +548,10 @@ def test_mutacja_widoku_bez_filtra_kanarek_pada(): Bez tego testu nie wiadomo, czy kanarek COKOLWIEK pilnuje — testy „substringowe" (czy widok zawiera ``deleted_at``) mogłyby przechodzić fałszywie pozytywnie z powodów niezwiązanych z logiką (np. literówka w - nazwie kolumny w komentarzu SQL). + nazwie kolumny w komentarzu SQL). Mutacja jest teraz jeszcze mocniejszym + dowodem niż przy starym algorytmie: usuwa REALNĄ zależność kolumnową w + ``pg_depend`` (nie tylko tekst), więc dowodzi, że rdzeń oparty o + ``pg_depend`` faktycznie widzi zmianę w katalogu, a nie w tekście SQL-a. """ widok = "bpp_wydawnictwo_ciagle_autorzy" with connection.cursor() as cur: @@ -314,7 +560,7 @@ def test_mutacja_widoku_bez_filtra_kanarek_pada(): # Format zgodny z pg_get_viewdef(regclass) BEZ jawnego pretty=true # (czyli tym samym, co zwraca pg_views.definition, którego używa - # znajdz_winowajcow) — Postgres owija tu warunek w nawias. + # _pobierz_widoki_publiczne) — Postgres owija tu warunek w nawias. sufiks = "\n WHERE (deleted_at IS NULL)" assert oryginal.endswith(sufiks), ( f"{widok}: definicja nie kończy się oczekiwanym filtrem — " @@ -330,8 +576,7 @@ def test_mutacja_widoku_bez_filtra_kanarek_pada(): try: cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {zepsuta}") - widoki_po_mutacji = _pobierz_widoki_publiczne(cur) - winowajcy = znajdz_winowajcow(widoki_po_mutacji) + winowajcy = znajdz_winowajcow(cur) zlapani = {w for w, _ in winowajcy} assert widok in zlapani, ( "MUTACJA NIE ZOSTAŁA WYKRYTA — kanarek nie pilnuje niczego. " @@ -344,8 +589,7 @@ def test_mutacja_widoku_bez_filtra_kanarek_pada(): # docstring). cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {oryginal}") - widoki_po_przywroceniu = _pobierz_widoki_publiczne(cur) - winowajcy_po = znajdz_winowajcow(widoki_po_przywroceniu) + winowajcy_po = znajdz_winowajcow(cur) zlapani_po = {w for w, _ in winowajcy_po} assert widok not in zlapani_po, ( "widok nie wrócił do stanu naprawionego po przywróceniu " From f3a0f1f22a3fc9ae476269e69b773725f85c050c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 09:09:03 +0200 Subject: [PATCH 37/75] docs(soft-delete): runbook wdrozeniowy fazy 01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zastrzezenia z finalnej recenzji gałezi byly dotad tylko w ledgerze SDD, a ten jest gitignored -- przy scaleniu by przepadly. Przenosze do trwalego dokumentu w docs/deweloper/. Pieć rzeczy, ktore osoba wdrazajaca MUSI wiedziec: 1. 0492 wymaga okna serwisowego: ADD CONSTRAINT EXCLUDE USING GIST bierze ACCESS EXCLUSIVE (blokuje takze ODCZYTY) i NIE MA wariantu wspolbieznego -- to ograniczenie PostgreSQL, nie przeoczenie. Zmierzyc na kopii produkcyjnej; to jedyna operacja w serii bez planu B. 2. Procedura po przerwanej migracji z serii atomic=False (0490-0493): awaria w srodku kroku nie cofa sie sama, obiekt zostaje w bazie bez wpisu w django_migrations. Tabelka z nazwami obiektow do recznego DROP. 3. Migracje musza domknac sie PRZED startem nowego kodu -- miedzy 0492 a 0493 obowiazuje jeszcze stare bezwarunkowe UNIQUE (rekord_id, kolejnosc). 4. Rollback: cofac kod I migracje RAZEM. django-denorm buduje triggery z modeli przy post_migrate, a te maja juz deleted_at w denorm_always_only, wiec cofniecie samego 0488 konczy sie ProgrammingError. 5. Osierocony wpis django_migrations na maszynach dev, ktore zaaplikowaly stara 0490 (numer uzyty ponownie po rozbiciu). Plus sekcja o zachowaniach swiadomie zaakceptowanych (eventual consistency liczba_autorow, brak przenumerowania kolejnosc, niezmierzony narzut GiST, 3 martwe widoki kronika) -- zeby nie wracaly jako zgloszenia bugow. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- docs/deweloper/runbook-soft-delete-faza-01.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/deweloper/runbook-soft-delete-faza-01.md diff --git a/docs/deweloper/runbook-soft-delete-faza-01.md b/docs/deweloper/runbook-soft-delete-faza-01.md new file mode 100644 index 000000000..16810e893 --- /dev/null +++ b/docs/deweloper/runbook-soft-delete-faza-01.md @@ -0,0 +1,155 @@ +# Runbook wdrożeniowy — soft-delete, faza 01 (autorstwa) + +> Dotyczy migracji `bpp/0488` … `bpp/0495` oraz +> `rozbieznosci_dyscyplin/0022`. Przeczytaj **przed** wdrożeniem na produkcję. +> +> Powstał 2026-08-06 z ustaleń finalnej recenzji gałęzi. Zastrzeżenia niżej +> nie blokują scalenia, ale **muszą** być znane osobie wdrażającej. + +## Co ta faza zmienia + +`delete()` na trzech modelach autorstwa (`Wydawnictwo_Ciagle_Autor`, +`Wydawnictwo_Zwarte_Autor`, `Patent_Autor`) przestaje fizycznie usuwać wiersz +— ustawia `deleted_at`. **Nie ma feature flagi: zmiana obowiązuje od pierwszej +sekundy po starcie nowego kodu.** + +Konsekwencja dla wdrożenia: migracje muszą się domknąć **zanim** nowy kod +zacznie serwować ruch (patrz §3). + +--- + +## 1. `0492` wymaga OKNA SERWISOWEGO + +`0492_autor_excl_rekord_kolejnosc` zakłada `ExclusionConstraint` (GiST): + +```sql +ALTER TABLE bpp_wydawnictwo_ciagle_autor + ADD CONSTRAINT wc_autor_excl_rekord_kolejnosc + EXCLUDE USING gist (rekord_id WITH =, kolejnosc WITH =) + WHERE (deleted_at IS NULL) DEFERRABLE INITIALLY DEFERRED; +``` + +- bierze **`ACCESS EXCLUSIVE`** — blokuje także **odczyty**, nie tylko zapisy; +- trwa tyle, ile budowa indeksu **GiST**, który na parze `(int, int)` jest + wielokrotnie wolniejszy od btree; +- **nie istnieje wariant współbieżny.** `EXCLUDE` nie da się dodać przez + `CREATE INDEX CONCURRENTLY` + `USING INDEX` — to ograniczenie PostgreSQL, + nie przeoczenie. + +Dotyczy trzech największych tabel systemu. + +> **DO ZROBIENIA PRZED WDROŻENIEM:** zmierzyć czas `0492` na kopii bazy +> produkcyjnej. To jedyna operacja w tej serii bez planu B. + +Ustaw `lock_timeout` + retry — jeden długi `SELECT` na tabeli `*_autor` +zatrzyma migrację i wszystko za nią (kolejka blokad). + +## 2. Procedura po PRZERWANEJ migracji z serii `0490`–`0493` + +Te cztery migracje mają `atomic = False` (świadomie — żeby blokady nie +kumulowały się do jednego `COMMIT`). Cena: **awaria w środku kroku nie cofa +się sama.** + +Objaw: obiekt istnieje w bazie, ale wpisu w `django_migrations` nie ma → +ponowny `migrate` pada na „already exists" (albo, dla `0493`, na +„Found wrong number (0) of constraints"). + +Naprawa — usuń ręcznie obiekty z częściowo wykonanego kroku, potem powtórz +`migrate`. Nazwy (prefiks `wc_` / `wz_` / `pat_` per tabela): + +| Krok | Obiekty do sprawdzenia | +|---|---| +| `0490` | indeksy `*_autor_rekord_id_*` | +| `0491` | `*_autor_uniq_rekord_autor_typ` | +| `0492` | `*_autor_excl_rekord_kolejnosc` | +| `0493` | (same `DROP`-y metadanowe — praktycznie nie pada) | + +```sql +-- przykład diagnostyki przed retry +SELECT conname FROM pg_constraint + WHERE conrelid = 'bpp_wydawnictwo_ciagle_autor'::regclass; +SELECT indexname FROM pg_indexes + WHERE tablename = 'bpp_wydawnictwo_ciagle_autor'; +``` + +## 3. Kolejność: migracje PRZED startem nowego kodu + +Między `0492` a `0493` obowiązuje **jeszcze stare, bezwarunkowe** +`UNIQUE (rekord_id, kolejnosc)`. Jeśli w tym oknie nowy kod zdąży zrobić +soft-delete i wstawić wiersz zastępczy, dostanie `IntegrityError`. + +Migracje idą z entrypointu przed startem aplikacji, więc domyślnie jest to +spełnione — ale przy ręcznym wdrożeniu lub rolling-restarcie trzeba tego +przypilnować. + +## 4. Rollback: cofać KOD I MIGRACJE razem + +**Cofnięcie samych migracji nie zadziała, jeśli nowy kod został na dysku.** + +`django-denorm` buduje triggery z modeli Pythona przy sygnale `post_migrate`. +Modele po tej fazie mają `deleted_at` w `denorm_always_only`, więc po +cofnięciu `0488` (usunięcie kolumny) najbliższy `post_migrate` próbuje +zbudować trigger odwołujący się do nieistniejącej kolumny: + +``` +ProgrammingError: kolumna old.deleted_at nie istnieje +``` + +To jedyny nieudany rewers w całej serii — pozostałe wracają do stanu +wyjściowego bit-w-bit (zweryfikowane zrzutem katalogu przed/po). + +Dodatkowo: rewers `0493` przywraca **bezwarunkowe** `UNIQUE (rekord_id, +kolejnosc)`, które padnie, jeśli ktokolwiek zdążył skorzystać z soft-delete +(soft-deletowane wiersze wyglądają wtedy jak duplikaty). Rollback po realnym +użyciu funkcji wymaga ręcznego wyczyszczenia kosza. + +## 5. Maszyny deweloperskie na tej gałęzi + +Kto zaaplikował **starą** wersję `0490_autor_warunkowy_unique` (plik został +skasowany, a numer użyty ponownie przy rozbiciu na `0490`–`0493`), ma +osierocony wpis w `django_migrations`. Dotyczy wyłącznie maszyn, na których +ta gałąź była wdrażana w trakcie prac — nie produkcji. + +Naprawa: `DELETE FROM django_migrations WHERE app='bpp' AND name LIKE +'0490_autor_warunkowy_unique';` i ponowny `migrate`, albo świeża baza. + +--- + +## Znane, świadomie zaakceptowane zachowania + +Nie są błędami do naprawy w tej fazie, ale warto je znać przy zgłoszeniach +od użytkowników. + +- **`Rekord.liczba_autorow` jest *eventually consistent*.** Odświeża się + dopiero, gdy flush `django-denorm` dotknie wiersza publikacji, podczas gdy + `bpp_autorzy_mat` aktualizuje się natychmiast (trigger). Mechanizm jest + identyczny jak przy twardym kasowaniu, ale rozjazd między dwoma cache'ami + bywa chwilowo widoczny. +- **Soft-delete nie przenumerowuje `kolejnosc`.** Skasowanie autora ze środka + listy zostawia dziurę. Kryterium multiseeka „Ostatnie nazwisko i imię" + (`kolejnosc ∈ [liczba_autorow-1, liczba_autorow)`) może w tę dziurę trafić + i zwrócić pusty wynik. Zachowanie identyczne jak przy twardym kasowaniu + (admin przenumerowuje przy zapisie formsetu, deduplikator nie). +- **Narzut GiST.** Btree `UNIQUE (rekord_id, kolejnosc)` zastąpiono indeksem + GiST. Każdy `INSERT`/`UPDATE` na najgorętszych tabelach (import publikacji, + PBN, deduplikator) płaci teraz za jego utrzymanie. `0490` dokłada btree po + `rekord_id`, co część kosztu łagodzi. **Nikt tego nie zmierzył** — testy + z definicji tego nie wykryją. +- **Trzy martwe widoki** `bpp_kronika_{wydawnictwo_ciagle,wydawnictwo_zwarte, + patent}_view` czytają surowe tabele bez filtra. Zweryfikowano brak + konsumentów (kod, szablony, modele) — są jawnym wyjątkiem w kanarku + katalogowym. Do skasowania przy okazji. + +## Weryfikacja po wdrożeniu + +```bash +# 1. soft-delete znika z obu cache'y +# (autorzy_set, bpp_autorzy_mat ORAZ liczba_autorow muszą się zgadzać) + +# 2. kanarek katalogowy — żaden widok nie czyta tabeli soft-delete +# bez zależności od jej deleted_at +uv run pytest src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py -q + +# 3. spójność cache +uv run pytest src/bpp/tests/test_soft_delete/ src/bpp/tests/test_cache/ -q +``` From e80d1656e8b0936b7fd09c17f335da6edc67e302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 10:02:40 +0200 Subject: [PATCH 38/75] fix(soft-delete): 11 zapytan ORM przestaje widziec skasowane autorstwa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faza 01 uczynila *_Autor soft-delete, ale manager `objects` odcina skasowane tylko przy zapytaniu o sam through-model. Lookup PRZEZ relacje (`filter(autorzy_set__…)`, `Count("autorzy_set")`, `Count("wydawnictwo_ciagle_autor")`) buduje JOIN po SUROWEJ tabeli i managera nie pyta — ta sama klasa bledu, co widoki SQL naprawione migracjami 0494/0495/rozbieznosci-0022, tylko o warstwe wyzej. Inwentaryzacja znalazla 15 miejsc: 11 naprawionych, 4 zostawione swiadomie (pelna tabela w orm-leak-report.md). Naprawione: - import_common/core/autor.py — licznik publikacji kandydata w deduplikatorze (husk autora wygladal na autora z dorobkiem), - bpp/admin/filters.py — BezJakichkolwiekDyscyplinFilter gubil rekordy, - pbn_wysylka_oswiadczen/queries.py — `tylko_odpiete` wskazywal do WYCOFANIA z PBN rekordy bez zywego autorstwa, - pbn_wyslij_oswiadczenia_instytucji — wysylka dla publikacji, ktorej jedyne autorstwo z dyscyplina skasowano, - ewaluacja_optymalizacja/views/verification.py — zawyzone liczniki, - rozbieznosci/views.py + bpp/views/browse.py — atrybucja rekordu do uczelni (multi-host): skasowane autorstwo pokazywalo rekord na cudzym hoscie, - bpp/management/commands/export_bibtex.py — `--author` przepisany z M2M `autorzy__` na `autorzy_set__autor__`, zeby predykat trafil na TEN SAM JOIN, - bpp/models/autor.py + views/autocomplete/search_services.py — ranking autora w wyszukiwarce (przy okazji o jeden JOIN mniej), - 3 szablony z linkami do changelistu admina + zapytanie DjangoQL deduplikatora + whitelist `lookup_allowed`. Zostawione swiadomie (z komentarzem w kodzie): `rebuild_instances_of` w dyscyplina_naukowa.py MA widziec skasowane — wybiera publikacje DO PRZELICZENIA, a ta ze skasowanym autorstwem potrzebuje go bardziej niz inne. DjangoQL i recznie sklejone parametry GET admina pozostaja surowe (jezyk zapytan uzytkownika = narzedzie audytowe). verify_cache.py to kod martwy (`raise NotImplementedError` linijke wyzej). Testy: src/bpp/tests/test_soft_delete/test_orm_wyciek_join.py — 14 testow. Trzy pierwsze przypinaja SEMANTYKE Django (dostep instancyjny i prefetch_related sa bezpieczne, JOIN nie jest) — dzieki temu wiadomo, ze ~70 wywolan `obj.autorzy_set.…` i wszystkie prefetche NIE wymagaja poprawek. Po `git stash` tego commita 8 testow pada, po jednym na naprawione zapytanie. --- src/bpp/admin/filters.py | 16 +- src/bpp/admin/wydawnictwo_ciagle.py | 7 + src/bpp/admin/wydawnictwo_zwarte.py | 5 + src/bpp/management/commands/export_bibtex.py | 15 +- src/bpp/models/autor.py | 23 +- src/bpp/models/dyscyplina_naukowa.py | 7 + .../test_soft_delete/test_orm_wyciek_join.py | 413 ++++++++++++++++++ src/bpp/views/autocomplete/search_services.py | 16 +- src/bpp/views/browse.py | 12 +- .../duplicate_authors.html | 9 +- .../admin/bpp/autor/change_form.html | 9 +- .../database_verification.html | 9 +- .../views/verification.py | 9 + src/import_common/core/autor.py | 15 +- .../pbn_wyslij_oswiadczenia_instytucji.py | 6 + src/pbn_wysylka_oswiadczen/queries.py | 13 +- src/rozbieznosci/views.py | 12 +- 17 files changed, 577 insertions(+), 19 deletions(-) create mode 100644 src/bpp/tests/test_soft_delete/test_orm_wyciek_join.py diff --git a/src/bpp/admin/filters.py b/src/bpp/admin/filters.py index 058845a5b..05917fba8 100644 --- a/src/bpp/admin/filters.py +++ b/src/bpp/admin/filters.py @@ -77,11 +77,23 @@ def queryset(self, request, queryset): field = self.parameter_name if v == "tak": + # Oba liczniki muszą mieć ``autorzy_set__deleted_at__isnull=True``: + # agregat po ``autorzy_set`` to JOIN po SUROWEJ tabeli ``*_autor`` + # (manager soft-delete nie jest pytany). Bez tego skasowane + # autorstwo Z dyscypliną podbijało wyłącznie ``total_authors``, + # więc rekord, w którym wszyscy ŻYWI autorzy są bez dyscypliny, + # wypadał z wyników filtra. return queryset.annotate( - total_authors=Count("autorzy_set"), + total_authors=Count( + "autorzy_set", + filter=Q(autorzy_set__deleted_at__isnull=True), + ), authors_without_discipline=Count( "autorzy_set", - filter=Q(autorzy_set__dyscyplina_naukowa__isnull=True), + filter=Q( + autorzy_set__dyscyplina_naukowa__isnull=True, + autorzy_set__deleted_at__isnull=True, + ), ), ).filter( total_authors__gt=0, # Ma autorów diff --git a/src/bpp/admin/wydawnictwo_ciagle.py b/src/bpp/admin/wydawnictwo_ciagle.py index 39523b0a8..1100224a0 100644 --- a/src/bpp/admin/wydawnictwo_ciagle.py +++ b/src/bpp/admin/wydawnictwo_ciagle.py @@ -450,6 +450,13 @@ def lookup_allowed(self, lookup, value, request=None): "autorzy_set__dyscyplina_naukowa__isnull", "autorzy_set__data_oswiadczenia__isnull", "autorzy_set__autor__id__exact", + # Lookupy po ``autorzy_set__`` to JOIN po surowej tabeli + # ``bpp_wydawnictwo_ciagle_autor`` — manager soft-delete nie jest + # pytany. Linki generowane przez BPP (change_form autora, + # ekran weryfikacji bazy) dokładają ten predykat, żeby changelist + # nie pokazywał publikacji po SKASOWANYM autorstwie. Whitelist + # musi go przepuścić, inaczej admin odrzuci własny link. + "autorzy_set__deleted_at__isnull", ): return True return super().lookup_allowed(lookup, value, request) diff --git a/src/bpp/admin/wydawnictwo_zwarte.py b/src/bpp/admin/wydawnictwo_zwarte.py index 506c23e44..6f40bef29 100644 --- a/src/bpp/admin/wydawnictwo_zwarte.py +++ b/src/bpp/admin/wydawnictwo_zwarte.py @@ -610,6 +610,11 @@ def lookup_allowed(self, lookup, value, request=None): "autorzy_set__dyscyplina_naukowa__isnull", "autorzy_set__data_oswiadczenia__isnull", "autorzy_set__autor__id__exact", + # Patrz komentarz w ``Wydawnictwo_CiagleAdmin.lookup_allowed``: + # linki BPP dokładają ``autorzy_set__deleted_at__isnull=True``, + # żeby JOIN po surowej tabeli ``*_autor`` nie pokazywał + # publikacji po skasowanym autorstwie. + "autorzy_set__deleted_at__isnull", ): return True return super().lookup_allowed(lookup, value, request) diff --git a/src/bpp/management/commands/export_bibtex.py b/src/bpp/management/commands/export_bibtex.py index 499d6df86..ed5914ba7 100644 --- a/src/bpp/management/commands/export_bibtex.py +++ b/src/bpp/management/commands/export_bibtex.py @@ -72,8 +72,17 @@ def handle(self, *args, **options): # Filter by author if options["author"]: - author_filter = Q(autorzy__nazwisko__icontains=options["author"]) | Q( - autorzy__imiona__icontains=options["author"] + # Idziemy przez ``autorzy_set__autor__…`` (through-model), a NIE + # przez M2M ``autorzy__…``: tylko wtedy predykat + # ``deleted_at__isnull=True`` trafia na TEN SAM JOIN, co warunek + # po nazwisku. M2M ``autorzy`` też złącza się po surowej tabeli + # ``*_autor`` (manager soft-delete nie jest pytany), ale dopięcie + # do niego ``autorzy_set__deleted_at`` dałoby DRUGI, nieskorelowany + # JOIN — czyli warunek „ma jakiekolwiek żywe autorstwo", a nie + # „autorstwo TEGO autora żyje". + author_filter = Q(autorzy_set__deleted_at__isnull=True) & ( + Q(autorzy_set__autor__nazwisko__icontains=options["author"]) + | Q(autorzy_set__autor__imiona__icontains=options["author"]) ) ciagle_q &= author_filter zwarte_q &= author_filter @@ -113,7 +122,7 @@ def handle(self, *args, **options): with open(options["output"], "w", encoding="utf-8") as f: f.write(bibtex_content) self.stdout.write( - self.style.SUCCESS(f'Successfully exported to {options["output"]}') + self.style.SUCCESS(f"Successfully exported to {options['output']}") ) except OSError as e: raise CommandError(f"Error writing to file: {e}") diff --git a/src/bpp/models/autor.py b/src/bpp/models/autor.py index 9180d7ed7..2301cb46d 100644 --- a/src/bpp/models/autor.py +++ b/src/bpp/models/autor.py @@ -145,7 +145,28 @@ def create_from_string(self, text, uczelnia=None): ) def fulltext_annotate(self, search_query, normalization): - return {self.fts_field + "__rank": Count("wydawnictwo_ciagle")} + """Ranking autora = liczba jego autorstw w wydawnictwach ciągłych. + + Liczymy przez ``wydawnictwo_ciagle_autor`` (through-model), a NIE + przez M2M ``wydawnictwo_ciagle``, z dwóch powodów: + + 1. agregat po relacji to JOIN po SUROWEJ tabeli + ``bpp_wydawnictwo_ciagle_autor`` — manager soft-delete nie jest + pytany, więc bez ``filter=Q(…__deleted_at__isnull=True)`` husk + autora (wszystkie autorstwa skasowane) rankowałby się tak samo + wysoko, jak przed skasowaniem. Predykat MUSI iść tą samą ścieżką + relacji co agregat, inaczej Django zrobi drugi, nieskorelowany + JOIN; + 2. przy okazji odpada drugi JOIN (do ``bpp_wydawnictwo_ciagle``) — + FK z through-modelu jest NOT NULL, więc kardynalność bez niego + jest ta sama. + """ + return { + self.fts_field + "__rank": Count( + "wydawnictwo_ciagle_autor", + filter=Q(wydawnictwo_ciagle_autor__deleted_at__isnull=True), + ) + } class Autor(LinkDoPBNMixin, ModelZAdnotacjami, ModelZPBN_ID): diff --git a/src/bpp/models/dyscyplina_naukowa.py b/src/bpp/models/dyscyplina_naukowa.py index f4ea66027..51ec09c1d 100644 --- a/src/bpp/models/dyscyplina_naukowa.py +++ b/src/bpp/models/dyscyplina_naukowa.py @@ -253,6 +253,13 @@ def przebuduj_prace_autora_po_udanej_transakcji(autor_id, rok): from bpp.models import Patent, Wydawnictwo_Ciagle, Wydawnictwo_Zwarte + # ŚWIADOMIE BEZ ``autorzy_set__deleted_at__isnull=True``: to jedyne miejsce + # w kodzie, gdzie JOIN po SUROWEJ tabeli ``*_autor`` (z widocznymi + # soft-deletami) jest POŻĄDANY. ``rebuild_instances_of`` tylko wybiera + # publikacje DO PRZELICZENIA — a publikacja, której autorstwo tego autora + # zostało skasowane, potrzebuje przeliczenia tak samo (a nawet bardziej) + # jak ta z autorstwem żywym. Dodanie predykatu wycięłoby ją ze zbioru + # i zostawiło nieaktualne punkty/sloty do czasu nocnej rekalkulacji. def _(autor_id=autor_id, rok=rok): for klass in [Wydawnictwo_Ciagle, Wydawnictwo_Zwarte, Patent]: rebuild_instances_of(klass, rok=rok, autorzy_set__autor_id=autor_id) diff --git a/src/bpp/tests/test_soft_delete/test_orm_wyciek_join.py b/src/bpp/tests/test_soft_delete/test_orm_wyciek_join.py new file mode 100644 index 000000000..ca95812b5 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_orm_wyciek_join.py @@ -0,0 +1,413 @@ +"""Wyciek soft-delete o warstwę wyżej niż widoki SQL: JOIN w ORM-ie. + +Faza 01 uczyniła ``Wydawnictwo_Ciagle_Autor`` / ``Wydawnictwo_Zwarte_Autor`` / +``Patent_Autor`` soft-delete. Domyślny manager ``objects`` odcina skasowane — +ale **wyłącznie gdy pytamy o sam through-model**. Kiedy filtrujemy publikację +*przez* relację (``Wydawnictwo_Ciagle.objects.filter(autorzy_set__…)``, +``annotate(Count("autorzy_set"))``, ``Autor.objects.annotate(Count( +"wydawnictwo_ciagle_autor"))``), Django buduje JOIN po SUROWEJ tabeli i +managera **w ogóle nie pyta** — dokładnie ta sama klasa błędu, co widoki SQL +naprawione migracjami 0494/0495/rozbieznosci-0022, tylko w Pythonie. + +Moduł ma dwie części: + +1. **Kanarki semantyki Django** — przypinają, CO w ogóle przecieka, a co nie. + Dostęp instancyjny (``wc.autorzy_set``) i ``prefetch_related`` idą przez + ``_default_manager`` i są bezpieczne; JOIN nie jest. Gdyby Django kiedyś + zmieniło którekolwiek z tych zachowań, te trzy testy zapalą się pierwsze + i powiedzą, czy reszta naprawek stała się zbędna, czy niewystarczająca. +2. **Regresje konkretnych miejsc produkcyjnych** — po jednym teście na + naprawione zapytanie. +""" + +import pytest +from django.db.models import Count, Q + +from bpp.models import ( + Autor, + Wydawnictwo_Ciagle, + Wydawnictwo_Ciagle_Autor, +) + +# -------------------------------------------------------------------------- +# 1. Kanarki semantyki Django +# -------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_dostep_instancyjny_autorzy_set_odcina_skasowane( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """``wc.autorzy_set`` (RelatedManager) dziedziczy po ``_default_manager``. + + Django buduje klasę RelatedManagera z ``related_model._default_manager. + __class__`` — czyli z ``BppSoftDeleteManager``. Dostęp instancyjny jest + więc BEZPIECZNY: ~70 wywołań ``obj.autorzy_set.…`` w kodzie produkcyjnym + nie wymaga żadnych poprawek. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + assert wc.autorzy_set.count() == 2 + + wc.autorzy_set.first().delete() + + assert wc.autorzy_set.count() == 1 + assert wc.autorzy_set.all().count() == 1 + + +@pytest.mark.django_db +def test_prefetch_related_autorzy_set_odcina_skasowane( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """``prefetch_related("autorzy_set…")`` też idzie przez ``_default_manager``. + + ``RelatedManager.get_prefetch_querysets()`` woła ``super().get_queryset()`` + — czyli manager z filtrem soft-delete. Dlatego ``prefetch_related`` z + ``autorzy_set__`` w ścieżce (m.in. ``ewaluacja_optymalizacja/tasks/ + discipline_swap/analysis.py``, viewsety ``api_v1``, ``cerif_export``) + NIE jest wyciekiem, mimo że wygląda jak lookup przez relację. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + wc.autorzy_set.first().delete() + + pobrany = Wydawnictwo_Ciagle.objects.prefetch_related("autorzy_set__autor").get( + pk=wc.pk + ) + assert len(pobrany.autorzy_set.all()) == 1 + + +@pytest.mark.django_db +def test_join_przez_relacje_WIDZI_skasowane_bez_jawnego_filtra( + wydawnictwo_ciagle_z_autorem, +): + """Kanarek NEGATYWNY: goły JOIN po ``autorzy_set__`` przecieka. + + To opis stanu faktycznego Django, nie bug do naprawienia — test pilnuje, + że PRZYCZYNA wszystkich poprawek niżej nadal istnieje. Gdyby kiedyś + przestała (np. Django nauczyłoby się pytać manager przy JOIN-ie), ten + test padnie i będzie sygnałem, że jawne ``deleted_at__isnull=True`` + w kodzie produkcyjnym można wycofać. + """ + wc = wydawnictwo_ciagle_z_autorem + autor = wc.autorzy_set.first().autor + wc.autorzy_set.first().delete() + + assert not Wydawnictwo_Ciagle_Autor.objects.filter(rekord=wc).exists() + # ...a mimo to JOIN nadal łapie publikację: + assert Wydawnictwo_Ciagle.objects.filter(autorzy_set__autor=autor).exists() + # ...i predykat na TYM SAMYM joinie ją odcina: + assert not Wydawnictwo_Ciagle.objects.filter( + autorzy_set__autor=autor, + autorzy_set__deleted_at__isnull=True, + ).exists() + + +# -------------------------------------------------------------------------- +# 2. Regresje konkretnych miejsc produkcyjnych +# -------------------------------------------------------------------------- + + +def _ustaw_dyscypline(autorstwo, dyscyplina, rodzaj_autora, **reszta): + """Ustawia dyscyplinę na autorstwie razem z wymaganym ``Autor_Dyscyplina``. + + ``BazaModeluOdpowiedzialnosciAutorow`` waliduje przy KAŻDYM ``save()``, że + autor ma na dany rok przypisanie do tej dyscypliny — a soft-delete też + idzie przez ``save()``, więc bez tego przypisania wywaliłby się już sam + ``delete()``. Stąd tworzymy ``Autor_Dyscyplina``, zamiast obchodzić + walidację ``.update()``-em. + """ + from bpp.models import Autor_Dyscyplina + + Autor_Dyscyplina.objects.get_or_create( + autor=autorstwo.autor, + rok=autorstwo.rekord.rok, + defaults=dict( + dyscyplina_naukowa=dyscyplina, + rodzaj_autora=rodzaj_autora, + ), + ) + autorstwo.dyscyplina_naukowa = dyscyplina + for k, v in reszta.items(): + setattr(autorstwo, k, v) + autorstwo.save() + + +@pytest.mark.django_db +def test_import_common_liczba_publikacji_kandydata_pomija_skasowane( + wydawnictwo_ciagle_z_autorem, +): + """``import_common.core.autor._publikacji_counts_bulk`` (deduplikator). + + Licznik po ``Count("wydawnictwo_ciagle_autor")`` liczył także autorstwa + skasowane, więc husk autora pokazywał się w deduplikatorze jako autor + z dorobkiem. + """ + from import_common.core.autor import _publikacji_counts_bulk + + autor = wydawnictwo_ciagle_z_autorem.autorzy_set.first().autor + assert _publikacji_counts_bulk([autor.pk]) == {autor.pk: 1} + + wydawnictwo_ciagle_z_autorem.autorzy_set.first().delete() + + assert _publikacji_counts_bulk([autor.pk]).get(autor.pk, 0) == 0 + + +@pytest.mark.django_db +def test_admin_filter_bez_dyscyplin_nie_liczy_skasowanych( + wydawnictwo_ciagle_z_dwoma_autorami, dyscyplina1, rodzaj_autora_n +): + """``bpp.admin.filters.BezJakichkolwiekDyscyplinFilter``. + + Filtr porównuje ``Count("autorzy_set")`` z liczbą autorstw bez + dyscypliny. Skasowane autorstwo Z dyscypliną podbijało tylko licznik + ogólny → rekord, w którym WSZYSCY żywi autorzy są bez dyscypliny, + wypadał z wyników. + """ + from bpp.admin.filters import BezJakichkolwiekDyscyplinFilter + + wc = wydawnictwo_ciagle_z_dwoma_autorami + pierwsze = wc.autorzy_set.first() + _ustaw_dyscypline(pierwsze, dyscyplina1, rodzaj_autora_n) + pierwsze.delete() + # Zostało jedno ŻYWE autorstwo, BEZ dyscypliny → rekord ma być złapany. + + f = BezJakichkolwiekDyscyplinFilter( + request=None, + params={"_bjkd_": ["tak"]}, + model=Wydawnictwo_Ciagle, + model_admin=None, + ) + qs = f.queryset(None, Wydawnictwo_Ciagle.objects.all()) + assert list(qs.values_list("pk", flat=True)) == [wc.pk] + + +@pytest.mark.django_db +def test_pbn_wysylka_liczniki_oswiadczen_pomijaja_skasowane( + wydawnictwo_ciagle_z_dwoma_autorami, dyscyplina1, rodzaj_autora_n +): + """``pbn_wysylka_oswiadczen.queries.get_publications_queryset``. + + Skasowane oświadczenie liczyło się do ``liczba_oswiadczen``, więc filtr + ``tylko_odpiete`` (oswiadczen > 0 AND przypietych = 0) wskazywał do + wycofania z PBN rekordy, na których nie ma już żywego autorstwa. + """ + from pbn_api.models import Publication + from pbn_wysylka_oswiadczen.queries import get_publications_queryset + + wc = wydawnictwo_ciagle_z_dwoma_autorami + for wca in wc.autorzy_set.all(): + _ustaw_dyscypline(wca, dyscyplina1, rodzaj_autora_n, przypieta=False) + + wc.pbn_uid = Publication.objects.create(mongoId="x" * 24, status="ACTIVE") + wc.save() + + wc.autorzy_set.first().delete() + + ciagle_qs, _zwarte = get_publications_queryset( + rok_od=wc.rok, rok_do=wc.rok, with_annotations=True + ) + obiekt = ciagle_qs.get(pk=wc.pk) + assert obiekt.liczba_oswiadczen == 1 + assert obiekt.liczba_przypietych == 0 + + +@pytest.mark.django_db +def test_ewaluacja_verification_licznik_pomija_skasowane( + wydawnictwo_ciagle_z_dwoma_autorami, dyscyplina1, rodzaj_autora_n, admin_client +): + """``ewaluacja_optymalizacja.views.verification`` — licznik „brak + oświadczenia" nie może liczyć publikacji po skasowanym autorstwie.""" + from django.urls import reverse + + wc = wydawnictwo_ciagle_z_dwoma_autorami + Wydawnictwo_Ciagle.objects.filter(pk=wc.pk).update(rok=2023) + for wca in wc.autorzy_set.all(): + _ustaw_dyscypline(wca, dyscyplina1, rodzaj_autora_n, data_oswiadczenia=None) + + url = reverse("ewaluacja_optymalizacja:database-verification") + assert admin_client.get(url).context["brak_oswiadczenia_ciagle_count"] == 1 + + for wca in wc.autorzy_set.all(): + wca.delete() + + assert admin_client.get(url).context["brak_oswiadczenia_ciagle_count"] == 0 + + +@pytest.mark.django_db +def test_pbn_wyslij_oswiadczenia_pomija_publikacje_bez_zywego_autorstwa( + wydawnictwo_ciagle_z_autorem, dyscyplina1, rodzaj_autora_n +): + """``pbn_wyslij_oswiadczenia_instytucji._get_publications_by_year``.""" + from pbn_api.management.commands.pbn_wyslij_oswiadczenia_instytucji import ( + Command, + ) + from pbn_api.models import Publication + + wc = wydawnictwo_ciagle_z_autorem + _ustaw_dyscypline(wc.autorzy_set.first(), dyscyplina1, rodzaj_autora_n) + wc.pbn_uid = Publication.objects.create(mongoId="y" * 24, status="ACTIVE") + wc.save() + + def _znalezione(): + return {p.pk for p in Command()._get_publications_by_year(wc.rok)} + + assert wc.pk in _znalezione() + + wc.autorzy_set.first().delete() + + assert wc.pk not in _znalezione() + + +@pytest.mark.django_db +def test_rozbieznosci_scope_do_uczelni_ignoruje_skasowane( + settings, site1, jednostka_uczelnia1, jednostka_uczelnia2, typy_odpowiedzialnosci +): + """``rozbieznosci.views._scope_do_uczelni`` — atrybucja multi-host. + + Skasowane autorstwo nie może przypisywać rekordu do uczelni; inaczej + rekord wycieka na cudzy host. Test wymaga DWÓCH uczelni — przy jednej + helper jest no-opem (guard ``tylko_jedna_uczelnia``). + """ + from model_bakery import baker + + from fixtures.conftest_multisite import make_request_for_site + from rozbieznosci.views import _scope_do_uczelni + + settings.ALLOWED_HOSTS = ["*"] + wc = baker.make("bpp.Wydawnictwo_Ciagle", rok=2023) + baker.make( + "bpp.Wydawnictwo_Ciagle_Autor", + rekord=wc, + autor=baker.make("bpp.Autor"), + jednostka=jednostka_uczelnia1, + ) + request = make_request_for_site(site1, path="/rozbieznosci/if/") + + qs_przed = _scope_do_uczelni(Wydawnictwo_Ciagle.objects.all(), request) + assert wc.pk in set(qs_przed.values_list("pk", flat=True)) + + wc.autorzy_set.first().delete() + + qs_po = _scope_do_uczelni(Wydawnictwo_Ciagle.objects.all(), request) + assert wc.pk not in set(qs_po.values_list("pk", flat=True)) + + +@pytest.mark.django_db +def test_export_bibtex_filtr_po_autorze_pomija_skasowane( + wydawnictwo_ciagle_z_autorem, capsys +): + """``bpp.management.commands.export_bibtex`` — ``--author``. + + Filtr szedł po M2M ``autorzy__nazwisko``; skasowane autorstwo dalej + wciągało publikację do eksportu. (``capsys``, bo komenda drukuje rekordy + zwykłym ``print()``, a nie przez ``self.stdout``.) + """ + from django.core.management import call_command + from django.core.management.base import CommandError + + wc = wydawnictwo_ciagle_z_autorem + nazwisko = wc.autorzy_set.first().autor.nazwisko + + call_command("export_bibtex", author=nazwisko) + assert "Exporting 1 publications" in capsys.readouterr().out + + wc.autorzy_set.first().delete() + + # Komenda kończy się CommandError-em, gdy nic nie pasuje — to jest tu + # POŻĄDANY wynik: po skasowaniu autorstwa nie ma czego eksportować. + with pytest.raises(CommandError, match="No publications found"): + call_command("export_bibtex", author=nazwisko) + + +@pytest.mark.django_db +def test_ranking_autora_w_wyszukiwarce_nie_liczy_skasowanych( + wydawnictwo_ciagle_z_autorem, +): + """``AutorManager.fulltext_annotate`` — ``search__rank`` husku autora. + + Ranking podpowiedzi autora to liczba jego autorstw. Bez predykatu husk + (wszystkie autorstwa skasowane) trzymał swoją pozycję w podpowiedziach. + """ + autor = wydawnictwo_ciagle_z_autorem.autorzy_set.first().autor + + def _rank(): + return ( + Autor.objects.filter(pk=autor.pk) + .annotate(**Autor.objects.fulltext_annotate(None, None)) + .values_list("search__rank", flat=True)[0] + ) + + assert _rank() == 1 + + wydawnictwo_ciagle_z_autorem.autorzy_set.first().delete() + + assert _rank() == 0 + + +@pytest.mark.django_db +def test_djangoql_deleted_at_jest_wyrazalne_w_zapytaniu( + wydawnictwo_ciagle_z_autorem, +): + """Deduplikator otwiera changelist zapytaniem DjangoQL — musi dać się + w nim wyrazić ``autorzy_set.deleted_at = None``. + + Bez tego (a) składnia sypie się na nieznanym polu, albo (b) — gorzej — + zapytanie przechodzi, ale JOIN pokazuje skasowane autorstwa, czyli + dokładnie husk, który deduplikator ma pomagać usuwać. + """ + from djangoql.queryset import apply_search + + from bpp.djangoql_schema import BppQLSchema + + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + zapytanie = f"autorzy_set.autor.id = {autor_id} and autorzy_set.deleted_at = None" + + qs = apply_search(Wydawnictwo_Ciagle.objects.all(), zapytanie, schema=BppQLSchema) + assert wc.pk in set(qs.values_list("pk", flat=True)) + + wc.autorzy_set.first().delete() + + qs = apply_search(Wydawnictwo_Ciagle.objects.all(), zapytanie, schema=BppQLSchema) + assert wc.pk not in set(qs.values_list("pk", flat=True)) + + +@pytest.mark.django_db +def test_denorm_rebuild_CELOWO_widzi_skasowane(wydawnictwo_ciagle_z_autorem): + """``bpp.models.dyscyplina_naukowa`` — jedyny ŚWIADOMY brak predykatu. + + ``rebuild_instances_of(klass, rok=…, autorzy_set__autor_id=…)`` wybiera + publikacje DO PRZELICZENIA. Publikacja, której autorstwo skasowano, + potrzebuje przeliczenia tak samo (a nawet bardziej) niż ta z autorstwem + żywym — dodanie ``deleted_at__isnull=True`` wycięłoby ją ze zbioru + i zostawiło nieaktualne punkty do nocnej rekalkulacji. Ten test przypina + tę decyzję, żeby nikt jej „nie naprawił" hurtem. + """ + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + wc.autorzy_set.first().delete() + + assert Wydawnictwo_Ciagle.objects.filter( + rok=wc.rok, autorzy_set__autor_id=autor_id + ).exists(), "rebuild_instances_of musi nadal widzieć tę publikację" + + +@pytest.mark.django_db +def test_kanarek_agregat_bez_predykatu_zawyza(wydawnictwo_ciagle_z_autorem): + """Kanarek negatywny dla agregatów (symetryczny do tego dla ``filter``).""" + wc = wydawnictwo_ciagle_z_autorem + wc.autorzy_set.first().delete() + + zawyzony = ( + Wydawnictwo_Ciagle.objects.filter(pk=wc.pk) + .annotate(n=Count("autorzy_set")) + .values_list("n", flat=True)[0] + ) + poprawny = ( + Wydawnictwo_Ciagle.objects.filter(pk=wc.pk) + .annotate( + n=Count("autorzy_set", filter=Q(autorzy_set__deleted_at__isnull=True)) + ) + .values_list("n", flat=True)[0] + ) + assert zawyzony == 1 + assert poprawny == 0 diff --git a/src/bpp/views/autocomplete/search_services.py b/src/bpp/views/autocomplete/search_services.py index e39e7342b..5d47dbb8b 100644 --- a/src/bpp/views/autocomplete/search_services.py +++ b/src/bpp/views/autocomplete/search_services.py @@ -1,5 +1,6 @@ """Global search functions for autocomplete views.""" +from django.db.models import Q from django.db.models.aggregates import Count from bpp import const @@ -72,13 +73,24 @@ def _scope(qs): ) ) + # Sortowanie pomocnicze po „dorobku" autora. Liczymy autorstwa przez + # through-model z ``filter=Q(…__deleted_at__isnull=True)`` — agregat po + # relacji idzie JOIN-em po SUROWEJ tabeli ``bpp_wydawnictwo_ciagle_autor`` + # i managera soft-delete nie pyta, więc bez predykatu husk autora + # (wszystkie autorstwa skasowane) trzymałby swoją pozycję w podpowiedziach. + # Ta sama zasada co w ``AutorManager.fulltext_annotate``. querysets.append( _scope( Autor.objects.fulltext_filter(q) - .annotate(Count("wydawnictwo_ciagle")) + .annotate( + liczba_autorstw=Count( + "wydawnictwo_ciagle_autor", + filter=Q(wydawnictwo_ciagle_autor__deleted_at__isnull=True), + ) + ) .only(*AUTOR_ONLY) .select_related(*AUTOR_SELECT_RELATED) - .order_by("-search__rank", "-wydawnictwo_ciagle__count") + .order_by("-search__rank", "-liczba_autorstw") ) ) diff --git a/src/bpp/views/browse.py b/src/bpp/views/browse.py index dd7db3377..72a8cfbbe 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -122,9 +122,16 @@ def get_uczelnia_context_data(uczelnia, article_slug=None): # JOIN po relacji wielowartościowej (rekord z N autorstwami tej samej # uczelni dałby N kopii streszczenia). W single-install filtru nie ma, # więc nie ma czego deduplikować. + # + # ``rekord__autorzy_set__deleted_at__isnull=True``: ten JOIN idzie po + # SUROWEJ tabeli ``bpp_wydawnictwo_ciagle_autor`` (manager soft-delete + # nie jest pytany przy lookupie przez relację), więc bez predykatu + # skasowane autorstwo nadal atrybuowałoby streszczenie do uczelni — + # czyli publikowałoby je na cudzym hoście. if uczelnia is not None and not tylko_jedna_uczelnia(): recent_abstracts = recent_abstracts.filter( - rekord__autorzy_set__jednostka__uczelnia=uczelnia + rekord__autorzy_set__jednostka__uczelnia=uczelnia, + rekord__autorzy_set__deleted_at__isnull=True, ).distinct() context["recent_abstracts"] = recent_abstracts.order_by( "-rekord__ostatnio_zmieniony" @@ -448,7 +455,8 @@ def get_queryset(self): # * ``fulltext_filter`` dla ``Autor`` — UWAGA, tu JOIN JEST: # ``AutorManager.fulltext_annotate`` (``bpp/models/autor.py``) # nadpisuje wersję z ``FulltextSearchMixin`` i zwraca - # ``Count("wydawnictwo_ciagle")``, co dokłada ``LEFT OUTER JOIN + # ``Count("wydawnictwo_ciagle_autor", filter=Q(…deleted_at + # __isnull=True))``, co dokłada ``LEFT OUTER JOIN # bpp_wydawnictwo_ciagle_autor``. Wierszy nie mnoży wyłącznie # dlatego, że agregat wymusza ``GROUP BY`` po pk — deduplikacja # pochodzi stamtąd, nie z braku złączenia. Zmiana tej adnotacji na diff --git a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html index fcbcb83ee..315b6e1c5 100644 --- a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html +++ b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html @@ -1347,8 +1347,15 @@
{% if duplikat_data.publikacje_c var type = target.dataset.openWydawnictwo; var autorId = target.dataset.autorId; + // 'autorzy_set.deleted_at = None' jest KONIECZNE: DjangoQL tłumaczy + // 'autorzy_set.autor.id = N' na JOIN po SUROWEJ tabeli + // bpp_wydawnictwo_*_autor i managera soft-delete nie pyta. Bez tego + // deduplikator pokazywałby publikacje, na których autorstwo tego + // autora zostało już skasowane — czyli dokładnie husk, który + // deduplikator ma pomagać usuwać. var baseQuery = encodeURIComponent( - 'rok >= 2022 and rok <= 2025 and autorzy_set.autor.id = ' + autorId + 'rok >= 2022 and rok <= 2025 and autorzy_set.autor.id = ' + autorId + + ' and autorzy_set.deleted_at = None' ); window.open('/admin/bpp/wydawnictwo_' + type + '/?q-l=on&q=' + baseQuery, '_blank'); e.preventDefault(); diff --git a/src/django_bpp/templates/admin/bpp/autor/change_form.html b/src/django_bpp/templates/admin/bpp/autor/change_form.html index ae0c31f84..7f2743325 100644 --- a/src/django_bpp/templates/admin/bpp/autor/change_form.html +++ b/src/django_bpp/templates/admin/bpp/autor/change_form.html @@ -1,16 +1,21 @@ {% extends "admin/change_form.html" %} {% block object-tools-items %} + {# ``autorzy_set__deleted_at__isnull=True`` w obu linkach niżej: changelist #} + {# admina filtruje JOIN-em po SUROWEJ tabeli ``bpp_wydawnictwo_*_autor``, #} + {# a nie przez manager soft-delete. Bez tego parametru „publikacje autora" #} + {# pokazywałyby też te, na których autorstwo zostało SKASOWANE. Lookup jest #} + {# przepuszczony przez ``lookup_allowed`` w Wydawnictwo_{Ciagle,Zwarte}Admin. #} {% if original %}
  • + href="{% url 'admin:bpp_wydawnictwo_ciagle_changelist' %}?autorzy_set__autor__id__exact={{ original.pk }}&autorzy_set__deleted_at__isnull=True"> 📰 Wyd. ciągłe
  • + href="{% url 'admin:bpp_wydawnictwo_zwarte_changelist' %}?autorzy_set__autor__id__exact={{ original.pk }}&autorzy_set__deleted_at__isnull=True"> 📚 Wyd. zwarte
  • diff --git a/src/ewaluacja_optymalizacja/templates/ewaluacja_optymalizacja/database_verification.html b/src/ewaluacja_optymalizacja/templates/ewaluacja_optymalizacja/database_verification.html index 98fc48720..17bdb6436 100644 --- a/src/ewaluacja_optymalizacja/templates/ewaluacja_optymalizacja/database_verification.html +++ b/src/ewaluacja_optymalizacja/templates/ewaluacja_optymalizacja/database_verification.html @@ -126,16 +126,21 @@

    Brak daty oświadczenia

    + {# ``autorzy_set__deleted_at__isnull=True`` MUSI być w obu linkach: #} + {# changelist admina filtruje JOIN-em po surowej tabeli ``*_autor``, #} + {# a liczniki wyżej (verification.py) już ten predykat mają. Bez niego #} + {# lista w adminie byłaby dłuższa niż liczba obok — o publikacje, #} + {# których problematyczne autorstwo zostało skasowane. #}
    {% if brak_oswiadczenia_ciagle_count > 0 %} - Wydawnictwa ciągłe w admin {% endif %} {% if brak_oswiadczenia_zwarte_count > 0 %} - Wydawnictwa zwarte w admin diff --git a/src/ewaluacja_optymalizacja/views/verification.py b/src/ewaluacja_optymalizacja/views/verification.py index d948aa03c..ac09e8171 100644 --- a/src/ewaluacja_optymalizacja/views/verification.py +++ b/src/ewaluacja_optymalizacja/views/verification.py @@ -60,11 +60,19 @@ def database_verification_view(request): ).select_related("rekord", "autor", "dyscyplina_naukowa") # Liczba publikacji z rok >= 2022, gdzie autor ma dyscyplinę, ale brak daty oświadczenia + # + # ``autorzy_set__deleted_at__isnull=True`` jest KONIECZNE: filtr przez + # relację buduje JOIN po SUROWEJ tabeli ``*_autor`` i nie pyta managera + # soft-delete. Bez tego licznik obejmowałby publikacje, których jedyne + # „problematyczne" autorstwo zostało skasowane — a przycisk obok + # (changelist admina z tym samym zestawem lookupów) też jest o to + # rozszerzony, żeby liczba i lista się zgadzały. brak_oswiadczenia_ciagle_count = ( Wydawnictwo_Ciagle.objects.filter( rok__gte=2022, autorzy_set__dyscyplina_naukowa__isnull=False, autorzy_set__data_oswiadczenia__isnull=True, + autorzy_set__deleted_at__isnull=True, ) .distinct() .count() @@ -75,6 +83,7 @@ def database_verification_view(request): rok__gte=2022, autorzy_set__dyscyplina_naukowa__isnull=False, autorzy_set__data_oswiadczenia__isnull=True, + autorzy_set__deleted_at__isnull=True, ) .distinct() .count() diff --git a/src/import_common/core/autor.py b/src/import_common/core/autor.py index 7436c942f..b5fdb2e88 100644 --- a/src/import_common/core/autor.py +++ b/src/import_common/core/autor.py @@ -415,10 +415,16 @@ def _publikacji_counts_bulk(pks: list[int]) -> dict[int, int]: Count("zwarte") + Count("patent")`` w jednym querysecie Django robi cross-JOIN i kardynalności się mnożą. Tu mamy 3 round-tripy na cały request, niezależnie od liczby kandydatów. + + ``filter=Q(…__deleted_at__isnull=True)`` jest KONIECZNY: agregat idzie + JOIN-em po surowej tabeli ``*_autor``, a nie przez ``objects`` tych + modeli, więc bez tego predykatu soft-delete autorstwa w ogóle nie + zmniejszałby licznika. Deduplikator pokazuje tę liczbę przy kandydacie + — husk autora wyglądałby na aktywnego. """ from collections import defaultdict - from django.db.models import Count + from django.db.models import Count, Q totals: dict[int, int] = defaultdict(int) for relation in ( @@ -428,7 +434,12 @@ def _publikacji_counts_bulk(pks: list[int]) -> dict[int, int]: ): rows = ( Autor.objects.filter(pk__in=pks) - .annotate(_n=Count(relation)) + .annotate( + _n=Count( + relation, + filter=Q(**{f"{relation}__deleted_at__isnull": True}), + ) + ) .values_list("pk", "_n") ) for pk, n in rows: diff --git a/src/pbn_api/management/commands/pbn_wyslij_oswiadczenia_instytucji.py b/src/pbn_api/management/commands/pbn_wyslij_oswiadczenia_instytucji.py index 3832da9d3..302396f8a 100644 --- a/src/pbn_api/management/commands/pbn_wyslij_oswiadczenia_instytucji.py +++ b/src/pbn_api/management/commands/pbn_wyslij_oswiadczenia_instytucji.py @@ -443,10 +443,16 @@ def _get_single_publication(self, publication_id): def _get_publications_by_year(self, year): """Get all publications (Wydawnictwo_Zwarte and Wydawnictwo_Ciagle) by year.""" + # ``autorzy_set__deleted_at__isnull=True``: lookup przez relację to + # JOIN po SUROWEJ tabeli ``*_autor`` — manager soft-delete nie jest + # pytany. Bez tego predykatu do wysyłki oświadczeń trafiałaby + # publikacja, której jedyne autorstwo z dyscypliną zostało + # skasowane (a więc bez czego wysłać). filter_kw = dict( rok=year, pbn_uid_id__isnull=False, autorzy_set__dyscyplina_naukowa__isnull=False, + autorzy_set__deleted_at__isnull=True, ) # Get Wydawnictwo_Zwarte publications zwarte_qs = Wydawnictwo_Zwarte.objects.filter(**filter_kw).select_related( diff --git a/src/pbn_wysylka_oswiadczen/queries.py b/src/pbn_wysylka_oswiadczen/queries.py index af0ff1f80..6de5b93fd 100644 --- a/src/pbn_wysylka_oswiadczen/queries.py +++ b/src/pbn_wysylka_oswiadczen/queries.py @@ -47,10 +47,20 @@ def get_publications_queryset( # Add annotations if needed (for views displaying counts) if with_annotations: + # ``autorzy_set__deleted_at__isnull=True`` w OBU licznikach: agregat + # po ``autorzy_set`` idzie JOIN-em po SUROWEJ tabeli ``*_autor``, + # z pominięciem managera soft-delete. Bez tego predykatu skasowane + # autorstwo liczy się do ``liczba_oswiadczen``, a filtr + # ``tylko_odpiete`` (liczba_oswiadczen > 0 AND liczba_przypietych = 0) + # wskazuje do wycofania z PBN rekordy, na których nie ma już ani + # jednego żywego oświadczenia. annotations = { "liczba_oswiadczen": Count( "autorzy_set__pk", - filter=Q(autorzy_set__dyscyplina_naukowa__isnull=False), + filter=Q( + autorzy_set__dyscyplina_naukowa__isnull=False, + autorzy_set__deleted_at__isnull=True, + ), distinct=True, ), "liczba_przypietych": Count( @@ -58,6 +68,7 @@ def get_publications_queryset( filter=Q( autorzy_set__przypieta=True, autorzy_set__dyscyplina_naukowa__isnull=False, + autorzy_set__deleted_at__isnull=True, ), distinct=True, ), diff --git a/src/rozbieznosci/views.py b/src/rozbieznosci/views.py index de5aa1bd0..389a81cf6 100644 --- a/src/rozbieznosci/views.py +++ b/src/rozbieznosci/views.py @@ -107,11 +107,21 @@ def _scope_do_uczelni(qs, request): No-op (zwraca ten sam qs) gdy brak mapowania Site→Uczelnia (fail-open, jak ``scope_rekord_do_uczelni``) albo gdy w systemie jest jedna uczelnia (guard ``tylko_jedna_uczelnia`` — single-host = brak zawężenia). + + ``autorzy_set__deleted_at__isnull=True`` jest częścią reguły atrybucji, nie + ozdobnikiem: lookup przez relację buduje JOIN po SUROWEJ tabeli + ``bpp_wydawnictwo_ciagle_autor``, więc bez tego predykatu SKASOWANE + autorstwo dalej przypisywałoby rekord do uczelni — czyli pokazywałoby go + na cudzym hoście. ``scope_rekord_do_uczelni`` tego problemu nie ma, bo + idzie po ``bpp_autorzy_mat`` (widok już filtruje ``deleted_at``). """ uczelnia = Uczelnia.objects.get_for_request(request) if uczelnia is None or tylko_jedna_uczelnia(): return qs - return qs.filter(autorzy_set__jednostka__uczelnia=uczelnia).distinct() + return qs.filter( + autorzy_set__jednostka__uczelnia=uczelnia, + autorzy_set__deleted_at__isnull=True, + ).distinct() class RozbieznosciView(MetrykaMixin, GroupRequiredMixin, ListView): From 9efdb7bbf6a78c492f9a158a54999860dc5bd756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 10:02:47 +0200 Subject: [PATCH 39/75] test(soft-delete): kanarek ORM pilnuje predykatu deleted_at przy JOIN-ach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odpowiednik test_kanarek_katalogowy (tamten pilnuje pg_views) o warstwe wyzej. Kanarek katalogowy NIE lapal wyciekow ORM-owych, bo patrzy na SQL w bazie, a nie na zapytania Pythona — te trzy naprawy w fazie 01 odkryly sie przez awarie, nie przez test. Kanarek parsuje kazdy produkcyjny .py do AST i szuka wywolan faktycznie budujacych zapytanie (filter/exclude/annotate/Q/Count/…, plus `dict()` i `rebuild_instances_of`), sprawdzajac nazwy kwargow i literaly napisowe pod katem sciezek do trzech through-modeli. Werdykt zapada na poziomie INSTRUKCJI, nie linii — bo predykat czesto laduje w innej linii niz lookup. `prefetch_related`/`select_related`/`Prefetch` sa celowo poza lista wywolan (ida przez _default_manager), wiec kanarek na nie nie krzyczy. Osobna regula lapie petle po liscie nazw relacji (wzorzec z import_common). Dowod, ze niesie informacje: po `git stash` poprawek produkcyjnych kanarek wskazuje 8 z 11 naprawionych miejsc po nazwie i linii. Meta-test przypina 4 wzorce pozytywne i 4 negatywne (router.register, docstring, prefetch, poprawny filter) — zeby zielony wynik cos znaczyl. Lista DOZWOLONE ma jeden wpis (denorm rebuild) i wlasny test pilnujacy, ze wpis wciaz pasuje do kodu — inaczej zamienilaby sie w cmentarz nieaktualnych wyjatkow, ktory po cichu przepusci przyszly wyciek w tym samym pliku. Slepe plamy sa udokumentowane w docstringu: M2M `autorzy__…` i odwrotne `Count("wydawnictwo_ciagle")` wymagaja znajomosci modelu po LEWEJ stronie zapytania, a ten bywa w innej instrukcji. Te dwa przypadki pokrywaja wylacznie testy semantyczne w test_orm_wyciek_join.py. Koszt: ~290 linii, ok. 0.1 s, bez bazy danych. --- .../test_soft_delete/test_kanarek_orm.py | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 src/bpp/tests/test_soft_delete/test_kanarek_orm.py diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_orm.py b/src/bpp/tests/test_soft_delete/test_kanarek_orm.py new file mode 100644 index 000000000..9a3312a31 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_kanarek_orm.py @@ -0,0 +1,320 @@ +"""Kanarek ORM-owy: nowy JOIN po soft-delete'owanym ``*_Autor`` bez predykatu. + +Odpowiednik ``test_kanarek_katalogowy`` (tamten pilnuje ``pg_views``) o warstwę +wyżej. Przeszukuje KOD PRODUKCYJNY w poszukiwaniu wywołań ORM, które sięgają +przez relację do ``Wydawnictwo_Ciagle_Autor`` / ``Wydawnictwo_Zwarte_Autor`` / +``Patent_Autor``, i wymaga, żeby w tej samej instrukcji padło ``deleted_at``. + +Dlaczego to jest potrzebne mimo managerów soft-delete: JOIN przez relację +(``filter(autorzy_set__…)``, ``Count("autorzy_set")``, +``Count("wydawnictwo_ciagle_autor")``) NIE pyta managera — Django złącza +surową tabelę. Ta klasa błędu wróciła w fazie 01 już trzy razy (widoki +``bpp_*_autorzy``, widoki pochodne, ``rozbieznosci_dyscyplin_zrodel``), za +każdym razem odkryta przez awarię, nie przez test. + +Jak to działa +============= + +Parsujemy każdy produkcyjny ``.py`` do AST i szukamy wywołań, które faktycznie +budują zapytanie (``WYWOLANIA_ORM``: ``filter``/``exclude``/``annotate``/ +``Q``/``Count``/…). Dopiero w NICH sprawdzamy nazwy argumentów kluczowych i +literały napisowe pod kątem ścieżek relacji (``PODEJRZANE_SCIEZKI``). Werdykt +zapada na poziomie **instrukcji** (``ast.stmt``), nie linii — bo +``.filter(\\n a=…,\\n autorzy_set__deleted_at__isnull=True,\\n)`` rozkłada +predykat na inną linię niż lookup. + +Świadomie WĄSKO, żeby kanarek nie krzyczał na: + +* ``prefetch_related("autorzy_set__autor")`` i ``select_related`` — idą przez + ``_default_manager``, są bezpieczne (przypina to + ``test_prefetch_related_autorzy_set_odcina_skasowane``); +* ``router.register(r"patent_autor", …)`` i mapy nazw endpointów — to nazwy + URL-i, nie relacje; +* docstringi cytujące ścieżki (``kompletnosc_polon/reguly.py``). + +Znane ślepe plamy (świadome, dlatego OBOK stoją testy semantyczne w +``test_orm_wyciek_join.py``): + +* **M2M ``autorzy__…``** — ``Q(autorzy__nazwisko=…)`` też złącza surową + tabelę ``*_autor``, ale ta sama nazwa relacji na ``Rekord`` idzie po + bezpiecznym ``bpp_autorzy_mat``. Rozróżnienie wymaga znajomości modelu po + LEWEJ stronie zapytania, a ten bywa w innej instrukcji (tak jest w + ``export_bibtex``). Statyczny kanarek tego nie rozstrzygnie; +* **odwrotne M2M od ``Autor``** — ``Count("wydawnictwo_ciagle")`` na + ``Autor`` przechodzi przez through-model, ale ta sama nazwa na ``Zrodlo`` + czy ``Charakter_Formalny`` to zwykły FK. Znowu: zależy od lewego modelu; +* ścieżka sklejona w innej instrukcji niż wywołanie, ``**kwargs`` przekazane + przez kilka warstw; +* DjangoQL i surowe parametry GET changelistu admina — to język zapytań + użytkownika, świadomie surowy (patrz raport ``orm-leak-report.md``). + +Gdy test padnie +=============== + +Nie dopisuj wpisu do ``DOZWOLONE`` odruchowo. Najpierw rozstrzygnij: + +* czy zapytanie MA widzieć skasowane (audyt, rebuild denorm)? → dopisz + do ``DOZWOLONE`` z powodem merytorycznym; +* w każdym innym przypadku dołóż ``…__deleted_at__isnull=True`` na TYM SAMYM + joinie (nie osobnym ``filter()`` — Django zrobi drugi, nieskorelowany JOIN) + i dopisz test regresji do ``test_orm_wyciek_join.py``. +""" + +import ast +import pathlib + +import pytest + +#: Katalog ``src/`` — korzeń skanowania. +KORZEN = pathlib.Path(__file__).resolve().parents[3] + +#: Nazwy relacji do trzech soft-delete'owanych through-modeli. ``autorzy_set`` +#: to strona publikacji; pozostałe to nazwy odwrotne od strony ``Autor``, +#: ``Jednostka`` i słowników (brak ``related_name`` → nazwa modelu małymi). +RELACJE = ( + "autorzy_set", + "wydawnictwo_ciagle_autor", + "wydawnictwo_zwarte_autor", + "patent_autor", +) + +#: Wywołania, w których nazwa argumentu / literał napisowy jest ścieżką ORM. +#: ``prefetch_related``/``select_related``/``Prefetch`` CELOWO poza listą. +WYWOLANIA_ORM = frozenset( + { + "filter", + "exclude", + "annotate", + "aggregate", + "alias", + "order_by", + "values", + "values_list", + "only", + "defer", + "distinct", + "get", + "get_or_create", + "update", + "Q", + "F", + "Count", + "Sum", + "Avg", + "Min", + "Max", + "Exists", + "OuterRef", + "Subquery", + # ``dict(...)`` — bo zestawy lookupów bywają budowane jako + # ``filter_kw = dict(rok=…, autorzy_set__…)`` i wstrzykiwane ``**``. + "dict", + # denorm: kwargs lecą prosto do ``qs.filter()``. + "rebuild_instances_of", + } +) + +#: Katalogi/pliki spoza kodu produkcyjnego. +POMIJANE_FRAGMENTY_SCIEZKI = ( + "/tests/", + "/migrations/", + "/test_", + "/conftest", + "/fixtures/", + "/demo_data/", +) + +#: Znane, ŚWIADOME wystąpienia bez ``deleted_at``. Klucz: ścieżka względem +#: ``src/`` + fragment kodu, który ma zostać rozpoznany. Wartość: powód. +#: Dopisanie tu czegokolwiek jest decyzją projektową — patrz docstring modułu. +DOZWOLONE = { + ("bpp/models/dyscyplina_naukowa.py", "rebuild_instances_of"): ( + "ŚWIADOMIE: rebuild_instances_of wybiera publikacje DO PRZELICZENIA. " + "Publikacja ze skasowanym autorstwem potrzebuje przeliczenia tak " + "samo (a nawet bardziej) niż ta z żywym — predykat wyciąłby ją ze " + "zbioru i zostawił nieaktualne punkty. Przypięte testem " + "test_denorm_rebuild_CELOWO_widzi_skasowane." + ), +} + + +def _pliki_produkcyjne(): + for sciezka in sorted(KORZEN.rglob("*.py")): + wzgledna = sciezka.relative_to(KORZEN).as_posix() + if any(frag in "/" + wzgledna for frag in POMIJANE_FRAGMENTY_SCIEZKI): + continue + if wzgledna.endswith("tests.py"): + continue + yield sciezka, wzgledna + + +def _jest_sciezka_relacji(tekst: str) -> bool: + """Czy ``tekst`` (nazwa kwargu albo literał) jest ścieżką ORM do + through-modelu? ``autorzy_set__autor`` tak, ``patent_autor`` (nazwa + endpointu w routerze) — tylko gdy stoi samo albo z ``__``.""" + if not isinstance(tekst, str): + return False + for relacja in RELACJE: + if tekst == relacja or tekst.startswith(relacja + "__"): + return True + if "__" + relacja + "__" in tekst or tekst.endswith("__" + relacja): + return True + return False + + +def _nazwa_wywolania(wezel: ast.Call) -> str | None: + if isinstance(wezel.func, ast.Attribute): + return wezel.func.attr + if isinstance(wezel.func, ast.Name): + return wezel.func.id + return None + + +def _wywolanie_dotyka_relacji(wezel: ast.Call) -> bool: + if _nazwa_wywolania(wezel) not in WYWOLANIA_ORM: + return False + for kw in wezel.keywords: + if kw.arg and _jest_sciezka_relacji(kw.arg): + return True + for arg in wezel.args: + if isinstance(arg, ast.Constant) and _jest_sciezka_relacji(arg.value): + return True + return False + + +def _podejrzane_instrukcje(zrodlo: str, wzgledna: str): + """Zwraca [(linia, pierwsza linia instrukcji)] — instrukcje z wywołaniem + ORM po relacji do ``*_Autor``, bez ``deleted_at``, spoza ``DOZWOLONE``.""" + try: + drzewo = ast.parse(zrodlo) + except SyntaxError: + # Plik niekompilowalny — nie zadanie tego kanarka. + return [] + + zlozone = ( + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.ClassDef, + ast.If, + ast.While, + ast.With, + ast.AsyncWith, + ast.Try, + ast.Module, + ) + wynik = [] + for wezel in ast.walk(drzewo): + if not isinstance(wezel, ast.stmt) or isinstance(wezel, zlozone): + continue + + if isinstance(wezel, (ast.For, ast.AsyncFor)): + # Pętla po LIŚCIE nazw relacji (``for relation in ("wydawnictwo_ + # ciagle_autor", …)``) — ścieżka jest tu, a agregat w ciele. + # Patrzymy na iterator, werdykt na całej pętli. + literaly = [ + w.value for w in ast.walk(wezel.iter) if isinstance(w, ast.Constant) + ] + if not any(_jest_sciezka_relacji(x) for x in literaly): + continue + elif not any( + isinstance(w, ast.Call) and _wywolanie_dotyka_relacji(w) + for w in ast.walk(wezel) + ): + continue + + segment = ast.get_source_segment(zrodlo, wezel) + if not segment or "deleted_at" in segment: + continue + if any( + plik == wzgledna and fragment in segment for (plik, fragment) in DOZWOLONE + ): + continue + wynik.append((wezel.lineno, segment.strip().splitlines()[0][:110])) + return wynik + + +def test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at(): + """Żadne produkcyjne wywołanie ORM nie JOIN-uje ``*_Autor`` bez + ``deleted_at``. + + Wyrocznia jest strukturalna (AST) + tekstowa (obecność ``deleted_at`` + w tej samej instrukcji), więc kanarek NIE dowodzi poprawności + semantycznej — dowodzi tylko, że autor kodu w ogóle pomyślał o + soft-delete. Semantyki pilnują testy w ``test_orm_wyciek_join.py``. + """ + znaleziska = [] + for sciezka, wzgledna in _pliki_produkcyjne(): + zrodlo = sciezka.read_text(encoding="utf-8") + if not any(relacja in zrodlo for relacja in RELACJE): + continue + for lineno, fragment in _podejrzane_instrukcje(zrodlo, wzgledna): + znaleziska.append(f"{wzgledna}:{lineno}: {fragment}") + + assert not znaleziska, ( + "JOIN po soft-delete'owanym through-modelu bez predykatu " + "``deleted_at`` (Django nie pyta tu managera!):\n " + + "\n ".join(znaleziska) + + "\n\nCo zrobić — patrz docstring modułu " + "src/bpp/tests/test_soft_delete/test_kanarek_orm.py" + ) + + +def test_kanarek_orm_wykrywa_swiezy_wyciek(): + """Meta-test: kanarek naprawdę łapie wzorce, a nie zawsze przechodzi. + + Bez tego „zielony kanarek" nie niósłby informacji — ten sam powód, dla + którego ``test_kanarek_katalogowy`` ma test semantyczny obok + substringowych. + """ + assert _podejrzane_instrukcje( + "Wydawnictwo_Ciagle.objects.filter(autorzy_set__autor=x)\n", "p.py" + ) + assert _podejrzane_instrukcje( + 'Autor.objects.annotate(n=Count("wydawnictwo_ciagle_autor"))\n', "p.py" + ) + assert _podejrzane_instrukcje( + "Wydawnictwo_Ciagle_Streszczenie.objects.filter(\n" + " rekord__autorzy_set__jednostka__uczelnia=u\n" + ")\n", + "p.py", + ) + assert _podejrzane_instrukcje( + 'for relation in ("wydawnictwo_ciagle_autor", "patent_autor"):\n' + " Autor.objects.annotate(n=Count(relation))\n", + "p.py", + ) + + # ...a na poprawnych i bezpiecznych formach MILCZY: + assert not _podejrzane_instrukcje( + "Wydawnictwo_Ciagle.objects.filter(\n" + " autorzy_set__autor=x,\n" + " autorzy_set__deleted_at__isnull=True,\n" + ")\n", + "p.py", + ) + assert not _podejrzane_instrukcje( + 'Wydawnictwo_Ciagle.objects.prefetch_related("autorzy_set__autor")\n', + "p.py", + ) + assert not _podejrzane_instrukcje( + 'router.register(r"patent_autor", Patent_AutorViewSet)\n', "p.py" + ) + assert not _podejrzane_instrukcje( + '"""Docstring cytujący autorzy_set__upowaznienie_pbn."""\n', "p.py" + ) + + +@pytest.mark.parametrize("klucz", sorted(DOZWOLONE)) +def test_wpisy_dozwolone_nadal_istnieja(klucz): + """Każdy wpis w ``DOZWOLONE`` musi wciąż pasować do realnego kodu. + + Bez tego lista zamienia się w cmentarz nieaktualnych wyjątków, który po + cichu przepuści przyszły wyciek w tym samym pliku. + """ + plik, fragment = klucz + sciezka = KORZEN / plik + assert sciezka.exists(), f"DOZWOLONE wskazuje na nieistniejący plik {plik}" + assert fragment in sciezka.read_text(encoding="utf-8"), ( + f"DOZWOLONE[{klucz}] nie pasuje już do kodu — usuń wpis albo " + f"zaktualizuj fragment." + ) From 99225c0e6362cf595645d81930e53a0c9872a484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 10:07:52 +0200 Subject: [PATCH 40/75] docs(soft-delete): newsfragment obejmuje miejsca domkniete przez fix wycieku ORM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newsfragment fazy mowil dotad tylko o widokach zrodlowych i cache'u bibliografii. Po naprawie wyciekow ORM-owych soft-delete jest widoczny takze w liczbie publikacji autora, w podpowiedziach wyszukiwarki, w eksporcie BibTeX, w listach panelu administracyjnego i w zestawieniach oswiadczen wysylanych do PBN — czyli w miejscach, ktore uzytkownik zobaczy. Pelna inwentaryzacja (15 znalezisk, 11 naprawionych, 4 zostawione z uzasadnieniem), kategorie z zerowa liczba trafien, dwie opcje niewdrozone do decyzji kontrolera oraz dwa znaleziska poboczne — w raporcie .superpowers/sdd/2026-06-04-soft-delete-01-autor-trigger-widoki/ orm-leak-report.md (katalog jest gitignored). --- src/bpp/newsfragments/soft-delete-autorstwo.feature.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst index 716c08362..5a9635b7e 100644 --- a/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst +++ b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst @@ -3,5 +3,8 @@ Wewnętrzny fundament pod usuwanie „miękkie" (soft-delete) powiązań autor- patenty) zyskały znaczniki kasowania/przywracania zamiast trwałego usuwania wiersza. Skasowane przypisania znikają też z widoków źródłowych i z materializowanego cache'u bibliografii, więc nie pokazują się na stronach -publikacji. Zmiana jest na razie wewnętrzna i niewidoczna dla użytkowników — +publikacji, w liczbie publikacji autora, w podpowiedziach wyszukiwarki, +w eksporcie BibTeX, w listach panelu administracyjnego ani w zestawieniach +oświadczeń wysyłanych do PBN. +Zmiana jest na razie wewnętrzna i niewidoczna dla użytkowników — pełne wsparcie (panel administracyjny, przywracanie) wdrażają kolejne etapy. From e86e9f0232e244a8e2bd7d6f8df3cc5906b9a53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 10:28:07 +0200 Subject: [PATCH 41/75] docs(soft-delete): runbook o nieaktualnych artefaktach DjangoQL src/bpp/data/*_djangoql_schema.compact.txt -- schematy wystawiane konsumentom /api/v1/zapytanie/, w tym asystentom LLM -- nie wymieniaja pola deleted_at i nie maja testu swiezosci. Skutek: klient ukladajacy zapytanie DjangoQL nie ma skad wiedziec, ze powinien wykluczyc skasowane rekordy, wiec domyslnie ich nie wyklucza. Jest to spojne ze swiadoma decyzja z naprawy wycieku ORM (DjangoQL = narzedzie audytowe, ktore MA widziec kosz), ale niekomunikowane na zewnatrz. Opisane trzy warianty rozstrzygniecia przed faza 02, z uwaga ze regeneracja artefaktow bez aktualizacji opisu API nie wystarcza -- samo pojawienie sie pola w schemacie nie mowi klientowi, ze powinien go uzyc. Test swiezosci artefaktow potrzebny niezaleznie od wybranego wariantu. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- docs/deweloper/runbook-soft-delete-faza-01.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/deweloper/runbook-soft-delete-faza-01.md b/docs/deweloper/runbook-soft-delete-faza-01.md index 16810e893..e2025b639 100644 --- a/docs/deweloper/runbook-soft-delete-faza-01.md +++ b/docs/deweloper/runbook-soft-delete-faza-01.md @@ -135,6 +135,29 @@ od użytkowników. PBN, deduplikator) płaci teraz za jego utrzymanie. `0490` dokłada btree po `rekord_id`, co część kosztu łagodzi. **Nikt tego nie zmierzył** — testy z definicji tego nie wykryją. +- **Artefakty DjangoQL dla LLM są nieaktualne.** + `src/bpp/data/*_djangoql_schema.compact.txt` — schematy wystawiane + konsumentom `/api/v1/zapytanie/` (w tym asystentom LLM) — **nie wymieniają + pola `deleted_at`** i nie mają testu świeżości. + + Skutek: klient układający zapytanie DjangoQL nie ma skąd wiedzieć, że + powinien wykluczyć skasowane rekordy, więc domyślnie ich **nie wyklucza**. + To spójne ze świadomą decyzją z §„Zostawione" naprawy wycieku ORM (DjangoQL + traktujemy jako narzędzie audytowe, które *ma* widzieć kosz), ale + niekomunikowane na zewnątrz. + + Do rozstrzygnięcia przed fazą 02 — trzy warianty: + 1. **zregenerować artefakty** (wtedy `deleted_at` staje się jawnym, + dostępnym polem; hałaśliwy diff, bo zmienia się nagłówek z wersją); + 2. **udokumentować w opisie API**, że wyniki obejmują kosz i jak go + odfiltrować (`deleted_at = None`); + 3. zostawić i przyjąć, że to narzędzie dla operatora, nie dla integracji. + + Wariant 1 bez 2 nie wystarczy — samo pojawienie się pola w schemacie nie + mówi klientowi, że *powinien* go użyć. **Dopisać test świeżości** artefaktów + niezależnie od wybranego wariantu; dziś nic nie pilnuje, żeby schemat + nadążał za modelami. + - **Trzy martwe widoki** `bpp_kronika_{wydawnictwo_ciagle,wydawnictwo_zwarte, patent}_view` czytają surowe tabele bez filtra. Zweryfikowano brak konsumentów (kod, szablony, modele) — są jawnym wyjątkiem w kanarku From 483991c4ab8525661801a8d2a59b7ef00d7ea3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 11:12:52 +0200 Subject: [PATCH 42/75] =?UTF-8?q?docs(soft-delete):=20faza=2005=20?= =?UTF-8?q?=E2=80=94=20plan=20zgodny=20z=20kodem=20+=20przypisanie=20konta?= =?UTF-8?q?=20technicznego?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan fazy 05 (PBN wycofanie) opisywał kod sprzed przebudowy kolejki, sprzed wyprowadzenia klienta PBN do pakietu `pbn-client` i sprzed uczelnia-scopingu `SentData`. Wykonanie go verbatim dałoby regresje. Poprawione (każdy punkt zweryfikowany w kodzie): * Numery migracji: `pbn_export_queue` `0008` -> `0011` (ostatnia to `0010_atomowa_kolejka_pbn`), `pbn_api` -> `0080`. * „Stan zastany" przepisany: `_zajmij_atomowo()` (models.py:419), `SendStatus.LOCKED_ELSEWHERE`, FK `uczelnia`, częściowy unikat, `sprobuj_utowrzyc_wpis(user, rekord, uczelnia=None)` z savepointem. * Task 05.3 nie łamie już decyzji #16: `withdraw_from_pbn()` to cienki wrapper na prymityw `wycofaj_oswiadczenia()`; klasyfikacja wyjątków przez istniejące `_handle_pbn_exception`. Usunięte polecenie „przenieś blok wysylke_podjeto/ilosc_prob" (tego bloku nie ma). * `_pozyskaj_klienta_pbn` bierze uczelnię z `self.uczelnia`, nie z nieistniejącego `Uczelnia.objects.get_default()` (usunięte, pilnowane guardem test_multihosted_get_default_guard.py — także w komentarzach). * Klient PBN: cytaty przekierowane do site-packages (`pbn_client/mixins/institutions.py:87`, `pbn_client/statements.py:211`); wzorzec obsługi wyjątków z `pbn_wysylka_oswiadczen/tasks.py:54-76`. * Task 05.5 nie kasuje już zabezpieczenia TOCTOU i nie gubi `uczelnia` — `sprobuj_utowrzyc_wpis` tylko rozszerzana o `operacja`. * Kontrakt `zakolejkuj_*` przypięty do wariantu fazy 06: funkcje modułowe w `pbn_export_queue/operacje.py`, `(instance, user=None, uczelnia=None)`. Plan 06 nie wymaga zmian sygnatur; jego Task 5 (shim) staje się zbędny. * `SentData` jest per-uczelnia — `mark_as_withdrawn` dostaje `uczelnia`, testy dostają fixture `uczelnia`, dodany test izolacji między uczelniami. Nowy Task 05.4a — konto techniczne dla `zamowil`, przypisane JAWNIE do fazy 05 (spec §4.2 mówił „faza 05/06", plan 05 „faza 06/07", plan 06 „dług fazy 05" — nikt tego nie robił). Bez tego systemowy soft-delete publikacji z `pbn_uid` PO CICHU nie wycofywał oświadczeń: `zamowil` jest NOT NULL, a `sprobuj_utowrzyc_wpis` przemianowywał każdy `IntegrityError` na `AlreadyEnqueuedError`, więc funkcja zwracała None, a operator widział „już w kolejce". Task dokłada `pobierz_konto_techniczne()`, test na `user=None`, rozróżnienie wyjątku po nazwie ograniczenia oraz test, że brak tokenu PBN konta technicznego kończy się GŁOŚNYM błędem. Zmiana wyłącznie dokumentacyjna — kod nietknięty. --- ...2026-06-04-soft-delete-05-pbn-wycofanie.md | 947 ++++++++++++++---- 1 file changed, 730 insertions(+), 217 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md index bcc3aaf14..0ea482cd5 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md @@ -6,7 +6,16 @@ > 🔄 **Zmiana zakresu 2026-08-06 (decyzja #16).** Wycofanie musi działać **dwoma wejściami**, nie jednym: rekord bywa wysyłany do PBN także **synchronicznie, bez kolejki** (`synchronizuj_publikacje`, `src/pbn_integrator/utils/synchronization.py:180`). Dlatego logika wycofania ma mieszkać w **wolnostojącej funkcji-prymitywie**, a `withdraw_from_pbn()` na modelu kolejki jest tylko jej cienkim wywołaniem. Patrz Task 05.0 i 05.9. -**Architecture:** Nowe pole `operacja` (`TextChoices` `WYSYLKA="wysylka"`/`WYCOFANIE="wycofanie"`, default `WYSYLKA` dla kompatybilności wstecznej) na `PBN_Export_Queue`. `send_to_pbn()` rozgałęzia się na początku: `WYCOFANIE` → nowa metoda `withdraw_from_pbn()` (GET klienta jak w wysyłce, `delete_all_publication_statements` z retry, aktualizacja `SentData`, status przez istniejące `_handle_successful_send`-analog / `error()`); `WYSYLKA` → dotychczasowa ścieżka bez zmian. Gate zakolejkowania: wycofanie tylko gdy rekord ma `pbn_uid_id`. +**Architecture:** Nowe pole `operacja` (`TextChoices` `WYSYLKA="wysylka"`/`WYCOFANIE="wycofanie"`, default `WYSYLKA` dla kompatybilności wstecznej) na `PBN_Export_Queue`. `send_to_pbn()` rozgałęzia się **po** atomowym zajęciu wiersza (`_zajmij_atomowo()`): `WYCOFANIE` → nowa metoda `withdraw_from_pbn()`, będąca **cienkim wywołaniem prymitywu** `wycofaj_oswiadczenia()` (klient PBN z `self.uczelnia`, klasyfikacja wyjątków przez istniejące `_handle_pbn_exception`); `WYSYLKA` → dotychczasowa ścieżka bez zmian. Gate zakolejkowania: wycofanie tylko gdy rekord ma `pbn_uid_id`; `zamowil` dla operacji systemowych to konto techniczne (Task 05.4a). + +> 📌 **Rewizja 2026-08-07 (dług dokumentacyjny).** Plan opisywał kod sprzed +> przebudowy kolejki (`_zajmij_atomowo`, `LOCKED_ELSEWHERE`, FK `uczelnia`), +> sprzed wyprowadzenia klienta PBN do pakietu `pbn-client` i sprzed +> uczelnia-scopingu `SentData`; numery migracji były o trzy do tyłu; +> Task 05.3 łamał decyzję #16, a Task 05.5 rozjeżdżał się kontraktem +> z fazą 06 i kasował zabezpieczenie TOCTOU. Doszedł **Task 05.4a** +> (konto techniczne + rozróżnienie `IntegrityError`), bo bez niego +> systemowy soft-delete **po cichu** nie wycofywał oświadczeń z PBN. **Tech Stack:** Django, PostgreSQL, Celery + `pbn_export_queue`, `pbn_api` (`PBNClient`, `SentData`), pytest + model_bakery + `unittest.mock`. @@ -21,34 +30,143 @@ - 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_*`. +- **NIE modyfikować istniejących migracji** w `src/*/migrations/`. Stan na + 2026-08-07: ostatnia migracja `pbn_export_queue` to + `0010_atomowa_kolejka_pbn.py` → nowa będzie **`0011_*`** (a NIE `0008_*`, jak + pisała pierwsza wersja planu: `0008`/`0009` dołożyły FK `uczelnia`, `0010` — + częściowy unikat aktywnego wpisu). Ostatnia migracja `pbn_api` to + `0079_constraint_publikacja_instytucji.py` → nowa **`0080_*`**. **Przed + pisaniem sprawdź `ls` obu katalogów** — numery mogły znów urosnąć. - Po każdym kroku z kodem produkcyjnym: `ruff check` + `ruff format` na dotkniętych plikach, potem commit. +- Commituj **po jawnych ścieżkach** (`git add …`), nie `git add -A` — + na tej gałęzi bywa równolegle więcej niż jeden wykonawca. --- -## Stan zastany (zweryfikowany w kodzie — używać tych nazw VERBATIM) +## Stan zastany (zweryfikowany w kodzie 2026-08-07 — używać tych nazw VERBATIM) + +> ⚠️ **Poprzednia wersja tej sekcji opisywała kod, którego już nie ma.** +> Kolejka została przebudowana na atomowy row-claim, doszedł FK `uczelnia`, +> a klient PBN wyprowadzono do zewnętrznego pakietu. Poniżej stan faktyczny; +> wszystkie cytaty `plik:linia` zweryfikowane. `src/pbn_export_queue/models.py`: -- `PBN_Export_Queue`: `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`. +- `PBN_Export_Queue` pola: `object_id`, `content_type`, `rekord_do_wysylki` + (GFK), `zamowil` (FK user, `on_delete=CASCADE`, **NOT NULL** — `:101`), + **`uczelnia`** (FK `bpp.Uczelnia`, `null=True`, `related_name= + "pbn_export_queue"` — `:103`), `zamowiono`, `wysylke_podjeto`, + `wysylke_zakonczono`, `ilosc_prob`, `zakonczono_pomyslnie`, `komunikat`, + `retry_after_user_authorised`, `rodzaj_bledu` + (`RodzajBledu.TECHNICZNY/MERYTORYCZNY`), `wykluczone`. +- `Meta.constraints` (`:146-155`): częściowy unikat + **`pbn_export_queue_jeden_aktywny_wpis_na_rekord`** na + `(content_type, object_id)` `WHERE wysylke_zakonczono IS NULL`. +- Manager `PBN_Export_QueueManager` (`:34`): + - `filter_rekord_do_wysylki(rekord)` (filtr `wysylke_zakonczono=None`), + - **`sprobuj_utowrzyc_wpis(user, rekord, uczelnia=None)`** (`:42`) — szybka + ścieżka `exists()` → `AlreadyEnqueuedError`, a następnie + `with transaction.atomic(): self.create(...)` z `except IntegrityError` + tłumaczonym na `AlreadyEnqueuedError` (`:53-63`, zabezpieczenie TOCTOU). + **Tej metody NIE przepisujemy od zera** — rozszerzamy o `operacja` + (Task 05.5) i uszczelniamy rozpoznanie wyjątku (Task 05.4a). Savepoint + + tłumaczenie kolizji unikatu MUSZĄ zostać. +- `SendStatus` (Enum, `:66`): `RETRY_SOON`, `RETRY_LATER`, `RETRY_MUCH_LATER`, + `RETRY_AFTER_USER_AUTHORISED`, `WYKLUCZONE`, `FINISHED_OKAY`, + `FINISHED_ERROR`, **`LOCKED_ELSEWHERE`** (`:80` — dodany przy row-claimie). +- **`_zajmij_atomowo()` (`:419`)** — `select_for_update(skip_locked=True)` + w krótkiej transakcji; ustawia `wysylke_podjeto`, zeruje + `retry_after_user_authorised`, inkrementuje `ilosc_prob` + (`save(update_fields=[...])`), na końcu `refresh_from_db()`. Zwraca `False`, + gdy wiersz trzyma inny worker. ⚠️ **Bloku + „`wysylke_podjeto`/`ilosc_prob`" wewnątrz `send_to_pbn()` JUŻ NIE MA** — + nie ma czego „przenosić" (Task 05.3 dawniej tak kazał). +- **`send_to_pbn()` (`:456`)**, kolejność: `refresh_from_db()` → wczesny zwrot + `FINISHED_OKAY` gdy `wysylke_zakonczono is not None` → + `check_if_record_still_exists()` → `error(...)` → + `if not self._zajmij_atomowo(): return SendStatus.LOCKED_ELSEWHERE` → + import + `sprobuj_wyslac_do_pbn_celery(user=self.zamowil.get_pbn_user(), + obj=self.rekord_do_wysylki, force_upload=True, **uczelnia=self.uczelnia**)` + → `_handle_pbn_exception(exc)` / `error(...)` gdy `sent_data is None` / + `_handle_successful_send(sent_data, notificator)`. +- Pozostałe: `error(msg, rodzaj=None)` (`:241`), **`exclude(msg)`** (`:249` → + `SendStatus.WYKLUCZONE`), `dopisz_komunikat` (`:232`), + `check_if_record_still_exists` (`:170`), `prepare_for_resend` (`:188`), + `sprobuj_wyslac_do_pbn()` (`:227`), `_handle_retry_exception` (`:284` — + `PraceSerwisowe`→RETRY_MUCH_LATER, `NeedsPBNAuthorisation`→ + RETRY_AFTER_USER_AUTHORISED, `ResourceLocked`/HTTP 423→RETRY_LATER, + `StatementsResendFailed`→RETRY_LATER), `_handle_exclude_exception` (`:329`), + `_handle_pbn_exception` (`:351`), `_handle_successful_send` (`:402`). + **`_handle_pbn_exception` jest wspólną klasyfikacją błędów dla obu + operacji** — wycofanie ma z niej korzystać, nie budować własnej drabinki + `except`. `src/pbn_export_queue/tasks.py`: -- `task_sprobuj_wyslac_do_pbn(pk)` — lock przez `cache.add(LOCK_PREFIX+pk)`, `wait_for_object`, `p.send_to_pbn()`, `match` na `SendStatus` (RETRY_* → `apply_async(countdown=...)`, `FINISHED_OKAY` → `check_and_send_next_in_queue()`). 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. +- `task_sprobuj_wyslac_do_pbn(pk)` — lock przez `cache.add(LOCK_PREFIX+pk)`, + `wait_for_object`, `p.send_to_pbn()`, `match` na `SendStatus` (RETRY_* → + `apply_async(countdown=...)`, `FINISHED_OKAY` → `check_and_send_next_in_queue()`, + `LOCKED_ELSEWHERE` obsłużony razem z `FINISHED_ERROR` — `:63-69`). Lock + zwalniany w `finally`. **Ta sama maszyneria obsłuży WYCOFANIE bez zmian** — + `send_to_pbn()` zwraca `SendStatus`. + +**Klient PBN to ZEWNĘTRZNY PAKIET `pbn-client`, nie kod repo.** +`src/pbn_api/client/mixins/` zawiera dziś wyłącznie `__pycache__` — +**każdy cytat `src/pbn_api/client/mixins/institutions.py:87` (w tym planie, +w specu §4.1 i w indeksie 00) jest martwy.** Realne lokalizacje: +- `delete_all_publication_statements(publicationId)` — + **`pbn_client/mixins/institutions.py:87`** (site-packages). Sam mapuje + HTTP 400 z PBN na `CannotDeleteStatementsException` / + `ResourceLockedException` — wołający NIE parsuje treści odpowiedzi. +- `_delete_statements_with_retry(pbn_uid_id, max_tries=5)` — + **`pbn_client/statements.py:211`** (site-packages), ponawia **wyłącznie** + na `CannotDeleteStatementsException`. ⚠️ To NIE jest wzorzec dla naszego + wycofania: u nas `CannotDeleteStatementsException` = „nie ma czego + kasować" = **sukces**, nie powód do retry. +- W repo zostały tylko BPP-owe mixiny: `src/pbn_api/client/publication_sync.py` + (`_pre_upload_clear_pbn_statements_if_any` `:95`, `_post_statements_with_retry` + `:331`, `_download_statements_with_retry` `:498` — `_delete_statements_with_retry` + tam **nie istnieje**) oraz `disciplines.py`; `BppPBNClient` sklejany + w `src/pbn_api/client/__init__.py:85` (zna swoją `uczelnia`). +- **Wzorzec obsługi wyjątków do przejęcia:** + `src/pbn_wysylka_oswiadczen/tasks.py::_delete_existing_statements` + (`:54-76`) — `CannotDeleteStatementsException` → `pass` (OK, nic nie było), + `PraceSerwisoweException` → `raise`, `HttpException` → zapis błędu. +- Wyjątki importuje się z `pbn_api.exceptions` (re-eksport) — tak jak robi to + już `pbn_export_queue/models.py:17-29`. + +`src/pbn_api/models/sentdata.py` — ⚠️ **`SentData` jest scope'owane po +uczelni** (multi-hosted). Wszystkie metody managera mają `uczelnia=None`: +`get_for_rec(rec, uczelnia=None)` (`:23`), `mark_as_successful(rec, +pbn_uid_id=None, api_response_status="", uczelnia=None)` (`:85`), +`mark_as_failed(rec, exception="", api_response_status="", uczelnia=None)` +(`:97`), `create_or_update_before_upload(...)` (`:57`). Model ma FK `uczelnia` +(`:206`). **Gdy dla `(object_id, content_type)` istnieją ≥2 wiersze, lookup +BEZ `uczelnia` rzuca `MultipleObjectsReturned`** — dlatego `mark_as_withdrawn` +MUSI przyjmować `uczelnia` (Task 05.2), a prymityw MUSI je przekazywać. +Pola: `content_type`/`object_id`/`object` (GFK), `uczelnia`, +`submitted_successfully`, `submitted_at`, `api_response_status`, `api_url`, +`uploaded_okay`, `exception`, `fee_sent`, `fee_uploaded_okay`, `pbn_uid` +(FK `pbn_api.Publication`). Pola `withdrawn_at` **nie ma** +(`grep -rn withdrawn_at src/` → 0 trafień). + +`src/bpp/admin/helpers/pbn_api/cli.py:36`: +- `sprobuj_wyslac_do_pbn_celery(user, obj, force_upload=False, + pbn_client=None, **uczelnia=None**)` — buduje + `pbn_client = uczelnia.pbn_client(user.pbn_token)`; bez `uczelnia` robi + `Uczelnia.objects.get()` (przy >1 rzuca — multi-hosted MUSI podać jawnie). + +**Uczelnia: `Uczelnia.objects.get_default()` NIE ISTNIEJE.** Została trwale +usunięta w audycie multi-hosted i jest pilnowana testem-guardem +`src/bpp/tests/test_multihosted_get_default_guard.py::test_get_default_usuniete_na_trwale` +(0 dozwolonych wystąpień w `src/`, **także w komentarzach**). Kod z jej użyciem +wywali CI. Dostępne API (`src/bpp/models/uczelnia.py`): +`get_for_request(request)`, `get_for_pbn_background(uczelnia_id)` (`:144` — +rzuca `ValueError` gdy `None`, świadomie BEZ fallbacku), +`get_single_uczelnia_or_none()` (`:48`), `get_single_uczelnia_or_fail()` +(`:59`), `Uczelnia.pbn_client(token)` (`:936`, zwraca `BppPBNClient`). + +`src/bpp/models/profile.py`: `BppUser.pbn_token` (`CharField`, default `""`), +`get_pbn_user()` (`:207`) — zwraca `przedstawiaj_w_pbn_jako`, gdy ustawione, +inaczej `self`. Istotne dla konta technicznego (Task 05.4a). `src/bpp/models/abstract/pbn.py`: rekordy mają `pbn_uid = OneToOneField("pbn_api.Publication")` → `rec.pbn_uid_id` to PBN UID (string id publikacji w PBN). @@ -60,6 +178,14 @@ **Uzasadnienie (dlaczego NIE `api_response_status`):** `api_response_status` to swobodny `TextField` nadpisywany przy KAŻDEJ operacji (`mark_as_successful`/`mark_as_failed`/`create_or_update_before_upload` go czyszczą/ustawiają surową odpowiedzią API). Użycie go jako znacznika stanu byłoby kruche — pierwsza kolejna wysyłka by go skasowała, a parsowanie statusu z tekstu odpowiedzi PBN jest nieodporne. Dedykowane `withdrawn_at` (timestamp) daje: (1) jednoznaczny, kwerowalny stan "rekord wycofany w PBN dnia X", (2) audyt kiedy, (3) symetrię: restore→`WYSYLKA`→`mark_as_successful` musi je wyzerować (`withdrawn_at=None`). Wiersza `SentData` NIE kasujemy (zostaje dla re-matchingu przy restore i dla `SoftDeleteLog` w fazie 06). +⚠️ **Wycofanie jest per-uczelnia, nie globalne.** `SentData` ma FK `uczelnia` +i dwie uczelnie wysyłające ten sam rekord BPP mają DWA niezależne wiersze. +Wycofanie zleca konkretna uczelnia (`PBN_Export_Queue.uczelnia`), więc +`withdrawn_at` ustawiamy **tylko na jej wierszu** — każde dotknięcie +`SentData` w tej fazie przekazuje `uczelnia=` w dół. Wołanie +`get_for_rec(rec)` bez `uczelnia` przy ≥2 wierszach rzuci +`MultipleObjectsReturned`. + --- ## Tasks @@ -68,6 +194,11 @@ > Dodane 2026-08-06 (decyzja #16). **Rób to PRZED 05.3** — `withdraw_from_pbn()` > ma być cienkim wywołaniem tego prymitywu, nie własną implementacją. +> +> ⚠️ **Kolejność:** prymityw woła `SentData.objects.mark_as_withdrawn(...)`, +> którą dostarcza dopiero **Task 05.2**. Zrób więc 05.2 **przed** 05.0 +> (albo scal oba w jeden krok) — inaczej testy 05.0 padną na `AttributeError` +> z powodu, który nie jest przedmiotem testu. Cała logika wycofania mieszka w wolnostojącej funkcji, żeby wejście asynchroniczne (kolejka) i synchroniczne (`synchronizuj_publikacje`) @@ -79,30 +210,62 @@ zostawiały **identyczny** stan `SentData` i identyczną klasyfikację błędów modelu kolejki) - Test: `src/pbn_api/tests/test_wycofanie.py` -Kontrakt: +Kontrakt (PINNED — pozostałe taski cytują te nazwy): ```python -def wycofaj_oswiadczenia(publikacja, client) -> WynikWycofania: +class StatusWycofania(models.TextChoices): + WYCOFANO = "wycofano" # DELETE poszedł, oświadczenia usunięte + BRAK_OSWIADCZEN = "brak" # PBN: nie było czego usuwać — też sukces + POMINIETO = "pominieto" # brak pbn_uid — klienta w ogóle nie wołamy + + +@dataclass +class WynikWycofania: + status: StatusWycofania + komunikat: str + + +def wycofaj_oswiadczenia(publikacja, client, uczelnia=None) -> WynikWycofania: """Wycofuje oświadczenia dyscyplin publikacji z profilu instytucji PBN. - Gate: publikacja bez pbn_uid -> zwraca wynik POMINIETO (nie błąd). + Gate: publikacja bez pbn_uid -> WynikWycofania(POMINIETO) (nie błąd), + bez dotykania SentData i bez wołania klienta. Obiektu publikacji w PBN NIE kasujemy (jest współdzielony). - Aktualizuje SentData: submitted_successfully=False + withdrawn_at. + Po sukcesie aktualizuje SentData WIERSZA TEJ UCZELNI: + submitted_successfully=False + withdrawn_at (SentData.DoesNotExist -> + nie jest błędem, po prostu nie ma czego oznaczać). + + Wyjątki PBN (PraceSerwisowe / ResourceLocked / Http / …) PROPAGUJE — + klasyfikuje je wywołujący (kolejka: _handle_pbn_exception, ścieżka + synchroniczna: własna obsługa). Prymityw odpowiada za semantykę + „co znaczy sukces" i za stan SentData, NIE za politykę retry. """ ``` +> **Podział odpowiedzialności (ważne dla 05.3 i 05.9):** prymityw = gate + +> wywołanie klienta + idempotencja (`CannotDeleteStatementsException`) + +> `SentData`. Wywołujący = klasyfikacja wyjątku i decyzja o ponowieniu. +> Dzięki temu kolejka reużywa istniejące `_handle_pbn_exception` +> (`models.py:351`) zamiast dublować drabinkę `except`. + - [ ] **Krok 05.0.1 — testy obsługi wyjątków (wzorzec do przejęcia: - `src/pbn_wysylka_oswiadczen/tasks.py:54-76`).** Trzy przypadki, każdy - z innym wynikiem: - - `CannotDeleteStatementsException` → **SUKCES** (oświadczeń nie było — - stan docelowy osiągnięty). To NIE jest błąd; zaległa pułapka. + `src/pbn_wysylka_oswiadczen/tasks.py:54-76`).** Przypadki: + - `CannotDeleteStatementsException` → **SUKCES** `BRAK_OSWIADCZEN` + (oświadczeń nie było — stan docelowy osiągnięty), `SentData` oznaczone + jak przy `WYCOFANO`. To NIE jest błąd; zaległa pułapka. - `PraceSerwisoweException` → propaguj (retry ma sens, PBN w oknie - serwisowym). - - `HttpException` → błąd zaklasyfikowany, z treścią odpowiedzi. - - `pbn_uid is None` → POMINIETO, bez wołania klienta. + serwisowym); `SentData` NIE dotknięte. + - `HttpException` (w tym 423) → propaguj; `SentData` NIE dotknięte. + ⚠️ Zmiana względem pierwszej wersji planu („błąd zaklasyfikowany"): + klasyfikacja należy do wywołującego, inaczej kolejka miałaby dwie + rozjeżdżające się tabele wyjątków. + - `pbn_uid is None` → `POMINIETO`, bez wołania klienta i bez `SentData`. + - `SentData.DoesNotExist` (rekord ma `pbn_uid`, ale nigdy nie było wiersza) + → nadal `WYCOFANO`, bez wyjątku. - [ ] **Krok 05.0.2 — implementacja** + aktualizacja `SentData` wewnątrz - prymitywu (NIE u wywołującego). -- [ ] **Krok 05.0.3 — PASS** + commit. + prymitywu (NIE u wywołującego), z przekazaniem `uczelnia=` do + `mark_as_withdrawn`. +- [ ] **Krok 05.0.3 — PASS** + commit (jawne ścieżki). --- @@ -110,7 +273,8 @@ def wycofaj_oswiadczenia(publikacja, client) -> WynikWycofania: **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) +- `src/pbn_export_queue/migrations/0011_pbn_export_queue_operacja.py` (NOWA — + numer wg `ls src/pbn_export_queue/migrations/`; ostatnia dziś to `0010_*`) - Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` (NOWY) - [ ] **Failing test — pole `operacja` istnieje z defaultem `WYSYLKA`.** W nowym pliku `src/pbn_export_queue/tests/test_operacja_wycofanie.py`: @@ -153,10 +317,10 @@ def wycofaj_oswiadczenia(publikacja, client) -> WynikWycofania: 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. +- [ ] **Migracja:** `uv run python src/manage.py makemigrations pbn_export_queue --name pbn_export_queue_operacja`. Zweryfikuj, że plik to `0011_pbn_export_queue_operacja.py` (kolejny po `0010_atomowa_kolejka_pbn.py`) i dodaje wyłącznie pole `operacja` (`AddField`, default `wysylka`). NIE edytować wcześniejszych migracji. - [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_operacja_default_wysylka -x` → PASS. -- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/migrations/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"` +- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/migrations/0011_pbn_export_queue_operacja.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit** (jawne ścieżki): `git commit -m "feat(pbn_export_queue): pole operacja (WYSYLKA|WYCOFANIE) + migracja"` --- @@ -164,7 +328,8 @@ def wycofaj_oswiadczenia(publikacja, client) -> WynikWycofania: **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`) +- `src/pbn_api/migrations/0080_sentdata_withdrawn_at.py` (NOWA — numer wg + `makemigrations`; ostatnia dziś to `0079_constraint_publikacja_instytucji.py`) - Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` - [ ] **Failing test — `mark_as_withdrawn` ustawia stan, `mark_as_successful` go zeruje.** Dopisz do `test_operacja_wycofanie.py`: @@ -206,14 +371,19 @@ def wycofaj_oswiadczenia(publikacja, client) -> WynikWycofania: ``` - [ ] **Implementacja — manager `mark_as_withdrawn`.** W `SentDataManager` dodaj: ```python - def mark_as_withdrawn(self, rec, api_response_status=""): + def mark_as_withdrawn(self, rec, api_response_status="", uczelnia=None): """Oznacza rekord jako wycofany z PBN (oświadczenia usunięte). + ``uczelnia`` obowiązkowe w praktyce (multi-hosted): wycofanie + dotyczy profilu KONKRETNEJ uczelni, a przy ≥2 wierszach + ``get_for_rec`` bez niej rzuci MultipleObjectsReturned. Default + ``None`` tylko dla zgodności z resztą managera. + Wiersza SentData NIE kasujemy — zostaje dla audytu i re-matchingu przy restore. submitted_successfully=False, bo rekord nie jest już "wystawiony" w PBN. """ - sd = self.get_for_rec(rec) + sd = self.get_for_rec(rec, uczelnia) sd.submitted_successfully = False sd.withdrawn_at = timezone.now() if api_response_status: @@ -221,17 +391,40 @@ def wycofaj_oswiadczenia(publikacja, client) -> WynikWycofania: sd.save() return sd ``` + ⚠️ Sygnatura idzie **za** konwencją reszty `SentDataManager` + (`uczelnia=None` jako OSTATNI kwarg — `get_for_rec` `:23`, + `mark_as_successful` `:85`, `mark_as_failed` `:97`). Nie wymyślaj innej. - [ ] **Implementacja — symetria w `mark_as_successful`.** W `SentDataManager.mark_as_successful`, po `sd.submitted_successfully = True`, dodaj `sd.withdrawn_at = None` (restore→WYSYLKA czyści znacznik wycofania). (`timezone` jest już zaimportowany w pliku.) +- [ ] **Test symetrii per-uczelnia (dopisz):** dwa wiersze `SentData` dla + tego samego rekordu i dwóch uczelni; `mark_as_withdrawn(rec, + uczelnia=u1)` ustawia `withdrawn_at` **tylko** na wierszu `u1`, a wiersz + `u2` zostaje `submitted_successfully=True`. Bez tego testu regresja + „wycofanie jednej uczelni kasuje stan drugiej" przejdzie niezauważona. - [ ] **Migracja:** `uv run python src/manage.py makemigrations pbn_api --name sentdata_withdrawn_at`. Zweryfikuj, że dodaje wyłącznie pole `withdrawn_at`. - [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_sentdata_mark_as_withdrawn -x` → PASS. - [ ] **Lint:** `uv run ruff check src/pbn_api/models/sentdata.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_api/models/sentdata.py src/pbn_api/migrations/*sentdata_withdrawn_at*.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` -- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_api): SentData.withdrawn_at + mark_as_withdrawn; reset przy mark_as_successful"` +- [ ] **Commit** (jawne ścieżki): `git commit -m "feat(pbn_api): SentData.withdrawn_at + mark_as_withdrawn; reset przy mark_as_successful"` --- -### Task 05.3 — `withdraw_from_pbn()` + 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`). +### Task 05.3 — `withdraw_from_pbn()` (CIENKI wrapper na prymityw) + rozgałęzienie w `send_to_pbn()` + +> ⚠️ **Przepisane 2026-08-07.** Poprzednia wersja tego tasku: (a) łamała +> decyzję #16 i niezmiennik §4.2 specu — re-implementowała retry, wołała +> `delete_all_publication_statements` bezpośrednio i sama aktualizowała +> `SentData`, zamiast użyć prymitywu z 05.0; (b) kazała „przenieść blok +> `wysylke_podjeto`/`ilosc_prob`", którego w `send_to_pbn()` **nie ma** — +> zastąpił go `_zajmij_atomowo()` (`models.py:419`); (c) pozyskiwała uczelnię +> przez **nieistniejące** `Uczelnia.objects.get_default()`, co dodatkowo +> wywala guard-test multi-hosted. + +Gałąź `WYCOFANIE` **nie zawiera logiki wycofania**: pozyskuje klienta PBN, +woła `wycofaj_oswiadczenia()` (Task 05.0) i tłumaczy wynik/wyjątek na +`SendStatus` — wyjątki przez istniejące `_handle_pbn_exception` +(`ResourceLocked`→`RETRY_LATER`, `PraceSerwisowe`→`RETRY_MUCH_LATER`, +HTTP 423→`RETRY_LATER`, reszta→`error(...)`). `WYSYLKA` → ścieżka bez zmian. +Lock/`ilosc_prob`/`task_sprobuj_wyslac_do_pbn` działają niezmienione +(zwracamy ten sam typ `SendStatus`). **Files:** - `src/pbn_export_queue/models.py` (`send_to_pbn` rozgałęzienie; nowa `withdraw_from_pbn`; helper `_pozyskaj_klienta_pbn`) @@ -240,7 +433,9 @@ Gałąź `WYCOFANIE` woła `client.delete_all_publication_statements(pbn_uid)` z - [ ] **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): + def test_wycofanie_wola_delete_all_statements( + wydawnictwo_ciagle, admin_user, uczelnia + ): from pbn_api.models import Publication from pbn_api.models.sentdata import SentData @@ -252,12 +447,14 @@ Gałąź `WYCOFANIE` woła `client.delete_all_publication_statements(pbn_uid)` z data_sent={}, submitted_successfully=True, uploaded_okay=True, + uczelnia=uczelnia, ) wpis = baker.make( PBN_Export_Queue, rekord_do_wysylki=wydawnictwo_ciagle, zamowil=admin_user, + uczelnia=uczelnia, operacja=PBN_Export_Queue.Operacja.WYCOFANIE, wysylke_zakonczono=None, ) @@ -280,114 +477,103 @@ Gałąź `WYCOFANIE` woła `client.delete_all_publication_statements(pbn_uid)` z ``` (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`): +- [ ] **Implementacja — helper klienta.** W `PBN_Export_Queue` dodaj metodę pozyskania klienta (wzorzec z `pbn_wysylka_oswiadczen/tasks.py::get_pbn_client` — uczelnia JAWNA, nigdy „domyślna"): ```python def _pozyskaj_klienta_pbn(self): - """Buduje klienta PBN dla użytkownika, który zlecił operację. + """Buduje klienta PBN dla TEGO wpisu kolejki. + + Uczelnia z FK wpisu (``self.uczelnia``) — NIE „domyślna": taki + byt nie istnieje (dawne API „uczelni domyślnej" zostało trwale + usunięte i jest pilnowane guardem + ``test_multihosted_get_default_guard.py``). Dla wpisów legacy + (``uczelnia_id is None``) jedyny dozwolony fallback to + „jedyna-albo-głośny-błąd". - Analogicznie do sprobuj_wyslac_do_pbn_celery: token z konta PBN - zamawiającego, klient z parametrów Uczelni. + Token: z konta PBN zamawiającego (``get_pbn_user()`` respektuje + ``przedstawiaj_w_pbn_jako``) — jak w wysyłce. """ from bpp.models import Uczelnia pbn_user = self.zamowil.get_pbn_user() - uczelnia = Uczelnia.objects.get_default() + uczelnia = self.uczelnia or Uczelnia.objects.get_single_uczelnia_or_fail() 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): + ⚠️ **Nie** wołaj tu `Uczelnia.objects.get_default()` ani + `Uczelnia.objects.first()` — oba są zakazane guard-testem multi-hosted + (`src/bpp/tests/test_multihosted_get_default_guard.py`). ⚠️ Guard skanuje + pliki `.py` **regexem, razem z komentarzami i docstringami** — nie + przepisuj tych dwóch nazw nawet do dokumentacji w kodzie (dlatego + docstring wyżej mówi opisowo „dawne API uczelni domyślnej"). +- [ ] **Implementacja — `withdraw_from_pbn` jako CIENKI wrapper.** Cała + logika (retry-semantyka, idempotencja, `SentData`) siedzi w prymitywie + z 05.0; tutaj wyłącznie klient → prymityw → `SendStatus`: ```python def withdraw_from_pbn(self): - """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 + """Gałąź WYCOFANIE — cienkie wywołanie prymitywu (decyzja #16). - from pbn_api.exceptions import ( - CannotDeleteStatementsException, - ResourceLockedException, - ) + NIE woła klienta PBN bezpośrednio i NIE dotyka SentData — robi to + wycofaj_oswiadczenia(), wspólne z wejściem synchronicznym (05.9). + Tu tylko: pozyskanie klienta, wywołanie prymitywu i tłumaczenie + wyniku/wyjątku na SendStatus. Klasyfikacja wyjątków PBN idzie + przez wspólne _handle_pbn_exception (ResourceLocked → RETRY_LATER, + PraceSerwisowe → RETRY_MUCH_LATER, HTTP 423 → RETRY_LATER, …). - 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 + :return: SendStatus + """ + from pbn_api.wycofanie import wycofaj_oswiadczenia try: client = self._pozyskaj_klienta_pbn() except Exception as exc: return self._handle_pbn_exception(exc) - 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 + wynik = wycofaj_oswiadczenia( + self.rekord_do_wysylki, client, uczelnia=self.uczelnia + ) + except Exception as exc: + zaloguj_polkniety_wyjatek( + "Błąd podczas wycofywania oświadczeń z PBN z kolejki " + f"eksportu (PBN_Export_Queue pk={self.pk})", + logger=logger, + do_rollbar=False, # Rollbar w _handle_pbn_exception + ) + return self._handle_pbn_exception(exc) self.wysylke_zakonczono = timezone.now() self.zakonczono_pomyslnie = True - self.dopisz_komunikat( - f"Wycofano oświadczenia dyscyplin z PBN (UID={pbn_uid})." - ) + self.dopisz_komunikat(wynik.komunikat) 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: + (`POMINIETO` też kończy wpis sukcesem — prymityw zwraca wtedy komunikat + „rekord nie ma PBN UID". To gate obronny: `zakolejkuj_wycofanie` takich + wpisów nie tworzy.) +- [ ] **Implementacja — rozgałęzienie w `send_to_pbn`.** ⚠️ **Niczego nie + przenosimy.** Wstaw DOKŁADNIE dwie linie między `_zajmij_atomowo()` + (`models.py:469-472`) a importem `sprobuj_wyslac_do_pbn_celery` + (`models.py:474`): ```python - if not self.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 not self._zajmij_atomowo(): + # Inny worker zdążył zająć ten wiersz (row lock) albo go zakończył. + # On dokończy — bieżące zadanie kończymy bez ponawiania. + return SendStatus.LOCKED_ELSEWHERE if self.operacja == self.Operacja.WYCOFANIE: return self.withdraw_from_pbn() + + from bpp.admin.helpers.pbn_api.cli import sprobuj_wyslac_do_pbn_celery ``` - (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.) + Dzięki umieszczeniu PO `_zajmij_atomowo()` wycofanie dziedziczy za darmo: + guard „rekord zniknął", licznik `ilosc_prob`, `wysylke_podjeto` i ochronę + przed dwoma workerami. Reszta `send_to_pbn` — ścieżka WYSYLKA — bez zmian. - [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_wola_delete_all_statements -x` → PASS. +- [ ] **Guard multi-hosted (obowiązkowy po tym tasku):** + `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -x` → + PASS (pilnuje, że `_pozyskaj_klienta_pbn` nie wprowadził + `get_default()`/`first()`). - [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` -- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_export_queue): withdraw_from_pbn + gałąź WYCOFANIE w send_to_pbn"` +- [ ] **Commit** (jawne ścieżki): `git commit -m "feat(pbn_export_queue): withdraw_from_pbn + gałąź WYCOFANIE w send_to_pbn"` --- @@ -401,18 +587,21 @@ Upewnij się, że dodanie gałęzi WYCOFANIE nie zmieniło ścieżki WYSYLKA: wp - [ ] **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): + def test_wysylka_nie_wola_delete_all_statements( + wydawnictwo_ciagle, admin_user, uczelnia + ): wpis = baker.make( PBN_Export_Queue, rekord_do_wysylki=wydawnictwo_ciagle, zamowil=admin_user, + uczelnia=uczelnia, operacja=PBN_Export_Queue.Operacja.WYSYLKA, wysylke_zakonczono=None, ) sent_data = MagicMock() - with patch.object(admin_user, "get_pbn_user"), patch( - "pbn_export_queue.models.PBN_Export_Queue._pozyskaj_klienta_pbn" + with patch.object( + PBN_Export_Queue, "_pozyskaj_klienta_pbn" ) as mock_klient, patch( "bpp.admin.helpers.pbn_api.cli.sprobuj_wyslac_do_pbn_celery", return_value=(sent_data, ["ok"]), @@ -421,33 +610,243 @@ Upewnij się, że dodanie gałęzi WYCOFANIE nie zmieniło ścieżki WYSYLKA: wp assert result == SendStatus.FINISHED_OKAY mock_send.assert_called_once() + # ścieżka WYSYLKA przekazuje uczelnię wpisu (multi-hosted) + assert mock_send.call_args.kwargs["uczelnia"] == uczelnia mock_klient.assert_not_called() ``` + Uwagi do patchowania (zweryfikowane w kodzie): + - `sprobuj_wyslac_do_pbn_celery` jest importowane **wewnątrz** + `send_to_pbn()` (`models.py:474`), więc patchujemy je w module + źródłowym `bpp.admin.helpers.pbn_api.cli` — NIE w `pbn_export_queue.models`. + - **Nie** patchuj `admin_user.get_pbn_user` przez `patch.object(admin_user, …)`: + `send_to_pbn()` robi `refresh_from_db()` i sięga po `self.zamowil`, czyli + po INNĄ instancję użytkownika. Bez patcha `get_pbn_user()` po prostu + zwraca `self` (`profile.py:207`) i nic nie woła po sieci. + - `_zajmij_atomowo()` używa `select_for_update` — test musi być + `@pytest.mark.django_db` (transakcja pytest-django wystarcza). - [ ] **Komenda:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wysylka_nie_wola_delete_all_statements -x`. Jeśli przechodzi od razu — to dowód regresyjny, że ścieżka WYSYLKA jest nietknięta; zostaw test jako guard (nie wymaga zmian implementacji). Jeśli FAIL — popraw rozgałęzienie w 05.3, by WYSYLKA nie wpadała w gałąź wycofania. - [ ] **Lint:** `uv run ruff check src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/tests/test_operacja_wycofanie.py` -- [ ] **Commit:** `git add -A && git commit -m "test(pbn_export_queue): guard regresyjny — WYSYLKA nie woła delete_all_statements"` +- [ ] **Commit** (jawne ścieżki): `git commit -m "test(pbn_export_queue): guard regresyjny — WYSYLKA nie woła delete_all_statements"` + +--- + +### Task 05.4a — Konto techniczne dla `zamowil` + rozróżnienie `IntegrityError` + +> **Dodane 2026-08-07. To NIE jest kosmetyka — to cichy błąd czekający na +> uruchomienie fazy.** Spec §4.2 przypisywał konto techniczne do „fazy +> 05/06", plan 05 pisał „rozwiązuje faza 06/07", plan 06 pisał „to dług fazy +> 05" — czyli **nikt tego nie robił**. Przypinamy jawnie do **fazy 05**, +> bo to ta faza dostarcza `zakolejkuj_*`. + +**Na czym polega cichy błąd (zweryfikowany w kodzie):** +`PBN_Export_Queue.zamowil` jest NOT NULL (`models.py:101`), a +`sprobuj_utowrzyc_wpis` łapie **każdy** `IntegrityError` i przemianowuje go +na `AlreadyEnqueuedError` (`models.py:53-63`). Systemowy soft-delete +publikacji z `pbn_uid` (sygnał, celery, merge — bez `request.user`) trafi +więc w naruszenie NOT NULL na `zamowil`, dostanie „już w kolejce", +`zakolejkuj_wycofanie` zwróci `None`, `SoftDeleteLog.pbn_status` zostanie +pusty — a **oświadczenia w PBN nigdy nie zostaną wycofane**. Operator +zobaczy komunikat sugerujący, że wszystko jest w porządku. + +**Files:** +- `src/pbn_export_queue/operacje.py` (NOWY — wspólny z Taskiem 05.5: + `pobierz_konto_techniczne()`) +- `src/pbn_export_queue/models.py` (`sprobuj_utowrzyc_wpis`: rozróżnienie + wyjątku) +- Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` + +- [ ] **Failing test — prawdziwy `IntegrityError` NIE udaje „już w kolejce".** + ```python + @pytest.mark.django_db + def test_integrity_error_zamowil_nie_udaje_already_enqueued( + wydawnictwo_ciagle, + ): + from django.db import IntegrityError + + # zamowil = NULL łamie NOT NULL, a NIE unikat aktywnego wpisu — + # to MUSI polecieć w górę jako IntegrityError, inaczej systemowe + # wycofanie z PBN cicho zniknie pod komunikatem "już w kolejce". + with pytest.raises(IntegrityError): + PBN_Export_Queue.objects.sprobuj_utowrzyc_wpis( + None, wydawnictwo_ciagle + ) + ``` +- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_integrity_error_zamowil_nie_udaje_already_enqueued -x` + → FAIL: podnosi się `AlreadyEnqueuedError` zamiast `IntegrityError`. +- [ ] **Implementacja — rozróżnienie w `sprobuj_utowrzyc_wpis`.** W + `src/pbn_export_queue/models.py`, przy managerze: + ```python + #: Nazwa częściowego unikatu z Meta.constraints — jedyny IntegrityError, + #: który wolno przetłumaczyć na domenowe „już w kolejce". + NAZWA_UNIKATU_AKTYWNEGO_WPISU = "pbn_export_queue_jeden_aktywny_wpis_na_rekord" + + + def _to_kolizja_aktywnego_wpisu(exc): + """Czy ten IntegrityError NAPRAWDĘ znaczy „już w kolejce"? + + psycopg wystawia nazwę naruszonego ograniczenia w + ``exc.__cause__.diag.constraint_name``; gdy jej nie ma (inny + sterownik/backend) — fallback na tekst wyjątku. Tłumaczenie „w + ciemno" połykało naruszenie NOT NULL na ``zamowil`` (operacja + systemowa bez usera) i zamieniało brak wycofania oświadczeń w PBN + w niewinny komunikat „już w kolejce". + """ + diag = getattr(getattr(exc, "__cause__", None), "diag", None) + nazwa = getattr(diag, "constraint_name", None) + if nazwa: + return nazwa == NAZWA_UNIKATU_AKTYWNEGO_WPISU + return NAZWA_UNIKATU_AKTYWNEGO_WPISU in str(exc) + ``` + a w samym `except IntegrityError as e:` (`models.py:60-63`): + ```python + except IntegrityError as e: + if not _to_kolizja_aktywnego_wpisu(e): + raise + raise AlreadyEnqueuedError( + "ten rekord jest już w kolejce do wysyłki" + ) from e + ``` +- [ ] **Regresja istniejącego zachowania:** + `uv run pytest src/pbn_export_queue/tests/test_pbn_queue_atomicity.py src/pbn_export_queue/tests/test_pbn_queue_manager.py -x` + → PASS. Wyścig na aktywnym wpisie MUSI nadal dawać `AlreadyEnqueuedError` + (`test_sprobuj_utowrzyc_wpis_wyscig_daje_alreadyenqueued`). +- [ ] **Failing test — konto techniczne dla operacji bez usera.** + ```python + @pytest.mark.django_db + def test_zakolejkuj_wycofanie_bez_usera_uzywa_konta_technicznego( + wydawnictwo_ciagle, uczelnia + ): + from pbn_api.models import Publication + from pbn_export_queue.operacje import ( + NAZWA_KONTA_TECHNICZNEGO, + zakolejkuj_wycofanie, + ) + + wydawnictwo_ciagle.pbn_uid = baker.make(Publication) + wydawnictwo_ciagle.save() + + with patch("pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn"): + wpis = zakolejkuj_wycofanie( + wydawnictwo_ciagle, user=None, uczelnia=uczelnia + ) + + assert wpis is not None, ( + "systemowy soft-delete MUSI utworzyć wpis wycofania — " + "zwrot None znaczy, że oświadczenia zostaną w PBN" + ) + assert wpis.zamowil.username == NAZWA_KONTA_TECHNICZNEGO + assert wpis.operacja == PBN_Export_Queue.Operacja.WYCOFANIE + ``` +- [ ] **Implementacja — `pobierz_konto_techniczne()`** w + `src/pbn_export_queue/operacje.py`: + ```python + #: Login konta używanego jako ``zamowil`` przy operacjach systemowych. + NAZWA_KONTA_TECHNICZNEGO = "bpp-system" + + + def pobierz_konto_techniczne(): + """Konto ``zamowil`` dla operacji bez zalogowanego użytkownika. + + ``PBN_Export_Queue.zamowil`` jest NOT NULL, a soft-delete zlecony + sygnałem/celery nie ma requestu. Świadomie NIE robimy ``zamowil`` + nullable (spec §4.2) — psułoby to założenia kolejki i raportów. + + Konto jest nieaktywne i bez hasła: ma istnieć jako podmiot audytu, + nie jako sposób logowania. + """ + from django.contrib.auth import get_user_model + + user, utworzono = get_user_model().objects.get_or_create( + username=NAZWA_KONTA_TECHNICZNEGO, + defaults={ + "first_name": "Konto", + "last_name": "systemowe BPP", + "is_active": False, + "is_staff": False, + "is_superuser": False, + }, + ) + if utworzono: + user.set_unusable_password() + user.save(update_fields=["password"]) + return user + ``` +- [ ] ⚠️ **Krok obowiązkowy — token PBN konta technicznego.** Konto + techniczne nie ma `pbn_token` (`BppUser.pbn_token` default `""`), więc + `_pozyskaj_klienta_pbn()` → `uczelnia.pbn_client("")` → + `UczelniaTransport.authorize` rzuci `WillNotExportError` + (`src/bpp/models/uczelnia.py:936+`). **To jest akceptowalne tylko dlatego, + że kończy się GŁOŚNO** (`_handle_pbn_exception` → `error(..., + MERYTORYCZNY)`), a nie cicho. Wymagane: + - dopisz test: wpis WYCOFANIE zamówiony przez konto techniczne **bez** + tokenu kończy się `SendStatus.FINISHED_ERROR` z komunikatem wskazującym + konfigurację (NIE `FINISHED_OKAY`, NIE cichym `None`); + - udokumentuj obejście produkcyjne: administrator ustawia kontu + technicznemu `przedstawiaj_w_pbn_jako` na konto z ważnym tokenem PBN + (`get_pbn_user()` — `profile.py:207` — sam podmieni użytkownika, żaden + kod się nie zmienia). +- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -k "konta_technicznego or integrity_error" -x` → PASS. +- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py src/pbn_export_queue/operacje.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/operacje.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit** (jawne ścieżki): `git commit -m "fix(pbn_export_queue): konto techniczne dla zamowil + IntegrityError nie udaje AlreadyEnqueued"` +- [ ] **Newsfragment:** `src/bpp/newsfragments/soft-delete-konto-techniczne.bugfix.rst` + — „Systemowe (bez zalogowanego użytkownika) usunięcie publikacji nie + powodowało już cichego pominięcia wycofania oświadczeń z PBN." --- ### Task 05.5 — Funkcje zakolejkowujące + gate na `pbn_uid` -`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. +> ⚠️ **Kontrakt PRZYPIĘTY 2026-08-07 — jedno miejsce, jedna sygnatura.** +> Poprzednia wersja tego tasku dostarczała **metody managera** +> (`PBN_Export_Queue.objects.zakolejkuj_wycofanie(...)`), podczas gdy faza 06 +> sprawdza istnienie **funkcji modułowych** (`uv run python -c "from +> pbn_export_queue.operacje import zakolejkuj_wycofanie, zakolejkuj_wysylke"`, +> plan 06 Task 5) i tak je woła w receiverach. Check fazy 06 nigdy by nie +> przeszedł → powstałby shim → dwie rozbieżne implementacje. +> **Wybór: funkcje modułowe w `src/pbn_export_queue/operacje.py`** (wersja +> fazy 06 — plan 06 NIE wymaga zmian). Manager dostaje tylko rozszerzenie +> `sprobuj_utowrzyc_wpis(..., operacja=...)`; metod `zakolejkuj_*` na +> managerze **nie dodajemy** (jedno miejsce prawdy). +> +> Konsekwencja dla fazy 06: jej **Task 5 (shim) należy POMINĄĆ** — ten task +> go zastępuje. Shim z planu 06 tworzy wpisy przez gołe +> `PBN_Export_Queue.objects.create(...)`, więc omija `sprobuj_utowrzyc_wpis` +> (TOCTOU, `uczelnia`, `operacja`) — gdyby powstał wcześniej, MUSI zniknąć +> przy scalaniu, nie zostać obok. + +`zakolejkuj_wycofanie(instance, user=None, uczelnia=None)` i +`zakolejkuj_wysylke(instance, user=None, uczelnia=None)` — publiczne funkcje +modułowe, wołane potem z fazy 06 (receivery sygnałów). Tworzą wpis przez +`sprobuj_utowrzyc_wpis` (z odpowiednią `operacja`) i delegują do +`task_sprobuj_wyslac_do_pbn.delay(pk)`. Gate: wycofanie tylko gdy +`instance.pbn_uid_id` ustawione (brak PBN UID → no-op, brak wpisu). +Idempotencja: jeśli rekord już w kolejce (`AlreadyEnqueuedError`) → `None`. +`user=None` → konto techniczne (Task 05.4a), **nigdy** `zamowil=None`. +Trzeci kwarg `uczelnia` jest opcjonalny i domyślnie `None`, więc wywołania +z fazy 06 (`zakolejkuj_wycofanie(instance, user)`) pozostają poprawne. **Files:** -- `src/pbn_export_queue/models.py` (manager `PBN_Export_QueueManager`: metody `zakolejkuj_wycofanie`, `zakolejkuj_wysylke`; rozszerz `sprobuj_utowrzyc_wpis` o argument `operacja`) +- `src/pbn_export_queue/operacje.py` (funkcje `zakolejkuj_wycofanie`, + `zakolejkuj_wysylke` — ten sam plik co `pobierz_konto_techniczne` + z Taska 05.4a) +- `src/pbn_export_queue/models.py` (rozszerz `sprobuj_utowrzyc_wpis` + o argument `operacja` — **bez** przepisywania metody) - Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` -- [ ] **Failing test — gate + utworzenie wpisu WYCOFANIE.** Dopisz: +- [ ] **Failing test — gate + utworzenie wpisu WYCOFANIE.** Dopisz + (patch celuje w `pbn_export_queue.tasks…`, bo tam realnie żyje symbol; + `operacje.py` importuje go lokalnie w funkcji, żeby nie robić cyklu — + `tasks.py` importuje `models`): ```python @pytest.mark.django_db def test_zakolejkuj_wycofanie_gate_brak_pbn_uid(wydawnictwo_ciagle, admin_user): + from pbn_export_queue.operacje import zakolejkuj_wycofanie + assert wydawnictwo_ciagle.pbn_uid_id is None with patch( - "pbn_export_queue.models.task_sprobuj_wyslac_do_pbn" + "pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn" ) as mock_task: - wpis = PBN_Export_Queue.objects.zakolejkuj_wycofanie( - wydawnictwo_ciagle, user=admin_user - ) + wpis = zakolejkuj_wycofanie(wydawnictwo_ciagle, user=admin_user) assert wpis is None assert PBN_Export_Queue.objects.filter_rekord_do_wysylki( wydawnictwo_ciagle @@ -456,33 +855,37 @@ Upewnij się, że dodanie gałęzi WYCOFANIE nie zmieniło ścieżki WYSYLKA: wp @pytest.mark.django_db - def test_zakolejkuj_wycofanie_tworzy_wpis(wydawnictwo_ciagle, admin_user): + def test_zakolejkuj_wycofanie_tworzy_wpis( + wydawnictwo_ciagle, admin_user, uczelnia + ): from pbn_api.models import Publication + from pbn_export_queue.operacje import zakolejkuj_wycofanie pub = baker.make(Publication) wydawnictwo_ciagle.pbn_uid = pub wydawnictwo_ciagle.save() with patch( - "pbn_export_queue.models.task_sprobuj_wyslac_do_pbn" + "pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn" ) as mock_task: - wpis = PBN_Export_Queue.objects.zakolejkuj_wycofanie( - wydawnictwo_ciagle, user=admin_user + wpis = zakolejkuj_wycofanie( + wydawnictwo_ciagle, user=admin_user, uczelnia=uczelnia ) assert wpis is not None assert wpis.operacja == PBN_Export_Queue.Operacja.WYCOFANIE + assert wpis.uczelnia == uczelnia mock_task.delay.assert_called_once_with(wpis.pk) @pytest.mark.django_db def test_zakolejkuj_wysylke_tworzy_wpis(wydawnictwo_ciagle, admin_user): + from pbn_export_queue.operacje import zakolejkuj_wysylke + with patch( - "pbn_export_queue.models.task_sprobuj_wyslac_do_pbn" + "pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn" ) as mock_task: - wpis = PBN_Export_Queue.objects.zakolejkuj_wysylke( - wydawnictwo_ciagle, user=admin_user - ) + wpis = zakolejkuj_wysylke(wydawnictwo_ciagle, user=admin_user) assert wpis is not None assert wpis.operacja == PBN_Export_Queue.Operacja.WYSYLKA mock_task.delay.assert_called_once_with(wpis.pk) @@ -491,99 +894,148 @@ Upewnij się, że dodanie gałęzi WYCOFANIE nie zmieniło ścieżki WYSYLKA: wp @pytest.mark.django_db def test_zakolejkuj_idempotentne(wydawnictwo_ciagle, admin_user): from pbn_api.models import Publication + from pbn_export_queue.operacje import zakolejkuj_wycofanie wydawnictwo_ciagle.pbn_uid = baker.make(Publication) wydawnictwo_ciagle.save() - with patch("pbn_export_queue.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 - ) + with patch("pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn"): + first = zakolejkuj_wycofanie(wydawnictwo_ciagle, user=admin_user) + second = zakolejkuj_wycofanie(wydawnictwo_ciagle, user=admin_user) assert first is not None assert second is None assert PBN_Export_Queue.objects.filter_rekord_do_wysylki( wydawnictwo_ciagle ).count() == 1 ``` -- [ ] **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`): + ⚠️ **Gate `pbn_uid` w `zakolejkuj_wysylke`:** faza 06 pisze w kontrakcie + PINNED „None gdy brak pbn_uid" **dla obu** funkcji, ale restore rekordu, + który nigdy nie poszedł do PBN, i tak powinien go wysłać (wysyłka sama + nadaje `pbn_uid`). **Rozstrzygnięcie: `zakolejkuj_wysylke` NIE ma gate'u + na `pbn_uid`** — gate ma tylko wycofanie. Docstring w kodzie musi to + mówić wprost, żeby nikt nie „naprawił" tego pod docstring z planu 06. +- [ ] **Komenda + FAIL:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -k zakolejkuj -x` → `ModuleNotFoundError`/`ImportError` (`pbn_export_queue.operacje`). +- [ ] **Implementacja — rozszerz `sprobuj_utowrzyc_wpis` o `operacja`.** + ⚠️ **Minimalna zmiana istniejącej metody (`models.py:42-63`) — NIE + przepisywać jej.** Zachować MUSZĄ: parametr `uczelnia`, savepoint + `transaction.atomic()` i tłumaczenie kolizji unikatu (z rozróżnieniem + z Taska 05.4a). Dopisujemy wyłącznie `operacja`: ```python - def _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): + def sprobuj_utowrzyc_wpis(self, user, rekord, uczelnia=None, operacja=None): + # Szybka ścieżka (przyjazny błąd bez trafiania w constraint bazy). if self.filter_rekord_do_wysylki(rekord).exists(): - raise AlreadyEnqueuedError( - "ten rekord jest już w kolejce do wysyłki" - ) - kwargs = {"rekord_do_wysylki": rekord, "zamowil": user} + raise AlreadyEnqueuedError("ten rekord jest już w kolejce do wysyłki") + + kwargs = { + "rekord_do_wysylki": rekord, + "zamowil": user, + "uczelnia": uczelnia, + } if operacja is not None: kwargs["operacja"] = operacja - 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 + # (komentarz o TOCTOU — zostawić bez zmian) 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. + with transaction.atomic(): + return self.create(**kwargs) + except IntegrityError as e: + if not _to_kolizja_aktywnego_wpisu(e): # Task 05.4a + raise + raise AlreadyEnqueuedError( + "ten rekord jest już w kolejce do wysyłki" + ) from e + ``` + (`operacja` opcjonalna → wszystkie dotychczasowe wywołania — + `bpp/admin/helpers/pbn_api/gui.py:92`, `pbn_export_queue/tasks.py:165`, + `przemapuj_zrodlo/views.py:159`, `przemapuj_zrodla_pbn/views.py:509` — + działają bez zmian i dostają default modelu `WYSYLKA`.) +- [ ] **Implementacja — `zakolejkuj_wycofanie` / `zakolejkuj_wysylke`** + w `src/pbn_export_queue/operacje.py` (funkcje MODUŁOWE — kontrakt fazy 06): + ```python + def _zakolejkuj(instance, operacja, user=None, uczelnia=None): + from pbn_api.exceptions import AlreadyEnqueuedError + + from pbn_export_queue import tasks + from pbn_export_queue.models import PBN_Export_Queue + + try: + wpis = PBN_Export_Queue.objects.sprobuj_utowrzyc_wpis( + user or pobierz_konto_techniczne(), + instance, + uczelnia=uczelnia, + operacja=operacja, + ) + except AlreadyEnqueuedError: + # Rekord czeka już w kolejce — idempotencja, nie błąd. + return None + tasks.task_sprobuj_wyslac_do_pbn.delay(wpis.pk) + return wpis + + + def zakolejkuj_wysylke(instance, user=None, uczelnia=None): + """Tworzy wpis WYSYLKA i uruchamia wysyłkę w tle. + + Wołane m.in. przy restore publikacji (faza 06). BEZ gate'u na + pbn_uid — rekord bez PBN UID też ma prawo pojechać do PBN (wysyłka + dopiero go nada). Idempotentne: gdy rekord już w kolejce → None. + user=None → konto techniczne (zamowil jest NOT NULL). + """ + return _zakolejkuj( + instance, + PBN_Export_Queue.Operacja.WYSYLKA, + user=user, + uczelnia=uczelnia, + ) - 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 + def zakolejkuj_wycofanie(instance, user=None, uczelnia=None): + """Tworzy wpis WYCOFANIE i uruchamia wycofanie w tle. + + Gate: tylko gdy rekord ma PBN UID (inaczej nic nie poszło do PBN — + no-op, zwraca None). Idempotentne: gdy rekord już w kolejce → None. + user=None → konto techniczne (zamowil jest NOT NULL); NIGDY nie + przekazujemy None do zamowil, bo IntegrityError udawałby wtedy + „już w kolejce" i wycofanie zniknęłoby po cichu. + """ + if not getattr(instance, "pbn_uid_id", None): + return None + return _zakolejkuj( + instance, + PBN_Export_Queue.Operacja.WYCOFANIE, + user=user, + uczelnia=uczelnia, + ) ``` - (`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.) + (Import `PBN_Export_Queue` na poziomie modułu jest OK — `operacje.py` + nie jest importowane przez `models.py`; cykl groziłby tylko przy + `tasks`, dlatego `tasks` importujemy w środku funkcji.) +- [ ] **Check zgodności z fazą 06:** + `uv run python -c "from pbn_export_queue.operacje import zakolejkuj_wycofanie, zakolejkuj_wysylke"` + → bez błędu (to dokładnie ten check, który wykonuje Task 5 planu 06, + żeby pominąć swój shim). - [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py -k zakolejkuj -x` → PASS. -- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py src/pbn_export_queue/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"` +- [ ] **Lint:** `uv run ruff check src/pbn_export_queue/models.py src/pbn_export_queue/operacje.py src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/models.py src/pbn_export_queue/operacje.py src/pbn_export_queue/tests/test_operacja_wycofanie.py` +- [ ] **Commit** (jawne ścieżki): `git commit -m "feat(pbn_export_queue): zakolejkuj_wycofanie/zakolejkuj_wysylke + gate pbn_uid"` --- ### Task 05.6 — Idempotencja/retry wycofania (ResourceLocked + CannotDelete) +> Oba testy sprawdzają zachowanie, które po przepisaniu 05.3 pochodzi z +> **dwóch różnych warstw**: `CannotDeleteStatementsException` obsługuje +> prymityw (05.0, sukces `BRAK_OSWIADCZEN`), a `ResourceLockedException` +> klasyfikuje wspólne `_handle_pbn_exception` (`models.py:299`). Gdy któryś +> padnie — popraw TĘ warstwę, nie dokładaj drugiej drabinki `except` +> w `withdraw_from_pbn`. + **Files:** - Test path: `src/pbn_export_queue/tests/test_operacja_wycofanie.py` - [ ] **Failing test — `CannotDeleteStatementsException` traktowany jak sukces.** Dopisz: ```python @pytest.mark.django_db - def test_wycofanie_brak_oswiadczen_to_sukces(wydawnictwo_ciagle, admin_user): + def test_wycofanie_brak_oswiadczen_to_sukces( + wydawnictwo_ciagle, admin_user, uczelnia + ): from pbn_api.exceptions import CannotDeleteStatementsException from pbn_api.models import Publication from pbn_api.models.sentdata import SentData @@ -595,11 +1047,13 @@ Upewnij się, że dodanie gałęzi WYCOFANIE nie zmieniło ścieżki WYSYLKA: wp data_sent={}, submitted_successfully=True, uploaded_okay=True, + uczelnia=uczelnia, ) wpis = baker.make( PBN_Export_Queue, rekord_do_wysylki=wydawnictwo_ciagle, zamowil=admin_user, + uczelnia=uczelnia, operacja=PBN_Export_Queue.Operacja.WYCOFANIE, wysylke_zakonczono=None, ) @@ -617,15 +1071,17 @@ Upewnij się, że dodanie gałęzi WYCOFANIE nie zmieniło ścieżki WYSYLKA: wp wpis.refresh_from_db() assert wpis.zakonczono_pomyslnie is True assert SentData.objects.get_for_rec( - wydawnictwo_ciagle + wydawnictwo_ciagle, uczelnia ).withdrawn_at is not None ``` -- [ ] **Komenda:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_brak_oswiadczen_to_sukces -x` → powinien przejść (logika z 05.3 traktuje `CannotDeleteStatementsException` jako break/sukces). Jeśli FAIL — dopracuj `withdraw_from_pbn`. +- [ ] **Komenda:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_brak_oswiadczen_to_sukces -x` → powinien przejść (prymityw z 05.0 zwraca `BRAK_OSWIADCZEN` = sukces). Jeśli FAIL — popraw **prymityw**, nie `withdraw_from_pbn`. - [ ] **Failing test — `ResourceLockedException` → RETRY_LATER, bez oznaczenia SentData.** Dopisz: ```python @pytest.mark.django_db - def test_wycofanie_locked_retry_later(wydawnictwo_ciagle, admin_user): + def test_wycofanie_locked_retry_later( + wydawnictwo_ciagle, admin_user, uczelnia + ): from pbn_api.exceptions import ResourceLockedException from pbn_api.models import Publication from pbn_api.models.sentdata import SentData @@ -637,11 +1093,13 @@ Upewnij się, że dodanie gałęzi WYCOFANIE nie zmieniło ścieżki WYSYLKA: wp data_sent={}, submitted_successfully=True, uploaded_okay=True, + uczelnia=uczelnia, ) wpis = baker.make( PBN_Export_Queue, rekord_do_wysylki=wydawnictwo_ciagle, zamowil=admin_user, + uczelnia=uczelnia, operacja=PBN_Export_Queue.Operacja.WYCOFANIE, wysylke_zakonczono=None, ) @@ -660,12 +1118,15 @@ Upewnij się, że dodanie gałęzi WYCOFANIE nie zmieniło ścieżki WYSYLKA: wp # wycofanie nie zakończone — zostanie ponowione assert wpis.wysylke_zakonczono is None assert SentData.objects.get_for_rec( - wydawnictwo_ciagle + wydawnictwo_ciagle, uczelnia ).withdrawn_at is None ``` -- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_locked_retry_later -x` → PASS (logika z 05.3 zwraca `RETRY_LATER` na `ResourceLockedException` bez ustawiania `wysylke_zakonczono`). Jeśli FAIL — popraw kolejność `except` w `withdraw_from_pbn`. +- [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_wycofanie_locked_retry_later -x` → PASS (prymityw propaguje `ResourceLockedException`, a `_handle_pbn_exception` → `_handle_retry_exception` (`models.py:299`) zwraca `RETRY_LATER` bez ustawiania `wysylke_zakonczono`). Jeśli FAIL — sprawdź, czy prymityw nie połyka wyjątku. +- [ ] **Test dopełniający — `PraceSerwisoweException` → `RETRY_MUCH_LATER`.** + Ta sama konstrukcja; dowodzi, że wycofanie dziedziczy CAŁĄ tabelę + klasyfikacji z wysyłki, a nie tylko dwa przypadki. - [ ] **Lint:** `uv run ruff check src/pbn_export_queue/tests/test_operacja_wycofanie.py && uv run ruff format src/pbn_export_queue/tests/test_operacja_wycofanie.py` -- [ ] **Commit:** `git add -A && git commit -m "test(pbn_export_queue): wycofanie — idempotencja CannotDelete + retry ResourceLocked"` +- [ ] **Commit** (jawne ścieżki): `git commit -m "test(pbn_export_queue): wycofanie — idempotencja CannotDelete + retry ResourceLocked"` --- @@ -686,10 +1147,10 @@ Drobne wsparcie operacyjne: pokaż operację na liście kolejki, by superuser od 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`. +- [ ] **Implementacja — admin.** W `src/pbn_export_queue/admin.py`: dodaj `"operacja"` do `list_display` (`:55`, np. zaraz po `"rekord_do_wysylki"`), do `list_filter` (`:68` — tam pierwszy element to `ZamowilUniqueFilter`, więc `"operacja"` dopisz PO nim) i do `readonly_fields` (`:76`). - [ ] **Komenda + PASS:** `uv run pytest src/pbn_export_queue/tests/test_operacja_wycofanie.py::test_admin_pokazuje_operacje -x` → PASS. - [ ] **Lint:** `uv run ruff check src/pbn_export_queue/admin.py && uv run ruff format src/pbn_export_queue/admin.py` -- [ ] **Commit:** `git add -A && git commit -m "feat(pbn_export_queue): admin pokazuje kolumnę/filtr operacja"` +- [ ] **Commit** (jawne ścieżki): `git commit -m "feat(pbn_export_queue): admin pokazuje kolumnę/filtr operacja"` --- @@ -707,7 +1168,12 @@ Rekord bywa wysyłany do PBN bez kolejki — ta sama ścieżka musi umieć wycof - [ ] **Krok 05.9.1 — ustal realny zbiór ścieżek synchronicznych.** Nie zgaduj; wypisz wywołujących i rozstrzygnij, które z nich mogą wystąpić w kontekście - soft-delete (management command? admin action? import?). + soft-delete (management command? admin action? import?). Stan na 2026-08-07 + (`grep`): `synchronizuj_publikacje` definiowana w + `src/pbn_integrator/utils/synchronization.py:180`, wołana z + `src/pbn_integrator/management/commands/pbn_uploader.py:12` i + `src/pbn_integrator/management/commands/pbn_integrator.py:392` (oba to + komendy CLI) — zweryfikuj, czy nie doszło nic nowego. - [ ] **Krok 05.9.2 — test: ścieżka synchroniczna woła prymityw i zostawia `SentData` w tym samym stanie co kolejka.** Kluczowa asercja — **równoważność obu wejść**: @@ -726,6 +1192,13 @@ Rekord bywa wysyłany do PBN bez kolejki — ta sama ścieżka musi umieć wycof ```bash grep -rn --include='*.py' "delete_all_publication_statements" src/ | grep -v tests ``` + Baseline (2026-08-07, przed fazą 05) — dokładnie te 3 wiersze produkcyjne: + `pbn_api/management/commands/pbn_test_wysylka_interaktywna.py:453`, + `pbn_api/management/commands/pbn_wyslij_oswiadczenia_instytucji.py:192`, + `pbn_wysylka_oswiadczen/tasks.py:68`. **Po fazie 05 mogą dojść wyłącznie + wystąpienia w prymitywie** — żadne w `pbn_export_queue/` ani + `pbn_integrator/`. (`pbn_client` jest w site-packages, więc się tu nie + pokaże — to poprawne.) --- @@ -734,15 +1207,55 @@ Rekord bywa wysyłany do PBN bez kolejki — ta sama ścieżka musi umieć wycof **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). +- [ ] **Testy prymitywu:** `uv run pytest src/pbn_api/tests/test_wycofanie.py -x` → PASS. +- [ ] **Regresja całego app kolejki + sentdata:** `uv run pytest src/pbn_export_queue/ src/pbn_api/tests/ -q` → zielono (gałąź WYCOFANIE nie zepsuła istniejących testów wysyłki/managerów/admina; szczególnie `test_pbn_queue_atomicity.py` i `test_pbn_queue_manager.py` po zmianie `sprobuj_utowrzyc_wpis`). +- [ ] **Guardy multi-hosted (obowiązkowo — ta faza dotyka klienta PBN i `SentData`):** + `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py src/pbn_api/tests/test_multihosted.py -q` → zielono. +- [ ] **Baseline po migracjach (`0011` w kolejce + `0080` w pbn_api):** + `make baseline-update`, commit `baseline-sql/baseline.sql` + + `baseline-sql/baseline.meta.json`. ⚠️ Wg CLAUDE.md: **nie** odświeżaj + baseline w równoległych feature-branchach — jeśli gałąź soft-delete ma + inne otwarte fazy z migracjami, zrób to RAZ przy scalaniu. - [ ] **Brak driftu migracji:** `uv run python src/manage.py makemigrations --check --dry-run` → "No changes detected". -- [ ] **Lint całości fazy:** `uv run ruff check src/pbn_export_queue/ src/pbn_api/models/sentdata.py` → czysto. -- [ ] **Commit (jeśli cokolwiek dopięte):** `git add -A && git commit -m "chore(soft-delete): faza 05 PBN wycofanie — weryfikacja końcowa"` +- [ ] **Lint całości fazy:** `uv run ruff check src/pbn_export_queue/ src/pbn_api/models/sentdata.py src/pbn_api/wycofanie.py` → czysto. +- [ ] **Newsfragment fazy:** `src/bpp/newsfragments/soft-delete-pbn-wycofanie.feature.rst` + — „Usunięcie publikacji wysłanej do PBN wycofuje jej oświadczenia dyscyplin + z profilu instytucji; przywrócenie publikacji wysyła je ponownie." +- [ ] **Commit (jeśli cokolwiek dopięte, jawne ścieżki):** `git commit -m "chore(soft-delete): faza 05 PBN wycofanie — weryfikacja końcowa"` --- ## Podsumowanie zakresu (co ta faza dostarcza fazie 06) +- Prymityw `pbn_api.wycofanie.wycofaj_oswiadczenia(publikacja, client, + uczelnia=None) -> WynikWycofania` — JEDYNE miejsce wołające + `delete_all_publication_statements` w kontekście soft-delete. - `PBN_Export_Queue.Operacja` (`WYSYLKA`/`WYCOFANIE`) + pole `operacja` (default `WYSYLKA`). -- `PBN_Export_Queue.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`. +- **`pbn_export_queue.operacje.zakolejkuj_wycofanie(instance, user=None, + uczelnia=None)` i `zakolejkuj_wysylke(instance, user=None, uczelnia=None)` + — FUNKCJE MODUŁOWE** (nie metody managera), publiczny kontrakt dla + receiverów fazy 06 (`post_soft_delete`→wycofanie, `post_restore`→wysyłka). + Gate `pbn_uid_id` **tylko** dla wycofania; obie idempotentne. + ⇒ **Faza 06 pomija swój Task 5 (shim)** — te funkcje już istnieją pod + ścieżką, której szuka jej check. +- `pbn_export_queue.operacje.pobierz_konto_techniczne()` + + `NAZWA_KONTA_TECHNICZNEGO` — `zamowil` dla operacji systemowych + (`user=None`), zamiast cichego `IntegrityError` udającego „już w kolejce". + **To zamyka dług, który spec §4.2 i plany 05/06 przerzucały między sobą.** +- Po udanym wycofaniu: `SentData.withdrawn_at` ustawione, + `submitted_successfully=False`, wiersz NIE skasowany — **na wierszu TEJ + uczelni**. Restore→WYSYLKA→`mark_as_successful` zeruje `withdrawn_at`. + +## Otwarte / do zgłoszenia poza tą fazą + +- **Spec §4.1 i indeks 00 cytują martwą ścieżkę** + `src/pbn_api/client/mixins/institutions.py:87` (katalog zawiera dziś tylko + `__pycache__`; kod jest w pakiecie `pbn-client`, plik + `pbn_client/mixins/institutions.py:87`). Spec §4.2 wskazuje też + `retry w pbn_api/client/publication_sync.py`, gdzie + `_delete_statements_with_retry` już nie mieszka. Poprawka specu/indeksu — + poza zakresem tego planu (inny właściciel plików). +- **Plan 06** nie wymaga zmian sygnatur (przypięliśmy jego wariant), ale jego + **Task 5 (shim `operacje.py`) jest teraz martwy** i jego uwaga + „`zamowil`… **To dług fazy 05**" jest już spełniona przez Task 05.4a — + warto to tam odnotować przy okazji. From 7571add2dc6af795d1622d2443f83a15b6711770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 11:24:20 +0200 Subject: [PATCH 43/75] feat(soft-delete): ostatnio_zmieniony bumpowany przy delete() i restore() Soft-delete jest modyfikacja rekordu i tak musi wygladac dla kazdego konsumenta przyrostowego (OAI-PMH, CERIF, REST API). Do tej pory znacznik stal w miejscu: pakiet django-soft-delete zapisuje przez save(update_fields=['deleted_at','restored_at','transaction_id']), a Django wola Field.pre_save() -- czyli jedyne miejsce, w ktorym zyje auto_now -- wylacznie dla pol obecnych w update_fields. Punkt zaczepienia to save(), NIE delete()/restore(): kaskada i ksiegowosc transaction_id siedza w kodzie pakietu, a pakiet w obu sciezkach wola self.save() z wlasna lista pol. Doklejenie sie do save() obsluguje kazda sciezke pakietu (instancja, QuerySet.delete(), kaskada one-to-many, deleted_objects.restore()) i nie kopiuje ani grama logiki pakietu. Helper dopisz_znacznik_zmiany() dopisuje nazwe pola tylko gdy w update_fields jest deleted_at (zwykly czesciowy zapis zostaje nietkniety -- podbijanie auto_now przy dowolnym update_fields rozjechaloby sie m.in. z DENORM_DISABLE_AUTOTIME_DURING_FLUSH) i tylko gdy model to pole naprawde ma (sprawdzane przez _meta, nie hasattr) -- faza 04 obejmie Autor, a modele bez znacznika, jak Element_Repozytorium, nie moga sie na tym wywrocic. Praktyczny zysk: nagrobki dla harvestu przyrostowego sa odpytywalne od reki, bez czekania na SoftDeleteLog z fazy 06: Model.deleted_objects.filter(ostatnio_zmieniony__gte=X) Test test_a2_* przypina osobne ustalenie: soft-delete autorstwa NIE podbija znacznika publikacji. Denorm faktycznie ja przelicza (kontrola pozytywna na opis_bibliograficzny_zapisani_autorzy_cache), ale przy DENORM_DISABLE_AUTOTIME_DURING_FLUSH=True buduje update_fields jawnie wykluczajace pola auto_now. To zachowanie prekursorskie wobec soft-delete -- identycznie dziala hard-delete autorstwa na dev -- wiec nie jest regresja tej fazy; naprawa dotykalaby denorma i jest osobna decyzja. Bez migracji: zmiana jest wylacznie na poziomie Pythona (makemigrations bpp --check --dry-run czysto). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- src/bpp/models/soft_delete.py | 87 ++++++++ .../soft-delete-autorstwo.feature.rst | 3 + .../test_ostatnio_zmieniony.py | 187 ++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 src/bpp/tests/test_soft_delete/test_ostatnio_zmieniony.py diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py index 47be9d8b4..1e759ec72 100644 --- a/src/bpp/models/soft_delete.py +++ b/src/bpp/models/soft_delete.py @@ -23,8 +23,21 @@ ``BppAutorstwoSoftDeleteMixin`` są PINNED — Task 1 zdefiniował pierwszą trójkę, Task 2 (runda poprawek 2) dopisał ``BppDeletedQuerySet``/ ``BppDeletedManager`` jako część tego samego kontraktu. + +INWARIANT (dot. ``ostatnio_zmieniony``): soft-delete JEST modyfikacją +rekordu, więc ``ostatnio_zmieniony`` MUSI być bumpowany przy ``delete()`` +ORAZ ``restore()`` — tak, jak wygląda to dla każdego konsumenta +przyrostowego (OAI-PMH, CERIF, REST API). Realizują to +``POLE_ZNACZNIKA_ZMIANY`` + ``dopisz_znacznik_zmiany()`` + +``BppAutorstwoSoftDeleteMixin.save()`` — również PINNED (kontrakt dopisany +2026-08-07, obowiązuje też fazy 02 i 04). Dzięki niemu nagrobki dla +harvestu przyrostowego są odpytywalne od ręki, bez ``SoftDeleteLog`` +z fazy 06:: + + Model.deleted_objects.filter(ostatnio_zmieniony__gte=X) """ +from django.core.exceptions import FieldDoesNotExist from django_softdelete.managers import ( DeletedManager, DeletedQuerySet, @@ -34,6 +47,60 @@ ) from django_softdelete.models import SoftDeleteModel +#: Nazwa pola-znacznika czasu modyfikacji. Ta sama w +#: ``BazaModeluOdpowiedzialnosciAutorow`` (``abstract/authors.py``) i w +#: ``ModelZAdnotacjami`` (``abstract/metadata.py``), więc jedna stała +#: obsługuje autorstwa (faza 01), publikacje (faza 02) i ``Autor`` (04). +POLE_ZNACZNIKA_ZMIANY = "ostatnio_zmieniony" + + +def dopisz_znacznik_zmiany(instance, update_fields): + """Dokłada ``ostatnio_zmieniony`` do ``update_fields`` zapisu, który + ustawia ``deleted_at`` — czyli soft-delete ALBO restore: pakiet w obu + ścieżkach woła ``save(update_fields=['deleted_at', 'restored_at', + 'transaction_id'])``. + + DLACZEGO PRZEZ ``save()``, a nie przez nadpisanie ``delete()``/ + ``restore()``: kaskada i księgowość transakcji siedzą w kodzie + pakietu, a ten woła ``self.save()`` z własną listą pól. Doklejenie + się do ``save()`` obsługuje KAŻDĄ ścieżkę pakietu (instancja, + queryset, kaskada one-to-many) i nie kopiuje ani grama logiki + pakietu — przeżyje więc jego aktualizację. + + DLACZEGO SAMO DOPISANIE NAZWY WYSTARCZA: ``Model._save_table()`` + najpierw zawęża listę pól do zapisu przez ``update_fields``, a + dopiero na tak zawężonej liście woła ``Field.pre_save()``. + ``DateTimeField.pre_save()`` to jedyne miejsce, w którym ``auto_now`` + podstawia ``timezone.now()`` — pole nieobecne w ``update_fields`` + nie jest więc bumpowane, a obecne jest. (Zweryfikowane testem + ``test_ostatnio_zmieniony.py``, nie tylko lekturą źródeł Django.) + + Model bez pola ``ostatnio_zmieniony`` (np. ``Element_Repozytorium``, + a docelowo część modeli z faz 02/04) dostaje ``update_fields`` + nietknięte — dopisanie nieistniejącej nazwy wywaliłoby ``save()`` + na ``ValueError``. + """ + if not update_fields: + # Pełny zapis (``update_fields=None``) i tak przepuszcza wszystkie + # pola przez ``pre_save()`` — ``auto_now`` zadziała samo. + return update_fields + + nazwy = set(update_fields) + if "deleted_at" not in nazwy: + # Zwykły częściowy zapis — nie nasza sprawa. Podbijanie znacznika + # przy KAŻDYM ``update_fields`` zmieniłoby semantykę daleko poza + # soft-deletem (np. denorm celowo wyklucza pola ``auto_now``). + return update_fields + if POLE_ZNACZNIKA_ZMIANY in nazwy: + return update_fields + + try: + instance._meta.get_field(POLE_ZNACZNIKA_ZMIANY) + except FieldDoesNotExist: + return update_fields + + return list(update_fields) + [POLE_ZNACZNIKA_ZMIANY] + class BppSoftDeleteQuerySet(SoftDeleteQuerySet): """Gate: blokuje bulk-ustawienie deleted_at/restored_at przez .update() @@ -119,6 +186,26 @@ class BppAutorstwoSoftDeleteMixin(SoftDeleteModel): class Meta: abstract = True + def save(self, *args, **kwargs): + """Soft-delete i restore MUSZĄ podbić ``ostatnio_zmieniony``. + + Pakiet zapisuje obie operacje przez ``save(update_fields=[ + 'deleted_at', 'restored_at', 'transaction_id'])``. Django woła + ``pre_save()`` (a więc i ``auto_now``) tylko dla pól obecnych w + ``update_fields``, więc bez dopisania nazwy znacznik stałby w + miejscu — a soft-delete jest modyfikacją rekordu i tak musi + wyglądać dla konsumentów przyrostowych (OAI-PMH, CERIF, REST API). + + Czytamy ``update_fields`` wyłącznie z ``kwargs``: pakiet (i cały + kod BPP) przekazuje je nazwanie, a przekazanie pozycyjne jest w + Django deprecated. Klucza nie wstrzykujemy, gdy go nie było — + pełny zapis ma zostać pełnym zapisem. + """ + update_fields = kwargs.get("update_fields") + if update_fields: + kwargs["update_fields"] = dopisz_znacznik_zmiany(self, update_fields) + return super().save(*args, **kwargs) + def restore( self, strict: bool = False, transaction_id: str = None, *args, **kwargs ): diff --git a/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst index 5a9635b7e..b6d6d43bc 100644 --- a/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst +++ b/src/bpp/newsfragments/soft-delete-autorstwo.feature.rst @@ -6,5 +6,8 @@ z materializowanego cache'u bibliografii, więc nie pokazują się na stronach publikacji, w liczbie publikacji autora, w podpowiedziach wyszukiwarki, w eksporcie BibTeX, w listach panelu administracyjnego ani w zestawieniach oświadczeń wysyłanych do PBN. +Skasowanie i przywrócenie przypisania aktualizuje jego znacznik „ostatnio +zmieniony", dzięki czemu systemy pobierające dane przyrostowo widzą taką +operację jako zwykłą modyfikację rekordu. Zmiana jest na razie wewnętrzna i niewidoczna dla użytkowników — pełne wsparcie (panel administracyjny, przywracanie) wdrażają kolejne etapy. diff --git a/src/bpp/tests/test_soft_delete/test_ostatnio_zmieniony.py b/src/bpp/tests/test_soft_delete/test_ostatnio_zmieniony.py new file mode 100644 index 000000000..9e09b9503 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_ostatnio_zmieniony.py @@ -0,0 +1,187 @@ +"""``ostatnio_zmieniony`` przy soft-delete (kontrakt PINNED, faza 01). + +Soft-delete JEST modyfikacją rekordu i tak musi wyglądać dla każdego +konsumenta przyrostowego (OAI-PMH, CERIF, REST API). Pakiet +``django-soft-delete`` zapisuje przez +``save(update_fields=['deleted_at','restored_at','transaction_id'])``, +a Django woła ``Field.pre_save()`` WYŁĄCZNIE dla pól obecnych w +``update_fields`` — więc bez naszej interwencji ``auto_now`` na +``ostatnio_zmieniony`` się nie odpala i znacznik czasu stoi w miejscu. + +Praktyczny zysk z bumpa: lista nagrobków „co skasowano od daty X" to +``Model.deleted_objects.filter(ostatnio_zmieniony__gte=X)`` — bez +czekania na ``SoftDeleteLog`` z fazy 06. +""" + +import datetime + +import pytest +from django.utils import timezone + +from bpp.models.soft_delete import dopisz_znacznik_zmiany +from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor + + +def _znacznik(pk): + """Wartość ``ostatnio_zmieniony`` PROSTO Z BAZY (nie z instancji w + pamięci) — inaczej test mógłby przejść na zaktualizowanym atrybucie + Pythona przy niezapisanej kolumnie.""" + return Wydawnictwo_Ciagle_Autor.global_objects.values_list( + "ostatnio_zmieniony", flat=True + ).get(pk=pk) + + +def _cofnij_znacznik(pk, o_ile=datetime.timedelta(hours=1)): + """Cofa ``ostatnio_zmieniony`` w bazie o zadany czas, omijając + ``auto_now`` (bulk ``update()`` nie woła ``pre_save``). Dzięki temu + asercja „wzrósł" nie zależy od rozdzielczości zegara.""" + stary = timezone.now() - o_ile + # Gate z BppSoftDeleteQuerySet blokuje wyłącznie deleted_at/restored_at. + Wydawnictwo_Ciagle_Autor.global_objects.filter(pk=pk).update( + ostatnio_zmieniony=stary + ) + return stary + + +@pytest.mark.django_db +def test_soft_delete_bumpuje_ostatnio_zmieniony(wydawnictwo_ciagle_z_autorem): + """delete() (soft) MUSI podbić ``ostatnio_zmieniony`` wiersza. + + Wyrocznia: gdyby override ``save()`` w ``BppAutorstwoSoftDeleteMixin`` + zniknął, ``update_fields`` pakietu nie zawierałoby + ``ostatnio_zmieniony``, ``auto_now`` by nie wystrzeliło i znacznik + zostałby na cofniętej wartości. + """ + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + stary = _cofnij_znacznik(wca.pk) + + Wydawnictwo_Ciagle_Autor.objects.get(pk=wca.pk).delete() + + assert _znacznik(wca.pk) > stary + + +@pytest.mark.django_db +def test_restore_bumpuje_ostatnio_zmieniony(wydawnictwo_ciagle_z_autorem): + """restore() też jest modyfikacją — i też MUSI podbić znacznik.""" + wca = wydawnictwo_ciagle_z_autorem.autorzy_set.first() + Wydawnictwo_Ciagle_Autor.objects.get(pk=wca.pk).delete() + + po_delete = _cofnij_znacznik(wca.pk) + + Wydawnictwo_Ciagle_Autor.global_objects.get(pk=wca.pk).restore() + + assert _znacznik(wca.pk) > po_delete + + +@pytest.mark.django_db +def test_nagrobki_odpytywalne_po_ostatnio_zmieniony( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """Podstawa nagrobków dla harvestu przyrostowego: + ``deleted_objects.filter(ostatnio_zmieniony__gte=okno)`` zwraca to, co + skasowano W OKNIE — i nic więcej. + + Wyrocznia: bez bumpa skasowany wiersz miałby znacznik sprzed okna i + wypadłby z wyniku (pierwsza asercja). Gdyby z kolei bump wyciekał na + wiersze nietknięte, upadłaby asercja o liczności. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + pierwszy, drugi = wc.autorzy_set.all()[:2] + + # Oba wiersze „stare"; skasowany dostanie świeży znacznik od delete(). + _cofnij_znacznik(pierwszy.pk) + _cofnij_znacznik(drugi.pk) + + okno_od = timezone.now() + Wydawnictwo_Ciagle_Autor.objects.get(pk=pierwszy.pk).delete() + + nagrobki = Wydawnictwo_Ciagle_Autor.deleted_objects.filter( + ostatnio_zmieniony__gte=okno_od + ) + assert list(nagrobki.values_list("pk", flat=True)) == [pierwszy.pk] + + # Nietknięty wiersz nie udaje nagrobka ani nie dostał bumpa. + assert not Wydawnictwo_Ciagle_Autor.deleted_objects.filter(pk=drugi.pk).exists() + assert _znacznik(drugi.pk) < okno_od + + +def test_dopisz_znacznik_zmiany_model_bez_pola(): + """Faza 04 obejmie ``Autor``; helper nie może zakładać, że pole + ``ostatnio_zmieniony`` w ogóle istnieje na modelu. + + ``Element_Repozytorium`` jest ``SoftDeleteModel`` i NIE ma tego pola — + helper musi oddać ``update_fields`` bez zmian (a nie rzucić + ``FieldDoesNotExist`` ani dopisać nieistniejącej kolumny, co + wywaliłoby ``save()`` na ``ValueError``). + """ + from bpp.models.repozytorium import Element_Repozytorium + + obj = Element_Repozytorium() + pola = ["deleted_at", "restored_at", "transaction_id"] + assert dopisz_znacznik_zmiany(obj, pola) == pola + + +def test_dopisz_znacznik_zmiany_nie_rusza_zwyklego_zapisu(): + """Zwykły ``save(update_fields=['kolejnosc'])`` NIE jest soft-deletem — + helper nie ma prawa dokładać tam znacznika (auto_now i tak zadziała + dopiero gdy pole jest w update_fields; podbijanie go przy KAŻDYM + częściowym zapisie zmieniłoby semantykę poza soft-deletem).""" + obj = Wydawnictwo_Ciagle_Autor() + assert dopisz_znacznik_zmiany(obj, ["kolejnosc"]) == ["kolejnosc"] + # ... a pełny zapis (update_fields=None) auto_now obsługuje sam. + assert dopisz_znacznik_zmiany(obj, None) is None + + +@pytest.mark.django_db +def test_a2_soft_delete_autorstwa_a_znacznik_publikacji( + denorms, wydawnictwo_ciagle_z_dwoma_autorami +): + """ZADANIE A2 — stan PRZYPIĘTY, nie postulat. + + Pytanie: czy soft-delete *autorstwa* podbija ``ostatnio_zmieniony`` + PUBLIKACJI (pośrednio, przez przeliczenie denormem + ``opis_bibliograficzny_cache``)? + + Odpowiedź: NIE. BPP ma ``DENORM_DISABLE_AUTOTIME_DURING_FLUSH = True`` + (``src/django_bpp/settings/base.py``), przez co + ``denorm.denorms._build_save_kwargs()`` buduje ``update_fields`` + JAWNIE WYKLUCZAJĄCE pola ``auto_now``. Flush denorma nigdy nie + dotyka ``ostatnio_zmieniony`` publikacji. + + To NIE jest regresja fazy 01 — dokładnie tak samo zachowuje się + hard-delete autorstwa na ``dev``. Test przypina fakt, żeby zmiana + (celowa albo przypadkowa) była widoczna. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + denorms.flush() + + def _stan_publikacji(): + return Wydawnictwo_Ciagle.objects.values_list( + "ostatnio_zmieniony", + "opis_bibliograficzny_zapisani_autorzy_cache", + ).get(pk=wc.pk) + + stary = timezone.now() - datetime.timedelta(hours=1) + Wydawnictwo_Ciagle.objects.filter(pk=wc.pk).update(ostatnio_zmieniony=stary) + _, opis_przed = _stan_publikacji() + + wca = wc.autorzy_set.first() + Wydawnictwo_Ciagle_Autor.objects.get(pk=wca.pk).delete() + denorms.flush() + + znacznik_po, opis_po = _stan_publikacji() + + # Kontrola pozytywna: denorm NAPRAWDĘ przeliczył publikację (skasowany + # autor wypadł z opisu). Bez tej asercji test przechodziłby także w + # scenariuszu „denorm w ogóle nie zauważył soft-delete", a to byłoby + # zupełnie inne (i groźniejsze) znalezisko. + assert opis_po != opis_przed, ( + "Denorm nie przeliczył publikacji po soft-delete autorstwa — " + "to inny problem niż A2, patrz denorm_always_only na *_Autor." + ) + + # ... a mimo zapisu publikacji znacznik NIE drgnął. + assert znacznik_po == stary, ( + "Znacznik publikacji zmienił się — zachowanie denorma/auto_now " + "uległo zmianie; zweryfikuj wnioski A2 przed zmianą tego testu." + ) From 8c283275ff5fea88d94db441d24c813bb570abc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 11:24:51 +0200 Subject: [PATCH 44/75] docs(soft-delete): kontrakt PINNED o ostatnio_zmieniony + korekta specu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overview dostaje kontrakt PINNED wprost: ostatnio_zmieniony MUSI byc bumpowany przy delete() I restore(), z podanym wzorcem realizacji (POLE_ZNACZNIKA_ZMIANY + dopisz_znacznik_zmiany() + override save()), zeby fazy 02 i 04 dostaly go z urodzenia, a nie jako retrofit. Dopisany takze mechanizm, dlaczego samo dopisanie nazwy do update_fields wystarcza (_save_table zawezania liste PRZED pre_save, auto_now zyje w pre_save). Druga notka w overview przypina ustalenie A2: ostatnio_zmieniony PUBLIKACJI nie drga przy soft-delete autorstwa, bo DENORM_DISABLE_AUTOTIME_DURING_FLUSH kaze denormowi budowac update_fields wykluczajace pola auto_now. Denorm publikacje przelicza (potwierdzone kontrola pozytywna w tescie), tylko znacznika nie rusza. Zachowanie prekursorskie wobec soft-delete, nie regresja fazy 01, ale realne ograniczenie harvestu przyrostowego po stronie publikacji -- do decyzji w fazie 02/06. Spec fazy 01 opisywal brak bumpa jako neutralny efekt uboczny update_fields. To byl blad oceny; §2.1 dostaje jawna korekte z data, zeby czytelnik tabeli "dwa falszywe zalozenia" nie wyciagnal z niej nieaktualnego wniosku. Sama teza o bramce WHEN triggera pozostaje w mocy -- ostatnio_zmieniony nie zasila widoku i nie ma prawa byc w bramce. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../2026-06-04-soft-delete-00-overview.md | 45 ++++++++++++++++++- ...soft-delete-publikacje-i-autorzy-design.md | 12 +++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md index 76468a60f..2d70a2d98 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md @@ -86,7 +86,50 @@ Szczegóły i uzasadnienie: §2.1 specu. z `strict=False`** (albo pisze własne, jak mixin publikacji w fazie 02). Dotyczy fazy 01 (`BppAutorstwoSoftDeleteMixin`) i fazy 04 (`Autor` — ma FK do `Tytul`, `Funkcja_Autora` itd., więc trafi na to samo). -- ⚠️ **`delete()` zapisuje przez `save(update_fields=['deleted_at','restored_at','transaction_id'])`** — to jest przyczyna, dla której bramka `WHEN` triggera cache nie przepuszcza soft-delete (i dla której `ostatnio_zmieniony`/`auto_now` NIE jest bumpowany). +- ⚠️ **`delete()` (i `restore()`) zapisuje przez `save(update_fields=['deleted_at','restored_at','transaction_id'])`** — to jest przyczyna, dla której bramka `WHEN` triggera cache nie przepuszcza soft-delete, oraz dla której `ostatnio_zmieniony`/`auto_now` NIE byłby bumpowany bez naszej interwencji (patrz kontrakt niżej). +- 🔴 **KONTRAKT PINNED: `ostatnio_zmieniony` MUSI być bumpowany przy `delete()` I `restore()`.** + Decyzja właściciela projektu (2026-08-07), **korekta** wcześniejszej oceny ze + specu fazy 01 (traktował brak bumpa jako neutralny efekt uboczny). Soft-delete + jest **modyfikacją rekordu** i tak musi wyglądać dla każdego konsumenta + przyrostowego (OAI-PMH, CERIF, REST API). Zysk praktyczny: nagrobki + („co skasowano od daty X") są odpytywalne od ręki, bez czekania na + `SoftDeleteLog` z fazy 06: + ```python + Model.deleted_objects.filter(ostatnio_zmieniony__gte=X) + ``` + **Realizacja (PINNED, faza 01 — fazy 02 i 04 dziedziczą/powtarzają wzorzec):** + `src/bpp/models/soft_delete.py` udostępnia + `POLE_ZNACZNIKA_ZMIANY = "ostatnio_zmieniony"` oraz + `dopisz_znacznik_zmiany(instance, update_fields)`; mixin nadpisuje `save()`: + ```python + def save(self, *args, **kwargs): + update_fields = kwargs.get("update_fields") + if update_fields: + kwargs["update_fields"] = dopisz_znacznik_zmiany(self, update_fields) + return super().save(*args, **kwargs) + ``` + Zaczep jest w `save()`, **nie** w `delete()`/`restore()` — kaskada i księgowość + transakcji siedzą w kodzie pakietu, a ten w obu ścieżkach woła `self.save()` + z własną listą pól; nie kopiujemy więc ani grama logiki pakietu. + Helper dopisuje nazwę **tylko** gdy `update_fields` zawiera `deleted_at` + (zwykły częściowy zapis zostaje nietknięty) i **tylko** gdy model ma to pole + (`_meta.get_field` → `FieldDoesNotExist`) — faza 04 (`Autor`) i modele bez + znacznika (np. `Element_Repozytorium`) nie mogą się na tym wywrócić. + Dlaczego samo dopisanie nazwy wystarcza: `Model._save_table()` zawęża listę + pól przez `update_fields` **przed** wywołaniem `Field.pre_save()`, a + `auto_now` żyje wyłącznie w `pre_save()`. Zweryfikowane testem + (`src/bpp/tests/test_soft_delete/test_ostatnio_zmieniony.py`), nie lekturą. +- ℹ️ **`ostatnio_zmieniony` PUBLIKACJI nie drga przy soft-delete autorstwa** + (ustalone empirycznie 2026-08-07, test `test_a2_soft_delete_autorstwa_a_znacznik_publikacji`). + Denorm **przelicza** publikację (`opis_bibliograficzny_cache` itd. — potwierdzone + kontrolą pozytywną), ale BPP ma `DENORM_DISABLE_AUTOTIME_DURING_FLUSH = True` + (`src/django_bpp/settings/base.py`), więc `denorm.denorms._build_save_kwargs()` + buduje `update_fields` **jawnie wykluczające** pola `auto_now`. To zachowanie + **prekursorskie wobec soft-delete** — identycznie działa hard-delete autorstwa + na `dev` — więc nie jest regresją fazy 01. Konsekwencja dla harvestu + przyrostowego po stronie publikacji: zmiana samego składu autorów nie podnosi + znacznika rekordu nadrzędnego. Jeśli faza 02/06 tego potrzebuje, to jest + osobna decyzja dotykająca denorma, nie soft-delete. ### Nowy moduł `src/bpp/models/soft_delete.py` (tworzy faza 01) ```python diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md index d02634f6d..ff1ac23e2 100644 --- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md +++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md @@ -133,6 +133,18 @@ WYŁĄCZNIE triggerami** — nie ma innej ścieżki zapisu. | „UPDATE ustawiający `deleted_at` doleci do triggera" | **Nie doleci.** `django-soft-delete` zapisuje przez `save(update_fields=['deleted_at','restored_at','transaction_id'])`. Żadna z tych kolumn nie jest w bramce (i być nie może — nie zasilają widoku), więc `WHEN` jest fałszywe. Efekt uboczny `update_fields`: `ostatnio_zmieniony` (`auto_now`) też **nie** jest bumpowany, więc nie ma przypadkowego ratunku. | | „Filtr `deleted_at IS NULL` w widoku wystarczy, bo trigger robi bezwarunkowy `DELETE` przed upsertem" | **Nie robi.** `INSERT ... SELECT` z widoku, który odfiltrował wiersz, wybiera zero wierszy → **no-op** → stary wiersz przeżywa w `_mat`. Inwariant „delete-first" nie istnieje dla `bpp_rekord_mat`. | +> ⚠️ **KOREKTA (2026-08-07) do zdania o `ostatnio_zmieniony` w pierwszym +> wierszu tabeli.** Ten spec traktował brak bumpa `ostatnio_zmieniony` jako +> neutralny efekt uboczny. **To był błąd oceny.** Decyzja właściciela projektu: +> soft-delete jest modyfikacją rekordu, więc `ostatnio_zmieniony` **MUSI** się +> zmieniać — przy `delete()` **i** przy `restore()`. Faza 01 realizuje to przez +> `dopisz_znacznik_zmiany()` + override `save()` w `BppAutorstwoSoftDeleteMixin` +> (`src/bpp/models/soft_delete.py`); pełny kontrakt PINNED siedzi w +> `docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md`. +> Dla triggera nic się nie zmienia: bramka `WHEN` nadal musi znać `deleted_at` +> (`ostatnio_zmieniony` nie zasila widoku i nie ma prawa być w bramce), więc +> zdanie „nie ma przypadkowego ratunku" pozostaje prawdziwe. + **Wzorzec do naśladowania jest już w kodzie.** Gałąź doktorat/habilitacja w `_create_rekord_function` robi dokładnie to, czego potrzebujemy — bo tam wiersz **może wypaść ze źródła** (widok `*_autorzy` ma INNER JOIN do From 0fb09b6b3de7be7221f993e0c3e5d202bef43ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 11:28:24 +0200 Subject: [PATCH 45/75] docs(soft-delete): martwe cytowania klienta PBN + nota o SentData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zgloszone przez agenta splacajacego dlug fazy 05. Spec §4.1 i indeks 00 cytowaly src/pbn_api/client/mixins/institutions.py:87 -- ten katalog zawiera dzis wylacznie __pycache__. Klient PBN zostal wydzielony do pakietu zewnetrznego (pbn_client), importowanego w src/pbn_api/client/__init__.py. Dopisane trzy rzeczy, ktore inaczej kosztowalyby wykonawce fazy 05 czas: - gdzie metoda faktycznie mieszka (pbn_client/mixins/institutions.py:87, statements.py:211 zamiast nieistniejacego publication_sync.py); - ze _delete_statements_with_retry z pakietu NIE jest wzorcem do nasladowania: ponawia na CannotDeleteStatementsException, podczas gdy u nas ten wyjatek oznacza SUKCES (oswiadczen nie bylo, stan docelowy osiagniety). Wzorzec obslugi jest w src/pbn_wysylka_oswiadczen/tasks.py:54-76; - ze SentData jest scope'owane po uczelni (get_for_rec(rec, uczelnia=None)), wiec lookup bez uczelni przy dwoch wierszach rzuca MultipleObjectsReturned. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../plans/2026-06-04-soft-delete-00-overview.md | 8 ++++++-- .../2026-06-04-soft-delete-publikacje-i-autorzy-design.md | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md index 2d70a2d98..d26ec493f 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-00-overview.md @@ -175,7 +175,11 @@ Pola PINNED: `content_type` (FK ContentType), `object_id` (PositiveIntegerField, ### `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`). +- Gałąź w logice wysyłki: `WYCOFANIE` → `client.delete_all_publication_statements(pbn_uid)`. + ⚠️ Klient PBN to **pakiet zewnętrzny** — metoda żyje w site-packages + (`pbn_client/mixins/institutions.py:87`), importowana przez + `src/pbn_api/client/__init__.py`. Katalog `src/pbn_api/client/mixins/` + zawiera dziś wyłącznie `__pycache__`. ### Wstrzykiwanie `user` (PINNED — API thread-local, faza 06 tworzy, 07 używa) - Override sygnatury: `delete(self, *args, user=None, reason="", **kwargs)` i `restore(self, *args, user=None, **kwargs)`. @@ -199,7 +203,7 @@ Pola PINNED: `content_type` (FK ContentType), `object_id` (PositiveIntegerField, - `verify_cache`: `src/bpp/management/commands/verify_cache.py` — **martwy stub**, nie używać. - Wzorzec testów cache: `src/bpp/tests/test_cache/test_cache_plpgsql_port.py`. Kanarki soft-delete: `test_soft_delete_preconditions.py`. - Admin tych modeli: `src/bpp/admin/{wydawnictwo_ciagle,wydawnictwo_zwarte,patent,praca_doktorska,praca_habilitacyjna,autor}.py`; mixiny `src/bpp/admin/helpers/mixins.py`. -- PBN klient: `src/pbn_api/client/mixins/institutions.py:87`. `SentData`: `src/pbn_api/models/sentdata.py`. Kolejka: `src/pbn_export_queue/{models,tasks,admin}.py`. +- PBN klient: **pakiet zewnętrzny** `pbn_client` (site-packages), nie `src/pbn_api/client/mixins/`. `SentData`: `src/pbn_api/models/sentdata.py` — ⚠️ scope'owane po uczelni (`get_for_rec(rec, uczelnia=None)`). Kolejka: `src/pbn_export_queue/{models,tasks,admin}.py`. - Merge autorów: `src/deduplikator_autorow/views/merge.py:155`, `utils/merge.py:191,284,354`. - Precedens wzorca: `src/zglos_publikacje/models.py` (`Zgłoszenie_Publikacji` już `SoftDeleteModel`). diff --git a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md index ff1ac23e2..832078b12 100644 --- a/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md +++ b/docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md @@ -522,10 +522,14 @@ się wywalić; wycofanie oświadczeń jest zawsze bezpieczne). Gate: jeśli reko nigdy nie poszedł do PBN (`pbn_uid is None`) — nic nie robimy. Prymityw PBN istnieje: -`src/pbn_api/client/mixins/institutions.py:87` → +⚠️ **w pakiecie zewnętrznym** `pbn_client/mixins/institutions.py:87` → `delete_all_publication_statements(publicationId)` (+ selektywne `delete_publication_statement` w `:135`, retry w -`pbn_api/client/publication_sync.py`). +`pbn_client/statements.py:211`). +⚠️ `_delete_statements_with_retry` z pakietu **NIE jest wzorcem** — ponawia +na `CannotDeleteStatementsException`, a u nas ten wyjątek oznacza SUKCES +(oświadczeń nie było, stan docelowy osiągnięty). Wzorzec obsługi: +`src/pbn_wysylka_oswiadczen/tasks.py:54-76`. ### 4.2 Mechanizm — jeden prymityw, DWA wejścia (decyzja #16) From f604734031d3a9e242867e81dc076581b3fb2cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 11:45:37 +0200 Subject: [PATCH 46/75] fix(tests): guard django_db_setup przeciw brakowi deleted_at na *_autor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sesja pytest bez ani jednego testu django_db wywalała się na tej gałęzi błędem UndefinedColumn ("kolumna old.deleted_at nie istnieje") przy install_triggers() w session-scoped django_db_setup — wymuszana zawsze przez autouse _audit_wipe_once, niezależnie od tego czy jakikolwiek test faktycznie potrzebuje bazy. Przyczyna: triggery denorm dla Patent/Wydawnictwo_Ciagle/Wydawnictwo_Zwarte (denorm_always_only = ("deleted_at",), faza 01) generują SQL referujący NEW."deleted_at". Gdy fixture trafi na bazę sprzed migracji bpp.0488_autor_soft_delete_fields (np. reużyty kontener testowy ze starym schematem), install_triggers() pada. Guard sprawdza przez information_schema.columns, czy WSZYSTKIE trzy tabele *_autor mają już kolumnę deleted_at. Jeśli tak — instaluje triggery jak dotychczas. Jeśli nie — pomija install_triggers() (i drop_triggers()) z czytelnym ostrzeżeniem (RuntimeWarning) tłumaczącym przyczynę i sugerującym naprawę (migrate / świeża baza), zamiast łykać wyjątek szeroko. Zweryfikowane na żywym hoście (dev, --reuse-db + pytest-testcontainers-django): - sesja bez testów django_db (test_kanarek_orm.py) — wcześniej 3 errors, teraz 3 passed + ostrzeżenie; - sesja z testami django_db na zmigrowanej bazie (test_kanarek_katalogowy.py, --create-db) — triggery instalują się normalnie, DDL-owy kanarek zielony (15 passed). Newsfragment: src/bpp/newsfragments/soft-delete-guard-django-db-setup.bugfix.rst Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- ...ft-delete-guard-django-db-setup.bugfix.rst | 7 ++ src/conftest.py | 65 ++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/bpp/newsfragments/soft-delete-guard-django-db-setup.bugfix.rst diff --git a/src/bpp/newsfragments/soft-delete-guard-django-db-setup.bugfix.rst b/src/bpp/newsfragments/soft-delete-guard-django-db-setup.bugfix.rst new file mode 100644 index 000000000..6b412bbcc --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-guard-django-db-setup.bugfix.rst @@ -0,0 +1,7 @@ +Sesja testów bez ani jednego testu korzystającego z bazy danych wywalała +się na gałęzi soft-delete autorstwa błędem ``UndefinedColumn: kolumna +deleted_at nie istnieje`` przy instalacji triggerów denorm, gdy testowa +baza pochodziła sprzed migracji dodającej tę kolumnę (np. reużyty +kontener testowy ze starym schematem). Rozpoznajemy teraz ten jeden, +konkretny przypadek i pomijamy instalację triggerów z czytelnym +ostrzeżeniem zamiast wywalać całą sesję pytest. diff --git a/src/conftest.py b/src/conftest.py index 620ed9118..92fd6fe7a 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -1,6 +1,7 @@ import os import random import time +import warnings from datetime import date from uuid import uuid4 @@ -1137,6 +1138,36 @@ def constance_cache_warmed_up(db): return config +# Tabele powiązań autorstwa, na które triggery denorm (denorm_always_only = +# ("deleted_at",) w Patent, Wydawnictwo_Ciagle, Wydawnictwo_Zwarte) generują +# SQL referujący NEW."deleted_at". Kolumna przyszła w migracji +# bpp.0488_autor_soft_delete_fields — patrz guard w django_db_setup niżej. +_AUTOR_TABELE_Z_DELETED_AT = ( + "bpp_patent_autor", + "bpp_wydawnictwo_ciagle_autor", + "bpp_wydawnictwo_zwarte_autor", +) + + +def _autor_tabele_maja_deleted_at(connection) -> bool: + """True, gdy WSZYSTKIE tabele ``*_autor`` mają już kolumnę ``deleted_at``. + + Rozpoznaje wyłącznie ten jeden, konkretny przypadek (baza sprzed migracji + 0488 — reużyty kontener/baza ze starym schematem) — nie łyka żadnych + innych błędów ``install_triggers()``. Patrz komentarz w + ``django_db_setup``. + """ + with connection.cursor() as cursor: + cursor.execute( + "SELECT table_name FROM information_schema.columns " + "WHERE table_schema = 'public' AND column_name = 'deleted_at' " + "AND table_name = ANY(%s)", + [list(_AUTOR_TABELE_Z_DELETED_AT)], + ) + znalezione = {row[0] for row in cursor.fetchall()} + return znalezione == set(_AUTOR_TABELE_Z_DELETED_AT) + + @pytest.fixture(scope="session") def django_db_setup(django_db_setup, django_db_blocker): """Rebuild denorm triggers on the freshly-built test DB, then jitter @@ -1158,13 +1189,43 @@ def django_db_setup(django_db_setup, django_db_blocker): 1.12.1) usuwa wszystkie ``d_*``-triggery, a install_triggers() instaluje świeże, rozwiązujące content_type dynamicznie (patrz ``denorm.helpers.content_type_select_sql``). + + Fixture jest session-scoped, ale uruchamia się w KAŻDEJ sesji pytest — + także bez ani jednego testu ``django_db`` — bo autouse ``_audit_wipe_once`` + (niżej) go zależnościowo wymusza. Od fazy 01 soft-delete triggery denorm + dla Patent/Wydawnictwo_Ciagle/Wydawnictwo_Zwarte referują + ``NEW."deleted_at"`` (denorm_always_only). Na bazie sprzed migracji + bpp.0488 (typowo: reużyty kontener testcontainers albo ``--reuse-db`` ze + starym schematem) tej kolumny jeszcze nie ma i + ``install_triggers()`` pada ``UndefinedColumn``, wywalając CAŁĄ sesję + pytest — również tę bez testów bazodanowych. Guard rozpoznaje WYŁĄCZNIE + ten jeden przypadek (brak kolumny) i pomija instalację triggerów z + ostrzeżeniem zamiast crashować; każdy inny błąd leci dalej. """ from denorm import denorms from django.db import connection with django_db_blocker.unblock(): - denorms.drop_triggers() - denorms.install_triggers() + if _autor_tabele_maja_deleted_at(connection): + denorms.drop_triggers() + denorms.install_triggers() + else: + warnings.warn( + "Pomijam denorm.drop_triggers()/install_triggers(): tabele " + + ", ".join(_AUTOR_TABELE_Z_DELETED_AT) + + " nie mają jeszcze kolumny deleted_at (migracja " + "bpp.0488_autor_soft_delete_fields nie została zastosowana " + "na tej bazie testowej — najczęściej reużyty kontener " + "testcontainers albo baza --reuse-db ze starym schematem). " + 'Triggery denorm referują NEW."deleted_at" ' + "(denorm_always_only), więc install_triggers() padłby " + "UndefinedColumn. Zastosuj migracje " + "(`uv run python src/manage.py migrate`) albo uruchom testy " + "na świeżej bazie (np. `--create-db` / świeży kontener " + "testcontainers).", + RuntimeWarning, + stacklevel=2, + ) # Przesuń każdą sekwencję w public o losową wartość z zakresu # [50 000, 500 000], niezależnie per sekwencja. Cel: nie pozwolić From d96cc5943ce204d24d4f204e66d481b774a8792a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 11:49:31 +0200 Subject: [PATCH 47/75] docs(soft-delete): sprzatanie kronika jako jedno zadanie fazy 02 Proba usuniecia trzech martwych widokow kronika w fazie 01 zablokowana: zaleza od nich dwa widoki nadrzedne (bpp_kronika_all_unsorted_view, bpp_kronika_view), wiec goly DROP bez CASCADE nie przejdzie. Cala siodemka zweryfikowana jako martwa. Decyzja: kasowac naraz w fazie 02, bo tam i tak dochodza dwa kolejne (praca_doktorska/habilitacyjna) przez rozszerzenie kanarka o tabele publikacji. Graf zaleznosci i kolejnosc DROP wpisane do planu. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../2026-06-04-soft-delete-02-publikacje.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md index c6588b223..ef1fccc9f 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md @@ -60,6 +60,41 @@ --- +## 🧹 Sprzątanie `bpp_kronika_*` — jedno zadanie, nie po kawałku (decyzja 2026-08-07) + +Próba usunięcia trzech martwych widoków `bpp_kronika_{wydawnictwo_ciagle, +wydawnictwo_zwarte,patent}_view` w fazie 01 została **zablokowana**: zależą od +nich dwa widoki nadrzędne, `bpp_kronika_all_unsorted_view` (UNION pięciu) i +`bpp_kronika_view` (`0443_drop_pl_PL_collation.sql:13-14,30-31`). Goły +`DROP VIEW` bez `CASCADE` nie przejdzie. + +Zweryfikowano: **cała siódemka jest martwa** — zero konsumentów w kodzie, +szablonach, `Meta.db_table`, `flexible_reports` i surowym SQL-u. + +**Decyzja: skasować wszystkie 7 naraz, w TEJ fazie**, bo faza 02 i tak dołoży +`bpp_kronika_praca_{doktorska,habilitacyjna}_view` do zakresu kanarka +(rozszerzenie `TABELE_SOFT_DELETE` o tabele publikacji) i wtedy trzeba by je +weryfikować osobno. Jedna migracja, jedna decyzja, pełny graf zależności: + +``` +bpp_kronika_view + └── bpp_kronika_all_unsorted_view + ├── bpp_kronika_wydawnictwo_ciagle_view + ├── bpp_kronika_wydawnictwo_zwarte_view + ├── bpp_kronika_patent_view + ├── bpp_kronika_praca_doktorska_view ← żywotność do potwierdzenia + └── bpp_kronika_praca_habilitacyjna_view ← żywotność do potwierdzenia +``` + +Kolejność `DROP`: od góry (parasole) w dół. Migracja **odwracalna** — `backward` +odtwarza całą siódemkę; wyjdź z `pg_get_viewdef()` przed skasowaniem. +Po zrobieniu: usuń wpisy z `WYJATKI` w `test_kanarek_katalogowy.py`. + +Jeśli któryś z dwóch niezweryfikowanych okaże się mieć konsumenta — **nie +kasuj żadnego**, zostaw wyjątki i zgłoś. + +--- + ## ⚠️ KOLEJNOŚĆ WYKONANIA (taski NIE stoją w dokumencie w kolejności wykonania) Taski dopisywane po rewizjach wylądowały poza numeracją. Wykonuj w TEJ kolejności: From dc45d1ce1ef22052f2e2d80e916071c193fb3707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 13:10:57 +0200 Subject: [PATCH 48/75] feat(djangoql): domyslnie nie pokazuj kosza (soft-delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zmiana decyzji z naprawy wycieku ORM (e80d1656e), gdzie DjangoQL zostawiono swiadomie surowy z uzasadnieniem "jezyk zapytan uzytkownika = narzedzie audytowe, ma widziec kosz". Wlasciciel projektu zdecydowal odwrotnie: kosz ma byc domyslnie niewidoczny, a audyt — czynnoscia JAWNA. Kontrakt (docstring bpp/djangoql_soft_delete.py): - zapytanie przechodzace przez relacje do modelu soft-delete i nie wspominajace deleted_at dostaje predykat "deleted_at IS NULL" na TYM SAMYM joinie; - wystarczy wymienic deleted_at na danej sciezce, zeby BPP przestalo cokolwiek dokladac: "autorzy_set.deleted_at != None" = tylko kosz; - zdjecie predykatu dziala PER PREFIKS, nie globalnie. Dlaczego w schemacie, a nie w querysecie bazowym: filtr bazowy zalatwia tylko model-korzen (ten i tak odsiewa BppSoftDeleteManager). Problemem jest JOIN przez relacje — on nie pyta managera, a predykat dolozony osobnym .filter() tworzy DRUGI, nieskorelowany JOIN. Predykat musi trafic do tego samego Q, ktore buduje warunek uzytkownika, czyli do DjangoQLField.get_lookup() zwracanego przez schema.resolve_name(). Jedno wpiecie obsluguje adminy, /zapytanie/, /api/v1/zapytanie/*, eksport multiseek->DjangoQL i rozbicie "dlaczego 0 wynikow". Zakres przez introspekcje (issubclass SoftDeleteModel), nie przez liste nazw — faza 02 (piec modeli publikacji) nie bedzie musiala tego przepisywac. Dodatkowo: - agregaty relacyjne (autorzy_set__count) licza tylko zywe wiersze — podmiana zrodla podzapytania, bo _base_manager Django to goly Manager; - ".deleted_at != None" znaczy "relacja MA wiersz w koszu", a nie (jak w golym djangoql) "nie ma wiersza zywego" — bez tego zywy wspolautor zabieral wynik audytowy. Swiadome slepe plamy (opisane + przypiete testem): M2M przez through, porownanie samej relacji z None, operatory negujace na sciezce relacyjnej (Django dekoreluje negacje — tryb awarii zachowawczy). Zaktualizowane nieaktualne uzasadnienia: docstring kanarka ORM (test_kanarek_orm.py) — DjangoQL nie jest juz slepa plama. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- src/bpp/djangoql_schema.py | 10 +- src/bpp/djangoql_soft_delete.py | 349 ++++++++++++++++++ ...oql-kosz-domyslnie-niewidoczny.feature.rst | 6 + .../test_djangoql_bez_skasowanych.py | 198 ++++++++++ .../test_soft_delete/test_kanarek_orm.py | 14 +- 5 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 src/bpp/djangoql_soft_delete.py create mode 100644 src/bpp/newsfragments/djangoql-kosz-domyslnie-niewidoczny.feature.rst create mode 100644 src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py diff --git a/src/bpp/djangoql_schema.py b/src/bpp/djangoql_schema.py index 567c2ec1d..c98f0339d 100644 --- a/src/bpp/djangoql_schema.py +++ b/src/bpp/djangoql_schema.py @@ -25,6 +25,7 @@ from django.utils.html import strip_tags from djangoql.extras import AutocompleteField, ExtrasSchema +from bpp.djangoql_soft_delete import WykluczSkasowaneMixin from bpp.models import Charakter_Formalny, Jednostka @@ -243,13 +244,16 @@ def get_field_instance(self, model, field_name): return super().get_field_instance(model, field_name) -class BppQLSchema(RelPickerSchemaMixin, ExtrasSchema): +class BppQLSchema(WykluczSkasowaneMixin, RelPickerSchemaMixin, ExtrasSchema): """ExtrasSchema (agregaty + części dat) + auto-pickery ``__rel``. Wspólny dla widoku „Szukaj zapytaniem" i adminów (``djangoql_schema = BppQLSchema``). Mapa modeli jest budowana lazy per-model, więc ten sam schemat obsługuje dowolny model (Rekord, Autor, Wydawnictwo_*, Patent, Praca_Doktorska/Habilitacyjna, …). + + ``WykluczSkasowaneMixin`` domyślnie odsiewa kosz (soft-delete) — + kontrakt „jak zobaczyć kosz" w ``bpp/djangoql_soft_delete.py``. """ def excluded(self, model): @@ -570,7 +574,9 @@ def _build_llm_fk_options(): } -class RekordLLMSchema(DeprecatedAndRestrictedFieldsMixin, ExtrasSchema): +class RekordLLMSchema( + WykluczSkasowaneMixin, DeprecatedAndRestrictedFieldsMixin, ExtrasSchema +): """Schemat dla eksportu opisu DjangoQL do promptu LLM. Baza ``ExtrasSchema`` (agregaty + części dat), **bez** pickerów diff --git a/src/bpp/djangoql_soft_delete.py b/src/bpp/djangoql_soft_delete.py new file mode 100644 index 000000000..bef41c40b --- /dev/null +++ b/src/bpp/djangoql_soft_delete.py @@ -0,0 +1,349 @@ +"""Domyślne odsiewanie kosza (soft-delete) w zapytaniach DjangoQL. + +DECYZJA (2026-08-07) — ZMIANA WCZEŚNIEJSZEJ +=========================================== + +Przy naprawie wycieku ORM (commit ``e80d1656e``) DjangoQL zostawiono +świadomie surowy, z uzasadnieniem „język zapytań użytkownika jest +narzędziem audytowym, ma widzieć kosz". Właściciel projektu zdecydował +inaczej: **domyślnie kosz ma być niewidoczny**, a audyt ma być czynnością +JAWNĄ. Ten moduł realizuje tę decyzję. + +KONTRAKT DLA UŻYTKOWNIKA — jak zobaczyć kosz +============================================ + +Regułą steruje sam tekst zapytania, per ŚCIEŻKA RELACJI: + +* zapytanie, które przechodzi przez relację do modelu soft-delete i nie + wspomina o ``deleted_at`` na tej relacji, dostaje domyślny predykat + ``.deleted_at = None`` (kosz niewidoczny):: + + autorzy_set.autor.nazwisko = "Kowalski" + # → tylko ŻYWE autorstwa + +* wystarczy w zapytaniu wymienić ``deleted_at`` na tej samej ścieżce, żeby + BPP przestało cokolwiek dokładać — od tego momentu kosz jest w Twoich + rękach:: + + autorzy_set.autor.nazwisko = "Kowalski" and autorzy_set.deleted_at != None + # → TYLKO kosz (skasowane autorstwa Kowalskiego) + + autorzy_set.autor.nazwisko = "Kowalski" and autorzy_set.deleted_at = None + # → to samo, co domyślnie (predykat napisany ręcznie) + +* zdjęcie predykatu jest PER PREFIKS, nie globalne: w zapytaniu mieszanym + ``autorzy_set.deleted_at != None and wydawnictwo_zwarte_autor.autor.id = 5`` + kosz odsłania się tylko dla ``autorzy_set``; + +* ``.deleted_at != None`` znaczy „relacja MA wiersz w koszu" + (a nie, jak wyszłoby z gołego djangoql, „relacja nie ma wiersza żywego") + — patrz :func:`_deleted_at_pozytywne`. + +DLACZEGO W SCHEMACIE, A NIE W QUERYSECIE BAZOWYM +================================================ + +Filtr na querysecie bazowym (``qs.filter(deleted_at__isnull=True)``) +załatwia wyłącznie model-korzeń — a ten i tak jest odsiany domyślnym +managerem (``BppSoftDeleteManager``). Problemem, który wracał w fazie 01 +trzy razy, jest **JOIN przez relację**: ``filter(autorzy_set__…)`` nie pyta +managera, tylko złącza surową tabelę. Dołożenie predykatu osobnym +``.filter()`` NIE pomaga — Django zrobi wtedy DRUGI, nieskorelowany JOIN +i warunek zdegeneruje się do „istnieje jakiekolwiek żywe autorstwo". +Predykat musi trafić do TEGO SAMEGO ``Q``, które buduje warunek +użytkownika — a jedynym miejscem, gdzie to ``Q`` powstaje, jest +``DjangoQLField.get_lookup()`` zwracane przez ``schema.resolve_name()``. + +Stąd wpięcie w ``resolve_name`` — jedno miejsce, które obsługuje WSZYSTKIE +punkty wejścia DjangoQL w BPP naraz: adminy (``BppDjangoQLSearchMixin``), +widok „Szukaj zapytaniem", ``/api/v1/zapytanie/*``, eksport +multiseek→DjangoQL oraz rozbicie „dlaczego 0 wyników" +(``djangoql.breakdown`` woła ``build_filter`` bezpośrednio). + +ZAKRES — introspekcja, nie lista nazw +===================================== + +Modele objęte odsiewaniem wykrywamy przez ``issubclass(model, +SoftDeleteModel)``. Dziś to trzy through-modele ``*_Autor``; gdy faza 02 +uczyni soft-delete pięć modeli publikacji, ten moduł nie wymaga ŻADNEJ +zmiany. + +ZNANE ŚLEPE PLAMY (świadome) +============================ + +* **M2M przez soft-delete through-model** — ``Autor.wydawnictwo_ciagle`` + idzie przez ``Wydawnictwo_Ciagle_Autor``, ale ``related_model`` tej + relacji to ``Wydawnictwo_Ciagle`` (nie soft-delete), a Django nie + pozwala dołożyć warunku na tabelę pośrednią w ścieżce M2M. Ekwiwalent + z predykatem: ``wydawnictwo_ciagle_autor.rekord.…`` (through jawnie). +* **porównanie samej relacji z None** — ``autorzy_set != None`` nie + dostaje predykatu, bo ``resolve_name()`` zwraca dla takiej ścieżki + ``None`` (djangoql buduje wtedy pole abstrakcyjne poza naszym zasięgiem). + Ekwiwalent z predykatem: ``autorzy_set.id != None``. +* **operatory negujące na ścieżce relacyjnej** (``autorzy_set.autor.id != + 5``) — predykat NIE jest dokładany. Django świadomie DEKORELUJE negację + na relacji wielowartościowej: ``~Q(A) & Q(B)`` kompiluje się do + ``NOT (EXISTS(A) AND EXISTS(B))`` z dwoma NIEZALEŻNYMI podzapytaniami + (udokumentowana różnica ``exclude()`` vs ``filter()`` — „Spanning + multi-valued relationships"). Predykat wciągnięty tam pod negację nie + zawęża wiersza, tylko dokłada osobny warunek „istnieje jakieś żywe + autorstwo" — czyli zmienia znaczenie zapytania. Poprawne rozwiązanie + (``exclude(rel__in=Subquery)``) wymagałoby przepisywania ``Q`` + użytkownika i nie da się go zrobić ogólnie na poziomie pola. Tryb awarii + jest ZACHOWAWCZY: skasowany wiersz może spowodować, że publikacja NIE + zostanie zwrócona — nigdy odwrotnie, więc treść kosza dalej nie wycieka. + Predykat na samym modelu-korzeniu (bez JOIN-u) działa też pod negacją. +* **agregaty relacyjne** (``autorzy_set__count``) — NIE są ślepą plamą, + ale obsługuje je inny mechanizm: podmiana managera źródłowego + podzapytania (:func:`_agregat_bez_skasowanych`), bo agregat nie filtruje + JOIN-em. +""" + +import copy + +from django.db.models import Q +from django_softdelete.models import SoftDeleteModel + +#: Nazwa pola-znacznika soft-delete (``django-soft-delete``). +POLE_SOFT_DELETE = "deleted_at" + +#: Operatory DjangoQL, dla których ``DjangoQLField.get_lookup()`` zwraca +#: ``~Q`` (kopia negatywnej połowy mapy z ``DjangoQLField.get_operator``). +#: Predykatu NIE dokładamy do nich na ścieżkach relacyjnych — patrz +#: „Znane ślepe plamy" w docstringu modułu. +OPERATORY_NEGUJACE = frozenset({"!=", "!~", "not in", "not startswith", "not endswith"}) + + +def jest_modelem_soft_delete(model) -> bool: + """Czy ``model`` jest modelem soft-delete (ma kolumnę ``deleted_at``)?""" + return isinstance(model, type) and issubclass(model, SoftDeleteModel) + + +def prefiksy_z_jawnym_deleted_at(node) -> frozenset: + """Prefiksy ścieżek, na których UŻYTKOWNIK sam wspomniał ``deleted_at``. + + Czysto składniowe — chodzi o intencję („pytam o kosz"), nie o to, czy + pole istnieje; walidację i tak zrobi zaraz potem sam djangoql. Dzięki + temu funkcja nie może wysypać się wcześniej niż walidator i nie musi + znać modeli. + """ + operator = getattr(node, "operator", None) + if getattr(operator, "operator", None) in ("and", "or"): + return prefiksy_z_jawnym_deleted_at(node.left) | prefiksy_z_jawnym_deleted_at( + node.right + ) + parts = getattr(getattr(node, "left", None), "parts", None) + if parts and parts[-1] == POLE_SOFT_DELETE: + return frozenset({tuple(parts[:-1])}) + return frozenset() + + +def _prefiksy_soft_delete(schema, parts): + """Prefiksy ścieżki ``parts``, na których zapytanie WCHODZI w model + soft-delete. ``()`` oznacza sam model-korzeń. + + Odwzorowuje trawersację z ``DjangoQLSchema.resolve_name()`` (łącznie z + hakiem ``resolve_unknown`` na wirtualne pola). Przy nieznanym członie + kończy cicho — komunikat błędu jest zadaniem ``resolve_name()``. + """ + prefiksy = [] + if jest_modelem_soft_delete(schema.current_model): + prefiksy.append(()) + + etykieta = schema.model_label(schema.current_model) + model_cls = schema.current_model + poprzednia_relacja = None + for i, czlon in enumerate(parts): + pole = schema.models.get(etykieta, {}).get(czlon) + if pole is None: + pole = schema.resolve_unknown(model_cls, poprzednia_relacja, czlon) + if pole is None: + break + if pole.type == "relation": + poprzednia_relacja = pole + etykieta = pole.relation + model_cls = pole.related_model + if jest_modelem_soft_delete(model_cls): + prefiksy.append(tuple(parts[: i + 1])) + else: + poprzednia_relacja = None + return prefiksy + + +class _ZrodloBezSkasowanych: + """Podstawka pod ``AggregateField.related_model`` w podzapytaniu agregatu. + + ``djangoql.extras.AggregateField._subquery()`` buduje skorelowane + podzapytanie z ``self.related_model._base_manager``. ``_base_manager`` + Django to goły ``Manager()`` — NIE nasz ``BppSoftDeleteManager`` — + więc ``autorzy_set__count`` liczyłby również kosz. Podstawiamy obiekt, + którego ``_base_manager`` to manager odsiewający. + + Świadomie WĄSKA atrapa: gdyby djangoql zaczął sięgać po inny atrybut + modelu, dostaniemy głośny ``AttributeError`` zamiast cichego powrotu + do liczenia kosza. + """ + + def __init__(self, model): + self._base_manager = model.objects + + +def _deleted_at_pozytywne(pole): + """Kopia pola ``deleted_at``, w której ``!= None`` znaczy „ma wiersz w + koszu", a nie „nie ma wiersza żywego". + + DLACZEGO: djangoql tłumaczy ``!=`` na ``~Q(...)``, a Django kompiluje + negację na relacji wielowartościowej jako ``NOT EXISTS(...)`` — więc + ``autorzy_set.deleted_at != None`` znaczyłoby „publikacja NIE MA + żadnego żywego autorstwa". Dla publikacji z jednym autorstwem + skasowanym i drugim żywym dałoby to pustkę, choć użytkownik pytał o + kosz. Wersja pozytywna (``deleted_at__isnull=False``) jest + skorelowana z tym samym JOIN-em, co reszta warunków użytkownika, więc + ``autorzy_set.autor.id = 5 and autorzy_set.deleted_at != None`` + naprawdę znajduje skasowane autorstwo autora 5. + + Odstępstwo od djangoql jest WĄSKIE: dotyczy wyłącznie pola + ``deleted_at`` na modelu soft-delete i wyłącznie porównania z ``None``. + """ + oryginalny_lookup = pole.get_lookup + kopia = copy.copy(pole) + + def get_lookup(path, operator, value): + if value is None and operator in ("=", "!="): + klucz = "__".join((*path, POLE_SOFT_DELETE, "isnull")) + return Q(**{klucz: operator == "="}) + return oryginalny_lookup(path, operator, value) + + kopia.get_lookup = get_lookup + return kopia + + +def _jest_agregatem_po_soft_delete(pole) -> bool: + """Czy ``pole`` to agregat relacyjny djangoql (``__count``, + ``.__sum``) liczony po modelu soft-delete? + + Rozpoznajemy po atrybutach ``AggregateField`` (``owner_lookup`` + + ``relation_name``), a nie po ``isinstance`` — żeby nie importować + prywatnych klas ``djangoql.extras``. + """ + return ( + hasattr(pole, "owner_lookup") + and hasattr(pole, "relation_name") + and jest_modelem_soft_delete(getattr(pole, "related_model", None)) + ) + + +def prefiks_agregatu(pole, parts) -> tuple: + """Ścieżka relacji, po której liczony jest agregat — do porównania z + prefiksami, na których użytkownik jawnie pyta o kosz. + + Nazwa płaska (``autorzy_set__count``) NIE zawiera relacji w ścieżce, + notacja z kropką (``autorzy_set.procent__sum``) — zawiera + (``relation_hop_in_path``). + """ + poczatek = tuple(parts[:-1]) + if getattr(pole, "relation_hop_in_path", False): + return poczatek + return poczatek + (pole.relation_name,) + + +def _agregat_bez_skasowanych(pole): + """Kopia agregatu, którego podzapytanie liczy TYLKO żywe wiersze. + + Agregat nie filtruje JOIN-em, tylko skorelowanym podzapytaniem po + ``related_model._base_manager`` — dokładanie predykatu do ``Q`` + dołożyłoby osobny, nieskorelowany warunek na zewnętrznym zapytaniu i + nie zmieniłoby wartości licznika. Odsiewamy więc ŹRÓDŁO podzapytania. + """ + kopia = copy.copy(pole) + kopia.related_model = _ZrodloBezSkasowanych(pole.related_model) + return kopia + + +def _z_predykatem_soft_delete(pole, prefiksy): + """Kopia pola DjangoQL, której ``get_lookup()`` domyka JOIN-y predykatem + ``deleted_at IS NULL``. + + Kopiujemy, bo instancje pól są współdzielone w ``schema.models`` między + wywołaniami — mutacja oryginału przeciekłaby na zapytania, które o kosz + poprosiły jawnie. + """ + oryginalny_lookup = pole.get_lookup + kopia = copy.copy(pole) + + predykat = Q() + #: Osobno część „na własnej tabeli" (prefiks ``()``, czyli model-korzeń) + #: — ta jedna jest bezpieczna także pod negacją, bo nie tworzy JOIN-u. + predykat_korzenia = Q() + for prefiks in prefiksy: + klucz = "__".join((*prefiks, POLE_SOFT_DELETE, "isnull")) + czesc = Q(**{klucz: True}) + predykat &= czesc + if not prefiks: + predykat_korzenia &= czesc + + def get_lookup(path, operator, value): + q = oryginalny_lookup(path, operator, value) + if operator in OPERATORY_NEGUJACE: + return q & predykat_korzenia + return q & predykat + + kopia.get_lookup = get_lookup + return kopia + + +class WykluczSkasowaneMixin: + """Domyślnie odsiewa rekordy soft-delete z zapytań DjangoQL. + + Musi stać PRZED klasą bazową schematu w MRO. Szczegóły kontraktu — + docstring modułu. + """ + + def __init__(self, model): + # Bezpieczny domyślny stan: „użytkownik o kosz nie prosił". + # Ustawiane przed ``super()``, bo konstruktor bazy robi + # introspekcję i może wołać metody schematu. + self._jawne_prefiksy_soft_delete = frozenset() + self._ast_zbadany = False + super().__init__(model) + + def validate(self, node): + """Zapamiętaj prefiksy, na których użytkownik JAWNIE pyta o kosz. + + ``apply_search()`` woła ``validate()`` z CAŁYM drzewem zanim + zbuduje filtr, a ``djangoql.breakdown`` — z każdym podzapytaniem + osobno; w obu wypadkach pierwszy węzeł, jaki widzimy, jest + korzeniem tego, co zaraz zostanie wykonane. ``validate()`` rekuruje + po sobie, więc analizę robimy tylko raz (``_ast_zbadany``). + """ + if not self._ast_zbadany: + self._ast_zbadany = True + self._jawne_prefiksy_soft_delete = prefiksy_z_jawnym_deleted_at(node) + return super().validate(node) + + def resolve_name(self, name): + pole = super().resolve_name(name) + if pole is None: + return None + if _jest_agregatem_po_soft_delete(pole): + # Agregat obsługujemy WYŁĄCZNIE podmianą źródła podzapytania — + # predykat na JOIN-ie nie zmieniłby wartości licznika, a dołożyłby + # zewnętrzny warunek „istnieje żywy wiersz". + if prefiks_agregatu(pole, name.parts) in self._jawne_prefiksy_soft_delete: + return pole + return _agregat_bez_skasowanych(pole) + + wszystkie = _prefiksy_soft_delete(self, name.parts) + if name.parts[-1] == POLE_SOFT_DELETE and tuple(name.parts[:-1]) in wszystkie: + # Samo ``deleted_at`` na modelu soft-delete: predykatu nie + # dokładamy (to jest właśnie jawne pytanie o kosz), ale + # naprawiamy semantykę ``!= None``. + return _deleted_at_pozytywne(pole) + + prefiksy = [ + prefiks + for prefiks in wszystkie + if prefiks not in self._jawne_prefiksy_soft_delete + ] + if not prefiksy: + return pole + return _z_predykatem_soft_delete(pole, prefiksy) diff --git a/src/bpp/newsfragments/djangoql-kosz-domyslnie-niewidoczny.feature.rst b/src/bpp/newsfragments/djangoql-kosz-domyslnie-niewidoczny.feature.rst new file mode 100644 index 000000000..57d2a0fd6 --- /dev/null +++ b/src/bpp/newsfragments/djangoql-kosz-domyslnie-niewidoczny.feature.rst @@ -0,0 +1,6 @@ +Zapytania w języku DjangoQL — w module Redagowanie, na stronie „Szukaj +zapytaniem" oraz w REST API (``/api/v1/zapytanie/``) — domyślnie NIE pokazują +już rekordów skasowanych (kosz). Żeby zajrzeć do kosza, wystarczy wymienić w +zapytaniu pole ``deleted_at`` na wybranej relacji: warunek +``autorzy_set.deleted_at != None`` pokazuje wyłącznie skasowane pozycje, +a ``autorzy_set.deleted_at = None`` jest tym samym, co zachowanie domyślne. diff --git a/src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py b/src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py new file mode 100644 index 000000000..168b13d37 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py @@ -0,0 +1,198 @@ +"""DjangoQL domyślnie NIE pokazuje kosza (soft-delete), ale da się go +jawnie poprosić o pokazanie. + +Kontrakt (zmiana decyzji z fazy „wyciek ORM", commit ``e80d1656e``, gdzie +DjangoQL świadomie zostawiono surowy): + +* zapytanie, które przechodzi przez relację do modelu soft-delete i NIE + wspomina ``deleted_at`` na tej relacji, dostaje domyślny predykat + ``.deleted_at = None`` **na tym samym JOIN-ie**; +* zapytanie, które wspomina ``deleted_at`` na danej relacji, NIE dostaje nic + — użytkownik świadomie steruje koszem (``!= None`` = tylko kosz). + +Testy semantyczne (nie substringowe) — patrz ``bpp/djangoql_soft_delete.py``. +""" + +import pytest +from djangoql.queryset import apply_search + +from bpp.djangoql_schema import BppQLSchema, BppQLSchemaOgraniczony, RekordLLMSchema +from bpp.models import Autor, Wydawnictwo_Ciagle + + +def _pk_set(qs): + return set(qs.values_list("pk", flat=True)) + + +def _szukaj_wc(zapytanie, schema=BppQLSchema): + return _pk_set( + apply_search(Wydawnictwo_Ciagle.objects.all(), zapytanie, schema=schema) + ) + + +@pytest.mark.django_db +def test_djangoql_domyslnie_odsiewa_skasowane_autorstwo(wydawnictwo_ciagle_z_autorem): + """Zapytanie BEZ wzmianki o ``deleted_at`` nie widzi skasowanego + autorstwa — wcześniej JOIN po ``autorzy_set`` pokazywał husk.""" + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + zapytanie = f"autorzy_set.autor.id = {autor_id}" + + assert wc.pk in _szukaj_wc(zapytanie) + + wc.autorzy_set.first().delete() + + assert wc.pk not in _szukaj_wc(zapytanie) + + +@pytest.mark.django_db +def test_djangoql_jawny_warunek_pokazuje_kosz(wydawnictwo_ciagle_z_dwoma_autorami): + """Jawne ``deleted_at != None`` na tej samej relacji ZDEJMUJE domyślny + predykat — narzędzie audytowe dalej działa, tylko trzeba o kosz poprosić. + + Publikacja ma DWA autorstwa (jedno zostanie skasowane, drugie żyje) — + wyrocznia na to, że ``!= None`` jest skorelowane z tym samym JOIN-em. + Gdyby zostało zanegowanym ``~Q(deleted_at = None)``, Django zrobiłby + z tego „publikacja nie ma ŻADNEGO żywego autorstwa" i żywy współautor + zabrałby wynik. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + autorstwo = wc.autorzy_set.order_by("kolejnosc").first() + autor_id = autorstwo.autor_id + zapytanie = f"autorzy_set.autor.id = {autor_id} and autorzy_set.deleted_at != None" + + assert wc.pk not in _szukaj_wc(zapytanie) + + autorstwo.delete() + + assert wc.pk in _szukaj_wc(zapytanie) + + +@pytest.mark.django_db +def test_djangoql_jawne_deleted_at_none_nadal_dziala(wydawnictwo_ciagle_z_autorem): + """Jawne ``deleted_at = None`` (zapytanie deduplikatora) daje ten sam + wynik co domyślne odsiewanie — dublowanie predykatu nic nie psuje.""" + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + zapytanie = f"autorzy_set.autor.id = {autor_id} and autorzy_set.deleted_at = None" + + assert wc.pk in _szukaj_wc(zapytanie) + + wc.autorzy_set.first().delete() + + assert wc.pk not in _szukaj_wc(zapytanie) + + +@pytest.mark.django_db +def test_djangoql_predykat_jest_skorelowany_z_tym_samym_joinem( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """Predykat MUSI wisieć na tym samym JOIN-ie co warunek użytkownika. + + Wyrocznia: publikacja z DWOMA autorstwami, z których jedno skasowano. + Nieskorelowany predykat (osobny ``.filter()``) dałby „istnieje jakieś + żywe autorstwo" i przepuściłby zapytanie o autora skasowanego. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + pierwsze = wc.autorzy_set.order_by("kolejnosc").first() + drugie = wc.autorzy_set.order_by("kolejnosc").last() + assert pierwsze.pk != drugie.pk + + skasowany_autor_id = pierwsze.autor_id + zywy_autor_id = drugie.autor_id + pierwsze.delete() + + # Żywe autorstwo (drugi autor) NIE może „uratować" zapytania o autora, + # którego autorstwo skasowano. + assert wc.pk not in _szukaj_wc(f"autorzy_set.autor.id = {skasowany_autor_id}") + assert wc.pk in _szukaj_wc(f"autorzy_set.autor.id = {zywy_autor_id}") + + +@pytest.mark.django_db +def test_djangoql_negacja_na_relacji_jest_zachowawcza( + wydawnictwo_ciagle_z_dwoma_autorami, +): + """ŚLEPA PLAMA (przypięta świadomie): operator negujący na ścieżce + relacyjnej NIE dostaje predykatu — patrz „Znane ślepe plamy" w + ``bpp/djangoql_soft_delete.py``. + + Django dekoreluje negację na relacji wielowartościowej, więc predykat + wciągnięty pod ``~`` zmieniałby znaczenie zapytania zamiast zawężać + wiersz. Skutek: skasowane autorstwo nadal WYKLUCZA publikację z wyniku + ``!=``. To tryb ZACHOWAWCZY — kosz się nie ujawnia, wynik bywa węższy. + + Test istnieje po to, żeby ograniczenie było widoczne i żeby jego + przyszła naprawa (``exclude(rel__in=Subquery)``) zapaliła czerwone + światło tutaj, a nie u użytkownika. + """ + wc = wydawnictwo_ciagle_z_dwoma_autorami + pierwsze = wc.autorzy_set.order_by("kolejnosc").first() + skasowany_autor_id = pierwsze.autor_id + + assert wc.pk not in _szukaj_wc(f"autorzy_set.autor.id != {skasowany_autor_id}") + + pierwsze.delete() + + assert wc.pk not in _szukaj_wc(f"autorzy_set.autor.id != {skasowany_autor_id}") + + +@pytest.mark.django_db +def test_djangoql_agregat_count_nie_liczy_kosza(wydawnictwo_ciagle_z_dwoma_autorami): + """``autorzy_set__count`` filtruje skorelowanym PODZAPYTANIEM, nie + JOIN-em — źródłem jest ``related_model._base_manager`` (goły ``Manager``, + NIE nasz ``BppSoftDeleteManager``). Bez podmiany źródła agregat + zawyżałby licznik o kosz.""" + wc = wydawnictwo_ciagle_z_dwoma_autorami + + assert wc.pk in _szukaj_wc("autorzy_set__count = 2") + + wc.autorzy_set.order_by("kolejnosc").first().delete() + + assert wc.pk not in _szukaj_wc("autorzy_set__count = 2") + assert wc.pk in _szukaj_wc("autorzy_set__count = 1") + + # …a jawna wzmianka o koszu na tej samej relacji zdejmuje odsiewanie + # również z agregatu (licznik znów obejmuje skasowane autorstwo). + assert wc.pk in _szukaj_wc( + "autorzy_set__count = 2 and autorzy_set.deleted_at != None" + ) + + +@pytest.mark.django_db +def test_schemat_ograniczony_i_llm_maja_ten_sam_kontrakt( + wydawnictwo_ciagle_z_autorem, +): + """Wszystkie trzy schematy BPP (admin, web-edytor, API/LLM) odsiewają + kosz tak samo — inaczej ten sam string zapytania dawałby różne wyniki + w zależności od punktu wejścia.""" + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + wc.autorzy_set.first().delete() + + for schema in (BppQLSchema, BppQLSchemaOgraniczony, RekordLLMSchema): + assert wc.pk not in _szukaj_wc( + f"autorzy_set.autor.id = {autor_id}", schema=schema + ), schema.__name__ + + +@pytest.mark.django_db +def test_api_zapytanie_autor_nie_widzi_skasowanego_autorstwa( + wydawnictwo_ciagle_z_autorem, +): + """Ścieżka ``/api/v1/zapytanie/autor/`` (``RekordLLMSchema`` na korzeniu + ``Autor``) — JOIN po ``wydawnictwo_ciagle_autor``.""" + wc = wydawnictwo_ciagle_z_autorem + autorstwo = wc.autorzy_set.first() + zapytanie = f"wydawnictwo_ciagle_autor.rekord.id = {wc.pk}" + + def _autorzy(q): + return _pk_set(apply_search(Autor.objects.all(), q, schema=RekordLLMSchema)) + + assert autorstwo.autor_id in _autorzy(zapytanie) + + autorstwo.delete() + + assert autorstwo.autor_id not in _autorzy(zapytanie) + assert autorstwo.autor_id in _autorzy( + zapytanie + " and wydawnictwo_ciagle_autor.deleted_at != None" + ) diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_orm.py b/src/bpp/tests/test_soft_delete/test_kanarek_orm.py index 9a3312a31..f8ba106b4 100644 --- a/src/bpp/tests/test_soft_delete/test_kanarek_orm.py +++ b/src/bpp/tests/test_soft_delete/test_kanarek_orm.py @@ -45,8 +45,18 @@ czy ``Charakter_Formalny`` to zwykły FK. Znowu: zależy od lewego modelu; * ścieżka sklejona w innej instrukcji niż wywołanie, ``**kwargs`` przekazane przez kilka warstw; -* DjangoQL i surowe parametry GET changelistu admina — to język zapytań - użytkownika, świadomie surowy (patrz raport ``orm-leak-report.md``). +* surowe parametry GET changelistu admina (``?autorzy_set__…=``) — whitelist + w ``lookup_allowed`` przepuszcza je bez predykatu; wszystkie linki + GENEROWANE przez BPP predykat niosą (patrz ``orm-leak-report.md``, poz. C). + +**DjangoQL nie jest już ślepą plamą.** Wcześniejsze uzasadnienie w tym +miejscu („język zapytań użytkownika = narzędzie audytowe, świadomie surowe, +ma widzieć kosz") jest NIEAKTUALNE — decyzją właściciela projektu z +2026-08-07 język zapytań domyślnie odsiewa skasowane, a kosz pokazuje +dopiero na jawne żądanie. Realizuje to ``bpp/djangoql_soft_delete.py`` +(kontrakt w docstringu modułu), pilnują testy +``test_djangoql_bez_skasowanych.py``. Ten kanarek i tak nie miałby czego +skanować — zapytania DjangoQL powstają w runtime, nie w kodzie. Gdy test padnie =============== From 8df5007d2828eb77cf38c82ee9c1f9a1be05b254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 13:12:46 +0200 Subject: [PATCH 49/75] docs(djangoql): zregeneruj artefakty schematu dla LLM (deleted_at) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trzy commitowane snapshoty schematu DjangoQL (src/bpp/data/{rekord,autor,autorzy}_djangoql_schema.compact.txt) byly nieaktualne od fazy 01 soft-delete: ZERO wystapien deleted_at, przy czym allow-lista schematu zawiera trzy through-modele *_Autor. Konsument (LLM, integrator /api/v1/zapytanie/) nie mial skad wiedziec, ze pole w ogole istnieje. Generator ISTNIEJE — manage.py opisz_schemat_djangoql_dla_llm --wszystkie-korzenie. Nic recznie nie dopisywalem; pliki sa w calosci wynikiem regeneracji. Regeneracja domyka tez inne zalegle roznice, ktore uzbieraly sie od ostatniego zrzutu: coar_type, coar_access_right, kod_bcp47, ror_id, uri licencji, nie_eksportuj_przez_api oraz znikniecie pola urodzony (blocklist PII). Do naglowka compact dolozona NOTA_SOFT_DELETE: samo pojawienie sie deleted_at w opisie pol nie mowi konsumentowi, ze zapytanie bez wzmianki o nim juz odsiewa kosz. Bez noty klient dopisywalby "deleted_at = None" w kolko albo — gorzej — sadzil, ze wyniki obejmuja skasowane. Nota podaje tez sposob zajrzenia do kosza. ZRODLO GENERACJI: baza testowa (baseline-sql/baseline.sql). Jest odtwarzalna i zawiera wylacznie standardowe slowniki referencyjne BPP — zadnych danych instytucji. Nastepny commit dokłada test swiezosci, ktory z tego korzysta. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../data/autor_djangoql_schema.compact.txt | 56 +++++++++++++------ .../data/autorzy_djangoql_schema.compact.txt | 56 +++++++++++++------ .../data/rekord_djangoql_schema.compact.txt | 56 +++++++++++++------ .../opisz_schemat_djangoql_dla_llm.py | 19 +++++++ 4 files changed, 133 insertions(+), 54 deletions(-) diff --git a/src/bpp/data/autor_djangoql_schema.compact.txt b/src/bpp/data/autor_djangoql_schema.compact.txt index 79d93e54a..143e95871 100644 --- a/src/bpp/data/autor_djangoql_schema.compact.txt +++ b/src/bpp/data/autor_djangoql_schema.compact.txt @@ -1,8 +1,13 @@ -# BPP 202607.1397 +# BPP 202608.1399 # Model: bpp.Autor Schemat: bpp.djangoql_schema.RekordLLMSchema # Wygenerowano: manage.py opisz_schemat_djangoql_dla_llm # Plik generowany — nie edytuj ręcznie. +# Rekordy skasowane (soft-delete, pole `deleted_at`) są DOMYŚLNIE pomijane. +# Zapytanie bez wzmianki o `deleted_at` widzi tylko żywe wiersze — nie dopisuj +# `deleted_at = None`, to już działa. Żeby zajrzeć do kosza, wymień to pole na +# wybranej relacji: `autorzy_set.deleted_at != None` = TYLKO skasowane autorstwa. + # DjangoQL schema # Query: , combined with and/or, grouped with (). # Negate with != / !~ / not in / not startswith / not endswith (no standalone `not`). @@ -58,7 +63,6 @@ bpp.autor: sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni tytul -> bpp.tytul? match nazwa - urodzony date? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -90,6 +94,7 @@ bpp.jednostka: level int lft int nazwa str + nie_eksportuj_przez_api bool obca_jednostka -> bpp.uczelnia? opis str? ostatnio_zmieniony datetime? @@ -103,6 +108,7 @@ bpp.jednostka: praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int rodzaj -> bpp.rodzajjednostki? match nazwa + ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" skrot_nazwy str? @@ -123,13 +129,13 @@ bpp.jednostka: bpp.autor_dyscyplina: autor -> bpp.autor - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id int procent_dyscypliny float? procent_subdyscypliny float? rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa rok int - subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? zatrudnienie_do datetime? zatrudnienie_od datetime? @@ -149,7 +155,7 @@ bpp.autorzy: afiliuje bool autor -> bpp.autor data_oswiadczenia date - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id unknown jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow @@ -165,7 +171,7 @@ bpp.autorzy: bpp.cache_punktacja_autora: autor -> bpp.autor - dyscyplina -> bpp.dyscyplina_naukowa match nazwa + dyscyplina -> bpp.dyscyplina_naukowa id int jednostka -> bpp.jednostka pkdaut float @@ -222,7 +228,8 @@ bpp.patent_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -233,6 +240,8 @@ bpp.patent_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.patent + restored_at datetime? + transaction_id str? typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str @@ -483,7 +492,8 @@ bpp.wydawnictwo_ciagle_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -494,6 +504,8 @@ bpp.wydawnictwo_ciagle_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_ciagle + restored_at datetime? + transaction_id str? typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str @@ -603,7 +615,8 @@ bpp.wydawnictwo_zwarte_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -614,6 +627,8 @@ bpp.wydawnictwo_zwarte_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_zwarte + restored_at datetime? + transaction_id str? typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str @@ -771,9 +786,11 @@ bpp.zrodlo_informacji: wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? bpp.rodzaj_prawa_patentowego: - id int - nazwa str - patent -> bpp.patent? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_15cd dla patentu. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, patenty o tym rodzaju prawa zostaną wyeksportowane bez typu zasobu. + eksportuj_jako_patent bool "Eksportuj do CERIF jako patent" — Odznacz dla praw, które nie są patentami w rozumieniu słownika COAR (np. znak towarowy). Takie rekordy nie trafią do eksportu CERIF/OpenAIRE — profil wymaga dla każdego rekordu typu z gałęzi „patent”, więc jedyną alternatywą byłoby zadeklarowanie ich patentami wbrew prawdzie. + id int + nazwa str + patent -> bpp.patent? taggit.tag: id int @@ -798,6 +815,7 @@ bpp.status_korekty: bpp.jezyk: id int + kod_bcp47 str "Kod języka wg BCP 47" — Kod języka w notacji BCP 47 (RFC 5646), np. „pl”, „en”, „en-GB”. Używany w eksporcie CERIF/OpenAIRE jako wartość atrybutu xml:lang; gdy pusty, elementy w tym języku zostaną wyeksportowane bez oznaczenia języka. nazwa str pbn_uid -> pbn_api.language? praca_doktorska -> bpp.praca_doktorska? @@ -869,6 +887,7 @@ bpp.charakter_formalny: charakter_pbn -> bpp.charakter_pbn? match help_text charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. id int level int lft int @@ -929,11 +948,13 @@ bpp.licencja_openaccess: nazwa str rekord -> bpp.rekord? skrot str + uri str "Adres URI licencji" — Kanoniczny adres URI licencji, np. https://creativecommons.org/licenses/by/4.0/ . Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace na tej licencji zostaną wyeksportowane bez odnośnika do jej treści. wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? zrodlo -> bpp.zrodlo? bpp.tryb_openaccess_wydawnictwo_ciagle: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -1001,6 +1022,7 @@ bpp.nagroda: uzasadnienie str bpp.tryb_openaccess_wydawnictwo_zwarte: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -1121,11 +1143,9 @@ dictionaries (shared relation values, referenced above): bpp.plec nazwa: "kobieta", "mężczyzna" bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "dr hab. inż.", "dr inż.", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "mgr, inż.", "prof. dr", "prof. dr hab.", "prof. dr hab. inż.", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" + nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" bpp.rodzajjednostki nazwa: "Koło naukowe", "Standard", "Wydział" - bpp.dyscyplina_naukowa - nazwa: "archeologia", "architektura i urbanistyka", "astronomia", "automatyka, elektronika, elektrotechnika i technologie kosmiczne", "biologia medyczna", "biotechnologia", "ekonomia i finanse", "etnologia i antropologia kulturowa", "filozofia", "geografia społeczno-ekonomiczna i gospodarka przestrzenna", "historia", "informatyka", "informatyka techniczna i telekomunikacja", "inżynieria bezpieczeństwa", "inżynieria biomedyczna", "inżynieria chemiczna", "inżynieria lądowa, geodezja i transport", "inżynieria materiałowa", "inżynieria mechaniczna", "inżynieria środowiska, górnictwo i energetyka", "językoznawstwo", "literaturoznawstwo", "matematyka", "nauki biblijne", "nauki biologiczne", "nauki chemiczne", "nauki farmaceutyczne", "nauki fizyczne", "nauki leśne", "nauki medyczne", "nauki o bezpieczeństwie", "nauki o komunikacji społecznej i mediach", "nauki o kulturze fizycznej", "nauki o kulturze i religii", "nauki o polityce i administracji", "nauki o rodzinie", "nauki o sztuce", "nauki o zarządzaniu i jakości", "nauki o zdrowiu", "nauki o Ziemi i środowisku", "nauki prawne", "nauki socjologiczne", "nauki teologiczne", "pedagogika", "polonistyka", "prawo kanoniczne", "psychologia", "rolnictwo i ogrodnictwo", "stosunki międzynarodowe", "sztuki filmowe i teatralne", "sztuki muzyczne", "sztuki plastyczne i konserwacja dzieł sztuki", "technologia żywności i żywienia", "weterynaria", "zootechnika i rybactwo" ewaluacja_common.rodzaj_autora nazwa: "doktorant", "inny zatrudniony, nie naukowy", "pracownik badawczy spoza N", "pracownik naukowy w liczbie N" bpp.typ_odpowiedzialnosci @@ -1137,11 +1157,11 @@ dictionaries (shared relation values, referenced above): bpp.status_korekty nazwa: "po korekcie", "przed korektą", "w trakcie korekty" bpp.jezyk - nazwa: "abchaski", "Achinese", "Acoli", "Adangme", "Adyghe", "afar", "Afrihili", "Afro-Asiatic languages", "afrykanerski", "Ainu", "ajmara", "akadyjski", "akan", "albański", "Aleut", "Algonquian languages", "Altaic languages", "amharski", "angielski", "Angika", "Apache languages", "arabski", "aragoński", "Arapaho", "Arawak", "Aromanian", "Artificial languages", "asamski", "Asturian", "Athapascan languages", "Australian languages", "Austronesian languages", "Awadhi", "awarski", "awestyjski", "azerski", "Balinese", "Baltic languages", "Baluchi", "bambara", "Bamileke languages", "Banda languages", "Bantu languages", "Basa", "baskijski", "baszkirski", "Batak languages", "Beja", "Bemba", "bengalski", "Berber languages", "Bhojpuri", "białoruski", "bihari", "Bikol", "Bini", "birmański", "bislama", "Blin", "Blissymbols", "bośniacki", "Braj", "brak danych", "bretoński", "Buginese", "bułgarski", "Buriat", "Caddo", "Caucasian languages", "Cebuano", "Celtic languages", "Central American Indian languages", "Chagatai", "Chamic languages", "Cherokee", "Cheyenne", "Chibcha", "Chinook jargon", "chiński", "Chipewyan", "Choctaw", "chorwacki", "Chuukese", "Classical Newari", "Coptic", "Creek", "Creoles and pidgins", "Creoles and pidgins, English based", "Creoles and pidgins, French-based", "Creoles and pidgins, Portuguese-based", "Crimean Tatar", "Cushitic languages", "czamorro", "czarnogórski", "czeczeński", "czeski", "cziczewa", "czuwaski", "Dakota", "Dargwa", "Delaware", "Dinka", "Dogri", "Dogrib", "Dravidian languages", "Duala", "duński", "Dutch, Middle (ca.1050-1350)", "Dyula", "dzongkha", "Eastern Frisian", "Efik", "Egyptian (Ancient)", "Ekajuk", "Elamite", "English, Middle (1100-1500)", "Erzya", "esperanto", "estoński", "ewe", "Ewondo", "Fang", "Fanti", "farerski", "fidżyjski", "Filipino", "Finno-Ugrian languages", "fiński", "Fon", "francuski", "French, Middle (ca.1400-1600)", "French, Old (842-ca.1400)", "Friulian", "fryzyjski", "ful", "Ga", "gaelicki szkocki", "Galibi Carib", "galicyjski", "Gayo", "Gbaya", "Geez", "German, Middle High (ca.1050-1500)", "German, Old High (ca.750-1050)", "Germanic languages", "Gilbertese", "Gondi", "Gorontalo", "Gothic", "Grebo", "grecki (nowożytny)", "grecki (starożytny)", "grenlandzki", "gruziński", "guarani", "gudźarati", "Gwich'in", "Haida", "haitański", "hausa", "Hawaiian", "hebrajski", "herero", "Hiligaynon", "Himachali languages", "hindi", "hiri motu", "hiszpański", "Hittite", "Hmong", "holenderski", "Hupa", "Iban", "ido", "igbo", "Ijo languages", "Iloko", "Inari Sami", "Indic languages", "Indo-European languages", "indonezyjski", "Ingush", "inny", "interlingua", "inuktitut", "inupiak", "Iranian languages", "Irish, Middle (900-1200)", "Irish, Old (to 900)", "irlandzki", "Iroquoian languages", "islandzki", "japoński", "jawajski", "jidysz", "joruba", "Judeo-Arabic", "Judeo-Persian", "Kabardian", "Kabyle", "Kachin", "Kalmyk", "Kamba", "kannada", "kanuri", "Kara-Kalpak", "Karachay-Balkar", "Karelian", "Karen languages", "Kashubian", "kaszmirski", "kataloński", "Kawi", "kazachski", "keczua", "Khasi", "khmerski", "Khoisan languages", "Khotanese", "kikuju", "Kimbundu", "kirgiski", "Klingon", "komi", "kongo", "Konkani", "koreański", "kornijski", "korsykański", "Kosraean", "Kpelle", "kri", "Kru languages", "Kumyk", "kurdyjski", "Kurukh", "Kutenai", "kwanyama", "Ladino", "Lahnda", "Lamba", "Land Dayak languages", "laotański", "Lezghian", "limburski", "lingala", "litewski", "Lojban", "Low German", "Lower Sorbian", "Lozi", "Luba-katanga", "Luba-Lulua", "luganda", "Luiseno", "luksemburski", "Lule Sami", "Lunda", "Luo (Kenya and Tanzania)", "Lushai", "łaciński", "łotewski", "macedoński", "Madurese", "Magahi", "Maithili", "Makasar", "malajalam", "malajski", "malediwski", "malgaski", "maltański", "Manchu", "Mandar", "Mandingo", "Manipuri", "Manobo languages", "manx", "maoryski", "Mapudungun", "marathi", "Mari", "marszalski", "Marwari", "Masai", "Mayan languages", "Mende", "Mi'kmaq", "Minangkabau", "Mirandese", "Mohawk", "Moksha", "Mon-Khmer languages", "Mongo", "mongolski", "Mossi", "Munda languages", "N'Ko", "Nahuatl languages", "nauruański", "nawaho", "ndebele południowy", "ndebele północny", "ndonga", "Neapolitan", "Nepal Bhasa", "nepalski", "Nias", "niemiecki", "Niger-Kordofanian languages", "Nilo-Saharan languages", "Niuean", "No linguistic content", "Nogai", "Norse, Old", "North American Indian languages", "Northern Frisian", "norweski", "norweski (bokmål)", "norweski (nynorsk)", "Nubian languages", "nuosu", "Nyamwezi", "Nyankole", "Nyoro", "Nzima", "occidental", "odżibwe", "Official Aramaic (700-300 BCE)", "orija", "ormiański", "oromo", "Osage", "osetyjski", "Otomian languages", "Pahlavi", "Palauan", "pali", "Pampanga", "Pangasinan", "Papiamento", "Papuan languages", "paszto", "Pedi", "pendżabski", "Persian, Old (ca.600-400 B.C.)", "perski", "Philippine languages", "Phoenician", "Pohnpeian", "polski", "portugalski", "północnolapoński", "Prakrit languages", "Provençal, Old (to 1500)", "prowansalski", "Rajasthani", "Rapanui", "Rarotongan", "Romance languages", "romansz", "Romany", "rosyjski", "ruanda", "rumuński", "rundi", "Salishan languages", "Samaritan Aramaic", "Sami languages", "samoański", "Sandawe", "sango", "sanskryt", "Santali", "sardyński", "Sasak", "Scots", "Selkup", "Semitic languages", "serbski", "Serer", "Shan", "shona", "Sicilian", "Sidamo", "Sign Languages", "Siksika", "sindhi", "Sino-Tibetan languages", "Siouan languages", "Skolt Sami", "Slave (Athapascan)", "Slavic languages", "słowacki", "słoweński", "Sogdian", "somalijski", "Songhai languages", "Soninke", "Sorbian languages", "sotho południowy", "South American Indian languages", "Southern Altai", "Southern Sami", "Sranan Tongo", "Standard Moroccan Tamazight", "staro-cerkiewno-słowiański", "suahili", "suazi", "Sukuma", "sumeryjski", "sundajski", "Susu", "Swiss German", "syngaleski", "syryjski", "syryjski (klasyczny)", "szwedzki", "tadżycki", "tagalski", "tahitański", "Tai languages", "tajski", "Tamashek", "tamilski", "tatarski", "telugu", "Tereno", "Tetum", "Tigre", "tigrinia", "Timne", "Tiv", "Tlingit", "Tok Pisin", "Tokelau", "tonga", "Tonga (Nyasa)", "Tsimshian", "tsonga", "tswana", "Tumbuka", "Tupi languages", "turecki", "Turkish, Ottoman (1500-1928)", "turkmeński", "Tuvalu", "Tuvinian", "twi", "tybetański", "Udmurt", "ugarycki", "ujgurski", "ukraiński", "Umbundu", "Uncoded languages", "Undetermined", "Upper Sorbian", "urdu", "uzbecki", "Vai", "venda", "volapük", "Votic", "Wakashan languages", "walijski", "waloński", "Waray", "Washo", "węgierski", "wiele języków", "wietnamski", "włoski", "Wolaitta", "wolof", "xhosa", "Yakut", "Yao", "Yapese", "Yupik languages", "Zande languages", "Zapotec", "Zaza", "Zenaga", "zhuang", "zulu", "Zuni" + nazwa: "angielski", "brak danych", "francuski", "hiszpański", "inny", "niemiecki", "polski", "rosyjski", "włoski" bpp.typ_kbn nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Editorial", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Poster - streszczenie", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" + nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" bpp.czas_udostepnienia_openaccess nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" bpp.licencja_openaccess @@ -1149,7 +1169,7 @@ dictionaries (shared relation values, referenced above): bpp.tryb_openaccess_wydawnictwo_ciagle nazwa: "Inne", "Otwarte czasopismo", "Otwarte repositorium" bpp.wersja_tekstu_openaccess - nazwa: "Inna", "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" + nazwa: "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" bpp.tryb_openaccess_wydawnictwo_zwarte nazwa: "Inne", "Otwarte repositorium", "Witryna wydawcy" bpp.charakter_pbn diff --git a/src/bpp/data/autorzy_djangoql_schema.compact.txt b/src/bpp/data/autorzy_djangoql_schema.compact.txt index 8b51b3301..0b226ad46 100644 --- a/src/bpp/data/autorzy_djangoql_schema.compact.txt +++ b/src/bpp/data/autorzy_djangoql_schema.compact.txt @@ -1,8 +1,13 @@ -# BPP 202607.1397 +# BPP 202608.1399 # Model: bpp.Autorzy Schemat: bpp.djangoql_schema.RekordLLMSchema # Wygenerowano: manage.py opisz_schemat_djangoql_dla_llm # Plik generowany — nie edytuj ręcznie. +# Rekordy skasowane (soft-delete, pole `deleted_at`) są DOMYŚLNIE pomijane. +# Zapytanie bez wzmianki o `deleted_at` widzi tylko żywe wiersze — nie dopisuj +# `deleted_at = None`, to już działa. Żeby zajrzeć do kosza, wymień to pole na +# wybranej relacji: `autorzy_set.deleted_at != None` = TYLKO skasowane autorstwa. + # DjangoQL schema # Query: , combined with and/or, grouped with (). # Negate with != / !~ / not in / not startswith / not endswith (no standalone `not`). @@ -25,7 +30,7 @@ bpp.autorzy: afiliuje bool autor -> bpp.autor data_oswiadczenia date - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id unknown jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow @@ -76,7 +81,6 @@ bpp.autor: sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni tytul -> bpp.tytul? match nazwa - urodzony date? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -113,6 +117,7 @@ bpp.jednostka: level int lft int nazwa str + nie_eksportuj_przez_api bool obca_jednostka -> bpp.uczelnia? opis str? ostatnio_zmieniony datetime? @@ -126,6 +131,7 @@ bpp.jednostka: praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int rodzaj -> bpp.rodzajjednostki? match nazwa + ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" skrot_nazwy str? @@ -232,13 +238,13 @@ bpp.funkcja_autora: bpp.autor_dyscyplina: autor -> bpp.autor - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id int procent_dyscypliny float? procent_subdyscypliny float? rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa rok int - subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? zatrudnienie_do datetime? zatrudnienie_od datetime? @@ -256,7 +262,7 @@ bpp.autor_jednostka: bpp.cache_punktacja_autora: autor -> bpp.autor - dyscyplina -> bpp.dyscyplina_naukowa match nazwa + dyscyplina -> bpp.dyscyplina_naukowa id int jednostka -> bpp.jednostka pkdaut float @@ -313,7 +319,8 @@ bpp.patent_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -324,6 +331,8 @@ bpp.patent_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.patent + restored_at datetime? + transaction_id str? typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str @@ -574,7 +583,8 @@ bpp.wydawnictwo_ciagle_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -585,6 +595,8 @@ bpp.wydawnictwo_ciagle_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_ciagle + restored_at datetime? + transaction_id str? typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str @@ -694,7 +706,8 @@ bpp.wydawnictwo_zwarte_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -705,6 +718,8 @@ bpp.wydawnictwo_zwarte_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_zwarte + restored_at datetime? + transaction_id str? typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str @@ -747,6 +762,7 @@ bpp.charakter_formalny: charakter_pbn -> bpp.charakter_pbn? match help_text charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. id int level int lft int @@ -770,6 +786,7 @@ bpp.charakter_formalny: bpp.jezyk: id int + kod_bcp47 str "Kod języka wg BCP 47" — Kod języka w notacji BCP 47 (RFC 5646), np. „pl”, „en”, „en-GB”. Używany w eksporcie CERIF/OpenAIRE jako wartość atrybutu xml:lang; gdy pusty, elementy w tym języku zostaną wyeksportowane bez oznaczenia języka. nazwa str pbn_uid -> pbn_api.language? praca_doktorska -> bpp.praca_doktorska? @@ -818,6 +835,7 @@ bpp.licencja_openaccess: nazwa str rekord -> bpp.rekord? skrot str + uri str "Adres URI licencji" — Kanoniczny adres URI licencji, np. https://creativecommons.org/licenses/by/4.0/ . Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace na tej licencji zostaną wyeksportowane bez odnośnika do jej treści. wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? zrodlo -> bpp.zrodlo? @@ -961,9 +979,11 @@ bpp.zrodlo_informacji: wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? bpp.rodzaj_prawa_patentowego: - id int - nazwa str - patent -> bpp.patent? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_15cd dla patentu. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, patenty o tym rodzaju prawa zostaną wyeksportowane bez typu zasobu. + eksportuj_jako_patent bool "Eksportuj do CERIF jako patent" — Odznacz dla praw, które nie są patentami w rozumieniu słownika COAR (np. znak towarowy). Takie rekordy nie trafią do eksportu CERIF/OpenAIRE — profil wymaga dla każdego rekordu typu z gałęzi „patent”, więc jedyną alternatywą byłoby zadeklarowanie ich patentami wbrew prawdzie. + id int + nazwa str + patent -> bpp.patent? bpp.wydawnictwo_ciagle_tytul: id int @@ -973,6 +993,7 @@ bpp.wydawnictwo_ciagle_tytul: tytul str "Tytuł" bpp.tryb_openaccess_wydawnictwo_ciagle: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -1007,6 +1028,7 @@ bpp.nagroda: uzasadnienie str bpp.tryb_openaccess_wydawnictwo_zwarte: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -1116,8 +1138,6 @@ bpp.organprzyznajacynagrody: skrot str dictionaries (shared relation values, referenced above): - bpp.dyscyplina_naukowa - nazwa: "archeologia", "architektura i urbanistyka", "astronomia", "automatyka, elektronika, elektrotechnika i technologie kosmiczne", "biologia medyczna", "biotechnologia", "ekonomia i finanse", "etnologia i antropologia kulturowa", "filozofia", "geografia społeczno-ekonomiczna i gospodarka przestrzenna", "historia", "informatyka", "informatyka techniczna i telekomunikacja", "inżynieria bezpieczeństwa", "inżynieria biomedyczna", "inżynieria chemiczna", "inżynieria lądowa, geodezja i transport", "inżynieria materiałowa", "inżynieria mechaniczna", "inżynieria środowiska, górnictwo i energetyka", "językoznawstwo", "literaturoznawstwo", "matematyka", "nauki biblijne", "nauki biologiczne", "nauki chemiczne", "nauki farmaceutyczne", "nauki fizyczne", "nauki leśne", "nauki medyczne", "nauki o bezpieczeństwie", "nauki o komunikacji społecznej i mediach", "nauki o kulturze fizycznej", "nauki o kulturze i religii", "nauki o polityce i administracji", "nauki o rodzinie", "nauki o sztuce", "nauki o zarządzaniu i jakości", "nauki o zdrowiu", "nauki o Ziemi i środowisku", "nauki prawne", "nauki socjologiczne", "nauki teologiczne", "pedagogika", "polonistyka", "prawo kanoniczne", "psychologia", "rolnictwo i ogrodnictwo", "stosunki międzynarodowe", "sztuki filmowe i teatralne", "sztuki muzyczne", "sztuki plastyczne i konserwacja dzieł sztuki", "technologia żywności i żywienia", "weterynaria", "zootechnika i rybactwo" bpp.typ_odpowiedzialnosci nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" bpp.funkcja_autora @@ -1125,19 +1145,19 @@ dictionaries (shared relation values, referenced above): bpp.plec nazwa: "kobieta", "mężczyzna" bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "dr hab. inż.", "dr inż.", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "mgr, inż.", "prof. dr", "prof. dr hab.", "prof. dr hab. inż.", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" + nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" bpp.rodzajjednostki nazwa: "Koło naukowe", "Standard", "Wydział" bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Editorial", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Poster - streszczenie", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" + nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" bpp.jezyk - nazwa: "abchaski", "Achinese", "Acoli", "Adangme", "Adyghe", "afar", "Afrihili", "Afro-Asiatic languages", "afrykanerski", "Ainu", "ajmara", "akadyjski", "akan", "albański", "Aleut", "Algonquian languages", "Altaic languages", "amharski", "angielski", "Angika", "Apache languages", "arabski", "aragoński", "Arapaho", "Arawak", "Aromanian", "Artificial languages", "asamski", "Asturian", "Athapascan languages", "Australian languages", "Austronesian languages", "Awadhi", "awarski", "awestyjski", "azerski", "Balinese", "Baltic languages", "Baluchi", "bambara", "Bamileke languages", "Banda languages", "Bantu languages", "Basa", "baskijski", "baszkirski", "Batak languages", "Beja", "Bemba", "bengalski", "Berber languages", "Bhojpuri", "białoruski", "bihari", "Bikol", "Bini", "birmański", "bislama", "Blin", "Blissymbols", "bośniacki", "Braj", "brak danych", "bretoński", "Buginese", "bułgarski", "Buriat", "Caddo", "Caucasian languages", "Cebuano", "Celtic languages", "Central American Indian languages", "Chagatai", "Chamic languages", "Cherokee", "Cheyenne", "Chibcha", "Chinook jargon", "chiński", "Chipewyan", "Choctaw", "chorwacki", "Chuukese", "Classical Newari", "Coptic", "Creek", "Creoles and pidgins", "Creoles and pidgins, English based", "Creoles and pidgins, French-based", "Creoles and pidgins, Portuguese-based", "Crimean Tatar", "Cushitic languages", "czamorro", "czarnogórski", "czeczeński", "czeski", "cziczewa", "czuwaski", "Dakota", "Dargwa", "Delaware", "Dinka", "Dogri", "Dogrib", "Dravidian languages", "Duala", "duński", "Dutch, Middle (ca.1050-1350)", "Dyula", "dzongkha", "Eastern Frisian", "Efik", "Egyptian (Ancient)", "Ekajuk", "Elamite", "English, Middle (1100-1500)", "Erzya", "esperanto", "estoński", "ewe", "Ewondo", "Fang", "Fanti", "farerski", "fidżyjski", "Filipino", "Finno-Ugrian languages", "fiński", "Fon", "francuski", "French, Middle (ca.1400-1600)", "French, Old (842-ca.1400)", "Friulian", "fryzyjski", "ful", "Ga", "gaelicki szkocki", "Galibi Carib", "galicyjski", "Gayo", "Gbaya", "Geez", "German, Middle High (ca.1050-1500)", "German, Old High (ca.750-1050)", "Germanic languages", "Gilbertese", "Gondi", "Gorontalo", "Gothic", "Grebo", "grecki (nowożytny)", "grecki (starożytny)", "grenlandzki", "gruziński", "guarani", "gudźarati", "Gwich'in", "Haida", "haitański", "hausa", "Hawaiian", "hebrajski", "herero", "Hiligaynon", "Himachali languages", "hindi", "hiri motu", "hiszpański", "Hittite", "Hmong", "holenderski", "Hupa", "Iban", "ido", "igbo", "Ijo languages", "Iloko", "Inari Sami", "Indic languages", "Indo-European languages", "indonezyjski", "Ingush", "inny", "interlingua", "inuktitut", "inupiak", "Iranian languages", "Irish, Middle (900-1200)", "Irish, Old (to 900)", "irlandzki", "Iroquoian languages", "islandzki", "japoński", "jawajski", "jidysz", "joruba", "Judeo-Arabic", "Judeo-Persian", "Kabardian", "Kabyle", "Kachin", "Kalmyk", "Kamba", "kannada", "kanuri", "Kara-Kalpak", "Karachay-Balkar", "Karelian", "Karen languages", "Kashubian", "kaszmirski", "kataloński", "Kawi", "kazachski", "keczua", "Khasi", "khmerski", "Khoisan languages", "Khotanese", "kikuju", "Kimbundu", "kirgiski", "Klingon", "komi", "kongo", "Konkani", "koreański", "kornijski", "korsykański", "Kosraean", "Kpelle", "kri", "Kru languages", "Kumyk", "kurdyjski", "Kurukh", "Kutenai", "kwanyama", "Ladino", "Lahnda", "Lamba", "Land Dayak languages", "laotański", "Lezghian", "limburski", "lingala", "litewski", "Lojban", "Low German", "Lower Sorbian", "Lozi", "Luba-katanga", "Luba-Lulua", "luganda", "Luiseno", "luksemburski", "Lule Sami", "Lunda", "Luo (Kenya and Tanzania)", "Lushai", "łaciński", "łotewski", "macedoński", "Madurese", "Magahi", "Maithili", "Makasar", "malajalam", "malajski", "malediwski", "malgaski", "maltański", "Manchu", "Mandar", "Mandingo", "Manipuri", "Manobo languages", "manx", "maoryski", "Mapudungun", "marathi", "Mari", "marszalski", "Marwari", "Masai", "Mayan languages", "Mende", "Mi'kmaq", "Minangkabau", "Mirandese", "Mohawk", "Moksha", "Mon-Khmer languages", "Mongo", "mongolski", "Mossi", "Munda languages", "N'Ko", "Nahuatl languages", "nauruański", "nawaho", "ndebele południowy", "ndebele północny", "ndonga", "Neapolitan", "Nepal Bhasa", "nepalski", "Nias", "niemiecki", "Niger-Kordofanian languages", "Nilo-Saharan languages", "Niuean", "No linguistic content", "Nogai", "Norse, Old", "North American Indian languages", "Northern Frisian", "norweski", "norweski (bokmål)", "norweski (nynorsk)", "Nubian languages", "nuosu", "Nyamwezi", "Nyankole", "Nyoro", "Nzima", "occidental", "odżibwe", "Official Aramaic (700-300 BCE)", "orija", "ormiański", "oromo", "Osage", "osetyjski", "Otomian languages", "Pahlavi", "Palauan", "pali", "Pampanga", "Pangasinan", "Papiamento", "Papuan languages", "paszto", "Pedi", "pendżabski", "Persian, Old (ca.600-400 B.C.)", "perski", "Philippine languages", "Phoenician", "Pohnpeian", "polski", "portugalski", "północnolapoński", "Prakrit languages", "Provençal, Old (to 1500)", "prowansalski", "Rajasthani", "Rapanui", "Rarotongan", "Romance languages", "romansz", "Romany", "rosyjski", "ruanda", "rumuński", "rundi", "Salishan languages", "Samaritan Aramaic", "Sami languages", "samoański", "Sandawe", "sango", "sanskryt", "Santali", "sardyński", "Sasak", "Scots", "Selkup", "Semitic languages", "serbski", "Serer", "Shan", "shona", "Sicilian", "Sidamo", "Sign Languages", "Siksika", "sindhi", "Sino-Tibetan languages", "Siouan languages", "Skolt Sami", "Slave (Athapascan)", "Slavic languages", "słowacki", "słoweński", "Sogdian", "somalijski", "Songhai languages", "Soninke", "Sorbian languages", "sotho południowy", "South American Indian languages", "Southern Altai", "Southern Sami", "Sranan Tongo", "Standard Moroccan Tamazight", "staro-cerkiewno-słowiański", "suahili", "suazi", "Sukuma", "sumeryjski", "sundajski", "Susu", "Swiss German", "syngaleski", "syryjski", "syryjski (klasyczny)", "szwedzki", "tadżycki", "tagalski", "tahitański", "Tai languages", "tajski", "Tamashek", "tamilski", "tatarski", "telugu", "Tereno", "Tetum", "Tigre", "tigrinia", "Timne", "Tiv", "Tlingit", "Tok Pisin", "Tokelau", "tonga", "Tonga (Nyasa)", "Tsimshian", "tsonga", "tswana", "Tumbuka", "Tupi languages", "turecki", "Turkish, Ottoman (1500-1928)", "turkmeński", "Tuvalu", "Tuvinian", "twi", "tybetański", "Udmurt", "ugarycki", "ujgurski", "ukraiński", "Umbundu", "Uncoded languages", "Undetermined", "Upper Sorbian", "urdu", "uzbecki", "Vai", "venda", "volapük", "Votic", "Wakashan languages", "walijski", "waloński", "Waray", "Washo", "węgierski", "wiele języków", "wietnamski", "włoski", "Wolaitta", "wolof", "xhosa", "Yakut", "Yao", "Yapese", "Yupik languages", "Zande languages", "Zapotec", "Zaza", "Zenaga", "zhuang", "zulu", "Zuni" + nazwa: "angielski", "brak danych", "francuski", "hiszpański", "inny", "niemiecki", "polski", "rosyjski", "włoski" bpp.czas_udostepnienia_openaccess nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" bpp.licencja_openaccess nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" bpp.wersja_tekstu_openaccess - nazwa: "Inna", "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" + nazwa: "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" bpp.status_korekty nazwa: "po korekcie", "przed korektą", "w trakcie korekty" bpp.typ_kbn diff --git a/src/bpp/data/rekord_djangoql_schema.compact.txt b/src/bpp/data/rekord_djangoql_schema.compact.txt index 62e2e6545..36b5d6ee3 100644 --- a/src/bpp/data/rekord_djangoql_schema.compact.txt +++ b/src/bpp/data/rekord_djangoql_schema.compact.txt @@ -1,8 +1,13 @@ -# BPP 202607.1397 +# BPP 202608.1399 # Model: bpp.Rekord Schemat: bpp.djangoql_schema.RekordLLMSchema # Wygenerowano: manage.py opisz_schemat_djangoql_dla_llm # Plik generowany — nie edytuj ręcznie. +# Rekordy skasowane (soft-delete, pole `deleted_at`) są DOMYŚLNIE pomijane. +# Zapytanie bez wzmianki o `deleted_at` widzi tylko żywe wiersze — nie dopisuj +# `deleted_at = None`, to już działa. Żeby zajrzeć do kosza, wymień to pole na +# wybranej relacji: `autorzy_set.deleted_at != None` = TYLKO skasowane autorstwa. + # DjangoQL schema # Query: , combined with and/or, grouped with (). # Negate with != / !~ / not in / not startswith / not endswith (no standalone `not`). @@ -79,7 +84,7 @@ bpp.autorzy: afiliuje bool autor -> bpp.autor data_oswiadczenia date - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id unknown jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow @@ -98,6 +103,7 @@ bpp.charakter_formalny: charakter_pbn -> bpp.charakter_pbn? match help_text charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. id int level int lft int @@ -121,6 +127,7 @@ bpp.charakter_formalny: bpp.jezyk: id int + kod_bcp47 str "Kod języka wg BCP 47" — Kod języka w notacji BCP 47 (RFC 5646), np. „pl”, „en”, „en-GB”. Używany w eksporcie CERIF/OpenAIRE jako wartość atrybutu xml:lang; gdy pusty, elementy w tym języku zostaną wyeksportowane bez oznaczenia języka. nazwa str pbn_uid -> pbn_api.language? praca_doktorska -> bpp.praca_doktorska? @@ -169,6 +176,7 @@ bpp.licencja_openaccess: nazwa str rekord -> bpp.rekord? skrot str + uri str "Adres URI licencji" — Kanoniczny adres URI licencji, np. https://creativecommons.org/licenses/by/4.0/ . Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace na tej licencji zostaną wyeksportowane bez odnośnika do jej treści. wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? zrodlo -> bpp.zrodlo? @@ -420,7 +428,6 @@ bpp.autor: sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni tytul -> bpp.tytul? match nazwa - urodzony date? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -457,6 +464,7 @@ bpp.jednostka: level int lft int nazwa str + nie_eksportuj_przez_api bool obca_jednostka -> bpp.uczelnia? opis str? ostatnio_zmieniony datetime? @@ -470,6 +478,7 @@ bpp.jednostka: praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int rodzaj -> bpp.rodzajjednostki? match nazwa + ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" skrot_nazwy str? @@ -847,7 +856,8 @@ bpp.wydawnictwo_zwarte_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -858,6 +868,8 @@ bpp.wydawnictwo_zwarte_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_zwarte + restored_at datetime? + transaction_id str? typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str @@ -882,6 +894,7 @@ bpp.nagroda: uzasadnienie str bpp.tryb_openaccess_wydawnictwo_zwarte: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -941,13 +954,13 @@ bpp.funkcja_autora: bpp.autor_dyscyplina: autor -> bpp.autor - dyscyplina_naukowa -> bpp.dyscyplina_naukowa match nazwa + dyscyplina_naukowa -> bpp.dyscyplina_naukowa id int procent_dyscypliny float? procent_subdyscypliny float? rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa rok int - subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? zatrudnienie_do datetime? zatrudnienie_od datetime? @@ -965,7 +978,7 @@ bpp.autor_jednostka: bpp.cache_punktacja_autora: autor -> bpp.autor - dyscyplina -> bpp.dyscyplina_naukowa match nazwa + dyscyplina -> bpp.dyscyplina_naukowa id int jednostka -> bpp.jednostka pkdaut float @@ -976,7 +989,8 @@ bpp.patent_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -987,6 +1001,8 @@ bpp.patent_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.patent + restored_at datetime? + transaction_id str? typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str @@ -1025,7 +1041,8 @@ bpp.wydawnictwo_ciagle_autor: afiliuje bool autor -> bpp.autor data_oswiadczenia date? "Data oświadczenia" — Informacja eksportowana do PBN, gdy uzupełniono - dyscyplina_naukowa -> bpp.dyscyplina_naukowa? match nazwa + deleted_at datetime? + dyscyplina_naukowa -> bpp.dyscyplina_naukowa? id int jednostka -> bpp.jednostka kierunek_studiow -> bpp.kierunek_studiow? @@ -1036,6 +1053,8 @@ bpp.wydawnictwo_ciagle_autor: profil_orcid bool "Praca w profilu ORCID autora" — Zaznacz, jeżeli praca znajdje się na profilu ORCID autora przypieta bool rekord -> bpp.wydawnictwo_ciagle + restored_at datetime? + transaction_id str? typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str @@ -1074,6 +1093,7 @@ bpp.rodzajjednostki: wyklucz_z_rankingu_autorow bool bpp.tryb_openaccess_wydawnictwo_ciagle: + coar_access_right str "Prawo dostępu wg COAR" — Pełny identyfikator prawa dostępu ze słownika COAR Access Rights, np. http://purl.org/coar/access_right/c_abf2 dla „open access”. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym trybie zostaną wyeksportowane bez określonego prawa dostępu. id int nazwa str skrot str @@ -1086,9 +1106,11 @@ bpp.wydawnictwo_ciagle_zewnetrzna_baza_danych: rekord -> bpp.wydawnictwo_ciagle bpp.rodzaj_prawa_patentowego: - id int - nazwa str - patent -> bpp.patent? + coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_15cd dla patentu. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, patenty o tym rodzaju prawa zostaną wyeksportowane bez typu zasobu. + eksportuj_jako_patent bool "Eksportuj do CERIF jako patent" — Odznacz dla praw, które nie są patentami w rozumieniu słownika COAR (np. znak towarowy). Takie rekordy nie trafią do eksportu CERIF/OpenAIRE — profil wymaga dla każdego rekordu typu z gałęzi „patent”, więc jedyną alternatywą byłoby zadeklarowanie ich patentami wbrew prawdzie. + id int + nazwa str + patent -> bpp.patent? bpp.organprzyznajacynagrody: id int @@ -1117,21 +1139,19 @@ bpp.wymiar_etatu: dictionaries (shared relation values, referenced above): bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Editorial", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Poster - streszczenie", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" + nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" bpp.jezyk - nazwa: "abchaski", "Achinese", "Acoli", "Adangme", "Adyghe", "afar", "Afrihili", "Afro-Asiatic languages", "afrykanerski", "Ainu", "ajmara", "akadyjski", "akan", "albański", "Aleut", "Algonquian languages", "Altaic languages", "amharski", "angielski", "Angika", "Apache languages", "arabski", "aragoński", "Arapaho", "Arawak", "Aromanian", "Artificial languages", "asamski", "Asturian", "Athapascan languages", "Australian languages", "Austronesian languages", "Awadhi", "awarski", "awestyjski", "azerski", "Balinese", "Baltic languages", "Baluchi", "bambara", "Bamileke languages", "Banda languages", "Bantu languages", "Basa", "baskijski", "baszkirski", "Batak languages", "Beja", "Bemba", "bengalski", "Berber languages", "Bhojpuri", "białoruski", "bihari", "Bikol", "Bini", "birmański", "bislama", "Blin", "Blissymbols", "bośniacki", "Braj", "brak danych", "bretoński", "Buginese", "bułgarski", "Buriat", "Caddo", "Caucasian languages", "Cebuano", "Celtic languages", "Central American Indian languages", "Chagatai", "Chamic languages", "Cherokee", "Cheyenne", "Chibcha", "Chinook jargon", "chiński", "Chipewyan", "Choctaw", "chorwacki", "Chuukese", "Classical Newari", "Coptic", "Creek", "Creoles and pidgins", "Creoles and pidgins, English based", "Creoles and pidgins, French-based", "Creoles and pidgins, Portuguese-based", "Crimean Tatar", "Cushitic languages", "czamorro", "czarnogórski", "czeczeński", "czeski", "cziczewa", "czuwaski", "Dakota", "Dargwa", "Delaware", "Dinka", "Dogri", "Dogrib", "Dravidian languages", "Duala", "duński", "Dutch, Middle (ca.1050-1350)", "Dyula", "dzongkha", "Eastern Frisian", "Efik", "Egyptian (Ancient)", "Ekajuk", "Elamite", "English, Middle (1100-1500)", "Erzya", "esperanto", "estoński", "ewe", "Ewondo", "Fang", "Fanti", "farerski", "fidżyjski", "Filipino", "Finno-Ugrian languages", "fiński", "Fon", "francuski", "French, Middle (ca.1400-1600)", "French, Old (842-ca.1400)", "Friulian", "fryzyjski", "ful", "Ga", "gaelicki szkocki", "Galibi Carib", "galicyjski", "Gayo", "Gbaya", "Geez", "German, Middle High (ca.1050-1500)", "German, Old High (ca.750-1050)", "Germanic languages", "Gilbertese", "Gondi", "Gorontalo", "Gothic", "Grebo", "grecki (nowożytny)", "grecki (starożytny)", "grenlandzki", "gruziński", "guarani", "gudźarati", "Gwich'in", "Haida", "haitański", "hausa", "Hawaiian", "hebrajski", "herero", "Hiligaynon", "Himachali languages", "hindi", "hiri motu", "hiszpański", "Hittite", "Hmong", "holenderski", "Hupa", "Iban", "ido", "igbo", "Ijo languages", "Iloko", "Inari Sami", "Indic languages", "Indo-European languages", "indonezyjski", "Ingush", "inny", "interlingua", "inuktitut", "inupiak", "Iranian languages", "Irish, Middle (900-1200)", "Irish, Old (to 900)", "irlandzki", "Iroquoian languages", "islandzki", "japoński", "jawajski", "jidysz", "joruba", "Judeo-Arabic", "Judeo-Persian", "Kabardian", "Kabyle", "Kachin", "Kalmyk", "Kamba", "kannada", "kanuri", "Kara-Kalpak", "Karachay-Balkar", "Karelian", "Karen languages", "Kashubian", "kaszmirski", "kataloński", "Kawi", "kazachski", "keczua", "Khasi", "khmerski", "Khoisan languages", "Khotanese", "kikuju", "Kimbundu", "kirgiski", "Klingon", "komi", "kongo", "Konkani", "koreański", "kornijski", "korsykański", "Kosraean", "Kpelle", "kri", "Kru languages", "Kumyk", "kurdyjski", "Kurukh", "Kutenai", "kwanyama", "Ladino", "Lahnda", "Lamba", "Land Dayak languages", "laotański", "Lezghian", "limburski", "lingala", "litewski", "Lojban", "Low German", "Lower Sorbian", "Lozi", "Luba-katanga", "Luba-Lulua", "luganda", "Luiseno", "luksemburski", "Lule Sami", "Lunda", "Luo (Kenya and Tanzania)", "Lushai", "łaciński", "łotewski", "macedoński", "Madurese", "Magahi", "Maithili", "Makasar", "malajalam", "malajski", "malediwski", "malgaski", "maltański", "Manchu", "Mandar", "Mandingo", "Manipuri", "Manobo languages", "manx", "maoryski", "Mapudungun", "marathi", "Mari", "marszalski", "Marwari", "Masai", "Mayan languages", "Mende", "Mi'kmaq", "Minangkabau", "Mirandese", "Mohawk", "Moksha", "Mon-Khmer languages", "Mongo", "mongolski", "Mossi", "Munda languages", "N'Ko", "Nahuatl languages", "nauruański", "nawaho", "ndebele południowy", "ndebele północny", "ndonga", "Neapolitan", "Nepal Bhasa", "nepalski", "Nias", "niemiecki", "Niger-Kordofanian languages", "Nilo-Saharan languages", "Niuean", "No linguistic content", "Nogai", "Norse, Old", "North American Indian languages", "Northern Frisian", "norweski", "norweski (bokmål)", "norweski (nynorsk)", "Nubian languages", "nuosu", "Nyamwezi", "Nyankole", "Nyoro", "Nzima", "occidental", "odżibwe", "Official Aramaic (700-300 BCE)", "orija", "ormiański", "oromo", "Osage", "osetyjski", "Otomian languages", "Pahlavi", "Palauan", "pali", "Pampanga", "Pangasinan", "Papiamento", "Papuan languages", "paszto", "Pedi", "pendżabski", "Persian, Old (ca.600-400 B.C.)", "perski", "Philippine languages", "Phoenician", "Pohnpeian", "polski", "portugalski", "północnolapoński", "Prakrit languages", "Provençal, Old (to 1500)", "prowansalski", "Rajasthani", "Rapanui", "Rarotongan", "Romance languages", "romansz", "Romany", "rosyjski", "ruanda", "rumuński", "rundi", "Salishan languages", "Samaritan Aramaic", "Sami languages", "samoański", "Sandawe", "sango", "sanskryt", "Santali", "sardyński", "Sasak", "Scots", "Selkup", "Semitic languages", "serbski", "Serer", "Shan", "shona", "Sicilian", "Sidamo", "Sign Languages", "Siksika", "sindhi", "Sino-Tibetan languages", "Siouan languages", "Skolt Sami", "Slave (Athapascan)", "Slavic languages", "słowacki", "słoweński", "Sogdian", "somalijski", "Songhai languages", "Soninke", "Sorbian languages", "sotho południowy", "South American Indian languages", "Southern Altai", "Southern Sami", "Sranan Tongo", "Standard Moroccan Tamazight", "staro-cerkiewno-słowiański", "suahili", "suazi", "Sukuma", "sumeryjski", "sundajski", "Susu", "Swiss German", "syngaleski", "syryjski", "syryjski (klasyczny)", "szwedzki", "tadżycki", "tagalski", "tahitański", "Tai languages", "tajski", "Tamashek", "tamilski", "tatarski", "telugu", "Tereno", "Tetum", "Tigre", "tigrinia", "Timne", "Tiv", "Tlingit", "Tok Pisin", "Tokelau", "tonga", "Tonga (Nyasa)", "Tsimshian", "tsonga", "tswana", "Tumbuka", "Tupi languages", "turecki", "Turkish, Ottoman (1500-1928)", "turkmeński", "Tuvalu", "Tuvinian", "twi", "tybetański", "Udmurt", "ugarycki", "ujgurski", "ukraiński", "Umbundu", "Uncoded languages", "Undetermined", "Upper Sorbian", "urdu", "uzbecki", "Vai", "venda", "volapük", "Votic", "Wakashan languages", "walijski", "waloński", "Waray", "Washo", "węgierski", "wiele języków", "wietnamski", "włoski", "Wolaitta", "wolof", "xhosa", "Yakut", "Yao", "Yapese", "Yupik languages", "Zande languages", "Zapotec", "Zaza", "Zenaga", "zhuang", "zulu", "Zuni" + nazwa: "angielski", "brak danych", "francuski", "hiszpański", "inny", "niemiecki", "polski", "rosyjski", "włoski" bpp.czas_udostepnienia_openaccess nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" bpp.licencja_openaccess nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" bpp.wersja_tekstu_openaccess - nazwa: "Inna", "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" + nazwa: "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" bpp.status_korekty nazwa: "po korekcie", "przed korektą", "w trakcie korekty" bpp.typ_kbn nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" - bpp.dyscyplina_naukowa - nazwa: "archeologia", "architektura i urbanistyka", "astronomia", "automatyka, elektronika, elektrotechnika i technologie kosmiczne", "biologia medyczna", "biotechnologia", "ekonomia i finanse", "etnologia i antropologia kulturowa", "filozofia", "geografia społeczno-ekonomiczna i gospodarka przestrzenna", "historia", "informatyka", "informatyka techniczna i telekomunikacja", "inżynieria bezpieczeństwa", "inżynieria biomedyczna", "inżynieria chemiczna", "inżynieria lądowa, geodezja i transport", "inżynieria materiałowa", "inżynieria mechaniczna", "inżynieria środowiska, górnictwo i energetyka", "językoznawstwo", "literaturoznawstwo", "matematyka", "nauki biblijne", "nauki biologiczne", "nauki chemiczne", "nauki farmaceutyczne", "nauki fizyczne", "nauki leśne", "nauki medyczne", "nauki o bezpieczeństwie", "nauki o komunikacji społecznej i mediach", "nauki o kulturze fizycznej", "nauki o kulturze i religii", "nauki o polityce i administracji", "nauki o rodzinie", "nauki o sztuce", "nauki o zarządzaniu i jakości", "nauki o zdrowiu", "nauki o Ziemi i środowisku", "nauki prawne", "nauki socjologiczne", "nauki teologiczne", "pedagogika", "polonistyka", "prawo kanoniczne", "psychologia", "rolnictwo i ogrodnictwo", "stosunki międzynarodowe", "sztuki filmowe i teatralne", "sztuki muzyczne", "sztuki plastyczne i konserwacja dzieł sztuki", "technologia żywności i żywienia", "weterynaria", "zootechnika i rybactwo" bpp.typ_odpowiedzialnosci nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" bpp.charakter_pbn @@ -1151,7 +1171,7 @@ dictionaries (shared relation values, referenced above): bpp.plec nazwa: "kobieta", "mężczyzna" bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "dr hab. inż.", "dr inż.", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "mgr, inż.", "prof. dr", "prof. dr hab.", "prof. dr hab. inż.", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" + nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" bpp.rodzajjednostki nazwa: "Koło naukowe", "Standard", "Wydział" bpp.tryb_openaccess_wydawnictwo_ciagle diff --git a/src/bpp/management/commands/opisz_schemat_djangoql_dla_llm.py b/src/bpp/management/commands/opisz_schemat_djangoql_dla_llm.py index 1346b9410..eb39bb160 100644 --- a/src/bpp/management/commands/opisz_schemat_djangoql_dla_llm.py +++ b/src/bpp/management/commands/opisz_schemat_djangoql_dla_llm.py @@ -208,6 +208,23 @@ def _zapisz(self, content, target, fmt): f"Zapisano opis schematu ({fmt}, BPP {VERSION}) → {target}", ) + #: Nota o kontrakcie soft-delete doklejana do nagłówka ``compact``. + #: Samo pojawienie się ``deleted_at`` w opisie pól nie mówi konsumentowi + #: (LLM, integrator API), CO to pole robi domyślnie — a robi dużo: + #: zapytanie bez wzmianki o nim nie widzi kosza. Bez tej noty klient + #: dopisywałby ``deleted_at = None`` w kółko albo — gorzej — sądziłby, + #: że wyniki obejmują skasowane. Treść odzwierciedla kontrakt z + #: ``bpp/djangoql_soft_delete.py``. + NOTA_SOFT_DELETE = ( + "# Rekordy skasowane (soft-delete, pole `deleted_at`) są DOMYŚLNIE pomijane.", + "# Zapytanie bez wzmianki o `deleted_at` widzi tylko żywe wiersze — " + "nie dopisuj", + "# `deleted_at = None`, to już działa. Żeby zajrzeć do kosza, wymień " + "to pole na", + "# wybranej relacji: `autorzy_set.deleted_at != None` = TYLKO " + "skasowane autorstwa.", + ) + def _render(self, bundle, *, fmt, model_label, schema_path, indent): if fmt == "compact": header = "\n".join( @@ -217,6 +234,8 @@ def _render(self, bundle, *, fmt, model_label, schema_path, indent): "# Wygenerowano: manage.py opisz_schemat_djangoql_dla_llm", "# Plik generowany — nie edytuj ręcznie.", "", + *self.NOTA_SOFT_DELETE, + "", "", ) ) From 4ad7ac6282da4e53272ae69eb0796ca0a13692e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 13:16:02 +0200 Subject: [PATCH 50/75] test(djangoql): test swiezosci artefaktow schematu dla LLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dotad NIC nie pilnowalo, zeby src/bpp/data/*_djangoql_schema.compact.txt nadazaly za modelami — i rozjechaly sie po cichu (zero wystapien deleted_at po fazie 01, plus zalegle coar_type/ror_id/kod_bcp47). Istniejacy test sprawdzal tylko, ze komenda cokolwiek zapisuje. Nowy test regeneruje artefakt kazdego z trzech kanonicznych korzeni (rekord/autor/autorzy) z AKTUALNYCH modeli i porownuje z plikiem w repo. Porownanie NIE jest bajt-w-bajt. Swiadomie pomijane (uzasadnienie w docstringu _normalizuj): - linia "# BPP " — stempel zmienia sie przy KAZDYM wydaniu (promote.yml podmienia version.py), a schemat nie; bez tego wyjatku test swiecilby na czerwono po kazdym release; - sekcja "dictionaries" i markery "match " — to WARTOSCI wierszy slownikow odczytane z bazy, na ktorej puszczono generator. Baza testowa (baseline) ma inny zestaw niz instalacja uczelni, wiec porownywanie ich zamienia test swiezosci MODELI w test zawartosci BAZY i uzaleznia CI od danych. Wszystko inne (komplet modeli, pol, typy, nullowalnosc, choices, verbose_name/help_text, naglowek z kontraktem soft-delete) porownywane doslownie. Dwa meta-testy normalizacji: jeden dowodzi, ze roznica POLA jest widziana (usuniecie deleted_at), drugi — ze wersja/slowniki/markery sa ignorowane. Bez nich zielony test swiezosci nie nioslby informacji. Mutacja sprawdzona recznie: usuniecie trzech linii "deleted_at" z rekord_..compact.txt daje FAILED z komunikatem wskazujacym komende regeneracji; przywrocenie pliku — 5 passed. Runbook fazy 01 zaktualizowany: punkt o nieaktualnych artefaktach i o "DjangoQL jako narzedziu audytowym widzacym kosz" zamkniety, zostaje tylko otwarte pytanie o reczne parametry GET changelistu admina. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- docs/deweloper/runbook-soft-delete-faza-01.md | 52 ++++--- .../tests/test_djangoql_schema_swiezosc.py | 137 ++++++++++++++++++ 2 files changed, 167 insertions(+), 22 deletions(-) create mode 100644 src/bpp/tests/test_djangoql_schema_swiezosc.py diff --git a/docs/deweloper/runbook-soft-delete-faza-01.md b/docs/deweloper/runbook-soft-delete-faza-01.md index e2025b639..cc51058da 100644 --- a/docs/deweloper/runbook-soft-delete-faza-01.md +++ b/docs/deweloper/runbook-soft-delete-faza-01.md @@ -135,28 +135,36 @@ od użytkowników. PBN, deduplikator) płaci teraz za jego utrzymanie. `0490` dokłada btree po `rekord_id`, co część kosztu łagodzi. **Nikt tego nie zmierzył** — testy z definicji tego nie wykryją. -- **Artefakty DjangoQL dla LLM są nieaktualne.** - `src/bpp/data/*_djangoql_schema.compact.txt` — schematy wystawiane - konsumentom `/api/v1/zapytanie/` (w tym asystentom LLM) — **nie wymieniają - pola `deleted_at`** i nie mają testu świeżości. - - Skutek: klient układający zapytanie DjangoQL nie ma skąd wiedzieć, że - powinien wykluczyć skasowane rekordy, więc domyślnie ich **nie wyklucza**. - To spójne ze świadomą decyzją z §„Zostawione" naprawy wycieku ORM (DjangoQL - traktujemy jako narzędzie audytowe, które *ma* widzieć kosz), ale - niekomunikowane na zewnątrz. - - Do rozstrzygnięcia przed fazą 02 — trzy warianty: - 1. **zregenerować artefakty** (wtedy `deleted_at` staje się jawnym, - dostępnym polem; hałaśliwy diff, bo zmienia się nagłówek z wersją); - 2. **udokumentować w opisie API**, że wyniki obejmują kosz i jak go - odfiltrować (`deleted_at = None`); - 3. zostawić i przyjąć, że to narzędzie dla operatora, nie dla integracji. - - Wariant 1 bez 2 nie wystarczy — samo pojawienie się pola w schemacie nie - mówi klientowi, że *powinien* go użyć. **Dopisać test świeżości** artefaktów - niezależnie od wybranego wariantu; dziś nic nie pilnuje, żeby schemat - nadążał za modelami. +- **DjangoQL: kosz domyślnie niewidoczny (ZAMKNIĘTE 2026-08-07).** + Wcześniejszy zapis mówił, że język zapytań traktujemy jako narzędzie + audytowe, które *ma* widzieć kosz, a artefakty schematu dla LLM są + nieaktualne. **Oba punkty są nieaktualne.** Decyzją właściciela projektu: + + 1. **Zapytania DjangoQL domyślnie NIE zwracają rekordów skasowanych** — + w adminach, na `/zapytanie/` i w `/api/v1/zapytanie/*`. Realizuje to + `src/bpp/djangoql_soft_delete.py` (`WykluczSkasowaneMixin` wpięty w + `BppQLSchema` i `RekordLLMSchema`); zakres wykrywany introspekcją + `SoftDeleteModel`, więc faza 02 nic tu nie przepisuje. + 2. **Kosz jest nadal dostępny, ale na jawne żądanie** — wystarczy wymienić + `deleted_at` na danej ścieżce relacji: + `autorzy_set.deleted_at != None` (tylko kosz), + `autorzy_set.deleted_at = None` (to samo, co domyślnie). + Zdjęcie predykatu działa PER PREFIKS ścieżki, nie globalnie. + Świadome ślepe plamy (M2M przez through, `relacja != None`, operatory + negujące na ścieżce relacyjnej) — opisane w docstringu modułu. + 3. **Artefakty `src/bpp/data/*_djangoql_schema.compact.txt` zregenerowane** + — mają `deleted_at` oraz notę w nagłówku mówiącą konsumentowi, że + kosz jest domyślnie pomijany i jak go zobaczyć. + 4. **Jest test świeżości** — + `src/bpp/tests/test_djangoql_schema_swiezosc.py` pada, gdy artefakt + rozjedzie się z modelami. Regeneracja: + `uv run python src/manage.py opisz_schemat_djangoql_dla_llm --wszystkie-korzenie` + (przeciw bazie z `baseline-sql/baseline.sql`, żeby nie wciągnąć danych + instytucji). + + Otwarte zostaje wyłącznie pytanie o **ręcznie sklejone parametry GET + changelistu admina** (`?autorzy_set__…=`) — tam whitelist `lookup_allowed` + nadal przepuszcza surowy lookup. - **Trzy martwe widoki** `bpp_kronika_{wydawnictwo_ciagle,wydawnictwo_zwarte, patent}_view` czytają surowe tabele bez filtra. Zweryfikowano brak diff --git a/src/bpp/tests/test_djangoql_schema_swiezosc.py b/src/bpp/tests/test_djangoql_schema_swiezosc.py new file mode 100644 index 000000000..ae2aa53a8 --- /dev/null +++ b/src/bpp/tests/test_djangoql_schema_swiezosc.py @@ -0,0 +1,137 @@ +"""Test świeżości commitowanych artefaktów schematu DjangoQL. + +``src/bpp/data/{rekord,autor,autorzy}_djangoql_schema.compact.txt`` to opisy +przestrzeni wyszukiwania wystawiane KONSUMENTOM (asystenci LLM, integratorzy +``/api/v1/zapytanie/``). Są generowane komendą +``opisz_schemat_djangoql_dla_llm``, ale do tej pory NIC nie pilnowało, żeby +nadążały za modelami — i faktycznie rozjechały się cicho: po fazie 01 +soft-delete nie wymieniały ``deleted_at`` w ogóle. Ta klasa zaniedbania wraca +przy każdej zmianie modeli w allow-liście (faza 02 doda ``deleted_at`` pięciu +modelom publikacji). + +Ten test porównuje artefakt z repo z artefaktem wygenerowanym z AKTUALNYCH +modeli. Porównanie NIE jest bajt-w-bajt — patrz :func:`_normalizuj`. + +Gdy test padnie +=============== + +To znaczy, że schemat DjangoQL zmienił się razem z modelami. Zregeneruj:: + + uv run python src/manage.py opisz_schemat_djangoql_dla_llm \\ + --wszystkie-korzenie + +i zacommituj trzy pliki z ``src/bpp/data/``. Regeneruj przeciw bazie +zbudowanej z ``baseline-sql/baseline.sql`` (np. ``uv run run-site run`` bez +dumpa) — wtedy do repo open-source nie trafiają dane Twojej instytucji. +""" + +import re + +import pytest +from django.core.management import call_command + +from bpp.management.commands.opisz_schemat_djangoql_dla_llm import ( + DATA_DIR, + KORZENIE, +) + +#: Nagłówek sekcji ze słownikami — wszystko od niej w dół to WARTOŚCI wierszy +#: pobrane z bazy, nie opis modeli. +SEKCJA_SLOWNIKOW = "dictionaries (shared relation values, referenced above):" + +#: Marker „po czym dopasować obiekt tej relacji" (``match nazwa``). djangoql +#: emituje go tylko wtedy, gdy w bazie SĄ jakieś wartości do dopasowania — +#: czyli jest funkcją zawartości bazy, nie modeli. +RE_MARKER_MATCH = re.compile(r" match [a-z_]+(?= |$)") + + +def _normalizuj(tekst: str) -> str: + """Zostaw z artefaktu to, co jest funkcją MODELI, wytnij resztę. + + Świadomie pomijane (i dlaczego): + + 1. **Linia ``# BPP ``** — stempel wersji zmienia się przy KAŻDYM + wydaniu (``promote.yml`` podmienia ``version.py``), a schemat nie. + Bez tego wyjątku test świeciłby się na czerwono po każdym release, + zamiast na realnym rozjeździe modeli. + 2. **Sekcja ``dictionaries``** oraz **markery ``match ``** — + to WARTOŚCI wierszy słowników odczytane z bazy, na której puszczono + generator. Baza testowa (baseline) ma inny zestaw niż instalacja + konkretnej uczelni, więc porównywanie ich zamieniłoby test świeżości + modeli w test zawartości bazy (i uzależniło CI od danych). + + Wszystko inne — komplet modeli, komplet pól, typy, nullowalność, choices, + verbose_name/help_text, nagłówek z kontraktem soft-delete — porównujemy + dosłownie. To wystarczy, żeby zniknięcie albo pojawienie się pola + (``deleted_at``!) zapaliło czerwone światło. + """ + linie = [] + for linia in tekst.splitlines(): + if linia.startswith("# BPP "): + continue + if linia.startswith(SEKCJA_SLOWNIKOW): + break + if "->" in linia: + linia = RE_MARKER_MATCH.sub("", linia) + linie.append(linia) + return "\n".join(linie).rstrip("\n") + "\n" + + +@pytest.mark.django_db +@pytest.mark.parametrize(("etykieta_modelu", "nazwa_pliku"), sorted(KORZENIE.items())) +def test_artefakt_djangoql_nadaza_za_modelami(etykieta_modelu, nazwa_pliku, tmp_path): + """Artefakt w repo == artefakt wygenerowany z aktualnych modeli.""" + swiezy = tmp_path / nazwa_pliku + call_command( + "opisz_schemat_djangoql_dla_llm", + "--model", + etykieta_modelu, + "--output", + str(swiezy), + ) + + zacommitowany = DATA_DIR / nazwa_pliku + assert zacommitowany.exists(), f"brak artefaktu {zacommitowany}" + + assert _normalizuj(zacommitowany.read_text(encoding="utf-8")) == _normalizuj( + swiezy.read_text(encoding="utf-8") + ), ( + f"Artefakt {nazwa_pliku} rozjechał się z modelami. Zregeneruj:\n" + f" uv run python src/manage.py opisz_schemat_djangoql_dla_llm " + f"--wszystkie-korzenie\n" + f"Szczegóły — docstring src/bpp/tests/test_djangoql_schema_swiezosc.py" + ) + + +def test_normalizacja_widzi_roznice_pol(): + """Meta-test: normalizacja NIE zjada tego, czego ma pilnować. + + Bez niego „zielony test świeżości" nie niósłby informacji — dokładnie ten + sam powód, dla którego kanarki ORM/katalogowy mają swoje meta-testy. + """ + bazowy = "bpp.wydawnictwo_ciagle_autor:\n afiliuje bool\n" + z_polem = ( + "bpp.wydawnictwo_ciagle_autor:\n afiliuje bool\n deleted_at datetime?\n" + ) + + assert _normalizuj(bazowy) != _normalizuj(z_polem) + + +def test_normalizacja_ignoruje_wersje_slowniki_i_markery(): + """…a jednocześnie milczy na tym, co świadomie pomijamy.""" + wspolne = 'bpp.rekord:\n jezyk -> bpp.jezyk match nazwa "Język"\n' + + a = ( + "# BPP 202601.1\n" + + wspolne + + SEKCJA_SLOWNIKOW + + '\n bpp.jezyk\n nazwa: "pol."\n' + ) + b = ( + "# BPP 202699.9\n" + + wspolne.replace(" match nazwa", "") + + SEKCJA_SLOWNIKOW + + '\n bpp.jezyk\n nazwa: "ang.", "pol."\n' + ) + + assert _normalizuj(a) == _normalizuj(b) From 5fec63e85f35e9a428c1b48a79ca764b78f6c61c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 13:22:22 +0200 Subject: [PATCH 51/75] =?UTF-8?q?fix(export=5Fbibtex):=20napraw=20C901/B90?= =?UTF-8?q?4=20zg=C5=82oszone=20przez=20"Lint=20changed=20files"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #312 dotknął export_bibtex.py (filtr deleted_at, e80d1656e), przez co check "Lint changed files" objął cały plik i ujawnił dwa wcześniejsze, niezwiązane z tą gałęzią długi: - C901: handle() miał złożoność 12 (próg 10). Wydzieliłem dwie metody o jednej odpowiedzialności każda: - _zbierz_publikacje(options) — budowa Q() dla ciagle/zwarte, filtry rok/autor/id/typ/limit, CommandError gdy brak wyników. - _zapisz_wynik(options, bibtex_content) — zapis do pliku (--output) albo na stdout. handle() teraz tylko woła obie metody + generuje BibTeX. Czysto strukturalny refaktor — logika filtra deleted_at (autorzy_set) i cała reszta zachowania bez zmian. - B904: `raise CommandError(...)` w except OSError dostał `from e`, żeby zachować łańcuch wyjątków. Dopisałem też smoke test dla samej komendy (test_export_bibtex_command.py) — wcześniej istniały tylko testy funkcji z bpp.export.bibtex, handle() management commandu nie miał żadnego pokrycia. Zweryfikowano: ruff check/format czyste, 4 nowe testy + 44 istniejące w src/bpp/tests/test_export/ przechodzą. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- src/bpp/management/commands/export_bibtex.py | 30 ++++++--- .../test_export/test_export_bibtex_command.py | 65 +++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 src/bpp/tests/test_export/test_export_bibtex_command.py diff --git a/src/bpp/management/commands/export_bibtex.py b/src/bpp/management/commands/export_bibtex.py index ed5914ba7..43bbd10bb 100644 --- a/src/bpp/management/commands/export_bibtex.py +++ b/src/bpp/management/commands/export_bibtex.py @@ -59,6 +59,23 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): + publications = self._zbierz_publikacje(options) + + # Generate BibTeX content + self.stdout.write(f"Exporting {len(publications)} publications to BibTeX...") + bibtex_content = export_to_bibtex(publications) + + self._zapisz_wynik(options, bibtex_content) + + self.stdout.write( + self.style.SUCCESS(f"Export completed: {len(publications)} publications") + ) + + def _zbierz_publikacje(self, options): + """Buduje zapytania wg opcji CLI i zwraca listę pasujących publikacji. + + Rzuca ``CommandError``, gdy po zastosowaniu filtrów nic nie zostanie. + """ publications = [] # Build queries based on options @@ -112,11 +129,10 @@ def handle(self, *args, **options): if not publications: raise CommandError("No publications found matching the criteria.") - # Generate BibTeX content - self.stdout.write(f"Exporting {len(publications)} publications to BibTeX...") - bibtex_content = export_to_bibtex(publications) + return publications - # Output to file or stdout + def _zapisz_wynik(self, options, bibtex_content): + """Zapisuje wygenerowany BibTeX do pliku (``--output``) albo na stdout.""" if options["output"]: try: with open(options["output"], "w", encoding="utf-8") as f: @@ -125,10 +141,6 @@ def handle(self, *args, **options): self.style.SUCCESS(f"Successfully exported to {options['output']}") ) except OSError as e: - raise CommandError(f"Error writing to file: {e}") + raise CommandError(f"Error writing to file: {e}") from e else: sys.stdout.write(bibtex_content) - - self.stdout.write( - self.style.SUCCESS(f"Export completed: {len(publications)} publications") - ) diff --git a/src/bpp/tests/test_export/test_export_bibtex_command.py b/src/bpp/tests/test_export/test_export_bibtex_command.py new file mode 100644 index 000000000..afc5df94d --- /dev/null +++ b/src/bpp/tests/test_export/test_export_bibtex_command.py @@ -0,0 +1,65 @@ +""" +Testy dla komendy zarządzającej ``export_bibtex``. + +Wcześniej istniały wyłącznie testy funkcji z ``bpp.export.bibtex`` (patrz +``test_export_bibtex.py`` / ``test_export_bibtex_simple.py``) — sama komenda +(``handle()``) nie miała żadnego pokrycia. Poniższe testy to smoke test +sprawdzający, że polecenie faktycznie działa end-to-end po refaktorze +rozbijającym ``handle()`` na ``_zbierz_publikacje`` / ``_zapisz_wynik`` +(pod C901 z ruff — patrz PR). +""" + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle + + +@pytest.mark.django_db +def test_export_bibtex_do_pliku(tmp_path): + """``--output`` zapisuje wynik do pliku (ścieżka ``_zapisz_wynik``).""" + baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Testowy Artykul", rok=2024) + output = tmp_path / "out.bib" + + call_command("export_bibtex", "--output", str(output)) + + tresc = output.read_text("utf-8") + assert "@article{" in tresc + assert "Testowy Artykul" in tresc + + +@pytest.mark.django_db +def test_export_bibtex_na_stdout(capsys): + """Bez ``--output`` wynik trafia na stdout (druga gałąź ``_zapisz_wynik``).""" + baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Drugi Artykul", rok=2024) + + call_command("export_bibtex") + + out = capsys.readouterr().out + assert "Drugi Artykul" in out + + +@pytest.mark.django_db +def test_export_bibtex_filtr_po_roku(capsys): + """Filtr ``--year`` ogranicza wynik (ścieżka ``_zbierz_publikacje``).""" + baker.make( + Wydawnictwo_Ciagle, tytul_oryginalny="Z Dwudziestego Trzeciego", rok=2023 + ) + baker.make( + Wydawnictwo_Ciagle, tytul_oryginalny="Z Dwudziestego Czwartego", rok=2024 + ) + + call_command("export_bibtex", "--year", "2024") + + out = capsys.readouterr().out + assert "Z Dwudziestego Czwartego" in out + assert "Z Dwudziestego Trzeciego" not in out + + +@pytest.mark.django_db +def test_export_bibtex_brak_wynikow_rzuca_command_error(): + """Brak trafień po filtrach -> ``CommandError`` (koniec ``_zbierz_publikacje``).""" + with pytest.raises(CommandError): + call_command("export_bibtex", "--year", "1900") From 2eb5b956dd9fa423bcf2504aec2d818c91af62c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 13:29:21 +0200 Subject: [PATCH 52/75] fix(djangoql): analiza "czy proszono o kosz" per wywolanie validate() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uzupelnienie dc45d1ce1. Prefiksy z jawnym deleted_at byly liczone raz na zycie instancji schematu (flaga _ast_zbadany). Dla apply_search() to bez znaczenia (nowa instancja na kazde zapytanie), ale instancja bywa DLUGOWIECZNA: bpp/multiseek_registry/djangoql_export.py trzyma BppQLSchemaOgraniczony(Rekord) jako modulowy singleton bramki walidacyjnej. Z flaga pierwsze zapytanie po starcie procesu zamrazaloby swoja odpowiedz na pytanie "czy uzytkownik prosil o kosz" dla wszystkich kolejnych. Zamiast flagi licznik glebokosci — validate() rekuruje po sobie, wiec analize robimy na wezle najwyzszym, ale KAZDEGO wywolania. Test regresji: test_dlugowieczna_instancja_schematu_nie_pamieta_prosby_o_kosz uruchamia dwa zapytania na TEJ SAMEJ instancji schematu (przez build_filter, tak jak robi to djangoql.breakdown). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- src/bpp/djangoql_soft_delete.py | 28 ++++++++++------- .../test_djangoql_bez_skasowanych.py | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/bpp/djangoql_soft_delete.py b/src/bpp/djangoql_soft_delete.py index bef41c40b..f84fa7e32 100644 --- a/src/bpp/djangoql_soft_delete.py +++ b/src/bpp/djangoql_soft_delete.py @@ -303,22 +303,30 @@ def __init__(self, model): # Ustawiane przed ``super()``, bo konstruktor bazy robi # introspekcję i może wołać metody schematu. self._jawne_prefiksy_soft_delete = frozenset() - self._ast_zbadany = False + self._glebokosc_validate = 0 super().__init__(model) def validate(self, node): """Zapamiętaj prefiksy, na których użytkownik JAWNIE pyta o kosz. - ``apply_search()`` woła ``validate()`` z CAŁYM drzewem zanim - zbuduje filtr, a ``djangoql.breakdown`` — z każdym podzapytaniem - osobno; w obu wypadkach pierwszy węzeł, jaki widzimy, jest - korzeniem tego, co zaraz zostanie wykonane. ``validate()`` rekuruje - po sobie, więc analizę robimy tylko raz (``_ast_zbadany``). + ``apply_search()`` woła ``validate()`` z CAŁYM drzewem, zanim + zbuduje filtr, więc analizę robimy na węźle NAJWYŻSZYM — ``validate()`` + rekuruje po sobie, stąd licznik głębokości. + + Licznik (a nie jednorazowa flaga) dlatego, że instancja schematu + bywa DŁUGOWIECZNA: ``bpp.multiseek_registry.djangoql_export`` + trzyma ``BppQLSchemaOgraniczony(Rekord)`` jako singleton bramki + walidacyjnej. Z flagą pierwsze zapytanie zamrażałoby swoje prefiksy + na zawsze i kolejne dostawałyby cudzą odpowiedź na pytanie „czy + użytkownik prosił o kosz". """ - if not self._ast_zbadany: - self._ast_zbadany = True - self._jawne_prefiksy_soft_delete = prefiksy_z_jawnym_deleted_at(node) - return super().validate(node) + self._glebokosc_validate += 1 + try: + if self._glebokosc_validate == 1: + self._jawne_prefiksy_soft_delete = prefiksy_z_jawnym_deleted_at(node) + return super().validate(node) + finally: + self._glebokosc_validate -= 1 def resolve_name(self, name): pole = super().resolve_name(name) diff --git a/src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py b/src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py index 168b13d37..5c800522b 100644 --- a/src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py +++ b/src/bpp/tests/test_soft_delete/test_djangoql_bez_skasowanych.py @@ -175,6 +175,36 @@ def test_schemat_ograniczony_i_llm_maja_ten_sam_kontrakt( ), schema.__name__ +@pytest.mark.django_db +def test_dlugowieczna_instancja_schematu_nie_pamieta_prosby_o_kosz( + wydawnictwo_ciagle_z_autorem, +): + """Instancja schematu bywa SINGLETONEM (``multiseek_registry. + djangoql_export`` trzyma ``BppQLSchemaOgraniczony(Rekord)`` jako bramkę + walidacyjną). Prośba o kosz z jednego zapytania nie może przeciekać na + następne — inaczej pierwsze zapytanie po starcie procesu decydowałoby + o widoczności kosza dla wszystkich kolejnych.""" + from djangoql.parser import DjangoQLParser + from djangoql.queryset import build_filter + + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + wc.autorzy_set.first().delete() + + schemat = BppQLSchema(Wydawnictwo_Ciagle) + + def _uruchom(zapytanie): + ast = DjangoQLParser().parse(zapytanie) + schemat.validate(ast) + return _pk_set(Wydawnictwo_Ciagle.objects.filter(build_filter(ast, schemat))) + + z_koszem = f"autorzy_set.autor.id = {autor_id} and autorzy_set.deleted_at != None" + assert wc.pk in _uruchom(z_koszem) + + # …a NASTĘPNE zapytanie na tej samej instancji znów odsiewa kosz. + assert wc.pk not in _uruchom(f"autorzy_set.autor.id = {autor_id}") + + @pytest.mark.django_db def test_api_zapytanie_autor_nie_widzi_skasowanego_autorstwa( wydawnictwo_ciagle_z_autorem, From b41f9067844a0baee2e9567ec180f2c09130d56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 14:13:15 +0200 Subject: [PATCH 53/75] docs(soft-delete): handoff przed faza 02 Faza 01 zamknieta (41 commitow, 9 migracji, PR #312 CI 23/23). Dokument przekazania, zeby nastepna sesja nie odtwarzala historii z gita. Zawiera rzeczy, ktorych nie widac w diffie, a ktore w fazie 01 kosztowaly rundy poprawek: - siedem mechanizmow, ktore zakladaly ze skasowany wiersz znika (bramka WHEN cache, funkcje refresh, bramka denorm, unique_together, legacy raw-SQL constraint z 2018, restore(strict=True), widoki pochodne) -- odkrywane pojedynczo, przez awarie; - instrukcje, zeby rozszerzyc kanarek katalogowy NA STARCIE fazy 02, nie na koncu: w tym cala jego wartosc, a faza 01 zrobila to odwrotnie; - pulapke agregatow: warunek na prawej stronie LEFT JOIN degeneruje go do INNER JOIN i publikacja bez zywych autorow ZNIKA z bpp_rekord_mat; poprawnie jest count(...) FILTER (WHERE ...); - 11 faktow o kodzie, ktore byly zrodlem bledow (m.in. .filter() resolwuje pola natychmiast, content_type_id nie istnieje na publikacjach, dwie fixtury to ten sam obiekt, _upsert_sql mapuje kolumny POZYCYJNIE, get_default() usuniete i pilnowane guardem); - czego NIE powtarzac procesowo: rownolegle agenty w jednym worktree (trzy kolizje w tej sesji), REUSE kontenerow dajacy falszywe alarmy, oraz zasade "pusty wynik nie jest potwierdzeniem"; - otwarte decyzje, w tym rekomendacje strategii wydania (scalac fazami, wydac dopiero po fazie 04 -- faza 01 sama nie daje wartosci uzytkownikowi, a faza 03 jest obowiazkowa razem z 02, bo inaczej re-import tworzy duplikaty). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FbCGt7UfRrzUZVdCuU5Xy4 --- .../HANDOFF-soft-delete-faza-02.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/superpowers/HANDOFF-soft-delete-faza-02.md diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-02.md b/docs/superpowers/HANDOFF-soft-delete-faza-02.md new file mode 100644 index 000000000..00ecb388a --- /dev/null +++ b/docs/superpowers/HANDOFF-soft-delete-faza-02.md @@ -0,0 +1,210 @@ +# Handoff: soft-delete, start fazy 02 + +> Dokument przekazania po zamknięciu **fazy 01**. Napisany 2026-08-07, po +> sesji, która wykonała całą fazę 01 (41 commitów, 9 migracji, PR #312, CI 23/23). +> +> **Czytaj to zamiast odtwarzania historii z gita.** Zawiera rzeczy, których +> nie widać w diffie, a które w fazie 01 kosztowały rundy poprawek. + +--- + +## 1. Gdzie jesteśmy + +| | | +|---|---| +| Gałąź | `feat/soft-delete`, worktree `~/Programowanie/bpp-soft-delete` | +| PR | #312, **CI 23/23 SUCCESS** | +| Commity fazy 01 | 41 (od `7119a766f`) | +| Migracje | `bpp/0488`…`0496`, `rozbieznosci_dyscyplin/0022` | +| Testy | 9409 passed (+157 Playwright), 0 failed | + +**Faza 01 objęła TYLKO autorstwa** — 3 through-modele `Wydawnictwo_Ciagle_Autor`, +`Wydawnictwo_Zwarte_Autor`, `Patent_Autor`. Publikacje to faza 02. + +Dokumenty: +- spec: `docs/superpowers/specs/2026-06-04-soft-delete-publikacje-i-autorzy-design.md` +- plan fazy 02: `docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md` +- **runbook wdrożeniowy**: `docs/deweloper/runbook-soft-delete-faza-01.md` +- ledger fazy 01 (gitignored, tylko lokalnie): + `.superpowers/sdd/2026-06-04-soft-delete-01-autor-trigger-widoki/progress.md` + +--- + +## 2. Najważniejsza lekcja fazy 01 + +**Soft-delete nie jest „dodaniem kolumny".** To zmiana kontraktu `delete()` +z „wiersz przestaje istnieć" na „wiersz istnieje, ale się nie liczy". Każdy +mechanizm, który polegał na pierwszym znaczeniu, trzeba znaleźć i przekonfigurować. + +W fazie 01 takich mechanizmów było **siedem**, odkrywanych pojedynczo, przez awarie: + +| # | Mechanizm | Jak się objawiał | +|---|---|---| +| 1 | bramka `WHEN` triggerów cache (`0433`) | `save(update_fields=[…])` nie ruszał bramkowanej kolumny → trigger się nie odpalał | +| 2 | funkcje refresh (`0432`) | czysty upsert bez `DELETE` → odfiltrowanie z widoku było no-opem | +| 3 | bramka `django-denorm` | **drugi, niezależny** system triggerów z własną bramką z list `only=` | +| 4 | `unique_together` | blokował „skasuj i wstaw od nowa" (`UniqueViolation` w re-imporcie) | +| 5 | legacy raw-SQL `UNIQUE … DEFERRABLE` z `0132` (2018) | **niewidoczny dla ORM**; wybuchał dopiero przy `COMMIT` | +| 6 | `restore(strict=True)` | pakiet sprawdza `strict` dla *każdej* relacji → gołe `.restore()` rzucało wyjątek | +| 7 | widoki pochodne | `liczba_autorow`, ranking, raport rozbieżności czytały surową tabelę | + +**Faza 02 dostaje dwa strażniki, żeby nie powtarzać tego odkrywania:** + +- `src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py` — pyta `pg_depend` + o zależność na poziomie **kolumny** i pada, gdy widok czyta tabelę soft-delete + bez filtra po jej `deleted_at`; +- `src/bpp/tests/test_soft_delete/test_kanarek_orm.py` — AST, wykrywa zapytania + ORM z JOIN-em po relacji bez predykatu `deleted_at`. + +> ⚠️ **Rozszerz `TABELE_SOFT_DELETE` w kanarku katalogowym NA STARCIE fazy 02, +> a nie na końcu.** To jest cała jego wartość: dostajesz listę winowajców +> w pierwszej godzinie, zamiast odkrywać ich po jednym przez trzy tygodnie. +> Faza 01 zrobiła to odwrotnie i kosztowało to trzy dodatkowe zadania oraz +> werdykt „NIE SCALAĆ" na finalnej recenzji. + +--- + +## 3. Zakres fazy 02 — co MUSI się w niej znaleźć + +Plan (`…-02-publikacje.md`) ma jawną tabelę kolejności wykonania — taski +dopisywane po rewizjach wylądowały poza numeracją, więc **czytaj tabelę, +nie numery nagłówków**. + +Poza pierwotnym zakresem doszły cztery rzeczy: + +### 3.1 Nagrobki (decyzja właściciela, obowiązkowa) + +`ostatnio_zmieniony` **MUSI** być bumpowany przy kasowaniu — to już jest +w kontrakcie PINNED (faza 01, `BppAutorstwoSoftDeleteMixin.save()`), więc +publikacje dostaną to „z urodzenia". Ale trzeba dołożyć **widoczność usunięć +na zewnątrz**: + +- **OAI-PMH**: `
    ` w `src/cerif_export` (serwuje + `ListRecords`/`ListIdentifiers`/`resumptionToken`; **nie ma dziś żadnej + obsługi `deleted`**), respektujące `from`/`until`; +- **CERIF**: odpowiednik nagrobka w formacie rekordu; +- **API `/api/v1/`**: sposób odkrycia usuniętych (endpoint „usunięte od…" + albo parametr). + +Dzięki bumpowi lista nagrobków to po prostu +`Model.deleted_objects.filter(ostatnio_zmieniony__gte=X)` — bez potrzeby +`SoftDeleteLog` z fazy 06. + +**Traktuj to jako bramkę wydania**: faza 02 nie idzie na produkcję bez tego. + +### 3.2 Agregaty przez `FILTER`, nie przez `WHERE`/`JOIN` + +To jest pułapka, która **niszczy dane**, a nie tylko przecieka: + +```sql +-- ŹLE: warunek na prawej stronie LEFT JOIN degeneruje go do INNER JOIN +-- → publikacja, której WSZYSCY autorzy są w koszu, ZNIKA z bpp_rekord_mat +LEFT JOIN bpp_..._autor ON … WHERE bpp_..._autor.deleted_at IS NULL + +-- DOBRZE: liczy zero, ale zachowuje wiersz +count(bpp_..._autor.autor_id) FILTER (WHERE bpp_..._autor.deleted_at IS NULL) +``` + +Sprawdź **wszystkie** agregaty po tabelach objętych soft-delete +(`count`/`min`/`max`/`array_agg`/`string_agg`/`sum`/`bool_*`), nie tylko `count`. + +### 3.3 Sprzątanie `bpp_kronika_*` — siedem widoków, jedną migracją + +Cała rodzina jest martwa (zero konsumentów w kodzie, szablonach, `Meta.db_table`, +`flexible_reports`, surowym SQL-u). Nie da się skasować trzech osobno — zależą +od nich dwa widoki nadrzędne. Graf zależności i kolejność `DROP` są w planie 02. +Po zrobieniu: usuń wpisy z `WYJATKI` w kanarku katalogowym. + +### 3.4 Rozszerzenie obu kanarków + +`TABELE_SOFT_DELETE` += 5 tabel publikacji. Po tym kanarek złapie też +`bpp_kronika_praca_{doktorska,habilitacyjna}_view` — ich żywotności **nikt +jeszcze nie zweryfikował**. + +--- + +## 4. Fakty o kodzie, które w fazie 01 były źródłem błędów + +Wszystkie zweryfikowane empirycznie. Nie zakładaj, że któryś jest inny — +ale przy okazji fazy 02 warto potwierdzić, bo `dev` żyje. + +- **`.filter()` resolwuje nazwy pól NATYCHMIAST** (`Query.build_filter`), nie + przy iteracji. `FieldError` leci od razu na modelu bez kolumny. +- **`content_type_id` NIE istnieje** na modelach publikacji — to property na + `RekordBase` (`rekord.py:288`). Używaj `ContentType.objects.get_for_model()`. +- **`transactional_db` NIE jest potrzebny** — triggery działają w transakcji + testowej. Zwykły `django_db` wystarcza. +- **Fixtury `wydawnictwo_ciagle_z_dwoma_autorami` i `wydawnictwo_ciagle_z_autorem` + to TEN SAM obiekt** (`conftest_publications.py:193-215`, pierwsza zwraca drugą). +- **`Rekord` czyta tabelę `bpp_rekord_mat`**, nie widok `bpp_rekord` + (`rekord.py:382-386`); widok obsługuje marginalna klasa `RekordView`. +- **`full_refresh()` to `denorm.rebuildall`**, NIE re-projekcja `_mat` + (`rekord.py:121-127`). Nie nadaje się do weryfikacji spójności soft-delete. +- **`_upsert_sql` z `0432` mapuje kolumny POZYCYJNIE.** Zmiana definicji widoku, + która przestawi kolumny, wpisze dane do złych pól i nic nie krzyknie. +- **`Uczelnia.objects.get_default()` NIE ISTNIEJE** — usunięte, pilnowane + `src/bpp/tests/test_multihosted_get_default_guard.py`. +- **`SentData` jest scope'owane po uczelni** — `get_for_rec(rec, uczelnia=None)`; + przy ≥2 wierszach lookup bez uczelni rzuca `MultipleObjectsReturned`. +- **Klient PBN to pakiet zewnętrzny** (`pbn_client`), nie `src/pbn_api/client/mixins/`. +- **`DENORM_DISABLE_AUTOTIME_DURING_FLUSH = True`** (`settings/base.py:1222`) — + zapisy denorma nie bumpują `ostatnio_zmieniony`. Skutek: zmiana składu autorów + nie podnosi znacznika publikacji. **Zachowanie prekursorskie** (hard-delete + działa tak samo), opisane w runbooku jako obserwacja. + +--- + +## 5. Jak pracować (proces, który się sprawdził — i gdzie zawiódł) + +### Co działało + +- **SDD** (`superpowers:subagent-driven-development`): implementer → recenzja → + runda poprawek → re-recenzja. Recenzje znalazły rzeczy, których nie znalazł + ani self-review planów, ani implementer. +- **Mutation testy.** W fazie 01 **cztery** testy okazały się przechodzić + niezależnie od implementacji (tautologie, oparcie o efekt uboczny hard-delete, + fixtury będące tym samym obiektem, sprawdzanie substringu w DDL). Pytaj + o każdy nowy test: *co muszę zepsuć, żeby spadł na czerwono* — i sprawdź to. +- **Inwentaryzacja przed naprawą.** Przy wycieku ORM dała 15 znalezionych / + 11 naprawionych / 4 świadomie zostawione, plus listę kategorii z **zerem** + trafień — żeby nikt nie szukał drugi raz. + +### Czego NIE powtarzać + +- ⚠️ **Nie uruchamiaj równoległych agentów w jednym worktree.** Rozdzielenie + plików nie wystarcza: `reset`, `stash`, `amend` widzą całą gałąź. W tej sesji + doszło do trzech kolizji (plik roboczy w cudzym commicie, `git stash pop` + zjadający cudzy stash, `git reset --soft` omal nie kasujący cudzego commitu). + Równoległość → **osobne worktree**. +- ⚠️ **`PYTEST_TESTCONTAINERS_REUSE=1` daje fałszywe alarmy** — reużywany + kontener dryfuje (w jednym przebiegu zniknęła `Instytucja_Finansujaca`). + Przy dziwnych, niepowtarzalnych awariach: świeży kontener. +- ⚠️ **`make clean-testcontainers` na tym hoście ubija CUDZE kontenery.** + Celuj po nazwie/ID. +- ⚠️ **Pusty wynik ≠ potwierdzenie.** Pathspec `'src/*/migrations/'` w cudzysłowie + zwraca pustkę; `xfail` bez `strict=True` chowa `XPASS`; `git stash push` na + czystym drzewie nic nie tworzy, ale `pop` i tak coś zdejmie. Zanim uznasz brak + wyniku za dowód — sprawdź, czy narzędzie w ogóle potrafi coś pokazać. + +--- + +## 6. Otwarte decyzje + +| Sprawa | Stan | +|---|---| +| **Strategia wydania** | Rekomendacja: scalać fazami do `dev`, **wydać dopiero po 04**. Faza 01 sama nie daje wartości użytkownikowi (kosz bez UI — admin to faza 07), a kosztuje okno serwisowe i narzut GiST. Faza 03 jest obowiązkowa razem z 02 (bez niej re-import tworzy duplikaty). | +| Ręczne GET-y changelistu admina (`?autorzy_set__…=`) | `lookup_allowed` przepuszcza surowy lookup; decyzja produktowa | +| Pomiar `0492` na kopii produkcyjnej | **przed wdrożeniem**, patrz runbook §1 | +| Pomiar narzutu GiST | nikt nie zmierzył; testy tego nie wykryją | +| A2 — zmiana składu autorów nie podnosi znacznika publikacji | prekursorskie, w runbooku | + +## 7. Długi pozostałych faz (spłacić przy starcie danej fazy) + +- **03**: decyzja #14 („pomiń + zaraportuj" przy trafieniu w kosz) niewpięta + w taski 2-5; test ma literalny placeholder; `deduplikator_autorow/utils/merge.py` + zrefaktoryzowany (cytowane linie nieaktualne). +- **04**: nikt nie przeplata `AutorManager` (husk autora zostanie widoczny); + `Autor.restore()` **musi** nadpisać `strict=False`. +- **05**: ✅ spłacony 2026-08-07. +- **06**: `MetrykaAutora` trzyma listy ID prac w JSON-ach — zostanie stale; + Task 5 (shim `zakolejkuj_*`) jest **martwy** po ustaleniach fazy 05 — usunąć. From 2e3b38611b385543fdfd1962ef929b24ecf51039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 23:50:26 +0200 Subject: [PATCH 54/75] =?UTF-8?q?feat(soft-delete):=20faza=2002=20?= =?UTF-8?q?=E2=80=94=20publikacje=20(5=20modeli,=20widoki,=20kaskada,=20ka?= =?UTF-8?q?narki)=20(#741)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(soft-delete): inwentaryzacja widokow na starcie fazy 02 Kanarek katalogowy fazy 01 wywolany na rozszerzonej liscie tabel, bez modyfikowania TABELE_SOFT_DELETE (czyli bez czerwonego testu na starcie galezi). 15 widokow w 5 kategoriach. Ustalenie, ktorego nie bylo w tabeli zadan planu: 6 widokow agregujacych (bpp_nowe_sumy_* x5 + rozbieznosci_dyscyplin) wymaga poprawki -- plan mial dla nich wylacznie ostrzezenie "sprawdz, czy wymagaja". Wynik negatywny: zero winowajcow wsrod 3 tabel *_autor fazy 01. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * feat(soft-delete): 5 modeli publikacji -> SoftDeleteModel + waska kaskada Mixin BppPublikacjaSoftDeleteMixin: delete()/restore() per-instancja, jawna kaskada na wiersze *_Autor pod wspolnym transaction_id, BEZ refleksyjnej kaskady pakietu (ta zjechalaby po *_Streszczenie i spolce, i to CICHO -- delete() pakietu ma domyslnie strict=False). Rozpoznanie ksztaltu kaskady przez _meta.related_objects, a NIE przez type(self).__dict__.get("autorzy_set"), jak proponowal plan. Ten drugi sposob NIE dziala: property autorzy_set jest zadeklarowana na abstrakcyjnej bazie Praca_Doktorska_Baza, a dziedziczenie abstrakcyjne w Django kopiuje do potomka POLA, nie zwykle atrybuty Pythona -- __dict__ klasy konkretnej jest pusty, wiec test na property dawalby falszywe "to through-model", a zaraz potem AttributeError na FakeSet.model. Metadane Django znaja wylacznie PRAWDZIWE relacje, wiec odpowiadaja na pytanie, ktore faktycznie zadajemy. Przy okazji nazwa pola FK pochodzi z relacji, zamiast byc zaszyta na sztywno w restore(). deleted_objects to BppDeletedManager (faza 01), nie pakietowy DeletedManager -- plan cytowal ten drugi, bo powstal przed runda poprawek fazy 01. save() bumpuje ostatnio_zmieniony przy soft-delete i restore (kontrakt PINNED fazy 01) -- bez tego nagrobki dla harvestu przyrostowego nie byly by odpytywalne po ostatnio_zmieniony__gte. Migracja 0496: 15 pol + 5 indeksow CZESCIOWYCH (WHERE deleted_at IS NOT NULL), tym samym wzorcem co faza 01 -- pelny btree byl by samym kosztem, bo deleted_at IS NULL pasuje do ~100% wierszy. Test zweryfikowany MUTACYJNIE (dwie mutacje, po jednej na asercje): kaskada wylaczona -> pada asercja o deleted_at; kaskada bez wspolnego txid -> pada asercja o transaction_id. Przy pierwszej mutacji wyszlo, ze dane fixtury byly nieprawidlowe (baker nadawal obu autorstwom kolejnosc=0, lamiac wc_autor_excl_rekord_kolejnosc) i test przechodzil CZESCIOWO z powodu efektu ubocznego: ograniczenie jest DEFERRABLE i warunkowane deleted_at IS NULL, wiec soft-delete wyprowadzal oba wiersze poza jego zakres przed COMMIT. Naprawione jawna, rozna kolejnoscia. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * feat(soft-delete): filtr deleted_at w 7 widokach publikacji + galaz kasujaca Migracja 0497, odpowiednik 0489 z fazy 01 dla drugiej sciezki. Ta sama wymuszona kolejnosc: widoki -> funkcje refresh -> regeneracja bramki WHEN (bramka jest wyliczana z pg_depend, wiec musi isc PO widokach). Wzorca z 0489 NIE dalo sie uzyc wprost. Tamta funkcja dopisuje WHERE na koncu i asertuje, ze definicja konczy sie golym "FROM ". Dla publikacji sa TRZY rozne ksztalty (introspekcja zywego katalogu): A. wydawnictwo_{ciagle,zwarte}_view, patent_view -- koncza sie LEFT JOIN ... GROUP BY .id, bez WHERE najwyzszego poziomu. WHERE po GROUP BY to blad skladni, wiec predykat idzie PRZED GROUP BY. B. praca_{doktorska,habilitacyjna}_view -- koncza sie FROM ; wzorzec z 0489 dziala wprost. C. praca_{doktorska,habilitacyjna}_autorzy -- maja WLASNY WHERE (join po przecinku), wiec predykat idzie do niego. Rozpoznanie ksztaltu NIE moze isc po samym slowie "WHERE": rodzina A zawiera count(...) FILTER (WHERE ..._autor.deleted_at IS NULL) z 0494, a wszystkie maja WHERE w podzapytaniach o django_content_type. Dyskryminatorem jest wciecie dwoch spacji (klauzula glowna w formacie pg_get_viewdef(pretty=true)). W rodzinie A filtrujemy tabele PUBLIKACJI -- lewa, napedzajaca strone LEFT JOIN-a. Pulapka z handoffu 3.2 dotyczy strony PRAWEJ i jest pokryta testem test_publikacja_bez_zywych_autorow_ZOSTAJE. Zdjete xfail(strict=True) z dwoch kanarkow fazy 01 w test_cache/test_soft_delete_preconditions.py -- razem z symulacja (ALTER ADD COLUMN + owijka widoku), ktora te testy stosowaly, zeby dalo sie je w ogole napisac przed faza 02. Teraz mechanizm jest prawdziwy. Weryfikacja mutacyjna (2 mutacje) ujawnila, ze pierwsza wersja testu na pulapke agregatu BYLA BEZWARTOSCIOWA: odpytywala bpp_rekord_mat, czyli cache, ktorego soft-delete autorstwa nie przelicza -- niesniezy wiersz maskowal zdegenerowany widok. Test odpytuje teraz WIDOK (wyrocznia), a cache dopiero po wymuszonym przeliczeniu. Test odwracalnosci 0497 zlapal blad w backward: katalog przechowuje predykat w postaci ZNORMALIZOWANEJ (dla jednej tabeli w zasiegu Postgres usuwa kwalifikacje), wiec _bez_filtra szukala tekstu, ktorego tam nie ma. Przy poprawce kolejnosc kandydatow jest nosna: wariant z "AND" musi byc sprawdzany pierwszy, bo "\n WHERE " pasuje jako PREFIKS do "WHERE AND ..." i usuniecie samego prefiksu zostawiloby WHERE zaczynajacy sie od AND. ZNANA REGRESJA, nie naprawiona w tym commicie (do decyzji): test_migracja_0489_rewers i test_migracje_0490_0493_rewers padaja. Przyczyna ustalona: django-denorm podpina post_migrate -> install_triggers(), ktore odbudowuje triggery z AKTUALNYCH modeli. Zjazd ponizej 0496 usuwa kolumny deleted_at z tabel publikacji, ale modele je nadal deklaruja, wiec denorm generuje WHEN z OLD."deleted_at" -> UndefinedColumn. Zweryfikowane: oba testy PRZECHODZA na stanie sprzed fazy 02. Ma to konsekwencje wdrozeniowe (rollback ponizej 0496), wiec wymaga decyzji, nie cichej latki w tescie. Trzeci failure (test_symulacja_fazy_02 w kanarku) jest OCZEKIWANY -- nalezy do zadania "rozszerzenie kanarkow". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * test(soft-delete): fixtura odpinajaca reinstalacje denorma w testach rewersu Naprawia regresje zglosozna w poprzednim commicie. django-denorm podpina post_migrate -> install_triggers(), ktore odbudowuje triggery z AKTUALNYCH modeli. Testy odwracalnosci schodza ponizej 0496, czyli ponizej migracji dodajacej deleted_at na tabelach publikacji -- kolumny w bazie wtedy nie ma, ale modele nadal ja deklaruja, wiec denorm generuje WHEN z OLD."deleted_at" i CREATE TRIGGER pada na UndefinedColumn. KOREKTA wobec poprzedniego commita: to NIE jest nowe zjawisko. Faza 01 udokumentowala dokladnie ten mechanizm w runbooku (sekcja 4 "Rollback: cofac KOD I MIGRACJE razem"), z identycznym komunikatem bledu. Nowe jest tylko to, ze sciezka zjazdu testow fazy 01 przecina teraz 0496, wiec trafiaja one w mechanizm, ktory wczesniej ich nie dotyczyl. To nie jest problem wdrozeniowy: w prawdziwym rollbacku wycofuje sie KOD razem ze schematem, a stare modele nie maja deleted_at. Kombinacja "nowy kod + stary schemat" powstaje wylacznie w tescie, ktory manipuluje samym schematem trzymajac kod na miejscu. Fixtura odpina handler na czas testu i na koncu przebudowuje triggery recznie. W finally doprowadza tez schemat do najnowszej migracji SAMA, zamiast ufac ze test zdazyl -- inaczej przy asercji padajacej w polowie przebudowa wywalilaby sie na brakujacej kolumnie, przykrywajac PRAWDZIWY powod porazki. Runbook fazy 01 rozszerzony o zakres fazy 02 i o informacje, ze obejscie jest wylacznie testowe. Pozostaje 1 oczekiwana porazka: test_symulacja_fazy_02 w kanarku katalogowym -- nalezy do zadania "rozszerzenie kanarkow", bo jego dowod zaklada, ze widoki publikacji SA jeszcze winowajcami. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * feat(soft-delete): 6 widokow agregujacych pomija skasowane publikacje Task 2d -- zadanie, ktorego NIE BYLO w tabeli kolejnosci planu fazy 02. Plan mial dla tych widokow wylacznie ostrzezenie "sprawdz, czy wymagaja poprawki"; inwentaryzacja kanarka na starcie fazy pokazala, ze wymagaja wszystkie szesc. bpp.0498 -- piec widokow bpp_nowe_sumy_*_view. rozbieznosci_dyscyplin.0023 -- rozbieznoscizrodelview. Oba dopelniaja migracje fazy 01 (0495 i 0022), ktore zamknely w tych widokach wymiar AUTORSTWA. Wymiar PUBLIKACJI byl wtedy otwarty, bo publikacje staly sie soft-delete dopiero w 0496. Zakres szerszy niz w fazie 01: 0495 objela 3 widoki (tylko typy z through-modelem), 0498 obejmuje 5 -- praca_doktorska i praca_habilitacyjna nie maja tabeli *_autor, wiec 0495 nie miala tam czego filtrowac, ale soft-delete samej pracy dotyczy ich tak samo. PULAPKA AGREGATU TU NIE WYSTEPUJE -- sprawdzone introspekcja, nie zalozone. Te widoki nie maja ANI JEDNEGO LEFT JOIN-a (zlaczenia po przecinku albo jawne JOIN) ani GROUP BY -- sumowanie dzieje sie dopiero w modelach Django nad UNION ALL. Semantyka jest zreszta odwrotna niz przy liczba_autorow: skasowana publikacja MA wypasc z rankingu, a nie zostac w nim z zerem. Dlatego wystarczyl wspoldzielony helper widok_dopisz_warunek (ktory zreszta sam odrzuca definicje z GROUP BY/OR/UNION). TESTY -- kazdy wymiar osobno, bo kaskada maskuje. Naiwny test "wc.delete() -> wiersz znika z sum" NIE jest wyrocznia dla tych migracji: delete() kaskaduje na autorstwa, wiec wiersz znika z DWOCH niezaleznych powodow -- przez nowy filtr publikacji ORAZ przez filtr autorstwa z fazy 01. Potwierdzone mutacyjnie: po wylaczeniu OBU migracji tego zadania test end-to-end dalej PRZECHODZI, a padaja tylko testy izolujace wymiar (surowy UPDATE samej publikacji) i testy kontraktu pg_depend. Oba warianty zostaja: izolujacy jako wyrocznia, end-to-end jako pokrycie realnej sciezki operatora wraz z restore. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * feat(soft-delete): kasacja martwej rodziny kronika + rozszerzenie kanarkow MIGRACJA 0499 -- siedem widokow bpp_kronika_* jednym cieciem. Faza 01 chciala skasowac trzy z nich i zostala zablokowana: zaleza od nich dwa widoki nadrzedne, wiec goly DROP nie przechodzi, a CASCADE zabralby po cichu takze je. Graf trzeba rozciac w jednym miejscu albo wcale. Dowod martwoty mocniejszy niz grep: pg_depend pokazuje, ze JEDYNE zaleznosci od tej siodemki sa WEWNATRZ rodziny (kronika_view <- all_unsorted_view <- piec lisci). Nic spoza niej na nich nie stoi -- obejmuje to takze widoki i reguly, ktorych nazwa nie zawiera slowa "kronika". Do tego zero trafien w kodzie/szablonach/JSON-ach i zero Meta.db_table. Zywotnosc dwoch z nich (praca_doktorska, praca_habilitacyjna) byla nieznana -- wyszla na jaw dopiero, gdy kanarek dostal do zakresu tabele publikacji. Dokladnie ta klasa odkrycia, dla ktorej kanarek istnieje. Odwracalnosc przez sidecar .sql WYGENEROWANY z pg_get_viewdef(), nie przepisany recznie -- przy 7 KB SQL-a przepisywanie to proszenie sie o cicha literowke. Test odwracalnosci sprawdza kolejnosc CREATE VIEW, ktorej sam generator nie gwarantuje. KANAREK KATALOGOWY -- TABELE_SOFT_DELETE += 5 tabel publikacji, WYJATKI oprozznione (przedmiot zniknal razem z kronika). Test symulacyjny fazy 01 opieral dowod na tym, ze widoki publikacji SA jeszcze winowajcami; faza 02 to uniewaznila, wiec przerobiony na dowod MUTACYJNY: sam wytwarza usterke (zdejmuje filtr) i pokazuje, ze stary rdzen tekstowy ja przepuszcza -- bo w definicji nadal jest slowo deleted_at, tyle ze z FILTER po tabeli *_autor, czyli po DRUGIEJ stronie JOIN-a. Ta wersja zostaje prawdziwa niezaleznie od stanu bazy. KANAREK ORM -- rozdzielony na dwa testy. Relacje autorstwa (faza 01) zostaja zwyklym, ZIELONYM testem. Relacje publikacji dostaja osobny test z xfail(strict=True), bo skan znalazl 10 prawdziwych wyciekow (Count/filter po odwrotnej relacji do publikacji od strony Zrodlo/Charakter_Formalny), a audyt wywolan ORM w imporcie/dedup/PBN plan przypisuje jawnie FAZIE 03. Wrzucenie wszystkiego do jednego xfail-a wylaczyloby takze ochrone wywalczona w fazie 01 -- stad podzial. strict=True jest mechanizmem wymuszajacym: gdy faza 03 to naprawi, test zacznie padac jako XPASS i zmusi do zdjecia markera. Ten sam wzorzec faza 01 zastosowala wobec fazy 02. Triage (10 wyciekow + 4 falszywe trafienia, kazde z uzasadnieniem) w docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md -- faza 03 nie musi go odtwarzac. Falszywe trafienia biora sie stad, ze nazwy relacji fazy 02 to zwykle slowa (patent, wydawnictwo_ciagle), a nie dystynktywne jak autorzy_set. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * docs(soft-delete): self-review audytu ORM -- trzy zastrzezenia do wlasnych wnioskow Audyt z poprzedniego commita byl najslabiej zweryfikowana rzecza w tej fazie (triage z podgladu, nie z dowodow). Self-review obalil trzy jego elementy: 1. PREMISA FALSZYWA. Dokument twierdzil, ze zapytania startujace OD publikacji sa bezpieczne, bo objects to BppSoftDeleteManager. Sprawdzone empirycznie: Wydawnictwo_Ciagle i Wydawnictwo_Zwarte maja wciaz menedzera z fazy 01 i WIDZA skasowane rekordy, bo Task 4 (przeplecenie menedzerow) nie jest jeszcze zrobiony. 2. LISTA NIEKOMPLETNA. Audyt uzyl pieciu nazw modeli, pomijajac "rekord" (FK z modeli-dzieci: streszczenia, dodatkowe tytuly, zewnetrzne bazy) i "wydawnictwo_nadrzedne". Po ich dolozeniu skan daje 120 znalezisk zamiast 14, wiec zdanie "lista gotowa, faza 03 nie musi jej odtwarzac" bylo nieuprawnione. 3. ...ale te 120 to w wiekszosci FALSZYWE trafienia -- i to jest wniosek o NARZEDZIU, nie o kodzie. Nazwa "rekord" oznacza raz publikacje, raz widok Rekord (juz przefiltrowany przez 0497), a raz parametr GET albo klucz formularza. Kanarek ORM dopasowuje NAZWY, nie modele: w fazie 01 dzialal swietnie, bo nazwy byly dystynktywne (autorzy_set), w fazie 02 to zwykle slowa i precyzja metody sie zalamuje. Uczciwe narzedzie musialoby rozwiazywac sciezke lookupu wobec _meta -- tak, jak kanarek katalogowy robi to na pg_depend. To osobne narzedzie, nie parametr. Dodatkowo poprawiony blad w tabeli: wpis o usun_zrodla_bez_publikacji mial naglowek "KASUJE ZRODLA", sugerujacy utrate danych, podczas gdy kierunek jest odwrotny i lagodny -- zrodlo z publikacjami w koszu NIE zostanie skasowane (zalegajace smieci, nie utrata danych). Podzial faza 02 = warstwa bazodanowa / faza 03 = wywolania ORM zostaje, ale faza 03 nie powinna traktowac tabelki jako gotowej listy zadan. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * fix(soft-delete): DROP VIEW IF EXISTS w 0499 + domkniecie dowodu martwoty Wynik self-review audytu martwoty rodziny kronika (na prosbe wlasciciela). BLAD W MIGRACJI. Uzylem golego DROP VIEW bez CASCADE -- swiadomie, zeby dostac glosny blad, gdyby cos spoza rodziny na tym stalo. Ale przegapilem druga, NIEZALEZNA os: brak IF EXISTS. Na bazie, gdzie ktos juz kiedys te pietnastoletnie widoki recznie sprzatnal, migracja twardo padala. IF EXISTS nie oslabia glosnosci -- przy istniejacej zaleznosci DROP dalej rzuca blad -- wiec daje idempotentnosc za darmo. LUKA METODY. Audyt przeszukiwal wylacznie repo aplikacji, a to sa obiekty BAZODANOWE: konsument nie musi tam mieszkac. Sprawdzone repozytoria siostrzane: bpp-mcp i bpp-skills czyste; bpp-deploy odwoluje sie do kroniki w trzech miejscach, ale ZADNE nie jest funkcjonalnym konsumentem (dwa komentarze + jedna kontrolka diagnostyczna). sed w migracji kolacji celuje we wzorzec COLLATE, a nie w nazwy widokow, wiec skrypty nie przestaja dzialac. Odnotowane w docstringu jako nieblokujace TODO dla bpp-deploy: pg-collation-migrate-3-load.sh drukuje po loadzie "kronika views: N" jako sanity-check, wiec po tej migracji wypisze 0 i operator moze odczytac to jako nieudany load. DOMKNIECIE DOWODU. flexible_reports trzyma definicje raportow jako wiersze w bazie produkcyjnej, wiec z repo nie dalo sie ich sprawdzic -- to byl jedyny element dowodu oparty na zalozeniu, nie na fakcie. Potwierdzone przez wlasciciela systemu: zaden raport nie odpytuje tych widokow. Wlasciciel potwierdzil takze, ze kod generujacy kronike zostal skasowany. Dowod stoi teraz na czterech niezaleznych przeslankach: pg_depend (jedyne zaleznosci wewnatrz rodziny -- obejmuje obiekty, ktorych nazwa nie zawiera slowa "kronika", czego grep by nie zobaczyl), grep po repo, przeglad repo siostrzanych, potwierdzenie wlasciciela. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * feat(soft-delete): przeplecenie menedzerow publikacji + shim na django-easy-audit TASK 4. Wydawnictwo_Ciagle_Manager i Wydawnictwo_Zwarte_Manager dziedzicza teraz po BppSoftDeleteManager zamiast po golym models.Manager. Do tej pory menedzer zadeklarowany w ciele klasy przeslanial ten wniesiony przez BppPublikacjaSoftDeleteMixin, wiec objects na DWOCH najwazniejszych modelach pokazywalo kosz -- podczas gdy Patent/Praca_Doktorska/Praca_Habilitacyjna mialy filtr "z urodzenia". Asymetria byla niewidoczna, bo wszystkie dotychczasowe testy fazy 02 szly przez widoki albo surowy SQL. Mutacja obalila moje wlasne uzasadnienie: twierdzilem w docstringu, ze KOLEJNOSC baz jest nosna, bo przy odwroceniu get_queryset() wzielby sie z mixinu oplat. Nieprawda -- ManagerModeliZOplataZaPublikacjeMixin NIE jest menedzerem, tylko czystym mixinem z jedna metoda, wiec nie wnosi wlasnego get_queryset i nie ma o co konkurowac w MRO. Odwrocenie kolejnosci nie zmienia zachowania (sprawdzone). Nosna jest DRUGA BAZA. Docstring poprawiony. SHIM NA django-easy-audit. Task 4 odslonil OSMY mechanizm zakladajacy, ze skasowany wiersz znika (handoff wymienial siedem z fazy 01) -- tym razem w pakiecie zewnetrznym. easyaudit pobiera poprzednia wersje wiersza przez sender.objects, wiec na modelu soft-delete restore() leci DoesNotExist (wiersz jest wtedy jeszcze w koszu). BPP ma PROPAGATE_EXCEPTIONS=True, wiec wyjatek wywraca cala operacje. To NIE jest blad wprowadzony przez faze 02. Zgloszenie_Publikacji jest SoftDeleteModel od dawna i figuruje w REGISTERED_CLASSES, wiec na dev NIE DA SIE dzis przywrocic skasowanego zgloszenia -- zweryfikowane. Faza 02 rozszerza zasieg z jednego modelu na szesc. Through-modele *_Autor byly nietkniete, bo nie ma ich w REGISTERED_CLASSES. Poprawne jest _base_manager: Django dokumentuje go jako menedzera zwracajacego WSZYSTKIE obiekty i sam tworzy dla niego zwykly Manager, gdy model nie ustawia base_manager_name. Zweryfikowane -- _base_manager widzi kosz. Przy okazji koryguje moja wczesniejsza teze, ze _base_manager jest przefiltrowany i ze przejscie po FK do skasowanej publikacji tez by padlo: nie padnie, i base_manager_name NIE jest potrzebne. Shim zamiast forka, bo wadliwe wywolanie jest w calym pakiecie DOKLADNIE JEDNO, a sygnaly sa podpinane z dispatch_uid -- mozna czysto podmienic sam handler. Podmiana dziala niezaleznie od kolejnosci ready(): Signal.connect ignoruje duplikat dispatch_uid, wiec gdy nasz ready() biegnie pierwszy (tak jest dzis), pozniejszy connect easyauditu jest pomijany. Upstream zna to jako issue #175 (otwarte od 2021-02). Cztery PR-y w szesc lat (#168, #176, #318, #342), zaden nie scalony, mimo ze projekt jest aktywnie wydawany. Z watku przy #168 wynika dlaczego: maintainer odsyla do obejscia przez CRUD_DIFFERENCE_CALLBACKS, ktore NIE DZIALA, bo wyjatek leci zanim callbacki zostana sprawdzone. Test test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny to STRAZNIK ODWROTNY: pada, gdy upstream naprawi blad, i kaze skasowac shim zamiast "naprawiac test". Test audytu wymagal zdjecia DWOCH bramek niezwiazanych z shimem (brak requestu z userem -> callback BPP odrzuca zdarzenie; brak settings.TEST -> easyaudit odklada zapis na on_commit, ktory w tescie nigdy nie odpala). Obie dawaly "zero zdarzen", czyli objaw nieodrozgnialny od zepsutego shimu. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * docs(soft-delete): gotowy tekst zgloszenia do upstreamu django-easy-audit Do wklejenia jako komentarz do issue #175 (nie nowe issue -- byloby piatym watkiem o tej samej sprawie). Nowa przeslanka wobec czterech poprzednich prob (#168, #176, #318, #342): obejscie sugerowane przez maintainera przez CRUD_DIFFERENCE_CALLBACKS jest NIEOSIAGALNE, bo wyjatek leci w linii 102, a callbacki sa wolane dopiero w 113. Zadne z poprzednich zgloszen tego nie stwierdzilo -- a to prawdopodobnie tlumaczy, dlaczego poprawka od szesciu lat nie wchodzi. Zawiera minimalna reprodukcje, uzasadnienie _base_manager zamiast _default_manager (ten drugi tez moze filtrowac) oraz obserwacje, ze przy braku Meta.base_manager_name Django samo tworzy nieprzefiltrowany Manager, wiec zmiana jest no-opem dla projektow, ktore niczego nie nadpisuja. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * feat(soft-delete): bramka denorm (2c) + testy integracyjne i weryfikacja fazy (5+6) TASK 2c -- ZERO ZMIAN W KODZIE, i to jest wynik, nie unik. Inwentaryzacja: w calym kodzie produkcyjnym sa DOKLADNIE DWIE zaleznosci @depend_on_related celujace w model publikacji -- obie ("self", "wydawnictwo_nadrzedne") na Wydawnictwo_Zwarte, obie BEZ only=. Brak only= znaczy, ze denorm buduje bramke WHEN ze WSZYSTKICH kolumn, wiec deleted_at wchodzi do niej automatycznie. Potwierdzone niezaleznie i przypadkiem: to wlasnie ta bramka wywalala testy odwracalnosci migracji na CREATE TRIGGER ... WHEN (OLD."deleted_at" ...) dla bpp_patent. Dokladanie denorm_always_only albo list only= byloby wiec martwym kodem. Zostaje test semantyczny odpowiadajacy na pytanie z planu ("czy soft-delete ksiazki-matki ma uniewazniac denorm-cache rozdzialow"): NIE, bo soft-delete nie zmienia tytulu matki -- nie ma czego uniewazniac. Test pilnuje, ze rozdzial zostaje zywy i jego cache sie NIE zmienia. TASK 5 -- testy integracyjne: restore podnosi wylacznie autorstwa skasowane tym samym transaction_id (te skasowane wczesniej, osobna decyzja, zostaja w koszu), post_soft_delete jest emitowany, kaskada nie rusza *_Streszczenie, gate na bulk update(deleted_at=) rzuca na wszystkich 5 modelach. TASK 6 -- weryfikacja fazy ujawnila REGRESJE, ktorej kanarek ORM nie mial szans zlapac. test_rok_habilitacji_view padal, bo widok robi `autor.praca_habilitacyjna` -- odwrotne OneToOne. Django rozwiazuje je przez ReverseOneToOneDescriptor, ktory pyta _base_manager, z definicji NIEprzefiltrowany. Skasowana habilitacja byla tam nadal osiagalna i widok zwracal 200 zamiast 404. To odwrotna strona faktu, ktory przy easyaudicie wygladal na dobra wiadomosc: nieprzefiltrowany _base_manager ratuje audyt i psuje trawersowanie relacji. Kanarek ORM skanuje ARGUMENTY wywolan (filter/annotate/Count), a tu nie ma zadnego wywolania -- jest dostep do atrybutu. Zadne rozszerzanie listy RELACJE tego nie zmieni; to inna os problemu. Naprawione punktowo w widoku (jawny check deleted_at) -- centralnie sie nie da, bo Meta.base_manager_name wskazujacy menedzer filtrujacy jest przez Django jawnie odradzany. Klasa wycieku dopisana do dokumentu dla fazy 03. Drugi skutek wyszedl warstwe glebiej: soft-skasowana habilitacja NADAL trzyma referencje O2O PROTECT do autora, wiec autora nie da sie skasowac (ProtectedError). To nie jest regresja -- rekord istnieje i nie wolno go osierocic. Test aktualizowany (hard_delete przed kasowaniem autora), bo sprawdza sciezki 404 widoku, a nie semantyke kasowania. WERYFIKACJA: 306 passed / 1 xfailed (swiadomy dlug fazy 03) w suitach soft-delete + cache + rozbieznosci; 416 passed w regresji publikacji (-k "wydawnictwo or patent or doktor or habilit"); makemigrations --check czysty dla bpp i rozbieznosci_dyscyplin. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * docs(soft-delete): decyzja o Tasku 3 + handoff przed faza 03 TASK 3 (slug) -- SWIADOMIE NIEWYKONANY, decyzja wlasciciela. slug zostaje unique=True, bezwarunkowo. Zadanie z planu polegalo na OSLABIENIU ograniczenia (unikalnosc tylko wsrod zywych), a jego przeslanka nie zachodzi: get_slug() sklada slug jako tytul-zrodlo-autorzy--, wiec zawiera klucz glowny. Dwa rozne wiersze nie moga miec tego samego slugu, a skasowany rekord nie zablokuje nowego -- nowy dostaje nowe pk. ID jest w slugu CELOWO, wlasnie po to, zeby slug byl unikalny. Zaznaczone w trzech miejscach planu (box przy tasku, tabela kolejnosci, Definition of Done), zeby nikt nie "dokonczyl" tego w dobrej wierze -- tresc oryginalna schowana w
    jako kontekst historyczny. Wyszlo przy pisaniu testow TDD: padly wszystkie dziesiec, w tym ten, ktory powinien przechodzic JESZCZE PRZED zmiana. Denorm dodatkowo nadpisuje recznie ustawiony slug, wiec kolizji nie da sie nawet wywolac sztucznie. Tabela kolejnosci uzupelniona takze o Task 2d, ktorego w planie nie bylo (6 widokow agregujacych -- plan mial dla nich tylko ostrzezenie "sprawdz"). HANDOFF dla fazy 03. Najwazniejsze, co niesie: - lista mechanizmow zakladajacych, ze skasowany wiersz znika, urosla z 7 (faza 01) do 9 -- oba nowe spoza kodu, ktory zmienialismy: django-easy-audit i odwrotne OneToOne przez _base_manager; - OSTRZEZENIE, ze tabelka 10 wyciekow ORM NIE jest gotowa lista zadan. Kanarek dopasowuje NAZWY, nie modele; po dolozeniu "rekord" daje 120 znalezisk, w wiekszosci falszywych. Faza 03 ma zbudowac narzedzie model-aware (pytajace _meta), a nie triazowac 120 pozycji recznie; - klasa wycieku, ktorej kanarek strukturalnie NIE MOZE zlapac: dostep atrybutowy do relacji; - cztery rzeczy obalone mutacyjnie w fazie 02, w tym dwa moje wlasne testy i jeden wiarygodnie brzmiacy docstring. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh * fix(soft-delete): 7 regresji wykrytych dopiero przez PELNA suite Moje wczesniejsze przebiegi byly PODZBIORAMI (soft-delete + cache + regresja publikacji) i nie dotykaly zadnego z tych miejsc. Pelna suita znalazla 7 failed + 2 errors. Osobno warte odnotowania: `make tests-without-playwright` zwrocilo EXIT 0 mimo tych porazek -- kod wyjscia nie jest tu dowodem, trzeba czytac podsumowanie pytest. 1. ARTEFAKTY DjangoQL (3 testy). Faza 02 dodala deleted_at do 5 modeli publikacji, wiec zapisane artefakty schematu dla LLM sie rozjechaly. Zregenerowane komenda, ktora podaje sam test. 2. pbn_api, DWA e2e testy migracji (2 errors, w TEARDOWNIE). Ta sama przyczyna, co naprawiona wczesniej fixtura: denorm przebudowuje triggery w post_migrate z AKTUALNYCH modeli. Te testy cofaja swoja aplikacje MigrationExecutor-em, a poniewaz migracje bpp zaleza od pbn_api, Django cofa razem z nimi takze 0496 -- i CREATE TRIGGER pada na nieistniejacej kolumnie deleted_at. Fixtura bez_reinstalacji_denorma przeniesiona z bpp/tests/test_soft_delete/conftest.py do GLOBALNEGO src/conftest.py, bo potrzebuja jej dwie rozne rodziny testow w roznych aplikacjach. 3. PBN_Export_Queue.check_if_record_still_exists (1 test). Uzywa ContentType.get_object_for_this_type(), ktore pyta _base_manager -- z definicji NIEfiltrujacy. Soft-skasowana publikacja byla tam nadal znajdowana, wiec kolejka wysylalaby do PBN rekord usuniety przez operatora. To TRZECIE wystapienie tej samej klasy bledu w tej fazie (po easyaudicie i odwrotnym OneToOne) -- _base_manager omija soft-delete wszedzie, gdzie ktos siega po niego wprost. 4. KOMENDY CZYSZCZACE (3 testy): wyczysc_publikacje_importu i cleanup_demo_data. QuerySet.delete() na modelu soft-delete jest MIEKKIE, wiec "wyczyszczone" publikacje zostawaly w bazie razem z dziecmi. Gorzej: trzymaly dalej FK z PROTECT (Wydawnictwo_Zwarte.wydawca), wiec kasowanie slownikow dalej w manifescie wywalalo sie na ProtectedError. Obie komendy dostaly hard_delete() ORAZ global_objects -- komenda czyszczaca musi widziec takze kosz, inaczej zostawia publikacje, ktore operator juz uznal za usuniete, a ktore przy kolejnym imporcie koliduja jako niewidoczne duplikaty. getattr zamiast isinstance, bo listy modeli przychodza z manifestu/konfiguracji i mieszaja soft-delete z reszta. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/deweloper/runbook-soft-delete-faza-01.md | 17 ++ .../HANDOFF-soft-delete-faza-03.md | 186 ++++++++++++ .../2026-06-04-soft-delete-02-publikacje.md | 36 ++- ...026-08-07-easyaudit-upstream-zgloszenie.md | 113 +++++++ .../2026-08-07-faza-02-inwentaryzacja-orm.md | 193 ++++++++++++ ...26-08-07-faza-02-inwentaryzacja-widokow.md | 97 ++++++ src/bpp/apps.py | 10 + .../data/autor_djangoql_schema.compact.txt | 195 +++++------- .../data/autorzy_djangoql_schema.compact.txt | 195 +++++------- .../data/rekord_djangoql_schema.compact.txt | 195 +++++------- src/bpp/demo_data/orchestrator.py | 16 +- src/bpp/easyaudit_shim.py | 163 ++++++++++ .../commands/wyczysc_publikacje_importu.py | 29 +- .../0496_publikacje_soft_delete_fields.py | 110 +++++++ .../0497_soft_delete_rekord_views.py | 283 ++++++++++++++++++ ...98_nowe_sumy_bez_skasowanych_publikacji.py | 74 +++++ src/bpp/migrations/0499_drop_kronika_views.py | 98 ++++++ .../migrations/0499_drop_kronika_views.sql | 196 ++++++++++++ src/bpp/models/patent.py | 15 +- src/bpp/models/praca_doktorska.py | 14 +- src/bpp/models/praca_habilitacyjna.py | 14 +- src/bpp/models/soft_delete.py | 160 ++++++++++ src/bpp/models/wydawnictwo_ciagle.py | 45 ++- src/bpp/models/wydawnictwo_zwarte.py | 29 +- .../test_soft_delete_preconditions.py | 99 ++---- .../test_soft_delete/test_denorm_gate.py | 71 +++++ .../test_soft_delete/test_easyaudit_shim.py | 168 +++++++++++ .../test_kanarek_katalogowy.py | 207 +++++++------ .../test_soft_delete/test_kanarek_orm.py | 101 +++++-- .../test_soft_delete/test_kronika_usunieta.py | 65 ++++ .../test_migracja_0489_rewers.py | 2 +- .../test_migracja_0497_rewers.py | 74 +++++ .../test_migracje_0490_0493_rewers.py | 2 +- .../test_views_sql_publikacje.py | 251 ++++++++++++++++ .../test_soft_delete/test_widoki_pochodne.py | 89 ++++++ src/bpp/tests/test_soft_delete_publikacje.py | 207 +++++++++++++ src/bpp/tests/test_views/test_api.py | 9 +- src/bpp/views/api/__init__.py | 14 + src/conftest.py | 52 ++++ .../test_migracja_dyscypliny_uuid_e2e.py | 2 +- ...test_migracja_publikacja_instytucji_e2e.py | 2 +- src/pbn_export_queue/models.py | 16 +- ...nosci_zrodel_bez_skasowanych_publikacji.py | 52 ++++ .../tests/test_soft_delete.py | 26 ++ src/rozbieznosci_dyscyplin/util.py | 4 +- 45 files changed, 3441 insertions(+), 555 deletions(-) create mode 100644 docs/superpowers/HANDOFF-soft-delete-faza-03.md create mode 100644 docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md create mode 100644 docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md create mode 100644 docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md create mode 100644 src/bpp/easyaudit_shim.py create mode 100644 src/bpp/migrations/0496_publikacje_soft_delete_fields.py create mode 100644 src/bpp/migrations/0497_soft_delete_rekord_views.py create mode 100644 src/bpp/migrations/0498_nowe_sumy_bez_skasowanych_publikacji.py create mode 100644 src/bpp/migrations/0499_drop_kronika_views.py create mode 100644 src/bpp/migrations/0499_drop_kronika_views.sql create mode 100644 src/bpp/tests/test_soft_delete/test_easyaudit_shim.py create mode 100644 src/bpp/tests/test_soft_delete/test_kronika_usunieta.py create mode 100644 src/bpp/tests/test_soft_delete/test_migracja_0497_rewers.py create mode 100644 src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py create mode 100644 src/bpp/tests/test_soft_delete_publikacje.py create mode 100644 src/rozbieznosci_dyscyplin/migrations/0023_rozbieznosci_zrodel_bez_skasowanych_publikacji.py diff --git a/docs/deweloper/runbook-soft-delete-faza-01.md b/docs/deweloper/runbook-soft-delete-faza-01.md index cc51058da..175719135 100644 --- a/docs/deweloper/runbook-soft-delete-faza-01.md +++ b/docs/deweloper/runbook-soft-delete-faza-01.md @@ -98,6 +98,23 @@ ProgrammingError: kolumna old.deleted_at nie istnieje To jedyny nieudany rewers w całej serii — pozostałe wracają do stanu wyjściowego bit-w-bit (zweryfikowane zrzutem katalogu przed/po). +> **Faza 02 rozszerza to na tabele publikacji.** Migracja `0496` dokłada +> `deleted_at` do pięciu tabel publikacji, więc **każdy** zjazd poniżej +> `0496` — nie tylko poniżej `0488` — wpada w ten sam mechanizm. Zasada +> pozostaje ta sama i nie zmienia procedury: rollback wycofuje KOD razem ze +> schematem, a stare modele nie deklarują `deleted_at`, więc `denorm` +> wygeneruje poprawne triggery. Kombinacja „nowy kod + stary schemat" +> powstaje wyłącznie sztucznie. +> +> Skutek uboczny w testach: testy odwracalności fazy 01 +> (`test_migracja_0489_rewers`, `test_migracje_0490_0493_rewers`) schodzą +> poniżej `0496`, trzymając kod na miejscu — czyli produkują dokładnie tę +> zakazaną kombinację. Dlatego korzystają z fixtury +> `bez_reinstalacji_denorma` (`src/bpp/tests/test_soft_delete/conftest.py`), +> która odpina handler `post_migrate` na czas testu i przebudowuje triggery +> ręcznie na końcu. To obejście **testowe** — nie zmienia niczego we +> wdrożeniu. + Dodatkowo: rewers `0493` przywraca **bezwarunkowe** `UNIQUE (rekord_id, kolejnosc)`, które padnie, jeśli ktokolwiek zdążył skorzystać z soft-delete (soft-deletowane wiersze wyglądają wtedy jak duplikaty). Rollback po realnym diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-03.md b/docs/superpowers/HANDOFF-soft-delete-faza-03.md new file mode 100644 index 000000000..45bc5ee94 --- /dev/null +++ b/docs/superpowers/HANDOFF-soft-delete-faza-03.md @@ -0,0 +1,186 @@ +# Handoff: soft-delete, start fazy 03 + +> Dokument przekazania po zamknięciu **fazy 02** (publikacje), 2026-08-07. +> +> **Czytaj to zamiast odtwarzania historii z gita.** Zawiera rzeczy, których +> nie widać w diffie, a które w fazie 02 kosztowały rundy poprawek. + +--- + +## 1. Gdzie jesteśmy + +| | | +|---|---| +| Gałąź | `feat/soft-delete-02`, worktree `~/Programowanie/bpp-soft-delete-02` | +| Baza | `feat/soft-delete` (faza 01, PR #312) — PR fazy 02 jest **stackowany**, nie na `dev` | +| Migracje | `bpp/0496`…`0499`, `rozbieznosci_dyscyplin/0023` | +| Testy | 306 passed / 1 xfailed (soft-delete+cache+rozbieżności), 416 passed (regresja publikacji) | + +**Faza 02 objęła 5 modeli publikacji** — `Wydawnictwo_Ciagle`, +`Wydawnictwo_Zwarte`, `Patent`, `Praca_Doktorska`, `Praca_Habilitacyjna`. + +Dokumenty warte otwarcia zanim cokolwiek zaczniesz: + +- **inwentaryzacja ORM (wejście fazy 03)**: + `docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md` + — z self-review, który obala część własnych wniosków. Czytaj RAZEM z nim. +- inwentaryzacja widoków: `…/2026-08-07-faza-02-inwentaryzacja-widokow.md` +- zgłoszenie upstream do `django-easy-audit`: + `…/2026-08-07-easyaudit-upstream-zgloszenie.md` + +--- + +## 2. Najważniejsza lekcja fazy 02 + +Faza 01 zostawiła listę **siedmiu** mechanizmów zakładających, że skasowany +wiersz znika. Faza 02 dołożyła **dwa kolejne** — oba spoza kodu, który +zmienialiśmy: + +| # | Mechanizm | Jak się objawił | +|---|---|---| +| 8 | `django-easy-audit` | pobiera poprzedni wiersz przez `sender.objects` → `restore()` leci `DoesNotExist`. **Błąd ZASTANY** — `Zgloszenie_Publikacji` było nieprzywracalne na `dev` już przed fazą 02 | +| 9 | odwrotne `OneToOne` | `autor.praca_habilitacyjna` idzie przez `_base_manager` (nieprzefiltrowany) → skasowana habilitacja nadal osiągalna, widok zwracał 200 zamiast 404 | + +**Wniosek dla fazy 03:** szukaj konsumentów NIE tylko w kodzie, który +zmieniasz, i NIE tylko w tym repo. Kronikę zweryfikowaliśmy dopiero po +sprawdzeniu repozytoriów siostrzanych (`bpp-deploy` faktycznie ją +referencuje — na szczęście tylko w komentarzach i jednej kontrolce). + +--- + +## 3. Co faza 03 dziedziczy — i czego NIE ma dziedziczyć + +### 3.1 `xfail(strict=True)`, który sam się upomni + +`test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at` jest +`xfail(strict=True)` z powodem „faza 03". Gdy naprawisz wycieki, test +zacznie padać jako XPASS i **wymusi** zdjęcie markera. Nie „napraw testu" — +zdejmij marker. + +Analogicznie działa `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`: +padnie, gdy upstream easyauditu scali poprawkę, i każe skasować +`src/bpp/easyaudit_shim.py`. + +### 3.2 ⚠️ Tabelka 10 wycieków NIE jest gotową listą zadań + +To najważniejsze zastrzeżenie całego handoffu. Kanarek ORM dopasowuje +**NAZWY**, nie modele. W fazie 01 działał świetnie, bo nazwy były +dystynktywne (`autorzy_set`). W fazie 02 to zwykłe słowa (`patent`, +`rekord`, `wydawnictwo_ciagle`) i precyzja się załamuje: + +- lista 5 nazw daje 14 znalezisk, +- dołożenie `rekord` i `wydawnictwo_nadrzedne` daje **120**, +- z czego większość to fałszywe trafienia, bo `rekord` oznacza raz + publikację, raz widok `Rekord` (**już przefiltrowany** przez `0497`), + a raz parametr GET albo klucz formularza. + +**Nie rozszerzaj listy `RELACJE` i nie triażuj 120 pozycji ręcznie.** +Zbuduj narzędzie **model-aware**: rozwiązujące ścieżkę lookupu wobec +`_meta` Django i pytające, czy faktycznie dochodzi do tabeli soft-delete — +czyli robiące dla ORM to, co kanarek katalogowy robi na `pg_depend`. + +### 3.3 Klasa wycieku, której kanarek NIE MOŻE złapać + +Dostęp **atrybutowy** do relacji (`autor.praca_habilitacyjna`, +`*.wydawnictwo_nadrzedne`) omija soft-delete i nie jest wywołaniem ORM, więc +AST-owy skaner go nie widzi. Potrzebny osobny przegląd. Naprawa centralna +odpada: `Meta.base_manager_name` na menedżer filtrujący jest przez Django +jawnie odradzany (rozwaliłby deserializację i `refresh_from_db`). + +--- + +## 4. Fakty o kodzie, które w fazie 02 kosztowały rundę poprawek + +- **`_base_manager` jest NIEprzefiltrowany** i Django tworzy go sam, gdy + model nie ustawia `Meta.base_manager_name`. Ratuje to easyaudit i psuje + trawersowanie relacji — ta sama właściwość, dwa przeciwne skutki. +- **`slug` zawiera `pk`** (`models/util.py:246-254`: + `tytuł-źródło-autorzy--`). Kolizja slugów jest + konstrukcyjnie niemożliwa — dlatego Task 3 planu został świadomie + niewykonany (patrz box w planie fazy 02). +- **`slugify_function` BPP NIE obniża wielkości liter.** +- **Menedżer w ciele klasy przesłania ten z bazy abstrakcyjnej.** Dlatego + `Wydawnictwo_Ciagle.objects` pokazywało kosz aż do Taska 4, mimo że model + dziedziczył `BppPublikacjaSoftDeleteMixin`. +- **`ManagerModeliZOplataZaPublikacjeMixin` NIE jest menedżerem** — to czysty + mixin bez `get_queryset()`, więc kolejność baz w MRO menedżera NIE jest + nośna (sprawdzone mutacyjnie). Nośna jest druga baza. +- **`DJANGO_EASY_AUDIT_REGISTERED_CLASSES`** obejmuje 5 modeli publikacji + i `Zgloszenie_Publikacji`, ale NIE through-modele `*_Autor` — stąd faza 01 + nigdy nie trafiła na błąd easyauditu. +- **Denorm: tylko DWIE zależności celują w publikacje**, obie + `("self", "wydawnictwo_nadrzedne")` bez `only=`. Brak `only=` = wszystkie + kolumny w bramce, więc `deleted_at` wchodzi sam. Nie dokładaj + `denorm_always_only` — byłby martwym kodem. +- **Widoki publikacji mają TRZY różne kształty** (patrz docstring `0497`). + Wzorzec z `0489` pasuje tylko do dwóch z siedmiu. +- **Postgres normalizuje predykaty w definicjach widoków** — przy jednej + tabeli w zasięgu usuwa kwalifikację. Wywróciło to najpierw asercję testu, + potem funkcję `backward` migracji. + +--- + +## 5. Jak pracować (co się sprawdziło) + +### Mutacja jest tu jedynym dowodem, że test cokolwiek pilnuje + +W fazie 02 mutacje obaliły **cztery** rzeczy — w tym dwie moje własne: + +1. test kaskady przechodził częściowo przez efekt uboczny (odroczone + ograniczenie fazy 01 + `baker` nadający obu autorstwom `kolejnosc=0`), +2. test na pułapkę agregatu był bezwartościowy — odpytywał **cache**, którego + soft-delete autorstwa nie przelicza, więc nieświeży wiersz maskował + zdegenerowany widok. Wyrocznią musi być **widok**, +3. test wymiaru publikacji w sumach przechodził mimo wyłączenia OBU migracji, + bo kaskada `delete()` kasuje też autorstwa, a te są filtrowane od fazy 01. + Trzeba było testu **izolującego wymiar** (surowy UPDATE), +4. docstring o kolejności baz w MRO menedżera brzmiał wiarygodnie i był + nieprawdziwy. + +**Zasada:** przy każdym teście zapytaj, co musisz zepsuć, żeby spadł na +czerwono — i zepsuj to naprawdę. + +### Rozszerz kanarka NA STARCIE — to znowu się opłaciło + +Inwentaryzacja widoków na starcie fazy zajęła **19 sekund** i dała pełną +listę 15 widoków w 5 kategoriach, w tym **zadanie, którego nie było w planie** +(Task 2d: 6 widoków agregujących, w planie tylko jako ostrzeżenie „sprawdź"). +Kanarek fazy 01 dało się wywołać na rozszerzonej liście tabel BEZ zmiany +stałej modułowej — czyli bez commitowania czerwonego testu. + +### Czego nie powtarzać + +- ⚠️ **`ruff format` na całym katalogu** przeformatował 25 plików, których nie + dotykałem. Formatuj TYLKO swoje pliki, inaczej PR staje się nieczytelny. +- ⚠️ **`git stash` przy czystym drzewie to cichy no-op** — do baseline'owania + używaj `git checkout --detach `. +- ⚠️ **Zero zdarzeń audytu ≠ zepsuty kod.** Dwie niezależne bramki + (`dont_log_anonymous_crud_events` bez requestu, brak `settings.TEST` → + `on_commit` w rollbackowanej transakcji) dają ten sam objaw. +- ⚠️ Na tym hoście biegają cudze stacki. Kontenery ubijaj po ID, po + sprawdzeniu, czy ryuk jest martwy. + +--- + +## 6. Otwarte decyzje + +| Sprawa | Stan | +|---|---| +| **PR upstream do easyauditu** | gałąź gotowa w `~/Programowanie/django-easy-audit` (`fix/175-use-base-manager-in-pre-save`), przetestowana na Django 5.2 i 6.1. **Nie wypchnięta** — czeka na decyzję o koncie/forku. Tekst zgłoszenia gotowy | +| **Strategia wydania** | rekomendacja z fazy 01 bez zmian: scalać fazami, **wydać dopiero po 04**. Faza 03 jest obowiązkowa razem z 02 (bez niej re-import tworzy duplikaty) | +| Pomiar `0492` i narzutu GiST na kopii produkcyjnej | wciąż nikt nie zmierzył (dług fazy 01) | +| `bpp-deploy`: kontrolka „kronika views: N" | po `0499` wypisze 0 i może zmylić operatora. Nieblokujące, opisane w docstringu `0499` | + +## 7. Długi pozostałych faz + +- **03**: decyzja #14 („pomiń + zaraportuj") niewpięta w taski 2-5; test ma + literalny placeholder; `deduplikator_autorow/utils/merge.py` + zrefaktoryzowany. **Plus wszystko z §3 tego dokumentu.** +- **04**: nikt nie przeplata `AutorManager`; `Autor.restore()` musi nadpisać + `strict=False`; FK flips `CASCADE→PROTECT`. Uwaga: soft-skasowana + publikacja NADAL trzyma referencję O2O PROTECT do autora, więc autora nie + da się skasować — to zachowanie poprawne, ale faza 04 musi je obsłużyć w UI. +- **06**: `MetrykaAutora` trzyma listy ID prac w JSON-ach; Task 5 (shim + `zakolejkuj_*`) martwy po ustaleniach fazy 05 — usunąć. +- **07**: admin (kosz / przywróć / usuń trwale) — konsument `user`/`reason`, + które faza 02 już przepuszcza w sygnaturach. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md index ef1fccc9f..d913674ac 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md @@ -105,8 +105,9 @@ Taski dopisywane po rewizjach wylądowały poza numeracją. Wykonuj w TEJ kolejn | 2 | Task 2 — 5 modeli + migracja pól | kolumny muszą istnieć przed DDL | | 3 | **Task 2b** — widoki + gałąź kasująca + bramka `WHEN` | czyta `deleted_at` z kroku 2 | | 4 | **Task 2c** — bramka denorm | drugi system triggerów | -| 5 | Task 3 — `slug` warunkowy unique | niezależny | +| ~~5~~ | ~~Task 3 — `slug` warunkowy unique~~ | **NIEWYKONANY świadomie** — slug zawiera `pk`, kolizja niemożliwa; patrz box przy tasku | | 6 | **Task 3b** — `unique_together` na `*_Autor` | niezależny | +| **3.5** | **Task 2d — sumy + rozbieżności (6 widoków)** | **DOPISANY** — inwentaryzacja kanarka na starcie fazy; w planie był tylko ostrzeżeniem (c) | | 7 | Task 4 — przeplecenie menedżerów | — | | 8 | Task 5 — testy integracyjne | wymaga 1-7 | | 9 | Task 6 — weryfikacja fazy | ostatni | @@ -377,7 +378,34 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " --- -## Task 3: `slug` — warunkowy `UniqueConstraint` (reuse slug po soft-delete) +## ~~Task 3: `slug` — warunkowy `UniqueConstraint`~~ → ŚWIADOMIE NIEWYKONANY + +> 🛑 **DECYZJA WŁAŚCICIELA, 2026-08-07: tego zadania NIE robimy.** +> `slug` zostaje `unique=True`, bezwarunkowo. To NIE jest dług ani +> przeoczenie — poniżej powód, żeby nikt nie „dokończył" tego w dobrej wierze. +> +> **Przesłanka zadania nie zachodzi.** `get_slug()` +> (`src/bpp/models/util.py:246-254`) skleja slug jako +> `tytuł-źródło-autorzy--`. Slug **zawiera klucz +> główny**, więc dwa różne wiersze nie mogą mieć tego samego slugu, a +> skasowany rekord nie zablokuje nowego — nowy dostaje nowe `pk`, czyli inny +> slug. Kolizja, którą to zadanie miało rozwiązać, jest konstrukcyjnie +> niemożliwa. ID jest w slugu CELOWO, właśnie po to, żeby slug był unikalny. +> +> Plan pisano najwyraźniej przy założeniu slugu opartego wyłącznie na +> tytule. Przy obecnej formule zamiana `unique=True` na +> `UniqueConstraint(condition=Q(deleted_at__isnull=True))` **osłabiłaby** +> gwarancję (unikalność tylko wśród żywych) i wymagała przebudowy indeksu na +> pięciu dużych tabelach — w zamian za rozwiązanie nieistniejącego problemu. +> +> Wyszło przy pisaniu testów TDD do tego zadania: padły wszystkie dziesięć, +> w tym ten, który powinien przechodzić JESZCZE PRZED zmianą. Denorm +> dodatkowo nadpisuje ręcznie ustawiony slug, więc kolizji nie da się nawet +> wywołać sztucznie. +> +> Poniższa treść zostaje wyłącznie jako kontekst historyczny. + +
    oryginalna treść tasku (nieaktualna) **Files:** - Modify: `src/bpp/models/wydawnictwo_ciagle.py:246` (denorm `slug`: `unique=True`→brak unique; `Meta.constraints`) @@ -446,6 +474,8 @@ Zamiana `unique=True` na `models.UniqueConstraint(fields=["slug"], condition=Q(d Co-Authored-By: Claude Opus 4.8 (1M context) " ``` +
    + --- ## Task 2b: Widoki + gałąź kasująca + bramka `WHEN` dla 5 tabel publikacji (LUKA) @@ -892,7 +922,7 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " - [ ] 5 modeli publikacji to `SoftDeleteModel` (przez `BppPublikacjaSoftDeleteMixin`); migracje `deleted_at`/`restored_at`/`transaction_id` + indeks per model (`0490_`). - [ ] `delete(self, *args, user=None, reason="", **kwargs)` / `restore(self, *args, user=None, **kwargs)` — per-instancja `save()`, wąska kaskada na `autorzy_set` pod wspólnym `transaction_id`, BEZ refleksyjnej kaskady pakietu, BEZ bulk `update(deleted_at=)`. - [ ] `*_Streszczenie` (i pozostałe nie-soft dzieci) nietknięte; `delete()` nie rzuca `SoftDeleteException`. -- [ ] `slug` → warunkowy `UniqueConstraint(condition=Q(deleted_at__isnull=True))` (`0491_`); reuse slug po soft-delete działa. +- [x] ~~`slug` → warunkowy `UniqueConstraint`~~ — **świadomie NIEwykonane.** `slug` zostaje `unique=True`; slug zawiera `pk`, więc kolizja jest konstrukcyjnie niemożliwa i warunkowanie tylko osłabiłoby gwarancję. Uzasadnienie w boxie przy Tasku 3. - [ ] `Wydawnictwo_*_Manager` przeplecione: `objects` filtruje `deleted_at` ORAZ ma `rekordy_z_oplata()`/`wydawnictwa_nadrzedne_dla_innych()`; `global_objects`/`deleted_objects` dostępne na wszystkich 5 modelach. - [ ] Testy: kaskada wspólny txid, znika z Rekord/Autorzy + restore, restore `*_Autor`, `post_soft_delete`, `*_Streszczenie` nietknięte, gate bulk-update — zielone. - [ ] `makemigrations --check --dry-run bpp` → `No changes detected`. Istniejące migracje NIE modyfikowane. diff --git a/docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md b/docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md new file mode 100644 index 000000000..f54f85d55 --- /dev/null +++ b/docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md @@ -0,0 +1,113 @@ +# Zgłoszenie do upstreamu `django-easy-audit` — gotowy tekst + +**Gdzie wkleić:** komentarz do istniejącego +[issue #175](https://github.com/soynatan/django-easy-audit/issues/175) +(otwarte od 2021-02, dokładnie ten problem). Nowego issue NIE zakładamy — +byłby piątym równoległym wątkiem o tej samej sprawie. + +**Dlaczego warto mimo czterech nieudanych prób:** poprzednie zgłoszenia +(#168, #176, #318, #342) nie zawierały informacji, która jest tu kluczowa — +że obejście sugerowane przez maintainera jest **nieosiągalne**. To jedyna +nowa przesłanka, jaką wnosimy do dyskusji. + +**Po scaleniu:** skasować `src/bpp/easyaudit_shim.py` + wywołanie +`zainstaluj()` w `BppConfig.ready()` + `test_easyaudit_shim.py`. Przypomni +o tym test `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`, który +wtedy zacznie padać. + +--- + +## Treść komentarza (EN) + +> ### The documented workaround for this issue is unreachable +> +> We hit this on a Django 5.2 project with soft-deletable models +> (`django-soft-delete`), on `django-easy-audit==1.3.9`. I want to add one +> piece of information that I don't think has been stated in this thread or +> in the related PRs (#168, #176, #318, #342), because I believe it explains +> why the fix keeps stalling. +> +> In #168 the suggestion was to handle this via +> `DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS`, letting the application +> decide whether to create the `CRUDEvent`. +> +> **That workaround cannot work for this bug.** The exception is raised +> *before* the callbacks are ever consulted. In `signals/model_signals.py` +> (1.3.9): +> +> ```python +> # line 102 — raises DoesNotExist +> old_model = sender.objects.get(pk=instance.pk) +> delta = model_delta(old_model, instance) +> ... +> # line 113 — callbacks are only reached here +> create_crud_event = call_callbacks(...) +> ``` +> +> So an application cannot opt out of the failing lookup: by the time it is +> asked, the lookup has already thrown. With +> `DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = True` (which we need, so that +> audit failures are not silently swallowed) the exception then propagates +> and aborts the user's write. +> +> ### Minimal reproduction +> +> ```python +> class Article(SoftDeleteModel): # objects filters deleted_at IS NULL +> title = models.CharField(max_length=100) +> +> # settings.py +> DJANGO_EASY_AUDIT_REGISTERED_CLASSES = ["myapp.Article"] +> DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = True +> +> a = Article.objects.create(title="x") +> a.delete() # soft delete +> Article.global_objects.get(pk=a.pk).restore() +> # -> Article.DoesNotExist: Article matching query does not exist +> ``` +> +> The row exists the whole time; it is merely hidden from `objects`. Any +> `save()` on a currently-hidden row fails the same way — `restore()` is +> just the case that always hits it, because the row is by definition still +> flagged as deleted at `pre_save` time. +> +> ### Suggested fix +> +> One line: +> +> ```diff +> - old_model = sender.objects.get(pk=instance.pk) +> + old_model = sender._base_manager.get(pk=instance.pk) +> ``` +> +> `_base_manager` rather than `_default_manager`, for the reason +> @sgordon16 already gave in this thread: the *default* manager may itself +> filter, so it does not remove the failure mode. `_base_manager` is the +> one Django documents for exactly this purpose — retrieving related/ +> internal objects — and +> [the docs state it must not filter out any results](https://docs.djangoproject.com/en/5.2/topics/db/managers/#django.db.models.Manager.base_manager_name). +> +> Worth noting: when a model does not set `Meta.base_manager_name`, Django +> creates a plain unfiltered `Manager` for `_base_manager` automatically. +> So this change is a no-op for every project that does not deliberately +> override it, and it does not require model authors to configure anything. +> We verified this on our models — `_base_manager` sees soft-deleted rows, +> `objects` does not. +> +> This is a semantic correction, not a workaround: `pre_save` wants "the row +> currently stored under this pk", which is precisely what `_base_manager` +> means, and is not what `objects` means for any project with a filtering +> default manager. +> +> ### Scope +> +> Grepping 1.3.9, this is the **only** place in the package where an audited +> model's manager is used to fetch an instance; every other `.objects` usage +> is on `CRUDEvent`, `LoginEvent`, `ContentType` or `User`. So the change is +> contained. +> +> We are currently carrying this as a local patch that replaces the +> `pre_save` receiver via its `dispatch_uid`. Happy to open a PR (with a +> regression test using a filtering default manager) if that would help move +> this along — just let me know whether you would prefer it on top of #318 +> or as a fresh branch. diff --git a/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md new file mode 100644 index 000000000..15595962b --- /dev/null +++ b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md @@ -0,0 +1,193 @@ +# Faza 02 — inwentaryzacja wycieków ORM po dołączeniu publikacji + +> Wynik rozszerzenia `RELACJE` w kanarku ORM +> (`src/bpp/tests/test_soft_delete/test_kanarek_orm.py`) o pięć modeli +> publikacji, 2026-08-07. Kanarek zgłosił **14 miejsc**; triage niżej. + +--- + +## ⚠️ SELF-REVIEW (2026-08-07, po napisaniu reszty dokumentu) + +**Ten audyt jest niekompletny i w jednym miejscu wprost nieprawdziwy.** +Zostawiam go w całości — poniższe zastrzeżenia są ważniejsze niż tabelka. + +### 1. Premisa dokumentu jest FAŁSZYWA na obecnym stanie gałęzi + +Sekcja niżej twierdzi, że „zapytania startujące OD publikacji są bezpieczne, +bo `objects` to `BppSoftDeleteManager`". Sprawdzone empirycznie: + +| model | `objects` | widzi skasowane? | +|---|---|---| +| `Wydawnictwo_Ciagle` | `Wydawnictwo_Ciagle_Manager` | **TAK** | +| `Wydawnictwo_Zwarte` | `Wydawnictwo_Zwarte_Manager` | **TAK** | +| `Patent` | `BppSoftDeleteManager` | nie | +| `Praca_Doktorska` | `BppSoftDeleteManager` | nie | +| `Praca_Habilitacyjna` | `BppSoftDeleteManager` | nie | + +Dwa najważniejsze modele mają wciąż menedżera z fazy 01 (mixin opłat + +`models.Manager`), bo **Task 4 — przeplecenie menedżerów — nie jest jeszcze +zrobiony**. Premisa stanie się prawdziwa dopiero po nim. Do tego czasu +KAŻDE `Wydawnictwo_Ciagle.objects...` w kodzie zwraca też rekordy z kosza. + +### 2. Lista nazw relacji jest niekompletna → „14 miejsc" to DOLNA GRANICA + +Audyt użył pięciu nazw modeli. Pominął co najmniej: + +- **`rekord`** — nazwa FK z modeli-dzieci do publikacji + (`*_Streszczenie`, `*_Dodatkowy_Tytul`, `*_Zewnetrzna_Baza_Danych`), +- **`wydawnictwo_nadrzedne`** — self-FK rozdział → książka-matka, +- `wydawnictwa_powiazane_set` — relacja odwrotna do powyższej (0 trafień). + +Po dołożeniu tych nazw skan daje **120 znalezisk zamiast 14**. Zdanie +„lista jest gotowa, nikt nie musi jej odtwarzać" było więc nieuprawnione. + +### 3. …ale te 120 to w większości FAŁSZYWE trafienia — i to jest wniosek o NARZĘDZIU + +Nazwa `rekord` jest wieloznaczna i kanarek nie umie tych znaczeń rozróżnić: + +1. `*_Autor.rekord` → publikacja (zakres fazy 01), +2. `*_Streszczenie.rekord` i pokrewne → publikacja (**realny zakres fazy 02**), +3. `Cache_Punktacja_Autora.rekord` → widok `Rekord`, **już przefiltrowany** + migracją 0497 — czyli bezpieczne, +4. `request.GET.get("rekord__id__exact")`, `cleaned_data.get("rekord")` — + parametry HTTP i formularzy, w ogóle nie ORM. + +**Sedno:** kanarek ORM jest matcherem NAZW, a nie modeli. W fazie 01 działał +świetnie, bo nazwy były dystynktywne (`autorzy_set`, `wydawnictwo_ciagle_autor` +— nic innego się tak nie nazywa). W fazie 02 nazwy to zwykłe słowa (`patent`, +`rekord`, `wydawnictwo_ciagle`), więc precyzja narzędzia się załamuje. To nie +jest usterka do załatania listą wyjątków — to granica metody. + +Uczciwe narzędzie dla fazy 02 musiałoby rozwiązywać ścieżkę lookupu wobec +metadanych Django (`_meta`) i pytać, czy faktycznie dochodzi do tabeli objętej +soft-delete — czyli działać tak, jak kanarek KATALOGOWY działa na `pg_depend`. +To jest osobne narzędzie, nie parametr istniejącego. + +### 4. Błąd w tabeli: pozycja #1 ma odwróconą wymowę + +Wpis dla `usun_zrodla_bez_publikacji` jest zatytułowany **„KASUJE ŹRÓDŁA"**, +co sugeruje utratę danych. Faktyczny kierunek jest odwrotny i łagodny: +`filter(wydawnictwo_ciagle__isnull=True)` **nie znajdzie** źródła, którego +publikacje są w koszu, więc takie źródło NIE zostanie skasowane. Skutek to +zalegające śmieci, nie utrata danych. + +### 5. DOPISANE PÓŹNIEJ: klasa wycieku, której kanarek NIE MOŻE złapać + +Wyszła przy weryfikacji fazy (Task 6), z padającego testu +`test_rok_habilitacji_view` — nie z kanarka. + +**Trawersowanie relacji przez ATRYBUT omija soft-delete.** Widok robił: + +```python +habilitacja = autor.praca_habilitacyjna # odwrotne OneToOne +``` + +Django rozwiązuje to przez `ReverseOneToOneDescriptor`, który pyta +`_base_manager` — z definicji nieprzefiltrowany. Skasowana habilitacja jest +więc tą ścieżką **nadal osiągalna**, mimo że `Praca_Habilitacyjna.objects` +jej nie pokazuje. Widok zwracał 200 zamiast 404. + +To odwrotna strona faktu, który przy okazji easyauditu wyglądał na dobrą +wiadomość: nieprzefiltrowany `_base_manager` ratuje audyt i psuje +trawersowanie relacji. + +⚠️ **Kanarek ORM nie ma szans tego wykryć.** Skanuje ARGUMENTY wywołań ORM +(`filter`, `annotate`, `Count`…), a tutaj nie ma żadnego wywołania — jest +dostęp do atrybutu. Żadne rozszerzanie listy `RELACJE` tego nie zmieni; to +inna oś problemu niż ta, którą kanarek pokrywa. + +Nie da się tego naprawić centralnie: `Meta.base_manager_name` wskazujący +menedżer filtrujący jest przez Django jawnie odradzany (rozwaliłby m.in. +deserializację i `refresh_from_db`). Zostaje sprawdzanie `deleted_at` +w miejscach użycia — tak zrobiono w `RokHabilitacjiView`. + +**Dla fazy 03:** potrzebny osobny przegląd dostępów atrybutowych do relacji +O2O/FK celujących w modele soft-delete. Kandydaci to `autor.praca_habilitacyjna` +i każde `*.wydawnictwo_nadrzedne`. Narzędziem nie może być obecny kanarek. + +### Co z tego wynika dla decyzji o fazie 03 + +Sam podział (faza 02 = warstwa bazodanowa, faza 03 = wywołania ORM) uważam +nadal za słuszny, a `xfail(strict=True)` spełnia swoją rolę. Ale faza 03 NIE +powinna traktować tabelki niżej jako gotowej listy zadań — powinna zacząć od +zbudowania narzędzia model-aware, bo inaczej utonie w fałszywych trafieniach. + +--- + +## Dlaczego to nie jest to samo ryzyko, co w fazie 01 + +Dla publikacji zagrożenie jest skierowane **odwrotnie** niż dla autorstw. +Zapytania startujące OD publikacji są bezpieczne — `Wydawnictwo_Ciagle.objects` +to `BppSoftDeleteManager`, który sam dokłada `deleted_at__isnull=True`. +Przecieka dołączenie **DO** publikacji od strony słownika lub relacji +(`Zrodlo`, `Charakter_Formalny`, `Wydawca`…), bo tam żaden nasz manager się +nie włącza — liczy się surowa tabela. + +## Prawdziwe wycieki (10) + +Wszystkie to `Count()`/`filter()` po odwrotnej relacji do publikacji, bez +predykatu `deleted_at`. + +| # | Miejsce | Co robi | Skutek wycieku | +|---|---|---|---| +| 1 | `bpp/management/commands/usun_zrodla_bez_publikacji.py:23` | `Zrodlo.objects.filter(wydawnictwo_ciagle__isnull=True)` | **KASUJE ŹRÓDŁA.** Źródło, którego wszystkie publikacje są w koszu, nie wygląda na puste → NIE zostanie skasowane. Odwrotnie niż groźnie, ale wynik i tak niezgodny z intencją | +| 2 | `bpp/admin/zrodlo.py:188` | to samo, akcja adminowa „usuń źródła bez prac" | jw. — decyzja o kasowaniu na podstawie zawyżonego licznika | +| 3 | `bpp/admin/zrodlo.py:217` | `Count("wydawnictwo_ciagle", distinct=True)` — kolumna „liczba prac" | operator widzi zawyżoną liczbę | +| 4 | `bpp/admin/filters.py:270` | `Count("wydawnictwo_ciagle")` — filtr „ma prace / nie ma prac" | źródło z samymi skasowanymi pracami trafia do „ma prace" | +| 5 | `admin_dashboard/views/charakter_stats.py:68` | `Count("wydawnictwo_ciagle")` + `Count("wydawnictwo_zwarte")` | statystyki charakterów zawyżone | +| 6 | `deduplikator_zrodel/operations.py:65` | `Count("wydawnictwo_ciagle")` — dobór kandydatów | do deduplikacji wchodzą źródła bez żywych prac | +| 7 | `deduplikator_zrodel/utils.py:72` | jw. | jw. | +| 8 | `deduplikator_zrodel/views.py:83` | `Count("main_zrodlo__wydawnictwo_ciagle")` do pól `_main_live_pub` / `_dup_live_pub` | **nazwa pola mówi `live`, a liczy też skasowane** | +| 9 | `komparator_pbn/views.py:163` | `Count("wydawnictwo_ciagle")` przy źródłach `DELETED` w PBN | zawyżony raport | +| 10 | `przemapuj_zrodla_pbn/views.py:409` | `Count("wydawnictwo_ciagle")` → `liczba_rekordow` | jw. | + +Wzorzec naprawy jest jednolity i znany z fazy 01: + +```python +Count("wydawnictwo_ciagle", filter=Q(wydawnictwo_ciagle__deleted_at__isnull=True)) +``` + +⚠️ Dla wariantu `filter(wydawnictwo_ciagle__isnull=True)` (#1, #2) `Count(...) +FILTER` nie wystarcza — trzeba przejść na `annotate(...)` + `filter(licznik=0)`, +inaczej warunek na odwrotnej relacji zawęzi zbiór źródeł zamiast policzyć zero. + +## Fałszywe trafienia (4) — do wpisu w `DOZWOLONE` albo do zawężenia matchera + +Powstały dlatego, że nazwy relacji fazy 02 są **zwykłymi słowami** (`patent`, +`wydawnictwo_ciagle`), a nie dystynktywnymi jak `autorzy_set` czy +`wydawnictwo_ciagle_autor` z fazy 01. + +| Miejsce | Dlaczego fałszywe | +|---|---| +| `django_bpp/sitemaps.py:110` | pętla po liście etykiet URL-i; literał `"patent"` trafia w gałąź „pętla po nazwach relacji" | +| `ewaluacja_optymalizacja/utils.py:183` | `dict(wydawnictwo_ciagle=isinstance(...), …)` — słownik flag, nie lookup ORM (`dict` jest w `WYWOLANIA_ORM`) | +| `pbn_api/…/pbn_test_wysylka_interaktywna.py:122` | `options.get("wydawnictwo_zwarte")` — opcja CLI (`get` jest w `WYWOLANIA_ORM`) | +| `pbn_api/…/pbn_test_wysylka_interaktywna.py:123` | jw., `wydawnictwo_ciagle` | + +## Rozstrzygnięcie zakresu + +Plan fazy 02 obejmuje warstwę **bazodanową** (modele, widoki, triggery, +ograniczenia, menedżery). Audyt wywołań ORM w imporcie/dedup/PBN jest w +podsumowaniu planu przypisany **fazie 03** („audyt `global_objects` +w imporcie/dedup/PBN → faza 03”), a handoff §7 wymienia dla fazy 03 dług +dotyczący `deduplikator_autorow`. + +Rozwiązanie: kanarek ORM ma teraz **dwa** testy zamiast jednego. + +| Test | Relacje | Stan | +|---|---|---| +| `test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at` | faza 01 (`autorzy_set`, `*_autor`) | ZIELONY — ochrona nienaruszona | +| `test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at` | faza 02 (5 modeli publikacji) | `xfail(strict=True)` — dług fazy 03 | + +Podział jest istotny: wrzucenie wszystkiego do jednego `xfail`-a wyłączyłoby +także ochronę wywalczoną w fazie 01. + +`strict=True` jest tu mechanizmem wymuszającym, nie ozdobą: gdy faza 03 +naprawi te miejsca, test zacznie padać jako XPASS i zmusi do zdjęcia +markera. Bez `strict` naprawa przeszłaby niezauważona, a kanarek zostałby +wyłączony na zawsze — dokładnie ta pułapka („pusty wynik ≠ potwierdzenie”) +jest opisana w handoffie fazy 01. Ten sam wzorzec faza 01 zastosowała wobec +fazy 02 w `test_cache/test_soft_delete_preconditions.py`. + +Kanarek **katalogowy** (widoki) został rozszerzony i jest w pełni ZIELONY — +to zakres fazy 02 i został domknięty. diff --git a/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md new file mode 100644 index 000000000..4e2d6906f --- /dev/null +++ b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md @@ -0,0 +1,97 @@ +# Faza 02 — inwentaryzacja widoków czytających tabele publikacji + +> Wykonana **2026-08-07, na starcie fazy 02**, zanim powstała jakakolwiek +> migracja — dokładnie tak, jak nakazuje handoff (§2) i ostrzeżenie (a) +> w planie fazy 02. Faza 01 odkrywała takich winowajców pojedynczo, przez +> awarie; ten dokument jest wynikiem jednego przebiegu, trwającego 19 sekund. + +## Metoda + +Kanarek katalogowy fazy 01 (`src/bpp/tests/test_soft_delete/ +test_kanarek_katalogowy.py`) eksportuje `znajdz_winowajcow(cur, tabele=...)` +przyjmujące **dowolną** listę tabel. Dzięki temu dało się uzyskać pełną listę +winowajców fazy 02 **bez** modyfikowania stałej `TABELE_SOFT_DELETE`, czyli bez +commitowania czerwonego testu na starcie gałęzi. + +Wywołanie: `TABELE_SOFT_DELETE + _TABELE_PUBLIKACJI_FAZA_02` (8 tabel) na +żywym katalogu Postgresa (testcontainer: baseline + wszystkie migracje). + +**Źródłem prawdy jest `pg_depend` na poziomie kolumny, nie tekst SQL-a.** To +istotne: analiza tekstowa `baseline.sql` przemilczałaby część widoków (baseline +jest snapshotem sprzed części migracji), a matcher substringowy dawał fałszywą +zieleń tam, gdzie widok dziedziczył słowo `deleted_at` z JOIN-a po *innej* +tabeli. + +## Wynik: 15 par (widok, tabela), 15 unikalnych widoków + +### Kategoria A — widoki rdzenia rekordu (5) → Task 2b + +Bez filtra publikacja nie zniknie z `bpp_rekord_mat`, czyli z całego serwisu. + +- `bpp_wydawnictwo_ciagle_view` +- `bpp_wydawnictwo_zwarte_view` +- `bpp_patent_view` +- `bpp_praca_doktorska_view` +- `bpp_praca_habilitacyjna_view` + +### Kategoria B — widoki autorstw bez through-modelu (2) → Task 2b + +Autor leży na wierszu publikacji, więc filtr jest tu „po własnej kolumnie". + +- `bpp_praca_doktorska_autorzy` +- `bpp_praca_habilitacyjna_autorzy` + +### Kategoria C — sumy (5) → **Task 2d (NOWY — brak w tabeli zadań planu)** + +Plan miał dla nich wyłącznie ostrzeżenie (c) „sprawdź, czy wymagają poprawki". +Sprawdzone: **wymagają, wszystkie pięć.** Faza 01 poprawiła w tych widokach +wyłącznie wymiar *autora* (migracja `0495`); wymiar *publikacji* nadal +przecieka. + +- `bpp_nowe_sumy_wydawnictwo_ciagle_view` +- `bpp_nowe_sumy_wydawnictwo_zwarte_view` +- `bpp_nowe_sumy_patent_view` +- `bpp_nowe_sumy_praca_doktorska_view` +- `bpp_nowe_sumy_praca_habilitacyjna_view` + +⚠️ To są widoki **agregujące** — obowiązuje w nich pułapka z handoffu §3.2: +warunek `deleted_at IS NULL` w `WHERE`/`ON` degeneruje `LEFT JOIN` do `INNER +JOIN`. Właściwy wzorzec to `agregat(...) FILTER (WHERE ... deleted_at IS +NULL)`. Sprawdzić **każdy** agregat, nie tylko `count` (`sum`, `min`, `max`, +`array_agg`, `string_agg`, `bool_*`). + +### Kategoria D — rozbieżności dyscyplin (1) → **Task 2d** + +- `rozbieznosci_dyscyplin_rozbieznoscizrodelview` + +Analogicznie do C: faza 01 poprawiła wymiar autora (`rozbieznosci_dyscyplin/ +0022`), wymiar publikacji został. + +### Kategoria E — kronika (2) → zadanie „Sprzątanie `bpp_kronika_*`" + +- `bpp_kronika_praca_doktorska_view` +- `bpp_kronika_praca_habilitacyjna_view` + +Dokładnie te dwa, których żywotności — jak zapowiadał handoff §3.4 — **nikt +jeszcze nie zweryfikował**. Pozostałe trzy z rodziny (`wydawnictwo_ciagle`, +`wydawnictwo_zwarte`, `patent`) nie pojawiają się na liście tylko dlatego, że +siedzą w `WYJATKI` jako zweryfikowanie martwe. + +## Wynik negatywny, który też jest wynikiem + +**Zero winowajców wśród trzech tabel `*_autor` fazy 01** +(`bpp_wydawnictwo_ciagle_autor`, `bpp_wydawnictwo_zwarte_autor`, +`bpp_patent_autor`) — suma par per tabela (2+4+4+3+2) wyczerpuje wszystkie 15, +więc dla tabel fazy 01 nie ma ani jednej. DDL fazy 01 trzyma się na żywym +katalogu. + +## Wniosek dla planu + +Tabela kolejności wykonania w planie fazy 02 dostaje **jedno zadanie więcej**: + +| # | Task | Źródło | +|---|---|---| +| … | **Task 2d — sumy + rozbieżności (6 widoków)** | ta inwentaryzacja; w planie tylko jako ostrzeżenie (c), bez zadania | + +Zakres Taska 2b jest potwierdzony jako dokładnie 7 widoków (5 rdzenia + 2 +autorstw), a nie „5 plus może coś jeszcze". diff --git a/src/bpp/apps.py b/src/bpp/apps.py index eb859be49..370ab210b 100644 --- a/src/bpp/apps.py +++ b/src/bpp/apps.py @@ -46,6 +46,16 @@ def ready(self): if apps.is_installed("favicon"): self._patch_favicon_save_per_site() + # django-easy-audit pobiera poprzednia wersje wiersza przez + # `sender.objects`, wiec na modelach soft-delete wywraca `restore()` + # (wiersz jest wtedy jeszcze skasowany -> DoesNotExist). Podmieniamy + # handler na wersje uzywajaca `_base_manager`. Pelne uzasadnienie, + # zakres bledu i stan zgloszenia upstream: bpp/easyaudit_shim.py. + if apps.is_installed("easyaudit"): + from bpp.easyaudit_shim import zainstaluj + + zainstaluj() + # Initialize Rollbar with global hostname handler from bpp.rollbar_config import configure_rollbar diff --git a/src/bpp/data/autor_djangoql_schema.compact.txt b/src/bpp/data/autor_djangoql_schema.compact.txt index 143e95871..4c9cd65c5 100644 --- a/src/bpp/data/autor_djangoql_schema.compact.txt +++ b/src/bpp/data/autor_djangoql_schema.compact.txt @@ -27,7 +27,7 @@ start model: bpp.autor bpp.autor: - aktualna_funkcja -> bpp.funkcja_autora? match nazwa + aktualna_funkcja -> bpp.funkcja_autora? aktualna_jednostka -> bpp.jednostka? autor_dyscyplina -> bpp.autor_dyscyplina? autor_jednostka -> bpp.autor_jednostka? @@ -48,7 +48,7 @@ bpp.autor: patent -> bpp.patent? patent_autor -> bpp.patent_autor? pbn_uid -> pbn_api.scientist? - plec -> bpp.plec? match nazwa + plec -> bpp.plec? pokazuj bool pokazuj_opis bool pokazuj_poprzednie_nazwiska bool @@ -62,7 +62,7 @@ bpp.autor: slug str sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni - tytul -> bpp.tytul? match nazwa + tytul -> bpp.tytul? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -107,7 +107,7 @@ bpp.jednostka: praca_doktorska -> bpp.praca_doktorska? praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int - rodzaj -> bpp.rodzajjednostki? match nazwa + rodzaj -> bpp.rodzajjednostki? ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" @@ -133,7 +133,7 @@ bpp.autor_dyscyplina: id int procent_dyscypliny float? procent_subdyscypliny float? - rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa + rodzaj_autora -> ewaluacja_common.rodzaj_autora? rok int subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? @@ -142,7 +142,7 @@ bpp.autor_dyscyplina: bpp.autor_jednostka: autor -> bpp.autor - funkcja -> bpp.funkcja_autora? match nazwa + funkcja -> bpp.funkcja_autora? grupa_pracownicza -> bpp.grupa_pracownicza? id int jednostka -> bpp.jednostka @@ -164,7 +164,7 @@ bpp.autorzy: profil_orcid bool przypieta bool rekord -> bpp.rekord - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci upowaznienie_pbn bool zapisany_jako str zatrudniony bool @@ -184,11 +184,12 @@ bpp.patent: cached_punkty_dyscyplin unknown? data_decyzji date? data_zgloszenia date? "Data zgłoszenia" + deleted_at datetime? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str nie_eksportuj_przez_api bool numer_prawa_wylacznego str? "Numer prawa wyłącznego" @@ -203,18 +204,20 @@ bpp.patent: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool - rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? match nazwa + restored_at datetime? + rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str + transaction_id str? tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str utworzono datetime? @@ -242,7 +245,7 @@ bpp.patent_autor: rekord -> bpp.patent restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -272,19 +275,20 @@ bpp.plec: bpp.praca_doktorska: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -312,18 +316,20 @@ bpp.praca_doktorska: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -336,19 +342,20 @@ bpp.praca_doktorska: bpp.praca_habilitacyjna: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -375,18 +382,20 @@ bpp.praca_habilitacyjna: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -407,7 +416,8 @@ bpp.wydawnictwo_ciagle: autorzy -> bpp.autor autorzy_set -> bpp.wydawnictwo_ciagle_autor? cached_punkty_dyscyplin unknown? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_ciagle_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -415,12 +425,12 @@ bpp.wydawnictwo_ciagle: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 @@ -431,12 +441,12 @@ bpp.wydawnictwo_ciagle: nie_eksportuj_przez_api bool nr_zeszytu str numer_odbitki str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? match nazwa "OpenAccess: tryb dostępu" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? "OpenAccess: tryb dostępu" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -463,19 +473,21 @@ bpp.wydawnictwo_ciagle: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_ciagle_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -506,7 +518,7 @@ bpp.wydawnictwo_ciagle_autor: rekord -> bpp.wydawnictwo_ciagle restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -517,7 +529,8 @@ bpp.wydawnictwo_zwarte: cached_punkty_dyscyplin unknown? calkowita_liczba_autorow int? calkowita_liczba_redaktorow int? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_zwarte_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -526,13 +539,13 @@ bpp.wydawnictwo_zwarte: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również @@ -543,12 +556,12 @@ bpp.wydawnictwo_zwarte: nie_eksportuj_przez_api bool numer_odbitki str numer_w_serii str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? match nazwa - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -578,20 +591,22 @@ bpp.wydawnictwo_zwarte: recenzowana bool redakcja str rekord -> bpp.rekord? + restored_at datetime? rok int search_index unknown seria_wydawnicza -> bpp.seria_wydawnicza? slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_zwarte_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -629,7 +644,7 @@ bpp.wydawnictwo_zwarte_autor: rekord -> bpp.wydawnictwo_zwarte restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -712,7 +727,7 @@ bpp.wymiar_etatu: bpp.rekord: autorzy -> bpp.autorzy? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" @@ -721,18 +736,18 @@ bpp.rekord: index_copernicus float informacje str isbn str - jezyk -> bpp.jezyk match nazwa "Język" + jezyk -> bpp.jezyk "Język" konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 liczba_autorow int liczba_cytowan int liczba_znakow_wydawniczych int? "Liczba znaków wydawniczych" - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str @@ -749,9 +764,9 @@ bpp.rekord: slowa_kluczowe -> taggit.tag? "Słowa kluczowe" — A comma-separated list of tags. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty szczegoly str "Szczegóły" — Np. str. 23-45 - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str tytul_oryginalny str tytul_oryginalny_sort str @@ -854,7 +869,7 @@ pbn_api.publication: bpp.typ_kbn: artykul_pbn bool "Artykuł w PBN" — Wydawnictwa ciągłe posiadające ten typ MNiSW/MEiN zostaną włączone do eksportu PBN jako artykuły - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? id int nazwa str praca_doktorska -> bpp.praca_doktorska? @@ -884,7 +899,7 @@ bpp.wydawca: bpp.charakter_formalny: charakter_ogolny str choices: Artykuł | Książka | Rozdział | Inne - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. @@ -893,7 +908,7 @@ bpp.charakter_formalny: lft int nazwa str nazwa_w_primo str choices: | Artykuł | Książka | Zasób tekstowy | Rozprawa naukowa | Recenzja | Artykuł prasowy | Rozdział | Czasopismo | Dane badawcze | Materiał konferencyjny | Obraz | Baza | Zestaw danych statystycznych | Multimedia | Inny - parent -> bpp.charakter_formalny? match nazwa "Charakter nadrzędny" + parent -> bpp.charakter_formalny? "Charakter nadrzędny" publikacja bool rekord -> bpp.rekord? rght int @@ -911,7 +926,7 @@ bpp.charakter_formalny: bpp.wydawnictwo_ciagle_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_ciagle tytul str "Tytuł" @@ -970,12 +985,12 @@ bpp.wersja_tekstu_openaccess: bpp.wydawnictwo_ciagle_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_ciagle streszczenie str bpp.wydawnictwo_ciagle_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_ciagle @@ -986,16 +1001,16 @@ bpp.zrodlo: e_issn str "e-ISSN" id int issn str - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? nazwa str nazwa_alternatywna str? - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" openaccess_tryb_dostepu str "OpenAccess: tryb dostępu" choices: pełny | częściowy ostatnio_zmieniony datetime? pbn_uid -> pbn_api.journal? "Odpowiednik w PBN" poprzednia_nazwa str? "Poprzedni tytuł" rekord -> bpp.rekord? - rodzaj -> bpp.rodzaj_zrodla match nazwa + rodzaj -> bpp.rodzaj_zrodla search unknown skrot str "Skrót" skrot_nazwy_alternatywnej str? @@ -1003,11 +1018,11 @@ bpp.zrodlo: www str? wydawca str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? - zasieg -> bpp.zasieg_zrodla? match nazwa + zasieg -> bpp.zasieg_zrodla? bpp.wydawnictwo_zwarte_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_zwarte tytul str "Tytuł" @@ -1035,18 +1050,18 @@ bpp.seria_wydawnicza: bpp.wydawnictwo_zwarte_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_zwarte streszczenie str bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_zwarte bpp.zewnetrznebazydanychview: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str rekord -> bpp.rekord @@ -1136,47 +1151,3 @@ bpp.organprzyznajacynagrody: nagroda -> bpp.nagroda? nazwa str skrot str - -dictionaries (shared relation values, referenced above): - bpp.funkcja_autora - nazwa: "adiunkt", "asystent", "kierownik", "młodszy asystent", "starszy asystent" - bpp.plec - nazwa: "kobieta", "mężczyzna" - bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" - bpp.rodzajjednostki - nazwa: "Koło naukowe", "Standard", "Wydział" - ewaluacja_common.rodzaj_autora - nazwa: "doktorant", "inny zatrudniony, nie naukowy", "pracownik badawczy spoza N", "pracownik naukowy w liczbie N" - bpp.typ_odpowiedzialnosci - nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" - bpp.zrodlo_informacji - nazwa: "od autora", "od redakcji", "wartość szacunkowa" - bpp.rodzaj_prawa_patentowego - nazwa: "odmiana rośliny", "wynalazek", "wzór przemysłowy", "wzór użytkowy", "znak towarowy" - bpp.status_korekty - nazwa: "po korekcie", "przed korektą", "w trakcie korekty" - bpp.jezyk - nazwa: "angielski", "brak danych", "francuski", "hiszpański", "inny", "niemiecki", "polski", "rosyjski", "włoski" - bpp.typ_kbn - nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" - bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" - bpp.czas_udostepnienia_openaccess - nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" - bpp.licencja_openaccess - nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" - bpp.tryb_openaccess_wydawnictwo_ciagle - nazwa: "Inne", "Otwarte czasopismo", "Otwarte repositorium" - bpp.wersja_tekstu_openaccess - nazwa: "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" - bpp.tryb_openaccess_wydawnictwo_zwarte - nazwa: "Inne", "Otwarte repositorium", "Witryna wydawcy" - bpp.charakter_pbn - help_text: "", " ", "Artykuł przeglądowy stanowi podsumowanie aktualnego stanu badań w danym obszarze tematycznym.", "Hasło encykolpedyczne/słownikowe/leksykonowe", "Monografia naukowa to spójne tematycznie, wyczerpujące opracowanie naukowe. W PBN za monografie naukowe uważane są również zbiorowe książki pokonferencyjne (tzw. conference proceedings). Jeśli dodajesz zbiorową książkę pokonferencyjną, pamiętaj o tym, by zaznaczyć również pola "Publikacja zbiorowa" oraz "Publikacja konferencyjna"", "Np. katalog zabytków", "Oryginalny artykuł naukowy przedstawia rezultaty oryginalnych badań naukowych lub eksperymentu.", "Publikacja podlegała recenzji naukowej", "Wstęp/Wprowadzenie/Przedmowa" - bpp.zewnetrzna_baza_danych - nazwa: "Scopus", "Web of Science" - bpp.rodzaj_zrodla - nazwa: "periodyk", "wydawnictwo", "źródło nieindeksowane" - bpp.zasieg_zrodla - nazwa: "krajowy", "międzynarodowy" diff --git a/src/bpp/data/autorzy_djangoql_schema.compact.txt b/src/bpp/data/autorzy_djangoql_schema.compact.txt index 0b226ad46..de8f82ba3 100644 --- a/src/bpp/data/autorzy_djangoql_schema.compact.txt +++ b/src/bpp/data/autorzy_djangoql_schema.compact.txt @@ -39,13 +39,13 @@ bpp.autorzy: profil_orcid bool przypieta bool rekord -> bpp.rekord - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci upowaznienie_pbn bool zapisany_jako str zatrudniony bool bpp.autor: - aktualna_funkcja -> bpp.funkcja_autora? match nazwa + aktualna_funkcja -> bpp.funkcja_autora? aktualna_jednostka -> bpp.jednostka? autor_dyscyplina -> bpp.autor_dyscyplina? autor_jednostka -> bpp.autor_jednostka? @@ -66,7 +66,7 @@ bpp.autor: patent -> bpp.patent? patent_autor -> bpp.patent_autor? pbn_uid -> pbn_api.scientist? - plec -> bpp.plec? match nazwa + plec -> bpp.plec? pokazuj bool pokazuj_opis bool pokazuj_poprzednie_nazwiska bool @@ -80,7 +80,7 @@ bpp.autor: slug str sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni - tytul -> bpp.tytul? match nazwa + tytul -> bpp.tytul? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -130,7 +130,7 @@ bpp.jednostka: praca_doktorska -> bpp.praca_doktorska? praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int - rodzaj -> bpp.rodzajjednostki? match nazwa + rodzaj -> bpp.rodzajjednostki? ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" @@ -164,7 +164,7 @@ bpp.kierunek_studiow: bpp.rekord: autorzy -> bpp.autorzy? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" @@ -173,18 +173,18 @@ bpp.rekord: index_copernicus float informacje str isbn str - jezyk -> bpp.jezyk match nazwa "Język" + jezyk -> bpp.jezyk "Język" konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 liczba_autorow int liczba_cytowan int liczba_znakow_wydawniczych int? "Liczba znaków wydawniczych" - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str @@ -201,9 +201,9 @@ bpp.rekord: slowa_kluczowe -> taggit.tag? "Słowa kluczowe" — A comma-separated list of tags. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty szczegoly str "Szczegóły" — Np. str. 23-45 - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str tytul_oryginalny str tytul_oryginalny_sort str @@ -242,7 +242,7 @@ bpp.autor_dyscyplina: id int procent_dyscypliny float? procent_subdyscypliny float? - rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa + rodzaj_autora -> ewaluacja_common.rodzaj_autora? rok int subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? @@ -251,7 +251,7 @@ bpp.autor_dyscyplina: bpp.autor_jednostka: autor -> bpp.autor - funkcja -> bpp.funkcja_autora? match nazwa + funkcja -> bpp.funkcja_autora? grupa_pracownicza -> bpp.grupa_pracownicza? id int jednostka -> bpp.jednostka @@ -275,11 +275,12 @@ bpp.patent: cached_punkty_dyscyplin unknown? data_decyzji date? data_zgloszenia date? "Data zgłoszenia" + deleted_at datetime? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str nie_eksportuj_przez_api bool numer_prawa_wylacznego str? "Numer prawa wyłącznego" @@ -294,18 +295,20 @@ bpp.patent: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool - rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? match nazwa + restored_at datetime? + rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str + transaction_id str? tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str utworzono datetime? @@ -333,7 +336,7 @@ bpp.patent_autor: rekord -> bpp.patent restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -363,19 +366,20 @@ bpp.plec: bpp.praca_doktorska: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -403,18 +407,20 @@ bpp.praca_doktorska: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -427,19 +433,20 @@ bpp.praca_doktorska: bpp.praca_habilitacyjna: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -466,18 +473,20 @@ bpp.praca_habilitacyjna: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -498,7 +507,8 @@ bpp.wydawnictwo_ciagle: autorzy -> bpp.autor autorzy_set -> bpp.wydawnictwo_ciagle_autor? cached_punkty_dyscyplin unknown? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_ciagle_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -506,12 +516,12 @@ bpp.wydawnictwo_ciagle: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 @@ -522,12 +532,12 @@ bpp.wydawnictwo_ciagle: nie_eksportuj_przez_api bool nr_zeszytu str numer_odbitki str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? match nazwa "OpenAccess: tryb dostępu" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? "OpenAccess: tryb dostępu" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -554,19 +564,21 @@ bpp.wydawnictwo_ciagle: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_ciagle_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -597,7 +609,7 @@ bpp.wydawnictwo_ciagle_autor: rekord -> bpp.wydawnictwo_ciagle restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -608,7 +620,8 @@ bpp.wydawnictwo_zwarte: cached_punkty_dyscyplin unknown? calkowita_liczba_autorow int? calkowita_liczba_redaktorow int? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_zwarte_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -617,13 +630,13 @@ bpp.wydawnictwo_zwarte: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również @@ -634,12 +647,12 @@ bpp.wydawnictwo_zwarte: nie_eksportuj_przez_api bool numer_odbitki str numer_w_serii str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? match nazwa - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -669,20 +682,22 @@ bpp.wydawnictwo_zwarte: recenzowana bool redakcja str rekord -> bpp.rekord? + restored_at datetime? rok int search_index unknown seria_wydawnicza -> bpp.seria_wydawnicza? slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_zwarte_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -720,7 +735,7 @@ bpp.wydawnictwo_zwarte_autor: rekord -> bpp.wydawnictwo_zwarte restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -759,7 +774,7 @@ bpp.rodzajjednostki: bpp.charakter_formalny: charakter_ogolny str choices: Artykuł | Książka | Rozdział | Inne - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. @@ -768,7 +783,7 @@ bpp.charakter_formalny: lft int nazwa str nazwa_w_primo str choices: | Artykuł | Książka | Zasób tekstowy | Rozprawa naukowa | Recenzja | Artykuł prasowy | Rozdział | Czasopismo | Dane badawcze | Materiał konferencyjny | Obraz | Baza | Zestaw danych statystycznych | Multimedia | Inny - parent -> bpp.charakter_formalny? match nazwa "Charakter nadrzędny" + parent -> bpp.charakter_formalny? "Charakter nadrzędny" publikacja bool rekord -> bpp.rekord? rght int @@ -891,7 +906,7 @@ bpp.status_korekty: bpp.typ_kbn: artykul_pbn bool "Artykuł w PBN" — Wydawnictwa ciągłe posiadające ten typ MNiSW/MEiN zostaną włączone do eksportu PBN jako artykuły - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? id int nazwa str praca_doktorska -> bpp.praca_doktorska? @@ -920,7 +935,7 @@ bpp.wydawca: wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? bpp.zewnetrznebazydanychview: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str rekord -> bpp.rekord @@ -931,16 +946,16 @@ bpp.zrodlo: e_issn str "e-ISSN" id int issn str - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? nazwa str nazwa_alternatywna str? - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" openaccess_tryb_dostepu str "OpenAccess: tryb dostępu" choices: pełny | częściowy ostatnio_zmieniony datetime? pbn_uid -> pbn_api.journal? "Odpowiednik w PBN" poprzednia_nazwa str? "Poprzedni tytuł" rekord -> bpp.rekord? - rodzaj -> bpp.rodzaj_zrodla match nazwa + rodzaj -> bpp.rodzaj_zrodla search unknown skrot str "Skrót" skrot_nazwy_alternatywnej str? @@ -948,7 +963,7 @@ bpp.zrodlo: www str? wydawca str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? - zasieg -> bpp.zasieg_zrodla? match nazwa + zasieg -> bpp.zasieg_zrodla? ewaluacja_common.rodzaj_autora: autor_dyscyplina -> bpp.autor_dyscyplina? @@ -987,7 +1002,7 @@ bpp.rodzaj_prawa_patentowego: bpp.wydawnictwo_ciagle_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_ciagle tytul str "Tytuł" @@ -1001,19 +1016,19 @@ bpp.tryb_openaccess_wydawnictwo_ciagle: bpp.wydawnictwo_ciagle_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_ciagle streszczenie str bpp.wydawnictwo_ciagle_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_ciagle bpp.wydawnictwo_zwarte_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_zwarte tytul str "Tytuł" @@ -1041,12 +1056,12 @@ bpp.seria_wydawnicza: bpp.wydawnictwo_zwarte_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_zwarte streszczenie str bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_zwarte @@ -1136,47 +1151,3 @@ bpp.organprzyznajacynagrody: nagroda -> bpp.nagroda? nazwa str skrot str - -dictionaries (shared relation values, referenced above): - bpp.typ_odpowiedzialnosci - nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" - bpp.funkcja_autora - nazwa: "adiunkt", "asystent", "kierownik", "młodszy asystent", "starszy asystent" - bpp.plec - nazwa: "kobieta", "mężczyzna" - bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" - bpp.rodzajjednostki - nazwa: "Koło naukowe", "Standard", "Wydział" - bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" - bpp.jezyk - nazwa: "angielski", "brak danych", "francuski", "hiszpański", "inny", "niemiecki", "polski", "rosyjski", "włoski" - bpp.czas_udostepnienia_openaccess - nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" - bpp.licencja_openaccess - nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" - bpp.wersja_tekstu_openaccess - nazwa: "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" - bpp.status_korekty - nazwa: "po korekcie", "przed korektą", "w trakcie korekty" - bpp.typ_kbn - nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" - ewaluacja_common.rodzaj_autora - nazwa: "doktorant", "inny zatrudniony, nie naukowy", "pracownik badawczy spoza N", "pracownik naukowy w liczbie N" - bpp.zrodlo_informacji - nazwa: "od autora", "od redakcji", "wartość szacunkowa" - bpp.rodzaj_prawa_patentowego - nazwa: "odmiana rośliny", "wynalazek", "wzór przemysłowy", "wzór użytkowy", "znak towarowy" - bpp.tryb_openaccess_wydawnictwo_ciagle - nazwa: "Inne", "Otwarte czasopismo", "Otwarte repositorium" - bpp.tryb_openaccess_wydawnictwo_zwarte - nazwa: "Inne", "Otwarte repositorium", "Witryna wydawcy" - bpp.charakter_pbn - help_text: "", " ", "Artykuł przeglądowy stanowi podsumowanie aktualnego stanu badań w danym obszarze tematycznym.", "Hasło encykolpedyczne/słownikowe/leksykonowe", "Monografia naukowa to spójne tematycznie, wyczerpujące opracowanie naukowe. W PBN za monografie naukowe uważane są również zbiorowe książki pokonferencyjne (tzw. conference proceedings). Jeśli dodajesz zbiorową książkę pokonferencyjną, pamiętaj o tym, by zaznaczyć również pola "Publikacja zbiorowa" oraz "Publikacja konferencyjna"", "Np. katalog zabytków", "Oryginalny artykuł naukowy przedstawia rezultaty oryginalnych badań naukowych lub eksperymentu.", "Publikacja podlegała recenzji naukowej", "Wstęp/Wprowadzenie/Przedmowa" - bpp.zewnetrzna_baza_danych - nazwa: "Scopus", "Web of Science" - bpp.rodzaj_zrodla - nazwa: "periodyk", "wydawnictwo", "źródło nieindeksowane" - bpp.zasieg_zrodla - nazwa: "krajowy", "międzynarodowy" diff --git a/src/bpp/data/rekord_djangoql_schema.compact.txt b/src/bpp/data/rekord_djangoql_schema.compact.txt index 36b5d6ee3..410fd98b0 100644 --- a/src/bpp/data/rekord_djangoql_schema.compact.txt +++ b/src/bpp/data/rekord_djangoql_schema.compact.txt @@ -28,7 +28,7 @@ start model: bpp.rekord bpp.rekord: autorzy -> bpp.autorzy? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" @@ -37,18 +37,18 @@ bpp.rekord: index_copernicus float informacje str isbn str - jezyk -> bpp.jezyk match nazwa "Język" + jezyk -> bpp.jezyk "Język" konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 liczba_autorow int liczba_cytowan int liczba_znakow_wydawniczych int? "Liczba znaków wydawniczych" - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str @@ -65,9 +65,9 @@ bpp.rekord: slowa_kluczowe -> taggit.tag? "Słowa kluczowe" — A comma-separated list of tags. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty szczegoly str "Szczegóły" — Np. str. 23-45 - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str tytul_oryginalny str tytul_oryginalny_sort str @@ -93,14 +93,14 @@ bpp.autorzy: profil_orcid bool przypieta bool rekord -> bpp.rekord - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci upowaznienie_pbn bool zapisany_jako str zatrudniony bool bpp.charakter_formalny: charakter_ogolny str choices: Artykuł | Książka | Rozdział | Inne - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. @@ -109,7 +109,7 @@ bpp.charakter_formalny: lft int nazwa str nazwa_w_primo str choices: | Artykuł | Książka | Zasób tekstowy | Rozprawa naukowa | Recenzja | Artykuł prasowy | Rozdział | Czasopismo | Dane badawcze | Materiał konferencyjny | Obraz | Baza | Zestaw danych statystycznych | Multimedia | Inny - parent -> bpp.charakter_formalny? match nazwa "Charakter nadrzędny" + parent -> bpp.charakter_formalny? "Charakter nadrzędny" publikacja bool rekord -> bpp.rekord? rght int @@ -232,7 +232,7 @@ bpp.status_korekty: bpp.typ_kbn: artykul_pbn bool "Artykuł w PBN" — Wydawnictwa ciągłe posiadające ten typ MNiSW/MEiN zostaną włączone do eksportu PBN jako artykuły - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? id int nazwa str praca_doktorska -> bpp.praca_doktorska? @@ -266,7 +266,8 @@ bpp.wydawnictwo_zwarte: cached_punkty_dyscyplin unknown? calkowita_liczba_autorow int? calkowita_liczba_redaktorow int? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_zwarte_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -275,13 +276,13 @@ bpp.wydawnictwo_zwarte: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również @@ -292,12 +293,12 @@ bpp.wydawnictwo_zwarte: nie_eksportuj_przez_api bool numer_odbitki str numer_w_serii str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? match nazwa - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -327,20 +328,22 @@ bpp.wydawnictwo_zwarte: recenzowana bool redakcja str rekord -> bpp.rekord? + restored_at datetime? rok int search_index unknown seria_wydawnicza -> bpp.seria_wydawnicza? slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_zwarte_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -361,7 +364,7 @@ bpp.wydawnictwo_zwarte: zewnetrzna_baza_danych -> bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych? bpp.zewnetrznebazydanychview: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str rekord -> bpp.rekord @@ -372,16 +375,16 @@ bpp.zrodlo: e_issn str "e-ISSN" id int issn str - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? nazwa str nazwa_alternatywna str? - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" openaccess_tryb_dostepu str "OpenAccess: tryb dostępu" choices: pełny | częściowy ostatnio_zmieniony datetime? pbn_uid -> pbn_api.journal? "Odpowiednik w PBN" poprzednia_nazwa str? "Poprzedni tytuł" rekord -> bpp.rekord? - rodzaj -> bpp.rodzaj_zrodla match nazwa + rodzaj -> bpp.rodzaj_zrodla search unknown skrot str "Skrót" skrot_nazwy_alternatywnej str? @@ -389,10 +392,10 @@ bpp.zrodlo: www str? wydawca str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? - zasieg -> bpp.zasieg_zrodla? match nazwa + zasieg -> bpp.zasieg_zrodla? bpp.autor: - aktualna_funkcja -> bpp.funkcja_autora? match nazwa + aktualna_funkcja -> bpp.funkcja_autora? aktualna_jednostka -> bpp.jednostka? autor_dyscyplina -> bpp.autor_dyscyplina? autor_jednostka -> bpp.autor_jednostka? @@ -413,7 +416,7 @@ bpp.autor: patent -> bpp.patent? patent_autor -> bpp.patent_autor? pbn_uid -> pbn_api.scientist? - plec -> bpp.plec? match nazwa + plec -> bpp.plec? pokazuj bool pokazuj_opis bool pokazuj_poprzednie_nazwiska bool @@ -427,7 +430,7 @@ bpp.autor: slug str sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni - tytul -> bpp.tytul? match nazwa + tytul -> bpp.tytul? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -477,7 +480,7 @@ bpp.jednostka: praca_doktorska -> bpp.praca_doktorska? praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int - rodzaj -> bpp.rodzajjednostki? match nazwa + rodzaj -> bpp.rodzajjednostki? ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" @@ -537,7 +540,8 @@ bpp.wydawnictwo_ciagle: autorzy -> bpp.autor autorzy_set -> bpp.wydawnictwo_ciagle_autor? cached_punkty_dyscyplin unknown? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_ciagle_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -545,12 +549,12 @@ bpp.wydawnictwo_ciagle: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 @@ -561,12 +565,12 @@ bpp.wydawnictwo_ciagle: nie_eksportuj_przez_api bool nr_zeszytu str numer_odbitki str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? match nazwa "OpenAccess: tryb dostępu" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? "OpenAccess: tryb dostępu" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -593,19 +597,21 @@ bpp.wydawnictwo_ciagle: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_ciagle_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -627,19 +633,20 @@ pbn_api.language: bpp.praca_doktorska: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -667,18 +674,20 @@ bpp.praca_doktorska: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -691,19 +700,20 @@ bpp.praca_doktorska: bpp.praca_habilitacyjna: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -730,18 +740,20 @@ bpp.praca_habilitacyjna: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -754,26 +766,26 @@ bpp.praca_habilitacyjna: bpp.wydawnictwo_ciagle_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_ciagle streszczenie str bpp.wydawnictwo_ciagle_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_ciagle tytul str "Tytuł" bpp.wydawnictwo_zwarte_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_zwarte streszczenie str bpp.wydawnictwo_zwarte_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_zwarte tytul str "Tytuł" @@ -794,11 +806,12 @@ bpp.patent: cached_punkty_dyscyplin unknown? data_decyzji date? data_zgloszenia date? "Data zgłoszenia" + deleted_at datetime? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str nie_eksportuj_przez_api bool numer_prawa_wylacznego str? "Numer prawa wyłącznego" @@ -813,18 +826,20 @@ bpp.patent: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool - rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? match nazwa + restored_at datetime? + rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str + transaction_id str? tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str utworzono datetime? @@ -870,7 +885,7 @@ bpp.wydawnictwo_zwarte_autor: rekord -> bpp.wydawnictwo_zwarte restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -906,7 +921,7 @@ bpp.seria_wydawnicza: wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_zwarte @@ -958,7 +973,7 @@ bpp.autor_dyscyplina: id int procent_dyscypliny float? procent_subdyscypliny float? - rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa + rodzaj_autora -> ewaluacja_common.rodzaj_autora? rok int subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? @@ -967,7 +982,7 @@ bpp.autor_dyscyplina: bpp.autor_jednostka: autor -> bpp.autor - funkcja -> bpp.funkcja_autora? match nazwa + funkcja -> bpp.funkcja_autora? grupa_pracownicza -> bpp.grupa_pracownicza? id int jednostka -> bpp.jednostka @@ -1003,7 +1018,7 @@ bpp.patent_autor: rekord -> bpp.patent restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -1055,7 +1070,7 @@ bpp.wydawnictwo_ciagle_autor: rekord -> bpp.wydawnictwo_ciagle restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -1100,7 +1115,7 @@ bpp.tryb_openaccess_wydawnictwo_ciagle: wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? bpp.wydawnictwo_ciagle_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_ciagle @@ -1136,47 +1151,3 @@ bpp.wymiar_etatu: autor_jednostka -> bpp.autor_jednostka? id int nazwa str - -dictionaries (shared relation values, referenced above): - bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" - bpp.jezyk - nazwa: "angielski", "brak danych", "francuski", "hiszpański", "inny", "niemiecki", "polski", "rosyjski", "włoski" - bpp.czas_udostepnienia_openaccess - nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" - bpp.licencja_openaccess - nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" - bpp.wersja_tekstu_openaccess - nazwa: "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" - bpp.status_korekty - nazwa: "po korekcie", "przed korektą", "w trakcie korekty" - bpp.typ_kbn - nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" - bpp.typ_odpowiedzialnosci - nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" - bpp.charakter_pbn - help_text: "", " ", "Artykuł przeglądowy stanowi podsumowanie aktualnego stanu badań w danym obszarze tematycznym.", "Hasło encykolpedyczne/słownikowe/leksykonowe", "Monografia naukowa to spójne tematycznie, wyczerpujące opracowanie naukowe. W PBN za monografie naukowe uważane są również zbiorowe książki pokonferencyjne (tzw. conference proceedings). Jeśli dodajesz zbiorową książkę pokonferencyjną, pamiętaj o tym, by zaznaczyć również pola "Publikacja zbiorowa" oraz "Publikacja konferencyjna"", "Np. katalog zabytków", "Oryginalny artykuł naukowy przedstawia rezultaty oryginalnych badań naukowych lub eksperymentu.", "Publikacja podlegała recenzji naukowej", "Wstęp/Wprowadzenie/Przedmowa" - bpp.zrodlo_informacji - nazwa: "od autora", "od redakcji", "wartość szacunkowa" - bpp.tryb_openaccess_wydawnictwo_zwarte - nazwa: "Inne", "Otwarte repositorium", "Witryna wydawcy" - bpp.zewnetrzna_baza_danych - nazwa: "Scopus", "Web of Science" - bpp.rodzaj_zrodla - nazwa: "periodyk", "wydawnictwo", "źródło nieindeksowane" - bpp.zasieg_zrodla - nazwa: "krajowy", "międzynarodowy" - bpp.funkcja_autora - nazwa: "adiunkt", "asystent", "kierownik", "młodszy asystent", "starszy asystent" - bpp.plec - nazwa: "kobieta", "mężczyzna" - bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" - bpp.rodzajjednostki - nazwa: "Koło naukowe", "Standard", "Wydział" - bpp.tryb_openaccess_wydawnictwo_ciagle - nazwa: "Inne", "Otwarte czasopismo", "Otwarte repositorium" - bpp.rodzaj_prawa_patentowego - nazwa: "odmiana rośliny", "wynalazek", "wzór przemysłowy", "wzór użytkowy", "znak towarowy" - ewaluacja_common.rodzaj_autora - nazwa: "doktorant", "inny zatrudniony, nie naukowy", "pracownik badawczy spoza N", "pracownik naukowy w liczbie N" diff --git a/src/bpp/demo_data/orchestrator.py b/src/bpp/demo_data/orchestrator.py index b0da5ea00..03fbda2c3 100644 --- a/src/bpp/demo_data/orchestrator.py +++ b/src/bpp/demo_data/orchestrator.py @@ -302,10 +302,24 @@ def run_cleanup(opts: CleanupOptions, *, stdin=None, stdout=None): total=n_batches, disable=opts.disable_progress, ) + # Manager widzący RÓWNIEŻ kosz i kasowanie NIEODWRACALNE — patrz + # komentarz niżej. `getattr` zamiast `isinstance`, bo lista modeli + # przychodzi z manifestu i miesza modele soft-delete z resztą. + manager = getattr(model, "global_objects", model.objects) + for start in pbar: chunk = sorted_pks[start : start + opts.batch_size] with transaction.atomic(): - model.objects.filter(pk__in=chunk).delete() + qs = manager.filter(pk__in=chunk) + # ⚠️ Na modelu soft-delete `QuerySet.delete()` jest MIĘKKIE: + # wiersz zostaje. Cleanup danych demo musi kasować trwale, + # i to z dwóch powodów. Po pierwsze zostawione publikacje + # trzymają FK z PROTECT (np. `Wydawnictwo_Zwarte.wydawca`), + # więc kasowanie słowników dalej w manifeście wywala się na + # `ProtectedError`. Po drugie „posprzątane" dane demo, które + # nadal siedzą w bazie, mijają się z celem komendy. + hard = getattr(qs, "hard_delete", None) + (hard or qs.delete)() ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") applied = opts.manifest.with_suffix(opts.manifest.suffix + f".applied.{ts}") diff --git a/src/bpp/easyaudit_shim.py b/src/bpp/easyaudit_shim.py new file mode 100644 index 000000000..36fc35723 --- /dev/null +++ b/src/bpp/easyaudit_shim.py @@ -0,0 +1,163 @@ +"""Poprawka do ``django-easy-audit``: nie zakładaj, że ``objects`` widzi +wszystkie wiersze. + +PROBLEM +------- +``easyaudit.signals.model_signals.pre_save`` pobiera poprzednią wersję +zapisywanego wiersza przez DOMYŚLNEGO menedżera:: + + old_model = sender.objects.get(pk=instance.pk) + +Dla modeli soft-delete ``objects`` filtruje ``deleted_at IS NULL``, a przy +``restore()`` wiersz w bazie jest w tym momencie WCIĄŻ skasowany — więc +lookup rzuca ``DoesNotExist``. Ponieważ BPP ma +``DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = True``, wyjątek wychodzi na +zewnątrz i wywraca cały ``restore()``. + +Poprawne jest ``_base_manager``: Django dokumentuje go jako menedżera, który +MUSI zwracać wszystkie obiekty, i sam tworzy dla niego zwykły, nieodfiltrowany +``Manager``, gdy model nie ustawia ``Meta.base_manager_name``. Sprawdzone na +naszych modelach — ``_base_manager`` widzi rekordy z kosza, ``objects`` nie. + +ZAKRES BŁĘDU — SZERSZY NIŻ FAZA 02 +---------------------------------- +To NIE jest problem wprowadzony przez soft-delete publikacji. +``Zgloszenie_Publikacji`` jest ``SoftDeleteModel`` od dawna i figuruje +w ``DJANGO_EASY_AUDIT_REGISTERED_CLASSES``, więc na ``dev`` **nie da się dziś +przywrócić skasowanego zgłoszenia**. Faza 02 rozszerza zasięg z jednego +modelu na sześć. Ten shim naprawia oba przypadki naraz. + +Through-modele ``*_Autor`` (faza 01) nie były dotknięte, bo nie ma ich +w ``REGISTERED_CLASSES``. + +DLACZEGO SHIM, A NIE FORK +------------------------- +Wadliwe wywołanie jest w całym pakiecie DOKŁADNIE JEDNO, a sygnały są +podpinane z ``dispatch_uid`` — można więc czysto podmienić sam handler, +bez forka i bez monkeypatchowania wnętrzności modułu. + +Upstream (``soynatan/django-easy-audit``) zna ten błąd jako issue #175, +otwarte od 2021-02. Cztery próby naprawy (PR #168, #176, #318, #342) w sześć +lat, żadna nie scalona — mimo że projekt jest aktywnie wydawany. Z wątku +przy #168 wynika, dlaczego: maintainer odsyła do obejścia przez +``DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS``, które NIE DZIAŁA, bo +wyjątek leci zanim callbacki zostaną w ogóle sprawdzone. + +Gdy poprawka wejdzie upstream — skasować ten moduł i wywołanie +``zainstaluj()`` w ``BppConfig.ready()``. Pilnuje tego +``test_easyaudit_shim.py``, który pada, gdy upstream się zmieni. +""" + +import json +from functools import partial + +from django.conf import settings +from django.core import serializers +from django.db import transaction +from django.db.models import signals + +#: ``dispatch_uid``, którym easyaudit podpina swój handler +#: (``model_signals.py``, na dole modułu). Używamy TEGO SAMEGO klucza, więc +#: podmiana jest czysta w obie strony — patrz ``zainstaluj()``. +DISPATCH_UID = "easy_audit_signals_pre_save" + +#: Wersja pakietu, na której kopiowano ciało handlera. Nie jest sprawdzana +#: w runtime — pilnuje jej test, żeby aktualizacja pakietu była GŁOŚNA. +WERSJA_UPSTREAM = "1.3.9" + + +def pre_save(sender, instance, raw, using, update_fields, **kwargs): + """Kopia ``easyaudit.signals.model_signals.pre_save`` (1.3.9) z JEDNĄ + zmianą: ``sender.objects`` -> ``sender._base_manager``. + + Cała reszta — łącznie z obsługą wyjątków, kolejnością callbacków + i ``transaction.on_commit`` — jest importowana z upstreamu, a nie + przepisana. Powielamy wyłącznie strukturę funkcji, bo jedyny sposób + na podmianę jednej linii w cudzym ciele to podmiana całej funkcji. + """ + # Import lokalny: moduł easyaudit ma być ładowany dopiero wtedy, gdy + # aplikacja jest faktycznie zainstalowana (w BPP wchodzi tylko + # w local.py/production.py). + from easyaudit.signals.crud_flows import pre_save_crud_flow + from easyaudit.signals.model_signals import ( + call_callbacks, + handle_signal_exception, + should_audit, + ) + from easyaudit.utils import model_delta + + if raw: + # Return if loading Fixtures + return None + + try: + if not should_audit(instance): + return False + + with transaction.atomic(using=using): + try: + object_json_repr = serializers.serialize("json", [instance]) + except Exception: + # We need a better way for this to work. ManyToMany will fail on + # pre_save on create + return None + + # Determine if the instance is a create + created = instance.pk is None or instance._state.adding + + # created or updated? + delta = {} + if not created: + # ↓↓↓ JEDYNA ZMIANA WOBEC UPSTREAMU ↓↓↓ + # Upstream: sender.objects.get(pk=instance.pk) + old_model = sender._base_manager.get(pk=instance.pk) + delta = model_delta(old_model, instance) + + if not delta and getattr( + settings, + "DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP", + False, + ): + return False + + # callbacks + create_crud_event = call_callbacks( + instance, object_json_repr, created, raw, using, update_fields, **kwargs + ) + + # Create crud event only if all callbacks returned True + if create_crud_event and not created: + crud_flow = partial( + pre_save_crud_flow, + instance=instance, + object_json_repr=object_json_repr, + changed_fields=json.dumps(delta), + ) + + if getattr(settings, "TEST", False): + crud_flow() + else: + transaction.on_commit(crud_flow, using=using) + except Exception: + handle_signal_exception("pre_save") + + +def zainstaluj(): + """Podmienia handler ``pre_save`` easyauditu na nasz. + + Działa NIEZALEŻNIE od kolejności ``ready()`` aplikacji, bo używamy tego + samego ``dispatch_uid`` co upstream: + + - gdy nasz ``ready()`` biegnie PIERWSZY (tak jest dziś: ``bpp`` stoi + w ``INSTALLED_APPS`` przed ``easyaudit``, który jest doklejany na + końcu w ``local.py``/``production.py``) — ``disconnect`` jest no-opem, + a nasz ``connect`` zajmuje klucz. Późniejszy ``connect`` easyauditu + jest wtedy pomijany, bo ``Signal.connect`` ignoruje duplikat + ``dispatch_uid``; + - gdy kolejność się kiedyś odwróci — ``disconnect`` zdejmuje ich + handler, a ``connect`` wstawia nasz. + + Idempotentne: powtórne wywołanie nie dokłada drugiego odbiorcy. + """ + signals.pre_save.disconnect(dispatch_uid=DISPATCH_UID) + signals.pre_save.connect(pre_save, dispatch_uid=DISPATCH_UID) diff --git a/src/bpp/management/commands/wyczysc_publikacje_importu.py b/src/bpp/management/commands/wyczysc_publikacje_importu.py index f3b31b2e2..c368dd5db 100644 --- a/src/bpp/management/commands/wyczysc_publikacje_importu.py +++ b/src/bpp/management/commands/wyczysc_publikacje_importu.py @@ -286,16 +286,39 @@ def _author_counts(self, publication_models): for model in publication_models ] + @staticmethod + def _wszystkie_wiersze(model): + """Manager widzący RÓWNIEŻ kosz. + + Od fazy 02 soft-delete ``model.objects`` ukrywa wiersze z + ``deleted_at``. Ta komenda ma CZYŚCIĆ, a nie chować — pominięcie kosza + zostawiłoby publikacje, które operator uznał już za usunięte, a które + przy kolejnym imporcie kolidowałyby jako niewidoczne duplikaty. + """ + return getattr(model, "global_objects", model.objects) + + @staticmethod + def _skasuj_trwale(queryset): + """Kasowanie NIEODWRACALNE, także gdy model jest soft-delete. + + ``QuerySet.delete()`` na modelu soft-delete jest MIĘKKIE — wiersze + zostają, a razem z nimi ich dzieci (bo CASCADE nie ma czego kasować). + Dla komendy czyszczącej to nie byłoby kasowanie, tylko oznaczanie. + """ + hard = getattr(queryset, "hard_delete", None) + return hard() if hard is not None else queryset.delete() + def _delete_publications(self, publication_models, batch_size): deleted = {} for model in publication_models: - pks = list(model.objects.order_by("pk").values_list("pk", flat=True)) + manager = self._wszystkie_wiersze(model) + pks = list(manager.order_by("pk").values_list("pk", flat=True)) total = len(pks) self._progress(f" - {model._meta.label}: usuwam {total} rekordow") for start in range(0, total, batch_size): batch = pks[start : start + batch_size] - _, details = model.objects.filter(pk__in=batch).delete() + _, details = self._skasuj_trwale(manager.filter(pk__in=batch)) for model_label, count in details.items(): deleted[model_label] = deleted.get(model_label, 0) + count @@ -323,7 +346,7 @@ def _delete_generic_references(self, specs, content_types, *, progress_label): f" [{index}/{len(grouped_queries)}] {model._meta.label}: " f"usuwam {planned_count}" ) - count, _ = queryset.delete() + count, _ = self._skasuj_trwale(queryset) results.append(GenericReferenceResult(model._meta.label, "usunieto", count)) return results diff --git a/src/bpp/migrations/0496_publikacje_soft_delete_fields.py b/src/bpp/migrations/0496_publikacje_soft_delete_fields.py new file mode 100644 index 000000000..fa7a9a8ea --- /dev/null +++ b/src/bpp/migrations/0496_publikacje_soft_delete_fields.py @@ -0,0 +1,110 @@ +# Generated by Django 5.2.16 on 2026-08-07 12:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0495_nowe_sumy_bez_skasowanych'), + ('pbn_api', '0079_constraint_publikacja_instytucji'), + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ] + + operations = [ + migrations.AddField( + model_name='patent', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='patent', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='patent', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_doktorska', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_doktorska', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_doktorska', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_habilitacyjna', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_habilitacyjna', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_habilitacyjna', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_ciagle', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_ciagle', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_ciagle', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_zwarte', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_zwarte', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_zwarte', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddIndex( + model_name='patent', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='patent_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='praca_doktorska', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='pdok_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='praca_habilitacyjna', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='phab_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='wydawnictwo_ciagle', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='wc_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='wydawnictwo_zwarte', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='wz_deleted_at_idx'), + ), + ] diff --git a/src/bpp/migrations/0497_soft_delete_rekord_views.py b/src/bpp/migrations/0497_soft_delete_rekord_views.py new file mode 100644 index 000000000..2bba70950 --- /dev/null +++ b/src/bpp/migrations/0497_soft_delete_rekord_views.py @@ -0,0 +1,283 @@ +"""Soft-delete PUBLIKACJI: filtr w widokach + gałąź kasująca + bramka WHEN. + +Odpowiednik migracji ``0489`` (faza 01) dla drugiej ścieżki: tamta objęła +3 tabele ``*_autor``, ta obejmuje 5 tabel publikacji. Kolejność operacji jest +WYMUSZONA z dokładnie tego samego powodu: + + 1) widoki (wprowadzają ``deleted_at`` do drzewa zapytania) -> + 2) funkcje ``bpp_refresh_rekord_*`` (gałąź kasująca) -> + 3) regeneracja bramki WHEN (czyta ``pg_depend`` PO kroku 1) + +Odwrócenie 1<->3 daje bramkę bez ``deleted_at``, czyli cichy staleness: +soft-deletowana publikacja zostaje w ``bpp_rekord_mat``. + +Żadna z trzech zmian nie wystarcza sama — uzasadnienie jak w ``0489``: +funkcja refresh po ``0432`` robi wyłącznie ``INSERT ... ON CONFLICT DO +UPDATE``, więc wypadnięcie wiersza ze źródła jest dla niej no-opem; a bez +``deleted_at`` w bramce ``WHEN`` UPDATE soft-delete w ogóle nie dochodzi do +funkcji triggera. + +DLACZEGO NIE DA SIĘ UŻYĆ ``_filtruj_widok`` Z ``0489`` +------------------------------------------------------ +Tamta funkcja dopisuje ``WHERE`` na KOŃCU definicji i asertuje, że definicja +kończy się gołym ``FROM ``. Dla widoków publikacji to założenie jest +FAŁSZYWE — występują tu trzy różne kształty (zweryfikowane introspekcją +żywego katalogu, nie lekturą plików ``.sql``): + +- **A**: ``bpp_wydawnictwo_ciagle_view``, ``bpp_wydawnictwo_zwarte_view``, + ``bpp_patent_view`` — kończą się ``LEFT JOIN _autor ... GROUP BY + .id`` i NIE mają ``WHERE`` najwyższego poziomu. Dopisanie ``WHERE`` + na końcu byłoby błędem składni (``WHERE`` po ``GROUP BY``), więc predykat + wstawiamy PRZED ``GROUP BY``. +- **B**: ``bpp_praca_doktorska_view``, ``bpp_praca_habilitacyjna_view`` — + kończą się gołym ``FROM ``; tu wzorzec z ``0489`` działa wprost. +- **C**: ``bpp_praca_doktorska_autorzy``, + ``bpp_praca_habilitacyjna_autorzy`` — mają WŁASNY ``WHERE`` najwyższego + poziomu (join po przecinku z ``bpp_autor``), więc predykat wstawiamy do + niego, zaraz za słowem ``WHERE``. + +⚠️ Rozpoznawanie kształtu NIE może iść po samym wystąpieniu słowa ``WHERE``: +widoki rodziny A zawierają ``count(...) FILTER (WHERE ... deleted_at IS +NULL)`` (migracja ``0494``), a wszystkie zawierają ``WHERE`` w skalarnych +podzapytaniach o ``django_content_type``. Dyskryminatorem jest ``WHERE``/ +``GROUP BY`` na WCIĘCIU DWÓCH SPACJI, czyli na poziomie klauzuli głównej +w formacie ``pg_get_viewdef(..., pretty=true)``. + +⚠️ AGREGATY: w rodzinie A filtrujemy tabelę PUBLIKACJI, która jest lewą +(napędzającą) stroną ``LEFT JOIN``-a — to jest bezpieczne. Pułapka opisana w +handoffie (§3.2) dotyczy filtrowania strony PRAWEJ (``*_autor``): warunek na +niej zdegenerowałby ``LEFT JOIN`` do ``INNER JOIN`` i publikacja, której +wszystkich autorów skasowano, wypadłaby z widoku. Dlatego ``liczba_autorow`` +jest liczona przez ``count(...) FILTER (...)`` (``0494``) i tej konstrukcji +tutaj NIE ruszamy. + +⚠️ KLUCZ w rodzinie C: ``object_id_raw`` to id PUBLIKACJI (autor leży na jej +wierszu), więc filtr po własnej kolumnie ``deleted_at`` tabeli publikacji +jest poprawny. To INNA sytuacja niż w widokach ``*_autorzy`` trzech typów +z through-modelem (faza 01), gdzie filtrowano po kolumnie wiersza through. +Nie kopiować klucza między fazami bez sprawdzenia, co dana kolumna znaczy. + +Definicje są GENEROWANE z introspekcji (jak w ``0432``/``0433``/``0489``), +a nie przepisane do ``.sql`` — kopia rozjechałaby się przy najbliższej +zmianie kolumn. +""" + +import importlib + +from django.db import connection, migrations + +_p0432 = importlib.import_module("bpp.migrations.0432_cache_trigger_plpgsql") +_p0433 = importlib.import_module("bpp.migrations.0433_cache_trigger_when_gate") + +REKORD_SITES = _p0432.REKORD_SITES # [(tabela, model, autor_na_wierszu), ...] + +# Klauzule główne w formacie pg_get_viewdef(pretty=true) stoją na wcięciu +# dwóch spacji. To odróżnia je od WHERE-ów w podzapytaniach skalarnych +# (wcięcie 10) i od FILTER (WHERE ...) w liście SELECT. +MARKER_GROUP_BY = "\n GROUP BY " +MARKER_WHERE = "\n WHERE " + + +def _widoki(tabela, autor_na_wierszu): + """Widoki danej publikacji objęte filtrem: rekordowy + ewentualny + autorski (tylko gdy autor leży na wierszu publikacji).""" + yield tabela + "_view" + if autor_na_wierszu: + yield tabela + "_autorzy" + + +def _viewdef(cur, widok): + cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok]) + return cur.fetchone()[0].rstrip().rstrip(";") + + +def _z_filtrem(orig, tabela, widok): + """Definicja widoku wzbogacona o ``.deleted_at IS NULL``. + + Kształt rozpoznajemy JAWNIE i każdy nieoczekiwany wariant kończy się + ``RuntimeError`` — cicha akceptacja dałaby widok bez filtra, czyli + dokładnie ten rodzaj przecieku, który ta migracja ma zamknąć. + """ + predykat = f"{tabela}.deleted_at IS NULL" + n_group_by = orig.count(MARKER_GROUP_BY) + n_where = orig.count(MARKER_WHERE) + + if n_group_by == 1 and n_where == 0: # rodzina A + glowa, ogon = orig.split(MARKER_GROUP_BY) + return f"{glowa}\n WHERE {predykat}{MARKER_GROUP_BY}{ogon}" + + if n_group_by == 0 and n_where == 1: # rodzina C + glowa, ogon = orig.split(MARKER_WHERE) + # Wstawiamy ZARAZ ZA "WHERE", a nie na końcu definicji — dzięki temu + # jesteśmy odporni na to, co po WHERE jeszcze następuje. + return f"{glowa}{MARKER_WHERE}{predykat} AND {ogon}" + + if n_group_by == 0 and n_where == 0: # rodzina B + if not orig.endswith(f"FROM {tabela}"): + raise RuntimeError( + f"{widok}: brak GROUP BY i WHERE, ale definicja nie konczy " + f"sie na 'FROM {tabela}' -- dopisanie WHERE bylo by " + f"niepoprawne. Definicja: ...{orig[-160:]!r}" + ) + return f"{orig}\n WHERE {predykat}" + + raise RuntimeError( + f"{widok}: nierozpoznany ksztalt definicji (GROUP BY x{n_group_by}, " + f"WHERE x{n_where}) -- nie zgaduje, gdzie wstawic filtr" + ) + + +def _bez_filtra(orig, tabela, widok): + """Odwrotność ``_z_filtrem`` — usuwa DOKŁADNIE ten tekst, który tamta + wstawiła. Symetria jest tu ważniejsza od elegancji: odtwarzanie + oryginału z plików ``.sql`` (jak w ``0489.backward``) nie zadziała, bo + widoki publikacji były redefiniowane w kilku migracjach (m.in. ``0494`` + dokładająca ``count(...) FILTER``) i nie ma jednego pliku źródłowego. + + ⚠️ Szukamy DWÓCH form predykatu, bo katalog nie przechowuje tekstu, który + wstawiliśmy, tylko jego postać ZNORMALIZOWANĄ. Gdy w zasięgu zapytania + jest jedna tabela (rodzina B), Postgres usuwa zbędną kwalifikację i + ``bpp_praca_doktorska.deleted_at IS NULL`` wraca jako gołe + ``deleted_at IS NULL``. + + ⚠️ KOLEJNOŚĆ KANDYDATÓW JEST NOŚNA, nie kosmetyczna. Wariant z ``AND`` + (rodzina C) MUSI być sprawdzany pierwszy: w definicji + ``WHERE AND bpp_autor.id = ...`` wzorzec + ``\\n WHERE `` pasuje jako PREFIKS, a usunięcie samego prefiksu + zostawiłoby ``WHERE`` zaczynające się od ``AND`` — składniowy gruz. + """ + kandydaci = [] + for predykat in (f"{tabela}.deleted_at IS NULL", "deleted_at IS NULL"): + kandydaci.append(f"{predykat} AND ") # rodzina C — NAJPIERW + for predykat in (f"{tabela}.deleted_at IS NULL", "deleted_at IS NULL"): + kandydaci.append(f"\n WHERE {predykat}") # rodziny A i B + + for wstawka in kandydaci: + if orig.count(wstawka) == 1: + return orig.replace(wstawka, "", 1) + raise RuntimeError( + f"{widok}: nie znalazlem (dokladnie jednej) wstawki z filtrem " + f"soft-delete -- definicja zmieniona poza ta migracja?" + ) + + +def _funkcja_z_galezia_kasujaca(cur, table, model, autor_na_wierszu): + """``bpp_refresh_rekord_()`` z ``0432`` + gałąź kasująca. + + Ciało generowane identycznie jak ``_p0432._create_rekord_function`` + (ten sam upsert z pozycyjnym mapowaniem kolumn), z jedną różnicą: gdy + wiersz jest soft-deletowany, kasujemy go z tabel ``_mat`` i wychodzimy. + + ``pg_advisory_xact_lock`` stoi PRZED rozgałęzieniem — kasowanie musi brać + ten sam lock co upsert (uzasadnienie w ``0489``). + + ⚠️ Doktorat i habilitacja (``autor_na_wierszu=True``) dotykają OBU tabel + ``_mat``, bo autor leży na wierszu publikacji. Nazwy kluczy są RÓŻNE: + w ``bpp_rekord_mat`` kolumna nazywa się ``id``, w ``bpp_autorzy_mat`` — + ``rekord_id`` (patrz ``_create_rekord_function`` i + ``_create_delete_rekord_function`` w ``0432``). + + Restore (``deleted_at`` -> NULL) leci normalną ścieżką upsertu — wiersz + wraca do widoku źródłowego, więc nic dodatkowego nie trzeba. + """ + body_autorzy = "" + kasuj_autorzy = "" + if autor_na_wierszu: + upsert_autorzy = _p0432._upsert_sql( + cur, "bpp_autorzy_mat", table + "_autorzy", "object_id_raw = NEW.id" + ) + body_autorzy = ( + "\n DELETE FROM bpp_autorzy_mat " + "WHERE rekord_id = ARRAY[ct, NEW.id]::integer[];\n" + f" {upsert_autorzy};" + ) + kasuj_autorzy = ( + "\n DELETE FROM bpp_autorzy_mat " + "WHERE rekord_id = ARRAY[ct, NEW.id]::integer[];" + ) + upsert_rekord = _p0432._upsert_sql( + cur, "bpp_rekord_mat", table + "_view", "object_id_raw = NEW.id" + ) + return f""" +CREATE OR REPLACE FUNCTION bpp_refresh_rekord_{model}() RETURNS trigger +LANGUAGE plpgsql AS $bpp_body$ +DECLARE ct integer; +BEGIN + {_p0432._ct_lookup(model)} + PERFORM pg_advisory_xact_lock(ct, NEW.id); + IF NEW.deleted_at IS NOT NULL THEN + DELETE FROM bpp_rekord_mat WHERE id = ARRAY[ct, NEW.id]::integer[];\ +{kasuj_autorzy} + RETURN NULL; + END IF; + {upsert_rekord};{body_autorzy} + RETURN NULL; +END $bpp_body$; +""" + + +def _regeneruj_bramke(): + """Ta sama logika co ``0433.forward``, ale tylko dla tabel PUBLIKACJI. + + Bramka jest wyliczana z ``pg_depend``, więc po zmianie definicji widoku + (krok 1) sama wciągnie ``deleted_at``; po cofnięciu widoku (backward) + sama ją zgubi. Tabele ``*_autor`` obsłużyła faza 01 — tu ich nie ruszamy. + """ + with connection.cursor() as cur: + for tabela, refresh_fn, widoki in _p0433.GATED: + if tabela.endswith("_autor"): + continue + kolumny = _p0433._gate_columns(cur, tabela, widoki) + if not kolumny: + # Bezpiecznik z 0433: bez kolumn powstałby niebramkowany + # UPDATE. Jeśli tu jesteśmy, krok 1 nie zadziałał. + raise RuntimeError( + f"bramka dla {tabela}: pg_depend nie zwrocil zadnej " + f"kolumny (widoki={widoki}) -- nie tworze " + f"niezbramkowanego UPDATE" + ) + when = _p0433._when_clause(kolumny) + cur.execute(f"DROP TRIGGER IF EXISTS {tabela}_cache_upd ON {tabela};") + cur.execute( + f"CREATE TRIGGER {tabela}_cache_upd AFTER UPDATE ON {tabela} " + f"FOR EACH ROW WHEN ({when}) " + f"EXECUTE PROCEDURE {refresh_fn}();" + ) + + +def _przebuduj_widoki(cur, transformacja): + for tabela, _model, autor_na_wierszu in REKORD_SITES: + for widok in _widoki(tabela, autor_na_wierszu): + orig = _viewdef(cur, widok) + nowa = transformacja(orig, tabela, widok) + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {nowa}") + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + _przebuduj_widoki(cur, _z_filtrem) # 1) widoki + for tabela, model, autor_na_wierszu in REKORD_SITES: # 2) funkcje + cur.execute( + _funkcja_z_galezia_kasujaca(cur, tabela, model, autor_na_wierszu) + ) + _regeneruj_bramke() # 3) bramka + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + _przebuduj_widoki(cur, _bez_filtra) # 1) widoki bez filtra + for tabela, model, autor_na_wierszu in REKORD_SITES: # 2) funkcje + cur.execute( + _p0432._create_rekord_function(cur, tabela, model, autor_na_wierszu) + ) + _regeneruj_bramke() # 3) bramka (deleted_at zniknie z pg_depend samo) + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0496_publikacje_soft_delete_fields"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/migrations/0498_nowe_sumy_bez_skasowanych_publikacji.py b/src/bpp/migrations/0498_nowe_sumy_bez_skasowanych_publikacji.py new file mode 100644 index 000000000..28bc4249c --- /dev/null +++ b/src/bpp/migrations/0498_nowe_sumy_bez_skasowanych_publikacji.py @@ -0,0 +1,74 @@ +"""Ranking autorów przestaje punktować soft-deletowane PUBLIKACJE. + +Dopełnienie migracji ``0495``, która zamknęła w tych widokach wymiar +AUTORSTWA (``*_autor.deleted_at``). Wymiar PUBLIKACJI został wtedy otwarty, +bo publikacje stały się soft-delete dopiero w fazie 02 (``0496``). + +Bez tego filtra soft-deletowana publikacja dalej wnosi punkty do sum: +``bpp_nowe_sumy__view`` -> UNION ALL ``bpp_nowe_sumy_view`` -> modele +``Nowe_Sumy_View``/``Sumy`` (``bpp/models/sumy_views.py``) -> +``ranking_autorow/views.py``. + +Zakres jest SZERSZY niż w ``0495``: tamta objęła 3 widoki (tylko typy +z through-modelem), ta obejmuje wszystkie 5. ``praca_doktorska`` i +``praca_habilitacyjna`` nie mają tabeli ``*_autor`` — autor leży na wierszu +publikacji — więc ``0495`` nie miała tam czego filtrować, ale soft-delete +samej pracy dotyczy ich tak samo jak reszty. + +Widoki ustalone INWENTARYZACJĄ kanarka katalogowego na starcie fazy 02 +(``docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md``), +nie zgadywaniem — plan fazy 02 miał dla nich wyłącznie ostrzeżenie +„sprawdź, czy wymagają poprawki". Wymagają, wszystkie pięć. + +PUŁAPKA AGREGATU TU NIE WYSTĘPUJE (sprawdzone, nie założone). Handoff (§3.2) +ostrzega, że warunek w ``WHERE`` degeneruje ``LEFT JOIN`` do ``INNER JOIN``, +przez co wiersz z zerem znika zamiast wyzerować licznik. Te widoki: + +- nie mają ANI JEDNEGO ``LEFT JOIN``-a (złączenia po przecinku albo jawne + ``JOIN``, czyli semantyka wewnętrzna od początku), +- nie mają ``GROUP BY`` ani żadnej funkcji agregującej — sumowanie dzieje + się dopiero w modelach Django, nad ``UNION ALL``. + +Semantyka jest tu zresztą odwrotna niż przy ``liczba_autorow``: skasowana +publikacja MA wypaść z rankingu, a nie zostać w nim z zerem. Zwykły warunek +w ``WHERE`` jest więc poprawną konstrukcją, a nie skrótem. +""" + +from django.db import connection, migrations + +from bpp.migration_util import widok_dopisz_warunek, widok_usun_warunek + +# (widok sum, tabela PUBLIKACJI, którą joinuje) +SUMY = [ + ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), + ("bpp_nowe_sumy_wydawnictwo_zwarte_view", "bpp_wydawnictwo_zwarte"), + ("bpp_nowe_sumy_patent_view", "bpp_patent"), + ("bpp_nowe_sumy_praca_doktorska_view", "bpp_praca_doktorska"), + ("bpp_nowe_sumy_praca_habilitacyjna_view", "bpp_praca_habilitacyjna"), +] + + +def _warunek(tabela): + return f"{tabela}.deleted_at IS NULL" + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + for widok, tabela in SUMY: + widok_dopisz_warunek(cur, widok, _warunek(tabela)) + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + for widok, tabela in SUMY: + widok_usun_warunek(cur, widok, _warunek(tabela)) + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0497_soft_delete_rekord_views"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/migrations/0499_drop_kronika_views.py b/src/bpp/migrations/0499_drop_kronika_views.py new file mode 100644 index 000000000..ffbfe8b1a --- /dev/null +++ b/src/bpp/migrations/0499_drop_kronika_views.py @@ -0,0 +1,98 @@ +"""Kasuje MARTWĄ rodzinę siedmiu widoków ``bpp_kronika_*``. + +DLACZEGO WSZYSTKIE SIEDEM NARAZ. Faza 01 chciała skasować trzy z nich +(``wydawnictwo_ciagle``/``wydawnictwo_zwarte``/``patent``) i została +zablokowana: zależą od nich dwa widoki nadrzędne, więc goły ``DROP VIEW`` +bez ``CASCADE`` nie przechodzi, a ``CASCADE`` po cichu zabrałby też te +nadrzędne. Rozbicie na kilka migracji nic nie daje — graf trzeba rozciąć +w jednym miejscu albo wcale. + +DOWÓD MARTWOTY (zweryfikowany 2026-08-07, faza 02): + +1. ``pg_depend``: JEDYNE zależności od tych siedmiu widoków są WEWNĄTRZ + rodziny — ``bpp_kronika_view`` <- ``bpp_kronika_all_unsorted_view`` <- + pięć widoków liści. Nic spoza rodziny na nich nie stoi. To mocniejszy + dowód niż grep: obejmuje też widoki i reguły, których nazwa nie zawiera + słowa „kronika". +2. Kod: zero trafień „kronika" w ``.py``/``.html``/``.json``/``.js``/SQL-u + poza katalogami migracji i ``baseline-sql/`` (czyli poza definicjami + samych widoków). Zero ``Meta.db_table`` wskazujących na którykolwiek. +3. Repozytoria SIOSTRZANE (to są obiekty bazodanowe, więc konsument nie + musi mieszkać w repo aplikacji — self-review, 2026-08-07): ``bpp-mcp`` + i ``bpp-skills`` czyste; ``bpp-deploy`` odwołuje się do nich WYŁĄCZNIE + w komentarzach i w jednej kontrolce diagnostycznej — patrz uwaga niżej. +4. ``flexible_reports``: definicje raportów żyją jako wiersze w bazie + produkcyjnej, więc z repo nie da się ich sprawdzić. **Potwierdzone przez + właściciela systemu (2026-08-07): żaden raport nie odpytuje tych + widoków.** To był jedyny element dowodu oparty na założeniu. + +Fazę 01 zweryfikowała tak trzy widoki; faza 02 dołożyła +``bpp_kronika_praca_{doktorska,habilitacyjna}_view``, których żywotności +nikt wcześniej nie sprawdzał — wyszły na jaw, gdy kanarek katalogowy +dostał do zakresu tabele publikacji. + +⚠️ DO ZROBIENIA W ``bpp-deploy`` (poza tym repo, nieblokujące): +``scripts/pg-collation-migrate-3-load.sh`` drukuje po załadowaniu bazy +kontrolkę ``SELECT 'kronika views: '||count(*) ... LIKE 'bpp_kronika%'``. +Po tej migracji wypisze ``0`` — a to sanity-check po odtworzeniu bazy, więc +operator ma prawo odczytać zero jako nieudany load. Do zdjęcia razem +z komentarzami w ``lib-pg-collation-migrate.sh`` i +``pg-collation-migrate-2-fix.sh``, które uzasadniają migrację kolacji przez +„5 widoków ``bpp_kronika_*``". Sam ``sed`` celuje we wzorzec ``COLLATE``, +a nie w nazwy widoków, więc skrypty NIE przestają działać. + +ODWRACALNOŚĆ: ``backward`` odtwarza całą siódemkę z sidecara +``0499_drop_kronika_views.sql``. Plik został WYGENEROWANY z +``pg_get_viewdef()`` na żywym katalogu, a nie przepisany ręcznie — przy +7 KB SQL-a przepisywanie byłoby proszeniem się o cichą literówkę. + +⚠️ Po tej migracji trzeba usunąć trzy wpisy ``WYJATKI`` z kanarka +katalogowego (``test_kanarek_katalogowy.py``) — wskazują na widoki, których +już nie ma. +""" + +from django.db import connection, migrations + +from bpp.migration_util import load_custom_sql + +# Kolejnosc KASOWANIA: od szczytu w dol (parasole przed liscmi). +# Odwrotna kolejnosc (odtwarzania) siedzi w sidecarze .sql. +RODZINA_OD_SZCZYTU = [ + "bpp_kronika_view", + "bpp_kronika_all_unsorted_view", + "bpp_kronika_wydawnictwo_ciagle_view", + "bpp_kronika_wydawnictwo_zwarte_view", + "bpp_kronika_patent_view", + "bpp_kronika_praca_doktorska_view", + "bpp_kronika_praca_habilitacyjna_view", +] + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + for widok in RODZINA_OD_SZCZYTU: + # Dwie NIEZALEŻNE decyzje w jednej instrukcji: + # + # bez CASCADE — gdyby cokolwiek spoza rodziny zdążyło się na + # którymś oprzeć, chcemy głośnego błędu, a nie cichego zabrania + # tego czegoś razem z widokiem; + # + # z IF EXISTS — bo to piętnastoletnie, martwe widoki i któryś + # DBA mógł je już ręcznie sprzątnąć; bez tego migracja twardo + # pada na takiej bazie. IF EXISTS NIE osłabia głośności: + # przy istniejącej zależności DROP dalej rzuca błąd. + cur.execute(f"DROP VIEW IF EXISTS {widok}") + + +def backward(apps, schema_editor): + load_custom_sql("0499_drop_kronika_views") + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0498_nowe_sumy_bez_skasowanych_publikacji"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/migrations/0499_drop_kronika_views.sql b/src/bpp/migrations/0499_drop_kronika_views.sql new file mode 100644 index 000000000..93f9f47f2 --- /dev/null +++ b/src/bpp/migrations/0499_drop_kronika_views.sql @@ -0,0 +1,196 @@ +-- Odtworzenie rodziny widokow bpp_kronika_* (backward migracji 0499). +-- +-- WYGENEROWANE z pg_get_viewdef() na zywym katalogu, nie przepisane recznie. +-- Kolejnosc jest WYMUSZONA grafem zaleznosci: piec widokow lisci, potem +-- bpp_kronika_all_unsorted_view (UNION po nich), na koncu bpp_kronika_view. +-- +-- Ta rodzina jest MARTWA (zero konsumentow) i migracja 0499 ja kasuje. +-- Plik istnieje wylacznie po to, zeby ta migracja byla odwracalna. + +CREATE VIEW bpp_kronika_wydawnictwo_ciagle_view AS + SELECT bpp_autor.id AS autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_jednostka.id AS jednostka_id, + bpp_wydawnictwo_ciagle.tytul_oryginalny, + bpp_wydawnictwo_ciagle.tytul_oryginalny_sort, + bpp_wydawnictwo_ciagle.rok, + bpp_wydawnictwo_ciagle_autor.kolejnosc, + 'bpp_wydawnictwo_ciagle'::text AS object, + bpp_wydawnictwo_ciagle.id AS object_pk, + bpp_wydawnictwo_ciagle.id, + bpp_wydawnictwo_ciagle.zrodlo_id + FROM bpp_wydawnictwo_ciagle, + bpp_wydawnictwo_ciagle_autor, + bpp_jednostka, + bpp_zrodlo, + bpp_autor + WHERE bpp_wydawnictwo_ciagle_autor.autor_id = bpp_autor.id AND bpp_wydawnictwo_ciagle_autor.rekord_id = bpp_wydawnictwo_ciagle.id AND bpp_wydawnictwo_ciagle_autor.jednostka_id = bpp_jednostka.id AND bpp_zrodlo.id = bpp_wydawnictwo_ciagle.zrodlo_id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_wydawnictwo_zwarte_view AS + SELECT bpp_autor.id AS autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_jednostka.id AS jednostka_id, + bpp_wydawnictwo_zwarte.tytul_oryginalny, + bpp_wydawnictwo_zwarte.tytul_oryginalny_sort, + bpp_wydawnictwo_zwarte.rok, + bpp_wydawnictwo_zwarte_autor.kolejnosc, + 'bpp_wydawnictwo_zwarte'::text AS object, + bpp_wydawnictwo_zwarte.id AS object_pk, + bpp_wydawnictwo_zwarte.id, + NULL::integer AS zrodlo_id + FROM bpp_wydawnictwo_zwarte, + bpp_wydawnictwo_zwarte_autor, + bpp_jednostka, + bpp_autor + WHERE bpp_wydawnictwo_zwarte_autor.autor_id = bpp_autor.id AND bpp_wydawnictwo_zwarte_autor.rekord_id = bpp_wydawnictwo_zwarte.id AND bpp_wydawnictwo_zwarte_autor.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_patent_view AS + SELECT bpp_autor.id AS autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_jednostka.id AS jednostka_id, + bpp_patent.tytul_oryginalny, + bpp_patent.tytul_oryginalny_sort, + bpp_patent.rok, + bpp_patent_autor.kolejnosc, + 'bpp_patent'::text AS object, + bpp_patent.id AS object_pk, + bpp_patent.id, + NULL::integer AS zrodlo_id + FROM bpp_patent, + bpp_autor, + bpp_patent_autor, + bpp_jednostka + WHERE bpp_patent_autor.autor_id = bpp_autor.id AND bpp_patent_autor.rekord_id = bpp_patent.id AND bpp_patent_autor.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_praca_doktorska_view AS + SELECT bpp_praca_doktorska.autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_praca_doktorska.jednostka_id, + bpp_praca_doktorska.tytul_oryginalny, + bpp_praca_doktorska.tytul_oryginalny_sort, + bpp_praca_doktorska.rok, + 1 AS kolejnosc, + 'bpp_praca_doktorska'::text AS object, + bpp_praca_doktorska.id AS object_pk, + bpp_praca_doktorska.id, + NULL::integer AS zrodlo_id + FROM bpp_praca_doktorska, + bpp_jednostka, + bpp_autor + WHERE bpp_praca_doktorska.autor_id = bpp_autor.id AND bpp_praca_doktorska.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_praca_habilitacyjna_view AS + SELECT bpp_praca_habilitacyjna.autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_praca_habilitacyjna.jednostka_id, + bpp_praca_habilitacyjna.tytul_oryginalny, + bpp_praca_habilitacyjna.tytul_oryginalny_sort, + bpp_praca_habilitacyjna.rok, + 1 AS kolejnosc, + 'bpp_praca_habilitacyjna'::text AS object, + bpp_praca_habilitacyjna.id AS object_pk, + bpp_praca_habilitacyjna.id, + NULL::integer AS zrodlo_id + FROM bpp_praca_habilitacyjna, + bpp_jednostka, + bpp_autor + WHERE bpp_praca_habilitacyjna.autor_id = bpp_autor.id AND bpp_praca_habilitacyjna.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_all_unsorted_view AS + SELECT bpp_kronika_patent_view.autor_id, + bpp_kronika_patent_view.imiona, + bpp_kronika_patent_view.nazwisko, + bpp_kronika_patent_view.jednostka_id, + bpp_kronika_patent_view.tytul_oryginalny, + bpp_kronika_patent_view.tytul_oryginalny_sort, + bpp_kronika_patent_view.rok, + bpp_kronika_patent_view.kolejnosc, + bpp_kronika_patent_view.object, + bpp_kronika_patent_view.object_pk, + bpp_kronika_patent_view.id, + bpp_kronika_patent_view.zrodlo_id + FROM bpp_kronika_patent_view +UNION + SELECT bpp_kronika_praca_habilitacyjna_view.autor_id, + bpp_kronika_praca_habilitacyjna_view.imiona, + bpp_kronika_praca_habilitacyjna_view.nazwisko, + bpp_kronika_praca_habilitacyjna_view.jednostka_id, + bpp_kronika_praca_habilitacyjna_view.tytul_oryginalny, + bpp_kronika_praca_habilitacyjna_view.tytul_oryginalny_sort, + bpp_kronika_praca_habilitacyjna_view.rok, + bpp_kronika_praca_habilitacyjna_view.kolejnosc, + bpp_kronika_praca_habilitacyjna_view.object, + bpp_kronika_praca_habilitacyjna_view.object_pk, + bpp_kronika_praca_habilitacyjna_view.id, + bpp_kronika_praca_habilitacyjna_view.zrodlo_id + FROM bpp_kronika_praca_habilitacyjna_view +UNION + SELECT bpp_kronika_praca_doktorska_view.autor_id, + bpp_kronika_praca_doktorska_view.imiona, + bpp_kronika_praca_doktorska_view.nazwisko, + bpp_kronika_praca_doktorska_view.jednostka_id, + bpp_kronika_praca_doktorska_view.tytul_oryginalny, + bpp_kronika_praca_doktorska_view.tytul_oryginalny_sort, + bpp_kronika_praca_doktorska_view.rok, + bpp_kronika_praca_doktorska_view.kolejnosc, + bpp_kronika_praca_doktorska_view.object, + bpp_kronika_praca_doktorska_view.object_pk, + bpp_kronika_praca_doktorska_view.id, + bpp_kronika_praca_doktorska_view.zrodlo_id + FROM bpp_kronika_praca_doktorska_view +UNION + SELECT bpp_kronika_wydawnictwo_ciagle_view.autor_id, + bpp_kronika_wydawnictwo_ciagle_view.imiona, + bpp_kronika_wydawnictwo_ciagle_view.nazwisko, + bpp_kronika_wydawnictwo_ciagle_view.jednostka_id, + bpp_kronika_wydawnictwo_ciagle_view.tytul_oryginalny, + bpp_kronika_wydawnictwo_ciagle_view.tytul_oryginalny_sort, + bpp_kronika_wydawnictwo_ciagle_view.rok, + bpp_kronika_wydawnictwo_ciagle_view.kolejnosc, + bpp_kronika_wydawnictwo_ciagle_view.object, + bpp_kronika_wydawnictwo_ciagle_view.object_pk, + bpp_kronika_wydawnictwo_ciagle_view.id, + bpp_kronika_wydawnictwo_ciagle_view.zrodlo_id + FROM bpp_kronika_wydawnictwo_ciagle_view +UNION + SELECT bpp_kronika_wydawnictwo_zwarte_view.autor_id, + bpp_kronika_wydawnictwo_zwarte_view.imiona, + bpp_kronika_wydawnictwo_zwarte_view.nazwisko, + bpp_kronika_wydawnictwo_zwarte_view.jednostka_id, + bpp_kronika_wydawnictwo_zwarte_view.tytul_oryginalny, + bpp_kronika_wydawnictwo_zwarte_view.tytul_oryginalny_sort, + bpp_kronika_wydawnictwo_zwarte_view.rok, + bpp_kronika_wydawnictwo_zwarte_view.kolejnosc, + bpp_kronika_wydawnictwo_zwarte_view.object, + bpp_kronika_wydawnictwo_zwarte_view.object_pk, + bpp_kronika_wydawnictwo_zwarte_view.id, + bpp_kronika_wydawnictwo_zwarte_view.zrodlo_id + FROM bpp_kronika_wydawnictwo_zwarte_view +; + +CREATE VIEW bpp_kronika_view AS + SELECT autor_id, + imiona, + nazwisko, + jednostka_id, + tytul_oryginalny, + tytul_oryginalny_sort, + rok, + kolejnosc, + object, + object_pk, + id, + zrodlo_id + FROM bpp_kronika_all_unsorted_view + ORDER BY nazwisko, imiona, tytul_oryginalny_sort +; diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py index 859536b9e..d2f2b728b 100644 --- a/src/bpp/models/patent.py +++ b/src/bpp/models/patent.py @@ -26,7 +26,10 @@ RekordBPPBaza, ) from bpp.models.autor import Autor -from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin +from bpp.models.soft_delete import ( + BppAutorstwoSoftDeleteMixin, + BppPublikacjaSoftDeleteMixin, +) from bpp.models.system import Charakter_Formalny, Jezyk from bpp.util import safe_tytul_html @@ -124,6 +127,7 @@ def jezyk(self): class Patent( + BppPublikacjaSoftDeleteMixin, RekordBPPBaza, ModelZRokiem, ModelZeStatusem, @@ -177,6 +181,15 @@ class Meta: verbose_name = "patent" verbose_name_plural = "patenty" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="patent_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def __str__(self): return self.tytul_oryginalny diff --git a/src/bpp/models/praca_doktorska.py b/src/bpp/models/praca_doktorska.py index 53edbb894..1e6c593cb 100644 --- a/src/bpp/models/praca_doktorska.py +++ b/src/bpp/models/praca_doktorska.py @@ -1,7 +1,7 @@ from denorm import denormalized, depend_on_fields, depend_on_related from django.contrib.postgres.fields import ArrayField from django.db import models -from django.db.models import CASCADE, SET_NULL +from django.db.models import CASCADE, SET_NULL, Q from django.utils.functional import cached_property from bpp.models import ( @@ -11,6 +11,7 @@ NieMaProcentowMixin, ) from bpp.models.abstract import DwaTytuly +from bpp.models.soft_delete import BppPublikacjaSoftDeleteMixin from .autor import Autor from .struktura import Jednostka @@ -149,7 +150,7 @@ def typ_odpowiedzialnosci_autor(self): _Praca_Doktorska_PropertyCache = _Praca_Doktorska_PropertyCache() -class Praca_Doktorska(Praca_Doktorska_Baza): +class Praca_Doktorska(BppPublikacjaSoftDeleteMixin, Praca_Doktorska_Baza): autor = models.ForeignKey(Autor, CASCADE) promotor = models.ForeignKey( @@ -165,6 +166,15 @@ class Meta: verbose_name_plural = "prace doktorskie" app_label = "bpp" ordering = ("rok", "tytul_oryginalny") + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="pdok_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def clean(self): DwaTytuly.clean(self) diff --git a/src/bpp/models/praca_habilitacyjna.py b/src/bpp/models/praca_habilitacyjna.py index 14b07310d..2b61c9864 100644 --- a/src/bpp/models/praca_habilitacyjna.py +++ b/src/bpp/models/praca_habilitacyjna.py @@ -1,11 +1,12 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models -from django.db.models import CASCADE, PROTECT +from django.db.models import CASCADE, PROTECT, Q from django.utils.functional import cached_property from bpp.models import Autor, Charakter_Formalny, DwaTytuly, ModelZOplataZaPublikacje from bpp.models.praca_doktorska import Praca_Doktorska_Baza +from bpp.models.soft_delete import BppPublikacjaSoftDeleteMixin class Publikacja_Habilitacyjna(models.Model): @@ -43,7 +44,7 @@ def charakter_formalny(self): _Praca_Habilitacyjna_PropertyCache = _Praca_Habilitacyjna_PropertyCache() -class Praca_Habilitacyjna(Praca_Doktorska_Baza): +class Praca_Habilitacyjna(BppPublikacjaSoftDeleteMixin, Praca_Doktorska_Baza): autor = models.OneToOneField(Autor, PROTECT) publikacje_habilitacyjne = GenericRelation(Publikacja_Habilitacyjna) @@ -56,6 +57,15 @@ class Meta: verbose_name = "praca habilitacyjna" verbose_name_plural = "prace habilitacyjne" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="phab_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def clean(self): DwaTytuly.clean(self) diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py index 1e759ec72..bf0a46f08 100644 --- a/src/bpp/models/soft_delete.py +++ b/src/bpp/models/soft_delete.py @@ -37,7 +37,11 @@ Model.deleted_objects.filter(ostatnio_zmieniony__gte=X) """ +import uuid + from django.core.exceptions import FieldDoesNotExist +from django.db import transaction +from django.utils import timezone from django_softdelete.managers import ( DeletedManager, DeletedQuerySet, @@ -46,6 +50,13 @@ SoftDeleteQuerySet, ) from django_softdelete.models import SoftDeleteModel +from django_softdelete.signals import post_restore, post_soft_delete + +#: Akcesor relacji odwrotnej publikacja -> wiersze ``*_Autor``. Istnieje +#: jako PRAWDZIWA relacja tylko dla trzech typów z through-modelem; +#: ``Praca_Doktorska``/``Praca_Habilitacyjna`` mają pod tą nazwą property +#: zwracającą atrapy — patrz ``BppPublikacjaSoftDeleteMixin``. +NAZWA_RELACJI_AUTORSTW = "autorzy_set" #: Nazwa pola-znacznika czasu modyfikacji. Ta sama w #: ``BazaModeluOdpowiedzialnosciAutorow`` (``abstract/authors.py``) i w @@ -225,3 +236,152 @@ def restore( ``obj.restore(False, txid)``. """ return super().restore(strict, transaction_id, *args, **kwargs) + + +class BppPublikacjaSoftDeleteMixin(SoftDeleteModel): + """SoftDeleteModel dla 5 modeli PUBLIKACJI (faza 02) z **wąską, + kontrolowaną** kaskadą na własne wiersze ``*_Autor`` pod wspólnym + ``transaction_id``. + + DLACZEGO NIE REFLEKSYJNA KASKADA PAKIETU: ``SoftDeleteModel.delete()`` + przechodzi po wszystkich relacjach zwrotnych. Zjechałaby więc po + ``*_Streszczenie``, ``*_Zewnetrzna_Baza_Danych`` i + ``Publikacja_Habilitacyjna`` — modelach, które soft-delete NIE obejmuje. + Co gorsza zrobiłaby to CICHO: ``delete()`` pakietu ma domyślnie + ``strict=False``, więc nie usłyszelibyśmy ``SoftDeleteException``. + Dlatego kaskadę piszemy sami i zatrzymujemy ją na ``*_Autor``. + + Kaskada NIE jest jednolita dla wszystkich pięciu modeli — patrz + ``_relacja_autorstw()``. + """ + + # Managery z gate'em na .update() (faza 01). ``deleted_objects`` to + # NASZ ``BppDeletedManager``, nie pakietowy ``DeletedManager`` — + # pakietowy zwraca queryset z ``strict=True`` w ``restore()``, co łamie + # inwariant z docstringu modułu. + objects = BppSoftDeleteManager() + global_objects = BppGlobalManager() + deleted_objects = BppDeletedManager() + + class Meta: + abstract = True + + # --- rozpoznanie kształtu kaskady ---------------------------------- + + def _relacja_autorstw(self): + """Relacja odwrotna ``autorzy_set`` (``ForeignObjectRel``) albo + ``None``, gdy publikacja nie ma through-modelu autorstw. + + DLACZEGO PYTAMY ``_meta``, A NIE TYPU ATRYBUTU: pod nazwą + ``autorzy_set`` kryją się dwie różne rzeczy. Dla + ``Wydawnictwo_Ciagle``/``Wydawnictwo_Zwarte``/``Patent`` to + prawdziwy related manager (``related_name`` FK w modelu + ``*_Autor``). Dla ``Praca_Doktorska``/``Praca_Habilitacyjna`` to + PROPERTY zwracająca ``FakeSet`` z atrapami (autor leży na wierszu + samej publikacji) — ``FakeSet`` jest podklasą ``list``, nie ma + ``.model``, a atrapy nie mają ``.delete()``. + + Rozpoznawanie tego przez ``type(self).__dict__.get("autorzy_set")`` + NIE DZIAŁA: property jest zadeklarowana na abstrakcyjnej bazie + ``Praca_Doktorska_Baza``, a dziedziczenie abstrakcyjne w Django + kopiuje do klasy potomnej POLA, nie zwykłe atrybuty Pythona — te + zostają na bazie i są znajdowane dopiero przez MRO. ``__dict__`` + klasy konkretnej jest więc pusty i test na ``property`` dawałby + fałszywe „to jest through-model", a zaraz potem ``AttributeError`` + na ``FakeSet.model``. + + ``_meta.related_objects`` zna wyłącznie PRAWDZIWE relacje, więc + odpowiada na pytanie, które faktycznie zadajemy. + """ + for rel in self._meta.related_objects: + if rel.get_accessor_name() == NAZWA_RELACJI_AUTORSTW: + return rel + return None + + def _model_through(self): + """Model ``*_Autor`` tej publikacji albo ``None``.""" + rel = self._relacja_autorstw() + return rel.related_model if rel is not None else None + + def _autorstwa_do_kaskady(self): + """Wiersze ``*_Autor`` do soft-delete; pusto bez through-modelu. + + Czyta przez domyślny manager (``objects``, więc już-skasowane są + pominięte) — kasowanie drugi raz nie szkodzi, ale i nie ma po co. + """ + if self._relacja_autorstw() is None: + return [] + return list(getattr(self, NAZWA_RELACJI_AUTORSTW).all()) + + # --- kontrakt zapisu ------------------------------------------------ + + def save(self, *args, **kwargs): + """Bump ``ostatnio_zmieniony`` przy soft-delete i restore. + + Ten sam kontrakt (PINNED), co + ``BppAutorstwoSoftDeleteMixin.save()`` — pełne uzasadnienie + mechanizmu jest w docstringu ``dopisz_znacznik_zmiany()``. Bez + tego nagrobki dla harvestu przyrostowego (OAI-PMH, CERIF, REST + API) byłyby nieodpytywalne po ``ostatnio_zmieniony__gte``, bo + ``auto_now`` nie rusza pola nieobecnego w ``update_fields``. + """ + update_fields = kwargs.get("update_fields") + if update_fields: + kwargs["update_fields"] = dopisz_znacznik_zmiany(self, update_fields) + return super().save(*args, **kwargs) + + # --- soft-delete / restore ------------------------------------------ + + def delete(self, *args, user=None, reason="", **kwargs): + """Soft-delete publikacji + wąska kaskada na ``*_Autor``. + + ``user``/``reason`` są na razie wyłącznie przepuszczane — konsumuje + je ``SoftDeleteLog`` z fazy 06. W sygnaturze MUSZĄ być już teraz + (kontrakt PINNED), żeby wołający kod nie wymagał później zmiany. + """ + txid = kwargs.pop("transaction_id", None) or uuid.uuid4() + with transaction.atomic(): + # 1. kaskada per-instancja — NIGDY bulk update(deleted_at=...), + # bo omijałby post_save, sygnały i reversion (gate w + # BppSoftDeleteQuerySet.update() egzekwuje to fail-fast). + for autorstwo in self._autorstwa_do_kaskady(): + autorstwo.delete(transaction_id=txid) + # 2. własny wiersz + self.deleted_at = timezone.now() + self.restored_at = None + self.transaction_id = txid + self.save(update_fields=["deleted_at", "restored_at", "transaction_id"]) + post_soft_delete.send(sender=self.__class__, instance=self) + return 1, {self._meta.label: 1} + + delete.alters_data = True + + def restore(self, *args, strict: bool = False, user=None, **kwargs): + """Przywrócenie publikacji + tych ``*_Autor``, które zniknęły RAZEM + z nią (ten sam ``transaction_id``). + + Filtr po ``transaction_id`` jest istotny: autorstwo skasowane + wcześniej, osobną decyzją operatora, ma POZOSTAĆ w koszu. + + ``strict`` przyjmujemy i ignorujemy świadomie — przekazują go + ścieżki queryset-owe (``global_objects``/``deleted_objects``), a my + i tak nie wołamy ``super().restore()``, więc pakietowy + ``SoftDeleteException`` nie ma jak polecieć (inwariant z docstringu + modułu). + """ + txid = self.transaction_id + rel = self._relacja_autorstw() + with transaction.atomic(): + if txid is not None and rel is not None: + # Nazwa pola FK z metadanych relacji — bez zaszywania + # "rekord" na sztywno. + filtr = {rel.field.name: self, "transaction_id": txid} + for autorstwo in rel.related_model.deleted_objects.filter(**filtr): + autorstwo.restore(transaction_id=txid) + self.deleted_at = None + self.restored_at = timezone.now() + self.transaction_id = None + self.save(update_fields=["deleted_at", "restored_at", "transaction_id"]) + post_restore.send(sender=self.__class__, instance=self, transaction_id=txid) + + restore.alters_data = True diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py index de559857a..cfe9409fe 100644 --- a/src/bpp/models/wydawnictwo_ciagle.py +++ b/src/bpp/models/wydawnictwo_ciagle.py @@ -47,7 +47,11 @@ ModelZWWW, Wydawnictwo_Baza, ) -from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin +from bpp.models.soft_delete import ( + BppAutorstwoSoftDeleteMixin, + BppPublikacjaSoftDeleteMixin, + BppSoftDeleteManager, +) from bpp.models.system import Zewnetrzna_Baza_Danych from bpp.models.util import ZapobiegajNiewlasciwymCharakterom @@ -169,11 +173,30 @@ class Meta: abstract = True -class Wydawnictwo_Ciagle_Manager(ManagerModeliZOplataZaPublikacjeMixin, models.Manager): - pass +class Wydawnictwo_Ciagle_Manager( + ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager +): + """Menedżer opłat PRZEPLECIONY z filtrem soft-delete (faza 02). + + Nośna jest DRUGA BAZA, nie kolejność. Do fazy 02 stało tu + ``models.Manager``, więc ``objects`` w ogóle nie znało ``deleted_at`` + i pokazywało kosz — mimo że model dziedziczył już + ``BppPublikacjaSoftDeleteMixin`` (menedżer zadeklarowany w ciele klasy + przesłania ten wniesiony przez bazę abstrakcyjną). Podmiana na + ``BppSoftDeleteManager`` to naprawia. + + Kolejność baz jest natomiast WYŁĄCZNIE konwencją (mixiny przed klasą + bazową) — sprawdzone mutacyjnie: odwrócenie jej nie zmienia zachowania. + Powód: ``ManagerModeliZOplataZaPublikacjeMixin`` NIE jest menedżerem, + tylko czystym mixinem z jedną metodą (``self.exclude(...)``), więc nie + wnosi własnego ``get_queryset()`` i nie ma o co konkurować w MRO. + Dzięki temu ``rekordy_z_oplata()`` operuje na już-przefiltrowanym + querysecie bez jednej linijki kodu o soft-delete. + """ class Wydawnictwo_Ciagle( + BppPublikacjaSoftDeleteMixin, ZapobiegajNiewlasciwymCharakterom, Wydawnictwo_Baza, DwaTytuly, @@ -229,6 +252,22 @@ class Meta: verbose_name = "wydawnictwo ciągłe" verbose_name_plural = "wydawnictwa ciągłe" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY (`WHERE deleted_at IS NOT NULL`) — ten sam + # wzorzec i to samo uzasadnienie, co `wc_autor_deleted_at_idx` + # wyżej (faza 01): predykat `deleted_at IS NULL` pasuje do ~100% + # wierszy, więc planner nigdy nie wybrałby pod niego indeksu, a + # pełny btree byłby wyłącznie kosztem (rozmiar + wpis przy + # każdym INSERT/UPDATE publikacji). Selektywne jest zapytanie + # ODWROTNE — kosz/audyt (`deleted_objects`) — i to ono dostaje + # tu mikroskopijny indeks. KANONICZNE UZASADNIENIE dla + # wszystkich pięciu tabel publikacji. + models.Index( + fields=["deleted_at"], + name="wc_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def punktacja_zrodla(self): """Funkcja - skrót do użycia w templatkach, zwraca punktację zrodla diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py index 6bc800b4b..cb53df3d7 100644 --- a/src/bpp/models/wydawnictwo_zwarte.py +++ b/src/bpp/models/wydawnictwo_zwarte.py @@ -54,7 +54,11 @@ ) from bpp.models.autor import Autor from bpp.models.nagroda import Nagroda -from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin +from bpp.models.soft_delete import ( + BppAutorstwoSoftDeleteMixin, + BppPublikacjaSoftDeleteMixin, + BppSoftDeleteManager, +) from bpp.models.system import Zewnetrzna_Baza_Danych from bpp.models.util import ZapobiegajNiewlasciwymCharakterom from bpp.models.wydawca import Wydawca @@ -232,7 +236,18 @@ class Meta: rok_regex = re.compile(r"\s[12]\d\d\d") -class Wydawnictwo_Zwarte_Manager(ManagerModeliZOplataZaPublikacjeMixin, models.Manager): +class Wydawnictwo_Zwarte_Manager( + ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager +): + """Jak ``Wydawnictwo_Ciagle_Manager`` — uzasadnienie doboru bazy + (i tego, dlaczego kolejność NIE jest nośna) w jego docstringu + (``wydawnictwo_ciagle.py``). + + ``wydawnictwa_nadrzedne_dla_innych()`` też korzysta na przepleceniu: + po fazie 02 nie zwróci już książki-matki, której jedyne rozdziały + trafiły do kosza. + """ + def wydawnictwa_nadrzedne_dla_innych(self): return ( self.exclude(wydawnictwo_nadrzedne_id=None) @@ -242,6 +257,7 @@ def wydawnictwa_nadrzedne_dla_innych(self): class Wydawnictwo_Zwarte( + BppPublikacjaSoftDeleteMixin, ZapobiegajNiewlasciwymCharakterom, Wydawnictwo_Zwarte_Baza, ModelZCharakterem, @@ -317,6 +333,15 @@ class Meta: verbose_name = "wydawnictwo zwarte" verbose_name_plural = "wydawnictwa zwarte" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="wz_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def wydawnictwa_powiazane_posortowane(self): """ diff --git a/src/bpp/tests/test_cache/test_soft_delete_preconditions.py b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py index 2033494bb..13403102a 100644 --- a/src/bpp/tests/test_cache/test_soft_delete_preconditions.py +++ b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py @@ -14,24 +14,24 @@ Faza 01 objela WYLACZNIE tabele ``*_Autor`` (migracja 0489: widok filtruje, funkcja refresh ma galaz kasujaca, bramka WHEN zna ``deleted_at`` -- patrz -``test_soft_delete/test_views_sql.py``). Tabele publikacji (np. -``bpp_wydawnictwo_ciagle``) sa poza zakresem -- to faza 02. Dlatego: - -- oryginalne dwa testy ponizej sa ODWROCONE na docelowe asercje (to co - soft-delete MA robic), ale zostawione jako ``xfail`` -- to JEDYNY - regresyjny dowod, ze bramka i galaz kasujaca dzialaja poprawnie DOPIERO - po fazie 02 (gdy przestana byc xfail, to znak, ze ktos wdrozyl mechanizm - dla publikacji i NIE zaktualizowal tego markera -- patrz uwaga przy - ``xfail`` nizej); -- ponizej dopisane sa ich ODPOWIEDNIKI dla ``bpp_wydawnictwo_ciagle_autor`` - / ``bpp_autorzy_mat``, ktore juz DZIALAJA (faza 01) -- to one sa realnym - dowodem regresyjnym na CO DZIEN, nie oryginaly. +``test_soft_delete/test_views_sql.py``), a faza 02 -- 5 tabel PUBLIKACJI +(migracje 0496 i 0497, patrz ``test_soft_delete/test_views_sql_publikacje.py``). + +Historia tego pliku, bo tlumaczy jego ksztalt: + +- oryginalne dwa testy zostaly po fazie 01 ODWROCONE na docelowe asercje (to, + co soft-delete MA robic) i oznaczone ``xfail(strict=True)``, bo dla + publikacji mechanizmu jeszcze nie bylo. Zeby w ogole dalo sie je napisac, + dokladaly kolumne ``deleted_at`` ALTER-em i owijaly widok filtrem WEWNATRZ + transakcji testowej; +- faza 02 dostarczyla jedno i drugie NAPRAWDE, wiec symulacja zostala + usunieta, a wraz z nia markery ``xfail``. Testy sa teraz zwyklymi testami + regresyjnymi; +- ich ODPOWIEDNIKI dla ``bpp_wydawnictwo_ciagle_autor`` / ``bpp_autorzy_mat`` + (dopisane w fazie 01) zostaja -- pokrywaja druga sciezke. Surowy SQL, zeby izolowac sam trigger bazodanowy (bez denorm / sygnalow -Django). Dla oryginalnych (publikacja) testow kolumne ``deleted_at`` -dokladamy ALTER-em wewnatrz transakcji testowej -- DDL w Postgresie jest -transakcyjny, wiec rollback ja sprzata. Dla nowych (``*_Autor``) testow -ALTER nie jest potrzebny -- kolumna jest realna od migracji 0488. +Django). """ import pytest @@ -62,48 +62,18 @@ def _ctid(cur, ct, pk): return row[0] if row else None -def _dodaj_deleted_at(cur): - cur.execute("ALTER TABLE bpp_wydawnictwo_ciagle ADD COLUMN deleted_at timestamptz") - - -def _filtruj_widok_po_deleted_at(cur): - """Owija bpp_wydawnictwo_ciagle_view filtrem deleted_at IS NULL. - - Odpowiednik "mechanizmu #1" ze specu, bez ruszania oryginalnej definicji - (CREATE OR REPLACE zachowuje liste kolumn -- bpp_rekord, ktory ten widok - UNION-uje, pozostaje wazny). - """ - cur.execute("SELECT pg_get_viewdef('bpp_wydawnictwo_ciagle_view'::regclass, true)") - orig = cur.fetchone()[0].rstrip().rstrip(";") - cur.execute( - f"CREATE OR REPLACE VIEW bpp_wydawnictwo_ciagle_view AS " - f"SELECT * FROM ({orig}) _orig " - f"WHERE _orig.object_id_raw NOT IN (" - f" SELECT id FROM bpp_wydawnictwo_ciagle WHERE deleted_at IS NOT NULL)" - ) - - -@pytest.mark.xfail( - reason="faza 02 -- soft-delete publikacji (bpp_wydawnictwo_ciagle)", - strict=True, -) @pytest.mark.django_db def test_update_samego_deleted_at_odpala_trigger(): - """Docelowo: bramka WHEN MA znac deleted_at -> UPDATE soft-delete MA - odpalac trigger, ktory kasuje wiersz z bpp_rekord_mat (ctid znika). + """Bramka WHEN zna deleted_at -> UPDATE soft-delete odpala trigger, + ktory kasuje wiersz z bpp_rekord_mat (ctid znika). Odwrocenie ``test_update_samego_deleted_at_nie_odpala_triggera`` (nazwa - i asercja sprzed fazy 01). Dla ``bpp_wydawnictwo_ciagle`` samej to - dalej NIE dziala -- faza 01 dotknela wylacznie tabel ``*_Autor`` - (migracja 0489). Ten test ma pozostac xfail az do fazy 02; gdy - zazieleni sie SAM (bez zmiany kodu tego pliku), oznacza to niezamierzona - regresje zakresu -- zbadaj, co dotknelo bramki ``bpp_wydawnictwo_ciagle``. + i asercja sprzed fazy 01). Do fazy 02 test byl ``xfail(strict=True)``, + a kolumne ``deleted_at`` dokladal ALTER-em w transakcji testowej -- + faza 01 dotknela wylacznie tabel ``*_Autor``. Faza 02 (migracje 0496 + i 0497) dodala kolumne i bramke NAPRAWDE, wiec symulacja zniknela, + a marker ``xfail`` razem z nia. """ - # DDL PRZED utworzeniem rekordu: ALTER TABLE nie przejdzie, gdy tabela ma - # zakolejkowane zdarzenia wyzwalaczy z INSERT-a w tej samej transakcji. - with connection.cursor() as cur: - _dodaj_deleted_at(cur) - wc = any_ciagle(tytul_oryginalny="Bramka a soft-delete") pk = wc.pk @@ -125,27 +95,18 @@ def test_update_samego_deleted_at_odpala_trigger(): ) -@pytest.mark.xfail( - reason="faza 02 -- soft-delete publikacji (bpp_wydawnictwo_ciagle)", - strict=True, -) @pytest.mark.django_db def test_soft_delete_usuwa_wiersz_z_mat(): - """Docelowo: goly UPDATE ... SET deleted_at MA usunac wiersz z - bpp_rekord_mat (mechanizm #1 -- filtr widoku -- wystarcza, bo funkcja - refresh ma galaz kasujaca uruchamiana PRZED upsertem). + """Goly ``UPDATE ... SET deleted_at`` usuwa wiersz z bpp_rekord_mat. Odwrocenie ``test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat`` (nazwa i - asercja sprzed fazy 01). Jak wyzej: dla publikacji to faza 02, ten test - ma zostac xfail do tego czasu. - """ - # Cale DDL przed INSERT-em (patrz test wyzej). Filtr widoku jest juz - # aktywny przy tworzeniu rekordu, ale deleted_at jest wtedy NULL, wiec - # wiersz normalnie wchodzi do bpp_rekord_mat. - with connection.cursor() as cur: - _dodaj_deleted_at(cur) - _filtruj_widok_po_deleted_at(cur) + asercja sprzed fazy 01). Jak w tescie wyzej: do fazy 02 byl to + ``xfail(strict=True)`` z symulacja (ALTER + owijka widoku); faza 02 + dostarczyla filtr i galaz kasujaca naprawde (migracja 0497). + Filtr widoku jest aktywny juz przy tworzeniu rekordu, ale ``deleted_at`` + jest wtedy NULL, wiec wiersz normalnie wchodzi do bpp_rekord_mat. + """ wc = any_ciagle(tytul_oryginalny="Filtr widoku bez DELETE", rok=2020) pk = wc.pk diff --git a/src/bpp/tests/test_soft_delete/test_denorm_gate.py b/src/bpp/tests/test_soft_delete/test_denorm_gate.py index 33ccff871..650a739c7 100644 --- a/src/bpp/tests/test_soft_delete/test_denorm_gate.py +++ b/src/bpp/tests/test_soft_delete/test_denorm_gate.py @@ -13,6 +13,7 @@ """ import pytest +from model_bakery import baker @pytest.mark.django_db @@ -38,3 +39,73 @@ def test_soft_delete_autorstwa_odswieza_opis_biblio( "denorm-cache nieświeży — bramka WHEN triggera denorm nie zna " "deleted_at (denorm_always_only)" ) + + +# --- Faza 02: publikacje -------------------------------------------------- +# +# INWENTARYZACJA (2026-08-07). W całym kodzie produkcyjnym są DOKŁADNIE DWIE +# zależności ``@depend_on_related`` celujące w model publikacji — obie to +# ``("self", "wydawnictwo_nadrzedne")`` na ``Wydawnictwo_Zwarte`` +# (``wydawnictwo_zwarte.py:384`` i ``:434``), obie BEZ ``only=``. +# +# Brak ``only=`` oznacza, że denorm buduje bramkę WHEN ze WSZYSTKICH kolumn, +# więc ``deleted_at`` wchodzi do niej automatycznie — bez żadnej zmiany +# w kodzie. Potwierdzone niezależnie i przypadkiem: testy odwracalności +# migracji padały na ``CREATE TRIGGER ... WHEN (OLD."deleted_at" IS DISTINCT +# FROM ...)`` dla ``bpp_patent``, czyli denorm tę kolumnę widzi. +# +# Dlatego faza 02 NIE dokłada tu ``denorm_always_only`` ani list ``only=`` — +# byłby to martwy kod. Zostaje jedna rzecz warta przypięcia testem: +# semantyka poniżej. + + +@pytest.mark.django_db +def test_soft_delete_ksiazki_matki_nie_psuje_cache_rozdzialu(denorms): + """Soft-delete książki-matki NIE może uszkodzić rozdziału ani jego cache'u. + + Kaskada fazy 02 jest WĄSKA — zatrzymuje się na ``*_Autor`` rodzica i nie + rusza rozdziałów. Rozdział zostaje żywy, a jego opis nadal odwołuje się + do tytułu matki, bo soft-delete NIE zmienia tytułu: usunięty rekord dalej + istnieje i dalej ma swoje dane. + + To jest odpowiedź na pytanie postawione w planie fazy 02 („czy soft-delete + książki-matki ma unieważniać denorm-cache rozdziałów"): NIE ma czego + unieważniać. Gdyby cache miał się tu zmieniać, znaczyłoby to, że opis + rozdziału zależy od tego, czy matka jest w koszu — a nie zależy i nie + powinien. + + Faza 04 doda guard PROTECT, który prawdopodobnie w ogóle zablokuje + skasowanie książki mającej rozdziały. Do tego czasu ta ścieżka jest + osiągalna i ten test opisuje, co się na niej dzieje. + """ + from bpp.models import Wydawnictwo_Zwarte + + matka = baker.make(Wydawnictwo_Zwarte, tytul_oryginalny="Ksiazka Matka Zzz") + rozdzial = baker.make( + Wydawnictwo_Zwarte, + tytul_oryginalny="Rozdzial Podrzedny Yyy", + wydawnictwo_nadrzedne=matka, + ) + denorms.flush() + + rozdzial.refresh_from_db() + slug_przed = rozdzial.slug + # ``slugify_function`` BPP NIE obniża wielkości liter — porównujemy bez + # rozróżniania, żeby test nie zależał od tego szczegółu. + assert "matka" in slug_przed.lower(), ( + f"setup zepsuty — slug rozdzialu nie zawiera tytulu matki: {slug_przed!r}" + ) + + matka.delete() # soft + denorms.flush() + + assert Wydawnictwo_Zwarte.objects.filter(pk=rozdzial.pk).exists(), ( + "rozdzial znikl razem z matka — kaskada NIE jest waska" + ) + + rozdzial.refresh_from_db() + assert rozdzial.deleted_at is None, "rozdzial zostal soft-skasowany kaskadowo" + assert rozdzial.slug == slug_przed, ( + f"denorm-cache rozdzialu zmienil sie po skasowaniu matki: " + f"{slug_przed!r} -> {rozdzial.slug!r}" + ) diff --git a/src/bpp/tests/test_soft_delete/test_easyaudit_shim.py b/src/bpp/tests/test_soft_delete/test_easyaudit_shim.py new file mode 100644 index 000000000..11c58d578 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_easyaudit_shim.py @@ -0,0 +1,168 @@ +"""Shim naprawiający ``django-easy-audit`` na modelach soft-delete. + +Pełne uzasadnienie w ``src/bpp/easyaudit_shim.py``. Tu pilnujemy trzech +rzeczy: że shim jest wpięty, że naprawia realny objaw i że zniknie, gdy +przestanie być potrzebny. +""" + +import inspect +import weakref + +import pytest +from django.apps import apps +from django.db.models import signals +from model_bakery import baker + +from bpp.easyaudit_shim import DISPATCH_UID, WERSJA_UPSTREAM +from bpp.easyaudit_shim import pre_save as nasz_pre_save +from bpp.models import Wydawnictwo_Ciagle + +pytestmark = pytest.mark.skipif( + not apps.is_installed("easyaudit"), + reason="easyaudit wchodzi tylko w local.py/production.py", +) + + +def _odbiorcy_pod_uid(): + """Funkcje podpięte pod ``pre_save`` naszym ``dispatch_uid``. + + ``Signal.receivers`` (Django 5.2) trzyma krotki + ``((dispatch_uid, id_nadawcy), odbiorca, is_async)``, a ``odbiorca`` bywa + słabą referencją (``connect(weak=True)`` jest domyślne) — stąd + rozpakowanie i ewentualne rozwinięcie ``weakref``. + """ + znalezione = [] + for klucz, odbiorca, *_ in signals.pre_save.receivers: + if klucz[0] != DISPATCH_UID: + continue + if isinstance(odbiorca, weakref.ReferenceType): + odbiorca = odbiorca() + znalezione.append(odbiorca) + return znalezione + + +def test_shim_jest_wpiety_zamiast_handlera_upstreamu(): + """Nasz handler zajmuje ``dispatch_uid`` easyauditu, nie stoi obok niego. + + Gdyby stał obok, oba by się wykonywały i audyt dublowałby wpisy. + """ + odbiorcy = _odbiorcy_pod_uid() + assert odbiorcy, "nikt nie jest podpiety pod dispatch_uid easyauditu" + assert len(odbiorcy) == 1, ( + f"handler podpiety {len(odbiorcy)} razy — zainstaluj() nie jest " + f"idempotentne albo obok naszego stoi handler upstreamu" + ) + assert odbiorcy[0] is nasz_pre_save, ( + f"pod {DISPATCH_UID} siedzi {odbiorcy[0]!r}, a nie nasz shim — " + "sprawdz kolejnosc ready() aplikacji" + ) + + +@pytest.mark.django_db +def test_restore_publikacji_nie_wywala_sie_na_audycie(): + """OBJAW, dla którego shim powstał. + + Bez niego ``restore()`` leci ``DoesNotExist``: easyaudit szuka + poprzedniej wersji wiersza przez ``objects``, a ten w trakcie + przywracania jest jeszcze odfiltrowany jako skasowany. + """ + wc = baker.make(Wydawnictwo_Ciagle) + pk = wc.pk + wc.delete() + + wc.restore() + + assert Wydawnictwo_Ciagle.objects.filter(pk=pk).exists(), ( + "po restore publikacja nie wrocila do objects" + ) + + +@pytest.mark.django_db +def test_restore_zgloszenia_publikacji_czyli_blad_ZASTANY(): + """``Zgloszenie_Publikacji`` jest ``SoftDeleteModel`` OD DAWNA i również + figuruje w ``DJANGO_EASY_AUDIT_REGISTERED_CLASSES``. + + Ten test dowodzi, że naprawiamy błąd ZASTANY, a nie wyłącznie skutek + uboczny fazy 02: na ``dev`` (bez shimu) przywrócenie skasowanego + zgłoszenia jest niemożliwe. + + ``strict=False`` jest tu potrzebne z INNEGO powodu — pakietowy + ``restore()`` domyślnie wymaga, żeby każdy model powiązany też był + ``SoftDeleteModel`` (``Zgloszenie_Publikacji_Autor`` nie jest). To ten + sam inwariant, który dla naszych modeli domyka ``soft_delete.py``. + """ + from zglos_publikacje.models import Zgloszenie_Publikacji + + z = baker.make(Zgloszenie_Publikacji) + pk = z.pk + z.delete() + + Zgloszenie_Publikacji.global_objects.get(pk=pk).restore(strict=False) + + assert Zgloszenie_Publikacji.objects.filter(pk=pk).exists() + + +@pytest.mark.django_db +def test_audyt_dalej_dziala_czyli_shim_niczego_nie_wycisza(admin_user, rf, settings): + """Shim ma naprawiać lookup, a nie wyłączać audyt. + + Bez tego testu „zielono" znaczyłoby tylko tyle, że nic nie wybucha — + a najprostszym sposobem, żeby nic nie wybuchało, byłoby przestać + audytować. + + Zanim ten test cokolwiek mierzył, trzeba było zdjąć DWIE bramki, z + których żadna nie ma związku z shimem — obie po kolei dawały „zero + zdarzeń", czyli objaw nieodróżnialny od zepsutego shimu: + + 1. **Request z użytkownikiem.** BPP ma + ``DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS = + ["bpp.util.dont_log_anonymous_crud_events"]``, który świadomie + odrzuca zdarzenia bez zalogowanego użytkownika. + 2. **``settings.TEST``.** Bez niego easyaudit odkłada zapis zdarzenia + na ``transaction.on_commit``, a w teście transakcja jest + rollbackowana — callback nigdy nie odpala. To własny przełącznik + pakietu, przewidziany dokładnie na tę sytuację. + """ + from easyaudit.middleware.easyaudit import _thread_locals + from easyaudit.models import CRUDEvent + + settings.TEST = True + request = rf.get("/") + request.user = admin_user + _thread_locals.request = request + try: + wc = baker.make(Wydawnictwo_Ciagle) + przed = CRUDEvent.objects.count() + + wc.tytul_oryginalny = "Zmieniony tytul dla audytu" + wc.save() + + assert CRUDEvent.objects.count() > przed, ( + "zapis nie wygenerowal zdarzenia audytu — shim wycisza easyaudit" + ) + finally: + del _thread_locals.request + + +def test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny(): + """STRAŻNIK ODWROTNY: pada, gdy shim przestanie być potrzebny. + + Sprawdza, czy ``easyaudit`` NADAL pobiera poprzedni wiersz przez + ``objects``. Gdy upstream scali poprawkę (issue #175, otwarte od 2021), + ten test spadnie na czerwono — i to jest sygnał, żeby skasować + ``bpp/easyaudit_shim.py`` razem z wywołaniem w ``BppConfig.ready()``, + a nie żeby test „naprawić". + + Bez tego strażnika shim zostałby w kodzie na zawsze, cicho duplikując + logikę, którą pakiet już by miał poprawną. + """ + from easyaudit.signals import model_signals + + zrodlo = inspect.getsource(model_signals.pre_save) + + assert "sender.objects.get(pk=instance.pk)" in zrodlo, ( + f"easyaudit (kopiowano z {WERSJA_UPSTREAM}) NIE uzywa juz " + f"`sender.objects` — poprawka prawdopodobnie weszla upstream. " + f"SKASUJ bpp/easyaudit_shim.py i wywolanie zainstaluj() " + f"w BppConfig.ready(), zamiast poprawiac ten test." + ) diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py index 7ea033aff..743eb0fa5 100644 --- a/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py +++ b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py @@ -93,41 +93,45 @@ naprawa faktycznie łapie to, co stary kod przepuszczał. """ +import importlib import re import pytest from django.db import connection +from bpp.migration_util import viewdef + # Tabele objęte soft-delete (django-soft-delete SoftDeleteModel) — jedyne # źródło niezmiennika, świadomie utrzymywane jako lista, nie regex-owa # heurystyka po nazwie. # # Faza 01 (2026-08-06): trzy tabele through autorstwa. +# Faza 02 (2026-08-07): pięć tabel publikacji. +# +# Rozszerzenie o fazę 02 zrobiono NA STARCIE tej fazy, jeszcze przed +# napisaniem jakiegokolwiek DDL — i to jest cała wartość tego kanarka. +# Zamiast odkrywać konsumentów pojedynczo, przez awarie (tak przebiegła +# faza 01), dostaliśmy pełną listę 15 widoków jednym przebiegiem trwającym +# 19 sekund. Rdzeń (pg_depend) jest generyczny względem tej listy, więc było +# to dopisanie pięciu linijek i nic więcej. # -# Faza 02 MUSI DOPISAĆ TU 5 TABEL PUBLIKACJI: -# "bpp_wydawnictwo_ciagle", -# "bpp_wydawnictwo_zwarte", -# "bpp_patent", -# "bpp_praca_doktorska", -# "bpp_praca_habilitacyjna", -# To WCIĄŻ jest jednolinijkowa (a ściślej: pięciolinijkowa) zmiana W TYM -# MIEJSCU — rdzeń kanarka (pg_depend) jest generyczny względem listy i nie -# wymaga żadnych innych zmian kodu. ALE: po dopisaniu tych 5 tabel kanarek -# realnie zaczerwieni się na WIĘCEJ niż na 3 znane winowajców z raportu — -# złapie też ``bpp_kronika_praca_doktorska_view`` i -# ``bpp_kronika_praca_habilitacyjna_view`` (czytają swoje tabele bez -# żadnego filtra i, w odróżnieniu od pozostałych trzech ``bpp_kronika_*``, -# NIE są jeszcze zweryfikowane jako martwe ani wpisane do ``WYJATKI`` — -# tego akurat nikt jeszcze nie sprawdzał, bo dotąd nie było powodu). To -# NIE jest fałszywy alarm do wyciszenia odruchowo — to dokładnie ta klasa -# odkrycia, dla której ten kanarek istnieje. Faza 02 ma: -# a) albo zweryfikować, że są martwe (jak pozostałe trzy) i dopisać je -# do ``WYJATKI`` z uzasadnieniem i datą, -# b) albo je naprawić wzorcem z sekcji „Wzorzec naprawy" wyżej. +# Inwentaryzacja i to, co z niej wynikło: +# docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md +# +# Przewidywanie fazy 01 się sprawdziło: rozszerzenie listy złapało też +# ``bpp_kronika_praca_{doktorska,habilitacyjna}_view``, których żywotności +# nikt wcześniej nie badał. Nie zostały wyciszone wyjątkiem — okazały się +# martwe i cała siedmioelementowa rodzina ``bpp_kronika_*`` została +# skasowana migracją ``0499``. TABELE_SOFT_DELETE = [ "bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_zwarte_autor", "bpp_patent_autor", + "bpp_wydawnictwo_ciagle", + "bpp_wydawnictwo_zwarte", + "bpp_patent", + "bpp_praca_doktorska", + "bpp_praca_habilitacyjna", ] # Widoki-wyjątki: nazwa widoku -> uzasadnienie + data weryfikacji. @@ -145,26 +149,20 @@ # (metadane pakietowania, nie kod). Naprawianie widoku, którego nikt nie # czyta, byłoby czystym churnem — zostawione świadomie, do ewentualnego # DROP-u przy innej okazji. -WYJATKI = { - "bpp_kronika_wydawnictwo_ciagle_view": ( - "martwy widok (migracja 0001_widoki_kronika.sql) — zero " - "konsumentów w kodzie/szablonach/modelach; jedyne trafienia " - "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane " - "2026-08-06, naprawa-finalna-report.md faza 01." - ), - "bpp_kronika_wydawnictwo_zwarte_view": ( - "martwy widok (migracja 0001_widoki_kronika.sql) — zero " - "konsumentów w kodzie/szablonach/modelach; jedyne trafienia " - "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane " - "2026-08-06, naprawa-finalna-report.md faza 01." - ), - "bpp_kronika_patent_view": ( - "martwy widok (migracja 0001_widoki_kronika.sql) — zero " - "konsumentów w kodzie/szablonach/modelach; jedyne trafienia " - "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane " - "2026-08-06, naprawa-finalna-report.md faza 01." - ), -} +# PUSTE — i to jest stan docelowy, nie przeoczenie. +# +# Faza 01 trzymała tu trzy widoki ``bpp_kronika_*``, zweryfikowane jako +# martwe, ale niemożliwe do skasowania w tamtej fazie: zależały od nich dwa +# widoki nadrzędne, więc goły ``DROP VIEW`` nie przechodził, a ``CASCADE`` +# zabrałby po cichu także je. Faza 02 rozcięła cały graf jedną migracją +# (``0499_drop_kronika_views``) — po siódemce nie ma śladu, więc wyjątki +# straciły przedmiot. +# +# Jeśli kanarek zacznie zgłaszać NOWY widok, to jest FAIL do zbadania, a nie +# sygnał, żeby dopisać go tutaj. Wyjątek wpisuje się dopiero po weryfikacji +# martwoty (``pg_depend`` + grep po kodzie/szablonach/``Meta.db_table``) +# i zawsze z uzasadnieniem oraz datą. +WYJATKI = {} def _regex_dla_tabeli(tabela): @@ -439,20 +437,13 @@ def test_matcher_prefiks_tabeli_publikacji_kontra_tabela_autorstwa(tekst, oczeki assert widok_uzywa_tabeli(tekst, "bpp_wydawnictwo_ciagle") is oczekiwane -# --- Test dowodowy: symulacja fazy 02 ----------------------------------- +# --- Test dowodowy: fałszywa zieleń starego rdzenia --------------------- -# Pięć tabel publikacji, które faza 02 dopisze do TABELE_SOFT_DELETE. -# Zduplikowane tu ŚWIADOMIE (a nie zaimportowane) — ten test ma przetrwać -# NIEZMIENIONY moment, w którym faza 02 faktycznie dopisze je do stałej -# modułowej; do tego czasu musi budować symulację sam, niezależnie od -# TABELE_SOFT_DELETE. -_TABELE_PUBLIKACJI_FAZA_02 = [ - "bpp_wydawnictwo_ciagle", - "bpp_wydawnictwo_zwarte", - "bpp_patent", - "bpp_praca_doktorska", - "bpp_praca_habilitacyjna", -] +# Funkcje transformujące definicję widoku, wzięte WPROST z migracji 0497 — +# zamiast kopii, która rozjechałaby się przy pierwszej zmianie kształtu +# widoku. Nazwy modułów migracji zaczynają się od cyfry, więc zwykły import +# nie przejdzie. +_p0497 = importlib.import_module("bpp.migrations.0497_soft_delete_rekord_views") def _winowajcy_starym_algorytmem(widoki, tabele, wyjatki): @@ -474,66 +465,68 @@ def _winowajcy_starym_algorytmem(widoki, tabele, wyjatki): @pytest.mark.django_db -def test_symulacja_fazy_02_kanarek_lapie_widoki_ktore_stara_wersja_przepuszczala(): - """DOWÓD, nie test dzisiejszego stanu bazy: symuluje przyszły stan po - fazie 02 (soft-delete PUBLIKACJI), gdzie ``TABELE_SOFT_DELETE`` rośnie - o 5 tabel publikacji, i udowadnia, że naprawiony (pg_depend) rdzeń - kanarka łapie DOKŁADNIE to, co stary (tekstowy) rdzeń przepuszczał. - - ``TABELE_SOFT_DELETE`` w tym pliku NIE jest tu modyfikowane — budujemy - lokalną, rozszerzoną listę i wołamy oba warianty algorytmu na tych - samych, żywych danych z ``pg_views``/``pg_depend``. Ten test ma - zostać na stałe (nie jest tymczasowym scratchem): to on dowodzi, że - faza 02 dostanie od kanarka prawdziwą, kompletną listę winowajców - jednym przebiegiem, zamiast — jak faza 01 — odkrywać ich pojedynczo - przez awarie produkcyjne. - - Trzy konkretne pary (widok, tabela) niżej pochodzą z recenzji - ``kanarek-fix-report.md`` (żywy katalog, 2026-08-06): widoki - odziedziczyły tekstowo ``deleted_at`` z filtra po - ``bpp_wydawnictwo_ciagle_autor`` (ten sam JOIN), mimo że nie filtrują - wcale po WŁASNYM ``deleted_at`` tabeli ``bpp_wydawnictwo_ciagle``. +def test_stary_rdzen_przepuszczal_widok_dziedziczacy_deleted_at_z_joina(): + """DOWÓD, że przejście na ``pg_depend`` zamknęło REALNĄ dziurę. + + Poprzednia wersja tego testu (faza 01) była symulacją: brała trzy + widoki, które NAPRAWDĘ czytały wtedy ``bpp_wydawnictwo_ciagle`` bez + filtra, i pokazywała, że stary (tekstowy) rdzeń ich nie widzi. Faza 02 + te widoki naprawiła, więc dowód stracił materiał — nie dlatego, że + przestał być prawdziwy, tylko dlatego, że opierał się na usterce, której + już nie ma. + + Wersja obecna wytwarza tę usterkę SAMA, w transakcji testowej, więc + zostaje prawdziwa niezależnie od stanu bazy. Zdejmujemy z + ``bpp_wydawnictwo_ciagle_view`` filtr po WŁASNYM ``deleted_at`` + (dokładnie tę wstawkę, którą zakłada migracja ``0497``) i pytamy oba + warianty algorytmu. + + Sedno fałszywej zieleni: po zdjęciu filtra w definicji NADAL jest słowo + ``deleted_at`` — bo widok liczy ``count(...) FILTER (WHERE + bpp_wydawnictwo_ciagle_autor.deleted_at IS NULL)`` (migracja ``0494``). + Matcher tekstowy widzi więc „jest deleted_at, jest OK", mimo że filtr + dotyczy INNEJ tabeli — tej po drugiej stronie JOIN-a. Zależność + kolumnowa w ``pg_depend`` takiej pomyłki nie popełnia. """ - tabele_symulowane = TABELE_SOFT_DELETE + _TABELE_PUBLIKACJI_FAZA_02 - - oczekiwane_falszywa_zielen = { - ("bpp_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), - ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), - ( - "rozbieznosci_dyscyplin_rozbieznoscizrodelview", - "bpp_wydawnictwo_ciagle", - ), - } + widok = "bpp_wydawnictwo_ciagle_view" + tabela = "bpp_wydawnictwo_ciagle" + para = (widok, tabela) with connection.cursor() as cur: - widoki = _pobierz_widoki_publiczne(cur) - stary_winowajcy = set( - _winowajcy_starym_algorytmem(widoki, tabele_symulowane, WYJATKI) + oryginal = viewdef(cur, widok) + zepsuta = _p0497._bez_filtra(oryginal, tabela, widok) + + assert "deleted_at" in zepsuta, ( + "po zdjęciu filtra w definicji NIE MA już słowa 'deleted_at' — " + "cały dowód opiera się na tym, że ono zostaje (przez FILTER po " + "tabeli *_autor). Widok się zmienił, zaktualizuj dowód." ) - nowy_winowajcy = set(znajdz_winowajcow(cur, tabele=tabele_symulowane)) - - # Dowód #1 (ISTNIENIE DZIURY): stary algorytm PRZEPUSZCZAŁ te 3 pary — - # nie ma ich wśród jego winowajców, mimo że widoki naprawdę czytają - # bpp_wydawnictwo_ciagle bez filtra po jej deleted_at. To jest - # regression-proof samego buga opisanego w brief-ie/raporcie: jeśli - # ten assert kiedyś zacznie padać, znaczy że któryś z tych widoków się - # zmienił (np. ktoś usunął z jego tekstu literalne "deleted_at") i - # test wymaga aktualizacji dowodu, nie że naprawa przestała działać. - zlapane_przez_stary = stary_winowajcy & oczekiwane_falszywa_zielen - assert not zlapane_przez_stary, ( - "Stary (tekstowy) algorytm złapał widoki, które wg raportu miał " - f"PRZEPUSZCZAĆ (fałszywa zieleń): {zlapane_przez_stary}. Dane " - "widoki się zmieniły — dowód wymaga aktualizacji, sprawdź " - "kanarek-fix-report.md." + + try: + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {zepsuta}") + + widoki = _pobierz_widoki_publiczne(cur) + stary = set( + _winowajcy_starym_algorytmem(widoki, TABELE_SOFT_DELETE, WYJATKI) + ) + nowy = set(znajdz_winowajcow(cur, tabele=TABELE_SOFT_DELETE)) + finally: + # Przywracamy NIEZALEŻNIE od wyniku asercji (rollback transakcji + # testowej i tak by to cofnął, ale test ma dowodzić całego cyklu). + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {oryginal}") + + # Dowód #1 — ISTNIENIE DZIURY: stary rdzeń przepuszcza zepsuty widok. + assert para not in stary, ( + "Stary (tekstowy) rdzeń ZŁAPAŁ widok bez filtra, choć miał go " + "przepuścić — czyli fałszywa zieleń, którą ten test dokumentuje, " + "już nie zachodzi. Sprawdź, czy widok nadal zawiera 'deleted_at' " + "wyłącznie przez FILTER po tabeli *_autor." ) - # Dowód #2 (NAPRAWA DZIAŁA): nowy (pg_depend) algorytm łapie wszystkie - # 3 pary, których stary nie widział. - niezlapane_przez_nowy = oczekiwane_falszywa_zielen - nowy_winowajcy - assert not niezlapane_przez_nowy, ( - "Nowy (pg_depend) algorytm NIE złapał widoków z symulacji fazy " - f"02, które miał złapać: {niezlapane_przez_nowy}. Naprawa nie " - "działa albo się cofnęła." + # Dowód #2 — NAPRAWA DZIAŁA: rdzeń oparty o pg_depend go łapie. + assert para in nowy, ( + "Rdzeń oparty o pg_depend NIE złapał widoku, który czyta " + f"{tabela} bez zależności od jej deleted_at. Naprawa się cofnęła." ) diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_orm.py b/src/bpp/tests/test_soft_delete/test_kanarek_orm.py index f8ba106b4..ecacf01ab 100644 --- a/src/bpp/tests/test_soft_delete/test_kanarek_orm.py +++ b/src/bpp/tests/test_soft_delete/test_kanarek_orm.py @@ -78,9 +78,18 @@ #: Katalog ``src/`` — korzeń skanowania. KORZEN = pathlib.Path(__file__).resolve().parents[3] -#: Nazwy relacji do trzech soft-delete'owanych through-modeli. ``autorzy_set`` -#: to strona publikacji; pozostałe to nazwy odwrotne od strony ``Autor``, +#: Nazwy relacji do soft-delete'owanych modeli. +#: +#: Faza 01 — trzy through-modele autorstwa. ``autorzy_set`` to strona +#: publikacji; pozostałe to nazwy odwrotne od strony ``Autor``, #: ``Jednostka`` i słowników (brak ``related_name`` → nazwa modelu małymi). +#: +#: Faza 02 — pięć modeli publikacji. Tu ryzyko jest odwrotnie skierowane: +#: zapytania startujące OD publikacji są bezpieczne, bo ``objects`` to +#: ``BppSoftDeleteManager`` i sam filtruje ``deleted_at``. Przecieka +#: dołączenie DO publikacji od strony słownika/relacji (``Zrodlo``, +#: ``Wydawca``, ``Charakter_Formalny``, ``Autor``…), gdzie żaden manager +#: się nie włącza — i to łapią nazwy niżej. RELACJE = ( "autorzy_set", "wydawnictwo_ciagle_autor", @@ -88,6 +97,16 @@ "patent_autor", ) +#: Relacje do pięciu modeli PUBLIKACJI (faza 02). Trzymane OSOBNO od +#: ``RELACJE``, bo pilnuje ich osobny test — patrz ``xfail`` niżej. +RELACJE_PUBLIKACJI = ( + "wydawnictwo_ciagle", + "wydawnictwo_zwarte", + "patent", + "praca_doktorska", + "praca_habilitacyjna", +) + #: Wywołania, w których nazwa argumentu / literał napisowy jest ścieżką ORM. #: ``prefetch_related``/``select_related``/``Prefetch`` CELOWO poza listą. WYWOLANIA_ORM = frozenset( @@ -158,13 +177,13 @@ def _pliki_produkcyjne(): yield sciezka, wzgledna -def _jest_sciezka_relacji(tekst: str) -> bool: +def _jest_sciezka_relacji(tekst: str, relacje=RELACJE) -> bool: """Czy ``tekst`` (nazwa kwargu albo literał) jest ścieżką ORM do through-modelu? ``autorzy_set__autor`` tak, ``patent_autor`` (nazwa endpointu w routerze) — tylko gdy stoi samo albo z ``__``.""" if not isinstance(tekst, str): return False - for relacja in RELACJE: + for relacja in relacje: if tekst == relacja or tekst.startswith(relacja + "__"): return True if "__" + relacja + "__" in tekst or tekst.endswith("__" + relacja): @@ -180,19 +199,19 @@ def _nazwa_wywolania(wezel: ast.Call) -> str | None: return None -def _wywolanie_dotyka_relacji(wezel: ast.Call) -> bool: +def _wywolanie_dotyka_relacji(wezel: ast.Call, relacje=RELACJE) -> bool: if _nazwa_wywolania(wezel) not in WYWOLANIA_ORM: return False for kw in wezel.keywords: - if kw.arg and _jest_sciezka_relacji(kw.arg): + if kw.arg and _jest_sciezka_relacji(kw.arg, relacje): return True for arg in wezel.args: - if isinstance(arg, ast.Constant) and _jest_sciezka_relacji(arg.value): + if isinstance(arg, ast.Constant) and _jest_sciezka_relacji(arg.value, relacje): return True return False -def _podejrzane_instrukcje(zrodlo: str, wzgledna: str): +def _podejrzane_instrukcje(zrodlo: str, wzgledna: str, relacje=RELACJE): """Zwraca [(linia, pierwsza linia instrukcji)] — instrukcje z wywołaniem ORM po relacji do ``*_Autor``, bez ``deleted_at``, spoza ``DOZWOLONE``.""" try: @@ -224,10 +243,10 @@ def _podejrzane_instrukcje(zrodlo: str, wzgledna: str): literaly = [ w.value for w in ast.walk(wezel.iter) if isinstance(w, ast.Constant) ] - if not any(_jest_sciezka_relacji(x) for x in literaly): + if not any(_jest_sciezka_relacji(x, relacje) for x in literaly): continue elif not any( - isinstance(w, ast.Call) and _wywolanie_dotyka_relacji(w) + isinstance(w, ast.Call) and _wywolanie_dotyka_relacji(w, relacje) for w in ast.walk(wezel) ): continue @@ -243,6 +262,18 @@ def _podejrzane_instrukcje(zrodlo: str, wzgledna: str): return wynik +def _skanuj(relacje): + """``[plik:linia: fragment]`` dla wszystkich podejrzanych instrukcji.""" + znaleziska = [] + for sciezka, wzgledna in _pliki_produkcyjne(): + zrodlo = sciezka.read_text(encoding="utf-8") + if not any(relacja in zrodlo for relacja in relacje): + continue + for lineno, fragment in _podejrzane_instrukcje(zrodlo, wzgledna, relacje): + znaleziska.append(f"{wzgledna}:{lineno}: {fragment}") + return znaleziska + + def test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at(): """Żadne produkcyjne wywołanie ORM nie JOIN-uje ``*_Autor`` bez ``deleted_at``. @@ -252,13 +283,7 @@ def test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at(): semantycznej — dowodzi tylko, że autor kodu w ogóle pomyślał o soft-delete. Semantyki pilnują testy w ``test_orm_wyciek_join.py``. """ - znaleziska = [] - for sciezka, wzgledna in _pliki_produkcyjne(): - zrodlo = sciezka.read_text(encoding="utf-8") - if not any(relacja in zrodlo for relacja in RELACJE): - continue - for lineno, fragment in _podejrzane_instrukcje(zrodlo, wzgledna): - znaleziska.append(f"{wzgledna}:{lineno}: {fragment}") + znaleziska = _skanuj(RELACJE) assert not znaleziska, ( "JOIN po soft-delete'owanym through-modelu bez predykatu " @@ -269,6 +294,48 @@ def test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at(): ) +@pytest.mark.xfail( + reason="faza 03 — audyt wywolan ORM w imporcie/dedup/PBN", + strict=True, +) +def test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at(): + """To samo, ale dla pięciu modeli PUBLIKACJI (faza 02). + + ⚠️ ``xfail(strict=True)`` JEST TU CELOWY i ma się utrzymać do fazy 03. + + Ryzyko jest skierowane odwrotnie niż przy autorstwach: zapytania + startujące OD publikacji są bezpieczne, bo ``objects`` to + ``BppSoftDeleteManager``. Przecieka dołączenie DO publikacji od strony + słownika (``Zrodlo``, ``Charakter_Formalny``…), gdzie żaden manager się + nie włącza. + + Faza 02 zamknęła warstwę BAZODANOWĄ (modele, widoki, triggery, + ograniczenia, menedżery). Audyt wywołań ORM w imporcie/dedup/PBN plan + przypisuje jawnie FAZIE 03 — dlatego ten test dokumentuje dług, zamiast + go po cichu ukrywać albo wymuszać naprawę poza zakresem fazy. + + Pełna, przetriagowana lista (10 prawdziwych wycieków + 4 fałszywe + trafienia, z uzasadnieniem każdego) jest w + ``docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md`` — + faza 03 nie musi jej odtwarzać. + + ``strict=True`` znaczy, że gdy faza 03 naprawi te miejsca, test zacznie + PADAĆ jako XPASS i zmusi do zdjęcia markera. Bez ``strict`` naprawa + przeszłaby niezauważona, a kanarek zostałby wyłączony na zawsze — tę + dokładnie pułapkę odnotowuje handoff fazy 01 („pusty wynik ≠ + potwierdzenie"). + """ + znaleziska = _skanuj(RELACJE_PUBLIKACJI) + + assert not znaleziska, ( + "JOIN po soft-delete'owanej PUBLIKACJI bez predykatu " + "``deleted_at``:\n " + + "\n ".join(znaleziska) + + "\n\nTriage: docs/superpowers/reviews/" + "2026-08-07-faza-02-inwentaryzacja-orm.md" + ) + + def test_kanarek_orm_wykrywa_swiezy_wyciek(): """Meta-test: kanarek naprawdę łapie wzorce, a nie zawsze przechodzi. diff --git a/src/bpp/tests/test_soft_delete/test_kronika_usunieta.py b/src/bpp/tests/test_soft_delete/test_kronika_usunieta.py new file mode 100644 index 000000000..bceb4e614 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_kronika_usunieta.py @@ -0,0 +1,65 @@ +"""Rodzina ``bpp_kronika_*`` została skasowana (migracja ``0499``). + +Siedem martwych widoków, kasowanych jednym cięciem, bo graf zależności +(``bpp_kronika_view`` <- ``bpp_kronika_all_unsorted_view`` <- pięć liści) +nie daje się rozciąć na raty bez ``CASCADE``. +""" + +import pytest +from django.core.management import call_command +from django.db import connection + +RODZINA = [ + "bpp_kronika_view", + "bpp_kronika_all_unsorted_view", + "bpp_kronika_wydawnictwo_ciagle_view", + "bpp_kronika_wydawnictwo_zwarte_view", + "bpp_kronika_patent_view", + "bpp_kronika_praca_doktorska_view", + "bpp_kronika_praca_habilitacyjna_view", +] + + +def _istniejace(cur): + cur.execute( + "SELECT viewname FROM pg_views " + "WHERE schemaname = 'public' AND viewname = ANY(%s)", + [RODZINA], + ) + return {r[0] for r in cur.fetchall()} + + +@pytest.mark.django_db +def test_rodzina_kronika_nie_istnieje(): + with connection.cursor() as cur: + zostale = _istniejace(cur) + assert not zostale, f"widoki kronika nadal istnieja: {sorted(zostale)}" + + +@pytest.mark.django_db +def test_0499_odwracalna(): + """``backward`` odtwarza całą siódemkę z sidecara ``.sql``. + + Sidecar był generowany maszynowo z ``pg_get_viewdef()``, ale to nie + dowodzi, że da się go ODTWORZYĆ — kolejność ``CREATE VIEW`` musi + respektować graf zależności, a ``pg_get_viewdef`` nie zwraca jej sam + z siebie. Ten test jest jedynym sprawdzeniem tej kolejności. + """ + try: + call_command("migrate", "bpp", "0498", verbosity=0) + + with connection.cursor() as cur: + odtworzone = _istniejace(cur) + brakujace = set(RODZINA) - odtworzone + assert not brakujace, ( + f"backward nie odtworzyl: {sorted(brakujace)} — sprawdz kolejnosc " + f"CREATE VIEW w sidecarze 0499_drop_kronika_views.sql" + ) + finally: + # ZAWSZE wracamy na szczyt: baza testowa jest wspoldzielona przez + # caly przebieg, wiec zostawienie jej z odtworzona kronika psuloby + # test_rodzina_kronika_nie_istnieje w kolejnych plikach. + call_command("migrate", "bpp", verbosity=0) + + with connection.cursor() as cur: + assert not _istniejace(cur), "po ponownym forward kronika nie znikla" diff --git a/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py b/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py index 94d6c6d67..54066b76b 100644 --- a/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py +++ b/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py @@ -59,7 +59,7 @@ def _stan_ddl(cur): @pytest.mark.django_db -def test_migracja_0489_odwracalna(): +def test_migracja_0489_odwracalna(bez_reinstalacji_denorma): with connection.cursor() as cur: przed = _stan_ddl(cur) diff --git a/src/bpp/tests/test_soft_delete/test_migracja_0497_rewers.py b/src/bpp/tests/test_soft_delete/test_migracja_0497_rewers.py new file mode 100644 index 000000000..ef94b22bc --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_migracja_0497_rewers.py @@ -0,0 +1,74 @@ +"""Odwracalność ``0497``: filtr soft-delete w widokach publikacji. + +``0497.backward`` nie odtwarza definicji widoków z plików ``.sql`` (jak robi +to ``0489.backward`` dla ścieżki autorstwa), bo widoki publikacji były +redefiniowane w kilku migracjach — m.in. ``0494`` dokładająca +``count(...) FILTER`` — i nie ma jednego pliku źródłowego. Zamiast tego +usuwa DOKŁADNIE ten tekst, który wstawiła ``_z_filtrem``. + +To rozwiązanie symetryczne, ale i kruche: literówka w usuwanym fragmencie +albo zmiana kształtu definicji sprawia, że ``_bez_filtra`` albo rzuci +wyjątkiem, albo (gorzej) zostawi widok z filtrem. Ten test jest jedynym +dowodem, że cykl forward->backward->forward faktycznie domyka się na żywym +katalogu. + +Test zjeżdża do ``0496`` i wraca do NAJNOWSZEJ migracji ``bpp`` +(``migrate bpp`` bez numeru) — powrót „do 0497" byłby błędem, bo baza +testowa jest współdzielona przez cały przebieg i migracje późniejsze niż cel +zostałyby niezastosowane, psując kolejne testy. +""" + +import pytest +from django.core.management import call_command +from django.db import connection + +from bpp.tests.test_soft_delete.test_kanarek_katalogowy import ( + _widoki_zalezne_od_deleted_at, +) + +TABELE = [ + "bpp_wydawnictwo_ciagle", + "bpp_wydawnictwo_zwarte", + "bpp_patent", + "bpp_praca_doktorska", + "bpp_praca_habilitacyjna", +] + +# 7 widokow: 5 rekordowych + 2 autorskie (doktorat/habilitacja maja autora +# na wlasnym wierszu, wiec ich widoki *_autorzy tez czytaja tabele publikacji) +OCZEKIWANE_PARY = {(t + "_view", t) for t in TABELE} | { + (t + "_autorzy", t) for t in ("bpp_praca_doktorska", "bpp_praca_habilitacyjna") +} + + +def _pary_zalezne(): + with connection.cursor() as cur: + return _widoki_zalezne_od_deleted_at(cur, TABELE) + + +@pytest.mark.django_db +def test_0497_odwracalna(bez_reinstalacji_denorma): + # Stan wyjsciowy: wszystkie 7 widokow zalezy od deleted_at swojej tabeli. + assert OCZEKIWANE_PARY <= _pary_zalezne(), ( + "stan przed testem juz jest niepoprawny — migracja 0497 nie jest " + "zastosowana albo widoki zostaly zmienione" + ) + + try: + call_command("migrate", "bpp", "0496", verbosity=0) + + po_cofnieciu = _pary_zalezne() + nadmiarowe = OCZEKIWANE_PARY & po_cofnieciu + assert not nadmiarowe, ( + f"backward NIE zdjal filtra z: {sorted(nadmiarowe)} — " + "_bez_filtra nie usunelo wstawki" + ) + finally: + # ZAWSZE wracamy do najnowszej, nawet gdy asercja wyzej padnie — + # inaczej zostawiamy wspoldzielona baze testowa w stanie sprzed 0497 + # i psujemy kazdy kolejny test w przebiegu. + call_command("migrate", "bpp", verbosity=0) + + assert OCZEKIWANE_PARY <= _pary_zalezne(), ( + "po ponownym forward filtr nie wrocil do wszystkich 7 widokow" + ) diff --git a/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py b/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py index dbef53e48..aaa6aee7b 100644 --- a/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py +++ b/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py @@ -73,7 +73,7 @@ def _sprawdz_stan_po(cur): @pytest.mark.django_db -def test_migracje_0490_0493_odwracalne(): +def test_migracje_0490_0493_odwracalne(bez_reinstalacji_denorma): with connection.cursor() as cur: _sprawdz_stan_po(cur) diff --git a/src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py b/src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py new file mode 100644 index 000000000..25c490ba3 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py @@ -0,0 +1,251 @@ +"""Kontrakt DDL po fazie 02: widok filtruje, funkcja kasuje, bramka przepuszcza. + +Odpowiednik ``test_views_sql.py`` (faza 01, ścieżka autorstwa) dla ścieżki +PUBLIKACJI. Podział ról jest ten sam: testy „substringowe" +(``pg_get_viewdef``/``pg_get_functiondef``/``pg_get_triggerdef``) pilnują +wyłącznie tego, że migracja ``0497`` w ogóle się odbyła, a wyrocznią +zachowania są testy SEMANTYCZNE na końcu pliku. + +Najważniejszy z nich to ``test_publikacja_bez_zywych_autorow_ZOSTAJE``. Broni +przed „poprawką", która wygląda naturalnie i niszczy dane: dopisaniem +``deleted_at IS NULL`` tabeli ``*_autor`` do ``WHERE``/``ON`` widoku +rekordowego. Taki warunek degeneruje ``LEFT JOIN`` do ``INNER JOIN``, więc +publikacja, której WSZYSTKICH autorów soft-skasowano, wypada z +``bpp_rekord_mat`` — czyli znika z całego serwisu, mimo że sama nie została +skasowana. Testy substringowe tego NIE łapią: ``deleted_at`` jest w definicji +w obu wariantach. +""" + +import pytest +from django.db import connection +from model_bakery import baker + +from bpp.models import Jednostka, Praca_Doktorska, Wydawnictwo_Ciagle +from bpp.tests.test_soft_delete.test_kanarek_katalogowy import ( + _widoki_zalezne_od_deleted_at, +) + +# (tabela publikacji, czy autor lezy na jej wierszu) +PUBLIKACJE = [ + ("bpp_wydawnictwo_ciagle", False), + ("bpp_wydawnictwo_zwarte", False), + ("bpp_patent", False), + ("bpp_praca_doktorska", True), + ("bpp_praca_habilitacyjna", True), +] + +WIDOKI = [(t + "_view", t) for t, _ in PUBLIKACJE] + [ + (t + "_autorzy", t) for t, autor_na_wierszu in PUBLIKACJE if autor_na_wierszu +] + +FUNKCJE = [ + ("bpp_refresh_rekord_wydawnictwo_ciagle", False), + ("bpp_refresh_rekord_wydawnictwo_zwarte", False), + ("bpp_refresh_rekord_patent", False), + ("bpp_refresh_rekord_praca_doktorska", True), + ("bpp_refresh_rekord_praca_habilitacyjna", True), +] + +TRIGGERY = [(t, f"{t}_cache_upd") for t, _ in PUBLIKACJE] + + +# --- kontrakt DDL (dowód, że migracja się odbyła) ----------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize("widok,tabela", WIDOKI) +def test_widok_zalezy_od_wlasnego_deleted_at(widok, tabela): + """Widok musi zależeć od kolumny ``deleted_at`` WŁASNEJ tabeli. + + Pytamy ``pg_depend`` (przez helper kanarka katalogowego), a NIE tekst + ``pg_get_viewdef``, z dwóch niezależnych powodów: + + 1. Samo ``"deleted_at" in defn`` daje FAŁSZYWĄ ZIELEŃ: widoki rekordowe + trzech typów z through-modelem zawierają ``count(...) FILTER (WHERE + _autor.deleted_at IS NULL)`` z migracji ``0494``, więc to + słowo jest w nich obecne niezależnie od tej fazy. + 2. Dopisanie kwalifikacji (``.deleted_at``) też nie działa: + w widokach rodziny B (jedna tabela w zasięgu, bez JOIN-a) Postgres + NORMALIZUJE predykat do gołego ``deleted_at IS NULL`` — kwalifikacja + jest zbędna, więc ``pg_get_viewdef`` jej nie zwraca. Test tekstowy + padałby mimo poprawnego filtra. + + Zależność kolumnowa w katalogu jest odporna na jedno i drugie. + """ + with connection.cursor() as cur: + zalezne = _widoki_zalezne_od_deleted_at(cur, [tabela]) + assert (widok, tabela) in zalezne, ( + f"{widok} nie zalezy od kolumny {tabela}.deleted_at — " + f"filtr soft-delete nie zostal wpiety" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("fn,autor_na_wierszu", FUNKCJE) +def test_funkcja_refresh_ma_galaz_kasujaca(fn, autor_na_wierszu): + """Bez DELETE odfiltrowanie z widoku jest no-opem (upsert nic nie usuwa).""" + with connection.cursor() as cur: + cur.execute("SELECT pg_get_functiondef(%s::regproc)", [fn]) + src = cur.fetchone()[0] + assert "NEW.deleted_at IS NOT NULL" in src, f"{fn}: brak gałęzi kasującej" + assert "DELETE FROM bpp_rekord_mat" in src, f"{fn}: brak DELETE z rekord_mat" + if autor_na_wierszu: + # Autor leży na wierszu publikacji, więc soft-delete publikacji musi + # wyczyścić OBIE tabele _mat. Uwaga na różne nazwy klucza: + # bpp_rekord_mat.id vs bpp_autorzy_mat.rekord_id. + assert "DELETE FROM bpp_autorzy_mat" in src, ( + f"{fn}: autor na wierszu, a brak DELETE z autorzy_mat" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("tabela,trigger", TRIGGERY) +def test_bramka_when_zna_deleted_at(tabela, trigger): + """Bez deleted_at w bramce UPDATE soft-delete nie dochodzi do funkcji.""" + with connection.cursor() as cur: + cur.execute( + "SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t " + "WHERE t.tgrelid = %s::regclass AND t.tgname = %s", + [tabela, trigger], + ) + row = cur.fetchone() + assert row is not None, f"brak triggera {trigger}" + assert "deleted_at" in row[0], f"{trigger}: bramka WHEN nie zna deleted_at" + + +# --- testy semantyczne (wyrocznia zachowania) --------------------------- + + +def _ct(cur, model): + cur.execute( + "SELECT id FROM django_content_type WHERE app_label='bpp' AND model=%s", + [model], + ) + return cur.fetchone()[0] + + +def _jest_w_rekord_mat(cur, model, pk): + cur.execute( + "SELECT count(*) FROM bpp_rekord_mat WHERE id = ARRAY[%s, %s]::integer[]", + [_ct(cur, model), pk], + ) + return cur.fetchone()[0] > 0 + + +def _liczba_w_autorzy_mat(cur, model, pk): + cur.execute( + "SELECT count(*) FROM bpp_autorzy_mat " + "WHERE rekord_id = ARRAY[%s, %s]::integer[]", + [_ct(cur, model), pk], + ) + return cur.fetchone()[0] + + +@pytest.mark.django_db +def test_soft_delete_publikacji_znika_z_rekord_mat_restore_wraca(): + jednostka = baker.make(Jednostka) + autor = baker.make("bpp.Autor", imiona="Jan", nazwisko="Kowalski") + wc = baker.make(Wydawnictwo_Ciagle, szczegoly="sz", uwagi="u") + wc.dodaj_autora(autor, jednostka) + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "publikacja nie trafiła do bpp_rekord_mat jeszcze przed kasowaniem " + "— setup testu jest zepsuty, nie sprawdzamy niczego" + ) + + wc.delete() + + with connection.cursor() as cur: + assert not _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "soft-skasowana publikacja została w bpp_rekord_mat" + ) + assert _liczba_w_autorzy_mat(cur, "wydawnictwo_ciagle", wc.pk) == 0, ( + "autorstwa soft-skasowanej publikacji zostały w bpp_autorzy_mat" + ) + + wc.restore() + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "po restore publikacja nie wróciła do bpp_rekord_mat" + ) + assert _liczba_w_autorzy_mat(cur, "wydawnictwo_ciagle", wc.pk) == 1, ( + "po restore autorstwo nie wróciło do bpp_autorzy_mat" + ) + + +@pytest.mark.django_db +def test_publikacja_bez_zywych_autorow_ZOSTAJE(): + """REGRESJA na pułapkę agregatu — patrz docstring modułu. + + Publikacja, której WSZYSTKIE autorstwa są soft-skasowane, ma zostać w + ``bpp_rekord_mat`` z ``liczba_autorow = 0``. Sama publikacja nie została + skasowana, więc jej zniknięcie z serwisu byłoby utratą danych. + """ + jednostka = baker.make(Jednostka) + autor = baker.make("bpp.Autor", imiona="Jan", nazwisko="Jedyny") + wc = baker.make(Wydawnictwo_Ciagle, szczegoly="sz", uwagi="u") + wca = wc.dodaj_autora(autor, jednostka) + + wca.delete() # kasujemy AUTORSTWO, nie publikację + + # WYROCZNIĄ JEST WIDOK, NIE CACHE. Wiersz w ``bpp_rekord_mat`` pochodzi + # z chwili utworzenia publikacji, a soft-delete autorstwa odpala trigger + # ścieżki autorstwa (``bpp_autorzy_mat``) — ``bpp_rekord_mat`` nie jest + # wtedy przeliczany. Sprawdzanie samego cache'u przepuszczało zepsuty + # widok: nieświeży wiersz maskował degenerację LEFT JOIN-a (potwierdzone + # mutacyjnie przy pisaniu tego testu). + with connection.cursor() as cur: + cur.execute( + "SELECT liczba_autorow FROM bpp_wydawnictwo_ciagle_view " + "WHERE object_id_raw = %s", + [wc.pk], + ) + wiersz = cur.fetchone() + assert wiersz is not None, ( + "publikacja bez żywych autorów WYPADŁA z bpp_wydawnictwo_ciagle_view " + "— LEFT JOIN zdegenerował do INNER JOIN (utrata danych!)" + ) + assert wiersz[0] == 0, f"liczba_autorow = {wiersz[0]}, oczekiwano 0" + + # Dopiero teraz cache: wymuszamy przeliczenie rekordu i sprawdzamy, że + # projekcja widoku faktycznie do niego dociera. + wc.save() + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "publikacja bez żywych autorów WYPADŁA z bpp_rekord_mat po przeliczeniu" + ) + cur.execute( + "SELECT liczba_autorow FROM bpp_rekord_mat " + "WHERE id = ARRAY[%s, %s]::integer[]", + [_ct(cur, "wydawnictwo_ciagle"), wc.pk], + ) + assert cur.fetchone()[0] == 0, "liczba_autorow w cache nie zeszła do zera" + + +@pytest.mark.django_db +def test_soft_delete_doktoratu_czysci_obie_tabele_mat(): + """Doktorat ma autora na WŁASNYM wierszu, więc jego soft-delete musi + wyczyścić zarówno ``bpp_rekord_mat``, jak i ``bpp_autorzy_mat`` — to + jedyne dwa modele, dla których gałąź kasująca dotyka obu tabel.""" + pd = baker.make(Praca_Doktorska, szczegoly="sz", uwagi="u") + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "praca_doktorska", pd.pk), ( + "doktorat nie trafił do bpp_rekord_mat — setup testu zepsuty" + ) + assert _liczba_w_autorzy_mat(cur, "praca_doktorska", pd.pk) == 1, ( + "autor doktoratu nie trafił do bpp_autorzy_mat — setup zepsuty" + ) + + pd.delete() + + with connection.cursor() as cur: + assert not _jest_w_rekord_mat(cur, "praca_doktorska", pd.pk), ( + "soft-skasowany doktorat został w bpp_rekord_mat" + ) + assert _liczba_w_autorzy_mat(cur, "praca_doktorska", pd.pk) == 0, ( + "autor soft-skasowanego doktoratu został w bpp_autorzy_mat" + ) diff --git a/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py b/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py index a2764f40a..c45443a47 100644 --- a/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py +++ b/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py @@ -19,9 +19,24 @@ """ import pytest +from django.db import connection from bpp.models.cache import Rekord from bpp.models.sumy_views import Sumy +from bpp.tests.test_soft_delete.test_kanarek_katalogowy import ( + _widoki_zalezne_od_deleted_at, +) + +# (widok sum, tabela PUBLIKACJI) — wymiar domkniety w fazie 02 (bpp.0498). +# Wymiar AUTORSTWA (0495) obejmowal tylko pierwsze trzy, bo pozostale dwa +# nie maja tabeli *_autor. +SUMY_PUBLIKACJE = [ + ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), + ("bpp_nowe_sumy_wydawnictwo_zwarte_view", "bpp_wydawnictwo_zwarte"), + ("bpp_nowe_sumy_patent_view", "bpp_patent"), + ("bpp_nowe_sumy_praca_doktorska_view", "bpp_praca_doktorska"), + ("bpp_nowe_sumy_praca_habilitacyjna_view", "bpp_praca_habilitacyjna"), +] @pytest.mark.django_db @@ -92,3 +107,77 @@ def test_sumy_rankingu_wracaja_po_restore(wydawnictwo_ciagle_z_autorem): Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore() assert Sumy.objects.filter(autor_id=autor_id).exists() + + +# --- wymiar PUBLIKACJI (faza 02, migracja 0498) ------------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize("widok,tabela", SUMY_PUBLIKACJE) +def test_sumy_zaleza_od_deleted_at_publikacji(widok, tabela): + """Kontrakt DDL: dowód, że ``0498`` w ogóle się wykonała. + + Pytamy ``pg_depend`` o zależność KOLUMNOWĄ, nie tekst definicji — trzy + z tych widoków zawierają już ``deleted_at`` z migracji ``0495`` (wymiar + autorstwa), więc test substringowy dawałby dla nich fałszywą zieleń. + """ + with connection.cursor() as cur: + assert (widok, tabela) in _widoki_zalezne_od_deleted_at(cur, [tabela]), ( + f"{widok} nie zalezy od kolumny {tabela}.deleted_at" + ) + + +@pytest.mark.django_db +def test_sumy_pomijaja_soft_deletowana_publikacje_IZOLOWANE( + wydawnictwo_ciagle_z_autorem, +): + """Wyrocznia dla SAMEGO wymiaru publikacji. + + ⚠️ Kasujemy publikację SUROWYM UPDATE-em, a nie ``wc.delete()``, i jest + to celowe. ``delete()`` kaskaduje na autorstwa, więc wiersz zniknąłby + z sum z DWÓCH niezależnych powodów: przez filtr publikacji (``0498``, + czyli to, co ten test ma sprawdzać) ORAZ przez filtr autorstwa + (``0495``). Test przechodziłby wtedy nawet po cofnięciu ``0498`` — czyli + nie byłby wyrocznią niczego. + + Surowy SQL omija też gate na ``.update(deleted_at=...)`` + (``BppSoftDeleteQuerySet``), co poza testem izolującym wymiar jest + zakazane. + """ + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + + assert Sumy.objects.filter(autor_id=autor_id).exists() + + with connection.cursor() as cur: + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s", + [wc.pk], + ) + + assert not Sumy.objects.filter(autor_id=autor_id).exists(), ( + "soft-skasowana publikacja dalej wnosi punkty do rankingu" + ) + + +@pytest.mark.django_db +def test_sumy_pomijaja_soft_deletowana_publikacje_end_to_end( + wydawnictwo_ciagle_z_autorem, +): + """Realna ścieżka (``delete()`` z kaskadą) + powrót po ``restore()``. + + Nie izoluje wymiaru (patrz test wyżej), ale pokrywa to, co faktycznie + robi operator, łącznie z odwracalnością. + """ + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + + assert Sumy.objects.filter(autor_id=autor_id).exists() + + wc.delete() + assert not Sumy.objects.filter(autor_id=autor_id).exists() + + wc.restore() + assert Sumy.objects.filter(autor_id=autor_id).exists(), ( + "po restore publikacja nie wrocila do rankingu" + ) diff --git a/src/bpp/tests/test_soft_delete_publikacje.py b/src/bpp/tests/test_soft_delete_publikacje.py new file mode 100644 index 000000000..c9c44631c --- /dev/null +++ b/src/bpp/tests/test_soft_delete_publikacje.py @@ -0,0 +1,207 @@ +"""Soft-delete PUBLIKACJI (faza 02). + +Testy wąskiej, kontrolowanej kaskady `publikacja -> *_Autor` pod wspólnym +`transaction_id`, bez refleksyjnej kaskady pakietu `django-soft-delete` +(która ruszyłaby `*_Streszczenie` i inne nie-soft dzieci). +""" + +import pytest +from model_bakery import baker + +from bpp.models import ( + Patent, + Praca_Doktorska, + Praca_Habilitacyjna, + Wydawnictwo_Ciagle, + Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte, +) + +#: Wszystkie 5 modeli publikacji objętych fazą 02. +MODELE_PUBLIKACJI = [ + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, + Patent, + Praca_Doktorska, + Praca_Habilitacyjna, +] + + +@pytest.mark.django_db +def test_soft_delete_publikacji_kaskaduje_na_autor_wspolny_txid(): + wc = baker.make(Wydawnictwo_Ciagle) + # `kolejnosc` JAWNIE różna. Bez tego `baker` nadaje obu wierszom 0, co + # łamie ograniczenie wykluczające `wc_autor_excl_rekord_kolejnosc` + # (faza 01). Ograniczenie jest DEFERRABLE i warunkowane + # `deleted_at IS NULL`, więc soft-delete wyprowadzał oba wiersze poza + # jego zakres, zanim zdążyło zadziałać przy COMMIT — test przechodził + # wtedy CZĘŚCIOWO z powodu efektu ubocznego, a nie samej kaskady. + a1 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=0) + a2 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=1) + + wc.delete() + + wc.refresh_from_db() + assert wc.deleted_at is not None + assert wc.transaction_id is not None + + for a in (a1, a2): + row = Wydawnictwo_Ciagle_Autor.global_objects.get(pk=a.pk) + assert row.deleted_at is not None, "autorstwo nie zostało soft-skasowane" + assert row.transaction_id == wc.transaction_id, "różny transaction_id" + + +# --- Task 4: przeplecenie menedżerów ------------------------------------ + + +@pytest.mark.django_db +@pytest.mark.parametrize("klasa", MODELE_PUBLIKACJI) +def test_objects_ukrywa_skasowane_global_widzi(klasa): + """``objects`` musi ukrywać kosz na WSZYSTKICH pięciu modelach. + + Do fazy 02 ``Wydawnictwo_Ciagle`` i ``Wydawnictwo_Zwarte`` miały własne + menedżery (mixin opłat + goły ``models.Manager``), które przesłaniały + menedżer wniesiony przez ``BppPublikacjaSoftDeleteMixin`` — i NIE + filtrowały ``deleted_at``. Pozostałe trzy modele dostawały filtr + „z urodzenia". Ta asymetria była niewidoczna, dopóki nie sprawdzono jej + wprost: żaden wcześniejszy test fazy 02 na nią nie trafiał, bo wszystkie + szły przez widoki albo przez surowy SQL. + """ + zywy = baker.make(klasa) + kosz = baker.make(klasa) + kosz.delete() + + widoczne = set(klasa.objects.values_list("pk", flat=True)) + assert zywy.pk in widoczne + assert kosz.pk not in widoczne, ( + f"{klasa.__name__}.objects ({type(klasa.objects).__name__}) " + f"NIE ukrywa skasowanych" + ) + + wszystkie = set(klasa.global_objects.values_list("pk", flat=True)) + assert kosz.pk in wszystkie, f"{klasa.__name__}.global_objects nie widzi kosza" + + skasowane = set(klasa.deleted_objects.values_list("pk", flat=True)) + assert skasowane == {kosz.pk} & skasowane and kosz.pk in skasowane, ( + f"{klasa.__name__}.deleted_objects nie zwraca skasowanego" + ) + assert zywy.pk not in skasowane, ( + f"{klasa.__name__}.deleted_objects zwraca ŻYWY rekord" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "klasa", [Wydawnictwo_Ciagle, Wydawnictwo_Zwarte], ids=["ciagle", "zwarte"] +) +def test_metoda_oplat_zachowana_i_tez_filtruje(klasa): + """``rekordy_z_oplata()`` (mixin opłat) ma DALEJ działać po przepleceniu + menedżerów — i sama z siebie pomijać kosz. + + To jest sedno Taska 4: filtr soft-delete i metody opłat muszą współżyć + bez nadpisywania tych drugich. ``ManagerModeliZOplataZaPublikacjeMixin`` + woła ``self.exclude(...)``, więc działa nad KAŻDYM querysetem — o ile + stoi w MRO PRZED menedżerem dostarczającym queryset. + """ + zywy = baker.make(klasa, opl_pub_cost_free=True) + kosz = baker.make(klasa, opl_pub_cost_free=True) + kosz.delete() + + z_oplata = set(klasa.objects.rekordy_z_oplata().values_list("pk", flat=True)) + assert zywy.pk in z_oplata, "rekordy_z_oplata() zgubiło żywy rekord" + assert kosz.pk not in z_oplata, "rekordy_z_oplata() pokazuje kosz" + + +@pytest.mark.django_db +def test_wydawnictwa_nadrzedne_dla_innych_zachowane(): + """Druga metoda menedżera ``Wydawnictwo_Zwarte`` przeżywa przeplecenie.""" + matka = baker.make(Wydawnictwo_Zwarte) + baker.make(Wydawnictwo_Zwarte, wydawnictwo_nadrzedne=matka) + + nadrzedne = set(Wydawnictwo_Zwarte.objects.wydawnictwa_nadrzedne_dla_innych()) + assert matka.pk in nadrzedne + + +# --- Task 5: integracja -------------------------------------------------- + + +@pytest.mark.django_db +def test_restore_przywraca_autorstwa_po_tym_samym_txid(): + """``restore()`` podnosi wyłącznie autorstwa skasowane RAZEM z publikacją. + + Autorstwo skasowane WCZEŚNIEJ, osobną decyzją operatora, ma zostać + w koszu — inaczej przywrócenie publikacji cofałoby też decyzje, których + nikt nie cofał. + """ + wc = baker.make(Wydawnictwo_Ciagle) + wczesniej = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=0) + razem = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=1) + + wczesniej.delete() # osobna decyzja, INNY transaction_id + wc.delete() # kaskada obejmuje tylko `razem` + + wc.restore() + + assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=razem.pk).exists(), ( + "autorstwo skasowane RAZEM z publikacja nie wrocilo" + ) + assert not Wydawnictwo_Ciagle_Autor.objects.filter(pk=wczesniej.pk).exists(), ( + "restore podniosl autorstwo skasowane WCZESNIEJ, osobna decyzja" + ) + + +@pytest.mark.django_db +def test_post_soft_delete_emitowany(): + """Sygnał musi lecieć — konsumuje go ``SoftDeleteLog`` z fazy 06.""" + from django_softdelete.signals import post_soft_delete + + odebrane = [] + + def odbiorca(sender, instance, **kwargs): + odebrane.append(instance) + + post_soft_delete.connect(odbiorca, sender=Wydawnictwo_Ciagle) + try: + wc = baker.make(Wydawnictwo_Ciagle) + wc.delete() + finally: + post_soft_delete.disconnect(odbiorca, sender=Wydawnictwo_Ciagle) + + assert len(odebrane) == 1 + assert odebrane[0].pk == wc.pk + + +@pytest.mark.django_db +def test_kaskada_nie_rusza_streszczenia(): + """Kaskada zatrzymuje się na ``*_Autor``. + + Gdybyśmy użyli refleksyjnej kaskady pakietu, zjechałaby po + ``*_Streszczenie``. Co gorsza CICHO: ``delete()`` pakietu ma domyślnie + ``strict=False``, więc nie usłyszelibyśmy ``SoftDeleteException`` — + stąd asercja na SAM BRAK wyjątku nie wystarcza i sprawdzamy też, że + streszczenie fizycznie zostało. + """ + from bpp.models import Wydawnictwo_Ciagle_Streszczenie + + wc = baker.make(Wydawnictwo_Ciagle) + strz = baker.make(Wydawnictwo_Ciagle_Streszczenie, rekord=wc) + + wc.delete() # NIE moze rzucic SoftDeleteException + + assert Wydawnictwo_Ciagle_Streszczenie.objects.filter(pk=strz.pk).exists(), ( + "kaskada zjechala po streszczeniu" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("klasa", MODELE_PUBLIKACJI) +def test_bulk_update_deleted_at_zabroniony(klasa): + """Kontrakt z reversion: soft-delete idzie WYŁĄCZNIE per-instancja. + + Bulk ``update(deleted_at=...)`` omija ``post_save``, kaskadę na + ``*_Autor``, sygnały i przyszły ``SoftDeleteLog``. Gate z fazy 01 ma to + blokować fail-fast, a nie „na ogół". + """ + baker.make(klasa) + with pytest.raises(RuntimeError): + klasa.objects.update(deleted_at="2026-01-01") diff --git a/src/bpp/tests/test_views/test_api.py b/src/bpp/tests/test_views/test_api.py index 2adeda83b..4723087c4 100644 --- a/src/bpp/tests/test_views/test_api.py +++ b/src/bpp/tests/test_views/test_api.py @@ -267,11 +267,18 @@ def test_rok_habilitacji_view(): assert str(CURRENT_YEAR) in res.content.decode() assert json.loads(res.content)["rok"] == CURRENT_YEAR - h.delete() + h.delete() # od fazy 02 MIĘKKO res = rhv.post(request) assert res.status_code == 404 assert "Habilitacja" in res.content.decode() + # ⚠️ Soft-delete ZOSTAWIA wiersz, a `Praca_Habilitacyjna.autor` to O2O + # z PROTECT — więc dopóki habilitacja siedzi w koszu, autora nie da się + # skasować (`ProtectedError`). To NIE jest regresja: rekord nadal + # istnieje i nie wolno go osierocić. Ten test sprawdza ścieżki 404 + # widoku, a nie semantykę kasowania, więc usuwamy habilitację trwale, + # żeby dojść do gałęzi „nie ma autora". + h.hard_delete() a.delete() res = rhv.post(request) assert res.status_code == 404 diff --git a/src/bpp/views/api/__init__.py b/src/bpp/views/api/__init__.py index 141ab17c3..7e5c633ef 100644 --- a/src/bpp/views/api/__init__.py +++ b/src/bpp/views/api/__init__.py @@ -37,6 +37,20 @@ def post(self, request, *args, **kw): except Praca_Habilitacyjna.DoesNotExist: return HttpResponseNotFound("Habilitacja") + # ⚠️ Odwrotne OneToOne NIE respektuje soft-delete. Django rozwiązuje + # `autor.praca_habilitacyjna` przez `ReverseOneToOneDescriptor`, a ten + # pyta `_base_manager` — z definicji NIEprzefiltrowany (Django wymaga, + # żeby zwracał wszystkie wiersze, bo służy do pobierania obiektów + # powiązanych). Skasowana habilitacja jest więc tą ścieżką nadal + # osiągalna, mimo że `Praca_Habilitacyjna.objects` jej nie pokazuje. + # + # Nie da się tego naprawić centralnie bez ustawienia + # `Meta.base_manager_name` na menedżer filtrujący — a tego Django + # jawnie odradza (rozwaliłoby m.in. deserializację i + # `refresh_from_db`). Dlatego sprawdzamy tu jawnie. + if habilitacja.deleted_at is not None: + return HttpResponseNotFound("Habilitacja") + return JsonResponse({"rok": habilitacja.rok}) diff --git a/src/conftest.py b/src/conftest.py index 92fd6fe7a..4a46346f1 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -11,6 +11,7 @@ from django.core.management import call_command from django.core.management.base import CommandError from django.db import connections +from django.db.models.signals import post_migrate from django.db.utils import OperationalError from django.test import TransactionTestCase from django.test.client import Client, RequestFactory @@ -1259,3 +1260,54 @@ def django_db_setup(django_db_setup, django_db_blocker): for sn, lv, ic in rows ] cursor.execute("\n".join(alter_stmts)) + + +@pytest.fixture +def bez_reinstalacji_denorma(): + """Odpina globalną przebudowę triggerów ``django-denorm`` na czas testu. + + PO CO: ``denorm`` podpina się pod ``post_migrate`` i po KAŻDYM ``migrate`` + odbudowuje wszystkie swoje triggery — zawsze z AKTUALNYCH definicji + modeli (``denorm/apps.py``: ``denorm_install_triggers_after_migrate``). + Test, który zejdzie migracjami poniżej ``bpp.0496``, ma wtedy bazę bez + kolumny ``deleted_at`` na tabelach publikacji, ale modele Pythona nadal ją + deklarują — więc ``denorm`` generuje bramkę + ``WHEN (OLD."deleted_at" IS DISTINCT FROM ...)``, a ``CREATE TRIGGER`` + pada na ``UndefinedColumn``. + + KTO TEGO POTRZEBUJE — dwie różne rodziny testów, stąd fixture siedzi + w GLOBALNYM conftescie, a nie przy testach soft-delete: + + 1. testy odwracalności migracji (``bpp/tests/test_soft_delete/``), które + schodzą poniżej ``0496`` świadomie; + 2. testy e2e migracji w INNYCH aplikacjach (``pbn_api``), które cofają + swoją aplikację ``MigrationExecutor``-em — a że migracje ``bpp`` + zależą od ``pbn_api``, Django cofa razem z nimi także ``0496``. + Tam wybuchało to dopiero w TEARDOWNIE i o przyczynie nie mówiło nic. + + DLACZEGO TO NIE JEST PROBLEM PRODUKCYJNY: w prawdziwym rollbacku wycofuje + się KOD razem ze schematem, a stare modele nie mają ``deleted_at`` — + ``denorm`` wygeneruje wtedy poprawne triggery. Kombinacja „nowy kod + + stary schemat" powstaje wyłącznie w teście, który rusza sam schemat. + + Po teście wpinamy handler z powrotem i odpalamy przebudowę RĘCZNIE — baza + testowa jest współdzielona przez cały przebieg, więc nie wolno zostawić + jej z triggerami niepasującymi do modeli. + """ + from denorm import denorms + from denorm.apps import denorm_install_triggers_after_migrate + + sender = apps.get_app_config("denorm") + post_migrate.disconnect(denorm_install_triggers_after_migrate, sender=sender) + try: + yield + finally: + post_migrate.connect(denorm_install_triggers_after_migrate, sender=sender) + # Doprowadzamy schemat z powrotem do najnowszej migracji SAMI, zamiast + # ufać, że test zdążył to zrobić: gdy asercja padnie w połowie, test + # przerywa przed swoim `migrate` i baza zostaje w stanie sprzed + # docelowej migracji. Wywołanie jest idempotentne (no-op, gdy już + # jesteśmy na szczycie), a bez niego przebudowa niżej wywaliłaby się + # na brakującej kolumnie, przykrywając PRAWDZIWY powód porażki testu. + call_command("migrate", "bpp", verbosity=0) + denorms.install_triggers() diff --git a/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py b/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py index 2811d6989..2eb6a6be0 100644 --- a/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py +++ b/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py @@ -45,7 +45,7 @@ def _policz(sql, *params): @pytest.mark.django_db(transaction=True) -def test_migracja_przechodzi_na_bazie_z_duplikatami(): +def test_migracja_przechodzi_na_bazie_z_duplikatami(bez_reinstalacji_denorma): uuid_slownika, uuid_dyscypliny = uuid4(), uuid4() MigrationExecutor(connection).migrate([PRZED]) diff --git a/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py b/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py index b3f5a111f..6d747c83e 100644 --- a/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py +++ b/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py @@ -29,7 +29,7 @@ def _policz(sql, *params): @pytest.mark.django_db(transaction=True) -def test_migracja_przechodzi_na_bazie_z_duplikatami(): +def test_migracja_przechodzi_na_bazie_z_duplikatami(bez_reinstalacji_denorma): # cofnij się PRZED constraint — dopiero wtedy baza wpuści duplikaty MigrationExecutor(connection).migrate([PRZED]) diff --git a/src/pbn_export_queue/models.py b/src/pbn_export_queue/models.py index 3fdd17eea..e5f2fc77f 100644 --- a/src/pbn_export_queue/models.py +++ b/src/pbn_export_queue/models.py @@ -177,11 +177,21 @@ def check_if_record_still_exists(self): return False try: - if self.content_type.get_object_for_this_type(pk=self.object_id): - return True - + obiekt = self.content_type.get_object_for_this_type(pk=self.object_id) except ObjectDoesNotExist: return False + + # ⚠️ `get_object_for_this_type` pyta `_base_manager`, który z + # definicji NIE filtruje (Django wymaga, żeby zwracał wszystkie + # wiersze). Rekord soft-skasowany jest więc tą drogą nadal + # znajdowany, mimo że `objects` go nie pokazuje. Dla kolejki PBN + # „w koszu" ma znaczyć „nie ma go" — inaczej wysyłalibyśmy do PBN + # publikację, którą operator usunął. + if getattr(obiekt, "deleted_at", None) is not None: + return False + + if obiekt: + return True except self.content_type.model_class().DoesNotExist: return False diff --git a/src/rozbieznosci_dyscyplin/migrations/0023_rozbieznosci_zrodel_bez_skasowanych_publikacji.py b/src/rozbieznosci_dyscyplin/migrations/0023_rozbieznosci_zrodel_bez_skasowanych_publikacji.py new file mode 100644 index 000000000..a5dda8a6c --- /dev/null +++ b/src/rozbieznosci_dyscyplin/migrations/0023_rozbieznosci_zrodel_bez_skasowanych_publikacji.py @@ -0,0 +1,52 @@ +"""Raport rozbieżności dyscyplin źródeł pomija soft-deletowane PUBLIKACJE. + +Dopełnienie migracji ``0022``, która zamknęła w tym widoku wymiar AUTORSTWA +(``bpp_wydawnictwo_ciagle_autor.deleted_at``). Wymiar PUBLIKACJI został +wtedy otwarty, bo publikacje stały się soft-delete dopiero w fazie 02 +(``bpp.0496``). + +Bez tego warunku skasowane wydawnictwo ciągłe dalej generuje pozycje +w raporcie rozbieżności — operator dostaje do rozstrzygnięcia rekord, +którego w serwisie już nie ma. + +Widok wskazany INWENTARYZACJĄ kanarka katalogowego na starcie fazy 02 +(``docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md``). + +Definicja żyjąca w bazie pochodzi z pliku ``.sql`` przeładowywanego przez +kilka migracji (patrz ``0022``), więc — tak samo jak tam — pliku nie +ruszamy, tylko dokładamy warunek osobną migracją, przez introspekcję. + +Pozostałe widoki tej aplikacji czytają ``bpp_autorzy``, a nie surowe tabele +publikacji — potwierdzone inwentaryzacją (``pg_depend``), nie gremem. +""" + +from django.db import connection, migrations + +from bpp.migration_util import widok_dopisz_warunek, widok_usun_warunek + +WIDOK = "rozbieznosci_dyscyplin_rozbieznoscizrodelview" +WARUNEK = "bpp_wydawnictwo_ciagle.deleted_at IS NULL" + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + widok_dopisz_warunek(cur, WIDOK, WARUNEK) + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + widok_usun_warunek(cur, WIDOK, WARUNEK) + + +class Migration(migrations.Migration): + dependencies = [ + ("rozbieznosci_dyscyplin", "0022_rozbieznosci_zrodel_bez_skasowanych"), + # Kolumna `deleted_at` na tabelach publikacji powstaje w bpp.0496; + # bez tej zależności migracja mogłaby pójść przed nią i wywalić się + # na nieznanej kolumnie. + ("bpp", "0496_publikacje_soft_delete_fields"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py b/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py index 5854c9f0f..b60b8ee3b 100644 --- a/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py +++ b/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py @@ -8,6 +8,7 @@ """ import pytest +from django.db import connection from model_bakery import baker from bpp.models import Dyscyplina_Zrodla, Wydawnictwo_Ciagle @@ -47,3 +48,28 @@ def test_rozbieznosci_zrodel_pomijaja_soft_deletowane(rozbieznosc_zrodla): assert not RozbieznosciZrodelView.objects.filter( autor_id=rozbieznosc_zrodla.autor_id ).exists() + + +@pytest.mark.django_db +def test_rozbieznosci_zrodel_pomijaja_soft_deletowana_publikacje(rozbieznosc_zrodla): + """Wymiar PUBLIKACJI (faza 02, migracja 0023 tej aplikacji). + + ⚠️ Kasujemy publikację SUROWYM UPDATE-em, nie ``rekord.delete()``, i jest + to celowe: ``delete()`` kaskaduje na autorstwa, więc wiersz raportu + zniknąłby z DWÓCH niezależnych powodów — przez filtr publikacji (to, co + ten test sprawdza) ORAZ przez filtr autorstwa z migracji ``0022``. Test + przechodziłby wtedy nawet po cofnięciu ``0023``, czyli nie byłby + wyrocznią niczego. + """ + autor_id = rozbieznosc_zrodla.autor_id + assert RozbieznosciZrodelView.objects.filter(autor_id=autor_id).exists() + + with connection.cursor() as cur: + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s", + [rozbieznosc_zrodla.rekord_id], + ) + + assert not RozbieznosciZrodelView.objects.filter(autor_id=autor_id).exists(), ( + "soft-skasowana publikacja dalej generuje wiersz raportu rozbieznosci" + ) diff --git a/src/rozbieznosci_dyscyplin/util.py b/src/rozbieznosci_dyscyplin/util.py index 6dd0ef5a8..39436366e 100644 --- a/src/rozbieznosci_dyscyplin/util.py +++ b/src/rozbieznosci_dyscyplin/util.py @@ -1,7 +1,9 @@ from django.core.exceptions import ObjectDoesNotExist -def object_or_something(model, attrname, default_pk=-1, default_attr="nazwa", default_value="--", **kwargs): +def object_or_something( + model, attrname, default_pk=-1, default_attr="nazwa", default_value="--", **kwargs +): """ Funkcja do rozsądnego zwracania czegoś w przypadku, gdy obiekt powiązany (ForeignKey) może być None/NULL. W takich sytuacjach Django zwraca błąd. From 49be9752720f88f64f76c003aa0fd1f15a56129f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 7 Aug 2026 23:55:04 +0200 Subject: [PATCH 55/75] test(soft-delete): re-import po pbn_uid widzi soft-deletowany rekord (failing) Test bazowy fazy 03. Rekord_w_bpp matchuje przez Rekord, czyli widok bpp_rekord_mat przefiltrowany po deleted_at juz w fazie 01 -- skasowana publikacja z niego znika, matching zwraca None, a importer tworzy DUPLIKAT. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../test_audyt_kategorii_b.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py diff --git a/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py new file mode 100644 index 000000000..0cff010f0 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py @@ -0,0 +1,44 @@ +"""Kategoria B: miejsca, które MUSZĄ widzieć rekordy w koszu (faza 03). + +Po fazie 02 domyślny menedżer publikacji ukrywa soft-skasowane wiersze — +i dla większości kodu jest to zachowanie właściwe („kategoria A", czysta +automatycznie). Kategoria B to wyjątki: matching podczas re-importu +i deduplikacji. Jeśli te miejsca użyją ukrywającego menedżera, skasowany +rekord staje się NIEWIDZIALNY i importer tworzy DUPLIKAT — czyli dokładnie +ten skutek, dla którego handoff fazy 02 nazywa fazę 03 obowiązkową razem z 02. +""" + +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(): + """Matching po ``pbn_uid`` MUSI znaleźć soft-skasowaną publikację. + + ``pbn_api.Publication.rekord_w_bpp`` szuka przez ``Rekord`` — a to widok + (``bpp_rekord_mat``), przefiltrowany po ``deleted_at`` już w fazie 01. + Skasowany rekord z niego znika, więc matching zwraca ``None`` i importer + zakłada, że publikacji nie ma. + """ + 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 From c394b9bd1233585c0ea39ef0746ce40a68ca5703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 00:00:42 +0200 Subject: [PATCH 56/75] fix(soft-delete): matching po pbn_uid widzi kosz (Task 2) get_bpp_publication i rekord_w_bpp matchowaly przez Rekord, czyli widok bpp_rekord_mat przefiltrowany po deleted_at juz w fazie 01. Soft-skasowana publikacja z niego znika, wiec matching zwracal None, importer zakladal brak rekordu i tworzyl DUPLIKAT. Teraz odpytujemy modele zrodlowe przez global_objects. Patent NIE ma pola pbn_uid -- sprawdzone na modelach, nie zalozone. Wpisanie go do listy wywalilo by FieldError przy pierwszym .filter(), bo Django resolwuje nazwy pol natychmiast, a nie przy iteracji. Zachowana historyczna ROZNICA miedzy tymi dwiema metodami: przy wielu trafieniach get_bpp_publication zwraca None, a rekord_w_bpp sklejone tytuly; przy braku trafien rekord_w_bpp spada do fuzzy matchingu. Nie ujednolicam tego przy okazji. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- src/pbn_api/models/publication.py | 76 +++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/src/pbn_api/models/publication.py b/src/pbn_api/models/publication.py index e624e7fb0..963d729b4 100644 --- a/src/pbn_api/models/publication.py +++ b/src/pbn_api/models/publication.py @@ -12,6 +12,49 @@ STATUS_ACTIVE = "ACTIVE" +def modele_publikacji_z_pbn_uid(): + """Modele publikacji BPP, które MAJĄ pole ``pbn_uid``. + + ⚠️ ``Patent`` go NIE ma — sprawdzone na modelach, nie założone. Dopisanie + go tutaj wywaliłoby ``FieldError`` przy pierwszym ``.filter()``, bo Django + resolwuje nazwy pól NATYCHMIAST (``Query.build_filter``), a nie dopiero + przy iteracji querysetu. + """ + from bpp.models import ( + Praca_Doktorska, + Praca_Habilitacyjna, + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, + ) + + return ( + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, + Praca_Doktorska, + Praca_Habilitacyjna, + ) + + +def znajdz_publikacje_po_pbn_uid(pbn_uid_pk): + """Publikacje BPP o danym ``pbn_uid`` — RAZEM Z KOSZEM. + + DLACZEGO NIE PRZEZ ``Rekord``: ``Rekord`` to widok (``bpp_rekord_mat``), + przefiltrowany po ``deleted_at`` już w fazie 01. Soft-skasowana + publikacja z niego znika, więc matching po ``pbn_uid`` zwracał ``None``, + a importer zakładał, że rekordu nie ma — i tworzył DUPLIKAT. Odpytujemy + zatem modele źródłowe przez ich ``global_objects``. + + Zwraca listę (zwykle pustą albo jednoelementową). Co zrobić z wieloma + trafieniami, rozstrzyga wołający — ``get_bpp_publication`` i + ``rekord_w_bpp`` robiły z tym historycznie DWIE różne rzeczy i ta + różnica jest zachowana. + """ + znalezione = [] + for klass in modele_publikacji_z_pbn_uid(): + znalezione.extend(klass.global_objects.filter(pbn_uid_id=pbn_uid_pk)) + return znalezione + + class Publication(LinkDoPBNMixin, BasePBNMongoDBModel): url_do_pbn = const.LINK_PBN_DO_PUBLIKACJI atrybut_dla_url_do_pbn = "pk" @@ -158,26 +201,29 @@ def matchuj_do_rekordu_bpp(self): ) def get_bpp_publication(self): - """Zwraca rekord BPP powiązany przez PBN UID (bez fuzzy matching).""" - from bpp.models.cache import Rekord + """Rekord BPP powiązany przez PBN UID (bez fuzzy matching). - try: - return Rekord.objects.get(pbn_uid_id=self.pk) - except (Rekord.DoesNotExist, Rekord.MultipleObjectsReturned): - return None + Widzi TAKŻE kosz — patrz ``znajdz_publikacje_po_pbn_uid``. Zachowane + zachowanie historyczne: zarówno brak trafienia, jak i wiele trafień + dają ``None`` (dawniej ``DoesNotExist`` / ``MultipleObjectsReturned``). + """ + znalezione = znajdz_publikacje_po_pbn_uid(self.pk) + if len(znalezione) == 1: + return znalezione[0] + return None @cached_property def rekord_w_bpp(self): - from bpp.models.cache import Rekord + """Jak wyżej, ale przy wielu trafieniach zwraca sklejone tytuły, + a przy braku — spada do fuzzy matchingu. Ta różnica wobec + ``get_bpp_publication`` jest historyczna i celowo zachowana.""" + znalezione = znajdz_publikacje_po_pbn_uid(self.pk) + + if len(znalezione) == 1: + return znalezione[0] - try: - return Rekord.objects.get(pbn_uid_id=self.pk) - except Rekord.MultipleObjectsReturned: - return ";; ".join( - [x.tytul_oryginalny for x in Rekord.objects.filter(pbn_uid_id=self.pk)] - ) - except Rekord.DoesNotExist: - pass + if len(znalezione) > 1: + return ";; ".join(x.tytul_oryginalny for x in znalezione) return self.matchuj_do_rekordu_bpp() From 416aad2b7bf4ee8421c06d5b8fb9fe842e417b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 01:14:41 +0200 Subject: [PATCH 57/75] feat(soft-delete): import PBN wskrzesza rekordy z kosza + rejestr (Task 1b) ZMIANA DECYZJI #14 planu fazy 03, rozstrzygnieta przez wlasciciela systemu. Plan zakladal "POMIN + ZARAPORTUJ", argumentujac, ze auto-restore pozwolilby importowi wskrzeszac rzeczy skasowane celowo. Wlasciciel rozstrzygnal inaczej: PBN jest zrodlem prawdy dla tych publikacji, wiec skoro rekord tam jest, ma wrocic takze do BPP. Ryzyko wskazane w tamtej decyzji nie znika -- zmienia postac z "nie robmy tego" na "robmy, ale zostawmy slad". Tym sladem jest nowy model RekordPrzywroconyPrzezImport (pbn_integrator, migracja 0001): rekord (generic FK, bo import dotyka kilku modeli), data, publikacja PBN i sciezka importu. Operator, ktory znajdzie w bazie publikacje skasowana przez siebie tydzien wczesniej, ma gdzie sprawdzic, ze wrocila z importu, kiedy i skad. Wpis powstaje WYLACZNIE przy realnym wskrzeszeniu -- zwykly re-import zywego rekordu nie zostawia nic. Inaczej rejestr zapelnilby sie szumem i przestal cokolwiek znaczyc; przypina to osobny test. Helper przyjmuje takze wejscie NIE-modelowe bez wyjatku: rekord_w_bpp zwraca STRING ze sklejonymi tytulami przy wielu trafieniach po pbn_uid (zachowanie historyczne, utrzymane w Tasku 2) albo None. Helper stoi na sciezce importu, wiec wywrocenie przebiegu z tego powodu byloby regresja niezwiazana z koszem. Wpiete w trzy wejscia importera: articles, books, chapters. Zweryfikowane mutacyjnie: wylaczenie warunku na deleted_at wywala test o wskrzeszeniu i rejestrze (pierwsza czerwien byla ImportError, wiec nie dowodzila, ze asercje cokolwiek pilnuja). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- src/pbn_integrator/importer/articles.py | 6 ++ src/pbn_integrator/importer/books.py | 5 ++ src/pbn_integrator/importer/chapters.py | 5 ++ src/pbn_integrator/kosz.py | 55 ++++++++++++ src/pbn_integrator/migrations/0001_initial.py | 34 ++++++++ src/pbn_integrator/models.py | 68 ++++++++++++++- .../tests/test_przywracanie_z_kosza.py | 83 +++++++++++++++++++ 7 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 src/pbn_integrator/kosz.py create mode 100644 src/pbn_integrator/migrations/0001_initial.py create mode 100644 src/pbn_integrator/tests/test_przywracanie_z_kosza.py diff --git a/src/pbn_integrator/importer/articles.py b/src/pbn_integrator/importer/articles.py index 987813fbe..7ecb4a876 100644 --- a/src/pbn_integrator/importer/articles.py +++ b/src/pbn_integrator/importer/articles.py @@ -15,6 +15,7 @@ from bpp.util import safe_tytul_html from pbn_api.client import PBNClient from pbn_api.models import Publication +from pbn_integrator.kosz import przywroc_jesli_w_koszu from .authors import utworz_autorow from .cache import ( @@ -67,7 +68,12 @@ def importuj_artykul( raise NotImplementedError(f"Publikacja {mongoId=} nie istnieje") from err ret = pbn_publication.rekord_w_bpp + if ret is not None and not force: + # Trafienie w kosz: PBN jest zrodlem prawdy, wiec rekord wraca -- + # i zostaje po tym slad w rejestrze. Uzasadnienie i zmiana wobec + # decyzji #14 planu: docstring RekordPrzywroconyPrzezImport. + przywroc_jesli_w_koszu(ret, pbn_publication, "articles") return ret pbn_json = pbn_publication.current_version["object"] diff --git a/src/pbn_integrator/importer/books.py b/src/pbn_integrator/importer/books.py index 5ecdc4e92..0ead7e919 100644 --- a/src/pbn_integrator/importer/books.py +++ b/src/pbn_integrator/importer/books.py @@ -13,6 +13,7 @@ ) from bpp.util import safe_tytul_html from pbn_api.client import PBNClient +from pbn_integrator.kosz import przywroc_jesli_w_koszu from .authors import utworz_autorow from .cache import ( @@ -67,6 +68,10 @@ def importuj_ksiazke( ret = pbn_publication.rekord_w_bpp if ret is not None and not force: + # Trafienie w kosz: PBN jest zrodlem prawdy, wiec rekord wraca -- + # i zostaje po tym slad w rejestrze. Uzasadnienie i zmiana wobec + # decyzji #14 planu: docstring RekordPrzywroconyPrzezImport. + przywroc_jesli_w_koszu(ret, pbn_publication, "books") return ret pbn_json = pbn_publication.current_version["object"] diff --git a/src/pbn_integrator/importer/chapters.py b/src/pbn_integrator/importer/chapters.py index d9f657470..b3c99ae81 100644 --- a/src/pbn_integrator/importer/chapters.py +++ b/src/pbn_integrator/importer/chapters.py @@ -16,6 +16,7 @@ from bpp.util import safe_tytul_html from pbn_api.client import PBNClient from pbn_api.models import Publication +from pbn_integrator.kosz import przywroc_jesli_w_koszu from .authors import utworz_autorow from .books import importuj_ksiazke @@ -90,6 +91,10 @@ def importuj_rozdzial( ret = pbn_publication.rekord_w_bpp if ret is not None and not force: + # Trafienie w kosz: PBN jest zrodlem prawdy, wiec rekord wraca -- + # i zostaje po tym slad w rejestrze. Uzasadnienie i zmiana wobec + # decyzji #14 planu: docstring RekordPrzywroconyPrzezImport. + przywroc_jesli_w_koszu(ret, pbn_publication, "chapters") return ret pbn_json = pbn_publication.current_version["object"] diff --git a/src/pbn_integrator/kosz.py b/src/pbn_integrator/kosz.py new file mode 100644 index 000000000..444ae9d28 --- /dev/null +++ b/src/pbn_integrator/kosz.py @@ -0,0 +1,55 @@ +"""Obsługa trafienia importu w rekord soft-skasowany („w koszu"). + +Kontekst decyzyjny jest w docstringu ``RekordPrzywroconyPrzezImport``. +""" + +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db import transaction + +from pbn_integrator.models import RekordPrzywroconyPrzezImport + +logger = logging.getLogger(__name__) + + +def przywroc_jesli_w_koszu(rekord, pbn_publication, zrodlo_importu): + """Wskrzesza ``rekord``, jeśli jest w koszu, i odnotowuje to w rejestrze. + + Zwraca ``True`` tylko przy REALNYM wskrzeszeniu — dzięki temu wołający + może to policzyć, a rejestr nie zapełnia się zwykłymi re-importami. + + ⚠️ ``rekord`` bywa czymś innym niż instancją modelu. ``rekord_w_bpp`` + zwraca STRING ze sklejonymi tytułami, gdy po ``pbn_uid`` trafi więcej niż + jedna publikacja (zachowanie historyczne, utrzymane w Tasku 2), albo + ``None``, gdy nie ma nic. Ten helper stoi na ścieżce importu, więc musi to + przyjąć bez wyjątku — inaczej wywróciłby cały przebieg z powodu, który + z koszem nie ma nic wspólnego. Stąd ``getattr`` zamiast ``isinstance`` + i wczesny zwrot. + """ + if getattr(rekord, "deleted_at", None) is None: + return False + + with transaction.atomic(): + # `restore()` mixinu fazy 02 podnosi też autorstwa skasowane tym samym + # `transaction_id` — czyli dokładnie te, które zniknęły RAZEM z tą + # publikacją. Autorstwa skasowane wcześniej, osobną decyzją operatora, + # zostają w koszu. + rekord.restore() + + RekordPrzywroconyPrzezImport.objects.create( + content_type=ContentType.objects.get_for_model(type(rekord)), + object_id=rekord.pk, + pbn_uid=pbn_publication, + zrodlo_importu=zrodlo_importu, + ) + + logger.warning( + "Import (%s) wskrzesil rekord z kosza: %r (pk=%s, pbn_uid=%s). " + "Wpis w rejestrze RekordPrzywroconyPrzezImport.", + zrodlo_importu, + rekord, + rekord.pk, + getattr(pbn_publication, "pk", None), + ) + return True diff --git a/src/pbn_integrator/migrations/0001_initial.py b/src/pbn_integrator/migrations/0001_initial.py new file mode 100644 index 000000000..6172ab58b --- /dev/null +++ b/src/pbn_integrator/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.16 on 2026-08-07 23:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('pbn_api', '0079_constraint_publikacja_instytucji'), + ] + + operations = [ + migrations.CreateModel( + name='RekordPrzywroconyPrzezImport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('przywrocono', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Kiedy import wskrzesił ten rekord.', verbose_name='Przywrócono')), + ('zrodlo_importu', models.CharField(help_text='Która ścieżka importu wskrzesiła rekord (articles / books / chapters).', max_length=50, verbose_name='Źródło importu')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('pbn_uid', models.ForeignKey(blank=True, help_text='Rekord PBN, przez który import trafił na tę publikację.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='pbn_api.publication', verbose_name='Publikacja w PBN')), + ], + options={ + 'verbose_name': 'rekord przywrócony przez import', + 'verbose_name_plural': 'rekordy przywrócone przez import', + 'ordering': ('-przywrocono',), + 'indexes': [models.Index(fields=['-przywrocono', 'content_type'], name='pbnint_przywr_data_ct_idx')], + }, + ), + ] diff --git a/src/pbn_integrator/models.py b/src/pbn_integrator/models.py index 6b2021999..2fd1439f8 100644 --- a/src/pbn_integrator/models.py +++ b/src/pbn_integrator/models.py @@ -1 +1,67 @@ -# Create your models here. +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class RekordPrzywroconyPrzezImport(models.Model): + """Rejestr publikacji WSKRZESZONYCH z kosza przez import z PBN. + + PO CO TO ISTNIEJE. Plan fazy 03 zakładał pierwotnie, że import ma rekord + w koszu POMIJAĆ (decyzja #14), bo auto-restore pozwala „wskrzeszać rzeczy + skasowane celowo". Właściciel systemu rozstrzygnął inaczej — PBN jest dla + tych publikacji źródłem prawdy, więc skoro rekord tam jest, ma wrócić + także do BPP. + + Ryzyko wskazane w tamtej decyzji nie znika, tylko zmienia postać: + z „nie róbmy tego" na „róbmy, ale zostawmy ślad". Ten model JEST tym + śladem. Operator, który znajdzie w bazie publikację skasowaną przez siebie + tydzień wcześniej, ma gdzie sprawdzić, że wróciła z importu, kiedy + i z którego źródła — zamiast zakładać, że coś mu się przywidziało. + + Wpis powstaje WYŁĄCZNIE przy realnym wskrzeszeniu. Zwykły re-import + żywego rekordu nie zostawia tu nic — inaczej rejestr zapełniłby się szumem + i przestałby cokolwiek znaczyć. + """ + + content_type = models.ForeignKey(ContentType, models.CASCADE) + object_id = models.PositiveIntegerField() + #: Publikacja, która wróciła z kosza. GenericFK, bo import dotyka kilku + #: modeli (wydawnictwa ciągłe, zwarte, prace doktorskie/habilitacyjne). + rekord = GenericForeignKey("content_type", "object_id") + + przywrocono = models.DateTimeField( + "Przywrócono", + auto_now_add=True, + db_index=True, + help_text="Kiedy import wskrzesił ten rekord.", + ) + pbn_uid = models.ForeignKey( + "pbn_api.Publication", + models.SET_NULL, + null=True, + blank=True, + verbose_name="Publikacja w PBN", + help_text="Rekord PBN, przez który import trafił na tę publikację.", + ) + zrodlo_importu = models.CharField( + "Źródło importu", + max_length=50, + help_text="Która ścieżka importu wskrzesiła rekord (articles / books / " + "chapters).", + ) + + class Meta: + verbose_name = "rekord przywrócony przez import" + verbose_name_plural = "rekordy przywrócone przez import" + ordering = ("-przywrocono",) + indexes = [ + # Najczęstsze pytanie operatora brzmi „co wróciło ostatnio" — + # stąd indeks po dacie malejąco razem z typem rekordu. + models.Index( + fields=["-przywrocono", "content_type"], + name="pbnint_przywr_data_ct_idx", + ), + ] + + def __str__(self): + return f"{self.rekord} (przywrócono: {self.przywrocono})" diff --git a/src/pbn_integrator/tests/test_przywracanie_z_kosza.py b/src/pbn_integrator/tests/test_przywracanie_z_kosza.py new file mode 100644 index 000000000..ee3be41f1 --- /dev/null +++ b/src/pbn_integrator/tests/test_przywracanie_z_kosza.py @@ -0,0 +1,83 @@ +"""Import z PBN wskrzesza publikacje trafione w koszu i to odnotowuje. + +DECYZJA WŁAŚCICIELA (2026-08-08) — ZMIANA wobec decyzji #14 z planu fazy 03. + +Plan zakładał „POMIŃ + ZARAPORTUJ", argumentując, że auto-restore pozwoliłby +importowi wskrzeszać rzeczy skasowane celowo. Właściciel systemu rozstrzygnął +inaczej: **PBN jest źródłem prawdy** dla tych publikacji, więc skoro rekord +nadal tam jest, ma wrócić także do BPP. + +Ryzyko, na które wskazywała pierwotna decyzja, nie znika — zostaje tylko +przeniesione z „nie róbmy tego" na „róbmy, ale zostaw ślad". Stąd rejestr +``RekordPrzywroconyPrzezImport``: operator, który znajdzie w bazie rekord +skasowany przez siebie tydzień temu, ma gdzie sprawdzić, że wrócił z importu, +kiedy i z którego źródła. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle + + +@pytest.mark.django_db +def test_przywraca_rekord_z_kosza_i_zapisuje_w_rejestrze(): + from pbn_api.models import Publication + from pbn_integrator.kosz import przywroc_jesli_w_koszu + from pbn_integrator.models import RekordPrzywroconyPrzezImport + + publication = baker.make(Publication) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + wc.delete() + + assert Wydawnictwo_Ciagle.objects.filter(pk=wc.pk).count() == 0 + + przywrocono = przywroc_jesli_w_koszu(wc, publication, "articles") + + assert przywrocono is True + assert Wydawnictwo_Ciagle.objects.filter(pk=wc.pk).count() == 1, ( + "rekord nie wrocil z kosza" + ) + + wpis = RekordPrzywroconyPrzezImport.objects.get() + assert wpis.rekord == wc + assert wpis.pbn_uid_id == publication.pk + assert wpis.zrodlo_importu == "articles" + assert wpis.przywrocono is not None + + +@pytest.mark.django_db +def test_zywy_rekord_nie_trafia_do_rejestru(): + """Wpis w rejestrze ma oznaczać REALNE wskrzeszenie. + + Bez tej asercji rejestr zapełniłby się każdym zwykłym re-importem + i przestałby cokolwiek znaczyć — a jego jedyną wartością jest to, że + operator może mu zaufać. + """ + from pbn_api.models import Publication + from pbn_integrator.kosz import przywroc_jesli_w_koszu + from pbn_integrator.models import RekordPrzywroconyPrzezImport + + publication = baker.make(Publication) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + + assert przywroc_jesli_w_koszu(wc, publication, "articles") is False + assert not RekordPrzywroconyPrzezImport.objects.exists() + + +@pytest.mark.django_db +def test_niemodelowy_wynik_matchingu_nie_wywala_helpera(): + """``rekord_w_bpp`` bywa STRINGIEM. + + Przy wielu trafieniach po ``pbn_uid`` zwraca sklejone tytuły (zachowanie + historyczne, utrzymane w Tasku 2). Helper stoi na ścieżce importu, więc + musi to przyjąć bez wyjątku — inaczej wywróciłby cały przebieg z powodu, + który z koszem nie ma nic wspólnego. + """ + from pbn_api.models import Publication + from pbn_integrator.kosz import przywroc_jesli_w_koszu + + publication = baker.make(Publication) + + assert przywroc_jesli_w_koszu("Tytul A ;; Tytul B", publication, "books") is False + assert przywroc_jesli_w_koszu(None, publication, "books") is False From 843399251c2387f60dbe4fdce4544b511381a75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 01:42:29 +0200 Subject: [PATCH 58/75] fix(soft-delete): matching importu widzi kosz (Task 4) + korekta decyzji #14 w planie matchuj_publikacje i helpery (_try_match_pub_by_doi/zrodlo/isbn/uri/title) odpytywaly klass.objects, wiec fuzzy matching nie widzial soft-skasowanych rekordow -- re-import uznawal je za nieistniejace i tworzyl DUPLIKAT, ktorego operator nie zauwazy, bo oryginal siedzi w koszu. Wprowadzony helper widzacy_manager(klass) z getattr-fallbackiem, bo klass bywa takze Rekord: to widok (managed=False), nie SoftDeleteModel, wiec nie ma global_objects -- za to jest odfiltrowany po deleted_at juz na poziomie SQL-a (faza 01), czyli "menedzer widzacy" dla niego nie istnieje. ODSTEPSTWO OD PLANU, swiadome: plan wymienial linie 87/108/181/235/249, pomijajac _build_isbn_query. Zmienilem tam rowniez baze zapytania, bo uzasadnienie jest identyczne -- re-import ksiazki matchowanej po ISBN tworzylby duplikat tak samo. Na objects zostaja WYLACZNIE dwa wywolania wydawnictwa_nadrzedne_dla_innych(): to metoda menedzera domenowego, ktorej GlobalManager pakietu nie ma, a plan swiadomie akceptuje liczenie nadrzednych sposrod nieusunietych. Test po DOI mial blad PO MOJEJ STRONIE: podawal celowo inny tytul, a _try_match_pub_by_doi mimo zawezenia po DOI przepuszcza kandydata przez prog podobienstwa tytulu (0.80). Test padal wiec z powodu reguly matchingu, nie widocznosci kosza. Poprawiony, zeby mierzyl to, co mial mierzyc. Plan fazy 03: decyzja #14 oznaczona jako ZMIENIONA (PRZYWROC + ODNOTUJ), z zachowaniem pierwotnego rozumowania -- bo to ono uzasadnia istnienie rejestru. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- ...-06-04-soft-delete-03-audyt-kategorii-b.md | 35 +++++++++-- .../test_audyt_kategorii_b.py | 62 +++++++++++++++++++ src/import_common/core/publikacja.py | 35 ++++++++--- 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md index 44549aa97..3e5b87553 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-03-audyt-kategorii-b.md @@ -139,11 +139,36 @@ git commit -m "test(soft-delete): re-import po pbn_uid widzi soft-deletowany rek > Bez jawnej decyzji domyślne zachowanie to „zaktualizuj po cichu", czyli > import nadpisuje zawartość kosza. -**DECYZJA: pomiń rekord w koszu, zaraportuj trafienie.** Nie tykamy go, nie -przywracamy, nie tworzymy obok nowego. Uzasadnienie: soft-delete to jawna -deklaracja „tego tu nie ma"; cichy update ją podważa, auto-restore pozwoliłby -importowi wskrzeszać rzeczy skasowane celowo, a nowy rekord obok odtwarzałby -problem duplikatów. +> 🔁 **DECYZJA ZMIENIONA 2026-08-08 przez właściciela systemu.** +> Obowiązuje wariant **PRZYWRÓĆ + ODNOTUJ**, opisany zaraz pod tym boksem. +> Poniższy akapit zostaje jako zapis pierwotnego rozumowania — bo argument, +> który podnosił, nadal jest ważny i to on uzasadnia istnienie rejestru. +> +> ~~**DECYZJA: pomiń rekord w koszu, zaraportuj trafienie.**~~ Nie tykamy go, +> nie przywracamy, nie tworzymy obok nowego. Uzasadnienie: soft-delete to +> jawna deklaracja „tego tu nie ma"; cichy update ją podważa, auto-restore +> pozwoliłby importowi wskrzeszać rzeczy skasowane celowo, a nowy rekord obok +> odtwarzałby problem duplikatów. + +**DECYZJA OBOWIĄZUJĄCA: przywróć rekord z kosza i odnotuj to w rejestrze.** + +Uzasadnienie właściciela: **PBN jest źródłem prawdy** dla tych publikacji — +skoro rekord nadal tam jest, ma wrócić także do BPP. + +Zastrzeżenie z pierwotnej decyzji nie zostało uznane za nieważne, tylko +przeniesione: z „nie róbmy tego" na „róbmy, ale zostawmy ślad". Śladem jest +model `pbn_integrator.RekordPrzywroconyPrzezImport` (migracja `0001`) — +rekord (generic FK), data, publikacja PBN, ścieżka importu. Dzięki niemu +operator, który znajdzie w bazie publikację skasowaną przez siebie tydzień +wcześniej, ma gdzie sprawdzić, że wróciła z importu, kiedy i skąd. + +⚠️ Wpis powstaje **wyłącznie przy realnym wskrzeszeniu**. Zwykły re-import +żywego rekordu nie zostawia nic — inaczej rejestr zapełniłby się szumem +i przestałby cokolwiek znaczyć. + +**Zrealizowane:** helper `pbn_integrator.kosz.przywroc_jesli_w_koszu()`, +wpięty w `articles.py`, `books.py`, `chapters.py`. Testy: +`src/pbn_integrator/tests/test_przywracanie_z_kosza.py`. **Konsekwencja dla Tasków 2-5:** samo `global_objects` NIE wystarcza — każde przełączone miejsce musi po dopasowaniu **sprawdzić `deleted_at`** i odciąć diff --git a/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py index 0cff010f0..668c42efd 100644 --- a/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +++ b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py @@ -42,3 +42,65 @@ def test_get_bpp_publication_widzi_soft_deletowany_rekord(): "matching po pbn_uid musi widzieć soft-deletowany rekord" ) assert znaleziony.pk == rec.pk + + +@pytest.mark.django_db +def test_matchuj_publikacje_po_tytule_widzi_soft_deletowany(): + """Fuzzy matching importu musi znaleźć soft-skasowaną publikację. + + Inaczej re-import „nie widzi" rekordu, tworzy go od nowa — i baza dostaje + duplikat, którego operator nie zobaczy, bo oryginał siedzi w koszu. + """ + 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, ( + "fuzzy matching nie widzi soft-deletowanego rekordu -> re-import " + "utworzy duplikat" + ) + assert wynik.pk == rec.pk + + +@pytest.mark.django_db +def test_matchuj_publikacje_po_doi_widzi_soft_deletowany(): + """To samo dla DOI. + + ⚠️ Tytuł MUSI być zgodny, mimo że matchujemy po DOI: + ``_try_match_pub_by_doi`` zawęża co prawda po DOI, ale kandydata i tak + przepuszcza przez próg podobieństwa tytułu (0.80). Pierwsza wersja tego + testu podawała celowo inny tytuł i padała — nie dlatego, że kod nie widzi + kosza, tylko dlatego, że tak działa matching. Wartość testu jest w tym, + żeby mierzył widoczność kosza, a nie regułę podobieństwa. + """ + from import_common.core.publikacja import matchuj_publikacje + + tytul = "Publikacja z DOI do matchowania importu" + rec = baker.make( + Wydawnictwo_Ciagle, + tytul_oryginalny=tytul, + rok=2021, + doi="10.1234/test.soft.delete", + ) + rec.delete() + + wynik = matchuj_publikacje( + Wydawnictwo_Ciagle, + title=tytul, + year=2021, + doi="10.1234/test.soft.delete", + ) + + assert wynik is not None, "matching po DOI nie widzi kosza" + assert wynik.pk == rec.pk diff --git a/src/import_common/core/publikacja.py b/src/import_common/core/publikacja.py index 81e0b4f4d..506a1f574 100644 --- a/src/import_common/core/publikacja.py +++ b/src/import_common/core/publikacja.py @@ -78,13 +78,29 @@ def _check_candidate(candidate, title: str, threshold: float) -> bool: return False +def widzacy_manager(klass): + """Menedżer widzący TAKŻE rekordy w koszu. + + Matching importu należy do „kategorii B" — miejsc, które MUSZĄ widzieć + soft-skasowane wiersze. Gdy ich nie widzi, re-import uznaje rekord za + nieistniejący i tworzy DUPLIKAT, którego operator nie zauważy, bo + oryginał siedzi w koszu. + + ``getattr`` z fallbackiem, bo ``klass`` bywa też ``Rekord`` — a to widok + (``managed=False``), nie ``SoftDeleteModel``, więc ``global_objects`` nie + ma. Jest za to odfiltrowany po ``deleted_at`` już na poziomie SQL-a + (faza 01), czyli dla niego „menedżer widzący" po prostu nie istnieje. + """ + return getattr(klass, "global_objects", klass.objects) + + def _try_match_pub_by_doi(klass, title, year, doi, doi_matchuj_tylko_nadrzedne, debug): """Próbuje dopasować publikację po DOI.""" doi = normalize_doi(doi) if not doi: return None - zapytanie = klass.objects.filter(doi__istartswith=doi, rok=year) + zapytanie = widzacy_manager(klass).filter(doi__istartswith=doi, rok=year) if doi_matchuj_tylko_nadrzedne and hasattr(klass, "wydawnictwo_nadrzedne_id"): zapytanie = zapytanie.filter(wydawnictwo_nadrzedne_id=None) @@ -105,7 +121,7 @@ def _try_match_pub_by_zrodlo(klass, title, year, zrodlo): if zrodlo is None or not hasattr(klass, "zrodlo"): return None try: - return klass.objects.get( + return widzacy_manager(klass).get( tytul_oryginalny__istartswith=title, rok=year, zrodlo=zrodlo ) except klass.DoesNotExist: @@ -121,8 +137,10 @@ def _build_isbn_query(klass, isbn_matchuj_tylko_nadrzedne): """Buduje zapytanie dla matchowania ISBN.""" from django.contrib.contenttypes.models import ContentType - zapytanie = klass.objects.exclude(isbn=None, e_isbn=None).exclude( - isbn="", e_isbn="" + zapytanie = ( + widzacy_manager(klass) + .exclude(isbn=None, e_isbn=None) + .exclude(isbn="", e_isbn="") ) if not isbn_matchuj_tylko_nadrzedne: @@ -178,7 +196,8 @@ def _try_match_pub_by_uri(klass, title, public_uri, debug): return None res = ( - klass.objects.filter(Q(www=public_uri) | Q(public_www=public_uri)) + widzacy_manager(klass) + .filter(Q(www=public_uri) | Q(public_www=public_uri)) .annotate(podobienstwo=TrigramSimilarity(normalized_db_title, title.lower())) .order_by("-podobienstwo")[:2] ) @@ -232,7 +251,8 @@ def _try_match_pub_by_title(klass, title, year, debug, isbn=None): """ # Najpierw próba z istartswith res = ( - klass.objects.filter(tytul_oryginalny__istartswith=title, rok=year) + widzacy_manager(klass) + .filter(tytul_oryginalny__istartswith=title, rok=year) .annotate(podobienstwo=TrigramSimilarity(normalized_db_title, title.lower())) .order_by("-podobienstwo")[:2] ) @@ -246,7 +266,8 @@ def _try_match_pub_by_title(klass, title, year, debug, isbn=None): # Ostatnia szansa - tylko po roku z niskim progiem res = ( - klass.objects.filter(rok=year) + widzacy_manager(klass) + .filter(rok=year) .annotate(podobienstwo=TrigramSimilarity(normalized_db_title, title.lower())) .order_by("-podobienstwo")[:2] ) From 62bf4f79899a00f26b0ea2f71b19e9c47f329a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 02:53:38 +0200 Subject: [PATCH 59/75] fix(soft-delete): ksiazka-matka i czyszczenie przed re-importem (Task 5, 6) TASK 5. Matching ksiazki-matki rozdzialu (chapters.py) szukal przez Wydawnictwo_Zwarte.objects, wiec skasowanej ksiazki nie widzial: importer uznawal ja za nieistniejaca i importowal PONOWNIE. Powstawal duplikat ksiazki, a rozdzialy odpinaly sie od oryginalu. Lookup wydzielony do znajdz_ksiazke_nadrzedna() -- glownie po to, zeby dalo sie go przetestowac bez budowania calego JSON-a z PBN. Trafienie w kosz oznacza wskrzeszenie, zgodnie z decyzja z Taska 1b; wpis w rejestrze dostaje osobne zrodlo "chapters:ksiazka-nadrzedna", zeby dalo sie odroznic wskrzeszenie ksiazki od wskrzeszenia samego rozdzialu. Kontrakt zachowany: brak trafienia to nadal None (a nie wyjatek), bo wolajacy ma wtedy zaimportowac ksiazke z PBN. Przypiete testem. TASK 6. _delete_existing_publications w pbn_import mial dac czysty stan przed pelnym re-importem, ale po fazie 02 QuerySet.delete() jest MIEKKIE. Zmienione na hard_delete() ORAZ global_objects -- oba potrzebne z roznych powodow: - hard_delete(), bo inaczej publikacje zostaja w bazie i "czyszczenie" nie czysci; - global_objects, bo objects nie widzi kosza, wiec publikacje PBN skasowane wczesniej przez operatora przezylyby czyszczenie, a potem re-import by je wskrzesil. Operator zobaczylby z powrotem rekordy, ktore usunal, i to bez sladu, ze przezyly czyszczenie. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../commands/fix_import_dat_oswiadczen_pbn.py | 2 +- src/pbn_import/utils/publication_import.py | 19 ++++++++- src/pbn_integrator/importer/chapters.py | 27 +++++++++++-- .../tests/test_przywracanie_z_kosza.py | 39 +++++++++++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/pbn_import/management/commands/fix_import_dat_oswiadczen_pbn.py b/src/pbn_import/management/commands/fix_import_dat_oswiadczen_pbn.py index e9a77ba60..89ca5699a 100644 --- a/src/pbn_import/management/commands/fix_import_dat_oswiadczen_pbn.py +++ b/src/pbn_import/management/commands/fix_import_dat_oswiadczen_pbn.py @@ -218,7 +218,7 @@ def _print_summary( self._print_missing_details( missing_link, "Brak powiązania autor-publikacja", - lambda x: (f"{x[1]}: {x[0]}{f', {x[2]}' if x[2] else ''} (PBN ID: {x[3]})"), + lambda x: f"{x[1]}: {x[0]}{f', {x[2]}' if x[2] else ''} (PBN ID: {x[3]})", ) def _should_skip_by_year(self, wa, year_range): diff --git a/src/pbn_import/utils/publication_import.py b/src/pbn_import/utils/publication_import.py index 8e256f820..53038b03a 100644 --- a/src/pbn_import/utils/publication_import.py +++ b/src/pbn_import/utils/publication_import.py @@ -130,8 +130,23 @@ def _delete_existing_publications(self, current_step, total_steps): ) self.log("warning", "Usuwanie istniejących publikacji PBN") - deleted_zwarte = Wydawnictwo_Zwarte.objects.exclude(pbn_uid_id=None).delete()[0] - deleted_ciagle = Wydawnictwo_Ciagle.objects.exclude(pbn_uid_id=None).delete()[0] + # ⚠️ `global_objects` ORAZ `hard_delete()` — jedno i drugie konieczne. + # + # `hard_delete()`, bo po fazie 02 `QuerySet.delete()` na modelu + # soft-delete jest MIĘKKIE: publikacje zostałyby w bazie, a ten krok + # ma dać czysty stan przed pełnym re-importem z PBN. + # + # `global_objects`, bo `objects` nie widzi kosza: publikacje PBN + # skasowane wcześniej przez operatora przetrwałyby „usuwanie + # istniejących", a potem re-import by je wskrzesił (patrz + # `przywroc_jesli_w_koszu`) — operator zobaczyłby z powrotem rekordy, + # które usunął, i to bez śladu, że przeżyły czyszczenie. + deleted_zwarte = Wydawnictwo_Zwarte.global_objects.exclude( + pbn_uid_id=None + ).hard_delete()[0] + deleted_ciagle = Wydawnictwo_Ciagle.global_objects.exclude( + pbn_uid_id=None + ).hard_delete()[0] self.log( "info", diff --git a/src/pbn_integrator/importer/chapters.py b/src/pbn_integrator/importer/chapters.py index b3c99ae81..f8e733ff1 100644 --- a/src/pbn_integrator/importer/chapters.py +++ b/src/pbn_integrator/importer/chapters.py @@ -38,6 +38,28 @@ logger = logging.getLogger(__name__) +def znajdz_ksiazke_nadrzedna(pbn_book_id): + """Książka-matka rozdziału, po ``pbn_uid`` — RAZEM Z KOSZEM. + + Wydzielone z ``importuj_rozdzial`` po to, żeby dało się to przetestować + bez budowania całego JSON-a z PBN. + + Bez widzenia kosza re-import rozdziału, którego książka-matka została + skasowana, uznawał ją za nieistniejącą i importował PONOWNIE — tworząc + duplikat książki, a przy okazji odpinając rozdziały od oryginału. + + Trafienie w kosz oznacza wskrzeszenie (decyzja właściciela, 2026-08-08): + skoro PBN nadal ma tę książkę, ma ją mieć też BPP. Fakt ląduje + w rejestrze ``RekordPrzywroconyPrzezImport``. + """ + ksiazka = Wydawnictwo_Zwarte.global_objects.filter(pbn_uid_id=pbn_book_id).first() + if ksiazka is None: + return None + + przywroc_jesli_w_koszu(ksiazka, ksiazka.pbn_uid, "chapters:ksiazka-nadrzedna") + return ksiazka + + def _chapter_json_z_nadrzednego(wydawnictwo_nadrzedne, mongoId): """Zwraca sub-słownik rozdziału z ``chapters`` wyd. nadrzędnego albo ``{}``. @@ -100,9 +122,8 @@ def importuj_rozdzial( pbn_json = pbn_publication.current_version["object"] orig_pbn_json = copy.deepcopy(pbn_json) # noqa pbn_book_id = pbn_json.pop("book")["id"] - try: - wydawnictwo_nadrzedne = Wydawnictwo_Zwarte.objects.get(pbn_uid_id=pbn_book_id) - except Wydawnictwo_Zwarte.DoesNotExist: + wydawnictwo_nadrzedne = znajdz_ksiazke_nadrzedna(pbn_book_id) + if wydawnictwo_nadrzedne is None: wydawnictwo_nadrzedne = importuj_ksiazke( pbn_book_id, default_jednostka, diff --git a/src/pbn_integrator/tests/test_przywracanie_z_kosza.py b/src/pbn_integrator/tests/test_przywracanie_z_kosza.py index ee3be41f1..6b1fe1b47 100644 --- a/src/pbn_integrator/tests/test_przywracanie_z_kosza.py +++ b/src/pbn_integrator/tests/test_przywracanie_z_kosza.py @@ -81,3 +81,42 @@ def test_niemodelowy_wynik_matchingu_nie_wywala_helpera(): assert przywroc_jesli_w_koszu("Tytul A ;; Tytul B", publication, "books") is False assert przywroc_jesli_w_koszu(None, publication, "books") is False + + +@pytest.mark.django_db +def test_ksiazka_nadrzedna_w_koszu_jest_znajdowana_i_wskrzeszana(): + """Re-import rozdziału nie może zgubić skasowanej książki-matki. + + Bez widzenia kosza importer uznawał ją za nieistniejącą i importował + PONOWNIE — powstawał duplikat książki, a rozdziały odpinały się od + oryginału. + """ + from pbn_api.models import Publication + from pbn_integrator.importer.chapters import znajdz_ksiazke_nadrzedna + from pbn_integrator.models import RekordPrzywroconyPrzezImport + + from bpp.models import Wydawnictwo_Zwarte + + pub_ksiazki = baker.make(Publication) + ksiazka = baker.make(Wydawnictwo_Zwarte, pbn_uid=pub_ksiazki) + ksiazka.delete() + + znaleziona = znajdz_ksiazke_nadrzedna(pub_ksiazki.pk) + + assert znaleziona is not None, "ksiazka-matka w koszu nie zostala znaleziona" + assert znaleziona.pk == ksiazka.pk + assert Wydawnictwo_Zwarte.objects.filter(pk=ksiazka.pk).exists(), ( + "ksiazka-matka nie zostala wskrzeszona" + ) + assert RekordPrzywroconyPrzezImport.objects.filter( + object_id=ksiazka.pk, zrodlo_importu="chapters:ksiazka-nadrzedna" + ).exists() + + +@pytest.mark.django_db +def test_brak_ksiazki_nadrzednej_zwraca_none(): + """Kontrakt zachowany: brak trafienia to ``None``, a nie wyjątek — + wołający ma wtedy zaimportować książkę z PBN.""" + from pbn_integrator.importer.chapters import znajdz_ksiazke_nadrzedna + + assert znajdz_ksiazke_nadrzedna("nie-istnieje-taki-pbn-uid") is None From 1c2bd74f07be4fdfe6fbd062137642932e95c96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 02:54:10 +0200 Subject: [PATCH 60/75] style(soft-delete): kolejnosc importow w tescie ksiazki-nadrzednej Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- src/pbn_integrator/tests/test_przywracanie_z_kosza.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pbn_integrator/tests/test_przywracanie_z_kosza.py b/src/pbn_integrator/tests/test_przywracanie_z_kosza.py index 6b1fe1b47..fe4bd5b24 100644 --- a/src/pbn_integrator/tests/test_przywracanie_z_kosza.py +++ b/src/pbn_integrator/tests/test_przywracanie_z_kosza.py @@ -91,12 +91,11 @@ def test_ksiazka_nadrzedna_w_koszu_jest_znajdowana_i_wskrzeszana(): PONOWNIE — powstawał duplikat książki, a rozdziały odpinały się od oryginału. """ + from bpp.models import Wydawnictwo_Zwarte from pbn_api.models import Publication from pbn_integrator.importer.chapters import znajdz_ksiazke_nadrzedna from pbn_integrator.models import RekordPrzywroconyPrzezImport - from bpp.models import Wydawnictwo_Zwarte - pub_ksiazki = baker.make(Publication) ksiazka = baker.make(Wydawnictwo_Zwarte, pbn_uid=pub_ksiazki) ksiazka.delete() From b2cb23aa237179eff888a33343aeb32f43b0bdc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 02:54:46 +0200 Subject: [PATCH 61/75] revert(soft-delete): cofniecie churnu formatowania w cudzym pliku ruff format na calym katalogu pbn_import zagarnal fix_import_dat_oswiadczen_pbn.py, ktorego faza 03 nie dotyka. Ta sama lekcja co w fazie 02: formatowac TYLKO wlasne pliki, inaczej PR robi sie nieczytelny. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../management/commands/fix_import_dat_oswiadczen_pbn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pbn_import/management/commands/fix_import_dat_oswiadczen_pbn.py b/src/pbn_import/management/commands/fix_import_dat_oswiadczen_pbn.py index 89ca5699a..e9a77ba60 100644 --- a/src/pbn_import/management/commands/fix_import_dat_oswiadczen_pbn.py +++ b/src/pbn_import/management/commands/fix_import_dat_oswiadczen_pbn.py @@ -218,7 +218,7 @@ def _print_summary( self._print_missing_details( missing_link, "Brak powiązania autor-publikacja", - lambda x: f"{x[1]}: {x[0]}{f', {x[2]}' if x[2] else ''} (PBN ID: {x[3]})", + lambda x: (f"{x[1]}: {x[0]}{f', {x[2]}' if x[2] else ''} (PBN ID: {x[3]})"), ) def _should_skip_by_year(self, wa, year_range): From ab3fa519fd39232c6638a0a1eeb2583d644aafcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 02:59:36 +0200 Subject: [PATCH 62/75] fix(soft-delete): transfer przy scalaniu autorow widzi kosz + rejestr decyzji (Task 7, 8) TASK 7. Jedyna realna zmiana w tym tasku: transfer w deduplikator_autorow/utils/merge.py. Cytowane w planie linie byly NIEAKTUALNE (plik zrefaktoryzowany -- ostrzegal o tym handoff fazy 02), wiec lookupy odnalezione na nowo: dwa miejsca, jedno dla publikacji (Praca_*), jedno dla through-modeli. Bez widzenia kosza autorstwa i prace soft-skasowane zostawaly przy autorze-duplikacie, ktory po scaleniu ma zniknac -- powstawaly SIEROTY. W fazie 04 zablokowalyby dodatkowo guard PROTECT, wiec problem ujawnilby sie dopiero tam, w miejscu niezwiazanym z przyczyna. Reszta Taska 7 to decyzje "ZOSTAW objects", zapisane jako rejestr w pliku testowym (ewaluacja, snapshot odpiec, komparator PBN, REST API). Sprawdzone przy okazji: wszystkie .update() w tych miejscach dotycza przypieta / dyscyplina_naukowa / afiliuje, a NIE deleted_at -- gate BppSoftDeleteQuerySet.update() z fazy 01 sie tam nie odpala. TASK 8. Skanowanie do dedupu publikacji ZOSTAJE na objects i dostaje test. Rekord w koszu nie jest duplikatem do rozstrzygniecia; podsuwanie go operatorowi kazaloby mu scalac rzecz, ktora sam usunal. To decyzja ODWROTNA niz przy matchingu importu -- i wlasnie dlatego jest przypieta testem, zamiast zostac "oczywista". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../test_audyt_kategorii_b.py | 77 +++++++++++++++++++ src/deduplikator_autorow/utils/merge.py | 22 +++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py index 668c42efd..88632411c 100644 --- a/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +++ b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py @@ -104,3 +104,80 @@ def test_matchuj_publikacje_po_doi_widzi_soft_deletowany(): assert wynik is not None, "matching po DOI nie widzi kosza" assert wynik.pk == rec.pk + + +# --------------------------------------------------------------------------- +# REJESTR DECYZJI AUDYTU (Task 7) — miejsca ZOSTAWIONE na `objects` +# --------------------------------------------------------------------------- +# +# Reguła nadrzędna: default „pomijaj skasowane" jest tu ZNACZNIE bezpieczniejszy +# niż odwrotny. Zmieniamy wyłącznie miejsca, które MUSZĄ widzieć kosz, żeby nie +# zostawić sierot albo nie zgubić transferu. Poniższe zostają bez zmian — +# świadomie, a nie przez przeoczenie: +# +# * ewaluacja (`ewaluacja_optymalizacja/**`, `ewaluacja_dwudyscyplinowcy`) — +# praca w koszu nie bierze udziału w punktacji ani optymalizacji; +# * snapshot odpięć (`snapshot_odpiec`) — praca w koszu to brak odpięcia do +# zapisania; +# * komparator PBN (`komparator_pbn/views.py`) — soft-delete wycofuje +# oświadczenia z PBN (faza 05), więc BPP tej pracy już nie deklaruje; +# `objects` daje obraz spójny z PBN; +# * REST API (`api_v1/viewsets/*`) — publiczne API nie może serwować autorstw +# pracy, która sama z API zniknęła; +# * skanowanie do dedupu publikacji (`deduplikator_publikacji/tasks.py`) — +# patrz test niżej. +# +# ⚠️ Wszystkie `.update()` w tych miejscach dotyczą `przypieta`, +# `dyscyplina_naukowa` i `afiliuje` — NIE `deleted_at`, więc gate +# `BppSoftDeleteQuerySet.update()` z fazy 01 się nie odpala. Sprawdzone. + + +@pytest.mark.django_db +def test_dedup_publikacji_NIE_widzi_kosza(): + """Skanowanie do deduplikacji ma pomijać kosz — i to jest poprawne. + + Rekord skasowany nie jest duplikatem do rozstrzygnięcia; podsuwanie go + operatorowi kazałoby mu scalać rzecz, którą sam usunął. To odwrotna + decyzja niż przy matchingu importu (który MUSI widzieć kosz, żeby nie + tworzyć duplikatów) — i właśnie dlatego jest tu przypięta testem, a nie + zostawiona jako „oczywista". + """ + from deduplikator_publikacji.tasks import _get_publications_to_scan + + zywa = baker.make(Wydawnictwo_Ciagle, rok=2020) + kosz = baker.make(Wydawnictwo_Ciagle, rok=2020) + kosz.delete() + + zebrane = {pub.pk for _ct, pub in _get_publications_to_scan(2020, 2020)} + + assert zywa.pk in zebrane + assert kosz.pk not in zebrane, "dedup podsuwa operatorowi rekord z kosza" + + +@pytest.mark.django_db +def test_transfer_przy_scalaniu_autorow_widzi_kosz(): + """Scalanie autorów MUSI przenieść też autorstwa w koszu. + + Inaczej zostają SIEROTY: wiersze wskazujące na autora-duplikat, który po + scaleniu ma zniknąć. W fazie 04 zablokują dodatkowo guard PROTECT — czyli + problem ujawniłby się dopiero tam, w miejscu niezwiązanym z przyczyną. + """ + from bpp.models import Autor, Wydawnictwo_Ciagle_Autor + from deduplikator_autorow.utils.merge import wiersze_do_transferu + + duplikat = baker.make(Autor) + wc = baker.make(Wydawnictwo_Ciagle) + zywe = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, autor=duplikat, kolejnosc=0) + skasowane = baker.make( + Wydawnictwo_Ciagle_Autor, rekord=wc, autor=duplikat, kolejnosc=1 + ) + skasowane.delete() + + do_transferu = { + x.pk for x in wiersze_do_transferu(Wydawnictwo_Ciagle_Autor, duplikat) + } + + assert zywe.pk in do_transferu + assert skasowane.pk in do_transferu, ( + "autorstwo w koszu nie zostanie przeniesione -> sierota przy duplikacie" + ) diff --git a/src/deduplikator_autorow/utils/merge.py b/src/deduplikator_autorow/utils/merge.py index 6307c555b..20cc897fe 100644 --- a/src/deduplikator_autorow/utils/merge.py +++ b/src/deduplikator_autorow/utils/merge.py @@ -227,6 +227,24 @@ def _transfer_authorship_record( return True +def wiersze_do_transferu(model, autor_duplikat): + """Rekordy duplikatu do przeniesienia — RAZEM Z KOSZEM. + + Scalanie autorów należy do „kategorii B". Gdyby transfer widział tylko + żywe wiersze, autorstwa (i prace) soft-skasowane zostałyby przy autorze + duplikacie — a ten po scaleniu ma zniknąć. Powstałyby SIEROTY: wiersze + w koszu wskazujące na autora, którego już nie ma. W fazie 04 zablokują + dodatkowo guard PROTECT, więc problem ujawniłby się dopiero tam — + w miejscu niezwiązanym z przyczyną. + + ``getattr`` z fallbackiem, bo ta sama funkcja obsługuje modele + soft-delete (publikacje z fazy 02, through-modele z fazy 01) i takie, + które nimi nie są. + """ + manager = getattr(model, "global_objects", model.objects) + return manager.filter(autor=autor_duplikat) + + def _transfer_simple_authorship( model, model_label, glowny_autor, autor_duplikat, user, skip_pbn, results ): @@ -236,7 +254,7 @@ def _transfer_simple_authorship( """ from pbn_export_queue.models import PBN_Export_Queue - for praca in model.objects.filter(autor=autor_duplikat): + for praca in wiersze_do_transferu(model, autor_duplikat): # Przemapuj autora praca.autor = glowny_autor praca.save() @@ -337,7 +355,7 @@ def scal_autora( # 1-3. Rekordy autorstwa (ciągłe, zwarte, patenty) for model_label, model, log_publication in authorship_models: - for record in model.objects.filter(autor=autor_duplikat): + for record in wiersze_do_transferu(model, autor_duplikat): _transfer_authorship_record( record, glowny_autor, From 28efdcb21cdc787f07c2eeac57d91a6fe2f2e404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 03:01:26 +0200 Subject: [PATCH 63/75] docs(soft-delete): handoff przed faza 04 Najwazniejsze, co niesie: zmiana decyzji #14 (PRZYWROC + ODNOTUJ zamiast POMIN), szesc faktow o kodzie, ktore w fazie 03 kosztowaly runde poprawek (m.in. Patent bez pbn_uid, rekord_w_bpp zwracajacy STRING, prog podobienstwa tytulu przy matchingu po DOI), oraz lista tego, co czeka faze 04 -- w tym odwrotne OneToOne omijajace soft-delete, na ktore faza 04 trafi ponownie, bo dotyka tej samej relacji. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../HANDOFF-soft-delete-faza-04.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/superpowers/HANDOFF-soft-delete-faza-04.md diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-04.md b/docs/superpowers/HANDOFF-soft-delete-faza-04.md new file mode 100644 index 000000000..8ddb0188f --- /dev/null +++ b/docs/superpowers/HANDOFF-soft-delete-faza-04.md @@ -0,0 +1,116 @@ +# Handoff: soft-delete, start fazy 04 + +> Po zamknięciu **fazy 03** (audyt kategorii B), 2026-08-08. +> Czytaj to zamiast odtwarzania historii z gita. + +--- + +## 1. Gdzie jesteśmy + +| | | +|---|---| +| Gałąź | `feat/soft-delete-03`, worktree `~/Programowanie/bpp-soft-delete-03` | +| Baza | `feat/soft-delete` (zawiera fazy 01 i 02; PR #741 scalony) | +| Migracje fazy 03 | `pbn_integrator/0001` (nowy model rejestru) | + +Faza 03 domknęła **blocker wydania**: re-import z PBN nie tworzy już +duplikatów rekordów skasowanych miękko. + +--- + +## 2. NAJWAŻNIEJSZE: zmieniona decyzja o polityce kosza + +Plan fazy 03 miał decyzję #14 „**POMIŃ + ZARAPORTUJ**”, argumentując, że +auto-restore pozwoliłby importowi wskrzeszać rzeczy skasowane celowo. + +**Właściciel systemu zmienił ją 2026-08-08 na „PRZYWRÓĆ + ODNOTUJ”.** +Uzasadnienie: PBN jest źródłem prawdy dla tych publikacji — skoro rekord tam +jest, ma wrócić także do BPP. + +Zastrzeżenie z pierwotnej decyzji nie zostało uznane za nieważne, tylko +przeniesione: z „nie róbmy tego” na „róbmy, ale zostawmy ślad”. Śladem jest +`pbn_integrator.RekordPrzywroconyPrzezImport` — rekord (generic FK), data, +publikacja PBN, ścieżka importu. + +⚠️ Wpis powstaje **wyłącznie przy realnym wskrzeszeniu**. Zwykły re-import +żywego rekordu nie zostawia nic — inaczej rejestr zapełniłby się szumem +i przestałby cokolwiek znaczyć. Przypięte osobnym testem. + +Punkt wejścia: `pbn_integrator.kosz.przywroc_jesli_w_koszu()`, wpięty +w `articles.py`, `books.py`, `chapters.py` (ta ostatnia w dwóch miejscach — +sam rozdział i jego książka-matka, rozróżnialne po `zrodlo_importu`). + +--- + +## 3. Co faza 03 zmieniła w kodzie + +| Miejsce | Zmiana | +|---|---| +| `pbn_api/models/publication.py` | `get_bpp_publication` / `rekord_w_bpp` matchują przez `global_objects` modeli źródłowych zamiast widoku `Rekord` | +| `import_common/core/publikacja.py` | helper `widzacy_manager()`; 6 lookupów matchingu widzi kosz | +| `pbn_integrator/importer/chapters.py` | `znajdz_ksiazke_nadrzedna()` — wydzielony, widzi kosz, wskrzesza | +| `pbn_import/utils/publication_import.py` | `global_objects` + `hard_delete()` przy czyszczeniu przed re-importem | +| `deduplikator_autorow/utils/merge.py` | `wiersze_do_transferu()` — transfer widzi kosz | + +**ZOSTAWIONE świadomie na `objects`** (rejestr decyzji w +`src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`): ewaluacja, +snapshot odpięć, komparator PBN, REST API, skanowanie do dedupu publikacji. + +--- + +## 4. Fakty, które kosztowały rundę poprawek + +- **`Patent` NIE ma pola `pbn_uid`.** Wpisanie go do listy modeli + matchowanych po `pbn_uid` wywala `FieldError` przy pierwszym `.filter()`. +- **`get_bpp_publication` i `rekord_w_bpp` zachowują się RÓŻNIE**: przy wielu + trafieniach pierwsza zwraca `None`, druga sklejone tytuły; przy braku + trafień druga spada do fuzzy matchingu. Różnica jest historyczna i celowo + zachowana — nie ujednolicaj jej „przy okazji”. +- **`rekord_w_bpp` bywa STRINGIEM.** Każdy helper na ścieżce importu musi to + przyjąć bez wyjątku (`getattr`, nie `isinstance`), inaczej wywróci cały + przebieg z powodu niezwiązanego z koszem. +- **`_try_match_pub_by_doi` mimo zawężenia po DOI przepuszcza kandydata przez + próg podobieństwa tytułu (0.80).** Test z celowo innym tytułem padnie — + i NIE będzie to dowód, że kod nie widzi kosza. +- **Linie cytowane w planie dla `merge.py` były nieaktualne** (plik + zrefaktoryzowany). Szukaj lookupów na nowo, nie po numerach. +- **`GlobalManager` pakietu nie ma metod domenowych.** `wydawnictwa_ + nadrzedne_dla_innych()` zostaje na `objects` — świadomie. + +--- + +## 5. Co czeka fazę 04 (guardy PROTECT) + +- **Nikt nie przeplata `AutorManager`** — po uczynieniu `Autor` + `SoftDeleteModel` husk autora zostanie widoczny. +- **`Autor.restore()` musi nadpisać `strict=False`** (inwariant z docstringu + `bpp/models/soft_delete.py`). +- **Soft-skasowana publikacja NADAL trzyma referencję O2O PROTECT do autora** + — `Praca_Habilitacyjna.autor`. Dziś oznacza to `ProtectedError` przy próbie + skasowania autora i jest to zachowanie poprawne (rekord istnieje, nie wolno + go osierocić), ale faza 04 musi to obsłużyć w UI, a nie zostawić jako gołe + 500. +- **Odwrotne `OneToOne` omija soft-delete** (`autor.praca_habilitacyjna` idzie + przez `_base_manager`). Naprawione punktowo w `RokHabilitacjiView`; faza 04 + dotyka tej samej relacji, więc trafi na to ponownie. + +## 6. Dług nadal otwarty + +| Sprawa | Stan | +|---|---| +| **Wycieki ORM (kanarek `xfail(strict=True)`)** | `test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at` dalej `xfail`. ⚠️ Tabelka 10 wycieków w `reviews/2026-08-07-faza-02-inwentaryzacja-orm.md` NIE jest listą zadań — patrz self-review w tym pliku. Potrzebne narzędzie model-aware (pytające `_meta`), nie rozszerzanie listy nazw | +| **PR upstream `django-easy-audit`** | gałąź gotowa w `~/Programowanie/django-easy-audit`, przetestowana na Django 5.2 i 6.1, **niewypchnięta** — czeka na decyzję o koncie/forku | +| **`bpp-deploy`** | kontrolka „kronika views: N” po `bpp.0499` wypisze 0 i może zmylić operatora | +| Pomiar `0492` i narzutu GiST | wciąż nikt nie zmierzył (dług fazy 01) | +| Strategia wydania | rekomendacja bez zmian: wydać po fazie 04 | + +## 7. Proces — co znowu się sprawdziło + +- **Mutacja jest jedynym dowodem, że test coś pilnuje.** W fazie 03 pierwsza + czerwień testu rejestru była `ImportError` — a to nie dowodzi, że asercje + działają. Dopiero wyłączenie warunku na `deleted_at` pokazało, że tak. +- **`ruff format` na całym katalogu znowu zagarnął cudzy plik.** Formatuj + tylko własne; cofnięcie kosztowało osobny commit. +- **`make tests-without-playwright` zwraca EXIT 0 mimo porażek** (sprawdzone + w fazie 02: 7 failed + 2 errors przy zerowym kodzie). Czytaj podsumowanie + pytest, nie kod wyjścia. From 5a46576cd78115a701b8b30886aacf9eab321982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 09:51:01 +0200 Subject: [PATCH 64/75] docs(soft-delete): mapa pozostalych faz w handoffie 04 Handoff byl skupiony wylacznie na fazie 04. Dopisana pelna mapa 04-08 wraz z zaleznosciami (kolejnosc nie jest dowolna: 06 konsumuje kolejke z 05, 07 konsumuje log z 06) oraz lista tego, co fazy 01-03 juz pod nie podlozyly -- sygnatury delete/restore z user/reason, emitowane sygnaly i kontrakt ostatnio_zmieniony. Odnotowane osobno: NAGROBKI na zewnatrz (OAI-PMH status=deleted, CERIF, API) sa nadal NIEZROBIONE, mimo ze handoff fazy 02 traktowal je jako bramke wydania. Fundament gotowy, brakuje ekspozycji; nie naleza do zadnej z faz 04-08, wiec bez tego wpisu przepadlyby miedzy fazami. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../HANDOFF-soft-delete-faza-04.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-04.md b/docs/superpowers/HANDOFF-soft-delete-faza-04.md index 8ddb0188f..11dda8d5d 100644 --- a/docs/superpowers/HANDOFF-soft-delete-faza-04.md +++ b/docs/superpowers/HANDOFF-soft-delete-faza-04.md @@ -79,6 +79,42 @@ snapshot odpięć, komparator PBN, REST API, skanowanie do dedupu publikacji. --- +## 4a. Mapa pozostałych faz — co dalej + +Kolejność nie jest dowolna: każda kolejna konsumuje coś, co dostarcza poprzednia. + +| Faza | Co robi | Zależy od | +|---|---|---| +| **04 — guardy PROTECT** | flip FK `CASCADE→PROTECT` na powiązaniach autora (`*_Autor.autor`, `Praca_Doktorska.autor`) i na self-FK rozdziałów (`Wydawnictwo_Zwarte.wydawnictwo_nadrzedne`) + guard aplikacyjny; `Autor` staje się `SoftDeleteModel` | — | +| **05 — wycofanie z PBN** | `pbn_export_queue` dostaje operację `WYCOFANIE` obok `WYSYLKA`; soft-delete publikacji asynchronicznie wycofuje oświadczenia dyscyplin z profilu instytucji w PBN | kolejka PBN | +| **06 — `SoftDeleteLog`** | model audytu (kto / kiedy / dlaczego / status PBN) zasilany receiverami `post_soft_delete` / `post_restore` / `post_hard_delete`; receiver DELETE kolejkuje wycofanie z fazy 05 | **05** | +| **07 — admin** | kosz w adminie dla 5 typów publikacji + `Autor`: „Usuń" = soft-delete z powodem, filtr „Pokaż skasowane", akcja „Przywróć", osobna „Usuń trwale" (superuser) | **06** (powód → log) | +| **08 — regresja E2E** | suita domykająca całość | wszystkie | + +### Co fazy 01–03 już pod nie podłożyły + +Trzy rzeczy są gotowe i kolejne fazy mają je po prostu skonsumować — nie +trzeba ich projektować od nowa: + +- **Sygnatury `delete(user=..., reason=...)` / `restore(user=...)`** istnieją + w mixinie publikacji od fazy 02 (puste, ale obecne — kontrakt PINNED). + Faza 06 wpina w nie `SoftDeleteLog`, faza 07 wstrzykuje `request.user`. + Sygnatur nie trzeba ruszać. +- **Sygnały `post_soft_delete` / `post_restore` są emitowane** i przypięte + testem (faza 02). Faza 06 podpina receivery pod gotowy mechanizm. +- **Kontrakt `ostatnio_zmieniony`** — soft-delete bumpuje znacznik, więc + nagrobki dla harvestu przyrostowego są odpytywalne przez + `deleted_objects.filter(ostatnio_zmieniony__gte=X)` już teraz, bez czekania + na `SoftDeleteLog` z fazy 06. + +### Nagrobki na zewnątrz — NIEZROBIONE, bramka wydania + +Handoff fazy 02 (§3.1) traktował to jako bramkę wydania i **nadal nie jest +zrobione**: OAI-PMH `
    ` w `src/cerif_export`, odpowiednik +w CERIF, sposób odkrycia usuniętych w `/api/v1/`. Fundament (bump +`ostatnio_zmieniony`) jest gotowy; brakuje samej ekspozycji. Do zaplanowania +jako osobny PR — nie należy do żadnej z faz 04–08. + ## 5. Co czeka fazę 04 (guardy PROTECT) - **Nikt nie przeplata `AutorManager`** — po uczynieniu `Autor` From 5523b0e27977b0b2b3ecb29e8f02a27c3095d502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 16:19:17 +0200 Subject: [PATCH 65/75] docs(soft-delete): bramka wydania na faze 07 + nagrobki wciagniete do fazy 05 DWIE DECYZJE WLASCICIELA, 2026-08-08. 1. BRAMKA WYDANIA PRZESUNIETA Z FAZY 04 NA 07. Rekomendacja faz 01-03 brzmiala "wydac po 04" i byla uzasadniona bezpieczenstwem -- guardy zapobiegaja osieroceniu. Ale waskim gardlem nie jest bezpieczenstwo, tylko spojnosc dla operatora. Sprawdzone na kodzie: src/bpp/admin/ nie ma DZIS zadnego kosza. Trafienia deleted_at to hydraulika -- ukryte pole formularza dla walidacji ograniczen i filtry agregatow -- a nie filtr "pokaz skasowane" ani akcja "Przywroc". Jednoczesnie SoftDeleteQuerySet.delete jest juz miekki, a delete_selected Django wola wlasnie queryset. Wydanie po fazie 04 daloby wiec operatorowi: "Usun" przestaje znaczyc "zniknelo", rekordu nie da sie przywrocic ani obejrzec bez programisty, i nie da sie go usunac naprawde. Traci jedna zdolnosc i nie dostaje zadnej w zamian, bo kosz przychodzi dopiero w fazie 07. To regresja UX, nie funkcja. Fazy 01-06 scalamy dalej, ale nie wydajemy. Swiadomie odrzucone: wydanie 01+02 z tymczasowym "Usun = twardo" w adminie -- oznaczaloby dwie semantyki kasowania rownolegle i wyrzucenie tego kodu w fazie 07. 2. NAGROBKI WCIAGNIETE DO FAZY 05: OAI-PMH status=deleted, CERIF, sposob odkrycia usunietych w API. To ten sam motyw co wycofanie oswiadczen z PBN -- systemy zewnetrzne dowiaduja sie, ze cos zniknelo. Trzymane osobno przepadlyby miedzy fazami, bo zaden plan ich nie obejmowal, a handoff fazy 02 traktowal je jako bramke wydania. Fundament gotowy: soft-delete bumpuje ostatnio_zmieniony, wiec lista nagrobkow to deleted_objects.filter po tym polu. Brakuje samej ekspozycji. Termin: przed faza 07, nie przed 04. Faza 05 przemianowana na "propagacja usuniecia na zewnatrz". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../HANDOFF-soft-delete-faza-04.md | 52 ++++++++++++++++--- ...2026-06-04-soft-delete-05-pbn-wycofanie.md | 26 +++++++++- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-04.md b/docs/superpowers/HANDOFF-soft-delete-faza-04.md index 11dda8d5d..63f11adb3 100644 --- a/docs/superpowers/HANDOFF-soft-delete-faza-04.md +++ b/docs/superpowers/HANDOFF-soft-delete-faza-04.md @@ -107,13 +107,51 @@ trzeba ich projektować od nowa: `deleted_objects.filter(ostatnio_zmieniony__gte=X)` już teraz, bez czekania na `SoftDeleteLog` z fazy 06. -### Nagrobki na zewnątrz — NIEZROBIONE, bramka wydania +### Nagrobki na zewnątrz — WCIĄGNIĘTE DO FAZY 05 (decyzja 2026-08-08) -Handoff fazy 02 (§3.1) traktował to jako bramkę wydania i **nadal nie jest -zrobione**: OAI-PMH `
    ` w `src/cerif_export`, odpowiednik -w CERIF, sposób odkrycia usuniętych w `/api/v1/`. Fundament (bump -`ostatnio_zmieniony`) jest gotowy; brakuje samej ekspozycji. Do zaplanowania -jako osobny PR — nie należy do żadnej z faz 04–08. +OAI-PMH `
    ` w `src/cerif_export` (dziś **zero** +obsługi `deleted`), odpowiednik w CERIF, sposób odkrycia usuniętych +w `/api/v1/`. Fundament gotowy — soft-delete bumpuje `ostatnio_zmieniony`, +więc `Model.deleted_objects.filter(ostatnio_zmieniony__gte=X)` działa już +teraz. Brakuje wyłącznie ekspozycji. + +**Dołączone do zakresu fazy 05**, bo to ten sam motyw co wycofanie oświadczeń +z PBN: *systemy zewnętrzne dowiadują się, że coś zniknęło* — inny odbiorca, +ta sama historia. Trzymane osobno przepadłyby między fazami, bo żaden plan +ich nie obejmował. + +⚠️ Muszą być gotowe **przed fazą 07**, nie przed 04. Dopóki kasowanie jest +rzadkie, luka w OAI-PMH jest teoretyczna; faza 07 czyni kasowanie rutynowym +i dopiero wtedy zaczyna realnie boleć. + +## 4b. Strategia wydania — bramka przesunięta na fazę 07 + +> 🔁 **DECYZJA 2026-08-08.** Poprzednia rekomendacja (fazy 01–03) brzmiała +> „wydać po fazie 04”. Zmieniona: **wydajemy dopiero po fazie 07**. + +Powód nie jest bezpieczeństwem — guardy z fazy 04 domykają je w porządku. +Powodem jest **spójność dla operatora**. + +Sprawdzone na kodzie (2026-08-08): `src/bpp/admin/` nie ma dziś ŻADNEGO +kosza — trafienia `deleted_at` to hydraulika (ukryte pole formularza dla +walidacji ograniczeń, filtry agregatów), a nie filtr „pokaż skasowane” ani +akcja „Przywróć”. Jednocześnie `SoftDeleteQuerySet.delete()` jest już miękki, +a `delete_selected` Django woła właśnie queryset. + +Czyli wydanie po fazie 04 dałoby operatorowi: + +- „Usuń” przestaje znaczyć „zniknęło” — rekord zostaje w bazie, +- **nie da się go przywrócić ani obejrzeć bez programisty**, +- nie da się go usunąć naprawdę. + +Operator traci jedną zdolność i nie dostaje w zamian żadnej, bo kosz +z przywracaniem przychodzi dopiero w fazie 07. To regresja UX, nie funkcja. + +**Fazy 01–06 scalamy dalej do `feat/soft-delete`, ale nie wydajemy.** + +Świadomie ODRZUCONE: wydanie faz 01+02 z tymczasowym „Usuń = twardo” +w adminie. Oznaczałoby utrzymywanie dwóch semantyk kasowania równolegle +i wyrzucenie tego kodu w fazie 07 — koszt bez odbiorcy. ## 5. Co czeka fazę 04 (guardy PROTECT) @@ -138,7 +176,7 @@ jako osobny PR — nie należy do żadnej z faz 04–08. | **PR upstream `django-easy-audit`** | gałąź gotowa w `~/Programowanie/django-easy-audit`, przetestowana na Django 5.2 i 6.1, **niewypchnięta** — czeka na decyzję o koncie/forku | | **`bpp-deploy`** | kontrolka „kronika views: N” po `bpp.0499` wypisze 0 i może zmylić operatora | | Pomiar `0492` i narzutu GiST | wciąż nikt nie zmierzył (dług fazy 01) | -| Strategia wydania | rekomendacja bez zmian: wydać po fazie 04 | +| **Strategia wydania** | ⚠️ **ZMIENIONA 2026-08-08: bramka przesunięta z fazy 04 na fazę 07.** Uzasadnienie w sekcji 4b | ## 7. Proces — co znowu się sprawdziło diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md index 0ea482cd5..7e436d24d 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-05-pbn-wycofanie.md @@ -1,4 +1,28 @@ -# Soft-delete — Faza 05: PBN wycofanie (kolejka + ścieżka synchroniczna) +# Soft-delete — Faza 05: propagacja usunięcia na zewnątrz (PBN + nagrobki) + +> 🔁 **ROZSZERZENIE ZAKRESU 2026-08-08 (decyzja właściciela).** +> Do tej fazy dochodzą **NAGROBKI** — ogłaszanie usunięć konsumentom +> przyrostowym. Powód: to ten sam motyw co wycofanie z PBN — *systemy +> zewnętrzne dowiadują się, że coś zniknęło*. Inny odbiorca, ta sama +> historia. Trzymane osobno przepadłyby między fazami, bo żaden plan ich nie +> obejmował, a handoff fazy 02 (§3.1) traktował je jako bramkę wydania. +> +> **Zakres nagrobków:** +> - **OAI-PMH**: `
    ` w `src/cerif_export` (serwuje +> `ListRecords`/`ListIdentifiers`/`resumptionToken`; **dziś ZERO obsługi +> `deleted`**), respektujące `from`/`until`; +> - **CERIF**: odpowiednik nagrobka w formacie rekordu; +> - **`/api/v1/`**: sposób odkrycia usuniętych (endpoint „usunięte od…” +> albo parametr). +> +> **Fundament jest gotowy** — soft-delete bumpuje `ostatnio_zmieniony` +> (kontrakt PINNED z fazy 01), więc lista nagrobków to po prostu +> `Model.deleted_objects.filter(ostatnio_zmieniony__gte=X)`. Nie trzeba +> `SoftDeleteLog` z fazy 06. Brakuje wyłącznie ekspozycji. +> +> ⚠️ **Termin:** nagrobki muszą być gotowe przed **fazą 07**, nie przed 04. +> Dopóki kasowanie jest rzadkie, luka w OAI-PMH jest teoretyczna; faza 07 +> czyni kasowanie rutynowym i dopiero wtedy zaczyna realnie boleć. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. TDD: każdy krok najpierw PRAWDZIWY failing test → komenda + FAIL → PRAWDZIWA implementacja → komenda + PASS → commit. From b34326ee59df9c900b9999cd123e5eeacd539efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 17:54:38 +0200 Subject: [PATCH 66/75] docs(soft-delete): PR upstream do django-easy-audit wystawiony soynatan/django-easy-audit#348, z forka mpasternak/django-easy-audit. Dopisany komentarz do ich issue #175 z jedyna nowa przeslanka wobec czterech poprzednich prob: obejscie sugerowane przez maintainera przez CRUD_DIFFERENCE_CALLBACKS jest NIEOSIAGALNE, bo wyjatek leci w linii 102, a callbacki sa wolane dopiero w 113. W handoffie zapisane, co zrobic po scaleniu: skasowac easyaudit_shim.py, wywolanie zainstaluj() w BppConfig.ready() i test_easyaudit_shim.py. Przypomni o tym test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny, ktory wtedy zacznie padac jako XPASS. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- docs/superpowers/HANDOFF-soft-delete-faza-04.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-04.md b/docs/superpowers/HANDOFF-soft-delete-faza-04.md index 63f11adb3..f523fac26 100644 --- a/docs/superpowers/HANDOFF-soft-delete-faza-04.md +++ b/docs/superpowers/HANDOFF-soft-delete-faza-04.md @@ -173,7 +173,7 @@ i wyrzucenie tego kodu w fazie 07 — koszt bez odbiorcy. | Sprawa | Stan | |---|---| | **Wycieki ORM (kanarek `xfail(strict=True)`)** | `test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at` dalej `xfail`. ⚠️ Tabelka 10 wycieków w `reviews/2026-08-07-faza-02-inwentaryzacja-orm.md` NIE jest listą zadań — patrz self-review w tym pliku. Potrzebne narzędzie model-aware (pytające `_meta`), nie rozszerzanie listy nazw | -| **PR upstream `django-easy-audit`** | gałąź gotowa w `~/Programowanie/django-easy-audit`, przetestowana na Django 5.2 i 6.1, **niewypchnięta** — czeka na decyzję o koncie/forku | +| **PR upstream `django-easy-audit`** | **WYSTAWIONY 2026-08-08: [soynatan/django-easy-audit#348](https://github.com/soynatan/django-easy-audit/pull/348)** (fork `mpasternak`). Gdy zostanie scalony — skasować `src/bpp/easyaudit_shim.py`, wywołanie `zainstaluj()` w `BppConfig.ready()` i `test_easyaudit_shim.py`. Przypomni o tym test `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`, który wtedy zacznie padać | | **`bpp-deploy`** | kontrolka „kronika views: N” po `bpp.0499` wypisze 0 i może zmylić operatora | | Pomiar `0492` i narzutu GiST | wciąż nikt nie zmierzył (dług fazy 01) | | **Strategia wydania** | ⚠️ **ZMIENIONA 2026-08-08: bramka przesunięta z fazy 04 na fazę 07.** Uzasadnienie w sekcji 4b | From eb046897a6fa5008fa50331a1637f6f35cfccad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 20:51:17 +0200 Subject: [PATCH 67/75] revert(soft-delete): akcesory NIE zagladaja do kosza + test na blokera admina DECYZJA WLASCICIELA 2026-08-08. Soft-delete znaczy, ze rekordu NIE MA. Akcesor, ktory zwraca rzecz skasowana, lamie kontrakt "Rekord albo None", na ktorym stoja wszyscy jego konsumenci. COFNIETE W CALOSCI: - pbn_api/models/publication.py -- rekord_w_bpp i get_bpp_publication wracaja do pytania Rekord. Moja wersja zwracala model KONKRETNY, przez co admin PBN wywalal sie AttributeError na CALEJ changeliscie: admin/publication.py:183 wola .original, a to cached_property istniejaca wylacznie na Rekord. Ten sam blad cicho psul szablon change_form.html (Django polyka brak atrybutu w szablonie -> pusty href, objaw gorszy niz wyjatek). - import_common/core/publikacja.py -- caly widzacy_manager. Fuzzy matching tez nie ma widziec kosza. Przy okazji znika blad, ktorego nie zauwazylem: drugim wolaczem matchuj_publikacje jest deduplikator_publikacji (tasks.py:187), ktory przekazuje modele KONKRETNE -- wiec dedup zaczal podsuwac operatorowi rekordy z kosza, wbrew decyzji z Taska 8. Moj wlasny test tego nie zlapal, bo pokrywal tylko polowe skanujaca (_get_publications_to_scan), a nie matchujaca. Zaglada do kosza IMPORTER, nie akcesor -- to jego decyzja i ma siedziec w sciezce importu. TESTY: dwa testy matchingu skasowane (sprawdzaly juz-bledne zachowanie), test akcesora ODWROCONY na kontrakt "skasowane = nieznalezione". DOLOZONY test_admin_rekord_w_bpp.py -- ta metoda admina nie miala ZADNEGO pokrycia, dlatego bloker wyszedl dopiero w recenzji. Zweryfikowany mutacyjnie: przywrocenie zepsutej wersji wywala oba testy na AttributeError 'Wydawnictwo_Ciagle' object has no attribute 'original'. Znika przy okazji ustalenie #8 z recenzji: nowy przypadek "wiele trafien" byl skutkiem iterowania po czterech modelach zamiast pytania Rekord. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../test_audyt_kategorii_b.py | 113 +++++------------- src/import_common/core/publikacja.py | 35 ++---- src/pbn_api/models/publication.py | 76 +++--------- src/pbn_api/tests/test_admin_rekord_w_bpp.py | 49 ++++++++ 4 files changed, 102 insertions(+), 171 deletions(-) create mode 100644 src/pbn_api/tests/test_admin_rekord_w_bpp.py diff --git a/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py index 88632411c..1e4509761 100644 --- a/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +++ b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py @@ -1,11 +1,21 @@ -"""Kategoria B: miejsca, które MUSZĄ widzieć rekordy w koszu (faza 03). - -Po fazie 02 domyślny menedżer publikacji ukrywa soft-skasowane wiersze — -i dla większości kodu jest to zachowanie właściwe („kategoria A", czysta -automatycznie). Kategoria B to wyjątki: matching podczas re-importu -i deduplikacji. Jeśli te miejsca użyją ukrywającego menedżera, skasowany -rekord staje się NIEWIDZIALNY i importer tworzy DUPLIKAT — czyli dokładnie -ten skutek, dla którego handoff fazy 02 nazywa fazę 03 obowiązkową razem z 02. +"""Kontrakt akcesorów wobec kosza (faza 03). + +⚠️ KOREKTA 2026-08-08 (decyzja właściciela). Pierwsza wersja tej fazy kazała +akcesorom `rekord_w_bpp` / `get_bpp_publication` / `matchuj_publikacje` +ZAGLĄDAĆ DO KOSZA i zwracać skasowaną publikację. To był błąd i zostało +cofnięte. + +Powód: soft-delete znaczy, że rekordu **nie ma**. `Rekord` (widok +`bpp_rekord_mat`) jest odfiltrowany po `deleted_at` już od fazy 01, i tak ma +zostać. Akcesor, który zwraca rzecz skasowaną, łamie kontrakt „`Rekord` albo +`None`", na którym stoją wszyscy jego konsumenci — m.in. admin PBN, który woła +`.original` (istniejące tylko na `Rekord`) i wywalał się `AttributeError` na +całej changeliście. + +Zaglądanie do kosza jest decyzją **importera**, nie akcesora, i ma siedzieć +w ścieżce importu — tam, gdzie zapada. + +Testy niżej przypinają kontrakt: skasowane = nieznalezione. """ import pytest @@ -15,13 +25,14 @@ @pytest.mark.django_db -def test_get_bpp_publication_widzi_soft_deletowany_rekord(): - """Matching po ``pbn_uid`` MUSI znaleźć soft-skasowaną publikację. - - ``pbn_api.Publication.rekord_w_bpp`` szuka przez ``Rekord`` — a to widok - (``bpp_rekord_mat``), przefiltrowany po ``deleted_at`` już w fazie 01. - Skasowany rekord z niego znika, więc matching zwraca ``None`` i importer - zakłada, że publikacji nie ma. +def test_get_bpp_publication_NIE_widzi_soft_deletowanego(): + """Skasowana publikacja jest dla akcesora NIEISTNIEJĄCA. + + To jest kontrakt, nie ograniczenie: `Rekord` to widok odfiltrowany po + `deleted_at`, a `get_bpp_publication` ma zwracać `Rekord` albo `None`. + Zwrócenie modelu konkretnego (co robiła pierwsza wersja fazy 03) wywalało + admina PBN, bo ten woła `.original` — atrybut istniejący wyłącznie na + `Rekord`. """ from pbn_api.models import Publication @@ -32,79 +43,17 @@ def test_get_bpp_publication_widzi_soft_deletowany_rekord(): 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 - - -@pytest.mark.django_db -def test_matchuj_publikacje_po_tytule_widzi_soft_deletowany(): - """Fuzzy matching importu musi znaleźć soft-skasowaną publikację. - Inaczej re-import „nie widzi" rekordu, tworzy go od nowa — i baza dostaje - duplikat, którego operator nie zobaczy, bo oryginał siedzi w koszu. - """ - from import_common.core.publikacja import matchuj_publikacje - - rec = baker.make( - Wydawnictwo_Ciagle, - tytul_oryginalny="Unikalny tytul do matchowania importu", - rok=2019, + assert publication.get_bpp_publication() is not None, ( + "setup zepsuty — zywy rekord powinien byc znaleziony" ) - rec.delete() - wynik = matchuj_publikacje( - Wydawnictwo_Ciagle, - title="Unikalny tytul do matchowania importu", - year=2019, - ) - - assert wynik is not None, ( - "fuzzy matching nie widzi soft-deletowanego rekordu -> re-import " - "utworzy duplikat" - ) - assert wynik.pk == rec.pk - - -@pytest.mark.django_db -def test_matchuj_publikacje_po_doi_widzi_soft_deletowany(): - """To samo dla DOI. - - ⚠️ Tytuł MUSI być zgodny, mimo że matchujemy po DOI: - ``_try_match_pub_by_doi`` zawęża co prawda po DOI, ale kandydata i tak - przepuszcza przez próg podobieństwa tytułu (0.80). Pierwsza wersja tego - testu podawała celowo inny tytuł i padała — nie dlatego, że kod nie widzi - kosza, tylko dlatego, że tak działa matching. Wartość testu jest w tym, - żeby mierzył widoczność kosza, a nie regułę podobieństwa. - """ - from import_common.core.publikacja import matchuj_publikacje - - tytul = "Publikacja z DOI do matchowania importu" - rec = baker.make( - Wydawnictwo_Ciagle, - tytul_oryginalny=tytul, - rok=2021, - doi="10.1234/test.soft.delete", - ) - rec.delete() + rec.delete() # soft-delete - wynik = matchuj_publikacje( - Wydawnictwo_Ciagle, - title=tytul, - year=2021, - doi="10.1234/test.soft.delete", + assert publication.get_bpp_publication() is None, ( + "akcesor zwraca rekord z kosza — lamie kontrakt 'Rekord albo None'" ) - assert wynik is not None, "matching po DOI nie widzi kosza" - assert wynik.pk == rec.pk - # --------------------------------------------------------------------------- # REJESTR DECYZJI AUDYTU (Task 7) — miejsca ZOSTAWIONE na `objects` diff --git a/src/import_common/core/publikacja.py b/src/import_common/core/publikacja.py index 506a1f574..81e0b4f4d 100644 --- a/src/import_common/core/publikacja.py +++ b/src/import_common/core/publikacja.py @@ -78,29 +78,13 @@ def _check_candidate(candidate, title: str, threshold: float) -> bool: return False -def widzacy_manager(klass): - """Menedżer widzący TAKŻE rekordy w koszu. - - Matching importu należy do „kategorii B" — miejsc, które MUSZĄ widzieć - soft-skasowane wiersze. Gdy ich nie widzi, re-import uznaje rekord za - nieistniejący i tworzy DUPLIKAT, którego operator nie zauważy, bo - oryginał siedzi w koszu. - - ``getattr`` z fallbackiem, bo ``klass`` bywa też ``Rekord`` — a to widok - (``managed=False``), nie ``SoftDeleteModel``, więc ``global_objects`` nie - ma. Jest za to odfiltrowany po ``deleted_at`` już na poziomie SQL-a - (faza 01), czyli dla niego „menedżer widzący" po prostu nie istnieje. - """ - return getattr(klass, "global_objects", klass.objects) - - def _try_match_pub_by_doi(klass, title, year, doi, doi_matchuj_tylko_nadrzedne, debug): """Próbuje dopasować publikację po DOI.""" doi = normalize_doi(doi) if not doi: return None - zapytanie = widzacy_manager(klass).filter(doi__istartswith=doi, rok=year) + zapytanie = klass.objects.filter(doi__istartswith=doi, rok=year) if doi_matchuj_tylko_nadrzedne and hasattr(klass, "wydawnictwo_nadrzedne_id"): zapytanie = zapytanie.filter(wydawnictwo_nadrzedne_id=None) @@ -121,7 +105,7 @@ def _try_match_pub_by_zrodlo(klass, title, year, zrodlo): if zrodlo is None or not hasattr(klass, "zrodlo"): return None try: - return widzacy_manager(klass).get( + return klass.objects.get( tytul_oryginalny__istartswith=title, rok=year, zrodlo=zrodlo ) except klass.DoesNotExist: @@ -137,10 +121,8 @@ def _build_isbn_query(klass, isbn_matchuj_tylko_nadrzedne): """Buduje zapytanie dla matchowania ISBN.""" from django.contrib.contenttypes.models import ContentType - zapytanie = ( - widzacy_manager(klass) - .exclude(isbn=None, e_isbn=None) - .exclude(isbn="", e_isbn="") + zapytanie = klass.objects.exclude(isbn=None, e_isbn=None).exclude( + isbn="", e_isbn="" ) if not isbn_matchuj_tylko_nadrzedne: @@ -196,8 +178,7 @@ def _try_match_pub_by_uri(klass, title, public_uri, debug): return None res = ( - widzacy_manager(klass) - .filter(Q(www=public_uri) | Q(public_www=public_uri)) + klass.objects.filter(Q(www=public_uri) | Q(public_www=public_uri)) .annotate(podobienstwo=TrigramSimilarity(normalized_db_title, title.lower())) .order_by("-podobienstwo")[:2] ) @@ -251,8 +232,7 @@ def _try_match_pub_by_title(klass, title, year, debug, isbn=None): """ # Najpierw próba z istartswith res = ( - widzacy_manager(klass) - .filter(tytul_oryginalny__istartswith=title, rok=year) + klass.objects.filter(tytul_oryginalny__istartswith=title, rok=year) .annotate(podobienstwo=TrigramSimilarity(normalized_db_title, title.lower())) .order_by("-podobienstwo")[:2] ) @@ -266,8 +246,7 @@ def _try_match_pub_by_title(klass, title, year, debug, isbn=None): # Ostatnia szansa - tylko po roku z niskim progiem res = ( - widzacy_manager(klass) - .filter(rok=year) + klass.objects.filter(rok=year) .annotate(podobienstwo=TrigramSimilarity(normalized_db_title, title.lower())) .order_by("-podobienstwo")[:2] ) diff --git a/src/pbn_api/models/publication.py b/src/pbn_api/models/publication.py index 963d729b4..e624e7fb0 100644 --- a/src/pbn_api/models/publication.py +++ b/src/pbn_api/models/publication.py @@ -12,49 +12,6 @@ STATUS_ACTIVE = "ACTIVE" -def modele_publikacji_z_pbn_uid(): - """Modele publikacji BPP, które MAJĄ pole ``pbn_uid``. - - ⚠️ ``Patent`` go NIE ma — sprawdzone na modelach, nie założone. Dopisanie - go tutaj wywaliłoby ``FieldError`` przy pierwszym ``.filter()``, bo Django - resolwuje nazwy pól NATYCHMIAST (``Query.build_filter``), a nie dopiero - przy iteracji querysetu. - """ - from bpp.models import ( - Praca_Doktorska, - Praca_Habilitacyjna, - Wydawnictwo_Ciagle, - Wydawnictwo_Zwarte, - ) - - return ( - Wydawnictwo_Ciagle, - Wydawnictwo_Zwarte, - Praca_Doktorska, - Praca_Habilitacyjna, - ) - - -def znajdz_publikacje_po_pbn_uid(pbn_uid_pk): - """Publikacje BPP o danym ``pbn_uid`` — RAZEM Z KOSZEM. - - DLACZEGO NIE PRZEZ ``Rekord``: ``Rekord`` to widok (``bpp_rekord_mat``), - przefiltrowany po ``deleted_at`` już w fazie 01. Soft-skasowana - publikacja z niego znika, więc matching po ``pbn_uid`` zwracał ``None``, - a importer zakładał, że rekordu nie ma — i tworzył DUPLIKAT. Odpytujemy - zatem modele źródłowe przez ich ``global_objects``. - - Zwraca listę (zwykle pustą albo jednoelementową). Co zrobić z wieloma - trafieniami, rozstrzyga wołający — ``get_bpp_publication`` i - ``rekord_w_bpp`` robiły z tym historycznie DWIE różne rzeczy i ta - różnica jest zachowana. - """ - znalezione = [] - for klass in modele_publikacji_z_pbn_uid(): - znalezione.extend(klass.global_objects.filter(pbn_uid_id=pbn_uid_pk)) - return znalezione - - class Publication(LinkDoPBNMixin, BasePBNMongoDBModel): url_do_pbn = const.LINK_PBN_DO_PUBLIKACJI atrybut_dla_url_do_pbn = "pk" @@ -201,29 +158,26 @@ def matchuj_do_rekordu_bpp(self): ) def get_bpp_publication(self): - """Rekord BPP powiązany przez PBN UID (bez fuzzy matching). + """Zwraca rekord BPP powiązany przez PBN UID (bez fuzzy matching).""" + from bpp.models.cache import Rekord - Widzi TAKŻE kosz — patrz ``znajdz_publikacje_po_pbn_uid``. Zachowane - zachowanie historyczne: zarówno brak trafienia, jak i wiele trafień - dają ``None`` (dawniej ``DoesNotExist`` / ``MultipleObjectsReturned``). - """ - znalezione = znajdz_publikacje_po_pbn_uid(self.pk) - if len(znalezione) == 1: - return znalezione[0] - return None + try: + return Rekord.objects.get(pbn_uid_id=self.pk) + except (Rekord.DoesNotExist, Rekord.MultipleObjectsReturned): + return None @cached_property def rekord_w_bpp(self): - """Jak wyżej, ale przy wielu trafieniach zwraca sklejone tytuły, - a przy braku — spada do fuzzy matchingu. Ta różnica wobec - ``get_bpp_publication`` jest historyczna i celowo zachowana.""" - znalezione = znajdz_publikacje_po_pbn_uid(self.pk) - - if len(znalezione) == 1: - return znalezione[0] + from bpp.models.cache import Rekord - if len(znalezione) > 1: - return ";; ".join(x.tytul_oryginalny for x in znalezione) + try: + return Rekord.objects.get(pbn_uid_id=self.pk) + except Rekord.MultipleObjectsReturned: + return ";; ".join( + [x.tytul_oryginalny for x in Rekord.objects.filter(pbn_uid_id=self.pk)] + ) + except Rekord.DoesNotExist: + pass return self.matchuj_do_rekordu_bpp() diff --git a/src/pbn_api/tests/test_admin_rekord_w_bpp.py b/src/pbn_api/tests/test_admin_rekord_w_bpp.py new file mode 100644 index 000000000..4cb617d4a --- /dev/null +++ b/src/pbn_api/tests/test_admin_rekord_w_bpp.py @@ -0,0 +1,49 @@ +"""Kolumna „rekord w BPP" na changeliście publikacji PBN. + +Ta metoda admina nie miała ŻADNEGO pokrycia, a woła `.original` — atrybut +istniejący wyłącznie na `Rekord` (`bpp/models/cache/rekord.py`). Pierwsza +wersja fazy 03 zmieniła `rekord_w_bpp` tak, że zwracał model konkretny, +i wywalała `AttributeError` na CAŁEJ changeliście (~20 tys. rekordów). +Wyszło dopiero w recenzji. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle + + +@pytest.mark.django_db +def test_changelist_publikacji_dziala_gdy_jest_powiazanie_z_bpp(admin_client): + """Changelista nie może się wywalić, gdy publikacja PBN ma odpowiednik. + + To jest test na TYP zwracany przez `rekord_w_bpp`: admin buduje z niego + link przez `.original`, więc każda zmiana typu musi tu zapalić czerwone. + """ + from pbn_api.models import Publication + + publication = baker.make(Publication) + baker.make(Wydawnictwo_Ciagle, pbn_uid=publication, rok=2020) + + res = admin_client.get("/admin/pbn_api/publication/") + + assert res.status_code == 200 + + +@pytest.mark.django_db +def test_changelist_publikacji_dziala_gdy_odpowiednik_w_koszu(admin_client): + """Po soft-delete odpowiednika changelista dalej działa. + + Rekordu wtedy NIE MA (`Rekord` jest odfiltrowany po `deleted_at`), więc + kolumna ma pokazać link do importu zamiast linku do rekordu — a nie + wywalić stronę. + """ + from pbn_api.models import Publication + + publication = baker.make(Publication) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication, rok=2020) + wc.delete() + + res = admin_client.get("/admin/pbn_api/publication/") + + assert res.status_code == 200 From 406ad6ab7b69b07ca01584aa85e6553def71f2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 21:08:03 +0200 Subject: [PATCH 68/75] docs(soft-delete): handoff domkniecia fazy 03 po self-review Sesja przerwana w polowie naprawiania. PR #742 NIE nadaje sie do scalenia. Blokery 1 i 2 (padniety admin PBN, cichy pusty href w szablonie) naprawione commitem eb046897a. Zostaje 5 pozycji, opisanych z file:line i kolejnoscia, plus 5 ustalen recenzji o nizszym priorytecie. Handoff zawiera dwie decyzje wlasciciela, ktore ustawiaja reszte pracy: soft-delete znaczy ze rekordu NIE MA -- akcesor zwraca Rekord albo None, a zagladanie do kosza jest decyzja IMPORTERA; oraz wariant A dla trafienia w kosz (PRZYWROC + ODNOTUJ). Osobna sekcja z pulapkami, ktore juz kosztowaly -- w tym najwazniejsza: lista wolaczy to nie to samo co lista zalozen wolaczy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../superpowers/HANDOFF-faza-03-domkniecie.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/superpowers/HANDOFF-faza-03-domkniecie.md diff --git a/docs/superpowers/HANDOFF-faza-03-domkniecie.md b/docs/superpowers/HANDOFF-faza-03-domkniecie.md new file mode 100644 index 000000000..1732e98e6 --- /dev/null +++ b/docs/superpowers/HANDOFF-faza-03-domkniecie.md @@ -0,0 +1,180 @@ +# Handoff: domknięcie fazy 03 (PR #742 wymaga poprawek) + +> Stan na 2026-08-08. Sesja przerwana w połowie naprawiania po self-review. +> **PR #742 NIE nadaje się do scalenia w obecnym stanie.** + +--- + +## 1. Gdzie jesteś + +| | | +|---|---| +| Worktree | `~/Programowanie/bpp-soft-delete-03` | +| Gałąź | `feat/soft-delete-03`, baza `feat/soft-delete` | +| PR | [#742](https://github.com/iplweb/bpp/pull/742), OPEN | +| Ostatni commit | `eb046897a` — cofnięcie akcesorów | +| Drzewo | czyste, wszystko wypchnięte | + +Uruchom testy na start, żeby potwierdzić stan: +```bash +cd ~/Programowanie/bpp-soft-delete-03 +uv run pytest src/bpp/tests/test_soft_delete/ src/pbn_api/ -q +``` +Oczekiwane: zielono. + +--- + +## 2. Co się wydarzyło (skrót) + +Faza 03 miała sprawić, żeby re-import z PBN nie tworzył duplikatów rekordów +soft-skasowanych. Pierwsza implementacja poszła **złą drogą**: kazała +akcesorom (`rekord_w_bpp`, `get_bpp_publication`, `matchuj_publikacje`) +zaglądać do kosza i zwracać skasowane rekordy. + +Self-review (osobny agent) znalazł 3 blokery i kilka rzeczy ważnych. +**Blokery 1 i 2 są już naprawione** commitem `eb046897a`. + +### Decyzja właściciela, która ustawia resztę + +> **Soft-delete znaczy, że rekordu NIE MA.** `Rekord` (widok +> `bpp_rekord_mat`) jest odfiltrowany po `deleted_at` i tak ma zostać. +> Akcesor zwraca `Rekord` albo `None` — nigdy rzeczy z kosza. +> +> Zaglądanie do kosza jest decyzją **IMPORTERA**, nie akcesora. + +Oraz, na pytanie „co zrobić, gdy import trafi na rekord w koszu": + +> **Wariant A: PRZYWRÓĆ + ODNOTUJ.** PBN jest źródłem prawdy — skoro rekord +> tam jest, ma wrócić do BPP. Ślad w rejestrze +> `pbn_integrator.RekordPrzywroconyPrzezImport`. + +--- + +## 3. Do zrobienia — 5 pozycji, w tej kolejności + +### 3.1. Przenieść zaglądanie do kosza w gałąź `None` (wariant A) + +Dziś w `articles.py:70`, `books.py:68`, `chapters.py:113` jest: + +```python +ret = pbn_publication.rekord_w_bpp +if ret is not None and not force: + przywroc_jesli_w_koszu(ret, pbn_publication, "articles") + return ret +``` + +Po cofnięciu akcesorów `ret` to `Rekord` albo `None` — **nigdy** obiekt +z kosza. Czyli `przywroc_jesli_w_koszu(ret, ...)` jest teraz **no-opem** +(`Rekord` nie ma pola `deleted_at`). + +Docelowo: gdy `ret is None`, sprawdzić kosz JAWNIE po `pbn_uid` +(`Wydawnictwo_Ciagle.deleted_objects.filter(pbn_uid_id=...)` itd.), +przywrócić i wpisać do rejestru; dopiero potem tworzyć nowy rekord. + +⚠️ `Patent` NIE ma pola `pbn_uid` — `.filter(pbn_uid_id=…)` na nim wywala +`FieldError` (Django resolwuje nazwy pól natychmiast). + +`przywroc_jesli_w_koszu()` w `pbn_integrator/kosz.py` zostaje bez zmian — +jest dobry, ma testy, zweryfikowany mutacyjnie. + +### 3.2. BLOKER 3 — warunkowy unique na habilitacji + +`Praca_Habilitacyjna.autor` to `OneToOneField` → `UNIQUE (autor_id)`, które +**nie zna kosza**. Duplikat z habilitacją w koszu + główny autor z żywą → +`scal_autora` pada `IntegrityError` i **całe scalanie zwraca porażkę**. +Przed fazą 03 wiersz z kosza nie był przenoszony, więc scalanie się udawało. + +Naprawa (decyzja właściciela): zdjąć `unique` z pola, dodać warunkowy +`UniqueConstraint(fields=["autor"], condition=Q(deleted_at__isnull=True))` +w `Meta`. Ten sam wzorzec, co faza 01 zastosowała do `*_Autor`. + +Gdy OBIE habilitacje są żywe — to prawdziwy konflikt. Jawny komunikat: +„Nie można scalić autorów: obaj mają pracę habilitacyjną", zamiast +`IntegrityError`. + +Test najpierw — musi być czerwony przed zmianą constraintu. + +### 3.3. Detekcja kolizji w scalaniu — `merge.py:145` + +`existing = model.objects.filter(rekord=…, autor=glowny, typ=…)` — na +menedżerze ŻYWYCH. Gdy publikacja jest w koszu, oba autorstwa też są +w koszu → kolizja niewykryta → powstają DWA wiersze +`(rekord, glowny, typ)` w koszu. + +Skutek: `wc.restore()` wywala się `IntegrityError` na +`wc_autor_uniq_rekord_autor_typ`. **Kosz staje się drzwiami +jednokierunkowymi.** Dotyczy też `przywroc_jesli_w_koszu` w imporcie. + +Naprawa: detekcja kolizji ma iść po `global_objects`. + +### 3.4. Scalanie kolejkuje do PBN publikację z kosza + +`merge.py:214-222` — `PBN_Export_Queue.objects.create(...)` dla +`record.rekord`, także gdy ten jest soft-skasowany. Sprzeczne z kierunkiem +fazy 05 (soft-delete ma **wycofywać** oświadczenia, nie wysyłać). + +Naprawa: pominąć kolejkowanie, gdy `record.rekord.deleted_at` jest ustawione. + +### 3.5. Poprawić opis PR #742 + +Obecny opis twierdzi, że blocker duplikatów jest domknięty. **Nie jest.** + +- Ścieżka fuzzy (rekord BPP **bez** `pbn_uid`, czyli praca wprowadzona + ręcznie) idzie przez `Rekord`, więc kosza nie widzi → duplikat nadal + powstanie. Po cofnięciu akcesorów to zachowanie **zamierzone** (rekordu + nie ma), ale opis PR-a sugeruje co innego. +- Ścieżka po `pbn_uid`: unique na `pbn_uid_id` **nie jest** partial, więc + skasowany rekord i tak trzyma to pole. Przed fazą 03 dawało to + *wywalenie importu*, nie cichy duplikat. + +Zaktualizować też tabelkę „6 lookupów matchingu widzi kosz" — nieaktualna +po cofnięciu. + +--- + +## 4. Ustalenia recenzji, które ZOSTAJĄ otwarte (niższy priorytet) + +| # | Rzecz | Gdzie | +|---|---|---| +| 9 | Rejestr wskrzeszeń niewidoczny — `pbn_integrator/admin.py` to nadal `# Register your models here.` Brak też indeksu `(content_type, object_id)` pod pytanie „czy TEN rekord wrócił?" | `pbn_integrator/admin.py`, `models.py` | +| 10 | `force=True` omija wskrzeszenie (`if ret is not None and not force`), a `znajdz_ksiazke_nadrzedna` leży ZA tym guardem → książka-matka jest wskrzeszana przy `force`, sam rozdział nie. Niespójne | 3 importery | +| 12 | Brak newsfragmentu (CLAUDE.md wymaga) | `src/bpp/newsfragments/` | +| 13 | `Oswiadczenie_Instytucji.get_bpp_publication` iteruje po 4 modelach po `pbn_uid` przez `.objects` — nie ma go ani na liście zmienionych, ani „świadomie zostawionych" | `pbn_api/models/oswiadczenie_instytucji.py:51-74` | +| 14 | `hard_delete()` na querysecie NIE emituje `post_hard_delete` — najbardziej destrukcyjna operacja przejdzie niezauważona przez `SoftDeleteLog` z fazy 06 | do handoffu fazy 06 | + +--- + +## 5. Pułapki, które już kosztowały (nie powtarzaj) + +- **Lista wołaczy ≠ lista założeń wołaczy.** Sprawdziłem, kto woła + `rekord_w_bpp`, ale nie co robi z wynikiem. Admin woła `.original` — + atrybut istniejący WYŁĄCZNIE na `Rekord`. Efekt: `AttributeError` na całej + changeliście, zero pokrycia testami. Naprawione + dołożony + `src/pbn_api/tests/test_admin_rekord_w_bpp.py`. +- **Django połyka brak atrybutu w SZABLONACH.** Ten sam błąd w + `change_form.html` dawał pusty `href` zamiast wyjątku — objaw gorszy niż + crash. +- **`matchuj_publikacje` ma DWÓCH wołaczy o sprzecznych potrzebach**: + `pbn_api` (przekazuje `Rekord`) i `deduplikator_publikacji` + (`tasks.py:187`, przekazuje modele konkretne). Zmiana „na globalny + menedżer widzący" sprawiła, że dedup zaczął podsuwać kosz — wbrew decyzji + z Taska 8. Mój test tego nie złapał, bo pokrywał tylko połowę skanującą. +- **`make tests-without-playwright` zwraca EXIT 0 mimo porażek.** Czytaj + podsumowanie pytest, nie kod wyjścia. +- **`ruff format` na całym katalogu zagarnia cudze pliki.** Formatuj tylko + swoje. +- **Mutacja jest jedynym dowodem, że test coś pilnuje.** Czerwień z + `ImportError` nie dowodzi, że asercje działają. + +--- + +## 6. Poza tą gałęzią + +- **PR upstream** [soynatan/django-easy-audit#348](https://github.com/soynatan/django-easy-audit/pull/348) + — wystawiony z forka `mpasternak`. Po scaleniu skasować + `src/bpp/easyaudit_shim.py`, wywołanie `zainstaluj()` w `BppConfig.ready()` + i `test_easyaudit_shim.py`. Przypomni o tym + `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`. +- **Bramka wydania przesunięta na fazę 07** (decyzja 2026-08-08) — patrz + `HANDOFF-soft-delete-faza-04.md` §4b. +- **Nagrobki (OAI-PMH / CERIF / API) wciągnięte do fazy 05.** From b98aad5beef0ddf1ba95bc3458613c6935b08ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 21:41:54 +0200 Subject: [PATCH 69/75] fix(soft-delete): import zaglada do kosza w galezi None, nie is not None (3.1) Zajrzenie do kosza siedzialo w galezi ``ret is not None``, gdzie po cofnieciu akcesorow (commit eb046897a) bylo z definicji no-opem: ``rekord_w_bpp`` zwraca tam ``Rekord``, a ``Rekord`` nie ma pola ``deleted_at``. Realny problem jest w galezi przeciwnej. Kosz jest jednoczesnie: - NIEWIDOCZNY dla matchingu -- ``Rekord`` to widok odfiltrowany po ``deleted_at``, wiec ``rekord_w_bpp`` zwraca ``None``, - WIDOCZNY dla bazy -- ``pbn_uid`` to ``OneToOneField(unique=True)`` BEZ warunku partial, wiec rekord w koszu nadal to pole trzyma. Import wchodzil wiec w galaz "utworz nowy" i wywalal sie IntegrityError-em na ``bpp_wydawnictwo_{ciagle,zwarte}_pbn_uid_id_*_uniq``. To nie cichy duplikat, tylko wywrocenie calego przebiegu. Nowe ``znajdz_lub_wskrzes_rekord()`` (preambula wspolna dla trzech importerow) najpierw pyta akcesor o rekord zywy, a gdy go nie ma -- jawnie szuka w koszu po ``pbn_uid`` i wskrzesza (wariant A: PBN jest zrodlem prawdy, slad w rejestrze ``RekordPrzywroconyPrzezImport``). Model jest parametrem, a nie petla po wszystkich publikacjach, bo ``Patent`` nie ma ``pbn_uid`` i ``.filter()`` na nim wywala ``FieldError``. Testy: 5 nowych, na poziomie realnych funkcji importera. Czerwien przed zmiana to IntegrityError z bazy. Zweryfikowane mutacyjnie -- kazda z czterech mutacji (deleted_objects->objects, restore bez rejestru, znajdz bez restore, zle zrodlo_importu) wywraca dokladnie te asercje, ktore ma wywracac. Refs #742 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- ...delete-import-wskrzesza-z-kosza.bugfix.rst | 3 + src/pbn_integrator/importer/articles.py | 19 +- src/pbn_integrator/importer/books.py | 20 ++- src/pbn_integrator/importer/chapters.py | 19 +- src/pbn_integrator/kosz.py | 55 ++++++ .../tests/test_importer_wskrzesza_z_kosza.py | 167 ++++++++++++++++++ 6 files changed, 256 insertions(+), 27 deletions(-) create mode 100644 src/bpp/newsfragments/soft-delete-import-wskrzesza-z-kosza.bugfix.rst create mode 100644 src/pbn_integrator/tests/test_importer_wskrzesza_z_kosza.py diff --git a/src/bpp/newsfragments/soft-delete-import-wskrzesza-z-kosza.bugfix.rst b/src/bpp/newsfragments/soft-delete-import-wskrzesza-z-kosza.bugfix.rst new file mode 100644 index 000000000..5829a9662 --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-import-wskrzesza-z-kosza.bugfix.rst @@ -0,0 +1,3 @@ +Import z PBN trafiający na publikację, której rekord BPP jest w koszu, nie +wywala się już błędem unikalności ``pbn_uid`` — rekord wraca z kosza, a fakt +jest odnotowany w rejestrze „Rekordy przywrócone przez import". diff --git a/src/pbn_integrator/importer/articles.py b/src/pbn_integrator/importer/articles.py index 7ecb4a876..a8b6fc7d9 100644 --- a/src/pbn_integrator/importer/articles.py +++ b/src/pbn_integrator/importer/articles.py @@ -15,7 +15,7 @@ from bpp.util import safe_tytul_html from pbn_api.client import PBNClient from pbn_api.models import Publication -from pbn_integrator.kosz import przywroc_jesli_w_koszu +from pbn_integrator.kosz import znajdz_lub_wskrzes_rekord from .authors import utworz_autorow from .cache import ( @@ -67,14 +67,15 @@ def importuj_artykul( except Publication.DoesNotExist as err: raise NotImplementedError(f"Publikacja {mongoId=} nie istnieje") from err - ret = pbn_publication.rekord_w_bpp - - if ret is not None and not force: - # Trafienie w kosz: PBN jest zrodlem prawdy, wiec rekord wraca -- - # i zostaje po tym slad w rejestrze. Uzasadnienie i zmiana wobec - # decyzji #14 planu: docstring RekordPrzywroconyPrzezImport. - przywroc_jesli_w_koszu(ret, pbn_publication, "articles") - return ret + if not force: + # Rekord moze juz byc w BPP: zywy (wtedy ``Rekord``) albo w koszu -- + # wtedy wraca, bo PBN jest zrodlem prawdy, a fakt laduje w rejestrze + # ``RekordPrzywroconyPrzezImport`` (zmiana wobec decyzji #14 planu). + istniejacy = znajdz_lub_wskrzes_rekord( + pbn_publication, Wydawnictwo_Ciagle, "articles" + ) + if istniejacy is not None: + return istniejacy pbn_json = pbn_publication.current_version["object"] orig_pbn_json = copy.deepcopy(pbn_json) # noqa diff --git a/src/pbn_integrator/importer/books.py b/src/pbn_integrator/importer/books.py index 0ead7e919..6695bf437 100644 --- a/src/pbn_integrator/importer/books.py +++ b/src/pbn_integrator/importer/books.py @@ -13,7 +13,7 @@ ) from bpp.util import safe_tytul_html from pbn_api.client import PBNClient -from pbn_integrator.kosz import przywroc_jesli_w_koszu +from pbn_integrator.kosz import znajdz_lub_wskrzes_rekord from .authors import utworz_autorow from .cache import ( @@ -65,14 +65,16 @@ def importuj_ksiazke( ) return None - ret = pbn_publication.rekord_w_bpp - - if ret is not None and not force: - # Trafienie w kosz: PBN jest zrodlem prawdy, wiec rekord wraca -- - # i zostaje po tym slad w rejestrze. Uzasadnienie i zmiana wobec - # decyzji #14 planu: docstring RekordPrzywroconyPrzezImport. - przywroc_jesli_w_koszu(ret, pbn_publication, "books") - return ret + if not force: + # Kosz jest niewidoczny dla matchingu (``Rekord`` filtruje po + # ``deleted_at``), ale widoczny dla unique na ``pbn_uid``. Bez zajrzenia + # tam re-import ksiazki z kosza wywala sie ``IntegrityError``-em. + # Szczegoly: docstring ``znajdz_lub_wskrzes_rekord``. + istniejacy = znajdz_lub_wskrzes_rekord( + pbn_publication, Wydawnictwo_Zwarte, "books" + ) + if istniejacy is not None: + return istniejacy pbn_json = pbn_publication.current_version["object"] orig_pbn_json = copy.deepcopy(pbn_json) # noqa diff --git a/src/pbn_integrator/importer/chapters.py b/src/pbn_integrator/importer/chapters.py index f8e733ff1..aed26cf46 100644 --- a/src/pbn_integrator/importer/chapters.py +++ b/src/pbn_integrator/importer/chapters.py @@ -16,7 +16,7 @@ from bpp.util import safe_tytul_html from pbn_api.client import PBNClient from pbn_api.models import Publication -from pbn_integrator.kosz import przywroc_jesli_w_koszu +from pbn_integrator.kosz import przywroc_jesli_w_koszu, znajdz_lub_wskrzes_rekord from .authors import utworz_autorow from .books import importuj_ksiazke @@ -110,14 +110,15 @@ def importuj_rozdzial( except Publication.DoesNotExist as err: raise NotImplementedError(f"Publikacja {mongoId=} nie istnieje") from err - ret = pbn_publication.rekord_w_bpp - - if ret is not None and not force: - # Trafienie w kosz: PBN jest zrodlem prawdy, wiec rekord wraca -- - # i zostaje po tym slad w rejestrze. Uzasadnienie i zmiana wobec - # decyzji #14 planu: docstring RekordPrzywroconyPrzezImport. - przywroc_jesli_w_koszu(ret, pbn_publication, "chapters") - return ret + if not force: + # Jak w ksiazkach: kosz jest niewidoczny dla matchingu, ale widoczny dla + # unique na ``pbn_uid``. Szczegoly: docstring + # ``znajdz_lub_wskrzes_rekord``. + istniejacy = znajdz_lub_wskrzes_rekord( + pbn_publication, Wydawnictwo_Zwarte, "chapters" + ) + if istniejacy is not None: + return istniejacy pbn_json = pbn_publication.current_version["object"] orig_pbn_json = copy.deepcopy(pbn_json) # noqa diff --git a/src/pbn_integrator/kosz.py b/src/pbn_integrator/kosz.py index 444ae9d28..ca9eef8f6 100644 --- a/src/pbn_integrator/kosz.py +++ b/src/pbn_integrator/kosz.py @@ -53,3 +53,58 @@ def przywroc_jesli_w_koszu(rekord, pbn_publication, zrodlo_importu): getattr(pbn_publication, "pk", None), ) return True + + +def wskrzes_z_kosza_po_pbn_uid(model, pbn_publication, zrodlo_importu): + """Szuka w koszu rekordu ``model`` po ``pbn_uid`` i wskrzesza go. + + Zwraca wskrzeszony rekord albo ``None``, gdy w koszu nic nie ma. + + To jest JAWNE zajrzenie do kosza po stronie importera. Akcesory + (``rekord_w_bpp``, ``get_bpp_publication``, ``matchuj_publikacje``) tego nie + robią i robić nie będą — idą po ``Rekord``, czyli po widoku odfiltrowanym + po ``deleted_at``. Skoro soft-delete znaczy „rekordu nie ma", to decyzja + „a jednak zajrzyj do kosza" należy do wołającego, nie do akcesora. + + Bez tego re-import publikacji, której rekord BPP jest w koszu, wchodzi + w gałąź „utwórz nowy" i wywala się ``IntegrityError`` — ``pbn_uid`` to + ``OneToOneField(unique=True)`` BEZ warunku partial, więc rekord w koszu + nadal to pole trzyma. Kosz jest jednocześnie niewidoczny dla matchingu + i widoczny dla bazy. + + ⚠️ ``model`` musi mieć pole ``pbn_uid`` (mixin ``ModelZPBN_UID``). ``Patent`` + go NIE ma — ``.filter(pbn_uid_id=…)`` na nim wywala ``FieldError``, bo + Django resolwuje nazwy pól natychmiast. Stąd model jest parametrem, a nie + pętlą po wszystkich modelach publikacji. + """ + rekord = model.deleted_objects.filter(pbn_uid_id=pbn_publication.pk).first() + if rekord is None: + return None + + przywroc_jesli_w_koszu(rekord, pbn_publication, zrodlo_importu) + return rekord + + +def znajdz_lub_wskrzes_rekord(pbn_publication, model, zrodlo_importu): + """Rekord tej publikacji, który JUŻ jest w BPP — żywy albo wyjęty z kosza. + + ``None`` znaczy „nie ma go nigdzie" — dopiero wtedy importer tworzy nowy. + + Preambuła wspólna dla trzech importerów (artykuł / książka / rozdział). + Wydzielona, bo różnią się wyłącznie modelem i etykietą źródła, a rozjazd + między nimi już raz kosztował: wskrzeszanie siedziało w gałęzi + ``ret is not None``, gdzie z definicji było no-opem (``Rekord`` nie ma + ``deleted_at``). + + ⚠️ Zwraca trzy różne rzeczy i wołający musi to znieść: ``Rekord`` (widok + cache) dla trafienia żywego, instancję modelu konkretnego dla wskrzeszenia + z kosza, oraz STRING ze sklejonymi tytułami, gdy po ``pbn_uid`` trafi więcej + niż jedna publikacja (zachowanie historyczne ``rekord_w_bpp``). Admin + rozróżnia je przez ``isinstance(..., Rekord)`` przed sięgnięciem po + ``.original``. + """ + ret = pbn_publication.rekord_w_bpp + if ret is not None: + return ret + + return wskrzes_z_kosza_po_pbn_uid(model, pbn_publication, zrodlo_importu) diff --git a/src/pbn_integrator/tests/test_importer_wskrzesza_z_kosza.py b/src/pbn_integrator/tests/test_importer_wskrzesza_z_kosza.py new file mode 100644 index 000000000..1e02f87be --- /dev/null +++ b/src/pbn_integrator/tests/test_importer_wskrzesza_z_kosza.py @@ -0,0 +1,167 @@ +"""Import trafiający w rekord w koszu wskrzesza go, zamiast tworzyć duplikat. + +Kontekst (faza 03 soft-delete, pozycja 3.1 handoffu): + +Akcesory (``rekord_w_bpp``, ``get_bpp_publication``, ``matchuj_publikacje``) +NIE zaglądają do kosza — decyzja właściciela: „soft-delete znaczy, że rekordu +nie ma". Wszystkie idą po ``Rekord``, czyli po widoku odfiltrowanym po +``deleted_at``. + +Skutkiem jest asymetria, która sama w sobie jest błędem importu: + +- dla matchingu rekord w koszu **nie istnieje** (``rekord_w_bpp`` → ``None``), +- ale ``pbn_uid`` to ``OneToOneField(unique=True)`` **bez** warunku partial, + więc rekord w koszu **wciąż trzyma** to pole. + +Import wchodzi więc w gałąź „utwórz nowy rekord" i wywala się ``IntegrityError`` +na unique ``pbn_uid_id``. Zaglądanie do kosza jest decyzją IMPORTERA (a nie +akcesora), i należy do gałęzi ``ret is None``. + +Wariant A (decyzja właściciela): PBN jest źródłem prawdy — skoro publikacja +tam nadal jest, rekord wraca do BPP, a fakt ląduje w rejestrze +``RekordPrzywroconyPrzezImport``. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Zwarte +from pbn_api.models import Publication +from pbn_integrator.importer.articles import importuj_artykul +from pbn_integrator.importer.books import importuj_ksiazke +from pbn_integrator.importer.chapters import importuj_rozdzial +from pbn_integrator.models import RekordPrzywroconyPrzezImport + +ARTICLE_OBJECT = { + "type": "ARTICLE", + "title": "Artykul, ktory wrocil z kosza", + "year": 2023, +} + +BOOK_OBJECT = { + "type": "BOOK", + "title": "Ksiazka, ktora wrocila z kosza", + "year": 2023, +} + +CHAPTER_OBJECT = { + "type": "CHAPTER", + "title": "Rozdzial, ktory wrocil z kosza", + "year": 2023, + "book": {"id": "nieuzywany-bo-wracamy-wczesniej"}, +} + + +def _publikacja(mongo_id, obiekt): + return baker.make( + Publication, + mongoId=mongo_id, + versions=[{"current": True, "object": dict(obiekt)}], + status="ACTIVE", + ) + + +def _sprawdz_wskrzeszenie(zwrocony, rekord, zrodlo_importu, model): + """Wspólne asercje: ten sam wiersz wrócił, bez duplikatu, ze śladem.""" + assert zwrocony is not None, "import nie zwrocil nic — rekord z kosza zgubiony" + assert zwrocony.pk == rekord.pk, ( + "import utworzyl NOWY rekord zamiast wskrzesic ten z kosza" + ) + assert model.global_objects.count() == 1, "w bazie powstal duplikat" + + rekord.refresh_from_db() + assert rekord.deleted_at is None, "rekord nie zostal wskrzeszony" + + wpis = RekordPrzywroconyPrzezImport.objects.get() + assert wpis.object_id == rekord.pk + assert wpis.zrodlo_importu == zrodlo_importu + + +@pytest.mark.django_db +def test_import_artykulu_wskrzesza_rekord_z_kosza(): + publication = _publikacja("a-kosz", ARTICLE_OBJECT) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + wc.delete() + + assert publication.rekord_w_bpp is None, ( + "zalozenie testu: matching NIE widzi kosza (akcesory po Rekord)" + ) + + # client=None jest bezpieczne: gałąź wskrzeszenia zwraca zanim cokolwiek + # sięgnie po PBN. + zwrocony = importuj_artykul("a-kosz", default_jednostka=None, client=None) + + _sprawdz_wskrzeszenie(zwrocony, wc, "articles", Wydawnictwo_Ciagle) + + +@pytest.mark.django_db +def test_import_ksiazki_wskrzesza_rekord_z_kosza(): + publication = _publikacja("b-kosz", BOOK_OBJECT) + wz = baker.make(Wydawnictwo_Zwarte, pbn_uid=publication) + wz.delete() + + zwrocony = importuj_ksiazke("b-kosz", default_jednostka=None, client=None) + + _sprawdz_wskrzeszenie(zwrocony, wz, "books", Wydawnictwo_Zwarte) + + +@pytest.mark.django_db +def test_import_rozdzialu_wskrzesza_rekord_z_kosza(): + publication = _publikacja("c-kosz", CHAPTER_OBJECT) + wz = baker.make(Wydawnictwo_Zwarte, pbn_uid=publication) + wz.delete() + + zwrocony = importuj_rozdzial("c-kosz", default_jednostka=None, client=None) + + _sprawdz_wskrzeszenie(zwrocony, wz, "chapters", Wydawnictwo_Zwarte) + + +@pytest.mark.django_db +def test_zywy_rekord_wraca_jako_rekord_i_nie_trafia_do_rejestru(): + """Kontrola: ścieżka bez kosza ma zostać nietknięta. + + Trafienie w ŻYWY rekord nadal zwraca obiekt ``Rekord`` (kontrakt, na którym + stoi admin: ``isinstance(created_record, Rekord)`` → ``.original``), a + rejestr wskrzeszeń zostaje pusty. + """ + from bpp.models import Rekord + + publication = _publikacja("a-zywy", ARTICLE_OBJECT) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + + zwrocony = importuj_artykul("a-zywy", default_jednostka=None, client=None) + + assert isinstance(zwrocony, Rekord) + assert zwrocony.pbn_uid_id == publication.pk + assert zwrocony.original.pk == wc.pk + assert not RekordPrzywroconyPrzezImport.objects.exists() + + +@pytest.mark.django_db +def test_pusty_kosz_idzie_dalej_do_tworzenia_nowego_rekordu( + jezyki, charaktery_formalne, typy_kbn, statusy_korekt, typy_odpowiedzialnosci +): + """Kontrola negatywna: pusty kosz nie może udawać „wskrzeszenia". + + Bez tej asercji implementacja mogłaby zatrzymać sterowanie na gałęzi kosza + (np. zwracać coś prawdziwego, gdy nic nie znalazła) i test wskrzeszenia + nadal by przechodził — a import przestałby cokolwiek importować. + + Ten jeden test przechodzi CAŁĄ ścieżkę tworzenia, więc potrzebuje słowników. + Bierzemy je z fixture'ów zamiast z baseline, bo testy transakcyjne z tego + samego przebiegu potrafią wyczyścić dane referencyjne. + """ + from bpp.models import Rodzaj_Zrodla + + # Artykuł bez ``journal`` dostaje źródło zastępcze o tym rodzaju. + Rodzaj_Zrodla.objects.get_or_create(nazwa="źródło nieindeksowane") + + _publikacja("a-brak", ARTICLE_OBJECT) + + zwrocony = importuj_artykul("a-brak", default_jednostka=None, client=None) + + assert isinstance(zwrocony, Wydawnictwo_Ciagle), ( + "import nie utworzyl nowego rekordu — sterowanie utknelo na koszu" + ) + assert zwrocony.pbn_uid_id == "a-brak" + assert not RekordPrzywroconyPrzezImport.objects.exists() From 9c3ecb23e660f9be6265feb2ea414db59c3ea533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 22:23:58 +0200 Subject: [PATCH 70/75] fix(soft-delete): warunkowy unique na habilitacji zamiast OneToOne (3.2) BLOKER 3 z self-review. ``Praca_Habilitacyjna.autor`` bylo ``OneToOneField``, czyli twarde ``UNIQUE (autor_id)``, ktore kosza NIE zna. Scalanie autorow przenosi wiersze RAZEM Z KOSZEM (zeby nie zostawiac sierot), wiec duplikat z habilitacja soft-skasowana + glowny autor z zywa wywracal CALE scalanie: bpp_praca_habilitacyjna_autor_id_b83969d3_uniq Zamiana na ``ForeignKey`` + ``UniqueConstraint(fields=["autor"], condition=Q(deleted_at__isnull=True))`` -- ten sam wzorzec, co faza 01 zastosowala do ``*_Autor``. Django wymusza ``unique=True`` w ``OneToOneField.__init__``, wiec zdjecie bezwarunkowego UNIQUE wymaga zmiany typu pola. Konsekwencje zmiany typu (sprawdzone, nie tylko "kto wola", ale "co robi z wynikiem"): - Akcesor odwrotny to teraz ``autor.praca_habilitacyjna_set`` (manager). Sciezka FILTROWANIA w ORM/DjangoQL sie NIE zmienia -- ``related_query_name`` domyslnie i tak jest nazwa modelu. - ``RokHabilitacjiView`` pyta teraz jawnie ``Praca_Habilitacyjna.objects``. Przy okazji znika reczny warunek na ``deleted_at``: odwrotne OneToOne szlo przez ``_base_manager`` i pokazywalo rekordy z kosza, manager relacji nie. - ``browse/autor.html`` idzie petla po ``praca_habilitacyjna_set``, jak juz robil to doktorat (ktory od zawsze jest FK). Bez tego sekcja "Stopnie naukowe" zniknelaby CICHO -- szablony Django polykaja brak atrybutu. Oba te miejsca mialy juz testy w repo i oba zaswiecily sie na czerwono. Prawdziwy konflikt (obie habilitacje zywe) konczy sie teraz ``KonfliktScalania`` z komunikatem "Nie mozna scalic autorow: obaj maja prace habilitacyjna." zamiast IntegrityError. Nowy wyjatek jest lapany osobno -- sprzeczne dane to nie awaria, wiec nie ida do Rollbara. Walidacja w adminie: ``validate_unique()`` juz tego nie pilnuje, a ``validate_constraints()`` CICHO POMIJA constraint, ktorego pole warunku (``deleted_at``) jest wykluczone z walidacji -- a admin wyklucza wszystko spoza ``fieldsets``. Sztuczka z ukrytym polem (``bpp/admin/core.py``) dziala dla inline'ow; tutaj wyprodukowalaby widoczny pusty wiersz "Deleted at", wiec jest jawne ``clean_autor()``. Constraint zostaje ostateczna gwarancja w bazie. Testy: 6 nowych (3 scalanie, 3 admin). Kazda z czterech mutacji wywraca dokladnie ten test, ktory ma wywracac; bezwarunkowy unique zostal udokumentowany przez oryginalna czerwien. Refs #742 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- src/bpp/admin/praca_habilitacyjna.py | 33 ++++ ...a_habilitacyjna_warunkowy_unique_autora.py | 34 ++++ src/bpp/models/praca_habilitacyjna.py | 43 ++++- ...te-habilitacja-warunkowy-unique.bugfix.rst | 3 + src/bpp/templates/browse/autor.html | 23 ++- .../test_praca_habilitacyjna_unique.py | 157 ++++++++++++++++++ src/bpp/views/api/__init__.py | 28 ++-- .../tests/test_scal_autora_soft_delete.py | 104 ++++++++++++ src/deduplikator_autorow/utils/merge.py | 53 +++++- 9 files changed, 448 insertions(+), 30 deletions(-) create mode 100644 src/bpp/migrations/0500_praca_habilitacyjna_warunkowy_unique_autora.py create mode 100644 src/bpp/newsfragments/soft-delete-habilitacja-warunkowy-unique.bugfix.rst create mode 100644 src/bpp/tests/test_admin/test_praca_habilitacyjna_unique.py create mode 100644 src/deduplikator_autorow/tests/test_scal_autora_soft_delete.py diff --git a/src/bpp/admin/praca_habilitacyjna.py b/src/bpp/admin/praca_habilitacyjna.py index ba1cad622..7ea95a71a 100644 --- a/src/bpp/admin/praca_habilitacyjna.py +++ b/src/bpp/admin/praca_habilitacyjna.py @@ -101,6 +101,39 @@ class Praca_HabilitacyjnaForm(forms.ModelForm): widget=autocomplete.ModelSelect2(url="bpp:autor-z-uczelni-autocomplete"), ) + def clean_autor(self): + """Jeden autor — jedna ŻYWA praca habilitacyjna. + + Do fazy 03 pilnował tego `validate_unique()`, bo `Praca_Habilitacyjna. + autor` było `OneToOneField`. Faza 03 zamieniła je na `ForeignKey` + z warunkowym `phab_uniq_autor_zywy` (żeby habilitacja w koszu nie + blokowała scalania autorów), a to przenosi walidację do + `Model.validate_constraints()` — który dla constraintu z `condition` + CICHO POMIJA sprawdzenie, gdy pole warunku (`deleted_at`) jest + wykluczone z walidacji. A jest: `_get_validation_exclusions()` wyklucza + wszystko spoza `Meta.fields`, a te admin nadpisuje spłaszczonymi + `fieldsets`. + + `bpp/admin/core.py` obchodzi to ukrytym polem `deleted_at` na liście + `fields`. Tam to działa, bo chodzi o inline; tutaj `fieldsets` sterują + renderowaniem, więc pole wyprodukowałoby widoczny, pusty wiersz + „Deleted at" w formularzu. Stąd jawne sprawdzenie — czytelniejsze niż + walka z wykluczeniami, i daje komunikat przy właściwym polu. + + Ograniczenie w bazie zostaje ostateczną gwarancją (to jest walidacja + formularza, nie blokada wyścigu). + """ + autor = self.cleaned_data["autor"] + + istniejace = Praca_Habilitacyjna.objects.filter(autor=autor) + if self.instance.pk is not None: + istniejace = istniejace.exclude(pk=self.instance.pk) + + if istniejace.exists(): + raise forms.ValidationError("Ten autor ma już pracę habilitacyjną.") + + return autor + jednostka = forms.ModelChoiceField( queryset=Jednostka.objects.all(), widget=autocomplete.ModelSelect2(url="bpp:jednostka-autocomplete"), diff --git a/src/bpp/migrations/0500_praca_habilitacyjna_warunkowy_unique_autora.py b/src/bpp/migrations/0500_praca_habilitacyjna_warunkowy_unique_autora.py new file mode 100644 index 000000000..9577a905b --- /dev/null +++ b/src/bpp/migrations/0500_praca_habilitacyjna_warunkowy_unique_autora.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.16 on 2026-08-08 20:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0499_drop_kronika_views"), + ("pbn_api", "0079_constraint_publikacja_instytucji"), + ( + "taggit", + "0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx", + ), + ] + + operations = [ + migrations.AlterField( + model_name="praca_habilitacyjna", + name="autor", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="bpp.autor" + ), + ), + migrations.AddConstraint( + model_name="praca_habilitacyjna", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("autor",), + name="phab_uniq_autor_zywy", + violation_error_message="Ten autor ma już pracę habilitacyjną.", + ), + ), + ] diff --git a/src/bpp/models/praca_habilitacyjna.py b/src/bpp/models/praca_habilitacyjna.py index 2b61c9864..e70010b23 100644 --- a/src/bpp/models/praca_habilitacyjna.py +++ b/src/bpp/models/praca_habilitacyjna.py @@ -45,7 +45,23 @@ def charakter_formalny(self): class Praca_Habilitacyjna(BppPublikacjaSoftDeleteMixin, Praca_Doktorska_Baza): - autor = models.OneToOneField(Autor, PROTECT) + # ``ForeignKey``, a NIE ``OneToOneField``, wyłącznie po to, żeby dało się + # zdjąć bezwarunkowy ``UNIQUE (autor_id)`` — Django wymusza `unique=True` + # w ``OneToOneField.__init__`` i nie ma tego jak wyłączyć. Reguła „jeden + # autor, jedna habilitacja" nie znika: pilnuje jej warunkowy + # ``phab_uniq_autor_zywy`` w ``Meta.constraints`` niżej. + # + # Twarde UNIQUE nie znało kosza. Duplikat z habilitacją soft-skasowaną + # + główny autor z żywą wywracał CAŁE scalanie autorów ``IntegrityError``-em + # (scalanie przenosi wiersze RAZEM Z KOSZEM, żeby nie zostawiać sierot). + # + # ⚠️ Akcesor odwrotny to teraz ``autor.praca_habilitacyjna_set`` (manager), + # a nie ``autor.praca_habilitacyjna`` (obiekt). Ścieżka FILTROWANIA w ORM / + # DjangoQL się NIE zmienia — ``related_query_name`` domyślnie i tak jest + # nazwą modelu. Zmiana akcesora to zarazem naprawa: manager relacji używa + # ``_default_manager`` (filtruje kosz), podczas gdy odwrotne OneToOne szło + # przez ``_base_manager`` i pokazywało rekordy skasowane. + autor = models.ForeignKey(Autor, PROTECT) publikacje_habilitacyjne = GenericRelation(Publikacja_Habilitacyjna) @@ -66,6 +82,31 @@ class Meta: condition=Q(deleted_at__isnull=False), ), ] + constraints = [ + # Następca bezwarunkowego `UNIQUE (autor_id)` z `OneToOneField`. + # Ten sam wzorzec, co faza 01 zastosowała do `*_Autor`: reguła + # obowiązuje TYLKO wśród żywych wierszy, więc habilitacja w koszu + # nie blokuje ani ponownego wprowadzenia, ani przeniesienia przy + # scalaniu autorów. + # + # ⚠️ To ograniczenie NIE jest samo z siebie widoczne w formularzu. + # `Model.validate_constraints()` (Django >=4.1) po cichu POMIJA + # sprawdzenie, gdy pole użyte w `condition` (`deleted_at`) jest + # wykluczone z walidacji — a admin wyklucza wszystko spoza swoich + # `fieldsets`. Komunikat dla operatora daje jawne `clean_autor()` + # w `bpp/admin/praca_habilitacyjna.py`; tutejsze ograniczenie jest + # ostateczną gwarancją na poziomie bazy. + models.UniqueConstraint( + fields=["autor"], + condition=Q(deleted_at__isnull=True), + name="phab_uniq_autor_zywy", + # Bez tego operator zobaczyłby w adminie domyślne + # „Constraint “phab_uniq_autor_zywy” is violated." — Django nie + # umie zmapować UniqueConstraint z `condition` na komunikat + # przy polu, więc trafia to w błędy ogólne formularza. + violation_error_message="Ten autor ma już pracę habilitacyjną.", + ), + ] def clean(self): DwaTytuly.clean(self) diff --git a/src/bpp/newsfragments/soft-delete-habilitacja-warunkowy-unique.bugfix.rst b/src/bpp/newsfragments/soft-delete-habilitacja-warunkowy-unique.bugfix.rst new file mode 100644 index 000000000..0bbe2a965 --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-habilitacja-warunkowy-unique.bugfix.rst @@ -0,0 +1,3 @@ +Scalanie autorów nie kończy się już błędem, gdy duplikat ma pracę +habilitacyjną w koszu, a autor główny — żywą. Gdy obaj mają żywą pracę +habilitacyjną, operator dostaje czytelny komunikat zamiast błędu bazy danych. diff --git a/src/bpp/templates/browse/autor.html b/src/bpp/templates/browse/autor.html index 9ae3183b6..0eac485ac 100644 --- a/src/bpp/templates/browse/autor.html +++ b/src/bpp/templates/browse/autor.html @@ -187,7 +187,12 @@

    Metryki ewaluacyjne

    {% endwith %} - {% if autor.praca_doktorska_set.exists or autor.praca_habilitacyjna %} + {# `praca_habilitacyjna_set`, a nie `praca_habilitacyjna`: pole `autor` #} + {# jest od fazy 03 zwyklym ForeignKey (warunkowy unique zamiast #} + {# twardego), wiec akcesor odwrotny to manager, nie obiekt. Manager #} + {# relacji filtruje kosz — odwrotne OneToOne szlo przez `_base_manager` #} + {# i pokazywalo rekordy skasowane. #} + {% if autor.praca_doktorska_set.exists or autor.praca_habilitacyjna_set.exists %}
    diff --git a/src/bpp/tests/test_admin/test_praca_habilitacyjna_unique.py b/src/bpp/tests/test_admin/test_praca_habilitacyjna_unique.py new file mode 100644 index 000000000..3dd9fa569 --- /dev/null +++ b/src/bpp/tests/test_admin/test_praca_habilitacyjna_unique.py @@ -0,0 +1,157 @@ +"""Formularz admina musi łapać drugą ŻYWĄ habilitację tego samego autora. + +Do fazy 03 pilnowało tego ``OneToOneField`` — Django sprawdzał to zwykłym +``validate_unique()`` i operator dostawał błąd formularza. + +Faza 03 zamieniła pole na ``ForeignKey`` + warunkowy +``UniqueConstraint(condition=Q(deleted_at__isnull=True))``, żeby habilitacja +w koszu nie blokowała scalania autorów. To przenosi walidację z +``validate_unique()`` (patrzy na ``Meta.unique_together``/``unique``) do +``validate_constraints()`` (patrzy na ``Meta.constraints``, Django >=4.1). + +I tu jest pułapka: ``Model.validate_constraints()`` **CICHO POMIJA** constraint, +którego pole warunku (``deleted_at``) jest wykluczone z walidacji — a jest, +bo ``_get_validation_exclusions()`` wyklucza wszystko spoza ``Meta.fields``, +które admin nadpisuje spłaszczonymi ``fieldsets``. Bez jawnego sprawdzenia +admin przepuściłby duplikat i wywalił się ``IntegrityError``-em (HTTP 500) +dopiero przy zapisie. + +Stąd ``Praca_HabilitacyjnaForm.clean_autor()``. Te testy pilnują trzech rzeczy +naraz: że kolizja jest łapana, że rekord nie koliduje SAM ZE SOBĄ przy edycji, +i że praca w koszu NIE blokuje wprowadzenia nowej (bo inaczej „naprawą" byłby +powrót do bezwarunkowej unikalności, czyli cofnięcie całej pozycji 3.2). +""" + +import pytest +from django.contrib import admin as django_admin +from django.test import RequestFactory +from model_bakery import baker + +from bpp.models import Autor, Jednostka, Praca_Habilitacyjna + + +def _klasa_formularza(admin_user): + """Formularz DOKŁADNIE taki, jaki zbuduje admin. + + ``Praca_HabilitacyjnaForm`` sam nie ma ``Meta.model`` — dostaje go dopiero + z ``ModelAdmin.get_form()``. Idziemy tą samą drogą, bo testujemy zachowanie + admina, a nie wyizolowanej klasy formularza. + """ + request = RequestFactory().get("/") + request.user = admin_user + model_admin = django_admin.site._registry[Praca_Habilitacyjna] + return model_admin.get_form(request, obj=None, change=False) + + +def _dane_formularza(autor, jednostka, typy_kbn, statusy_korekt, jezyki): + from bpp.models import Jezyk, Typ_KBN + + return { + "tytul_oryginalny": "Druga habilitacja tego samego autora", + "autor": autor.pk, + "jednostka": jednostka.pk, + "rok": 2023, + "jezyk": Jezyk.objects.get(skrot="pol.").pk, + "typ_kbn": Typ_KBN.objects.first().pk, + "status_korekty": statusy_korekt["przed korektą"].pk, + # Pola punktacji są wymagane przez formularz. Wypełniamy KOMPLET, + # żeby jedynym możliwym powodem odrzucenia był konflikt autora — + # inaczej pierwszy test byłby czerwony z byle powodu. + "punkty_kbn": 0, + "impact_factor": 0, + "index_copernicus": 0, + "punktacja_snip": 0, + "punktacja_wewnetrzna": 0, + } + + +@pytest.mark.django_db +def test_admin_nie_pozwala_na_druga_zywa_habilitacje_autora( + admin_user, + typy_kbn, + statusy_korekt, + jezyki, + charaktery_formalne, + typy_odpowiedzialnosci, +): + autor = baker.make(Autor) + jednostka = baker.make(Jednostka) + baker.make(Praca_Habilitacyjna, autor=autor) + + form = _klasa_formularza(admin_user)( + data=_dane_formularza(autor, jednostka, typy_kbn, statusy_korekt, jezyki) + ) + + assert not form.is_valid(), ( + "formularz przepuscil druga zywa habilitacje — przy zapisie poleci " + "IntegrityError (HTTP 500) zamiast bledu formularza" + ) + # Komunikat ma być dla człowieka, a nie „Constraint “phab_uniq_autor_zywy” + # is violated." — Django nie umie zmapować UniqueConstraint z `condition` + # na pole, więc bez `violation_error_message` operator dostaje nazwę + # ograniczenia z bazy. + assert "Ten autor ma już pracę habilitacyjną." in str(form.errors), ( + f"blad nie tlumaczy przyczyny: {form.errors.as_json()}" + ) + + +@pytest.mark.django_db +def test_admin_pozwala_zapisac_edycje_istniejacej_habilitacji( + admin_user, + typy_kbn, + statusy_korekt, + jezyki, + charaktery_formalne, + typy_odpowiedzialnosci, +): + """Kontrola: rekord nie może kolidować SAM ZE SOBĄ. + + Bez wykluczenia edytowanego wiersza każda zmiana istniejącej habilitacji + (poprawka literówki w tytule!) byłaby odrzucana jako duplikat. + """ + autor = baker.make(Autor) + jednostka = baker.make(Jednostka) + habilitacja = baker.make(Praca_Habilitacyjna, autor=autor) + + request = RequestFactory().get("/") + request.user = admin_user + model_admin = django_admin.site._registry[Praca_Habilitacyjna] + FormKlasa = model_admin.get_form(request, obj=habilitacja, change=True) + + form = FormKlasa( + instance=habilitacja, + data=_dane_formularza(autor, jednostka, typy_kbn, statusy_korekt, jezyki), + ) + + assert form.is_valid(), ( + f"edycja wlasnej habilitacji odrzucona jako duplikat: {form.errors.as_json()}" + ) + + +@pytest.mark.django_db +def test_admin_pozwala_na_habilitacje_gdy_poprzednia_jest_w_koszu( + admin_user, + typy_kbn, + statusy_korekt, + jezyki, + charaktery_formalne, + typy_odpowiedzialnosci, +): + """Kontrola: warunkowy unique NIE obejmuje kosza. + + Bez tej asercji „naprawą" pierwszego testu mogłoby być przywrócenie + bezwarunkowej unikalności — czyli cofnięcie całej pozycji 3.2. + """ + autor = baker.make(Autor) + jednostka = baker.make(Jednostka) + stara = baker.make(Praca_Habilitacyjna, autor=autor) + stara.delete() + + form = _klasa_formularza(admin_user)( + data=_dane_formularza(autor, jednostka, typy_kbn, statusy_korekt, jezyki) + ) + + assert form.is_valid(), ( + f"habilitacja w koszu nie moze blokowac wprowadzenia nowej: " + f"{form.errors.as_json()}" + ) diff --git a/src/bpp/views/api/__init__.py b/src/bpp/views/api/__init__.py index 7e5c633ef..f6752fc6d 100644 --- a/src/bpp/views/api/__init__.py +++ b/src/bpp/views/api/__init__.py @@ -32,23 +32,19 @@ def post(self, request, *args, **kw): except Autor.DoesNotExist: return HttpResponseNotFound("Autor") - try: - habilitacja = autor.praca_habilitacyjna - except Praca_Habilitacyjna.DoesNotExist: - return HttpResponseNotFound("Habilitacja") - - # ⚠️ Odwrotne OneToOne NIE respektuje soft-delete. Django rozwiązuje - # `autor.praca_habilitacyjna` przez `ReverseOneToOneDescriptor`, a ten - # pyta `_base_manager` — z definicji NIEprzefiltrowany (Django wymaga, - # żeby zwracał wszystkie wiersze, bo służy do pobierania obiektów - # powiązanych). Skasowana habilitacja jest więc tą ścieżką nadal - # osiągalna, mimo że `Praca_Habilitacyjna.objects` jej nie pokazuje. + # Jawne zapytanie po `objects` (menedżer filtrujący kosz), a NIE po + # akcesorze odwrotnym. Wcześniej `autor.praca_habilitacyjna` szło przez + # `ReverseOneToOneDescriptor`, ten pyta `_base_manager` — z definicji + # NIEprzefiltrowany — i habilitacja z kosza była tą ścieżką nadal + # osiągalna, więc trzeba było dokładać ręczny warunek na `deleted_at`. # - # Nie da się tego naprawić centralnie bez ustawienia - # `Meta.base_manager_name` na menedżer filtrujący — a tego Django - # jawnie odradza (rozwaliłoby m.in. deserializację i - # `refresh_from_db`). Dlatego sprawdzamy tu jawnie. - if habilitacja.deleted_at is not None: + # Od czasu, gdy `Praca_Habilitacyjna.autor` jest zwykłym `ForeignKey` + # (warunkowy unique, faza 03), tamten akcesor już nie istnieje. + # `first()` zamiast `get()`, bo unikalność jest teraz pilnowana + # warunkowo — a niepowtarzalny jest tylko wiersz ŻYWY, czyli dokładnie + # ten, którego `objects` szuka. + habilitacja = Praca_Habilitacyjna.objects.filter(autor=autor).first() + if habilitacja is None: return HttpResponseNotFound("Habilitacja") return JsonResponse({"rok": habilitacja.rok}) diff --git a/src/deduplikator_autorow/tests/test_scal_autora_soft_delete.py b/src/deduplikator_autorow/tests/test_scal_autora_soft_delete.py new file mode 100644 index 000000000..ed5e7635e --- /dev/null +++ b/src/deduplikator_autorow/tests/test_scal_autora_soft_delete.py @@ -0,0 +1,104 @@ +"""Scalanie autorów a kosz (faza 03 soft-delete, pozycje 3.2–3.4 handoffu). + +Scalanie należy do „kategorii B": przenosi rekordy duplikatu RAZEM Z KOSZEM, +żeby po usunięciu duplikatu nie zostały sieroty. To poprawne — ale odsłoniło +trzy miejsca, w których reszta kodu zakłada, że wiersz w koszu nie istnieje: + +3.2. ``Praca_Habilitacyjna.autor`` było ``OneToOneField`` → twardy + ``UNIQUE (autor_id)``, który kosza NIE zna. +3.3. Detekcja kolizji autorstw szła po menedżerze ŻYWYCH. +3.4. Kolejkowanie do PBN nie sprawdzało, czy publikacja jest w koszu. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Praca_Habilitacyjna +from deduplikator_autorow.utils.merge import scal_autora + + +@pytest.fixture +def user(db): + return baker.make("bpp.BppUser") + + +@pytest.fixture +def main_dup(autor_maker, tytuly, typy_odpowiedzialnosci, charaktery_formalne): + # Słowniki z fixture'ów, nie z baseline: testy transakcyjne z tego samego + # przebiegu potrafią wyczyścić dane referencyjne (patrz zapis habilitacji, + # który sięga po Typ_Odpowiedzialnosci przy przebudowie cache'u). + glowny = autor_maker(imiona="Jan", nazwisko="Kowalski") + duplikat = autor_maker(imiona="Jan", nazwisko="Kowalski-Duplikat") + return glowny, duplikat + + +# --- 3.2. Habilitacja: unique musi znać kosz ------------------------------- + + +def test_scalanie_przenosi_habilitacje_z_kosza_gdy_glowny_ma_zywa(main_dup, user): + """Habilitacja duplikatu W KOSZU nie może wywrócić całego scalania. + + ``UNIQUE (autor_id)`` z ``OneToOneField`` nie zna kosza: przeniesienie + skasowanej habilitacji duplikatu na autora, który ma już żywą, wywalało + ``IntegrityError`` — a ten leci przez ``except Exception`` w ``scal_autora`` + i cała operacja zwraca porażkę. Przed fazą 03 wiersz z kosza w ogóle nie był + przenoszony, więc scalanie się udawało; po włączeniu transferu „razem + z koszem" zaczęło padać. + """ + glowny, duplikat = main_dup + baker.make(Praca_Habilitacyjna, autor=glowny) + hab_duplikatu = baker.make(Praca_Habilitacyjna, autor=duplikat) + hab_duplikatu.delete() + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=True) + + assert wynik["success"] is True, ( + f"scalanie padlo zamiast przeniesc habilitacje z kosza: {wynik.get('error')!r}" + ) + hab_duplikatu.refresh_from_db() + assert hab_duplikatu.autor_id == glowny.pk, ( + "habilitacja z kosza zostala przy usunietym duplikacie — sierota" + ) + assert hab_duplikatu.deleted_at is not None, ( + "transfer nie moze przy okazji wskrzeszac rekordu z kosza" + ) + + +def test_dwie_zywe_habilitacje_daja_czytelny_komunikat_nie_integrityerror( + main_dup, user +): + """Prawdziwy konflikt ma być nazwany po imieniu. + + Gdy OBAJ autorzy mają ŻYWĄ pracę habilitacyjną, scalenie jest niemożliwe — + ale operator ma się dowiedzieć DLACZEGO, zamiast dostać komunikat bazy + o naruszeniu ograniczenia. + """ + glowny, duplikat = main_dup + baker.make(Praca_Habilitacyjna, autor=glowny) + baker.make(Praca_Habilitacyjna, autor=duplikat) + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=True) + + assert wynik["success"] is False + assert "obaj mają pracę habilitacyjną" in wynik["error"], ( + f"komunikat nie tlumaczy przyczyny: {wynik['error']!r}" + ) + assert "duplicate key" not in wynik["error"] + assert "UNIQUE" not in wynik["error"] + + # Konflikt nie moze zostawic bazy w polowie scalonej. + assert type(glowny).objects.filter(pk=duplikat.pk).exists(), ( + "duplikat zostal usuniety mimo nieudanego scalania" + ) + + +def test_habilitacja_duplikatu_przechodzi_gdy_glowny_nie_ma_zadnej(main_dup, user): + """Kontrola: bez konfliktu transfer ma działać jak dotąd.""" + glowny, duplikat = main_dup + habilitacja = baker.make(Praca_Habilitacyjna, autor=duplikat) + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=True) + + assert wynik["success"] is True, wynik.get("error") + habilitacja.refresh_from_db() + assert habilitacja.autor_id == glowny.pk diff --git a/src/deduplikator_autorow/utils/merge.py b/src/deduplikator_autorow/utils/merge.py index 20cc897fe..32af21fd9 100644 --- a/src/deduplikator_autorow/utils/merge.py +++ b/src/deduplikator_autorow/utils/merge.py @@ -18,6 +18,15 @@ logger = logging.getLogger(__name__) +class KonfliktScalania(Exception): + """Scalania nie da się wykonać z powodu danych, nie z powodu awarii. + + Wydzielone z ``Exception``, żeby ``scal_autora`` mogło odróżnić „operator + poprosił o coś sprzecznego" od „coś się zepsuło": to pierwsze wraca jako + czytelny komunikat i NIE trafia do Rollbara. + """ + + def _assign_discipline_if_missing( autor_record, glowny_autor, rok, auto_assign_discipline, use_subdiscipline, warnings ): @@ -246,15 +255,37 @@ def wiersze_do_transferu(model, autor_duplikat): def _transfer_simple_authorship( - model, model_label, glowny_autor, autor_duplikat, user, skip_pbn, results + model, + model_label, + glowny_autor, + autor_duplikat, + user, + skip_pbn, + results, + opis_konfliktu=None, ): """ Przenosi proste rekordy autorstwa (Praca_Habilitacyjna / Praca_Doktorska), gdzie sam obiekt jest publikacją — przemapowuje autora i kolejkuje do PBN. + + ``opis_konfliktu`` podaje się dla modeli, w których autor może mieć tylko + JEDEN żywy wiersz (habilitacja). Wtedy zderzenie dwóch żywych prac jest + prawdziwym konfliktem danych i kończy scalanie czytelnym komunikatem, + zamiast pozwalać bazie rzucić ``IntegrityError``. """ from pbn_export_queue.models import PBN_Export_Queue for praca in wiersze_do_transferu(model, autor_duplikat): + # Kolizja dotyczy WYŁĄCZNIE wierszy żywych — warunkowy unique + # (`deleted_at IS NULL`) nie obejmuje kosza, więc praca skasowana + # przechodzi na głównego autora bez przeszkód. + if ( + opis_konfliktu + and getattr(praca, "deleted_at", None) is None + and model.objects.filter(autor=glowny_autor).exists() + ): + raise KonfliktScalania(f"Nie można scalić autorów: {opis_konfliktu}.") + # Przemapuj autora praca.autor = glowny_autor praca.save() @@ -322,10 +353,12 @@ def scal_autora( ("Wydawnictwo_Zwarte_Autor", Wydawnictwo_Zwarte_Autor, False), ("Patent_Autor", Patent_Autor, False), ] - # Proste publikacje (sam obiekt jest publikacją). + # Proste publikacje (sam obiekt jest publikacją). Trzeci element to opis + # konfliktu dla modeli z regułą „jeden żywy wiersz na autora"; ``None`` + # znaczy, że autor może mieć takich prac wiele (doktorat). simple_models = [ - ("Praca_Habilitacyjna", Praca_Habilitacyjna), - ("Praca_Doktorska", Praca_Doktorska), + ("Praca_Habilitacyjna", Praca_Habilitacyjna, "obaj mają pracę habilitacyjną"), + ("Praca_Doktorska", Praca_Doktorska, None), ] try: @@ -370,7 +403,7 @@ def scal_autora( ) # 4-5. Prace doktorskie / habilitacyjne - for model_label, model in simple_models: + for model_label, model, opis_konfliktu in simple_models: _transfer_simple_authorship( model, model_label, @@ -379,12 +412,22 @@ def scal_autora( user, skip_pbn, results, + opis_konfliktu=opis_konfliktu, ) autor_duplikat.delete() return results + except KonfliktScalania as e: + # Sprzeczne dane, nie awaria — operator ma dostać komunikat, którym może + # coś zrobić, a Rollbar nie ma dostać szumu. `transaction.atomic` już + # wycofał częściowe zmiany. + logger.info("Scalanie autorow przerwane konfliktem danych: %s", e) + results["success"] = False + results["error"] = str(e) + return results + except Exception as e: traceback.print_exc() rollbar.report_exc_info(sys.exc_info()) From 369e6a5f674d64923ad694f6e49c0cfa83e797d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 22:51:14 +0200 Subject: [PATCH 71/75] fix(soft-delete): scalanie widzi kosz przy kolizjach i nie kolejkuje go do PBN (3.3, 3.4) 3.3 -- detekcja kolizji autorstw szla po `model.objects`, czyli po menedzerze ZYWYCH. Gdy publikacja jest w koszu, oba autorstwa (glownego i duplikatu) tez tam sa, wiec kolizji nie bylo widac: transfer przechodzil i w koszu ladowaly DWA wiersze `(rekord, glowny, typ)`. Warunkowy `wc_autor_uniq_rekord_autor_typ` obowiazuje wsrod zywych, wiec kolizja wybuchala dopiero przy `publikacja.restore()` -- kosz stawal sie drzwiami jednokierunkowymi. Detekcja idzie teraz po `global_objects` (ten sam `getattr`-fallback, co `wiersze_do_transferu`, bo funkcja obsluguje tez modele bez soft-delete). Uwaga na przyszlosc, ujawniona przy weryfikacji mutacyjnej: wiersz duplikatu znika dzis dlatego, ze `autor_duplikat.delete()` kaskaduje TWARDO po FK. Pierwsza wersja poprawki odpinala go dodatkowo od grupy restore'u wlasnym `transaction_id` -- mutacja pokazala, ze to martwy kod, wiec zostal usuniety. Gdy faza 04 zmieni kasowanie autora na miekkie, ta galaz bedzie musiala wrocic; zapisane w handoffie fazy 04. 3.4 -- scalanie kolejkowalo do `PBN_Export_Queue` wszystko, co przenioslo, takze rekordy w koszu. To sprzeczne z kierunkiem fazy 05: soft-delete ma oswiadczenia z PBN WYCOFYWAC, a nie wysylac tam rzeczy, ktorych w BPP "nie ma". Guard `_w_koszu()` w obu miejscach kolejkujacych (autorstwa + proste publikacje). Testy: 5 nowych. Kazda z trzech mutacji (detekcja po `objects`, zdjecie guardu PBN w kazdym z dwoch miejsc) wywraca dokladnie swoj test. Refs #742 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../soft-delete-scalanie-a-kosz.bugfix.rst | 3 + .../tests/test_scal_autora_soft_delete.py | 137 ++++++++++++++++++ src/deduplikator_autorow/utils/merge.py | 40 ++++- 3 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 src/bpp/newsfragments/soft-delete-scalanie-a-kosz.bugfix.rst diff --git a/src/bpp/newsfragments/soft-delete-scalanie-a-kosz.bugfix.rst b/src/bpp/newsfragments/soft-delete-scalanie-a-kosz.bugfix.rst new file mode 100644 index 000000000..36dfafee4 --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-scalanie-a-kosz.bugfix.rst @@ -0,0 +1,3 @@ +Scalanie duplikatów autorów przestało dublować autorstwa w koszu (co blokowało +późniejsze przywrócenie publikacji) oraz przestało kolejkować do eksportu +w PBN publikacje, które są w koszu. diff --git a/src/deduplikator_autorow/tests/test_scal_autora_soft_delete.py b/src/deduplikator_autorow/tests/test_scal_autora_soft_delete.py index ed5e7635e..7b2d69723 100644 --- a/src/deduplikator_autorow/tests/test_scal_autora_soft_delete.py +++ b/src/deduplikator_autorow/tests/test_scal_autora_soft_delete.py @@ -102,3 +102,140 @@ def test_habilitacja_duplikatu_przechodzi_gdy_glowny_nie_ma_zadnej(main_dup, use assert wynik["success"] is True, wynik.get("error") habilitacja.refresh_from_db() assert habilitacja.autor_id == glowny.pk + + +# --- 3.3. Detekcja kolizji autorstw musi widzieć kosz ---------------------- + + +def test_kolizja_autorstw_w_koszu_nie_tworzy_dwoch_wierszy( + main_dup, user, wydawnictwo_ciagle, jednostka +): + """Kosz nie może stać się drzwiami jednokierunkowymi. + + Gdy publikacja jest w koszu, oba autorstwa (głównego i duplikatu) też tam + są. Detekcja kolizji szła po ``model.objects`` — menedżerze ŻYWYCH — więc + kolizji nie widziała i przepuszczała transfer. W koszu lądowały DWA wiersze + ``(rekord, glowny, typ)``, a to znaczy, że ``publikacja.restore()`` wywala + się ``IntegrityError``-em na ``wc_autor_uniq_rekord_autor_typ``: rekordu + nie da się już wyjąć z kosza. + + Ta sama pułapka dotyczy wskrzeszania z importu (pozycja 3.1). + """ + from bpp.models import Wydawnictwo_Ciagle_Autor + + glowny, duplikat = main_dup + wydawnictwo_ciagle.dodaj_autora(glowny, jednostka) + wydawnictwo_ciagle.dodaj_autora(duplikat, jednostka) + # Kasujemy publikację — kaskada z fazy 02 zabiera oba autorstwa do kosza. + wydawnictwo_ciagle.delete() + + duplikat_pk = duplikat.pk + wynik = scal_autora(glowny, duplikat, user, skip_pbn=True) + + assert wynik["success"] is True, wynik.get("error") + + # 1. Zdublowane autorstwo nie może zostać przy autorze, który znika — + # inaczej w koszu leży sierota wskazująca na nieistniejącego autora. + assert not Wydawnictwo_Ciagle_Autor.global_objects.filter( + autor_id=duplikat_pk + ).exists(), "autorstwo w koszu zostalo przy usunietym duplikacie — sierota" + + # 2. Rekord musi dać się wyjąć z kosza, i to z DOKŁADNIE jednym żywym + # wierszem (rekord, glowny, typ). Dwa wiersze w koszu z tym samym + # `transaction_id` wróciłyby razem i wywaliły `restore()` + # na `wc_autor_uniq_rekord_autor_typ`. + wydawnictwo_ciagle.restore() + + assert ( + Wydawnictwo_Ciagle_Autor.objects.filter( + rekord=wydawnictwo_ciagle, autor=glowny + ).count() + == 1 + ), "restore wskrzesil zdublowane autorstwo" + + +def test_kolizja_zywych_autorstw_nadal_wykrywana( + main_dup, user, wydawnictwo_ciagle, jednostka +): + """Kontrola: przejście na globalny menedżer nie może zgubić starej ścieżki. + + Kolizja dwóch ŻYWYCH autorstw ma nadal kasować wiersz duplikatu i zgłaszać + to w ostrzeżeniach. + """ + from bpp.models import Wydawnictwo_Ciagle_Autor + + glowny, duplikat = main_dup + wydawnictwo_ciagle.dodaj_autora(glowny, jednostka) + wydawnictwo_ciagle.dodaj_autora(duplikat, jednostka) + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=True) + + assert wynik["success"] is True, wynik.get("error") + assert ( + Wydawnictwo_Ciagle_Autor.objects.filter( + rekord=wydawnictwo_ciagle, autor=glowny + ).count() + == 1 + ) + assert any("już ma publikację" in w for w in wynik["warnings"]) + + +# --- 3.4. Nie kolejkujemy do PBN tego, czego w BPP „nie ma" ---------------- + + +def test_scalanie_nie_kolejkuje_do_pbn_publikacji_z_kosza( + main_dup, user, wydawnictwo_ciagle, jednostka +): + """Publikacja w koszu nie ma czego szukać w kolejce eksportu do PBN. + + Kierunek fazy 05 jest odwrotny: soft-delete ma WYCOFYWAĆ oświadczenia + z PBN, a nie je tam wysyłać. Scalanie autorów kolejkowało wszystko, co + przeniosło — łącznie z rekordami, które operator wcześniej skasował. + """ + from pbn_export_queue.models import PBN_Export_Queue + + glowny, duplikat = main_dup + wydawnictwo_ciagle.dodaj_autora(duplikat, jednostka) + wydawnictwo_ciagle.delete() + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=False) + + assert wynik["success"] is True, wynik.get("error") + assert not PBN_Export_Queue.objects.exists(), ( + "rekord z kosza trafil do kolejki eksportu do PBN" + ) + assert wynik["publications_queued_for_pbn"] == [] + + +def test_scalanie_nie_kolejkuje_do_pbn_habilitacji_z_kosza(main_dup, user): + """To samo dla „prostych" publikacji (sam obiekt jest publikacją).""" + from pbn_export_queue.models import PBN_Export_Queue + + glowny, duplikat = main_dup + habilitacja = baker.make(Praca_Habilitacyjna, autor=duplikat) + habilitacja.delete() + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=False) + + assert wynik["success"] is True, wynik.get("error") + assert not PBN_Export_Queue.objects.exists(), ( + "habilitacja z kosza trafila do kolejki eksportu do PBN" + ) + + +def test_scalanie_nadal_kolejkuje_zywa_publikacje( + main_dup, user, wydawnictwo_ciagle, jednostka +): + """Kontrola: pominięcie kosza nie może wyłączyć kolejkowania w ogóle.""" + from pbn_export_queue.models import PBN_Export_Queue + + glowny, duplikat = main_dup + wydawnictwo_ciagle.dodaj_autora(duplikat, jednostka) + + wynik = scal_autora(glowny, duplikat, user, skip_pbn=False) + + assert wynik["success"] is True, wynik.get("error") + assert PBN_Export_Queue.objects.count() == 1, ( + "zywa publikacja przestala trafiac do kolejki PBN" + ) + assert wynik["publications_queued_for_pbn"] != [] diff --git a/src/deduplikator_autorow/utils/merge.py b/src/deduplikator_autorow/utils/merge.py index 32af21fd9..6a0baa20c 100644 --- a/src/deduplikator_autorow/utils/merge.py +++ b/src/deduplikator_autorow/utils/merge.py @@ -27,6 +27,15 @@ class KonfliktScalania(Exception): """ +def _w_koszu(obiekt): + """``True``, gdy obiekt jest soft-skasowany. + + ``getattr`` z fallbackiem, bo scalanie przechodzi też przez modele spoza + soft-delete (jak ``wiersze_do_transferu``). + """ + return getattr(obiekt, "deleted_at", None) is not None + + def _assign_discipline_if_missing( autor_record, glowny_autor, rok, auto_assign_discipline, use_subdiscipline, warnings ): @@ -151,8 +160,18 @@ def _transfer_authorship_record( # Store old discipline before any changes old_discipline = record.dyscyplina_naukowa - # CHECK IF MAIN AUTHOR ALREADY HAS THIS PUBLICATION - existing = model.objects.filter( + # CZY GŁÓWNY AUTOR JUŻ MA TĘ PUBLIKACJĘ — SZUKAMY RAZEM Z KOSZEM. + # + # Po menedżerze ŻYWYCH kolizja była niewidoczna dokładnie wtedy, gdy + # publikacja jest w koszu: wtedy oba autorstwa (głównego i duplikatu) też + # tam są. Transfer przechodził i w koszu lądowały DWA wiersze + # `(rekord, glowny, typ)` — a warunkowy `wc_autor_uniq_rekord_autor_typ` + # obowiązuje wśród ŻYWYCH, więc kolizja wybuchała dopiero przy + # `publikacja.restore()`. Kosz stawał się drzwiami jednokierunkowymi: + # rekordu nie dało się już z niego wyjąć (dotyczy też wskrzeszania + # z importu, `pbn_integrator/kosz.py`). + manager = getattr(model, "global_objects", model.objects) + existing = manager.filter( rekord=record.rekord, autor=glowny_autor, typ_odpowiedzialnosci=record.typ_odpowiedzialnosci, @@ -165,6 +184,11 @@ def _transfer_authorship_record( f"z typem odpowiedzialności {record.typ_odpowiedzialnosci}. " f"Usunięto duplikat." ) + # Wiersz duplikatu zostaje przy duplikacie i znika razem z nim + # (`autor_duplikat.delete()` na końcu scalania kaskaduje TWARDO po FK). + # Dla wiersza już skasowanego `delete()` odświeża tylko `deleted_at` — + # to no-op, ale trzymamy jedną ścieżkę zamiast rozgałęziać na coś, + # czego i tak za chwilę nie będzie. record.delete() return False @@ -221,8 +245,11 @@ def _transfer_authorship_record( **log_ctx, ) - # Dodaj do kolejki PBN - if not skip_pbn and record.rekord: + # Dodaj do kolejki PBN — ale NIE rekordu z kosza. Kierunek soft-delete jest + # odwrotny: faza 05 ma oświadczenia z PBN WYCOFYWAĆ, a nie wysyłać tam + # rzeczy, których w BPP „nie ma". Scalanie kolejkowało wszystko, co + # przeniosło, łącznie z rekordami skasowanymi wcześniej przez operatora. + if not skip_pbn and record.rekord and not _w_koszu(record.rekord): content_type = ContentType.objects.get_for_model(record.rekord) PBN_Export_Queue.objects.create( content_type=content_type, @@ -290,8 +317,9 @@ def _transfer_simple_authorship( praca.autor = glowny_autor praca.save() - # Dodaj do kolejki PBN - if not skip_pbn: + # Dodaj do kolejki PBN — z pominięciem kosza, jak w + # `_transfer_authorship_record`. + if not skip_pbn and not _w_koszu(praca): content_type = ContentType.objects.get_for_model(praca) PBN_Export_Queue.objects.create( content_type=content_type, From ac809b958313c1098b1d99b774a5752900dbc713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 23:01:22 +0200 Subject: [PATCH 72/75] docs(soft-delete): handoff fazy 04 po domknieciu 3.1-3.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §1: baseline jest niesswiezy (bpp/0487 vs 0500) i byl taki juz przed faza 03; odswiezenie przy scalaniu, nie w feature-branchu - §2a (nowe): druga zmiana kierunku -- akcesory NIE zagladaja do kosza - §3: tabela zmian przepisana na stan faktyczny (poprzednia opisywala implementacje cofnieta w eb046897a) + sekcja "Czego faza 03 NIE naprawia" - §4: dwie pulapki o wolaczach akcesorow (`.original` w adminie, `matchuj_publikacje` z dwoma wolaczami o sprzecznych potrzebach) - §5: ostrzezenie dla fazy 04 -- `scal_autora` polega dzis na TWARDEJ kaskadzie `autor_duplikat.delete()`; po zmianie na miekkie kasowanie autora wiersz przetrwa jako sierota i zepsuje `restore()` - §6: cztery pozycje dlugu z handoffu domkniecia (admin rejestru, force, Oswiadczenie_Instytucji, post_hard_delete) - §7: trzy nowe wnioski procesowe -- mutacja ktora PRZESZLA to informacja; dane referencyjne z baseline nie sa w testach gwarantowane; `git checkout` kasuje niezacommitowana implementacje przy testowaniu mutacyjnym Handoff domkniecia oznaczony jako historyczny, z mapowaniem pozycji na commity. Refs #742 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../superpowers/HANDOFF-faza-03-domkniecie.md | 20 +++ .../HANDOFF-soft-delete-faza-04.md | 128 ++++++++++++++++-- 2 files changed, 138 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/HANDOFF-faza-03-domkniecie.md b/docs/superpowers/HANDOFF-faza-03-domkniecie.md index 1732e98e6..360f93e2b 100644 --- a/docs/superpowers/HANDOFF-faza-03-domkniecie.md +++ b/docs/superpowers/HANDOFF-faza-03-domkniecie.md @@ -1,5 +1,25 @@ # Handoff: domknięcie fazy 03 (PR #742 wymaga poprawek) +> ## ✅ ZAMKNIĘTE 2026-08-08 — dokument HISTORYCZNY, nie lista zadań +> +> Wszystkie pięć pozycji (3.1–3.5) zostało zrobione. Nie działaj na podstawie +> tego pliku — aktualny stan i dług są w +> `docs/superpowers/HANDOFF-soft-delete-faza-04.md`. +> +> | Pozycja | Commit | +> |---|---| +> | 3.1 zaglądanie do kosza w gałęzi `None` | `b98aad5be` | +> | 3.2 warunkowy unique na habilitacji | `9c3ecb23e` | +> | 3.3 detekcja kolizji po `global_objects` | `369e6a5f6` | +> | 3.4 brak kolejkowania kosza do PBN | `369e6a5f6` | +> | 3.5 opis PR #742 | przepisany w GitHubie | +> +> Pozycje 9, 10, 13, 14 z §4 (niższy priorytet) NADAL otwarte — przeniesione +> do §6 handoffu fazy 04. Pułapki z §5 przeniesione do §7 tamże, wraz z trzema +> nowymi, które kosztowały tę sesję. +> +> --- + > Stan na 2026-08-08. Sesja przerwana w połowie naprawiania po self-review. > **PR #742 NIE nadaje się do scalenia w obecnym stanie.** diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-04.md b/docs/superpowers/HANDOFF-soft-delete-faza-04.md index f523fac26..868edc73b 100644 --- a/docs/superpowers/HANDOFF-soft-delete-faza-04.md +++ b/docs/superpowers/HANDOFF-soft-delete-faza-04.md @@ -11,10 +11,18 @@ |---|---| | Gałąź | `feat/soft-delete-03`, worktree `~/Programowanie/bpp-soft-delete-03` | | Baza | `feat/soft-delete` (zawiera fazy 01 i 02; PR #741 scalony) | -| Migracje fazy 03 | `pbn_integrator/0001` (nowy model rejestru) | +| Migracje fazy 03 | `pbn_integrator/0001` (rejestr wskrzeszeń), `bpp/0500` (warunkowy unique na habilitacji) | -Faza 03 domknęła **blocker wydania**: re-import z PBN nie tworzy już -duplikatów rekordów skasowanych miękko. +Faza 03 domknęła **blocker wydania**: re-import z PBN publikacji, której rekord +BPP siedzi w koszu, nie wywala się już `IntegrityError`-em na unikalności +`pbn_uid` — rekord wraca z kosza, ze śladem w rejestrze. ⚠️ Zakres jest węższy, +niż brzmiała pierwotna obietnica „re-import nie tworzy duplikatów" — patrz +§3, „Czego faza 03 NIE naprawia". + +⚠️ **Baseline (`baseline-sql/`) jest NIEŚWIEŻY** — stoi na `bpp/0487`, gałąź na +`bpp/0500`. Był taki już przed fazą 03 (fazy 01–02 dołożyły `0488`–`0499`). +Zgodnie z CLAUDE.md odświeżenie (`make baseline-update`) robi się **raz, przy +scalaniu**, a nie w równoległych feature-branchach. --- @@ -42,20 +50,65 @@ sam rozdział i jego książka-matka, rozróżnialne po `zrodlo_importu`). --- +## 2a. Druga zmiana kierunku: AKCESORY NIE ZAGLĄDAJĄ DO KOSZA + +> Aktualizacja 2026-08-08, po self-review. Pierwsza implementacja fazy 03 +> poszła **złą drogą** i została cofnięta (`eb046897a`). + +Pierwotnie faza 03 kazała akcesorom (`rekord_w_bpp`, `get_bpp_publication`, +`matchuj_publikacje`) zaglądać do kosza i **zwracać rekordy skasowane**. +Właściciel rozstrzygnął inaczej: + +> **Soft-delete znaczy, że rekordu NIE MA.** `Rekord` (widok `bpp_rekord_mat`) +> jest odfiltrowany po `deleted_at` i tak ma zostać. Akcesor zwraca `Rekord` +> albo `None` — nigdy rzeczy z kosza. +> +> Zaglądanie do kosza jest decyzją **IMPORTERA**, nie akcesora. + +Dlaczego to ważne dla fazy 04: akcesory mają wołaczy, którzy zakładają KONKRETNY +typ zwracany. `PublicationAdmin` woła `.original` — atrybut istniejący +WYŁĄCZNIE na `Rekord`. Zwrócenie modelu konkretnego wywalało `AttributeError` +na całej changeliście, a w szablonie (`change_form.html`) to samo dawało pusty +`href` **bez żadnego wyjątku**. Pilnuje tego dziś +`src/pbn_api/tests/test_admin_rekord_w_bpp.py`. + +--- + ## 3. Co faza 03 zmieniła w kodzie | Miejsce | Zmiana | |---|---| -| `pbn_api/models/publication.py` | `get_bpp_publication` / `rekord_w_bpp` matchują przez `global_objects` modeli źródłowych zamiast widoku `Rekord` | -| `import_common/core/publikacja.py` | helper `widzacy_manager()`; 6 lookupów matchingu widzi kosz | +| `pbn_integrator/kosz.py` | `przywroc_jesli_w_koszu()`, `wskrzes_z_kosza_po_pbn_uid()`, `znajdz_lub_wskrzes_rekord()` — całe zaglądanie do kosza w jednym module | +| `pbn_integrator/importer/{articles,books,chapters}.py` | wspólna preambuła `znajdz_lub_wskrzes_rekord()` w gałęzi `ret is None` | | `pbn_integrator/importer/chapters.py` | `znajdz_ksiazke_nadrzedna()` — wydzielony, widzi kosz, wskrzesza | | `pbn_import/utils/publication_import.py` | `global_objects` + `hard_delete()` przy czyszczeniu przed re-importem | -| `deduplikator_autorow/utils/merge.py` | `wiersze_do_transferu()` — transfer widzi kosz | +| `bpp/models/praca_habilitacyjna.py` | `autor`: `OneToOneField` → `ForeignKey` + warunkowy `phab_uniq_autor_zywy` (migracja `bpp/0500`) | +| `bpp/admin/praca_habilitacyjna.py` | `clean_autor()` — jawna walidacja „jeden autor, jedna żywa habilitacja" | +| `bpp/views/api/__init__.py` | `RokHabilitacjiView` pyta `Praca_Habilitacyjna.objects`, nie akcesor odwrotny | +| `bpp/templates/browse/autor.html` | pętla po `praca_habilitacyjna_set` (jak doktorat) | +| `deduplikator_autorow/utils/merge.py` | `wiersze_do_transferu()` widzi kosz; detekcja kolizji po `global_objects`; `KonfliktScalania`; guard `_w_koszu()` przed kolejką PBN | + +**NIE zmienione (cofnięte świadomie):** `pbn_api/models/publication.py` +i `import_common/core/publikacja.py` — patrz §2a. Helper `widzacy_manager()` +został usunięty razem z testami, które pilnowały złego zachowania. **ZOSTAWIONE świadomie na `objects`** (rejestr decyzji w `src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py`): ewaluacja, snapshot odpięć, komparator PBN, REST API, skanowanie do dedupu publikacji. +### Czego faza 03 NIE naprawia (świadomie) + +**Ścieżka fuzzy tworzy duplikat.** Rekord BPP **bez** `pbn_uid` (praca +wprowadzona ręcznie), skasowany miękko, nie zostanie znaleziony przez +`matchuj_publikacje` — bo ten idzie po `Rekord`, a `Rekord` kosza nie widzi. +Import utworzy nowy rekord. Po decyzji z §2a to zachowanie **zamierzone** +(rekordu nie ma), ale nie należy go opisywać jako „duplikaty domknięte". + +Domknięta jest ścieżka po `pbn_uid` — i tam problemem nie był cichy duplikat, +tylko **wywalenie importu**: `pbn_uid` to `OneToOneField(unique=True)` BEZ +warunku partial, więc rekord w koszu nadal trzyma to pole, a próba utworzenia +nowego kończyła się `IntegrityError`. + --- ## 4. Fakty, które kosztowały rundę poprawek @@ -76,6 +129,14 @@ snapshot odpięć, komparator PBN, REST API, skanowanie do dedupu publikacji. zrefaktoryzowany). Szukaj lookupów na nowo, nie po numerach. - **`GlobalManager` pakietu nie ma metod domenowych.** `wydawnictwa_ nadrzedne_dla_innych()` zostaje na `objects` — świadomie. +- **Lista wołaczy ≠ lista założeń wołaczy.** Sprawdzenie, KTO woła akcesor, + nie mówi nic o tym, CO robi z wynikiem. `PublicationAdmin` woła `.original` + — atrybut istniejący wyłącznie na `Rekord`. To samo pominięcie w szablonie + nie daje wyjątku, tylko pusty `href`. +- **`matchuj_publikacje` ma DWÓCH wołaczy o sprzecznych potrzebach**: + `pbn_api` (przekazuje `Rekord`) i `deduplikator_publikacji` + (`tasks.py:187`, przekazuje modele konkretne). Przełączenie go „na globalny + menedżer" sprawiło, że dedup zaczął podsuwać kosz — wbrew decyzji z Taska 8. --- @@ -159,14 +220,31 @@ i wyrzucenie tego kodu w fazie 07 — koszt bez odbiorcy. `SoftDeleteModel` husk autora zostanie widoczny. - **`Autor.restore()` musi nadpisać `strict=False`** (inwariant z docstringu `bpp/models/soft_delete.py`). -- **Soft-skasowana publikacja NADAL trzyma referencję O2O PROTECT do autora** +- **Soft-skasowana publikacja NADAL trzyma referencję PROTECT do autora** — `Praca_Habilitacyjna.autor`. Dziś oznacza to `ProtectedError` przy próbie skasowania autora i jest to zachowanie poprawne (rekord istnieje, nie wolno go osierocić), ale faza 04 musi to obsłużyć w UI, a nie zostawić jako gołe 500. -- **Odwrotne `OneToOne` omija soft-delete** (`autor.praca_habilitacyjna` idzie - przez `_base_manager`). Naprawione punktowo w `RokHabilitacjiView`; faza 04 - dotyka tej samej relacji, więc trafi na to ponownie. +- **`Praca_Habilitacyjna.autor` NIE jest już `OneToOneField`** (faza 03, + migracja `bpp/0500`). Akcesor odwrotny to `autor.praca_habilitacyjna_set`. + Ścieżka filtrowania w ORM/DjangoQL się nie zmieniła + (`related_query_name` = nazwa modelu), ale każdy kod sięgający po + `autor.praca_habilitacyjna` jako po OBIEKT jest zepsuty. Dwa znane miejsca + są już naprawione (`RokHabilitacjiView`, `browse/autor.html`) i oba miały + testy, które to złapały. +- ⚠️ **`scal_autora` polega dziś na TWARDEJ kaskadzie `autor_duplikat.delete()`.** + Gdy autorstwo duplikatu koliduje z autorstwem głównego i OBA są w koszu, + wiersz duplikatu zostaje przy duplikacie — i znika dopiero dlatego, że + usunięcie autora kaskaduje po FK fizycznie. **Faza 04 zmienia kasowanie + autora na miękkie, więc ten wiersz przetrwa** i zostanie sierotą wskazującą + na autora w koszu; co gorsza będzie miał `transaction_id` publikacji, więc + `publikacja.restore()` wskrzesi DWA wiersze `(rekord, glowny, typ)` + i wywali się na `wc_autor_uniq_rekord_autor_typ`. + Miejsce: gałąź `if existing:` w `_transfer_authorship_record` + (`deduplikator_autorow/utils/merge.py`). Poprawka to prawdopodobnie + przepięcie wiersza na głównego autora + nadanie mu WŁASNEGO + `transaction_id` (odpięcie od grupy restore'u publikacji). Napisana raz + i usunięta, bo mutacja pokazała, że dziś jest martwym kodem — patrz §7. ## 6. Dług nadal otwarty @@ -176,13 +254,43 @@ i wyrzucenie tego kodu w fazie 07 — koszt bez odbiorcy. | **PR upstream `django-easy-audit`** | **WYSTAWIONY 2026-08-08: [soynatan/django-easy-audit#348](https://github.com/soynatan/django-easy-audit/pull/348)** (fork `mpasternak`). Gdy zostanie scalony — skasować `src/bpp/easyaudit_shim.py`, wywołanie `zainstaluj()` w `BppConfig.ready()` i `test_easyaudit_shim.py`. Przypomni o tym test `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`, który wtedy zacznie padać | | **`bpp-deploy`** | kontrolka „kronika views: N” po `bpp.0499` wypisze 0 i może zmylić operatora | | Pomiar `0492` i narzutu GiST | wciąż nikt nie zmierzył (dług fazy 01) | +| **Rejestr wskrzeszeń niewidoczny w adminie** | `pbn_integrator/admin.py` to nadal `# Register your models here.`. Brak też indeksu `(content_type, object_id)` pod pytanie „czy TEN rekord wrócił?". Rejestr, do którego nie da się zajrzeć, nie spełnia swojej roli — a to ona uzasadniała zmianę decyzji #14 | +| **`force=True` omija wskrzeszenie** | Guard to `if not force`, więc `--force` idzie prosto do tworzenia nowego rekordu — i przy rekordzie w koszu wywali się na unikalności `pbn_uid` (tak samo jak przy rekordzie żywym; to nie jest regresja fazy 03). Niespójność: `znajdz_ksiazke_nadrzedna` leży ZA tym guardem, więc przy `force` książka-matka JEST wskrzeszana, a sam rozdział nie | +| **`Oswiadczenie_Instytucji.get_bpp_publication`** | `pbn_api/models/oswiadczenie_instytucji.py:51-74` iteruje po 4 modelach po `pbn_uid` przez `.objects`. Nie jest ani na liście zmienionych, ani „świadomie zostawionych" — po prostu nikt go nie rozpatrzył | +| **`hard_delete()` na querysecie nie emituje `post_hard_delete`** | Najbardziej destrukcyjna operacja przejdzie niezauważona przez `SoftDeleteLog` z fazy 06. Do handoffu fazy 06 | | **Strategia wydania** | ⚠️ **ZMIENIONA 2026-08-08: bramka przesunięta z fazy 04 na fazę 07.** Uzasadnienie w sekcji 4b | ## 7. Proces — co znowu się sprawdziło +- **Mutacja, która PRZESZŁA, to informacja — nie powód, żeby dopisać asercję.** + Przy pozycji 3.3 dodałem gałąź odpinającą `transaction_id` dla autorstwa + już siedzącego w koszu. Mutacja (`pass`) przeszła. Zamiast wzmacniać test + sprawdziłem DLACZEGO — i okazało się, że `autor_duplikat.delete()` kaskaduje + twardo i fizycznie usuwa ten wiersz. Cała gałąź była martwym kodem opartym + na moim wyobrażeniu, nie na tym, co robi baza. Reguła: mutacja, która + przeszła, znaczy **albo słaby test, albo zbędny kod** — rozstrzygnij który, + ZANIM cokolwiek dopiszesz. (Konsekwencja dla fazy 04: §5, ostatni punkt.) - **Mutacja jest jedynym dowodem, że test coś pilnuje.** W fazie 03 pierwsza czerwień testu rejestru była `ImportError` — a to nie dowodzi, że asercje działają. Dopiero wyłączenie warunku na `deleted_at` pokazało, że tak. +- **Dane referencyjne z `baseline.sql` NIE są w testach gwarantowane.** Testy + transakcyjne z tego samego przebiegu flushują bazę i przywracają tylko + część danych — a przy `PYTEST_TESTCONTAINERS_REUSE=1` zanieczyszczenie + przechodzi między przebiegami. Objaw: `Typ_Odpowiedzialnosci / + Rodzaj_Zrodla / Jezyk DoesNotExist` w teście, który „przecież przechodził". + Bierz słowniki z fixture'ów (`jezyki`, `charaktery_formalne`, `typy_kbn`, + `statusy_korekt`, `typy_odpowiedzialnosci`), nie z baseline. Zanim uznasz + taką porażkę za regresję — powtórz na świeżych kontenerach (bez `REUSE`). +- **`git checkout ` cofa do HEAD, a nie do „stanu sprzed mutacji".** + Przy mutacyjnym testowaniu NIEZACOMMITOWANEJ zmiany to kasuje całą + implementację. Rób kopię pliku i przywracaj z niej. +- **Django ma DWA mechanizmy walidacji unikalności o różnej polityce + milczenia.** `validate_unique()` (dla `unique=True`/`unique_together`) + zgłasza błąd; `validate_constraints()` (dla `Meta.constraints`) **cicho + pomija** ograniczenie, którego pole warunku jest wykluczone z walidacji — + a `_get_validation_exclusions()` wyklucza wszystko spoza `Meta.fields`, + które admin nadpisuje spłaszczonymi `fieldsets`. Zamiana `unique=True` na + warunkowy `UniqueConstraint` po cichu zabiera walidację w formularzu. - **`ruff format` na całym katalogu znowu zagarnął cudzy plik.** Formatuj tylko własne; cofnięcie kosztowało osobny commit. - **`make tests-without-playwright` zwraca EXIT 0 mimo porażek** (sprawdzone From ff598fc62e3361dca27d0aeddcaac26a73900c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 8 Aug 2026 23:06:21 +0200 Subject: [PATCH 73/75] docs(soft-delete): CI nie biegnie na PR-ach do feat/soft-delete + wyniki suity Workflow `Tests` ma `pull_request: branches: [dev]`, wiec PR #742 (`feat/soft-delete-03` -> `feat/soft-delete`) dostaje wylacznie GitGuardiana. Zielony check na takim PR-ze NIE jest dowodem, ze testy przeszly -- dotyczy to tak samo faz 04-07, dopoki sa stackowane na `feat/soft-delete`. Dopisany wynik lokalnego `make tests` dla fazy 03: 9522 + 157 + 81 passed, 0 failed. Refs #742 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- docs/superpowers/HANDOFF-soft-delete-faza-04.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-04.md b/docs/superpowers/HANDOFF-soft-delete-faza-04.md index 868edc73b..d7f3441bb 100644 --- a/docs/superpowers/HANDOFF-soft-delete-faza-04.md +++ b/docs/superpowers/HANDOFF-soft-delete-faza-04.md @@ -24,6 +24,17 @@ niż brzmiała pierwotna obietnica „re-import nie tworzy duplikatów" — patr Zgodnie z CLAUDE.md odświeżenie (`make baseline-update`) robi się **raz, przy scalaniu**, a nie w równoległych feature-branchach. +⚠️ **CI NIE URUCHAMIA SIĘ na PR-ach do `feat/soft-delete`.** Workflow `Tests` +ma `pull_request: branches: [dev]`, więc PR #742 (`feat/soft-delete-03` → +`feat/soft-delete`) dostaje wyłącznie GitGuardiana. Zielony check na takim +PR-ze **nie jest dowodem, że testy przeszły** — jedyną weryfikacją jest +przebieg lokalny. To samo będzie dotyczyć PR-ów faz 04–07, dopóki są stackowane +na `feat/soft-delete`. Odpalaj `make tests` lokalnie i czytaj podsumowanie +pytest (target zwraca EXIT 0 mimo porażek i przerywa się na pierwszym błędnym +kroku, więc `tests-only-playwright` i `js-tests` trzeba dokończyć osobno). + +Stan fazy 03 na `ac809b958`: 9522 + 157 + 81 passed, 0 failed. + --- ## 2. NAJWAŻNIEJSZE: zmieniona decyzja o polityce kosza From a0bba1d91f1e75999c97b2b824c6120dada61520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 9 Aug 2026 10:31:22 +0200 Subject: [PATCH 74/75] feat(soft-delete): rejestr wskrzeszen w adminie + rozstrzygniecie lookupu (#9, #13) #9 -- rejestr osiagalny wylacznie przez `manage.py shell` nie spelnial swojej roli. To jego DOSTEPNOSC dla operatora byla calym uzasadnieniem zmiany decyzji #14 planu ("nie robmy tego" -> "robmy, ale zostaw slad"). Slad, do ktorego nie da sie zajrzec, sladem nie jest. - `RekordPrzywroconyPrzezImportAdmin`, TYLKO DO ODCZYTU: trzy `has_*_permission` zwracaja False, bo rejestr, w ktorym operator moze dopisac albo skasowac wpis, przestaje byc dowodem. Samo `readonly_fields` by nie wystarczylo -- chowa pola, ale wciaz pozwala dodac i skasowac wiersz. - Indeks `pbnint_przywr_ct_objid_idx` na `(content_type, object_id)` (migracja `pbn_integrator/0002`). Istniejacy `(przywrocono DESC, content_type)` obsluguje "co wrocilo ostatnio", ale NIE "czy TEN rekord wrocil?" -- a to jedyne pytanie zadawane wprost o konkretna publikacje. #13 -- `Oswiadczenie_Instytucji.get_bpp_publication` (INNY model niz `Publication.get_bpp_publication`, ta sama nazwa metody) nie byl przy audycie rozstrzygniety w ogole. Rozstrzygniecie: ZOSTAJE na `objects`, bo to akcesor, a zagladanie do kosza jest decyzja importera. Nie jest to martwy przepis -- lancuch wolaczy realizuje wariant A w calosci: akcesor zwraca None -> `statements.py:404` wola `importuj_publikacje_instytucji` -> `znajdz_lub_wskrzes_rekord` wskrzesza i wpisuje do rejestru. Wolacz przyjmuje oba ksztalty wyniku (`Rekord` przez `.original`, model konkretny wprost). Gdyby akcesor sam zagladal do kosza, importer nigdy by sie nie odpalil. Testy: 5 nowych (4 rejestr, 1 rejestr decyzji). Test rejestru decyzji przechodzi od razu -- to przypiecie istniejacego zachowania -- wiec zweryfikowany mutacyjnie (`objects` -> `global_objects`). Zlapana wlasna slaba asercja: `"articles" in tresc` na changeliscie bylo falszywie zielone, bo ten lancuch pojawia sie tez w bocznym `list_filter`. Przechodzilo nawet po wyrzuceniu kolumny z `list_display`. Asercje ida teraz po `class="field-"`, czyli po komorkach tabeli. Refs #742 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- ...elete-rejestr-wskrzeszen-admin.feature.rst | 3 + .../test_audyt_kategorii_b.py | 46 +++++++ src/pbn_integrator/admin.py | 51 +++++++- .../0002_indeks_content_type_object_id.py | 20 ++++ src/pbn_integrator/models.py | 9 ++ .../tests/test_rejestr_wskrzeszen_admin.py | 112 ++++++++++++++++++ 6 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 src/bpp/newsfragments/soft-delete-rejestr-wskrzeszen-admin.feature.rst create mode 100644 src/pbn_integrator/migrations/0002_indeks_content_type_object_id.py create mode 100644 src/pbn_integrator/tests/test_rejestr_wskrzeszen_admin.py diff --git a/src/bpp/newsfragments/soft-delete-rejestr-wskrzeszen-admin.feature.rst b/src/bpp/newsfragments/soft-delete-rejestr-wskrzeszen-admin.feature.rst new file mode 100644 index 000000000..6e5493b58 --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-rejestr-wskrzeszen-admin.feature.rst @@ -0,0 +1,3 @@ +Rejestr „Rekordy przywrócone przez import" jest widoczny w panelu +administracyjnym (tylko do odczytu): można sprawdzić, która publikacja wróciła +z kosza przy imporcie z PBN, kiedy i z której ścieżki importu. diff --git a/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py index 1e4509761..4bae45bb9 100644 --- a/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py +++ b/src/bpp/tests/test_soft_delete/test_audyt_kategorii_b.py @@ -55,6 +55,48 @@ def test_get_bpp_publication_NIE_widzi_soft_deletowanego(): ) +@pytest.mark.django_db +def test_oswiadczenie_instytucji_get_bpp_publication_NIE_widzi_kosza(): + """Ta sama nazwa metody, INNY model — i też zostaje na `objects`. + + ⚠️ Łatwo pomylić z `Publication.get_bpp_publication` wyżej. + `Oswiadczenie_Instytucji.get_bpp_publication` iteruje po czterech modelach + publikacji po `pbn_uid`. Przy pierwszym audycie nie została rozstrzygnięta + w ogóle — ani zmieniona, ani wpisana jako „zostawiona świadomie". + + ROZSTRZYGNIĘCIE (2026-08-09): zostaje na `objects`, bo to **akcesor**, + a zaglądanie do kosza jest decyzją importera. Nie jest to jednak martwy + przepis — łańcuch wołaczy realizuje wariant A w całości: + + `integruj_oswiadczenia...` (statements.py:404) + → get_bpp_publication() zwraca None (rekord w koszu = "nie ma") + → importuj_publikacje_instytucji(...) + → importuj_publikacje_po_pbn_uid_id(...) + → znajdz_lub_wskrzes_rekord() ← TU rekord wraca z kosza + + Wołacz przyjmuje wynik obu kształtów: `Rekord` rozpakowuje przez + `.original`, model konkretny (taki wraca ze wskrzeszenia) przepuszcza. + Gdyby akcesor sam zaglądał do kosza, importer nigdy by się nie odpalił — + i nie powstałby wpis w `RekordPrzywroconyPrzezImport`. + """ + from pbn_api.models import OswiadczenieInstytucji, Publication + + publication = baker.make(Publication) + rec = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + oswiadczenie = baker.make(OswiadczenieInstytucji, publicationId=publication) + + assert oswiadczenie.get_bpp_publication() is not None, ( + "setup zepsuty — zywy rekord powinien byc znaleziony" + ) + + rec.delete() # soft-delete + + assert oswiadczenie.get_bpp_publication() is None, ( + "akcesor oswiadczenia widzi kosz — importer nigdy nie dostanie szansy " + "wskrzesic rekordu i wpisac tego do rejestru" + ) + + # --------------------------------------------------------------------------- # REJESTR DECYZJI AUDYTU (Task 7) — miejsca ZOSTAWIONE na `objects` # --------------------------------------------------------------------------- @@ -73,6 +115,10 @@ def test_get_bpp_publication_NIE_widzi_soft_deletowanego(): # `objects` daje obraz spójny z PBN; # * REST API (`api_v1/viewsets/*`) — publiczne API nie może serwować autorstw # pracy, która sama z API zniknęła; +# * `Oswiadczenie_Instytucji.get_bpp_publication` +# (`pbn_api/models/oswiadczenie_instytucji.py`) — akcesor, nie importer; +# wskrzeszanie robi wołacz o poziom wyżej. Dopisane 2026-08-09, bo przy +# pierwszym audycie NIE zostało rozstrzygnięte w ogóle. Patrz test wyżej; # * skanowanie do dedupu publikacji (`deduplikator_publikacji/tasks.py`) — # patrz test niżej. # diff --git a/src/pbn_integrator/admin.py b/src/pbn_integrator/admin.py index 846f6b406..29bf8e63e 100644 --- a/src/pbn_integrator/admin.py +++ b/src/pbn_integrator/admin.py @@ -1 +1,50 @@ -# Register your models here. +"""Admin `pbn_integrator` — na razie wyłącznie rejestr wskrzeszeń. + +Rejestr osiągalny tylko przez ``manage.py shell`` nie spełniałby swojej roli: +to jego dostępność dla operatora była całym uzasadnieniem zmiany decyzji #14 +planu fazy 03 („POMIŃ + ZARAPORTUJ" → „PRZYWRÓĆ + ODNOTUJ"). Kontekst: +docstring ``RekordPrzywroconyPrzezImport``. +""" + +from django.contrib import admin + +from .models import RekordPrzywroconyPrzezImport + + +@admin.register(RekordPrzywroconyPrzezImport) +class RekordPrzywroconyPrzezImportAdmin(admin.ModelAdmin): + """Ślad audytowy — TYLKO DO ODCZYTU. + + Wpisy powstają wyłącznie w ``pbn_integrator/kosz.py``, na ścieżce importu. + Rejestr, w którym operator może dopisać, zmienić albo skasować wpis, + przestaje być dowodem — a dowodem miał być, bo to on równoważy ryzyko + auto-restore'u wskazane w pierwotnej decyzji #14. Stąd trzy `False` niżej, + a nie samo `readonly_fields` (to ostatnie chowa pola, ale wciąż pozwala + dodać i skasować wiersz). + """ + + list_display = ( + "rekord", + "przywrocono", + "zrodlo_importu", + "pbn_uid", + ) + list_filter = ("zrodlo_importu", "content_type", "przywrocono") + # `object_id` w wyszukiwarce, bo operator idzie tu zwykle od konkretnej + # publikacji („czy TEN rekord wrócił?") — pod to jest indeks + # `pbnint_przywr_ct_objid_idx`. + search_fields = ("object_id", "pbn_uid__pk") + date_hierarchy = "przywrocono" + # `rekord` to GenericForeignKey — nie da się go `select_related`, więc + # changelista i tak dobija po jednym zapytaniu na wiersz przy renderowaniu + # `__str__`. `content_type` jest zwykłym FK i tyle da się tanio uprzedzić. + list_select_related = ("content_type", "pbn_uid") + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/src/pbn_integrator/migrations/0002_indeks_content_type_object_id.py b/src/pbn_integrator/migrations/0002_indeks_content_type_object_id.py new file mode 100644 index 000000000..452acd234 --- /dev/null +++ b/src/pbn_integrator/migrations/0002_indeks_content_type_object_id.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.16 on 2026-08-09 08:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("pbn_api", "0079_constraint_publikacja_instytucji"), + ("pbn_integrator", "0001_initial"), + ] + + operations = [ + migrations.AddIndex( + model_name="rekordprzywroconyprzezimport", + index=models.Index( + fields=["content_type", "object_id"], name="pbnint_przywr_ct_objid_idx" + ), + ), + ] diff --git a/src/pbn_integrator/models.py b/src/pbn_integrator/models.py index 2fd1439f8..58821ad90 100644 --- a/src/pbn_integrator/models.py +++ b/src/pbn_integrator/models.py @@ -61,6 +61,15 @@ class Meta: fields=["-przywrocono", "content_type"], name="pbnint_przywr_data_ct_idx", ), + # Drugie pytanie, i jedyne zadawane WPROST o konkretną publikację: + # „czy TEN rekord wrócił?". Indeks po dacie go nie obsługuje, a sam + # `content_type_id` (indeks domyślny FK) zawęża do typu, nie do + # wiersza — przy jednym typie publikacji to praktycznie skan całej + # tabeli, która rośnie z każdym importem. + models.Index( + fields=["content_type", "object_id"], + name="pbnint_przywr_ct_objid_idx", + ), ] def __str__(self): diff --git a/src/pbn_integrator/tests/test_rejestr_wskrzeszen_admin.py b/src/pbn_integrator/tests/test_rejestr_wskrzeszen_admin.py new file mode 100644 index 000000000..90a697d70 --- /dev/null +++ b/src/pbn_integrator/tests/test_rejestr_wskrzeszen_admin.py @@ -0,0 +1,112 @@ +"""Rejestr wskrzeszeń musi być DOSTĘPNY, inaczej nie spełnia swojej roli. + +Zmiana decyzji #14 planu fazy 03 („POMIŃ + ZARAPORTUJ" → „PRZYWRÓĆ + ODNOTUJ") +została uzasadniona tym, że ryzyko nie znika, tylko zostaje przeniesione: +z „nie róbmy tego" na „róbmy, ale zostaw ślad". Całym uzasadnieniem jest więc +ŚLAD — operator, który znajdzie w bazie rekord skasowany przez siebie tydzień +temu, ma gdzie sprawdzić, że wrócił z importu, kiedy i z którego źródła. + +Rejestr osiągalny wyłącznie przez ``manage.py shell`` tego nie spełnia. + +Dwie rzeczy, które testujemy: + +1. **Widoczność w adminie** — i to jako trwały ślad audytowy, którego nie da + się dopisać ani zmienić z UI. Rejestr, który operator może edytować, + przestaje być dowodem. +2. **Indeks pod pytanie „czy TEN rekord wrócił?"** — czyli po + ``(content_type, object_id)``. Sprawdzamy PRAWDZIWY indeks w ``pg_indexes``, + a nie deklarację w ``Meta``: interesuje nas, czy migracja go założyła. + Wzorzec z ``bpp/tests/test_soft_delete/test_autor_rekord_index.py``. +""" + +import pytest +from django.contrib import admin as django_admin +from django.db import connection +from django.urls import reverse +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle +from pbn_api.models import Publication +from pbn_integrator.models import RekordPrzywroconyPrzezImport + +TABELA = RekordPrzywroconyPrzezImport._meta.db_table + + +def _wpis_rejestru(): + from django.contrib.contenttypes.models import ContentType + + publication = baker.make(Publication) + wc = baker.make(Wydawnictwo_Ciagle, pbn_uid=publication) + return RekordPrzywroconyPrzezImport.objects.create( + content_type=ContentType.objects.get_for_model(Wydawnictwo_Ciagle), + object_id=wc.pk, + pbn_uid=publication, + zrodlo_importu="articles", + ) + + +@pytest.mark.django_db +def test_rejestr_ma_indeks_na_content_type_i_object_id(): + """„Czy TEN rekord wrócił?" nie może być seq scanem. + + To jedyne pytanie, jakie operator zada temu rejestrowi wprost — z poziomu + konkretnej publikacji. Bez indeksu na parze kolumn generic-FK odpowiedź + wymaga przejrzenia całej tabeli, która rośnie z każdym importem. + """ + with connection.cursor() as cur: + cur.execute("SELECT indexdef FROM pg_indexes WHERE tablename = %s", [TABELA]) + indeksy = [wiersz[0] for wiersz in cur.fetchall()] + + assert any("(content_type_id, object_id)" in indexdef for indexdef in indeksy), ( + f"brak indeksu na (content_type_id, object_id) w {TABELA}; są: {indeksy}" + ) + + +@pytest.mark.django_db +def test_rejestr_jest_zarejestrowany_w_adminie(): + assert RekordPrzywroconyPrzezImport in django_admin.site._registry, ( + "rejestr wskrzeszen nie jest widoczny w adminie — czyli slad, ktorym " + "uzasadniono zmiane decyzji #14, jest nieosiagalny dla operatora" + ) + + +@pytest.mark.django_db +def test_changelist_pokazuje_wpis_ze_zrodlem_importu(admin_client): + wpis = _wpis_rejestru() + + res = admin_client.get( + reverse("admin:pbn_integrator_rekordprzywroconyprzezimport_changelist") + ) + + assert res.status_code == 200 + tresc = res.content.decode() + + # Asercje po klasach `field-`, którymi Django oznacza KOMÓRKI + # tabeli. Samo „articles in tresc" byłoby fałszywie zielone: ten łańcuch + # pojawia się też w bocznym filtrze `list_filter`, więc przechodziłoby + # nawet po wyrzuceniu kolumny z `list_display` (sprawdzone mutacyjnie). + assert 'class="field-zrodlo_importu"' in tresc, ( + "changelista nie ma kolumny ze zrodlem importu" + ) + assert 'class="field-rekord"' in tresc, ( + "changelista nie pokazuje, KTORY rekord wrocil" + ) + assert str(wpis.rekord) in tresc + assert wpis.zrodlo_importu in tresc + + +@pytest.mark.django_db +def test_rejestru_nie_da_sie_dopisac_ani_zmienic_z_admina(admin_client): + """Ślad audytowy, który operator może edytować, nie jest dowodem. + + Wpisy powstają wyłącznie w kodzie importu (``pbn_integrator/kosz.py``). + """ + wpis = _wpis_rejestru() + model_admin = django_admin.site._registry[RekordPrzywroconyPrzezImport] + request = admin_client.get( + reverse("admin:pbn_integrator_rekordprzywroconyprzezimport_changelist") + ).wsgi_request + + assert not model_admin.has_add_permission(request) + assert not model_admin.has_change_permission(request, wpis) + assert not model_admin.has_delete_permission(request, wpis) From 39c813fcaf5966d0cb55907bfbffa8bdbfd8036a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 9 Aug 2026 10:59:08 +0200 Subject: [PATCH 75/75] docs(soft-delete): rozjazdy plan-vs-kod dla fazy 04 + prompt startowy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan fazy 04 powstal przed fazami 02 i 03 i cytuje stan, ktorego juz nie ma. Nowa sekcja §4c handoffu -- pieciopunktowa lista rozjazdow, kazdy sprawdzony w kodzie, nie zgadniety: - R1 (BLOKER): Zadanie 3 (miekki `Autor.delete()`) zepsuje scalanie autorow w przypadku "kolizja autorstw w koszu", bo znika twarda kaskada, na ktorej ten przypadek dzis stoi. Zadanie 6 tego NIE wykryje -- symuluje wylacznie czysty transfer. Miejsce poprawki i uzasadnienie opisane; pisac RAZEM z Zadaniem 3, nie po nim. - R2: `Praca_Habilitacyjna.autor` to juz nie `OneToOneField` (faza 03). Konkluzja planu ("nie ruszamy") zostaje sluszna, ale reverse i licznik relacji juz nie -- autor moze miec wiele habilitacji (zywa + kosz). - R3: Zadanie 5 wskazuje nieistniejacy override `delete()` w `wydawnictwo_zwarte.py`. Faza 02 umiescila go we WSPOLDZIELONYM mixinie, wiec guard na rozdzialy trafilby tam do wszystkich pieciu modeli publikacji. - R4: `AutorManager` NIE jest przepleciony filtrem `deleted_at`. Plan traktuje to jako przypis ("warunek wstepny"), a to osobna robota (MRO + FTS) w zakresie Zadania 3 -- inaczej husk autora bedzie widoczny wszedzie. - R5: wszystkie numery linii nieaktualne, z tabela poprawnych. Plus lista tego, co w planie NADAL jest prawdziwe -- zeby nastepna sesja nie zweryfikowala wszystkiego od zera. Nowy `PROMPT-start-fazy-04.md`: gotowy prompt do wklejenia w nowa sesje, z zalozeniem worktree, kolejnoscia czytania dokumentow i zasadami, ktore sprawdzily sie w fazie 03 (mutacje, kopia pliku zamiast `git checkout`, fixture zamiast baseline, brak CI na tym PR-ze). §1 zaktualizowany o scalenie fazy 03 (774f1a72d). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh --- .../HANDOFF-soft-delete-faza-04.md | 147 ++++++++++++++++-- docs/superpowers/PROMPT-start-fazy-04.md | 71 +++++++++ 2 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/PROMPT-start-fazy-04.md diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-04.md b/docs/superpowers/HANDOFF-soft-delete-faza-04.md index d7f3441bb..3d2c68fab 100644 --- a/docs/superpowers/HANDOFF-soft-delete-faza-04.md +++ b/docs/superpowers/HANDOFF-soft-delete-faza-04.md @@ -9,9 +9,11 @@ | | | |---|---| -| Gałąź | `feat/soft-delete-03`, worktree `~/Programowanie/bpp-soft-delete-03` | -| Baza | `feat/soft-delete` (zawiera fazy 01 i 02; PR #741 scalony) | -| Migracje fazy 03 | `pbn_integrator/0001` (rejestr wskrzeszeń), `bpp/0500` (warunkowy unique na habilitacji) | +| Stan fazy 03 | ✅ **SCALONA** 2026-08-09, merge commit `774f1a72d`, PR #742 MERGED | +| Punkt startowy fazy 04 | gałąź `feat/soft-delete` (zawiera fazy 01, 02, 03) | +| Gałąź do założenia | `feat/soft-delete-04`, worktree `~/Programowanie/bpp-soft-delete-04` | +| Migracje fazy 03 | `pbn_integrator/0001` (rejestr wskrzeszeń), `pbn_integrator/0002` (indeks), `bpp/0500` (warunkowy unique na habilitacji) | +| Plan fazy 04 | `docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md` — ⚠️ **czytaj razem z §4c**, plan jest starszy niż fazy 02–03 | Faza 03 domknęła **blocker wydania**: re-import z PBN publikacji, której rekord BPP siedzi w koszu, nie wywala się już `IntegrityError`-em na unikalności @@ -225,6 +227,129 @@ z przywracaniem przychodzi dopiero w fazie 07. To regresja UX, nie funkcja. w adminie. Oznaczałoby utrzymywanie dwóch semantyk kasowania równolegle i wyrzucenie tego kodu w fazie 07 — koszt bez odbiorcy. +## 4c. ⚠️ PLAN FAZY 04 vs KOD — rozjazdy sprawdzone 2026-08-09 + +Plan `docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md` +powstał **przed fazami 02 i 03**. Poniższe zweryfikowane w kodzie, nie +zgadywane. **Przeczytaj to PRZED Zadaniem 1** — plan cytuje stan, którego +już nie ma. + +### R1. BLOKER: Zadanie 3 zepsuje scalanie autorów, a Zadanie 6 tego nie złapie + +To jest najważniejsza pozycja na tej liście. + +Plan (Zadanie 6) zakłada, że po transferze prac husk duplikatu jest pusty, +więc guard go przepuści. **To nieprawda dla przypadku „kolizja w koszu"**, +i to nie hipotetycznie — ten scenariusz ma już test: +`deduplikator_autorow/tests/test_scal_autora_soft_delete.py:: +test_kolizja_autorstw_w_koszu_nie_tworzy_dwoch_wierszy`. + +Mechanizm: + +1. Publikacja jest w koszu → oba autorstwa (głównego i duplikatu) też. +2. `_transfer_authorship_record` wykrywa kolizję (faza 03, po `global_objects`) + i **NIE przenosi** wiersza duplikatu — zostawia go przy duplikacie. +3. Dziś to bezpieczne, bo `autor_duplikat.delete()` jest TWARDY i kaskaduje po + FK — wiersz znika fizycznie. +4. **Zadanie 3 zmienia `Autor.delete()` na miękki.** Kaskada znika. Wiersz + zostaje przy duplikacie. +5. Guard z Zadania 3 liczy przez `global_objects`, więc **widzi ten wiersz + w koszu** → `ProtectedError` → `scal_autora` łapie to jako `Exception` + i zwraca `success=False`. **Całe scalanie pada.** + +Zadanie 6 tego nie wykryje, bo symuluje wyłącznie czysty transfer (autorstwo +żywe, przeniesione ręcznie). Test regresji z Zadania 6 +(`uv run pytest src/deduplikator_autorow/`) **wykryje** — i wtedy trzeba +wiedzieć, że to nie przypadek, tylko przewidziana konsekwencja. + +**Poprawka należy do gałęzi `if existing:` w `_transfer_authorship_record`** +(`deduplikator_autorow/utils/merge.py`): wiersz duplikatu trzeba przepiąć na +głównego autora ORAZ nadać mu WŁASNY `transaction_id`. To drugie jest +konieczne, bo inaczej `publikacja.restore()` wskrzesi dwa wiersze +`(rekord, glowny, typ)` naraz i wywali się na +`wc_autor_uniq_rekord_autor_typ`. + +Ta poprawka **była już raz napisana i usunięta** w fazie 03 — mutacja +pokazała, że przy twardej kaskadzie jest martwym kodem. Faza 04 ją ożywia. +Kolejność: napisz ją RAZEM z Zadaniem 3, nie po nim, bo inaczej między +commitami zostaje zepsuty merge. + +### R2. `Praca_Habilitacyjna.autor` NIE jest już `OneToOneField` + +Plan, sekcja „Stan zweryfikowany w kodzie": „`OneToOneField(Autor, PROTECT)` — +**już PROTECT, nie ruszamy**; reverse `autor.praca_habilitacyjna`". + +Po fazie 03 (migracja `bpp/0500`) to `ForeignKey(Autor, PROTECT)` + warunkowy +`phab_uniq_autor_zywy`. `PROTECT` się zgadza, więc **konkluzja planu („nie +ruszamy") zostaje słuszna** — ale dwie rzeczy w niej nie: + +- reverse to `autor.praca_habilitacyjna_set` (manager), nie obiekt; +- w tabeli mapowania relacji plan pisze „`autor` (O2O)". Helper i tak liczy + przez `Model.global_objects.filter(autor=…)`, więc kod działa bez zmian — + ale autor może teraz mieć **wiele** habilitacji (żywą + dowolnie wiele + w koszu). Nie zakładaj, że licznik zwróci 0 albo 1. + +### R3. Zadanie 5 wskazuje nieistniejący plik docelowy + +Plan: „w istniejącym override `delete()` (z fazy 02) w +`src/bpp/models/wydawnictwo_zwarte.py` dodaj guard na samym początku". + +**W `wydawnictwo_zwarte.py` NIE MA override `delete()`.** Faza 02 umieściła go +w `BppPublikacjaSoftDeleteMixin.delete()` (`bpp/models/soft_delete.py:335`), +dzielonym przez WSZYSTKIE pięć modeli publikacji. + +Wstawienie tam guarda na rozdziały byłoby błędem: dla `Wydawnictwo_Ciagle` +czy `Patent` helper odpytałby +`Wydawnictwo_Zwarte.global_objects.filter(wydawnictwo_nadrzedne=)` — zapytanie międzytypowe, w najlepszym razie zawsze puste, w gorszym +wyjątek. + +Guard musi trafić do **własnego** `delete()` w `Wydawnictwo_Zwarte`, wołającego +`super().delete(*args, **kwargs)`. Sygnatura musi przepuścić `user`/`reason` +(kontrakt PINNED fazy 06/07). + +### R4. `AutorManager` NIE jest przepleciony — to nie jest „warunek wstępny", to zakres + +Zadanie 3 pisze: „To zadanie traktuje przeplecenie menedżera jako warunek +wstępny; jeśli go brak — najpierw dorób". **Brak.** `Autor` ma +`objects = AutorManager()` (`autor.py:298`, NIE `:200`) i własny `save()`, +a `AutorManager` dziedziczy `FulltextSearchMixin` + +`models.Manager.from_queryset(AutorQuerySet)` — zero filtrowania po +`deleted_at`. + +Czyli po dodaniu `SoftDeleteModel` do bazy klasy **husk autora będzie widoczny +wszędzie**: w autocomplete, w wyszukiwarce pełnotekstowej, na listach. +To osobna, nietrywialna robota (MRO + FTS), a nie jednolinijkowiec — wyceń ją +jako część Zadania 3, nie jako przypis. + +### R5. Wszystkie numery linii w planie są nieaktualne + +Ta sama pułapka, co w fazie 03. Szukaj po nazwach, nie po numerach. + +| Plan mówi | Jest naprawdę | +|---|---| +| `abstract/authors.py:22` | `:25` (`autor = models.ForeignKey("bpp.Autor", CASCADE)`) | +| `praca_doktorska.py:136` | `:154` | +| `wydawnictwo_zwarte.py:202` | `:286` | +| `autor.py:81` (`class Autor`) | `:172` | +| `autor.py:200` (`objects = AutorManager()`) | `:298` | +| `merge.py:191/223/265/317/335/410/430` | plik zrefaktoryzowany w fazie 03 — wszystkie nieaktualne | + +### Co w planie jest nadal PRAWDZIWE (sprawdzone) + +- `Wydawnictwo_*_Autor.autor` i `Praca_Doktorska.autor` to nadal `CASCADE` — + flip do `PROTECT` (Zadanie 2) jest do zrobienia. +- `Wydawnictwo_Zwarte.wydawnictwo_nadrzedne` to nadal `CASCADE` + + `related_name="wydawnictwa_powiazane_set"`. +- `Autor` nie ma własnego `delete()` — dziś kasuje się twardo, z kaskadą FK. +- `raise_if_has_protected_children` nie istnieje nigdzie w `src/`. +- Katalog `src/bpp/tests/test_models/` istnieje, `test_wydawnictwo_zwarte.py` + też — ścieżki testów z planu są dobre. +- Migracja state-only (`SeparateDatabaseAndState`) jest właściwa: `on_delete` + żyje wyłącznie w ORM, DDL nie jest potrzebne. + +--- + ## 5. Co czeka fazę 04 (guardy PROTECT) - **Nikt nie przeplata `AutorManager`** — po uczynieniu `Autor` @@ -243,19 +368,9 @@ i wyrzucenie tego kodu w fazie 07 — koszt bez odbiorcy. `autor.praca_habilitacyjna` jako po OBIEKT jest zepsuty. Dwa znane miejsca są już naprawione (`RokHabilitacjiView`, `browse/autor.html`) i oba miały testy, które to złapały. -- ⚠️ **`scal_autora` polega dziś na TWARDEJ kaskadzie `autor_duplikat.delete()`.** - Gdy autorstwo duplikatu koliduje z autorstwem głównego i OBA są w koszu, - wiersz duplikatu zostaje przy duplikacie — i znika dopiero dlatego, że - usunięcie autora kaskaduje po FK fizycznie. **Faza 04 zmienia kasowanie - autora na miękkie, więc ten wiersz przetrwa** i zostanie sierotą wskazującą - na autora w koszu; co gorsza będzie miał `transaction_id` publikacji, więc - `publikacja.restore()` wskrzesi DWA wiersze `(rekord, glowny, typ)` - i wywali się na `wc_autor_uniq_rekord_autor_typ`. - Miejsce: gałąź `if existing:` w `_transfer_authorship_record` - (`deduplikator_autorow/utils/merge.py`). Poprawka to prawdopodobnie - przepięcie wiersza na głównego autora + nadanie mu WŁASNEGO - `transaction_id` (odpięcie od grupy restore'u publikacji). Napisana raz - i usunięta, bo mutacja pokazała, że dziś jest martwym kodem — patrz §7. +- ⚠️ **`scal_autora` polega dziś na TWARDEJ kaskadzie `autor_duplikat.delete()` + — Zadanie 3 to zepsuje.** Pełny mechanizm i miejsce poprawki: **§4c, R1** + (to jest bloker, nie przypis). ## 6. Dług nadal otwarty diff --git a/docs/superpowers/PROMPT-start-fazy-04.md b/docs/superpowers/PROMPT-start-fazy-04.md new file mode 100644 index 000000000..b5b71e855 --- /dev/null +++ b/docs/superpowers/PROMPT-start-fazy-04.md @@ -0,0 +1,71 @@ +# Prompt startowy — faza 04 soft-delete (guardy PROTECT) + +Skopiuj treść między liniami do nowej sesji Claude Code, uruchomionej +w `~/Programowanie/bpp`. + +--- + +Zrób fazę 04 soft-delete w BPP (guardy PROTECT). + +**Worktree najpierw** — jeszcze nie istnieje, załóż go z `feat/soft-delete` +(tam wylądowały fazy 01–03, merge commit `774f1a72d`): + +```bash +git worktree add ~/Programowanie/bpp-soft-delete-04 -b feat/soft-delete-04 feat/soft-delete +``` + +potem `EnterWorktree path=~/Programowanie/bpp-soft-delete-04`. + +**Zacznij od dwóch dokumentów, w tej kolejności:** + +1. `docs/superpowers/HANDOFF-soft-delete-faza-04.md` — stan, decyzje + właściciela, dług. **Sekcja §4c jest obowiązkowa**: to lista rozjazdów + między planem a kodem, sprawdzona w kodzie 2026-08-09. +2. `docs/superpowers/plans/2026-06-04-soft-delete-04-guardy-protect.md` — + plan, 7 zadań. Powstał PRZED fazami 02 i 03, więc **czytaj go przez filtr + §4c handoffu**. Nie odtwarzaj stanu z gita. + +**Czego NIE rób:** nie ufaj numerom linii w planie (wszystkie nieaktualne, +tabela w §4c/R5); nie wstawiaj guarda na rozdziały do +`BppPublikacjaSoftDeleteMixin` (§4c/R3); nie zakładaj, że +`Praca_Habilitacyjna.autor` to `OneToOneField` (§4c/R2). + +**Bloker, o którym trzeba wiedzieć zanim napiszesz Zadanie 3:** miękkie +`Autor.delete()` psuje scalanie autorów w przypadku „kolizja autorstw +w koszu", a Zadanie 6 planu tego nie wykryje. Mechanizm i miejsce poprawki: +§4c/R1. Poprawkę pisz RAZEM z Zadaniem 3, nie po nim. + +**Zasady (te same, które sprawdziły się w fazie 03):** + +- TDD: każda zmiana najpierw czerwonym testem, potem implementacja. + Czerwień z `ImportError` nie liczy się jako dowód — **zweryfikuj mutacyjnie**, + że asercje pilnują tego, co mają. Mutacja, która PRZESZŁA, znaczy albo słaby + test, albo zbędny kod — rozstrzygnij który, zanim cokolwiek dopiszesz. +- Do mutacji rób **kopię pliku**, nie `git checkout` (cofa do HEAD i kasuje + niezacommitowaną implementację). +- `ruff format` i `ruff check` tylko na własnych plikach. +- Testy uruchamiaj lokalnie. Czytaj podsumowanie pytest, **nie kod wyjścia + `make`** (zwraca 0 mimo porażek). `make tests` przerywa się na pierwszym + błędnym kroku — dokończ `tests-only-playwright` i `js-tests` osobno. +- Słowniki w testach bierz z fixture'ów (`jezyki`, `charaktery_formalne`, + `typy_kbn`, `statusy_korekt`, `typy_odpowiedzialnosci`), nie z baseline — + testy transakcyjne potrafią wyczyścić dane referencyjne. Zanim uznasz + porażkę za regresję, powtórz na świeżych kontenerach (bez + `PYTEST_TESTCONTAINERS_REUSE=1`). +- Przy zmianie typu pola albo typu zwracanego: sprawdź nie tylko KTO to woła, + ale CO robi z wynikiem. Szablony Django połykają brak atrybutu bez wyjątku. +- **CI nie biegnie na PR-ach do `feat/soft-delete`** (workflow `Tests` ma + `pull_request: branches: [dev]`). Zielony check na takim PR-ze nie jest + dowodem — jedyną weryfikacją jest przebieg lokalny. +- Newsfragment (`src/bpp/newsfragments/.{feature,bugfix}.rst`) do każdej + zmiany widocznej dla użytkownika. +- **Nie odświeżaj baseline** (`make baseline-update`) — stoi na `bpp/0487` + i tak ma zostać do czasu scalania całego `feat/soft-delete` do `dev`. + +**Na koniec:** pełne `make tests` (wszystkie trzy kroki), `pre-commit` na +plikach fazy, PR do `feat/soft-delete` z opisem mówiącym też, czego faza NIE +domyka, i handoff dla fazy 05. + +Zacznij od Zadania 1 z planu — ale dopiero po przeczytaniu §4c. + +---