Skip to content

soft-delete faza 05b — nagrobki dla konsumentów przyrostowych - #767

Open
mpasternak wants to merge 17 commits into
feat/soft-delete-05from
feat/soft-delete-05b
Open

soft-delete faza 05b — nagrobki dla konsumentów przyrostowych#767
mpasternak wants to merge 17 commits into
feat/soft-delete-05from
feat/soft-delete-05b

Conversation

@mpasternak

@mpasternak mpasternak commented Aug 16, 2026

Copy link
Copy Markdown
Member

Faza 05b stosu soft-delete. Baza: feat/soft-delete-05.

Konsument przyrostowy (harvester OAI-PMH, klient REST) dowiaduje się, że
rekord przestał być wystawiany — zamiast odkrywać to po cichej nieobecności.

Spec: docs/superpowers/specs/2026-08-15-soft-delete-nagrobki-design.md
Plan: docs/superpowers/plans/2026-08-16-soft-delete-05b-nagrobki.md

Cztery decyzje specu

D1 Nagrobek = dopełnienie ekspozycji, nie przynależności. Rekord, który przestał być eksportowany z JAKIEGOKOLWIEK powodu, dostaje nagrobek — nie tylko ten w koszu.
D2 Jeden strumień, jeden kursor. Nagrobki płyną razem z żywymi rekordami w tym samym porządku keyset.
D3 deletedRecord = "transient", nie persistent — nie gwarantujemy trwałości nagrobka (husk znika przy twardym kasowaniu albo czyszczeniu kosza w fazie 07).
D5 REST znaczy węziej niż OAI: /api/v1/usuniete/ wystawia wyłącznie kosz, bo REST nie składa obietnicy deletedRecord.

Rozszczepienie przynależność / ekspozycja

Dotychczasowy queryset() sklejał dwa różne pytania: „czy ten rekord jest
nasz?" (atrybucja tenanta) i „czy wolno go pokazać?" (reguły ekspozycji).
Nagrobek to różnica tych zbiorów, więc muszą zamieszkać w osobnych metodach:

  • ProviderEncji.przynaleznosc(uczelnia, model) — sama atrybucja,
  • ProviderEncji.nagrobki(...)przynaleznosc − queryset.

Dlaczego nie dopełnienie całej widoczności: widoczne_jednostki()
filtruje uczelnia=uczelnia wprost, więc naiwne „wszystko minus widoczne"
zamieniłoby każdą jednostkę drugiej uczelni w nagrobek pierwszej — wyciek
identyfikatorów w multi-hosted. Pilnuje tego
test_nagrobki_nie_wyciekaja_miedzy_uczelniami.

Dlaczego strona() paginuje nadzbiór

resumptionToken niesie jeden kursor (datestamp, pk) i zakłada jeden
porządek. Nagrobki puszczone osobnym przebiegiem po żywych rekordach
wymagałyby drugiego kursora i rozjechałyby okno from/until — czyli
zepsułyby dokładnie tę przyrostowość, którą ta faza naprawia.

Skutkiem jest zmiana kształtu zwrotki: strona() zwraca
[(obiekt, czy_nagrobek)]. Wszyscy wołający zostali dostosowani; testy poza
test_nagrobki.py używają helpera tests/pomocnicze.py::strona_zywych,
który odtwarza dawną semantykę, więc ich asercje o widoczności zostały
nietknięte.

Odchylenia od planu (i dlaczego)

  1. naleza_* używają global_objects po OBU stronach, nie tylko
    w podzapytaniu autorstwa jak w snippecie planu. Wydawnictwo_Ciagle.objects
    to menedżer soft-delete i ukrywa kosz — wersja z planu po cichu gubiłaby
    drogę „rekord w koszu". Udowodnione mutacyjnie: podmiana któregokolwiek
    managera na objects wywala odpowiednio przypadek kosz i
    odpiecie_autora w test_kazda_droga_znikniecia_daje_nagrobek.
  2. Walidacja XSD: plan zakładał istniejący test walidujący odpowiedź.
    Takiego nie było — testy serializerów walidują pojedyncze encje profilu
    CERIF, a status="deleted" to konstrukcja koperty OAI-PMH. Zvendorowany
    OAI-PMH.xsd + plik spinający oba namespace'y (<metadata> ma
    processContents="strict", więc sam schemat koperty nie wystarcza dla
    rekordów żywych). Też mutation-tested.
  3. Czerwona suita po Tasku 4 dotyczyła test_providery/serializery/widocznosc,
    a nie test_oai.py — ten ostatni chodzi po atrapach, więc kontraktu realnej
    klasy bazowej nie dotykał.
  4. Ostrzeżenie o eksport_cerif_osoby poszło do docs/, nie w help_text
    zmiana help_text wygenerowałaby migrację AlterField, a ta faza
    z założenia migracji nie dodaje.

Wyniki przebiegu lokalnego

CI nie biegnie na PR-ach do gałęzi feat/soft-delete*, więc to jedyny dowód.

uv run pytest -m "not playwright"   9611 passed, 4 skipped, 2 xfailed   (17:35)
uv run pytest -m playwright          157 passed, 1 skipped              (05:09)
npx vitest run                        81 passed
ruff check / format                   pliki tej fazy — czysto
makemigrations --check --dry-run      zero zgłoszeń dla src/
                                      (favicon/flexible_reports/siteblog — zastane)

Poza zakresem (świadomie, sekcja „Ograniczenia" specu)

  • Przepięcie autorstwa do innej uczelni bez śladu w koszu nadal znika po cichu.
  • Rekordy nigdy-niewidoczne też dostają nagrobek.
  • Słowniki (Zrodlo, Konferencja, Projekt, Jednostka) nie są soft-delete —
    ich nagrobki biorą się z dopełnienia ekspozycji, więc twarde skasowanie
    takiego wiersza znika po cichu.

Stronicowanie /api/v1/usuniete/ (dołożone po pierwszym przebiegu)

Endpoint stronicuje BppLimitOffsetPagination — ale ORDER BY i
LIMIT/OFFSET wykonuje baza
, nie Python.

Samo owinięcie sklejonej listy paginatorem byłoby półśrodkiem: każde żądanie
dalej ciągnęłoby cały kosz z sześciu modeli, a klient robiłby teraz N żądań
zamiast jednego — łączna praca by wzrosła. Zamiast tego sześć gałęzi
łączy UNION ALL o wspólnym kształcie (etykieta modelu, pk, deleted_at);
etykieta to stała z Value, bo po złączeniu klucze główne kolidują między
modelami.

Sortowanie (deleted_at, etykieta, pk) — sama data nie wystarcza: dwa
rekordy skasowane w tej samej mikrosekundzie miałyby niezdeterminowaną
kolejność, a to na granicy strony znaczy zgubiony albo zdublowany wiersz.
NULL-e lądują na końcu (Postgres, ASC), czyli tam, gdzie stawiał je
poprzedni sort w Pythonie.

Przy okazji każda gałąź dostaje .order_by() czyszczące Meta.ordering
modelu (Autor.sort, Praca_Doktorska.rok/tytul_oryginalny) — sortowanie po
kolumnach, których nawet nie ma w SELECT, i tak nadpisane przez zewnętrzny
ORDER BY.

test_usuniete_tnie_strone_w_bazie_a_nie_w_pythonie sprawdza, że złączone
zapytanie faktycznie niesie LIMIT — żeby ta optymalizacja nie
zdegradowała się po cichu do pesymalizacji.

Dług ujawniony przy okazji (nie naprawiany tutaj)

BppSoftDeleteQuerySet blokuje .update(deleted_at=...) (omijałoby
post_save, kaskadę *_Autor, SoftDeleteLog i reversion), ale gate
dziedziczy tylko AutorQuerySetBppDeletedQuerySet publikacji nie.
Czyli Autor.deleted_objects...update(deleted_at=...) rzuca RuntimeError,
a to samo na Wydawnictwo_Ciagle przechodzi. Wygląda na przeoczenie, nie na
decyzję. Testy tej fazy na tej asymetrii nie polegają (opierają się na
zegarze i jawnie asertują, że znaczniki wyszły różne), więc domknięcie
gate'u ich nie zepsuje. Zapisane w handoffie fazy 06.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK

mpasternak and others added 17 commits August 15, 2026 14:11
…wych

Faza 05b wyszla z fazy 05 (miala baner zakresu i zero taskow). Spec powstal
z brainstormingu; cztery decyzje wlasciciela:

D1: nagrobek = DOPELNIENIE EKSPOZYCJI, nie tylko soft-delete. Rekord znika z
feedu na cztery sposoby (kosz, nie_eksportuj_przez_api, ukryty status_korekty,
odpiety ostatni autor z uczelni) i dla harvestera sa one nierozroznialne.
Nagrobki tylko dla kosza czynilyby deklaracje deletedRecord czesciowo
nieprawdziwa.

D2: deletedRecord = "transient" (dzis "no"). Nie gwarantujemy trwalosci
nagrobka: husk moze zniknac przy twardym kasowaniu albo czyszczeniu kosza w
fazie 07.

D3+D5: REST dostaje osobny endpoint /api/v1/usuniete/ z samym identyfikatorem
i znacznikiem czasu — nigdy trescia huska, ktory usunieto czesto wlasnie
dlatego, ze byl bledny albo zawieral dane osobowe. REST znaczy wezej niz OAI
(kosz, nie dopelnienie), bo nie sklada obietnicy deletedRecord.

D4: wszystkie 7 niepustych setow — deklaracje deletedRecord sklada sie dla
CALEGO repozytorium.

Kluczowe ustalenie architektoniczne: predykat widocznosci sklei dzis
PRZYNALEZNOSC do tenanta z EKSPOZYCJA. Dopelniac wolno tylko druga — naiwne
"wszystko minus widoczne" wystawiloby w multi-hosted nagrobki dla rekordow
innych uczelni (widoczne_jednostki filtruje uczelnia=uczelnia wprost).

Stronicowanie: paginujemy nadzbior (przynaleznosc) zamiast dokladac drugi
strumien. resumptionToken niesie jeden kursor (datestamp, pk) i zaklada jeden
porzadek; dwa strumienie zepsulyby przyrostowosc from/until, czyli dokladnie
to, co ta faza naprawia.

Ostrzezenie operacyjne zapisane w specu: wylaczenie Uczelnia.eksport_cerif_osoby
wystawi nagrobki dla WSZYSTKICH autorow uczelni naraz (widoczni_autorzy zwraca
wtedy none()). Zachowanie poprawne, ale jednorazowo bardzo hałaśliwe.
…pecu

Plan rozpisuje spec z 2026-08-15 na 10 taskow TDD, 1400 linii, z konkretnymi
plikami, komendami i trescia testow.

Kolejnosc podyktowana ryzykiem: kontrakt providera (1) → przynaleznosc
providerow wlasnych z testem izolacji tenantow (2) → pochodnych (3) →
stronicowanie nadzbioru (4, najdelikatniejsze) → emisja status=deleted (5) →
GetRecord (6) → Identify transient (7) → cztery drogi znikniecia (8) → REST (9)
→ weryfikacja (10).

Task 4 jako jedyny konczy sie przy CZERWONYM test_oai.py — zmienia ksztalt
zwrotki strona() na pary (obiekt, nagrobek), a wolajacych dostosowuje Task 5.
Zapisane wprost w komunikacie commita tego kroku, zeby wykonawca nie uznal
tego za regresje.

Global Constraints zawieraja dwa zakazy specyficzne dla tego repo: nowa grupa
GrupaApiV1 jest zabroniona (wymagalaby pola api_v1_<value> na Uczelni plus
migracji, pilnuje tego test_kazda_grupa_ma_pole_na_uczelni), a
make clean-testcontainers jest zabronione, bo host bywa wspoldzielony.

Self-review usunelo trzy placeholdery: tresc nalezacy_grantodawcy,
sciezke relacji w nalezacy_autorzy i cztery testy Taska 8 opisane proza.
Wszystkie zastapione kodem zweryfikowanym w zrodlach.

KOREKTA SPECU: sekcja "Ograniczenia" twierdzila, ze Instytucja_Finansujaca
w secie orgunits generuje nagrobki przez widocznosc pochodna od projektow.
Nieprawda — widoczni_grantodawcy filtruje finansowanie__projekt__jednostka__
uczelnia wprost, czyli sama atrybucja bez reguly ekspozycji, wiec dopelnienie
jest puste. orgunits generuje nagrobki, ale wylacznie przez Jednostka.
…videra

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
…a tenantow

Helpery naleza_*/nalezace_* obok istniejacych widoczne_*: sama atrybucja
tenanta, bez regul ekspozycji. Publikacje, patenty i autorzy ida przez
global_objects po OBU stronach (rekord i model autorstwa), zeby rekord
w koszu dostal nagrobek zamiast zniknac po cichu.

publikacje.py: prefetche wydzielone do dekoruj(), zeby queryset()
i przynaleznosc() nie mogly sie rozjechac — strona() paginuje ta druga.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
…todawcy)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
UWAGA: po tym commicie CZERWONE sa test_providery.py, test_serializery.py
i test_widocznosc.py — zmienil sie ksztalt zwrotki strona(), a one wolaja
realnego providera i rozpakowuja gole obiekty. Wolajacych dostosowuje
nastepny task.

test_oai.py zostaje ZIELONY, bo chodzi po atrapach (FikcyjnyProvider),
wiec kontraktu realnej klasy bazowej w ogole nie dotyka — plan spodziewal
sie tu czerwieni, ale trafil w niewlasciwy plik.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
_naglowek() dostaje flage usuniety=; _dopisz_rekordy wypuszcza nagrobek
PRZED serializacja (nie ma czego serializowac) i liczy zbiory widocznosci
wylacznie dla zywych rekordow.

Dostosowani wolajacy zmienionego ksztaltu strona():
- czasowniki: _zbierz_strone/_wg_setu/_kursor_dla przenosza pary,
- test_oai.py: FikcyjnyProvider zwraca pary (same zywe),
- pozostale suity: helper strona_zywych() odtwarza dawna semantyke
  zwrotki, wiec asercje o widocznosci zostaja bez zmian,
- test_integralnosc*: harvest pomija nagrobki — nie maja ladunku, a
  zaliczanie ich do wydanych oslabialoby test wiszacych referencji.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
…Exist

pojedynczy() szuka w nadzbiorze, wiec rekord tenanta ktory przestal byc
wystawiany JEST odnajdywany; _znajdz_rekord dokleja czwarty element
(czy_nagrobek), a _get_record wychodzi wczesnie — bez zbiorow widocznosci
i kontekstu, ktore dla nagrobka sa czystym kosztem.

najstarszy_datestamp tez idzie po nadzbiorze: nagrobek jest rekordem
o dacie i moze byc najstarszy.

idDoesNotExist zostaje dla identyfikatorow spoza tenanta (4 przypadki
w test_oai.py dalej zielone).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
Test czterech drog jest mutation-tested: podmiana ktoregokolwiek
global_objects na objects w naleza_wydawnictwa lapie sie odpowiednio
na przypadku 'kosz' i 'odpiecie_autora'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
Sam identyfikator (model + pk) i data usuniecia, nigdy tresc: rekord bywa
kasowany wlasnie dlatego, ze byl bledny albo mial dane osobowe.

Zakres WEZSZY niz nagrobek OAI (decyzja D5 specu) — tu wychodzi wylacznie
kosz, bo REST nie sklada obietnicy deletedRecord.

Filtr zakresowy usuniety_od_after/_before dziala na querysecie kazdego
modelu (deleted_at__gte/__lte), nie na sklejonej liscie. Data
nieparsowalna daje 400, nie ciche pominiecie filtra — inaczej klient
przyrostowy dostalby caly kosz i uznal, ze to wszystko zniknelo od jego
ostatniego odpytania.

permission_classes=[AllowAny]: domyslne DjangoModelPermissionsOrAnonReadOnly
wymaga queryset-u, ktorego viewset laczacy szesc modeli nie ma; dla GET
tamta klasa i tak nie zada uprawnien, wiec zachowanie sie nie zmienia.
Realna bramka to z_bramka_api_v1(grupa=DANE).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
…operacyjne

Walidacja XSD: plan zakladal istniejacy test walidujacy ODPOWIEDZ, ale
testy serializerow walidowaly tylko pojedyncze encje profilu CERIF.
status=deleted to konstrukcja koperty OAI-PMH, wiec zaden z nich by go
nie zobaczyl. Zvendorowany OAI-PMH.xsd + plik spinajacy oba namespace'y
(metadata ma processContents=strict, wiec sam schemat koperty nie
wystarcza dla rekordow zywych). Testy sa mutation-tested: podmiana
status=deleted na inna wartosc oraz doklejenie pustego <metadata>
lapia sie na XSD.

Ostrzezenie o eksport_cerif_osoby poszlo do docs/, NIE w help_text pola
— zmiana help_text wygenerowalaby migracje AlterField, a ta faza z
zalozenia migracji nie dodaje (makemigrations --check potwierdza:
zgloszenia tylko dla favicon/flexible_reports/siteblog, zastane).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
trailing-whitespace/end-of-file-fixer/mixed-line-ending przepisywalyby
schematy pobrane wprost od wydawcy (profil OpenAIRE CERIF 1.2, koperta
OAI-PMH 2.0). Przy podbiciu wersji diff ma pokazywac zmiany SCHEMATU,
nie nasze przeformatowanie. Problem byl utajony — istniejace vendorowane
pliki tez maja trailing whitespace; nowy OAI-PMH.xsd tylko go ujawnil.

Wyjatek nie obejmuje naszego pliku spinajacego oai-pmh-z-profilem.xsd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
Samo owiniecie sklejonej listy paginatorem byloby polsrodkiem: kazde
zadanie dalej ciagneloby CALY kosz, a klient robilby teraz N zadan
zamiast jednego — laczna praca by WZROSLA. Dlatego szesc modeli laczy
UNION ALL o wspolnym ksztalcie (etykieta modelu, pk, deleted_at),
a ORDER BY i LIMIT/OFFSET wykonuje PostgreSQL.

Sortowanie (deleted_at, etykieta, pk): sama data nie wystarcza, bo dwa
rekordy skasowane w tej samej mikrosekundzie mialyby niezdeterminowana
kolejnosc — a to na granicy strony znaczy zgubiony albo zdublowany
wiersz. NULL-e ida na koniec (Postgres, ASC), czyli tam gdzie stawial
je poprzedni sort w Pythonie.

order_by() czysci Meta.ordering kazdej galezi (Autor.sort,
Praca_Doktorska.rok/tytul) — sortowanie po kolumnach spoza SELECT-a,
i tak nadpisane przez ORDER BY calosci.

test_usuniete_tnie_strone_w_bazie_a_nie_w_pythonie pilnuje, zeby ta
optymalizacja nie zdegradowala sie po cichu: sprawdza, ze zlaczone
zapytanie faktycznie niesie LIMIT.

Przy okazji: fixture'y przestaly przestawiac deleted_at przez .update()
— BppSoftDeleteQuerySet slusznie to blokuje (Autor dziedziczy gate,
publikacje nie, wiec dotychczasowy test opieral sie na tej asymetrii).
Zamiast obchodzic zabezpieczenie polegamy na zegarze i jawnie
asertujemy, ze znaczniki wyszly rozne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
…ria gate'u update()

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
Swieza sesja nie odtworzy z gita, ze wszystkie cztery PR-y stosu sa
otwarte i ze #767 czeka na review. Dopisany tez wybor bazy dla fazy 06
(05 vs 05b) z uzasadnieniem, ze fazy sa rozlaczne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
…ciciela)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drz3jfnuP864JmYxqfjsYK
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