Skip to content

feat(soft-delete): faza 03 — audyt kategorii B (import i scalanie a kosz) - #742

Merged
mpasternak merged 20 commits into
feat/soft-deletefrom
feat/soft-delete-03
Aug 9, 2026
Merged

feat(soft-delete): faza 03 — audyt kategorii B (import i scalanie a kosz)#742
mpasternak merged 20 commits into
feat/soft-deletefrom
feat/soft-delete-03

Conversation

@mpasternak

@mpasternak mpasternak commented Aug 8, 2026

Copy link
Copy Markdown
Member

Faza 03 soft-delete: audyt kategorii B — miejsca, które MUSZĄ widzieć rekordy w koszu.

Stackowany na feat/soft-delete (zawiera fazy 01 i 02, PR #741 scalony).

⚠️ Opis przepisany 2026-08-08 po self-review. Poprzednia wersja opisywała
implementację, która została cofnięta (eb046897a), i przeceniała zakres.
Historia zmiany kierunku jest niżej, w sekcji „Dwie zmiany kierunku".

Co to naprawdę domyka

Re-import z PBN publikacji, której rekord BPP siedzi w koszu, wywalał się
IntegrityError-em:

bpp_wydawnictwo_ciagle_pbn_uid_id_72976b86_uniq
DETAIL:  Klucz (pbn_uid_id)=(...) już istnieje.

Kosz jest bowiem jednocześnie:

  • niewidoczny dla matchinguRekord to widok odfiltrowany po deleted_at,
    więc rekord_w_bpp zwraca None;
  • widoczny dla bazypbn_uid to OneToOneField(unique=True) bez
    warunku partial, więc rekord w koszu nadal to pole trzyma.

Import wchodził w gałąź „utwórz nowy" i wywracał cały przebieg. Teraz rekord
wraca z kosza, a fakt ląduje w rejestrze.

❗ Czego ten PR NIE naprawia (świadomie)

Poprzedni opis twierdził, że „blocker duplikatów" jest domknięty. Nie jest
i po zmianie kierunku (niżej) nie ma być.

  • Ścieżka fuzzy nadal 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. Import utworzy nowy rekord.
    Po decyzji właściciela to zachowanie zamierzone: soft-delete znaczy, że
    rekordu nie ma.
  • Domknięta jest wyłącznie ścieżka po pbn_uid — i tam problemem nie był
    cichy duplikat, tylko wywalenie importu.

Dwie zmiany kierunku — do świadomej akceptacji

1. Decyzja #14 planu: „POMIŃ + ZARAPORTUJ" → „PRZYWRÓĆ + ODNOTUJ".
Właściciel: PBN jest źródłem prawdy dla tych publikacji, więc 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.
Wpis powstaje wyłącznie przy realnym wskrzeszeniu — zwykły re-import żywego
rekordu nie zostawia nic, inaczej rejestr zapełniłby się szumem (przypięte
osobnym testem).

2. Akcesory NIE zaglądają do kosza (cofnięcie pierwszej implementacji).
Pierwsza wersja fazy 03 kazała rekord_w_bpp / get_bpp_publication /
matchuj_publikacje zwracać rekordy skasowane. Właściciel rozstrzygnął:

Soft-delete znaczy, że rekordu NIE MA. Akcesor zwraca Rekord albo None
nigdy rzeczy z kosza. Zaglądanie do kosza jest decyzją IMPORTERA.

To nie była kosmetyka. PublicationAdmin woła na wyniku .original — atrybut
istniejący WYŁĄCZNIE na Rekord — więc zwracanie modelu konkretnego wywalało
AttributeError na całej changeliście, przy zerowym pokryciu testami. To
samo pominięcie w szablonie (change_form.html) nie dawało wyjątku, tylko pusty
href. Dołożone src/pbn_api/tests/test_admin_rekord_w_bpp.py.

Drugi skutek uboczny: matchuj_publikacje ma dwóch wołaczy o sprzecznych
potrzebach — pbn_api i deduplikator_publikacji. Przełączenie go „na globalny
menedżer" sprawiło, że dedup zaczął podsuwać rekordy z kosza, wbrew decyzji
z Taska 8.

Zmiany w kodzie

Miejsce Zmiana
pbn_integrator/kosz.py całe zaglądanie do kosza w jednym module: przywroc_jesli_w_koszu(), wskrzes_z_kosza_po_pbn_uid(), znajdz_lub_wskrzes_rekord()
pbn_integrator/importer/{articles,books,chapters}.py wspólna preambuła w gałęzi ret is None (wcześniej wskrzeszanie siedziało w gałęzi is not None, gdzie było no-opem)
pbn_integrator/importer/chapters.py znajdz_ksiazke_nadrzedna() — wydzielony i testowalny
pbn_import/utils/publication_import.py global_objects + hard_delete() przy czyszczeniu przed re-importem
bpp/models/praca_habilitacyjna.py autor: OneToOneFieldForeignKey + 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, bpp/templates/browse/autor.html konsumenci akcesora odwrotnego po zmianie typu pola
pbn_integrator/admin.py, models.py rejestr wskrzeszeń widoczny w adminie (tylko do odczytu) + indeks (content_type, object_id) (migracja pbn_integrator/0002)
deduplikator_autorow/utils/merge.py transfer widzi kosz; detekcja kolizji po global_objects; KonfliktScalania; guard _w_koszu() przed kolejką PBN

Na co zwrócić uwagę w recenzji

1. Zmiana typu pola Praca_Habilitacyjna.autor na ForeignKey (BLOKER 3
z self-review). Twarde UNIQUE (autor_id) nie zna kosza, więc scalanie autorów
— które przenosi wiersze RAZEM Z KOSZEM, żeby nie zostawiać sierot — padało,
gdy duplikat miał habilitację w koszu, a główny żywą. Django wymusza
unique=True w OneToOneField.__init__, więc warunkowy unique wymaga zmiany
typu. Konsekwencje sprawdzone po kolei:

  • akcesor odwrotny → autor.praca_habilitacyjna_set (manager, nie obiekt);
  • ścieżka filtrowania w ORM/DjangoQL bez zmian (related_query_name
    domyślnie = nazwa modelu, tak samo jak dla praca_doktorska, który od zawsze
    jest FK);
  • dwa miejsca konsumujące akcesor jako obiekt — oba miały już testy w repo
    i oba zaświeciły się na czerwono;
  • przy okazji naprawione: odwrotne OneToOne szło przez _base_manager
    i pokazywało rekordy z kosza; manager relacji FK już nie.

2. Walidacja unikalności w adminie zmienia mechanizm. validate_unique()
zgłasza błąd; validate_constraints() (dla Meta.constraints) cicho pomija
ograniczenie, którego pole warunku (deleted_at) jest wykluczone z walidacji —
a _get_validation_exclusions() wyklucza wszystko spoza Meta.fields, które
admin nadpisuje spłaszczonymi fieldsets. Sztuczka z ukrytym polem
(bpp/admin/core.py) działa dla inline'ów; tutaj wyprodukowałaby widoczny pusty
wiersz „Deleted at", więc jest jawne clean_autor(). Constraint zostaje
ostateczną gwarancją w bazie.

3. Kosz przestał być drzwiami jednokierunkowymi. Detekcja kolizji autorstw
szła po menedżerze żywych, więc gdy publikacja była w koszu, kolizji nie było
widać: w koszu lądowały DWA wiersze (rekord, glowny, typ), a
publikacja.restore() wywalał się na wc_autor_uniq_rekord_autor_typ. Test
kończy się jawnym restore() jako dowodem.

4. Patent nie ma pola pbn_uid — sprawdzone na modelach. Dlatego
wskrzes_z_kosza_po_pbn_uid() przyjmuje model jako parametr, zamiast
iterować po wszystkich modelach publikacji (.filter(pbn_uid_id=…) na Patent
wywala FieldError, bo Django resolwuje nazwy pól natychmiast).

5. Zachowana historyczna różnica między get_bpp_publication a
rekord_w_bpp: przy wielu trafieniach pierwsza zwraca None, druga sklejone
tytuły; przy braku trafień druga spada do fuzzy matchingu. Nie ujednolicane przy
okazji — i dlatego helpery na ścieżce importu muszą przyjmować wejście
nie-modelowe (STRING/None) bez wyjątku.

Weryfikacja

Pełne make tests lokalnie, wszystkie trzy kroki (target przerywa się na
pierwszym błędnym, więc każdy odpalony i przeczytany osobno):

Krok Wynik
tests-without-playwright 9522 passed, 0 failed, 0 errors, 4 skipped, 2 xfailed (4:55)
tests-only-playwright 157 passed, 1 skipped (2:04)
js-tests (vitest) 81 passed, 8 plików
  • Nowe testy fazy: 16 (5 wskrzeszanie z importu, 8 scalanie a kosz,
    3 walidacja admina habilitacji), wszystkie pisane czerwień-najpierw
    i zweryfikowane mutacyjnie — łącznie 11 mutacji, każda wywróciła
    dokładnie ten test, który miała wywrócić
  • makemigrations --check czysty dla bpp, pbn_integrator, pbn_api
  • pre-commit zielony na 16 plikach fazy (w tym djLint i hook Django {#)

⚠️ CI NIE URUCHAMIA SIĘ NA TYM PR-ze. Workflow Tests ma
pull_request: branches: [dev], a ten PR celuje w feat/soft-delete.
Jedyny zielony check to GitGuardian — to nie jest dowód, że testy
przeszły
. Powyższe liczby pochodzą z przebiegu lokalnego. Testy na CI
odpalą się dopiero dla PR-a feat/soft-deletedev.

⚠️ make tests-without-playwright zwraca EXIT 0 mimo porażek. Czytać
podsumowanie pytest, nie kod wyjścia.

⚠️ Baseline (baseline-sql/) jest nieświeży — stoi na bpp/0487, gałąź na
bpp/0500. Był taki już przed tym PR-em (fazy 01–02 dołożyły 04880499).
Zgodnie z CLAUDE.md odświeżenie robi się raz, przy scalaniu, a nie
w równoległych feature-branchach.

Dług przekazany dalej

docs/superpowers/HANDOFF-soft-delete-faza-04.md. Najważniejsze:

  • ⚠️ scal_autora polega dziś na TWARDEJ kaskadzie autor_duplikat.delete().
    Gdy oba kolidujące autorstwa są w koszu, wiersz duplikatu znika tylko dlatego,
    że usunięcie autora kaskaduje po FK fizycznie. Faza 04 zmienia kasowanie
    autora na miękkie
    — wtedy ten wiersz przetrwa jako sierota i zepsuje
    restore(). Opisane z proponowaną poprawką.
  • force=True omija wskrzeszenie, ale znajdz_ksiazke_nadrzedna leży ZA tym
    guardem → przy force książka-matka jest wskrzeszana, a rozdział nie.
  • hard_delete() na querysecie nie emituje post_hard_delete (dla fazy 06).
  • Kanarek ORM w xfail(strict=True); PR upstream
    soynatan/django-easy-audit#348.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh

mpasternak and others added 10 commits August 7, 2026 23:55
…(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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
…sk 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
…zji #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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
… 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
… 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
@mpasternak

Copy link
Copy Markdown
Member Author

Co dalej

Zostało pięć faz. 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; Autor staje się SoftDeleteModel
05 — wycofanie z PBN pbn_export_queue dostaje operację WYCOFANIE; soft-delete publikacji asynchronicznie wycofuje oświadczenia dyscyplin z profilu instytucji kolejka PBN
06 — SoftDeleteLog audyt kto/kiedy/dlaczego/status PBN, zasilany receiverami sygnałów; receiver DELETE kolejkuje wycofanie z 05 05
07 — admin kosz dla 5 typów publikacji + Autor: „Usuń" = soft-delete z powodem, filtr „Pokaż skasowane", „Przywróć", osobna „Usuń trwale" 06
08 — regresja E2E suita domykająca całość wszystkie

Co ten PR (i poprzednie) już pod nie podłożył

Trzy rzeczy są gotowe — kolejne fazy mają je skonsumować, nie projektować od nowa:

  • Sygnatury delete(user=..., reason=...) / restore(user=...) istnieją od fazy 02 (puste, ale obecne — kontrakt PINNED). Faza 06 wpina w nie SoftDeleteLog, faza 07 wstrzykuje request.user.
  • Sygnały post_soft_delete / post_restore są emitowane i przypięte testem. 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.

⚠️ Rzecz, która nie należy do żadnej z faz 04–08

Nagrobki na zewnątrz są nadal NIEZROBIONE, mimo że handoff fazy 02 (§3.1) traktował je jako bramkę wydania: OAI-PMH <header status="deleted"> w src/cerif_export, odpowiednik w CERIF, sposób odkrycia usuniętych w /api/v1/. Fundament jest gotowy (bump ostatnio_zmieniony), brakuje samej ekspozycji. Do zaplanowania jako osobny PR — bez świadomej decyzji przepadnie między fazami.

Otwarte, niezależnie od kolejki faz

  1. PR upstream do django-easy-audit — gałąź fix/175-use-base-manager-in-pre-save gotowa w lokalnym klonie, przetestowana na Django 5.2 i 6.1 (57 passed na obu), niewypchnięta. Czeka na decyzję o koncie/forku. Tekst zgłoszenia do ich issue ci(dependabot): dodaj cooldown 3d/7d dla wszystkich ekosystemów #175 przygotowany w docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md.
  2. Kanarek ORM w xfail(strict=True) — tabelka 10 wycieków w dokumencie inwentaryzacji nie jest listą zadań (patrz self-review w tym samym pliku). Kanarek dopasowuje nazwy, nie modele; po rozszerzeniu listy daje 120 znalezisk, w większości fałszywych. Potrzebne narzędzie model-aware pytające _meta.
  3. Strategia wydania — rekomendacja z fazy 01 bez zmian: scalać fazami, wydać dopiero po 04. Fazy 01–03 same nie dają użytkownikowi widocznej wartości (kosz bez UI — admin to faza 07).

Pełny handoff: docs/superpowers/HANDOFF-soft-delete-faza-04.md (w tym PR-ze).

… 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
@mpasternak

Copy link
Copy Markdown
Member Author

Zaktualizowane decyzje (2026-08-08)

Dwie rzeczy z notatki „co dalej” zostały rozstrzygnięte i zapisane w repo (commit 5523b0e27), więc PR jest samowystarczalny.

1. Bramka wydania: faza 04 → faza 07

Rekomendacja faz 01–03 brzmiała „wydać po 04”. Zmieniona. Powodem nie jest bezpieczeństwo (guardy z 04 domykają je w porządku), tylko spójność dla operatora.

Sprawdzone na kodzie: 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.

Wydanie po fazie 04 dałoby więc operatorowi: „Usuń” przestaje znaczyć „zniknęło”, rekordu nie da się przywrócić ani obejrzeć bez programisty, i nie da się go usunąć naprawdę. Traci jedną zdolność, nie dostając żadnej w zamian — kosz 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 01+02 z tymczasowym „Usuń = twardo” w adminie — dwie semantyki kasowania równolegle i wyrzucenie tego kodu w fazie 07.

2. Nagrobki wciągnięte do fazy 05

OAI-PMH <header status="deleted">, CERIF, sposób odkrycia usuniętych w /api/v1/ — dołączone do zakresu fazy 05, która przemianowana na „propagacja usunięcia na zewnątrz”. To ten sam motyw co wycofanie oświadczeń z PBN: systemy zewnętrzne dowiadują się, że coś zniknęło. Trzymane osobno przepadłyby między fazami, bo żaden plan ich nie obejmował.

Fundament gotowy — soft-delete bumpuje ostatnio_zmieniony, więc lista nagrobków to deleted_objects.filter(ostatnio_zmieniony__gte=X). Brakuje wyłącznie ekspozycji. Termin: przed fazą 07, nie przed 04 — dopóki kasowanie jest rzadkie, luka jest teoretyczna; faza 07 czyni je rutynowym.

mpasternak and others added 7 commits August 8, 2026 17:54
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
…a 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
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 eb04689. 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
…one (3.1)

Zajrzenie do kosza siedzialo w galezi ``ret is not None``, gdzie po cofnieciu
akcesorow (commit eb04689) 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
…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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
- §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 eb04689) + 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
@mpasternak mpasternak changed the title feat(soft-delete): faza 03 — audyt kategorii B (re-import nie tworzy duplikatów) feat(soft-delete): faza 03 — audyt kategorii B (import i scalanie a kosz) Aug 8, 2026
mpasternak and others added 2 commits August 8, 2026 23:06
…iki 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
…kupu (#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-<nazwa>"`, czyli po komorkach tabeli.

Refs #742

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4vnLWzPinqUrj5GTjPRnh
@mpasternak
mpasternak merged commit 774f1a7 into feat/soft-delete Aug 9, 2026
1 check passed
@mpasternak
mpasternak deleted the feat/soft-delete-03 branch August 9, 2026 08:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant