diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 681ca27f6..81606d212 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -73,12 +73,23 @@ repos: # `mkdocs build --strict` parsuje plik bez problemu. exclude: ^(docker-compose\.test\.ci\.yml|mkdocs\.yml)$ - id: check-toml + # `src/cerif_export/tests/xsd/` to VENDOROWANE schematy XSD (profil + # OpenAIRE CERIF 1.2 + koperta OAI-PMH 2.0) — kopie plików cudzych, + # pobrane wprost od wydawcy. Nie normalizujemy w nich białych znaków: + # plik ma zostać porównywalny bajt-w-bajt z upstreamem, żeby przy + # podbiciu wersji diff pokazywał ZMIANY SCHEMATU, a nie nasze + # przeformatowanie. Wyjątek nie obejmuje naszego pliku spinającego + # `oai-pmh-z-profilem.xsd` — ten piszemy sami. - id: end-of-file-fixer - exclude: ^baseline-sql/baseline\.sql$ + exclude: &wylaczone_z_bialych_znakow | + (?x)^( + baseline-sql/baseline\.sql$ + | src/cerif_export/tests/xsd/(?!oai-pmh-z-profilem\.xsd).*\.xsd$ + ) - id: trailing-whitespace - exclude: ^baseline-sql/baseline\.sql$ + exclude: *wylaczone_z_bialych_znakow - id: mixed-line-ending - exclude: ^baseline-sql/baseline\.sql$ + exclude: *wylaczone_z_bialych_znakow - id: check-added-large-files args: ['--maxkb=500'] # .test_durations to celowy, generowany plik danych pytest-split diff --git a/docs/deweloper/eurocris-co-jeszcze.md b/docs/deweloper/eurocris-co-jeszcze.md index 0904b8eb5..e9b7614b1 100644 --- a/docs/deweloper/eurocris-co-jeszcze.md +++ b/docs/deweloper/eurocris-co-jeszcze.md @@ -135,6 +135,18 @@ zombie w indeksie. `eksport_cerif_osoby=False` — sam set osób (autorzy zostają wtedy przy publikacjach jako samo imię i nazwisko, bez `@id`, ORCID-a i afiliacji). + **⚠️ Operacyjnie — od fazy 05b (nagrobki):** przestawienie + `eksport_cerif_osoby` na `False` wystawi w najbliższym harveście + **nagrobki dla WSZYSTKICH autorów uczelni naraz** (nagłówki ze statusem + `deleted`). Zachowanie jest poprawne i zamierzone — harvester ma te osoby + u siebie usunąć, a to jest właśnie skutek, o który chodzi przy decyzji + RODO. Jednorazowo jest to jednak bardzo duży wsad: przy kilku tysiącach + autorów harvest przyrostowy zwróci tyle samo nagrobków, rozłożonych na + wiele stron `resumptionToken`. Operator powinien o tym wiedzieć przed + przestawieniem przełącznika i najlepiej zrobić to w oknie serwisowym, + a nie w środku dnia. To samo dotyczy ponownego włączenia — autorzy wrócą + wtedy jako rekordy żywe, znów wszyscy naraz. + --- ## E. Drobiazgi z profilu — pola dziś pomijane diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-06.md b/docs/superpowers/HANDOFF-soft-delete-faza-06.md index 145c8387c..3261ddf8d 100644 --- a/docs/superpowers/HANDOFF-soft-delete-faza-06.md +++ b/docs/superpowers/HANDOFF-soft-delete-faza-06.md @@ -10,9 +10,37 @@ | | | |---|---| | Stan fazy 05a | gałąź `feat/soft-delete-05`, PR do `feat/soft-delete-04` (stacked) | -| Punkt startowy fazy 06 | `feat/soft-delete-05` | +| Punkt startowy fazy 06 | **`feat/soft-delete-05b`** (decyzja właściciela 2026-08-16) | +| Plan fazy 06 | [`plans/2026-06-04-soft-delete-06-softdeletelog.md`](plans/2026-06-04-soft-delete-06-softdeletelog.md), 914 linii — **istnieje, nie trzeba brainstormingu ani specu** | | Migracje fazy 05a | `pbn_export_queue/0011` (pole `operacja`), `pbn_api/0080` (`SentData.withdrawn_at`), `zglos_publikacje/0028` (state-only, dług fazy 04) | -| **Zakres rozdzielony** | nagrobki OAI-PMH/CERIF/REST **wyszły do fazy 05b** (decyzja właściciela 2026-08-10) | +| **Zakres rozdzielony** | nagrobki OAI-PMH/CERIF/REST **wyszły do fazy 05b** (decyzja właściciela 2026-08-10), ZROBIONE — patrz §5 | + +### Stos PR-ów (stan 2026-08-16, wszystkie OTWARTE, żaden niescalony) + +``` +#312 feat/soft-delete -> dev +#745 feat/soft-delete-04 -> feat/soft-delete +#755 feat/soft-delete-05 -> feat/soft-delete-04 +#767 feat/soft-delete-05b -> feat/soft-delete-05 (faza 05b, nagrobki) +``` + +**Baza fazy 06: `feat/soft-delete-05b`** — rozstrzygnięte, stos rośnie +liniowo. (Technicznie faza 06 **nie zależy** od 05b: nagrobki liczą się +z dopełnienia ekspozycji, nie z `SoftDeleteLog`, a pliki są rozłączne — +06 rusza `bpp/models/soft_delete.py` i sygnały, 05b ruszała `cerif_export/` ++ `api_v1/`. Odbicie od 05 też by działało; wybrano liniowość.) + +```bash +git checkout feat/soft-delete-05b && git checkout -b feat/soft-delete-06 +``` + +⚠️ **PR #767 nie był jeszcze recenzowany ani scalony**, więc faza 06 +dziedziczy jego commity. Gdyby review wymusiło zmiany w 05b, trzeba będzie +je przenieść (rebase) do gałęzi 06. + +⚠️ Jeśli zadaniem sesji jest **dopilnowanie samego PR #767** (review, +poprawki, merge), a nie start fazy 06 — ten handoff jest złym punktem +startu. Zacznij od `gh pr view 767` i od §5. ⚠️ **Faza 05 była w planie JEDNĄ fazą o dwóch niezależnych podsystemach.** Wycofanie z PBN miało drobiazgowy plan (1286 linii); nagrobki miały baner @@ -132,29 +160,59 @@ per-aplikacja. Szukanie dziedziczących tylko w `bpp/` nie wystarcza. --- -## 5. Faza 05b — nagrobki (przed fazą 07, nie przed 06) - -Wyszła z fazy 05 decyzją właściciela 2026-08-10. **Nie ma jeszcze specu ani -planu** — potrzebuje własnego cyklu brainstorming → spec → plan → PR. - -Punkt startowy rozpoznany: - -- `src/cerif_export/const.py:115` → **`DELETED_RECORD = "no"`**. To nie jest - „brak funkcji", to **obietnica w `Identify`**: harvester ma prawo nie pytać - przyrostowo o usunięcia. Zmiana na `persistent`/`transient` to zmiana - kontraktu, nie dopisanie atrybutu. -- Emisja nagłówka: `oai/czasowniki.py` (`_naglowek()`), `Identify` w `:198`. -- **Architektura providerów jest gotowa**: `ProviderEncji.strona()` - (`providers/base.py`) stronicuje keysetem po - `(COALESCE(ostatnio_zmieniony, EPOKA), pk)`, a soft-delete bumpuje - `ostatnio_zmieniony`. Husk wpadłby więc **naturalnie na właściwe miejsce - w kursorze**. Brakuje wyłącznie poszerzenia `queryset()` o kosz i flagi - „to nagrobek" na obiekcie. +## 5. Faza 05b — nagrobki: ZROBIONA (2026-08-16) + +Wyszła z fazy 05 decyzją właściciela 2026-08-10; zaimplementowana na gałęzi +`feat/soft-delete-05b`. + +- Spec: [`specs/2026-08-15-soft-delete-nagrobki-design.md`](specs/2026-08-15-soft-delete-nagrobki-design.md) +- Plan: [`plans/2026-08-16-soft-delete-05b-nagrobki.md`](plans/2026-08-16-soft-delete-05b-nagrobki.md) + +Co weszło: + +- `ProviderEncji.przynaleznosc(uczelnia, model)` — drugi człon kontraktu: + atrybucja tenanta BEZ reguł ekspozycji. `nagrobki()` to różnica + `przynaleznosc − queryset`. Rozszczepienie jest konieczne, bo dopełnienie + całej widoczności wystawiałoby w multi-hosted rekordy cudzych uczelni + (`widoczne_jednostki()` filtruje `uczelnia=` wprost). +- `strona()` paginuje **nadzbiór** i zwraca pary `(obiekt, czy_nagrobek)` — + jeden strumień, jeden kursor keyset. **To zmiana kształtu zwrotki**: + wszyscy wołający (OAI + suity testowe) zostali dostosowani, testy poza + `test_nagrobki.py` używają helpera `tests/pomocnicze.py::strona_zywych`. +- `status="deleted"` w `ListRecords`, `ListIdentifiers` i `GetRecord` + (bez ``); `GetRecord` na rekordzie usuniętym zwraca nagrobek, + a `idDoesNotExist` zostaje dla identyfikatorów spoza tenanta. +- `DELETED_RECORD = "transient"` — obietnica w `Identify` jest teraz prawdziwa. +- `/api/v1/usuniete/` — identyfikator + data, nigdy treść. Zakres **węższy** + niż OAI (sam kosz), decyzja D5 specu. Stronicowany + (`BppLimitOffsetPagination`), przy czym `ORDER BY` i `LIMIT/OFFSET` + wykonuje baza: sześć modeli kosza łączy `UNION ALL` o wspólnym kształcie + `(etykieta modelu, pk, deleted_at)`. Sklejanie list w Pythonie byłoby tu + pesymalizacją — każde żądanie ciągnęłoby cały kosz, a stron jest wiele. +- Faza **nie dodaje migracji** (dlatego ostrzeżenie o `eksport_cerif_osoby` + poszło do `docs/deweloper/eurocris-co-jeszcze.md`, a nie w `help_text`). + +Co zostaje otwarte dla faz dalszych: + +- **Asymetria gate'u na `.update(deleted_at=...)`.** `BppSoftDeleteQuerySet` + blokuje bulk-ustawienie znacznika (omijałoby `post_save`, kaskadę + `*_Autor`, `SoftDeleteLog` i reversion), ale gate dziedziczy tylko + `AutorQuerySet` — `BppDeletedQuerySet` publikacji **nie**. Czyli + `Autor.deleted_objects.filter(...).update(deleted_at=...)` rzuca + `RuntimeError`, a to samo na `Wydawnictwo_Ciagle` przechodzi. Wygląda na + przeoczenie, nie na decyzję. Testy fazy 05b tego nie wykorzystują (opierają + się na zegarze), więc domknięcie gate'u ich nie zepsuje. +- Modele soft-delete to nadal publikacje (faza 02) + `Autor` (faza 04). + Słowniki (`Zrodlo`, `Konferencja`, `Projekt`, `Jednostka`) — **nie**; + ich nagrobki biorą się z dopełnienia ekspozycji, nie z kosza, więc + twarde skasowanie takiego wiersza nadal znika po cichu. - ⚠️ `z_datestampem()` niesie dwie zapisane blizny (`Trunc` do sekundy, - `tzinfo=UTC`) — obie o duplikatach na granicy strony. Nagrobki muszą iść - tą samą ścieżką. -- Modele soft-delete: publikacje (faza 02) + `Autor` (faza 04). Słowniki - (`Zrodlo`, `Konferencja`, `Projekt`, `Jednostka`) — **nie**. + `tzinfo=UTC`) — obie o duplikatach na granicy strony. Nagrobki idą tą samą + ścieżką; pilnuje tego + `test_harvest_po_tokenach_nie_gubi_i_nie_dubluje_na_granicy_nagrobka`. +- Ograniczenia świadomie poza zakresem (sekcja „Ograniczenia" specu): + przepięcie autorstwa do innej uczelni bez śladu w koszu nadal znika po + cichu; rekordy nigdy-niewidoczne też dostają nagrobek. --- diff --git a/docs/superpowers/plans/2026-08-16-soft-delete-05b-nagrobki.md b/docs/superpowers/plans/2026-08-16-soft-delete-05b-nagrobki.md new file mode 100644 index 000000000..546a21837 --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-soft-delete-05b-nagrobki.md @@ -0,0 +1,1407 @@ +# Soft-delete faza 05b: nagrobki — plan implementacji + +> **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:** Konsument przyrostowy (harvester OAI-PMH, klient REST) dowiaduje się, że rekord przestał być wystawiany — zamiast odkrywać to po cichej nieobecności. + +**Architecture:** Providerzy CERIF dostają drugą metodę kontraktu, `przynaleznosc(uczelnia, model)` — rekordy tenanta bez reguł ekspozycji. Nagrobek to różnica `przynaleznosc − queryset`, liczona w klasie bazowej. Stronicowanie przechodzi na nadzbiór (`przynaleznosc`), więc żywe rekordy i nagrobki płyną **jednym** strumieniem, jednym kursorem keyset. `/api/v1/usuniete/` to osobny, ubogi endpoint zwracający wyłącznie typ, klucz i znacznik czasu. + +**Tech Stack:** Django, PostgreSQL, lxml (OAI-PMH XML), Django REST Framework + django-filter, pytest + model_bakery. + +**Spec źródłowy:** [`../specs/2026-08-15-soft-delete-nagrobki-design.md`](../specs/2026-08-15-soft-delete-nagrobki-design.md) — czytaj go przed startem, zwłaszcza sekcje „Decyzje" i „Ograniczenia". + +**Zależność:** faza 02 (kosz publikacji i autorstw), faza 04 (`Autor`). NIE zależy od fazy 05a ani od `SoftDeleteLog` z fazy 06. + +--- + +## Global Constraints + +- Wszystkie komendy Pythona przez `uv run` (np. `uv run pytest ...`). NIGDY gołe `python`. +- Max długość linii **88 znaków** (ruff). Komentarze, docstringi i komunikaty **po polsku**. +- Testy: pytest, standalone functions, **NIGDY `unittest.TestCase`**; `@pytest.mark.django_db`; `model_bakery.baker.make`. +- **NIE modyfikować istniejących migracji** w `src/*/migrations/`. +- Po każdym kroku z kodem produkcyjnym: `uv run ruff check ` + `uv run ruff format `, potem commit. +- Commituj **po jawnych ścieżkach** (`git add …`), nigdy `git add -A`. +- **NIE uruchamiaj `make clean-testcontainers`** — host bywa współdzielony z równoległymi sesjami; ubiłbyś cudzą pracę. Gdy kontener nie wstaje w 120 s, użyj `TC_MAX_TRIES=600`. +- **Nowa grupa `GrupaApiV1` jest ZABRONIONA w tej fazie.** Każda grupa wymaga pola `api_v1_` na `Uczelnia` (pilnuje `test_kazda_grupa_ma_pole_na_uczelni`), czyli migracji i nowego przełącznika. `/api/v1/usuniete/` rejestrujemy pod istniejącą `GrupaApiV1.DANE_BIBLIOGRAFICZNE`. +- **Ta faza nie dodaje żadnej migracji.** Jeśli `makemigrations --check` zrobi się czerwony, coś poszło nie tak — zatrzymaj się. + +--- + +## Stan zastany (zweryfikowany w kodzie 2026-08-16 — używać tych nazw VERBATIM) + +`src/cerif_export/providers/base.py`: +- `ADNOTACJA_TS = "_cerif_ts"`; `z_datestampem(queryset, pole="ostatnio_zmieniony")` + robi `Trunc(Coalesce(pole, EPOKA), "second", tzinfo=datetime.UTC)`. **Nie ruszaj tej + funkcji** — jej dwie „blizny" (obcięcie do sekundy, `tzinfo=UTC`) powstały po realnych + duplikatach na granicy strony. +- `na_datestamp(wartosc) -> str`. +- `class ProviderEncji`: atrybuty `set_spec`, `typ_cerif`, `modele`; metody + `queryset(uczelnia, model)`, `zbiory_widocznosci(uczelnia, obiekty)`, + `strona(uczelnia, od=None, do=None, kursor=None, rozmiar=None)`, + `_istnieje_dalej(...)`, `_kursor(slug, obiekt)`, + `_strona_modelu(uczelnia, model, od, do, kursor, limit)`, + `pojedynczy(uczelnia, model, pk)`, `najstarszy_datestamp(uczelnia)`. +- `class ProviderPusty(ProviderEncji)` — `strona()` zwraca `([], None)`, + `pojedynczy()` zwraca `None`, `najstarszy_datestamp()` zwraca `None`. + +Helpery widoczności (wszystkie **bez prefetchy**, nadają się do podzapytań): + +| plik | helper | reguły ekspozycji ponad atrybucję | +|---|---|---| +| `publikacje.py:85` | `widoczne_wydawnictwa(model, uczelnia)` | `status_korekty` (kanał `cerif`), `nie_eksportuj_przez_api` | +| `publikacje.py:105` | `widoczne_prace(model, uczelnia)` | jw. | +| `publikacje.py:124` | `widoczne_dla_modelu(model, uczelnia)` | dispatcher | +| `publikacje.py:151` | `widoczne_konferencje(uczelnia)` | **pochodna** od `widoczne_wydawnictwa` | +| `osoby.py:31` | `widoczni_autorzy(uczelnia)` | `Uczelnia.eksport_cerif_osoby`, `pokazuj` | +| `patenty.py:28` | `widoczne_patenty(uczelnia)` | `status_korekty`, `nie_eksportuj_przez_api`, `rodzaj_prawa.eksportuj_jako_patent` | +| `jednostki.py:43` | `widoczne_jednostki(uczelnia)` | `widoczna`, `nie_eksportuj_przez_api` | +| `jednostki.py:58` | `widoczni_grantodawcy(uczelnia)` | **pochodna** od projektów | +| `projekty.py:39` | `widoczne_projekty(uczelnia)` | brak — czysta atrybucja | +| `finansowanie.py:24` | `widoczne_finansowania(uczelnia)` | brak — czysta atrybucja | + +`src/cerif_export/oai/czasowniki.py`: +- `_naglowek(rodzic, set_spec, obiekt, namespace)` (`:619`) — buduje `
` z + `identifier`, `datestamp`, `setSpec`. +- `_dopisz_rekordy(korzen, zadanie, rejestr, pary, namespace)` (`:507`) — pętla po + setach, `_bezpiecznie(...)`, `
`. +- `_lista(zadanie, argumenty, nazwa, z_metadanymi)` (`:339`) — `ListIdentifiers` + woła `_naglowek` wprost (`:357`). +- `_get_record(zadanie, argumenty)` (`:294`) i `_znajdz_rekord(zadanie, identyfikator)` + (`:552`) — dziś `provider.pojedynczy()` → `None` → `NieznanyIdentyfikator`. +- `_identify(zadanie, argumenty)` (`:188`) — `_pod(identify, "deletedRecord", const.DELETED_RECORD)` (`:198`). + +`src/cerif_export/const.py:115`: `DELETED_RECORD = "no"`. + +`src/api_v1/urls.py`: `DANE = GrupaApiV1.DANE_BIBLIOGRAFICZNE` (`:120`); +`router.register(prefix, viewset, grupa=...)` — `grupa` jest **keyword-only bez defaultu**. + +`src/cerif_export/tests/conftest.py` dostarcza fixtury `uczelnia`, `jednostka`, +`typ_autor`. **Nowe testy piszemy w `src/cerif_export/tests/test_nagrobki.py`.** + +--- + +## File Structure + +| plik | odpowiedzialność | +|---|---| +| `src/cerif_export/providers/base.py` (modify) | kontrakt `przynaleznosc()`, `nagrobki()`, stronicowanie nadzbioru | +| `src/cerif_export/providers/{publikacje,osoby,patenty,jednostki,projekty,finansowanie,konferencje,puste}.py` (modify) | `przynaleznosc()` per provider | +| `src/cerif_export/oai/czasowniki.py` (modify) | emisja `status="deleted"`, GetRecord na nagrobku | +| `src/cerif_export/const.py` (modify) | `DELETED_RECORD` | +| `src/cerif_export/tests/test_nagrobki.py` (create) | cała faza — testy nagrobków | +| `src/api_v1/viewsets/usuniete.py` (create) | viewset `/api/v1/usuniete/` | +| `src/api_v1/serializers/usuniete.py` (create) | serializer nagrobka REST | +| `src/api_v1/urls.py` (modify) | rejestracja endpointu | +| `src/api_v1/tests/test_usuniete.py` (create) | testy endpointu | + +--- + +## Task 1: Kontrakt `przynaleznosc()` w klasie bazowej + +**Files:** +- Modify: `src/cerif_export/providers/base.py` +- Modify: `src/cerif_export/providers/puste.py` +- Test: `src/cerif_export/tests/test_nagrobki.py` (NOWY) + +**Interfaces:** +- Produces: `ProviderEncji.przynaleznosc(uczelnia, model) -> QuerySet` (abstrakcyjna, + `NotImplementedError`); `ProviderEncji.nagrobki(uczelnia, model) -> QuerySet`. + +- [ ] **Krok 1.1: Failing test — każdy zarejestrowany provider ma `przynaleznosc`.** + +Utwórz `src/cerif_export/tests/test_nagrobki.py`: + +```python +"""Faza 05b soft-delete: nagrobki dla konsumentów przyrostowych. + +Nagrobek = rekord, który NALEŻY do tenanta, ale nie jest już wystawiany. +Dopełniamy ekspozycję, nigdy przynależność — dopełnienie przynależności +wystawiłoby w multi-hosted rekordy cudzych uczelni. +""" + +import pytest + +from cerif_export.providers import rejestr_providerow + + +def test_kazdy_provider_deklaruje_przynaleznosc(): + """Kontrakt musi być kompletny, inaczej nagrobki milkną w losowym secie. + + Provider bez ``przynaleznosc`` wywaliłby się dopiero przy harveście + akurat tego setu — czyli u konsumenta, nie w testach. + """ + for set_spec, provider in rejestr_providerow().items(): + assert hasattr(provider, "przynaleznosc"), ( + f"Provider setu {set_spec} nie deklaruje przynaleznosc()" + ) +``` + +- [ ] **Krok 1.2: Komenda + FAIL** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q` +Expected: FAIL — `AttributeError`/`assert` (brak metody). + +- [ ] **Krok 1.3: Implementacja — kontrakt + różnica w klasie bazowej** + +W `src/cerif_export/providers/base.py`, w klasie `ProviderEncji`, tuż **pod** +metodą `queryset`: + +```python + def przynaleznosc(self, uczelnia, model): + """Rekordy TEGO tenanta — także niewidoczne i te w koszu. + + Wyłącznie atrybucja tenanta. ŻADNYCH reguł ekspozycji + (``nie_eksportuj_przez_api``, ``status_korekty``, ``widoczna``, + ``pokazuj``, przełączniki ``Uczelnia.eksport_cerif_*``) — te należą + do ``queryset()`` i to ich dopełnienie daje nagrobki. + + Rozszczepienie jest konieczne, bo predykat widoczności sklei dziś + dwie różne rzeczy. Dopełnienie CAŁEJ widoczności wystawiłoby + w multi-hosted nagrobki dla rekordów innych uczelni — + ``widoczne_jednostki()`` filtruje ``uczelnia=uczelnia`` wprost. + + Prefetche: te same co w ``queryset()``. Prefetch na husku jest + nieszkodliwy, a alternatywa (ponowne pobranie żywych z prefetchami) + dokładałaby zapytanie na każdą stronę harvestu. + """ + raise NotImplementedError + + def nagrobki(self, uczelnia, model): + """Rekordy tenanta, które przestały być wystawiane. + + Różnica liczona po kluczach głównych: ``queryset()`` niesie + prefetche, a te w podzapytaniu i tak nie działają — ``values("pk")`` + sprowadza je do samego klucza. + """ + widoczne = self.queryset(uczelnia, model).values("pk") + return self.przynaleznosc(uczelnia, model).exclude(pk__in=widoczne) +``` + +W `src/cerif_export/providers/puste.py`, w klasie `ProviderPusty`, pod `queryset`: + +```python + def przynaleznosc(self, uczelnia, model): + raise NotImplementedError("Set pusty nie ma modeli") +``` + +- [ ] **Krok 1.4: Komenda + PASS** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q` +Expected: PASS. + +- [ ] **Krok 1.5: Lint + commit** + +```bash +uv run ruff check src/cerif_export/providers/base.py src/cerif_export/providers/puste.py src/cerif_export/tests/test_nagrobki.py +uv run ruff format src/cerif_export/providers/base.py src/cerif_export/providers/puste.py src/cerif_export/tests/test_nagrobki.py +git add src/cerif_export/providers/base.py src/cerif_export/providers/puste.py src/cerif_export/tests/test_nagrobki.py +git commit -m "feat(cerif): kontrakt przynaleznosc() + roznica nagrobkow w bazie providera" +``` + +--- + +## Task 2: `przynaleznosc()` providerów o atrybucji własnej + +**Files:** +- Modify: `src/cerif_export/providers/publikacje.py`, `osoby.py`, `patenty.py`, + `jednostki.py`, `projekty.py`, `finansowanie.py` +- Test: `src/cerif_export/tests/test_nagrobki.py` + +**Interfaces:** +- Consumes: `ProviderEncji.przynaleznosc` (Task 1). +- Produces: moduł-level helpery `naleza_wydawnictwa(model, uczelnia)`, + `naleza_prace(model, uczelnia)`, `naleza_dla_modelu(model, uczelnia)`, + `nalezacy_autorzy(uczelnia)`, `nalezace_patenty(uczelnia)`, + `nalezace_jednostki(uczelnia)`, `nalezace_projekty(uczelnia)`, + `nalezace_finansowania(uczelnia)` — wszystkie **bez prefetchy**. + +- [ ] **Krok 2.1: Failing test — izolacja tenantów (TEST KRYTYCZNY).** + +Dopisz do `test_nagrobki.py`: + +```python +@pytest.fixture +def druga_uczelnia(db): + """Druga uczelnia z własną jednostką — multi-hosted.""" + from django.contrib.sites.models import Site + + from bpp.models import Jednostka, Uczelnia + + site = Site.objects.create(domain="druga.example.org", name="druga") + uczelnia = Uczelnia.objects.create(nazwa="Druga", skrot="DRU", site=site) + Jednostka.objects.create(nazwa="Jednostka Drugiej", skrot="JDR", uczelnia=uczelnia) + return uczelnia + + +@pytest.mark.django_db +def test_nagrobki_nie_wyciekaja_miedzy_uczelniami(uczelnia, druga_uczelnia): + """Dopełnienie NIE może objąć rekordów cudzego tenanta. + + To jedyne ryzyko, które dopełnienie widoczności wnosi wprost: + ``widoczne_jednostki()`` filtruje ``uczelnia=uczelnia``, więc naiwne + „wszystko minus widoczne" zamieniłoby każdą jednostkę drugiej uczelni + w nagrobek pierwszej — wyciek identyfikatorów i lawina szumu. + """ + from bpp.models import Jednostka + + from cerif_export.providers import rejestr_providerow + from cerif_export import const + + provider = rejestr_providerow()[const.SET_ORGUNITS] + nagrobki = provider.nagrobki(uczelnia, Jednostka) + + obce = Jednostka.objects.filter(uczelnia=druga_uczelnia) + assert obce.exists(), "fixture musi utworzyć jednostkę drugiej uczelni" + assert not nagrobki.filter(pk__in=obce.values("pk")).exists(), ( + "nagrobki uczelni A zawierają jednostkę uczelni B — wyciek tenanta" + ) +``` + +- [ ] **Krok 2.2: Komenda + FAIL** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py::test_nagrobki_nie_wyciekaja_miedzy_uczelniami -x -q` +Expected: FAIL — `NotImplementedError` z `przynaleznosc`. + +- [ ] **Krok 2.3: Implementacja — `jednostki.py`** + +W `src/cerif_export/providers/jednostki.py`, obok `widoczne_jednostki`: + +```python +def nalezace_jednostki(uczelnia): + """Jednostki TEJ uczelni — bez reguł ekspozycji. + + Atrybucja to bezpośredni FK ``uczelnia``; ``widoczna`` + i ``nie_eksportuj_przez_api`` są regułami ekspozycji i zostają + w ``widoczne_jednostki()``, żeby ich dopełnienie dało nagrobki. + """ + wymagaj_uczelni(uczelnia) + return Jednostka.objects.filter(uczelnia=uczelnia) +``` + +a w klasie providera jednostek, pod `queryset`: + +```python + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is Jednostka: + return nalezace_jednostki(uczelnia) + if model is Uczelnia: + return Uczelnia.objects.filter(pk=uczelnia.pk) + if model is Instytucja_Finansujaca: + return nalezacy_grantodawcy(uczelnia) + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) +``` + +(`nalezacy_grantodawcy` dostarcza Task 3 — do tego czasu ten warunek nie jest +wołany przez testy Taska 2. Jeśli wykonujesz taski po kolei, dopisz go w Tasku 3; +jeśli ktoś czyta ten task osobno — patrz Task 3 po treść tej funkcji.) + +- [ ] **Krok 2.4: Implementacja — `publikacje.py`** + +Obok `widoczne_wydawnictwa` / `widoczne_prace` / `widoczne_dla_modelu`: + +```python +def naleza_wydawnictwa(model, uczelnia): + """Wydawnictwa TEJ uczelni — bez reguł ekspozycji. + + ⚠️ Scope idzie przez ``global_objects`` modelu autorstwa, czyli + **z koszem**. Autorstwa mają ``BppAutorstwoSoftDeleteMixin`` od fazy 02, + więc rekord, któremu skasowano ostatniego autora z tej uczelni, + pozostaje „kiedyś nasz" i dostanie nagrobek zamiast zniknąć po cichu. + Użycie ``objects`` cofnęłoby jedną z czterech dróg zniknięcia + z powrotem do ciszy. + """ + wymagaj_uczelni(uczelnia) + return model.objects.filter( + pk__in=model.autor_rekordu_klass.global_objects.filter( + jednostka__uczelnia=uczelnia + ).values("rekord_id") + ) + + +def naleza_prace(model, uczelnia): + """Prace dyplomowe TEJ uczelni — bez reguł ekspozycji. + + Atrybucja przez bezpośredni FK ``jednostka`` (jak ``widoczne_prace``). + """ + wymagaj_uczelni(uczelnia) + return model.objects.filter(jednostka__uczelnia=uczelnia) + + +def naleza_zrodla(uczelnia): + """Źródła wskazywane przez wydawnictwa ciągłe NALEŻĄCE do tej uczelni. + + Provider pochodny — odpowiednik ``widoczne_zrodla`` wyprowadzony + z przynależności. Tylko ``Wydawnictwo_Ciagle`` ma FK ``zrodlo``. + Różnica obu zbiorów to źródła, do których prowadziły wyłącznie + wydawnictwa, które przestały być widoczne — i one dostają nagrobek. + """ + wymagaj_uczelni(uczelnia) + return Zrodlo.objects.filter( + pk__in=naleza_wydawnictwa(Wydawnictwo_Ciagle, uczelnia) + .filter(zrodlo__isnull=False) + .values("zrodlo_id") + ) + + +def naleza_dla_modelu(model, uczelnia): + """Dispatcher równoległy do ``widoczne_dla_modelu`` (publikacje.py:124). + + MUSI mieć te same trzy gałęzie co tamten — w tym ``Zrodlo``. Pominięcie + którejś dałoby ``NotImplementedError`` dopiero przy harveście akurat + tego modelu, czyli u konsumenta. + """ + if model in MODELE_WYDAWNICTW: + return naleza_wydawnictwa(model, uczelnia) + if model in MODELE_PRAC: + return naleza_prace(model, uczelnia) + if model is Zrodlo: + return naleza_zrodla(uczelnia) + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {const.SET_PUBLICATIONS}" + ) +``` + +W klasie `ProviderPublikacji`, pod `queryset`: + +```python + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model not in self.modele: + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + return naleza_dla_modelu(model, uczelnia) +``` + +⚠️ **`przynaleznosc` MUSI nieść te same `select_related`/`prefetch_related` co +`queryset`** — stronicowanie (Task 4) paginuje właśnie ją, a serializacja żywych +rekordów czyta z niej relacje. Wydziel listy prefetchy do wspólnych stałych albo +powiel wywołania `.select_related(...)/.prefetch_related(...)` z `queryset()`. +Bez tego harvest dostanie N+1 na każdej stronie — testy tego NIE złapią. + +- [ ] **Krok 2.5: Implementacja — `osoby.py`, `patenty.py`, `projekty.py`, `finansowanie.py`** + +```python +# osoby.py — obok widoczni_autorzy +def nalezacy_autorzy(uczelnia): + """Autorzy afiliowani przy TEJ uczelni — bez reguł ekspozycji. + + ⚠️ Świadomie IGNORUJEMY ``Uczelnia.eksport_cerif_osoby``. Przełącznik + jest regułą ekspozycji, więc jego wyłączenie MA produkować nagrobki — + harvester ma te osoby usunąć. Skutek uboczny (opisany w specu): + przestawienie przełącznika wystawia nagrobki dla wszystkich autorów + uczelni naraz. To poprawne, ale jednorazowo bardzo hałaśliwe. + """ + wymagaj_uczelni(uczelnia) + return Autor.objects.filter( + pk__in=Autor_Jednostka.objects.filter(jednostka__uczelnia=uczelnia).values( + "autor_id" + ) + ) +``` + +(Ścieżka zweryfikowana w `widoczni_autorzy` — ta sama konstrukcja +`Autor_Jednostka.objects…values("autor_id")`, bez `pokazuj=True` i bez gałęzi +`eksport_cerif_osoby`, bo oba są regułami ekspozycji.) + +```python +# patenty.py — obok widoczne_patenty +def nalezace_patenty(uczelnia): + """Patenty TEJ uczelni — bez reguł ekspozycji. + + Scope przez model autorstwa z KOSZEM (``global_objects``), jak + wydawnictwa. ``status_korekty``, ``nie_eksportuj_przez_api`` + i ``rodzaj_prawa.eksportuj_jako_patent`` to ekspozycja — zostają + w ``widoczne_patenty()``. + """ + wymagaj_uczelni(uczelnia) + return Patent.objects.filter( + pk__in=Patent_Autor.global_objects.filter( + jednostka__uczelnia=uczelnia + ).values("rekord_id") + ) + + +# projekty.py — obok widoczne_projekty +def nalezace_projekty(uczelnia): + """Projekty TEJ uczelni. Czysta atrybucja — identyczna z widocznością, + więc dopełnienie jest puste i ten set nagrobków nie wygeneruje. + Kontrakt implementujemy dla spójności i gotowości na przyszłe reguły.""" + wymagaj_uczelni(uczelnia) + return Projekt.objects.filter(jednostka__uczelnia=uczelnia) + + +# finansowanie.py — obok widoczne_finansowania +def nalezace_finansowania(uczelnia): + """Finansowania projektów TEJ uczelni. Jak projekty: czysta atrybucja, + dopełnienie puste.""" + wymagaj_uczelni(uczelnia) + return Finansowanie.objects.filter(projekt__jednostka__uczelnia=uczelnia) +``` + +W każdej z czterech klas providerów dopisz `przynaleznosc` w kształcie: + +```python + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is not : + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + return (uczelnia). +``` + +- [ ] **Krok 2.6: Komenda + PASS** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q` +Expected: PASS (oba testy). + +- [ ] **Krok 2.7: Regresja providerów** + +Run: `uv run pytest src/cerif_export/tests/test_providery.py src/cerif_export/tests/test_widocznosc.py -q` +Expected: zielono — `przynaleznosc` niczego nie zmienia w widoczności. + +- [ ] **Krok 2.8: Lint + commit** + +```bash +uv run ruff check src/cerif_export/providers/ src/cerif_export/tests/test_nagrobki.py +uv run ruff format src/cerif_export/providers/ src/cerif_export/tests/test_nagrobki.py +git add src/cerif_export/providers/ src/cerif_export/tests/test_nagrobki.py +git commit -m "feat(cerif): przynaleznosc() providerow o atrybucji wlasnej + izolacja tenantow" +``` + +--- + +## Task 3: `przynaleznosc()` providerów pochodnych + +Konferencje i grantodawcy nie mają własnej atrybucji — ich widoczność jest +wyprowadzona z publikacji i projektów. Przynależność wyprowadzamy **z przynależności** +rekordów nadrzędnych, nie z ich widoczności. Dzięki temu konferencja, do której +prowadziły wyłącznie publikacje, które przestały być widoczne, dostaje nagrobek — +bo realnie znika z feedu. + +**Files:** +- Modify: `src/cerif_export/providers/publikacje.py` (helper konferencji), + `src/cerif_export/providers/konferencje.py`, `src/cerif_export/providers/jednostki.py` +- Test: `src/cerif_export/tests/test_nagrobki.py` + +**Interfaces:** +- Consumes: `naleza_wydawnictwa` (Task 2), `nalezace_projekty` (Task 2). +- Produces: `nalezace_konferencje(uczelnia)`, `nalezacy_grantodawcy(uczelnia)`. + +- [ ] **Krok 3.1: Failing test — konferencja osierocona dostaje nagrobek** + +```python +@pytest.mark.django_db +def test_konferencja_bez_widocznych_publikacji_to_nagrobek( + uczelnia, jednostka, typ_autor +): + """Provider pochodny: konferencja znika, gdy znikną jej publikacje. + + Widoczność konferencji jest wyprowadzona z publikacji. Gdy jedyna + publikacja wskazująca konferencję przestaje być widoczna, konferencja + też wypada z feedu — i musi dostać nagrobek, a nie zniknąć po cichu. + """ + from model_bakery import baker + + from bpp.models import Konferencja, Wydawnictwo_Ciagle + from cerif_export import const + from cerif_export.providers import rejestr_providerow + + konferencja = baker.make(Konferencja) + praca = baker.make(Wydawnictwo_Ciagle, konferencja=konferencja) + praca.dodaj_autora( + baker.make("bpp.Autor"), jednostka, typ_odpowiedzialnosci_skrot="aut." + ) + + provider = rejestr_providerow()[const.SET_EVENTS] + assert not provider.nagrobki(uczelnia, Konferencja).filter( + pk=konferencja.pk + ).exists(), "konferencja z widoczną publikacją nie jest nagrobkiem" + + praca.nie_eksportuj_przez_api = True + praca.save() + + assert provider.nagrobki(uczelnia, Konferencja).filter( + pk=konferencja.pk + ).exists(), ( + "konferencja straciła jedyną widoczną publikację, a nie dostała " + "nagrobka — znika z feedu po cichu" + ) +``` + +⚠️ **Sprawdź sygnaturę `dodaj_autora`** (`grep -n "def dodaj_autora" -A 6 src/bpp/models/abstract/*.py`) +i dostosuj wywołanie — nazwa argumentu typu odpowiedzialności bywa różna w tym repo. + +- [ ] **Krok 3.2: Komenda + FAIL** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py::test_konferencja_bez_widocznych_publikacji_to_nagrobek -x -q` +Expected: FAIL — `NotImplementedError` (provider konferencji nie ma `przynaleznosc`). + +- [ ] **Krok 3.3: Implementacja** + +W `publikacje.py`, tuż pod `widoczne_konferencje`: + +```python +def nalezace_konferencje(uczelnia): + """Konferencje wskazywane przez publikacje NALEŻĄCE do tej uczelni. + + Odpowiednik ``widoczne_konferencje``, ale wyprowadzony z przynależności, + nie z widoczności. Różnica tych dwóch zbiorów to właśnie konferencje, + które wypadły z feedu — i o nie chodzi w nagrobkach. + """ + wymagaj_uczelni(uczelnia) + warunek = Q() + for model in MODELE_WYDAWNICTW: + warunek |= Q( + pk__in=naleza_wydawnictwa(model, uczelnia) + .filter(konferencja__isnull=False) + .values("konferencja_id") + ) + return Konferencja.objects.filter(warunek) +``` + +W `konferencje.py`, w klasie providera: + +```python + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is not Konferencja: + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + return nalezace_konferencje(uczelnia).select_related("pbn_uid") +``` + +(import `nalezace_konferencje` z `cerif_export.providers.publikacje` — tak jak +`konferencje.py` importuje dziś `widoczne_konferencje`; sprawdź istniejący import.) + +W `jednostki.py`, obok `widoczni_grantodawcy` — odwzoruj jego treść, podmieniając +wewnętrzny zbiór projektów na `nalezace_projekty`: + +```python +def nalezacy_grantodawcy(uczelnia): + """Instytucje finansujące projekty TEJ uczelni. + + ⚠️ Treść jest IDENTYCZNA z ``widoczni_grantodawcy`` — i to nie pomyłka. + Tamten helper filtruje ``finansowanie__projekt__jednostka__uczelnia`` + wprost, czyli sama atrybucja, bez żadnej reguły ekspozycji. Dopełnienie + jest więc puste i ten model nagrobków nie wygeneruje. + + Implementujemy mimo to, bo kontrakt providera musi być kompletny + (``test_kazdy_provider_deklaruje_przynaleznosc``), a rozdzielenie nazw + pokazuje następnemu czytelnikowi, gdzie dopisać regułę ekspozycji, gdyby + kiedyś powstała — wtedy nagrobki zaczną działać bez zmian w bazie. + """ + wymagaj_uczelni(uczelnia) + return Instytucja_Finansujaca.objects.filter( + finansowanie__projekt__jednostka__uczelnia=uczelnia + ).distinct() +``` + +- [ ] **Krok 3.4: Komenda + PASS** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q` +Expected: PASS. + +- [ ] **Krok 3.5: Lint + commit** + +```bash +uv run ruff check src/cerif_export/providers/ src/cerif_export/tests/test_nagrobki.py +uv run ruff format src/cerif_export/providers/ src/cerif_export/tests/test_nagrobki.py +git add src/cerif_export/providers/ src/cerif_export/tests/test_nagrobki.py +git commit -m "feat(cerif): przynaleznosc() providerow pochodnych (konferencje, grantodawcy)" +``` + +--- + +## Task 4: Stronicowanie nadzbioru (NAJDELIKATNIEJSZY) + +**Files:** +- Modify: `src/cerif_export/providers/base.py` +- Test: `src/cerif_export/tests/test_nagrobki.py` + +**Interfaces:** +- Produces: `strona()` zwraca `([(obiekt, czy_nagrobek)], kursor)` — **zmiana + kształtu zwrotki**; `ProviderEncji.widoczne_pk_ze_strony(uczelnia, model, obiekty) -> frozenset`. + +⚠️ **Zmiana kształtu zwrotki `strona()` dotyka `oai/czasowniki.py` (`_zbierz_strone`, +`_lista`, `_dopisz_rekordy`). Task 5 dostosowuje wołających — do tego czasu suita +OAI będzie czerwona. To jedyny task, po którym wolno zacommitować przy czerwonym +`test_oai.py`; napisz to w komunikacie commita.** + +- [ ] **Krok 4.1: Failing test — nagrobek pojawia się w stronie, w porządku dat** + +```python +@pytest.mark.django_db +def test_strona_miesza_zywe_i_nagrobki_w_porzadku_dat(uczelnia, jednostka, typ_autor): + """Jeden strumień, jeden kursor. + + Nagrobki NIE mogą iść osobnym przebiegiem po żywych rekordach: + ``resumptionToken`` niesie jeden kursor ``(datestamp, pk)`` i zakłada + jeden porządek. Dwa strumienie zepsułyby przyrostowość ``from``/``until``, + czyli dokładnie to, co ta faza naprawia. + """ + from model_bakery import baker + + from bpp.models import Jednostka + from cerif_export import const + from cerif_export.providers import rejestr_providerow + + ukryta = baker.make( + Jednostka, uczelnia=uczelnia, nazwa="Ukryta", skrot="UKR", widoczna=False + ) + + provider = rejestr_providerow()[const.SET_ORGUNITS] + pary, _kursor = provider.strona(uczelnia, rozmiar=100) + + mapa = {obiekt.pk: nagrobek for obiekt, nagrobek in pary} + assert mapa.get(ukryta.pk) is True, "jednostka ukryta ma być nagrobkiem" + assert mapa.get(jednostka.pk) is False, "jednostka widoczna ma być żywa" +``` + +- [ ] **Krok 4.2: Komenda + FAIL** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py::test_strona_miesza_zywe_i_nagrobki_w_porzadku_dat -x -q` +Expected: FAIL — `strona()` zwraca gołe obiekty, nie pary; ukryta jednostka w ogóle nie wychodzi. + +- [ ] **Krok 4.3: Implementacja** + +W `base.py`: + +1. W `_strona_modelu` podmień źródło — `self.queryset(...)` na `self.przynaleznosc(...)`: + +```python + def _strona_modelu(self, uczelnia, model, od, do, kursor, limit): + from django.db.models import Q + + # Nadzbiór: żywe + nagrobki w JEDNYM porządku keyset. Kursor + # resumption tokenu niesie (datestamp, pk) i zakłada jeden strumień. + qs = z_datestampem(self.przynaleznosc(uczelnia, model)) + ... # reszta ciała BEZ ZMIAN +``` + +2. Dodaj pomocniczą metodę ustalającą, które obiekty strony są żywe: + +```python + def widoczne_pk_ze_strony(self, uczelnia, model, obiekty) -> frozenset: + """Klucze obiektów tej strony, które są nadal wystawiane. + + Jedno tanie zapytanie na stronę, zawężone do jej kluczy — nie + skanuje całego zbioru widocznych. + """ + if not obiekty: + return frozenset() + klucze = [obiekt.pk for obiekt in obiekty] + return frozenset( + self.queryset(uczelnia, model) + .filter(pk__in=klucze) + .values_list("pk", flat=True) + ) +``` + +3. W `strona()` owiń zebrane obiekty w pary `(obiekt, czy_nagrobek)`. Obiekty + zbierane są per model, więc oznaczaj je **przed** dołożeniem do `zebrane`: + wszędzie, gdzie dziś jest `zebrane.extend(partia)` albo + `zebrane.extend(partia[:brakuje])`, wstaw najpierw + +```python + widoczne = self.widoczne_pk_ze_strony(uczelnia, model, partia) + oznaczone = [(obiekt, obiekt.pk not in widoczne) for obiekt in partia] +``` + + i dokładaj `oznaczone`. `self._kursor(slugi[indeks], partia[-1])` MUSI dalej + dostawać **goły obiekt**, nie parę — kursor czyta `ADNOTACJA_TS` i `pk`. + +⚠️ **Nie ruszaj sondy `brakuje + 1`, `_istnieje_dalej` ani `_kursor`.** To one +gwarantują, że token wydajemy tylko wtedy, gdy realnie jest co pokazać. + +- [ ] **Krok 4.4: Komenda + PASS (test tej fazy)** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q` +Expected: PASS. + +- [ ] **Krok 4.5: Commit z jawnym ostrzeżeniem o czerwonym OAI** + +```bash +uv run ruff check src/cerif_export/providers/base.py +uv run ruff format src/cerif_export/providers/base.py +git add src/cerif_export/providers/base.py src/cerif_export/tests/test_nagrobki.py +git commit -m "feat(cerif): strona() paginuje nadzbior i zwraca pary (obiekt, nagrobek) + +UWAGA: test_oai.py jest po tym commicie CZERWONY — zmienil sie ksztalt +zwrotki strona(). Wolajacych dostosowuje nastepny task." +``` + +--- + +## Task 5: Emisja `status="deleted"` w ListRecords / ListIdentifiers + +**Files:** +- Modify: `src/cerif_export/oai/czasowniki.py` +- Test: `src/cerif_export/tests/test_nagrobki.py` + +**Interfaces:** +- Consumes: `strona()` zwracające pary (Task 4). +- Produces: `_naglowek(rodzic, set_spec, obiekt, namespace, usuniety=False)`. + +- [ ] **Krok 5.1: Failing test — nagrobek w ListRecords bez metadanych** + +```python +@pytest.mark.django_db +def test_listrecords_emituje_nagrobek_bez_metadanych(uczelnia, jednostka, rf): + """Rekord usunięty to SAM nagłówek — dokładanie łamie schemat.""" + from model_bakery import baker + + from bpp.models import Jednostka + from cerif_export.oai import czasowniki + + baker.make( + Jednostka, uczelnia=uczelnia, nazwa="Ukryta", skrot="UKR", widoczna=False + ) + + korzen = czasowniki.obsluz( + _zadanie_dla(uczelnia), + {"verb": "ListRecords", "metadataPrefix": "oai_cerif_openaire", + "set": "openaire_cris_orgunits"}, + ) + naglowki = korzen.findall(".//{http://www.openarchives.org/OAI/2.0/}header") + usuniete = [h for h in naglowki if h.get("status") == "deleted"] + assert usuniete, "brak nagrobka w ListRecords" + + for naglowek in usuniete: + rekord = naglowek.getparent() + assert ( + rekord.find("{http://www.openarchives.org/OAI/2.0/}metadata") is None + ), "nagrobek nie może nieść " +``` + +⚠️ **Sprawdź publiczne API modułu** (`grep -n "^def obsluz\|^def " src/cerif_export/oai/czasowniki.py | head`) +oraz jak `test_oai.py` buduje „zadanie" i wywołuje czasowniki — powiel **ten sam** +sposób zamiast wymyślać `_zadanie_dla` i `metadataPrefix`. Stałą prefiksu weź +z `const.METADATA_PREFIX`, a `setSpec` z `const.SET_ORGUNITS`. + +- [ ] **Krok 5.2: Komenda + FAIL** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q -k listrecords` +Expected: FAIL — brak atrybutu `status`. + +- [ ] **Krok 5.3: Implementacja — `_naglowek` + wołający** + +```python +def _naglowek(rodzic, set_spec, obiekt, namespace, usuniety=False): + """Nagłówek rekordu; ``usuniety=True`` daje nagrobek. + + OAI-PMH sygnalizuje usunięcie atrybutem ``status="deleted"`` na + ``
``. Rekord usunięty NIE niesie ```` — dołożenie ich + złamałoby schemat odpowiedzi. + """ + naglowek = _pod(rodzic, "header") + if usuniety: + naglowek.set("status", "deleted") + _pod(naglowek, "identifier", identyfikatory.zbuduj(namespace, obiekt)) + _pod(naglowek, "datestamp", na_datestamp(getattr(obiekt, ADNOTACJA_TS, None))) + _pod(naglowek, "setSpec", set_spec) + return naglowek +``` + +W `_lista`, gałąź `ListIdentifiers` (dziś `:357`): + +```python + for biezacy_set, (obiekt, nagrobek) in pary: + _naglowek(korzen, biezacy_set, obiekt, namespace, usuniety=nagrobek) +``` + +W `_dopisz_rekordy` — nagrobek **przed** serializacją, bo nie ma czego serializować: + +```python + for obiekt, nagrobek in obiekty: + if nagrobek: + rekord = _pod(korzen, "record") + _naglowek(rekord, biezacy_set, obiekt, namespace, usuniety=True) + continue + + identyfikator = identyfikatory.zbuduj(namespace, obiekt) + ... # dotychczasowa ścieżka żywego rekordu BEZ ZMIAN +``` + +⚠️ `_wg_setu`, `_zbierz_strone` i `_kursor` przenoszą teraz pary — przejrzyj +`grep -n "pary\|obiekt" src/cerif_export/oai/czasowniki.py` i dostosuj rozpakowanie +wszędzie, gdzie iterowano po gołych obiektach. **Nie zmieniaj** logiki tokenu. + +- [ ] **Krok 5.4: Komenda + PASS + regresja OAI** + +```bash +uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q +uv run pytest src/cerif_export/tests/test_oai.py -q +``` +Expected: oba zielone — `test_oai.py` wraca do zdrowia po Tasku 4. + +- [ ] **Krok 5.5: Lint + commit** + +```bash +uv run ruff check src/cerif_export/oai/czasowniki.py src/cerif_export/tests/test_nagrobki.py +uv run ruff format src/cerif_export/oai/czasowniki.py src/cerif_export/tests/test_nagrobki.py +git add src/cerif_export/oai/czasowniki.py src/cerif_export/tests/test_nagrobki.py +git commit -m "feat(cerif): ListRecords/ListIdentifiers emituja naglowek status=deleted" +``` + +--- + +## Task 6: GetRecord zwraca nagrobek zamiast `idDoesNotExist` + +**Files:** +- Modify: `src/cerif_export/providers/base.py` (`pojedynczy`), `src/cerif_export/oai/czasowniki.py` +- Test: `src/cerif_export/tests/test_nagrobki.py` + +**Interfaces:** +- Produces: `ProviderEncji.pojedynczy(uczelnia, model, pk)` szuka w nadzbiorze; + `_znajdz_rekord` zwraca `(set_spec, provider, obiekt, czy_nagrobek)` — **czwarty element**. + +- [ ] **Krok 6.1: Failing test** + +```python +@pytest.mark.django_db +def test_getrecord_na_usunietym_zwraca_nagrobek(uczelnia): + """Usunięty rekord ma nagrobek, nie błąd. + + ``idDoesNotExist`` znaczy „nigdy o takim nie słyszałem" — dla rekordu, + który harvester dostał od nas wcześniej, to odpowiedź myląca. + """ + from model_bakery import baker + + from bpp.models import Jednostka + from cerif_export import const, identyfikatory + from cerif_export.oai import czasowniki + + ukryta = baker.make( + Jednostka, uczelnia=uczelnia, nazwa="Ukryta", skrot="UKR", widoczna=False + ) + zadanie = _zadanie_dla(uczelnia) + identyfikator = identyfikatory.zbuduj(zadanie.namespace, ukryta) + + korzen = czasowniki.obsluz( + zadanie, + {"verb": "GetRecord", "identifier": identyfikator, + "metadataPrefix": const.METADATA_PREFIX}, + ) + naglowek = korzen.find(".//{http://www.openarchives.org/OAI/2.0/}header") + assert naglowek.get("status") == "deleted" + assert korzen.find(".//{http://www.openarchives.org/OAI/2.0/}metadata") is None +``` + +- [ ] **Krok 6.2: Komenda + FAIL** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q -k getrecord` +Expected: FAIL — podnosi się `NieznanyIdentyfikator`. + +- [ ] **Krok 6.3: Implementacja** + +W `base.py`: + +```python + def pojedynczy(self, uczelnia, model, pk): + """Obiekt należący do tenanta albo ``None``. + + Szuka w NADZBIORZE: rekord niewidoczny nadal istnieje dla OAI — + jako nagrobek. O tym, czy jest żywy, decyduje wywołujący + (``widoczne_pk_ze_strony``). + """ + return z_datestampem(self.przynaleznosc(uczelnia, model)).filter(pk=pk).first() +``` + +W `ProviderPusty` `pojedynczy()` zostaje bez zmian (`return None`). + +W `czasowniki.py`, `_znajdz_rekord` — dołóż czwarty element zwrotki: + +```python + obiekt = provider.pojedynczy(zadanie.uczelnia, model, pk) + if obiekt is None: + break + nagrobek = obiekt.pk not in provider.widoczne_pk_ze_strony( + zadanie.uczelnia, model, [obiekt] + ) + return set_spec, provider, obiekt, nagrobek +``` + +W `_get_record`: + +```python + set_spec, provider, obiekt, nagrobek = _znajdz_rekord(zadanie, identyfikator) + + korzen = etree.Element(f"{{{NS_PMH}}}GetRecord") + rekord = _pod(korzen, "record") + _naglowek(rekord, set_spec, obiekt, zadanie.namespace, usuniety=nagrobek) + if nagrobek: + # Rekord usunięty to sam nagłówek — nie ma czego serializować. + return korzen + + widoczne = provider.zbiory_widocznosci(zadanie.uczelnia, [obiekt]) + ... # dotychczasowa ścieżka BEZ ZMIAN +``` + +⚠️ Blok `widoczne = provider.zbiory_widocznosci(...)` i `kontekst = _kontekst(...)` +przenieś **pod** wczesny zwrot — dla nagrobka są zbędne i kosztują zapytania. + +- [ ] **Krok 6.4: Komenda + PASS** + +```bash +uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q +uv run pytest src/cerif_export/tests/test_oai.py -q +``` +Expected: zielono. `GetRecord` na identyfikatorze spoza tenanta MUSI dalej dawać +`idDoesNotExist` — sprawdza to istniejący `test_oai.py`. + +- [ ] **Krok 6.5: Lint + commit** + +```bash +uv run ruff check src/cerif_export/providers/base.py src/cerif_export/oai/czasowniki.py +uv run ruff format src/cerif_export/providers/base.py src/cerif_export/oai/czasowniki.py +git add src/cerif_export/providers/base.py src/cerif_export/oai/czasowniki.py src/cerif_export/tests/test_nagrobki.py +git commit -m "feat(cerif): GetRecord na usunietym zwraca nagrobek zamiast idDoesNotExist" +``` + +--- + +## Task 7: `Identify` deklaruje `transient` + `earliestDatestamp` z nadzbioru + +**Files:** +- Modify: `src/cerif_export/const.py`, `src/cerif_export/providers/base.py` +- Test: `src/cerif_export/tests/test_nagrobki.py`, `src/cerif_export/tests/test_oai.py` + +- [ ] **Krok 7.1: Failing test** + +```python +@pytest.mark.django_db +def test_identify_deklaruje_transient(uczelnia): + """Deklaracja to obietnica wobec harvestera, nie kosmetyka. + + ``no`` znaczy „nie dowiesz się o usunięciach — rób pełny re-harvest". + ``transient`` znaczy „ogłaszam usunięcia, ale nie gwarantuję, że + nagrobek zostanie na zawsze" — i to jest prawda: husk może zniknąć przy + twardym kasowaniu albo czyszczeniu kosza w fazie 07. + """ + from cerif_export.oai import czasowniki + + korzen = czasowniki.obsluz(_zadanie_dla(uczelnia), {"verb": "Identify"}) + element = korzen.find(".//{http://www.openarchives.org/OAI/2.0/}deletedRecord") + assert element.text == "transient" +``` + +- [ ] **Krok 7.2: Komenda + FAIL** + +Run: `uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q -k identify` +Expected: FAIL — `"no" != "transient"`. + +- [ ] **Krok 7.3: Implementacja** + +`src/cerif_export/const.py:115`: + +```python +# Faza 05b soft-delete: ogłaszamy usunięcia nagłówkiem status="deleted". +# `transient`, nie `persistent`: nie gwarantujemy trwałości nagrobka — +# husk może zniknąć przy twardym kasowaniu albo czyszczeniu kosza (faza 07), +# a sety bez soft-delete (jednostki, projekty) nie mają trwałego śladu. +DELETED_RECORD = "transient" +``` + +W `base.py`, `najstarszy_datestamp` — `self.queryset` → `self.przynaleznosc` +(nagrobek też jest rekordem o dacie i może być najstarszy). + +- [ ] **Krok 7.4: Komenda + PASS** + +```bash +uv run pytest src/cerif_export/tests/test_nagrobki.py -x -q +uv run pytest src/cerif_export/tests/test_oai.py -q +``` + +⚠️ `test_oai.py:228` asertuje `tekst(korzen, "Identify", "deletedRecord") == const.DELETED_RECORD` +— porównuje ze stałą, więc przejdzie automatycznie. Jeśli gdzieś jest zaszyty literał +`"no"`, popraw **test**, nie stałą. + +- [ ] **Krok 7.5: Lint + commit** + +```bash +uv run ruff check src/cerif_export/const.py src/cerif_export/providers/base.py +uv run ruff format src/cerif_export/const.py src/cerif_export/providers/base.py +git add src/cerif_export/const.py src/cerif_export/providers/base.py src/cerif_export/tests/test_nagrobki.py +git commit -m "feat(cerif): Identify deklaruje deletedRecord=transient" +``` + +--- + +## Task 8: Cztery drogi zniknięcia + `from`/`until` + granica strony + +**Files:** +- Test: `src/cerif_export/tests/test_nagrobki.py` + +To task **wyłącznie testowy** — domyka pokrycie ryzyk nazwanych w specu. + +- [ ] **Krok 8.1: Test — cztery drogi zniknięcia dają nagrobek** + +```python +def _ukryj_kosz(praca, uczelnia): + praca.delete() + + +def _ukryj_opt_out(praca, uczelnia): + praca.nie_eksportuj_przez_api = True + praca.save() + + +def _ukryj_status(praca, uczelnia): + from bpp.models import Status_Korekty + + ukryte = list(uczelnia.ukryte_statusy("cerif")) + assert ukryte, ( + "fixture musi mieć status ukryty w kanale cerif — bez tego przypadek " + "nie odtwarza trzeciej drogi zniknięcia" + ) + praca.status_korekty = Status_Korekty.objects.get(pk=ukryte[0]) + praca.save() + + +def _ukryj_odpiecie_autora(praca, uczelnia): + # Skasowanie autorstwa to soft-delete (faza 02) — wiersz zostaje w koszu + # i to on trzyma historyczną atrybucję rekordu do uczelni. + praca.autorzy_set.first().delete() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "ukryj", + [_ukryj_kosz, _ukryj_opt_out, _ukryj_status, _ukryj_odpiecie_autora], + ids=["kosz", "opt_out", "ukryty_status", "odpiecie_autora"], +) +def test_kazda_droga_znikniecia_daje_nagrobek(uczelnia, jednostka, typ_autor, ukryj): + """Cztery drogi, jeden skutek dla harvestera — więc jeden nagrobek. + + Przypadek ``odpiecie_autora`` jest tu najważniejszy: dowodzi, że + ``przynaleznosc`` idzie przez ``global_objects`` modelu autorstwa. + Gdyby szła przez ``objects``, rekord wypadłby z nadzbioru i zniknął + po cichu — czyli wróciłaby dokładnie ta luka, którą faza zamyka. + """ + from model_bakery import baker + + from bpp.models import Autor, Wydawnictwo_Ciagle + from cerif_export import const + from cerif_export.providers import rejestr_providerow + + praca = baker.make(Wydawnictwo_Ciagle) + praca.dodaj_autora(baker.make(Autor), jednostka) + + provider = rejestr_providerow()[const.SET_PUBLICATIONS] + assert not provider.nagrobki(uczelnia, Wydawnictwo_Ciagle).filter( + pk=praca.pk + ).exists(), "rekord widoczny nie może być nagrobkiem" + + ukryj(praca, uczelnia) + + assert provider.nagrobki(uczelnia, Wydawnictwo_Ciagle).filter( + pk=praca.pk + ).exists(), "rekord przestał być widoczny, a nie dostał nagrobka" +``` + +⚠️ Jeśli fixture `uczelnia` nie ma statusu ukrytego w kanale `cerif`, dopisz go +w teście (`uczelnia.ukryte_statusy_cerif` albo pole równoważne — sprawdź +`grep -n "def ukryte_statusy" -A 15 src/bpp/models/uczelnia.py`) zamiast pomijać +przypadek. + +- [ ] **Krok 8.2: Test — `from`/`until` obejmuje nagrobki** + +Nagrobek utworzony „teraz" wpada w okno `od = wczoraj`, a wypada z okna +`do = wczoraj`. Znacznik bierze się z `ostatnio_zmieniony`, który soft-delete +bumpuje (kontrakt PINNED fazy 01). + +- [ ] **Krok 8.3: Test — `resumptionToken` na granicy żywy/nagrobek** + +Utwórz co najmniej `rozmiar + 1` obiektów tak, żeby strona kończyła się +**dokładnie na nagrobku**, przejdź harvest do końca po tokenach i sprawdź: +identyfikatory bez duplikatu (`len(set(...)) == len(...)`) i bez luki +(komplet oczekiwanych). Rozmiar strony wymuś przez `rozmiar=` albo +`const.ROZMIAR_STRONY` — patrz jak robi to `test_oai.py`. + +To tu żyły wcześniejsze bugi `Trunc`/`tzinfo` opisane w `z_datestampem`. + +- [ ] **Krok 8.4: Komenda + PASS** + +```bash +uv run pytest src/cerif_export/tests/test_nagrobki.py -q +uv run pytest src/cerif_export/ -q +``` + +- [ ] **Krok 8.5: Commit** + +```bash +uv run ruff check src/cerif_export/tests/test_nagrobki.py +uv run ruff format src/cerif_export/tests/test_nagrobki.py +git add src/cerif_export/tests/test_nagrobki.py +git commit -m "test(cerif): cztery drogi zniknięcia, okno from/until, granica strony" +``` + +--- + +## Task 9: `/api/v1/usuniete/` + +**Files:** +- Create: `src/api_v1/serializers/usuniete.py`, `src/api_v1/viewsets/usuniete.py`, + `src/api_v1/tests/test_usuniete.py` +- Modify: `src/api_v1/urls.py` + +**Interfaces:** +- Produces: endpoint `GET /api/v1/usuniete/`, pola `model`, `pk`, `usuniety_od`. + +⚠️ **REST znaczy WĘŻEJ niż OAI** (decyzja D5 specu): tu wychodzą wyłącznie rekordy +**z kosza**, nie całe dopełnienie ekspozycji. REST nie składa obietnicy +`deletedRecord`. Napisz to w docstringu viewsetu. + +- [ ] **Krok 9.1: Failing test — endpoint listuje husk, bez treści** + +```python +@pytest.mark.django_db +def test_usuniete_zwraca_nagrobek_bez_tresci(api_client, uczelnia, wydawnictwo_ciagle): + """Nagrobek REST niesie identyfikator, nigdy treść. + + Rekord bywa usuwany właśnie dlatego, że był błędny albo zawierał dane + osobowe — wystawienie huska w całości cofnęłoby skutek usunięcia. + """ + tytul = wydawnictwo_ciagle.tytul_oryginalny + wydawnictwo_ciagle.delete() + + odpowiedz = api_client.get("/api/v1/usuniete/") + assert odpowiedz.status_code == 200 + + tresc = odpowiedz.json() + wyniki = tresc["results"] + assert any( + w["model"] == "wydawnictwo_ciagle" and w["pk"] == wydawnictwo_ciagle.pk + for w in wyniki + ) + assert tytul not in odpowiedz.content.decode(), ( + "treść usuniętego rekordu wyciekła przez endpoint nagrobków" + ) +``` + +⚠️ **Sprawdź, jak istniejące testy `src/api_v1/tests/` budują klienta i uwierzytelnienie** +(bramka `z_bramka_api_v1` + przełącznik `api_v1_dane_bibliograficzne` na `Uczelnia`) +i powiel ten wzorzec zamiast wymyślać `api_client`. + +- [ ] **Krok 9.2: Komenda + FAIL** + +Run: `uv run pytest src/api_v1/tests/test_usuniete.py -x -q` +Expected: FAIL — 404 (brak trasy). + +- [ ] **Krok 9.3: Implementacja — serializer** + +`src/api_v1/serializers/usuniete.py`: + +```python +from rest_framework import serializers + + +class UsunietySerializer(serializers.Serializer): + """Nagrobek: co zniknęło i kiedy — NIGDY treść rekordu. + + Świadomie ``Serializer``, nie ``ModelSerializer``: łączymy wiele modeli + w jedną listę, a każde pole ponad te trzy byłoby wyciekiem danych, + które redakcja usunęła. + """ + + model = serializers.CharField() + pk = serializers.IntegerField() + usuniety_od = serializers.DateTimeField() +``` + +- [ ] **Krok 9.4: Implementacja — viewset** + +`src/api_v1/viewsets/usuniete.py`: + +```python +from rest_framework import viewsets +from rest_framework.response import Response + +from api_v1.serializers.usuniete import UsunietySerializer +from bpp.models import ( + Autor, + Patent, + Praca_Doktorska, + Praca_Habilitacyjna, + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, +) + +#: Modele soft-delete wystawiane jako nagrobki. Klucz to nazwa w odpowiedzi. +MODELE_NAGROBKOW = { + "wydawnictwo_ciagle": Wydawnictwo_Ciagle, + "wydawnictwo_zwarte": Wydawnictwo_Zwarte, + "patent": Patent, + "praca_doktorska": Praca_Doktorska, + "praca_habilitacyjna": Praca_Habilitacyjna, + "autor": Autor, +} + + +class UsunieteViewSet(viewsets.ViewSet): + """Rekordy usunięte (w koszu) — sam identyfikator i znacznik czasu. + + ⚠️ Znaczy WĘŻEJ niż nagrobek OAI-PMH. Tam nagrobek to „przestało być + eksportowane" (dopełnienie ekspozycji), bo ``deletedRecord`` jest + obietnicą wobec harvestera. REST takiej obietnicy nie składa, więc tu + wychodzi wyłącznie kosz. Rekord ukryty przez ``nie_eksportuj_przez_api`` + dostanie nagrobek w OAI, ale NIE pojawi się tutaj (decyzja D5 specu + 2026-08-15). + + Treści rekordu nie wystawiamy nigdy — patrz ``UsunietySerializer``. + """ + + serializer_class = UsunietySerializer + + def list(self, request): + wiersze = [] + for nazwa, model in MODELE_NAGROBKOW.items(): + for pk, usuniety_od in model.deleted_objects.values_list( + "pk", "deleted_at" + ): + wiersze.append( + {"model": nazwa, "pk": pk, "usuniety_od": usuniety_od} + ) + wiersze.sort(key=lambda w: (w["usuniety_od"] is None, w["usuniety_od"])) + return Response({"results": UsunietySerializer(wiersze, many=True).data}) +``` + +⚠️ **Zanim to napiszesz, zweryfikuj dwie rzeczy** i dostosuj kod: +1. `uv run python -c "from bpp.models import Wydawnictwo_Ciagle; print(Wydawnictwo_Ciagle.deleted_objects)"` + — nazwa menedżera kosza i to, czy każdy z sześciu modeli go ma. +2. Czy `Patent`, `Praca_Doktorska`, `Praca_Habilitacyjna` są soft-delete + (faza 02 objęła 5 modeli publikacji). Modele bez kosza **usuń ze słownika** — + nie dopisuj obejść. + +- [ ] **Krok 9.5: Implementacja — routing** + +W `src/api_v1/urls.py`: import `UsunieteViewSet` oraz obok pozostałych rejestracji + +```python +router.register(r"usuniete", UsunieteViewSet, basename="usuniete", grupa=DANE) +``` + +`basename` jest **obowiązkowy** — `ViewSet` bez `queryset` nie ma z czego go wywieść. +`grupa=DANE` (`GrupaApiV1.DANE_BIBLIOGRAFICZNE`) — nowej grupy NIE zakładamy +(Global Constraints). + +- [ ] **Krok 9.6: Komenda + PASS** + +```bash +uv run pytest src/api_v1/tests/test_usuniete.py -q +uv run pytest src/api_v1/ -q +``` + +- [ ] **Krok 9.7: Filtr zakresowy** + +Dopisz test i obsługę parametrów `usuniety_od_after` / `usuniety_od_before` +(konwencja `DateTimeFromToRangeFilter` z pozostałych viewsetów — patrz +`src/api_v1/viewsets/patent.py:32`). Filtrowanie rób na queryset każdego modelu +(`deleted_at__gte` / `deleted_at__lte`), NIE na liście w Pythonie. + +- [ ] **Krok 9.8: Lint + commit** + +```bash +uv run ruff check src/api_v1/serializers/usuniete.py src/api_v1/viewsets/usuniete.py src/api_v1/urls.py src/api_v1/tests/test_usuniete.py +uv run ruff format src/api_v1/serializers/usuniete.py src/api_v1/viewsets/usuniete.py src/api_v1/urls.py src/api_v1/tests/test_usuniete.py +git add src/api_v1/serializers/usuniete.py src/api_v1/viewsets/usuniete.py src/api_v1/urls.py src/api_v1/tests/test_usuniete.py +git commit -m "feat(api_v1): endpoint /usuniete/ — nagrobki bez tresci rekordu" +``` + +--- + +## Task 10: Weryfikacja końcowa fazy + +**Files:** `src/bpp/newsfragments/`, `docs/superpowers/` + +- [ ] **Krok 10.1: Walidacja XSD odpowiedzi z nagrobkami** + +`src/cerif_export/tests/xsd/` zawiera schematy. Znajdź test walidujący odpowiedź +(`grep -rn "xsd\|schema" src/cerif_export/tests/test_oai.py | head`) i **rozszerz go** +o przypadek z nagrobkiem — odpowiedź z `status="deleted"` musi przejść walidację. +Bez tego kroku łatwo wyemitować XML, który agregator odrzuci. + +- [ ] **Krok 10.2: Pełna regresja** + +```bash +uv run pytest src/cerif_export/ src/api_v1/ -q +uv run pytest -m "not playwright" -q +uv run pytest -m playwright -q +npx vitest run +``` +Expected: wszystko zielono. Przy wolnym starcie kontenerów: `TC_MAX_TRIES=600`. + +- [ ] **Krok 10.3: Brak driftu migracji (ta faza NIE dodaje migracji)** + +```bash +uv run python src/manage.py makemigrations --check --dry-run +``` +Expected: zero zgłoszeń dla aplikacji z `src/` (zgłoszenia dla pakietów +zewnętrznych — `favicon`, `flexible_reports`, `siteblog` — są zastane). + +- [ ] **Krok 10.4: Newsfragment** + +`src/bpp/newsfragments/soft-delete-nagrobki.feature.rst`: + +```rst +Repozytorium OAI-PMH ogłasza teraz usunięcia: rekord, który przestał być +eksportowany, wychodzi w harveście jako nagrobek (nagłówek ze statusem +``deleted``) zamiast po prostu zniknąć. Dzięki temu systemy pobierające dane +przyrostowo mogą usunąć go u siebie. Nowy endpoint ``/api/v1/usuniete/`` +udostępnia listę usuniętych rekordów — wyłącznie identyfikator i datę, +bez treści. +``` + +- [ ] **Krok 10.5: Ostrzeżenie operacyjne w dokumentacji przełącznika** + +Znajdź dokumentację `Uczelnia.eksport_cerif_osoby` +(`grep -rn "eksport_cerif_osoby" docs/ src/bpp/models/uczelnia.py`) i dopisz: +wyłączenie przełącznika wystawia nagrobki dla **wszystkich** autorów uczelni +naraz. Zachowanie poprawne (harvester ma je usunąć), ale jednorazowo bardzo +duży wsad — operator musi o tym wiedzieć przed przestawieniem. + +- [ ] **Krok 10.6: Handoff + commit** + +Zaktualizuj `docs/superpowers/HANDOFF-soft-delete-faza-06.md` §5 (faza 05b +przestaje być „do zrobienia") i zacommituj całość: + +```bash +git add src/bpp/newsfragments/soft-delete-nagrobki.feature.rst docs/ +git commit -m "docs(soft-delete): newsfragment fazy 05b + ostrzezenie o eksport_cerif_osoby" +``` + +- [ ] **Krok 10.7: PR** + +```bash +git push -u origin feat/soft-delete-05b +gh pr create --base feat/soft-delete-05 --head feat/soft-delete-05b \ + --title "soft-delete faza 05b — nagrobki dla konsumentów przyrostowych" +``` + +W opisie PR-a wymień: cztery decyzje specu, rozszczepienie przynależność/ekspozycja, +powód paginowania nadzbioru, oraz **wyniki przebiegu lokalnego** — CI nie biegnie +na PR-ach do gałęzi `feat/soft-delete*`, więc to jedyny dowód. + +--- + +## Podsumowanie zakresu + +| dostarczone | | +|---|---| +| `ProviderEncji.przynaleznosc()` + `nagrobki()` | kontrakt rozszczepiający przynależność i ekspozycję | +| `strona()` na nadzbiorze | jeden strumień, jeden kursor keyset, nagrobki w porządku dat | +| `status="deleted"` w ListRecords / ListIdentifiers / GetRecord | bez `` | +| `deletedRecord = "transient"` | prawdziwa deklaracja zamiast `"no"` | +| `/api/v1/usuniete/` | identyfikator + data, bez treści huska | + +**Poza zakresem (świadomie):** zmiana atrybucji tenanta bez śladu w koszu (przepięcie +autorstwa do innej uczelni) nadal znika po cichu; rekordy nigdy-niewidoczne też +dostają nagrobek; REST węższy niż OAI. Wszystkie trzy opisane w specu, sekcja +„Ograniczenia". diff --git a/docs/superpowers/specs/2026-08-15-soft-delete-nagrobki-design.md b/docs/superpowers/specs/2026-08-15-soft-delete-nagrobki-design.md new file mode 100644 index 000000000..58435a5e7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-soft-delete-nagrobki-design.md @@ -0,0 +1,221 @@ +# Soft-delete faza 05b: nagrobki dla konsumentów przyrostowych + +> Spec zatwierdzony 2026-08-15. Wydzielony z fazy 05 decyzją właściciela +> 2026-08-10 (plan fazy 05 miał nagrobki w banerze zakresu i **zero tasków**). +> Termin: **przed fazą 07** — dopóki kasowanie jest rzadkie, luka jest +> teoretyczna; faza 07 czyni kasowanie rutynowym. + +## Problem + +BPP wystawia korpus przyrostowo: OAI-PMH (`/cerif/`, profil OpenAIRE CRIS) +i REST (`/api/v1/`). Konsument, który raz pobrał rekord, **nie ma jak się +dowiedzieć, że rekord przestał być wystawiany** — po prostu przestaje +przychodzić. Kopia po jego stronie zostaje niespójna na zawsze. + +`src/cerif_export/const.py:115` deklaruje dziś `DELETED_RECORD = "no"`. To +nie jest „brak funkcji", tylko **obietnica w `Identify`**: harvester ma +prawo założyć, że usunięć nie ogłaszamy, i nie pytać o nie przyrostowo. + +## Zakres + +**Dwa kanały, nie trzy.** Baner rozszerzenia zakresu z 2026-08-08 wymieniał +„OAI-PMH + odpowiednik w CERIF + REST". W rzeczywistości `cerif_export/urls.py` +ma **jedną** ścieżkę (`OAICerifView`), a w OAI-PMH rekord usunięty to **sam +nagłówek** ze `status="deleted"` i **zero metadanych** — payload CERIF-owy +z definicji nie powstaje. „Nagrobek CERIF" nie jest osobnym bytem. + +W zakresie: **OAI-PMH** (7 niepustych setów) + **REST** (nowy endpoint). +Poza zakresem: zmiana formatu metadanych, kosz w adminie (faza 07), +`SoftDeleteLog` (faza 06). + +## Decyzje (zatwierdzone) + +| # | Decyzja | Uzasadnienie | +|---|---|---| +| D1 | Nagrobek = **dopełnienie ekspozycji**, nie tylko soft-delete | Rekord znika z feedu na cztery sposoby, dla harvestera nierozróżnialne. Nagrobki tylko dla kosza czyniłyby deklarację `deletedRecord` częściowo nieprawdziwą | +| D2 | `deletedRecord = "transient"` | Nie gwarantujemy trwałości nagrobka: husk może zniknąć (twarde kasowanie, czyszczenie kosza w fazie 07), a sety bez soft-delete nie mają trwałego śladu | +| D3 | REST dostaje **osobny endpoint** `/api/v1/usuniete/` z samym identyfikatorem | Symetria z OAI (nagrobek nie niesie treści) i brak ryzyka wycieku huska, który usunięto właśnie dlatego, że był błędny lub zawierał dane osobowe | +| D4 | Nagrobki we **wszystkich 7 niepustych setach** | `deletedRecord` deklaruje się dla CAŁEGO repozytorium; w setach pominiętych deklaracja byłaby nieprawdziwa | +| D5 | REST znaczy **węziej** niż OAI: `/api/v1/usuniete/` to rekordy **z kosza** | REST nie składa obietnicy `deletedRecord`. Rozciąganie dopełnienia na modele API wymaga zdefiniowania reguł ekspozycji per model API — osobna praca | + +### Cztery drogi zniknięcia (uzasadnienie D1) + +`widoczne_wydawnictwa()` (`providers/publikacje.py:85`) odfiltrowuje rekord, gdy: + +1. trafił do kosza (`.objects` pomija husk — faza 02), +2. `nie_eksportuj_przez_api=True`, +3. `status_korekty` zmieniony na ukryty w kanale `cerif`, +4. ostatni autor z tej uczelni odpięty (scope tenanta przez `autor_rekordu_klass`). + +## Architektura + +### Rozszczepienie przynależności i ekspozycji + +Predykat widoczności skleja dziś dwie różne rzeczy. **Dopełniać wolno tylko +ekspozycję.** Naiwne „wszystko minus widoczne" wystawiłoby w multi-hosted +nagrobki dla rekordów **innych uczelni** — `widoczne_jednostki()` filtruje +`uczelnia=uczelnia` bezpośrednio, więc dopełnienie objęłoby cudze jednostki. +To wyciek identyfikatorów i lawina szumu. + +Dochodzi jedna metoda kontraktu providera: + +```python +def przynaleznosc(self, uczelnia, model): + """Rekordy TEGO tenanta — także niewidoczne i te w koszu. + + Wyłącznie atrybucja tenanta; ŻADNYCH reguł ekspozycji + (`nie_eksportuj_przez_api`, `status_korekty`, `widoczna`, `pokazuj`, + przełączniki `Uczelnia.eksport_cerif_*`). Te należą do `queryset()` + i to ich dopełnienie daje nagrobki. + """ +``` + +Nagrobki liczy **klasa bazowa** (providerzy implementują tylko `przynaleznosc`): + +``` +nagrobki(uczelnia, model) = przynaleznosc(uczelnia, model) − widoczne(uczelnia, model) +``` + +**Dwa rodzaje providerów.** Część setów ma atrybucję **własną** (publikacje, +patenty, osoby, jednostki, projekty, finansowanie — bezpośredni FK albo model +autorstwa). Część ma ją **pochodną**: `widoczne_konferencje()` to konferencje +wskazywane przez *widoczne* publikacje, a `widoczni_grantodawcy()` — +instytucje finansujące *widoczne* projekty. + +Dla providerów pochodnych `przynaleznosc` powstaje przez podmianę wewnętrznego +zbioru: „konferencje wskazywane przez publikacje **należące** do tej uczelni" +zamiast „przez publikacje **widoczne**". Dopełnienie daje wtedy nagrobek dla +konferencji, do której prowadziły wyłącznie publikacje, które przestały być +widoczne — i to jest dokładnie pożądane zachowanie, bo taka konferencja realnie +znika z feedu. + +**Przynależność historyczna.** Dla publikacji i patentów `przynaleznosc` +idzie przez `autor_rekordu_klass.global_objects` — czyli **z koszem +autorstw** (modele `*_Autor` mają `BppAutorstwoSoftDeleteMixin` od fazy 02). +Dzięki temu rekord, któremu odpięto ostatniego autora z naszej uczelni, +pozostaje „kiedyś nasz" i dostaje nagrobek, zamiast zniknąć po cichu. +To pokrywa drogę zniknięcia nr 4 w przypadku, gdy autorstwo skasowano; +gdy autorstwo **przepięto** (zmiana jednostki na inną uczelnię), śladu nie +ma i nagrobek nie powstanie — patrz „Ograniczenia". + +### Stronicowanie: jeden strumień, nie dwa + +`ProviderEncji.strona()` stronicuje keysetem po +`(COALESCE(ostatnio_zmieniony, EPOKA), pk)`. **Zmieniamy wyłącznie źródło**: +paginujemy `przynaleznosc` (nadzbiór) zamiast `queryset`. Dla każdej strony +jedno dodatkowe, tanie zapytanie ustala zbiór widocznych PK; obiekt spoza +niego dostaje nagłówek `status="deleted"` bez ``. + +**Dlaczego NIE drugi przebieg po żywych rekordach:** `resumptionToken` niesie +kursor `(datestamp, pk)` i zakłada **jeden** porządek po datestampie. Dwa +strumienie znaczą dwa kursory albo nagrobki poza porządkiem — a wtedy +`from`/`until` przestaje działać przyrostowo, czyli psujemy dokładnie to, +co ta faza naprawia. + +Paginowanie nadzbioru zachowuje niezmiennik: jeden porządek, jeden kursor, +nagrobek to po prostu rekord, dla którego nie budujemy metadanych. Blizny +z `z_datestampem()` (`Trunc` do sekundy, `tzinfo=UTC` — obie zapisane po +realnych duplikatach na granicy strony) działają dalej bez zmian. + +`przynaleznosc()` niesie te same `select_related`/`prefetch_related` co +`queryset()`. Prefetch na husku jest nieszkodliwy, a alternatywa (ponowne +pobranie żywych z prefetchami) dokładałaby zapytanie na stronę. + +### Punkty dotknięcia + +| plik | zmiana | +|---|---| +| `providers/base.py` | `przynaleznosc()` w kontrakcie; `nagrobki()`; `strona()`, `pojedynczy()`, `najstarszy_datestamp()` na nadzbiorze | +| `providers/{publikacje,osoby,patenty,jednostki,projekty,konferencje,finansowanie}.py` | implementacja `przynaleznosc()` | +| `providers/puste.py` | `przynaleznosc()` zwraca pusto (set bez zawartości) | +| `oai/czasowniki.py` | `_naglowek(..., usuniety=False)`; `_dopisz_rekordy` pomija `` dla nagrobka; `_znajdz_rekord` szuka w nadzbiorze | +| `const.py` | `DELETED_RECORD = "transient"` | +| `api_v1/` | nowy viewset + routing `/api/v1/usuniete/` | + +### Zachowanie czasowników OAI + +- **`Identify`** → `transient`. +- **`ListIdentifiers`** → nagrobek to ten sam nagłówek + `status="deleted"`. +- **`ListRecords`** → `
`, + **bez** ``. +- **`GetRecord`** na rekordzie niewidocznym, ale należącym do tenanta → + rekord z nagłówkiem `status="deleted"` i bez metadanych, **zamiast** + dotychczasowego błędu `idDoesNotExist`. Identyfikator spoza tenanta → + `idDoesNotExist` bez zmian. +- **`earliestDatestamp`** liczony z nadzbioru (nagrobek też jest rekordem + o dacie). + +### REST: `/api/v1/usuniete/` + +Read-only endpoint zwracający **wyłącznie** typ, klucz i znacznik czasu — +nigdy treści rekordu: + +```json +{"model": "wydawnictwo_ciagle", "pk": 123, "usuniety_od": "2026-08-13T10:00:00Z"} +``` + +Źródło: `deleted_objects` modeli soft-delete (publikacje z fazy 02, `Autor` +z fazy 04). Filtr zakresowy po znaczniku czasu, spójny konwencją +z istniejącymi `DateTimeFromToRangeFilter` w `api_v1/viewsets/`. Scope +tenanta jak w pozostałych viewsetach (`Uczelnia.objects.get_for_request`). + +## Ograniczenia i skutki uboczne (świadome) + +- **Rekordy nigdy-niewidoczne też dostaną nagrobek.** Rekord od zawsze + oznaczony `nie_eksportuj_przez_api` trafi do nagrobków, mimo że harvester + nigdy go nie miał. Wg specyfikacji OAI to nieszkodliwe („niedostępny"), + ale przy pierwszym pełnym harveście po wdrożeniu daje jednorazowy wolumen + szumu. Alternatywa (trwały ślad „był wyeksportowany") wymagałaby nowej + tabeli i zapisu na ścieżce read-only harvestu — odrzucona jako + nieproporcjonalna. +- **⚠️ Wyłączenie `Uczelnia.eksport_cerif_osoby` wystawi nagrobki dla + WSZYSTKICH autorów uczelni naraz.** `widoczni_autorzy()` zwraca wtedy + `Autor.objects.none()`, więc dopełnienie obejmuje cały zbiór. To jest + zachowanie **poprawne** — harvester ma te osoby usunąć, a przełącznik + właśnie o to prosi — ale operator musi wiedzieć, że przestawienie go + produkuje jednorazowy, bardzo duży wsad nagrobków. Do odnotowania + w dokumentacji przełącznika. +- **Zmiana atrybucji tenanta bez śladu w koszu nie da nagrobka.** Gdy + projekt zmieni `jednostka` na inną uczelnię albo autorstwo zostanie + *przepięte* (a nie skasowane), rekord wypada z `przynaleznosc` i znika + cicho — tak jak dziś. Pokrycie tego wymagałoby historii atrybucji. +- **Sety `projects` i `funding` w praktyce nie wygenerują nagrobków.** + `widoczne_projekty()` i `widoczne_finansowania()` to czysta atrybucja bez + reguł ekspozycji, a modele nie mają soft-delete → dopełnienie jest puste. + Kontrakt i tak implementujemy (spójność, gotowość na przyszłe reguły). + Uwaga: **`orgunits` nagrobki wygeneruje**, ale wyłącznie przez `Jednostka` + (reguły `widoczna` i `nie_eksportuj_przez_api`). Trzeci model tego setu, + `Instytucja_Finansujaca`, ma widoczność + (`finansowanie__projekt__jednostka__uczelnia`) **równą** atrybucji, więc + jego dopełnienie też jest puste — korekta ustalona przy pisaniu planu + 2026-08-16. +- **REST węższy niż OAI** (D5): rekord ukryty przez `nie_eksportuj_przez_api` + dostanie nagrobek w OAI, ale nie pojawi się w `/api/v1/usuniete/`. + +## Testy + +Krytyczne (bez nich faza nie jest gotowa): + +1. **Izolacja tenantów** — nagrobki uczelni A nigdy nie zawierają rekordów + uczelni B. To ryzyko, które dopełnienie wnosi wprost. +2. **Cztery drogi zniknięcia → cztery nagrobki** — kosz, `nie_eksportuj_przez_api`, + ukryty `status_korekty`, skasowane autorstwo ostatniego autora z uczelni. +3. **`resumptionToken` przez granicę żywy/nagrobek** — bez duplikatu i bez + luki; osobno przypadek, gdy strona kończy się dokładnie na nagrobku. + To tu żyły wcześniejsze bugi `Trunc`/`tzinfo`. +4. **`from`/`until` obejmuje nagrobki** — nagrobek wpada w okno po + `ostatnio_zmieniony`, tak samo jak rekord żywy. + +Pozostałe: `Identify` mówi `transient`; `ListRecords` nie emituje +`` dla nagrobka i wynik waliduje się schematem OAI-PMH; +`GetRecord` na usuniętym zwraca nagrobek zamiast `idDoesNotExist`, a na +cudzym — nadal `idDoesNotExist`; `/api/v1/usuniete/` nie wypuszcza treści +huska i respektuje filtr zakresowy oraz scope tenanta. + +## Zależności + +- Faza 02 (publikacje `SoftDeleteModel`, kosz autorstw), faza 04 (`Autor`). +- Kontrakt PINNED z fazy 01: soft-delete bumpuje `ostatnio_zmieniony` — + bez tego nagrobek nie wpadłby w okno `from`/`until`. +- **Nie** zależy od fazy 05a (wycofanie z PBN) ani od `SoftDeleteLog` + z fazy 06. diff --git a/src/api_v1/serializers/usuniete.py b/src/api_v1/serializers/usuniete.py new file mode 100644 index 000000000..35861a5b7 --- /dev/null +++ b/src/api_v1/serializers/usuniete.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + + +class UsunietySerializer(serializers.Serializer): + """Nagrobek: co zniknęło i kiedy — NIGDY treść rekordu. + + Świadomie ``Serializer``, nie ``ModelSerializer``: łączymy wiele modeli + w jedną listę, a każde pole ponad te trzy byłoby wyciekiem danych, + które redakcja usunęła. + """ + + model = serializers.CharField() + pk = serializers.IntegerField() + usuniety_od = serializers.DateTimeField() diff --git a/src/api_v1/tests/test_usuniete.py b/src/api_v1/tests/test_usuniete.py new file mode 100644 index 000000000..c8d40e17f --- /dev/null +++ b/src/api_v1/tests/test_usuniete.py @@ -0,0 +1,240 @@ +"""Testy endpointu ``/api/v1/usuniete/`` — nagrobki dla klientów REST.""" + +from datetime import timedelta +from urllib.parse import urlencode + +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +def test_usuniete_zwraca_nagrobek_bez_tresci(api_client, wydawnictwo_ciagle): + """Nagrobek REST niesie identyfikator, nigdy treść. + + Rekord bywa usuwany właśnie dlatego, że był błędny albo zawierał dane + osobowe — wystawienie huska w całości cofnęłoby skutek usunięcia. + """ + tytul = wydawnictwo_ciagle.tytul_oryginalny + pk = wydawnictwo_ciagle.pk + wydawnictwo_ciagle.delete() + + odpowiedz = api_client.get(reverse("api_v1:usuniete-list")) + assert odpowiedz.status_code == 200 + + wyniki = odpowiedz.json()["results"] + assert any(w["model"] == "wydawnictwo_ciagle" and w["pk"] == pk for w in wyniki), ( + f"brak nagrobka dla skasowanego rekordu {pk}: {wyniki}" + ) + + assert tytul not in odpowiedz.content.decode(), ( + "treść usuniętego rekordu wyciekła przez endpoint nagrobków" + ) + + +@pytest.mark.django_db +def test_usuniete_pomija_rekordy_zywe(api_client, wydawnictwo_ciagle): + """Endpoint mówi „co zniknęło", więc żywy rekord nie ma tu czego szukać.""" + odpowiedz = api_client.get(reverse("api_v1:usuniete-list")) + assert odpowiedz.status_code == 200 + assert odpowiedz.json()["results"] == [] + + +@pytest.mark.django_db +def test_usuniete_filtruje_po_zakresie_dat(api_client, wydawnictwo_ciagle): + """Klient przyrostowy pyta „co zniknęło od ostatniego razu". + + Bez filtra zakresowego musiałby pobierać cały kosz przy każdym + odpytaniu — czyli dokładnie ten pełny re-harvest, którego ta faza + ma go pozbawić. + """ + from bpp.models import Wydawnictwo_Ciagle + + pk = wydawnictwo_ciagle.pk + wydawnictwo_ciagle.delete() + + usuniety_od = Wydawnictwo_Ciagle.deleted_objects.get(pk=pk).deleted_at + przed = usuniety_od - timedelta(hours=1) + po = usuniety_od + timedelta(hours=1) + + url = reverse("api_v1:usuniete-list") + + def wyniki(parametr, moment): + # ``urlencode``, bo offset strefy ``+00:00`` w surowym query stringu + # rozkodowałby się jako spacja i data przestałaby się parsować. + odpowiedz = api_client.get(f"{url}?{urlencode({parametr: moment.isoformat()})}") + assert odpowiedz.status_code == 200, odpowiedz.content + return odpowiedz.json()["results"] + + assert wyniki("usuniety_od_after", przed) + assert wyniki("usuniety_od_before", po) + assert not wyniki("usuniety_od_after", po) + assert not wyniki("usuniety_od_before", przed) + + +@pytest.mark.django_db +def test_usuniete_odrzuca_bledna_date(api_client): + """Nieparsowalna data to błąd klienta, nie cichy brak filtra. + + Zignorowanie parametru dałoby odpowiedź wyglądającą poprawnie, a + zawierającą cały kosz — konsument przyrostowy przetworzyłby ją jako + „to wszystko zniknęło od wczoraj". + """ + url = reverse("api_v1:usuniete-list") + odpowiedz = api_client.get(f"{url}?usuniety_od_after=wczoraj") + assert odpowiedz.status_code == 400 + + +@pytest.mark.django_db +def test_usuniete_sortuje_od_najstarszego(api_client, wydawnictwo_ciagle, patent): + """Porządek rosnący po dacie — konsument przesuwa kursor do przodu. + + Kasujemy po kolei i polegamy na zegarze zamiast przestawiać + ``deleted_at`` — patrz uzasadnienie przy fixture ``kosz_szesciu``. + """ + from bpp.models import Patent, Wydawnictwo_Ciagle + + wydawnictwo_ciagle.delete() + patent.delete() + + assert ( + Wydawnictwo_Ciagle.deleted_objects.get(pk=wydawnictwo_ciagle.pk).deleted_at + < Patent.deleted_objects.get(pk=patent.pk).deleted_at + ), "oba kasowania trafiły w ten sam znacznik — test nie bada kolejności" + + wyniki = api_client.get(reverse("api_v1:usuniete-list")).json()["results"] + modele = [w["model"] for w in wyniki] + assert modele == ["wydawnictwo_ciagle", "patent"], wyniki + + +@pytest.fixture +def kosz_szesciu(db): + """Sześć nagrobków w dwóch modelach, skasowanych po kolei. + + Znaczników ``deleted_at`` NIE przestawiamy: ``BppSoftDeleteQuerySet`` + słusznie blokuje ``.update(deleted_at=...)`` (omijałoby ``post_save``, + kaskadę ``*_Autor``, ``SoftDeleteLog`` i reversion), a ``Autor`` ten gate + dziedziczy. Zamiast obchodzić zabezpieczenie w teście, opieramy się na + zegarze: każde ``.delete()`` to osobna runda do bazy, więc znaczniki + wychodzą różne. Fixture to **sprawdza** i pada głośno, gdyby kiedyś + zaczęły się zlewać — zamiast po cichu produkować flaka. + + Zwraca listę ``(model, pk)`` w oczekiwanym porządku odpowiedzi. + """ + from model_bakery import baker + + from bpp.models import Autor, Wydawnictwo_Ciagle + + utworzone = [] + for numer in range(6): + if numer % 2: + obiekt = baker.make(Autor, nazwisko=f"Nazwisko{numer}", imiona="Jan") + nazwa, model = "autor", Autor + else: + obiekt = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny=f"Praca {numer}") + nazwa, model = "wydawnictwo_ciagle", Wydawnictwo_Ciagle + obiekt.delete() + utworzone.append( + (nazwa, obiekt.pk, model.deleted_objects.get(pk=obiekt.pk).deleted_at) + ) + + znaczniki = [wiersz[2] for wiersz in utworzone] + assert len(set(znaczniki)) == len(znaczniki), ( + "kasowania zlały się w jeden znacznik czasu — test kolejności stron " + f"nie miałby czego sprawdzać: {znaczniki}" + ) + assert znaczniki == sorted(znaczniki), "zegar cofnął się w trakcie fixture" + + return [(nazwa, pk) for nazwa, pk, _ in utworzone] + + +@pytest.mark.django_db +def test_usuniete_stronicuje(api_client, kosz_szesciu): + """Odpowiedź jest stronicowana — inaczej duży kosz idzie jednym ciosem.""" + odpowiedz = api_client.get(reverse("api_v1:usuniete-list") + "?limit=2") + assert odpowiedz.status_code == 200 + + tresc = odpowiedz.json() + assert tresc["count"] == 6, tresc + assert len(tresc["results"]) == 2 + assert tresc["next"], "brak linku do kolejnej strony" + assert tresc["previous"] is None + + +@pytest.mark.django_db +def test_usuniete_przejscie_stron_nie_gubi_i_nie_dubluje(api_client, kosz_szesciu): + """Przejście po ``next`` oddaje komplet, w porządku dat, bez powtórek.""" + url = reverse("api_v1:usuniete-list") + "?limit=2" + zebrane = [] + daty = [] + + for _ in range(20): + tresc = api_client.get(url).json() + zebrane.extend((w["model"], w["pk"]) for w in tresc["results"]) + daty.extend(w["usuniety_od"] for w in tresc["results"]) + if not tresc["next"]: + break + url = tresc["next"] + else: + raise AssertionError("stronicowanie się nie zakończyło") + + assert len(zebrane) == len(set(zebrane)), f"rekord wyszedł dwa razy: {zebrane}" + assert zebrane == kosz_szesciu, "kolejność albo komplet się nie zgadza" + assert daty == sorted(daty), "porządek dat rozjechał się na granicy strony" + + +@pytest.mark.django_db +def test_usuniete_stronicowanie_wspolpracuje_z_filtrem(api_client, kosz_szesciu): + """``count`` liczy przefiltrowany zbiór, nie cały kosz. + + Gdyby ``count`` szedł po całości, klient przyrostowy stronicowałby + w nieskończoność po pustych stronach. + """ + from bpp.models import Autor + + nazwa, pk = kosz_szesciu[3] + assert nazwa == "autor" + moment = Autor.deleted_objects.get(pk=pk).deleted_at + + url = reverse("api_v1:usuniete-list") + tresc = api_client.get( + f"{url}?{urlencode({'usuniety_od_after': moment.isoformat(), 'limit': 2})}" + ).json() + + # Rekordy 3, 4 i 5 (licząc od zera) mają datę >= momentowi rekordu 3. + assert tresc["count"] == 3, tresc + assert len(tresc["results"]) == 2 + + +@pytest.mark.django_db(transaction=False) +def test_usuniete_tnie_strone_w_bazie_a_nie_w_pythonie(api_client, kosz_szesciu): + """``LIMIT``/``OFFSET`` muszą zejść do SQL-a. + + To jedyny powód, dla którego endpoint składa ``UNION`` zamiast sklejać + sześć list w Pythonie. Gdyby slice wykonywał się po stronie aplikacji, + każde żądanie ciągnęłoby CAŁY kosz — a przy stronicowaniu klient robi + N żądań zamiast jednego, więc łączna praca byłaby WIĘKSZA niż przed + wprowadzeniem stron. Ten test pilnuje, żeby optymalizacja nie + zdegradowała się po cichu do pesymalizacji. + """ + from django.db import connection + from django.test.utils import CaptureQueriesContext + + with CaptureQueriesContext(connection) as zapytania: + odpowiedz = api_client.get(reverse("api_v1:usuniete-list") + "?limit=2") + + assert odpowiedz.status_code == 200 + assert len(odpowiedz.json()["results"]) == 2 + + # Bez filtra na ``startswith("SELECT")``: złączone zapytanie zaczyna się + # od nawiasu (``(SELECT ...) UNION ALL (SELECT ...)``), więc taki warunek + # przepuszczałby wyłącznie zapytanie ``COUNT``. + zlaczone = [ + z["sql"] for z in zapytania.captured_queries if "UNION" in z["sql"].upper() + ] + assert zlaczone, "brak złączonego zapytania — UNION się nie wykonał" + + z_limitem = [s for s in zlaczone if "LIMIT 2" in s.upper()] + assert z_limitem, ( + "złączone zapytanie poszło bez LIMIT-u — strona jest wycinana " + f"w Pythonie po pobraniu całego kosza:\n{zlaczone}" + ) diff --git a/src/api_v1/urls.py b/src/api_v1/urls.py index 714a7e13a..e1732b47e 100644 --- a/src/api_v1/urls.py +++ b/src/api_v1/urls.py @@ -31,6 +31,7 @@ Typ_KBNViewSet, ) from api_v1.viewsets.szukaj import SzukajViewSet +from api_v1.viewsets.usuniete import UsunieteViewSet from api_v1.viewsets.wydawca import Poziom_WydawcyViewSet, WydawcaViewSet from api_v1.viewsets.wydawnictwo_ciagle import ( Wydawnictwo_Ciagle_AutorViewSet, @@ -185,6 +186,12 @@ def get_api_root_view(self, api_urls=None): router.register(r"tytul", TytulViewSet, grupa=DANE) router.register(r"autor_jednostka", Autor_JednostkaViewSet, grupa=DANE) +# Nagrobki (faza 05b soft-delete). ``basename`` jest obowiązkowy — ``ViewSet`` +# bez ``queryset`` nie ma z czego go wywieść. Pod istniejącą grupą DANE: +# nowa ``GrupaApiV1`` wymagałaby pola ``api_v1_`` na ``Uczelnia``, +# czyli migracji, a ta faza migracji nie dodaje. +router.register(r"usuniete", UsunieteViewSet, basename="usuniete", grupa=DANE) + # # Wyszukiwanie — kosztowne, objęte osobnym limitem zapytań # diff --git a/src/api_v1/viewsets/usuniete.py b/src/api_v1/viewsets/usuniete.py new file mode 100644 index 000000000..6aae5c51e --- /dev/null +++ b/src/api_v1/viewsets/usuniete.py @@ -0,0 +1,154 @@ +from django.db.models import CharField, Value +from rest_framework import viewsets +from rest_framework.exceptions import ValidationError +from rest_framework.fields import DateTimeField +from rest_framework.permissions import AllowAny + +from api_v1.pagination import BppLimitOffsetPagination +from api_v1.serializers.usuniete import UsunietySerializer +from bpp.models import ( + Autor, + Patent, + Praca_Doktorska, + Praca_Habilitacyjna, + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, +) + +#: Modele soft-delete wystawiane jako nagrobki. Klucz to nazwa w odpowiedzi. +#: Wszystkie sześć potwierdzone jako ``SoftDeleteModel`` (fazy 02 i 04) — +#: model bez ``deleted_objects`` NIE ma tu prawa się znaleźć, bo endpoint +#: milknie wtedy dopiero w produkcji. +MODELE_NAGROBKOW = { + "wydawnictwo_ciagle": Wydawnictwo_Ciagle, + "wydawnictwo_zwarte": Wydawnictwo_Zwarte, + "patent": Patent, + "praca_doktorska": Praca_Doktorska, + "praca_habilitacyjna": Praca_Habilitacyjna, + "autor": Autor, +} + +#: Parametr zapytania -> lookup na ``deleted_at``. Nazwy jak +#: ``DateTimeFromToRangeFilter`` w pozostałych viewsetach +#: (``?ostatnio_zmieniony_after=``), żeby klient nie musiał się uczyć +#: drugiej konwencji. +FILTRY_ZAKRESU = { + "usuniety_od_after": "deleted_at__gte", + "usuniety_od_before": "deleted_at__lte", +} + +#: Nazwa kolumny z etykietą modelu w złączonym zapytaniu. Nie może kolidować +#: z żadnym polem sześciu modeli, bo ``annotate`` podniósłby wtedy błąd. +KOLUMNA_MODELU = "_etykieta_modelu" + + +class UsunieteViewSet(viewsets.ViewSet): + """Rekordy usunięte (w koszu) — sam identyfikator i znacznik czasu. + + ⚠️ Znaczy WĘŻEJ niż nagrobek OAI-PMH. Tam nagrobek to „przestało być + eksportowane" (dopełnienie ekspozycji), bo ``deletedRecord`` jest + obietnicą wobec harvestera. REST takiej obietnicy nie składa, więc tu + wychodzi wyłącznie kosz. Rekord ukryty przez ``nie_eksportuj_przez_api`` + dostanie nagrobek w OAI, ale NIE pojawi się tutaj (decyzja D5 specu + 2026-08-15). + + Treści rekordu nie wystawiamy nigdy — patrz ``UsunietySerializer``. + + Sortowanie rosnąco po dacie usunięcia: konsument przyrostowy przesuwa + kursor do przodu, więc najstarsze musi iść pierwsze. + """ + + serializer_class = UsunietySerializer + pagination_class = BppLimitOffsetPagination + + # Domyślne ``DjangoModelPermissionsOrAnonReadOnly`` wymaga ``queryset`` + # albo ``get_queryset()``, a ten viewset łączy sześć modeli i żadnego + # pojedynczego querysetu nie ma. Dla GET tamta klasa i tak nie żąda + # uprawnień (mapa uprawnień ma dla odczytu pustą listę), więc ``AllowAny`` + # odtwarza zachowanie pozostałych endpointów DANE, nie rozluźniając go. + # Realną kontrolą ekspozycji jest bramka ``z_bramka_api_v1(grupa=DANE)`` + # z ``api_v1/urls.py`` i przełącznik ``Uczelnia.api_v1_dane_bibliograficzne``. + permission_classes = [AllowAny] + + def get_queryset(self): + """Jeden queryset ``UNION`` ponad sześcioma modelami kosza. + + DLACZEGO UNION, A NIE SKLEJANIE LIST W PYTHONIE: stronicowanie ma + sens tylko wtedy, gdy schodzi do bazy. Gdyby każde żądanie pobierało + cały kosz i dopiero potem wycinało z niego stronę, koszt jednej + odpowiedzi zostałby ten sam co bez stronicowania — a klient robiłby + teraz N żądań zamiast jednego, więc **łączna** praca by wzrosła. + Tak `ORDER BY` i `LIMIT/OFFSET` wykonuje PostgreSQL, a wraca dokładnie + tyle wierszy, ile mieści strona. + + Kolumny są trzy i we wszystkich sześciu gałęziach mają ten sam + kształt (wymóg ``UNION``): etykieta modelu, klucz główny, data. + Etykieta jest stałą wstrzykniętą przez ``Value`` — inaczej po + złączeniu nie dałoby się odróżnić, z którego modelu pochodzi wiersz + (klucze główne kolidują między modelami). + + Sortowanie ``(deleted_at, etykieta, pk)``: sama data nie wystarcza, + bo dwa rekordy skasowane w tej samej mikrosekundzie miałyby + niezdeterminowaną kolejność, a to na granicy strony znaczy zgubiony + albo zdublowany wiersz. PostgreSQL sortuje ``NULL`` na końcu przy + ``ASC``, więc wiersze sprzed wprowadzenia ``deleted_at`` lądują tam, + gdzie wcześniej stawiał je sort w Pythonie. + """ + warunki = self._warunki_zakresu(self.request) + + galezie = [ + model.deleted_objects.filter(**warunki) + # ``order_by()`` bez argumentów CZYŚCI porządek domyślny modelu. + # Bez tego każda gałąź wnosi swoje ``Meta.ordering`` + # (``Autor.sort``, ``Praca_Doktorska.rok, tytul_oryginalny``) — + # sortowanie po kolumnach, których nawet nie wybieramy, w wyniku + # i tak nadpisane przez ``ORDER BY`` całości. Czysty koszt, a przy + # tym kolumny spoza listy SELECT-a wewnątrz ``UNION`` to + # konstrukcja, której nie każdy silnik przyjmie. + .order_by() + .annotate(**{KOLUMNA_MODELU: Value(nazwa, output_field=CharField())}) + .values_list(KOLUMNA_MODELU, "pk", "deleted_at") + for nazwa, model in MODELE_NAGROBKOW.items() + ] + + zlaczone = galezie[0].union(*galezie[1:], all=True) + return zlaczone.order_by("deleted_at", KOLUMNA_MODELU, "pk") + + def list(self, request): + queryset = self.get_queryset() + + strona = self.paginator.paginate_queryset(queryset, request, view=self) + wiersze = [ + {"model": nazwa, "pk": pk, "usuniety_od": usuniety_od} + for nazwa, pk, usuniety_od in strona + ] + return self.paginator.get_paginated_response( + UsunietySerializer(wiersze, many=True).data + ) + + @property + def paginator(self): + """Paginator instancji — ``ViewSet`` (w odróżnieniu od + ``GenericViewSet``) nie dostaje go z gotowej implementacji.""" + if not hasattr(self, "_paginator"): + self._paginator = self.pagination_class() + return self._paginator + + def _warunki_zakresu(self, request): + """Przetłumacz parametry zakresu na filtry ``deleted_at``. + + Data nieparsowalna daje 400, nie ciche pominięcie filtra: klient + przyrostowy dostałby wtedy cały kosz i uznał, że to wszystko + zniknęło od jego ostatniego odpytania. + """ + pole = DateTimeField() + warunki = {} + for parametr, lookup in FILTRY_ZAKRESU.items(): + wartosc = request.query_params.get(parametr) + if not wartosc: + continue + try: + warunki[lookup] = pole.to_internal_value(wartosc) + except ValidationError as wyjatek: + raise ValidationError({parametr: wyjatek.detail}) from wyjatek + return warunki diff --git a/src/bpp/newsfragments/soft-delete-nagrobki.feature.rst b/src/bpp/newsfragments/soft-delete-nagrobki.feature.rst new file mode 100644 index 000000000..213a4b280 --- /dev/null +++ b/src/bpp/newsfragments/soft-delete-nagrobki.feature.rst @@ -0,0 +1,6 @@ +Repozytorium OAI-PMH ogłasza teraz usunięcia: rekord, który przestał być +eksportowany, wychodzi w harveście jako nagrobek (nagłówek ze statusem +``deleted``) zamiast po prostu zniknąć. Dzięki temu systemy pobierające dane +przyrostowo mogą usunąć go u siebie. Nowy endpoint ``/api/v1/usuniete/`` +udostępnia listę usuniętych rekordów — wyłącznie identyfikator i datę, +bez treści. diff --git a/src/cerif_export/const.py b/src/cerif_export/const.py index 6c11aeb2c..abff255e9 100644 --- a/src/cerif_export/const.py +++ b/src/cerif_export/const.py @@ -112,4 +112,8 @@ def nazwa_setu(set_spec: str) -> str: TOKEN_TTL = 24 * 60 * 60 TOKEN_SALT = "cerif_export.resumption" -DELETED_RECORD = "no" +# Faza 05b soft-delete: ogłaszamy usunięcia nagłówkiem status="deleted". +# `transient`, nie `persistent`: nie gwarantujemy trwałości nagrobka — +# husk może zniknąć przy twardym kasowaniu albo czyszczeniu kosza (faza 07), +# a sety bez soft-delete (jednostki, projekty) nie mają trwałego śladu. +DELETED_RECORD = "transient" diff --git a/src/cerif_export/oai/czasowniki.py b/src/cerif_export/oai/czasowniki.py index 8d5e71eab..1e8cece90 100644 --- a/src/cerif_export/oai/czasowniki.py +++ b/src/cerif_export/oai/czasowniki.py @@ -301,14 +301,19 @@ def _get_record(zadanie, argumenty): raise BlednyArgument("GetRecord wymaga argumentu metadataPrefix") _sprawdz_prefix(prefix) - set_spec, provider, obiekt = _znajdz_rekord(zadanie, identyfikator) - - widoczne = provider.zbiory_widocznosci(zadanie.uczelnia, [obiekt]) - kontekst = _kontekst(zadanie, widoczne) + set_spec, provider, obiekt, nagrobek = _znajdz_rekord(zadanie, identyfikator) korzen = etree.Element(f"{{{NS_PMH}}}GetRecord") rekord = _pod(korzen, "record") - _naglowek(rekord, set_spec, obiekt, zadanie.namespace) + _naglowek(rekord, set_spec, obiekt, zadanie.namespace, usuniety=nagrobek) + if nagrobek: + # Rekord usunięty to sam nagłówek — nie ma czego serializować. + # Zbiory widoczności i kontekst liczymy DOPIERO tutaj, bo dla + # nagrobka byłyby zbędnymi zapytaniami. + return korzen + + widoczne = provider.zbiory_widocznosci(zadanie.uczelnia, [obiekt]) + kontekst = _kontekst(zadanie, widoczne) # GetRecord dotyczy jednego rekordu — pominięcie go dałoby odpowiedź # niezgodną ze schematem, więc tutaj błąd serializacji propaguje się @@ -353,8 +358,8 @@ def _lista(zadanie, argumenty, nazwa, z_metadanymi): if z_metadanymi: _dopisz_rekordy(korzen, zadanie, rejestr, pary, namespace) else: - for biezacy_set, obiekt in pary: - _naglowek(korzen, biezacy_set, obiekt, namespace) + for biezacy_set, (obiekt, nagrobek) in pary: + _naglowek(korzen, biezacy_set, obiekt, namespace, usuniety=nagrobek) _dopisz_token(korzen, argumenty, kolejny, set_spec, prefix, od, do) return korzen @@ -401,10 +406,14 @@ def _parametry_listy(argumenty): def _zbierz_strone(uczelnia, rejestr, set_spec, od, do, kursor, rozmiar=None): - """Zbierz stronę ``[(setSpec, obiekt), ...]`` i kolejny kursor. + """Zbierz stronę ``[(setSpec, (obiekt, czy_nagrobek)), ...]`` i kolejny kursor. Bez argumentu ``set`` harvest idzie przez wszystkie sety po kolei; pozycję niesie ``Kursor.slug``, bo każdy slug należy do dokładnie jednego setu. + + Od fazy 05b provider zwraca pary ``(obiekt, czy_nagrobek)`` — żywe rekordy + i nagrobki jednym strumieniem. Kursor liczymy zawsze z GOŁEGO obiektu, + bo czyta ``ADNOTACJA_TS`` i ``pk``. """ rozmiar = rozmiar or const.ROZMIAR_STRONY kolejnosc = [set_spec] if set_spec else list(const.WSZYSTKIE_SETY) @@ -426,7 +435,7 @@ def _zbierz_strone(uczelnia, rejestr, set_spec, od, do, kursor, rozmiar=None): # rozmiar=1, przycinała wynik do zera i wywalała się na # `obiekty[-1]`.) if _cos_zostalo(uczelnia, rejestr, kolejnosc, pozycja, od, do): - return zebrane, _kursor_dla(zebrane[-1][1]) + return zebrane, _kursor_dla(zebrane[-1][1][0]) return zebrane, None biezacy_set = kolejnosc[pozycja] @@ -435,17 +444,17 @@ def _zbierz_strone(uczelnia, rejestr, set_spec, od, do, kursor, rozmiar=None): # Nadmiarowy element to sonda: jego obecność mówi, że w tym secie # jest jeszcze co najmniej jeden rekord, więc trzeba wydać token. - obiekty, _ = provider.strona( + oznaczone, _ = provider.strona( uczelnia, od=od, do=do, kursor=wewnetrzny, rozmiar=brakuje + 1 ) - obiekty = list(obiekty) + oznaczone = list(oznaczone) - if len(obiekty) > brakuje: - obiekty = obiekty[:brakuje] - zebrane.extend((biezacy_set, obiekt) for obiekt in obiekty) - return zebrane, _kursor_dla(obiekty[-1]) + if len(oznaczone) > brakuje: + oznaczone = oznaczone[:brakuje] + zebrane.extend((biezacy_set, para) for para in oznaczone) + return zebrane, _kursor_dla(oznaczone[-1][0]) - zebrane.extend((biezacy_set, obiekt) for obiekt in obiekty) + zebrane.extend((biezacy_set, para) for para in oznaczone) return zebrane, None @@ -506,12 +515,22 @@ def _dopisz_token(korzen, argumenty, kolejny, set_spec, prefix, od, do): def _dopisz_rekordy(korzen, zadanie, rejestr, pary, namespace): pominiete = 0 - for biezacy_set, obiekty in _wg_setu(pary): + for biezacy_set, oznaczone in _wg_setu(pary): provider = rejestr[biezacy_set] - widoczne = provider.zbiory_widocznosci(zadanie.uczelnia, obiekty) + # Zbiory widoczności liczymy WYŁĄCZNIE dla żywych rekordów: nagrobek + # nie jest serializowany, więc jego encje sąsiadujące nie mają gdzie + # się osadzić, a husk w partii tylko poszerzałby zapytania. + zywe = [obiekt for obiekt, nagrobek in oznaczone if not nagrobek] + widoczne = provider.zbiory_widocznosci(zadanie.uczelnia, zywe) kontekst = _kontekst(zadanie, widoczne) - for obiekt in obiekty: + for obiekt, nagrobek in oznaczone: + if nagrobek: + # Nagrobek PRZED serializacją — nie ma czego serializować. + rekord = _pod(korzen, "record") + _naglowek(rekord, biezacy_set, obiekt, namespace, usuniety=True) + continue + identyfikator = identyfikatory.zbuduj(namespace, obiekt) serializuj = serializer_dla(identyfikatory.slug_dla(obiekt)) element = _bezpiecznie( @@ -536,13 +555,17 @@ def _dopisz_rekordy(korzen, zadanie, rejestr, pary, namespace): def _wg_setu(pary): - """Pogrupuj ``[(setSpec, obiekt)]`` zachowując kolejność setów.""" + """Pogrupuj ``[(setSpec, para)]`` zachowując kolejność setów. + + ``para`` to ``(obiekt, czy_nagrobek)`` — grupujemy je w całości, bo + ``_dopisz_rekordy`` potrzebuje obu członów. + """ grupy = [] - for set_spec, obiekt in pary: + for set_spec, para in pary: if grupy and grupy[-1][0] == set_spec: - grupy[-1][1].append(obiekt) + grupy[-1][1].append(para) else: - grupy.append((set_spec, [obiekt])) + grupy.append((set_spec, [para])) return grupy @@ -550,7 +573,13 @@ def _wg_setu(pary): def _znajdz_rekord(zadanie, identyfikator): - """Zwróć ``(setSpec, provider, obiekt)`` albo podnieś idDoesNotExist.""" + """Zwróć ``(setSpec, provider, obiekt, czy_nagrobek)`` albo idDoesNotExist. + + ``idDoesNotExist`` zostaje dla identyfikatorów spoza tenanta i spoza + repozytorium. Rekord, który do tenanta NALEŻY, ale przestał być + wystawiany, wychodzi jako nagrobek — harvester dostał go od nas + wcześniej, więc „nigdy o takim nie słyszałem" byłoby kłamstwem. + """ try: namespace, model, pk = identyfikatory.rozbierz(identyfikator) except identyfikatory.BlednyIdentyfikator as wyjatek: @@ -568,7 +597,10 @@ def _znajdz_rekord(zadanie, identyfikator): obiekt = provider.pojedynczy(zadanie.uczelnia, model, pk) if obiekt is None: break - return set_spec, provider, obiekt + nagrobek = obiekt.pk not in provider.widoczne_pk_ze_strony( + zadanie.uczelnia, model, [obiekt] + ) + return set_spec, provider, obiekt, nagrobek raise NieznanyIdentyfikator(f"Brak rekordu o identyfikatorze {identyfikator}") @@ -616,8 +648,16 @@ def _pod(rodzic, nazwa, tekst=None): return element -def _naglowek(rodzic, set_spec, obiekt, namespace): +def _naglowek(rodzic, set_spec, obiekt, namespace, usuniety=False): + """Nagłówek rekordu; ``usuniety=True`` daje nagrobek. + + OAI-PMH sygnalizuje usunięcie atrybutem ``status="deleted"`` na + ``
``. Rekord usunięty NIE niesie ```` — dołożenie ich + złamałoby schemat odpowiedzi. + """ naglowek = _pod(rodzic, "header") + if usuniety: + naglowek.set("status", "deleted") _pod(naglowek, "identifier", identyfikatory.zbuduj(namespace, obiekt)) _pod(naglowek, "datestamp", na_datestamp(getattr(obiekt, ADNOTACJA_TS, None))) _pod(naglowek, "setSpec", set_spec) diff --git a/src/cerif_export/providers/base.py b/src/cerif_export/providers/base.py index 58a88a7af..415a99272 100644 --- a/src/cerif_export/providers/base.py +++ b/src/cerif_export/providers/base.py @@ -90,6 +90,35 @@ def queryset(self, uczelnia, model): i z kompletem prefetchy potrzebnym serializerowi.""" raise NotImplementedError + def przynaleznosc(self, uczelnia, model): + """Rekordy TEGO tenanta — także niewidoczne i te w koszu. + + Wyłącznie atrybucja tenanta. ŻADNYCH reguł ekspozycji + (``nie_eksportuj_przez_api``, ``status_korekty``, ``widoczna``, + ``pokazuj``, przełączniki ``Uczelnia.eksport_cerif_*``) — te należą + do ``queryset()`` i to ich dopełnienie daje nagrobki. + + Rozszczepienie jest konieczne, bo predykat widoczności sklei dziś + dwie różne rzeczy. Dopełnienie CAŁEJ widoczności wystawiłoby + w multi-hosted nagrobki dla rekordów innych uczelni — + ``widoczne_jednostki()`` filtruje ``uczelnia=uczelnia`` wprost. + + Prefetche: te same co w ``queryset()``. Prefetch na husku jest + nieszkodliwy, a alternatywa (ponowne pobranie żywych z prefetchami) + dokładałaby zapytanie na każdą stronę harvestu. + """ + raise NotImplementedError + + def nagrobki(self, uczelnia, model): + """Rekordy tenanta, które przestały być wystawiane. + + Różnica liczona po kluczach głównych: ``queryset()`` niesie + prefetche, a te w podzapytaniu i tak nie działają — ``values("pk")`` + sprowadza je do samego klucza. + """ + widoczne = self.queryset(uczelnia, model).values("pk") + return self.przynaleznosc(uczelnia, model).exclude(pk__in=widoczne) + def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: """Prekomputuj klucze encji sąsiadujących, które wyjdą w swoich setach — dla podanej partii obiektów.""" @@ -98,7 +127,13 @@ def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: # -- stronicowanie keyset ------------------------------------------- def strona(self, uczelnia, od=None, do=None, kursor=None, rozmiar=None): - """Zwróć ``(obiekty, kolejny_kursor)``. + """Zwróć ``([(obiekt, czy_nagrobek), ...], kolejny_kursor)``. + + Stronicujemy **nadzbiór** (``przynaleznosc``), więc żywe rekordy + i nagrobki płyną jednym strumieniem, w jednym porządku keyset. + Nagrobki NIE mogą iść osobnym przebiegiem: ``resumptionToken`` + niesie dokładnie jeden kursor ``(datestamp, pk)``, a dwa strumienie + zepsułyby okno ``from``/``until``. Modele wyczerpywane są sekwencyjnie w kolejności ``self.modele``; w obrębie modelu porządek to ``(COALESCE(ostatnio_zmieniony, EPOKA), @@ -139,21 +174,55 @@ def strona(self, uczelnia, od=None, do=None, kursor=None, rozmiar=None): if len(partia) > brakuje: partia = partia[:brakuje] - zebrane.extend(partia) + zebrane.extend(self._oznacz(uczelnia, model, partia)) return zebrane, self._kursor(slugi[indeks], partia[-1]) - zebrane.extend(partia) + zebrane.extend(self._oznacz(uczelnia, model, partia)) if len(zebrane) >= rozmiar: # Strona pełna, bieżący model wyczerpany (sonda nic nie # dołożyła). Token wydajemy tylko, gdy realnie jest co # jeszcze pokazać. + # + # Kursor dostaje GOŁY obiekt, nie parę — czyta ``ADNOTACJA_TS`` + # i ``pk``. ``partia[-1]`` jest tu tożsame z ostatnim + # zebranym: strona przekroczyła rozmiar dopiero po tym + # ``extend``, więc partia na pewno nie była pusta. if self._istnieje_dalej(uczelnia, modele, indeks, od, do): - return zebrane, self._kursor(slugi[indeks], zebrane[-1]) + return zebrane, self._kursor(slugi[indeks], partia[-1]) return zebrane, None return zebrane, None + def _oznacz(self, uczelnia, model, partia): + """Opakuj obiekty w pary ``(obiekt, czy_nagrobek)``. + + Oznaczamy per model, bo ``widoczne_pk_ze_strony`` pyta o widoczność + konkretnego modelu — strona bywa sklejona z kilku. + """ + widoczne = self.widoczne_pk_ze_strony(uczelnia, model, partia) + return [(obiekt, obiekt.pk not in widoczne) for obiekt in partia] + + def widoczne_pk_ze_strony(self, uczelnia, model, obiekty) -> frozenset: + """Klucze obiektów tej strony, które są nadal wystawiane. + + Jedno tanie zapytanie na stronę, zawężone do jej kluczy — nie + skanuje całego zbioru widocznych. + + ``prefetch_related(None)`` czyści prefetche odziedziczone po + ``queryset()``: nie ma na co ich nakładać, bo ``values_list`` + zwraca krotki, a nie instancje modelu. + """ + if not obiekty: + return frozenset() + klucze = [obiekt.pk for obiekt in obiekty] + return frozenset( + self.queryset(uczelnia, model) + .prefetch_related(None) + .filter(pk__in=klucze) + .values_list("pk", flat=True) + ) + def _istnieje_dalej(self, uczelnia, modele, indeks, od, do): """Czy w modelach po ``indeks`` został jeszcze jakikolwiek rekord?""" for model in modele[indeks + 1 :]: @@ -179,7 +248,9 @@ def _kursor(self, slug, obiekt): def _strona_modelu(self, uczelnia, model, od, do, kursor, limit): from django.db.models import Q - qs = z_datestampem(self.queryset(uczelnia, model)) + # Nadzbiór: żywe + nagrobki w JEDNYM porządku keyset. Kursor + # resumption tokenu niesie (datestamp, pk) i zakłada jeden strumień. + qs = z_datestampem(self.przynaleznosc(uczelnia, model)) if od is not None: qs = qs.filter(**{f"{ADNOTACJA_TS}__gte": od}) @@ -197,15 +268,22 @@ def _strona_modelu(self, uczelnia, model, od, do, kursor, limit): # -- pozostałe operacje --------------------------------------------- def pojedynczy(self, uczelnia, model, pk): - """Pojedynczy obiekt albo ``None``, gdy niewidoczny.""" - return z_datestampem(self.queryset(uczelnia, model)).filter(pk=pk).first() + """Obiekt należący do tenanta albo ``None``. + + Szuka w NADZBIORZE: rekord niewidoczny nadal istnieje dla OAI — + jako nagrobek. O tym, czy jest żywy, decyduje wywołujący + (``widoczne_pk_ze_strony``). + """ + return z_datestampem(self.przynaleznosc(uczelnia, model)).filter(pk=pk).first() def najstarszy_datestamp(self, uczelnia): """Najstarszy datestamp w secie albo ``None``, gdy set pusty.""" najstarszy = None for model in self.modele: wiersz = ( - z_datestampem(self.queryset(uczelnia, model)) + # Nadzbiór, bo nagrobek też jest rekordem o dacie i może być + # najstarszym, co repozytorium ma do pokazania. + z_datestampem(self.przynaleznosc(uczelnia, model)) .order_by(ADNOTACJA_TS) .values_list(ADNOTACJA_TS, flat=True) .first() @@ -228,6 +306,9 @@ class ProviderPusty(ProviderEncji): def queryset(self, uczelnia, model): raise NotImplementedError("Set pusty nie ma modeli") + def przynaleznosc(self, uczelnia, model): + raise NotImplementedError("Set pusty nie ma modeli") + def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: return ZbioryWidocznosci() diff --git a/src/cerif_export/providers/finansowanie.py b/src/cerif_export/providers/finansowanie.py index 33fda85c1..5d9d55f02 100644 --- a/src/cerif_export/providers/finansowanie.py +++ b/src/cerif_export/providers/finansowanie.py @@ -27,6 +27,13 @@ def widoczne_finansowania(uczelnia): return Finansowanie.objects.filter(projekt__jednostka__uczelnia=uczelnia) +def nalezace_finansowania(uczelnia): + """Finansowania projektów TEJ uczelni. Jak projekty: czysta atrybucja, + dopełnienie puste.""" + wymagaj_uczelni(uczelnia) + return Finansowanie.objects.filter(projekt__jednostka__uczelnia=uczelnia) + + class ProviderFinansowania(ProviderEncji): """Źródła finansowania projektów tej uczelni.""" @@ -43,6 +50,15 @@ def queryset(self, uczelnia, model): return widoczne_finansowania(uczelnia).select_related("instytucja") + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is not Finansowanie: + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + + return nalezace_finansowania(uczelnia).select_related("instytucja") + def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: """Prekomputuj widoczność grantodawców osadzanych w ``Funder``.""" wymagaj_uczelni(uczelnia) diff --git a/src/cerif_export/providers/jednostki.py b/src/cerif_export/providers/jednostki.py index efd4bdad1..e5d641b90 100644 --- a/src/cerif_export/providers/jednostki.py +++ b/src/cerif_export/providers/jednostki.py @@ -55,6 +55,17 @@ def widoczne_jednostki(uczelnia): ) +def nalezace_jednostki(uczelnia): + """Jednostki TEJ uczelni — bez reguł ekspozycji. + + Atrybucja to bezpośredni FK ``uczelnia``; ``widoczna`` + i ``nie_eksportuj_przez_api`` są regułami ekspozycji i zostają + w ``widoczne_jednostki()``, żeby ich dopełnienie dało nagrobki. + """ + wymagaj_uczelni(uczelnia) + return Jednostka.objects.filter(uczelnia=uczelnia) + + def widoczni_grantodawcy(uczelnia): """Instytucje finansujące eksportowane dla tej uczelni — bez prefetchy. @@ -74,6 +85,25 @@ def widoczni_grantodawcy(uczelnia): ).distinct() +def nalezacy_grantodawcy(uczelnia): + """Instytucje finansujące projekty TEJ uczelni. + + ⚠️ Treść jest IDENTYCZNA z ``widoczni_grantodawcy`` — i to nie pomyłka. + Tamten helper filtruje ``finansowanie__projekt__jednostka__uczelnia`` + wprost, czyli sama atrybucja, bez żadnej reguły ekspozycji. Dopełnienie + jest więc puste i ten model nagrobków nie wygeneruje. + + Implementujemy mimo to, bo kontrakt providera musi być kompletny + (``test_kazdy_provider_deklaruje_przynaleznosc``), a rozdzielenie nazw + pokazuje następnemu czytelnikowi, gdzie dopisać regułę ekspozycji, gdyby + kiedyś powstała — wtedy nagrobki zaczną działać bez zmian w bazie. + """ + wymagaj_uczelni(uczelnia) + return Instytucja_Finansujaca.objects.filter( + finansowanie__projekt__jednostka__uczelnia=uczelnia + ).distinct() + + def widoczne_pk(queryset, kandydaci) -> frozenset: """Przetnij zbiór kandydatów z querysetem widoczności — jednym zapytaniem. @@ -115,6 +145,26 @@ def queryset(self, uczelnia, model): raise BlednyIdentyfikator(f"Model {model!r} nie należy do setu {self.set_spec}") + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + + if model is Jednostka: + return nalezace_jednostki(uczelnia).select_related( + "uczelnia", + "parent", + "wydzial", + "rodzaj", + "pbn_uid", + ) + + if model is Uczelnia: + return Uczelnia.objects.filter(pk=uczelnia.pk).select_related("site") + + if model is Instytucja_Finansujaca: + return nalezacy_grantodawcy(uczelnia) + + raise BlednyIdentyfikator(f"Model {model!r} nie należy do setu {self.set_spec}") + def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: """Prekomputuj widoczność jednostek nadrzędnych (``PartOf``).""" wymagaj_uczelni(uczelnia) diff --git a/src/cerif_export/providers/konferencje.py b/src/cerif_export/providers/konferencje.py index 00a503879..53343c0cd 100644 --- a/src/cerif_export/providers/konferencje.py +++ b/src/cerif_export/providers/konferencje.py @@ -12,7 +12,10 @@ from cerif_export.kontekst import ZbioryWidocznosci from cerif_export.providers.base import ProviderEncji from cerif_export.providers.jednostki import wymagaj_uczelni -from cerif_export.providers.publikacje import widoczne_konferencje +from cerif_export.providers.publikacje import ( + nalezace_konferencje, + widoczne_konferencje, +) class ProviderKonferencji(ProviderEncji): @@ -30,6 +33,14 @@ def queryset(self, uczelnia, model): ) return widoczne_konferencje(uczelnia).select_related("pbn_uid") + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is not Konferencja: + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + return nalezace_konferencje(uczelnia).select_related("pbn_uid") + def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: """Konferencja nie osadza encji sąsiadujących — zbiory są puste.""" wymagaj_uczelni(uczelnia) diff --git a/src/cerif_export/providers/osoby.py b/src/cerif_export/providers/osoby.py index d94a70e0f..d8c9f2bc7 100644 --- a/src/cerif_export/providers/osoby.py +++ b/src/cerif_export/providers/osoby.py @@ -54,6 +54,26 @@ def widoczni_autorzy(uczelnia): ) +def nalezacy_autorzy(uczelnia): + """Autorzy afiliowani przy TEJ uczelni — bez reguł ekspozycji. + + ⚠️ Świadomie IGNORUJEMY ``Uczelnia.eksport_cerif_osoby`` i ``pokazuj``. + Oba są regułami ekspozycji, więc ich wyłączenie MA produkować nagrobki + — harvester ma te osoby usunąć. Skutek uboczny (opisany w specu): + przestawienie przełącznika wystawia nagrobki dla wszystkich autorów + uczelni naraz. To poprawne, ale jednorazowo bardzo hałaśliwe. + + ``global_objects``, bo ``Autor`` jest soft-delete od fazy 04 — autor + w koszu ma dostać nagrobek, a nie zniknąć po cichu. + """ + wymagaj_uczelni(uczelnia) + return Autor.global_objects.filter( + pk__in=Autor_Jednostka.objects.filter(jednostka__uczelnia=uczelnia).values( + "autor_id" + ) + ) + + # Affiliation — komplet powiązań autor-jednostka wraz z jednostką. Serializer # emituje ``@id`` jednostki wyłącznie gdy siedzi ona w zbiorze widoczności, # ale nazwę/skrót osadza zawsze, więc obiekt ``Jednostka`` musi tu być. @@ -83,6 +103,19 @@ def queryset(self, uczelnia, model): .prefetch_related(PREFETCH_AFILIACJI) ) + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is not Autor: + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + + return ( + nalezacy_autorzy(uczelnia) + .select_related("plec", "tytul", "pbn_uid", "aktualna_jednostka") + .prefetch_related(PREFETCH_AFILIACJI) + ) + def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: """Prekomputuj widoczność jednostek afiliacji.""" wymagaj_uczelni(uczelnia) diff --git a/src/cerif_export/providers/patenty.py b/src/cerif_export/providers/patenty.py index 7be1925b9..c574670e5 100644 --- a/src/cerif_export/providers/patenty.py +++ b/src/cerif_export/providers/patenty.py @@ -53,6 +53,23 @@ def widoczne_patenty(uczelnia): ) +def nalezace_patenty(uczelnia): + """Patenty TEJ uczelni — bez reguł ekspozycji. + + Scope przez model autorstwa z KOSZEM (``global_objects``), jak + wydawnictwa; sam ``Patent`` też przez ``global_objects``, bo jest + soft-delete od fazy 02. ``status_korekty``, ``nie_eksportuj_przez_api`` + i ``rodzaj_prawa.eksportuj_jako_patent`` to ekspozycja — zostają + w ``widoczne_patenty()``. + """ + wymagaj_uczelni(uczelnia) + return Patent.global_objects.filter( + pk__in=Patent_Autor.global_objects.filter(jednostka__uczelnia=uczelnia).values( + "rekord_id" + ) + ) + + PREFETCH_TWORCOW = Prefetch( "autorzy_set", queryset=Patent_Autor.objects.select_related( @@ -83,6 +100,21 @@ def queryset(self, uczelnia, model): ) ) + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is not Patent: + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + + return ( + nalezace_patenty(uczelnia) + .select_related("rodzaj_prawa", "status_korekty", "wydzial") + .prefetch_related( + "slowa_kluczowe", PREFETCH_TWORCOW, *prefetche_pochodzenia() + ) + ) + def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: """Prekomputuj widoczność twórców (``Inventors``), ich jednostek oraz encji osadzanych przez ``OriginatesFrom``.""" diff --git a/src/cerif_export/providers/projekty.py b/src/cerif_export/providers/projekty.py index 0ed8da56c..1e274c8e2 100644 --- a/src/cerif_export/providers/projekty.py +++ b/src/cerif_export/providers/projekty.py @@ -42,6 +42,14 @@ def widoczne_projekty(uczelnia): return Projekt.objects.filter(jednostka__uczelnia=uczelnia) +def nalezace_projekty(uczelnia): + """Projekty TEJ uczelni. Czysta atrybucja — identyczna z widocznością, + więc dopełnienie jest puste i ten set nagrobków nie wygeneruje. + Kontrakt implementujemy dla spójności i gotowości na przyszłe reguły.""" + wymagaj_uczelni(uczelnia) + return Projekt.objects.filter(jednostka__uczelnia=uczelnia) + + def prefetche_projektu(prefiks=""): """Komplet prefetchy wymaganych przez ``cerif.project.serializuj``. @@ -163,6 +171,19 @@ def queryset(self, uczelnia, model): .prefetch_related(*prefetche_projektu()) ) + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is not Projekt: + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + + return ( + nalezace_projekty(uczelnia) + .select_related("jednostka") + .prefetch_related(*prefetche_projektu()) + ) + def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: """Prekomputuj widoczność zespołu, jednostki realizującej i grantodawców. diff --git a/src/cerif_export/providers/publikacje.py b/src/cerif_export/providers/publikacje.py index 2f7af2a33..0498b9d88 100644 --- a/src/cerif_export/providers/publikacje.py +++ b/src/cerif_export/providers/publikacje.py @@ -148,6 +148,72 @@ def widoczne_zrodla(uczelnia): ) +# -- predykaty przynależności (faza 05b: nagrobki) ---------------------- + + +def naleza_wydawnictwa(model, uczelnia): + """Wydawnictwa TEJ uczelni — bez reguł ekspozycji. + + ⚠️ OBA managery to ``global_objects``, czyli **z koszem**. Zewnętrzny, + bo rekord wrzucony do kosza ma dostać nagrobek, a nie zniknąć — + ``objects`` (``BppSoftDeleteManager``) odsiałby go od razu. Wewnętrzny, + bo autorstwa mają ``BppAutorstwoSoftDeleteMixin`` od fazy 02, więc + rekord, któremu skasowano ostatniego autora z tej uczelni, pozostaje + „kiedyś nasz". Użycie ``objects`` po którejkolwiek stronie cofnęłoby + jedną z czterech dróg zniknięcia z powrotem do ciszy. + """ + wymagaj_uczelni(uczelnia) + return model.global_objects.filter( + pk__in=model.autor_rekordu_klass.global_objects.filter( + jednostka__uczelnia=uczelnia + ).values("rekord_id") + ) + + +def naleza_prace(model, uczelnia): + """Prace dyplomowe TEJ uczelni — bez reguł ekspozycji. + + Atrybucja przez bezpośredni FK ``jednostka`` (jak ``widoczne_prace``), + ale przez ``global_objects`` — praca w koszu ma dostać nagrobek. + """ + wymagaj_uczelni(uczelnia) + return model.global_objects.filter(jednostka__uczelnia=uczelnia) + + +def naleza_zrodla(uczelnia): + """Źródła wskazywane przez wydawnictwa ciągłe NALEŻĄCE do tej uczelni. + + Provider pochodny — odpowiednik ``widoczne_zrodla`` wyprowadzony + z przynależności. Tylko ``Wydawnictwo_Ciagle`` ma FK ``zrodlo``. + Różnica obu zbiorów to źródła, do których prowadziły wyłącznie + wydawnictwa, które przestały być widoczne — i one dostają nagrobek. + """ + wymagaj_uczelni(uczelnia) + return Zrodlo.objects.filter( + pk__in=naleza_wydawnictwa(Wydawnictwo_Ciagle, uczelnia) + .filter(zrodlo__isnull=False) + .values("zrodlo_id") + ) + + +def naleza_dla_modelu(model, uczelnia): + """Dispatcher równoległy do ``widoczne_dla_modelu``. + + MUSI mieć te same trzy gałęzie co tamten — w tym ``Zrodlo``. Pominięcie + którejś dałoby ``NotImplementedError`` dopiero przy harveście akurat + tego modelu, czyli u konsumenta. + """ + if model in MODELE_WYDAWNICTW: + return naleza_wydawnictwa(model, uczelnia) + if model in MODELE_PRAC: + return naleza_prace(model, uczelnia) + if model is Zrodlo: + return naleza_zrodla(uczelnia) + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {const.SET_PUBLICATIONS}" + ) + + def widoczne_konferencje(uczelnia): """Konferencje wskazywane przez co najmniej jedną widoczną publikację. @@ -165,6 +231,24 @@ def widoczne_konferencje(uczelnia): return Konferencja.objects.filter(warunek) +def nalezace_konferencje(uczelnia): + """Konferencje wskazywane przez publikacje NALEŻĄCE do tej uczelni. + + Odpowiednik ``widoczne_konferencje``, ale wyprowadzony z przynależności, + nie z widoczności. Różnica tych dwóch zbiorów to właśnie konferencje, + które wypadły z feedu — i o nie chodzi w nagrobkach. + """ + wymagaj_uczelni(uczelnia) + warunek = Q() + for model in MODELE_WYDAWNICTW: + warunek |= Q( + pk__in=naleza_wydawnictwa(model, uczelnia) + .filter(konferencja__isnull=False) + .values("konferencja_id") + ) + return Konferencja.objects.filter(warunek) + + # -- typ COAR prac dyplomowych ------------------------------------------ @@ -272,6 +356,59 @@ def _prefetche_wydawnictwa(model): ) +def dekoruj(model, queryset): + """Nałóż na queryset komplet prefetchy/adnotacji wymaganych przez serializer. + + Wydzielone z ``queryset()``, bo od fazy 05b dokładnie ten sam komplet + musi nieść ``przynaleznosc()`` — to ją paginuje ``strona()``, więc + serializacja żywych rekordów czyta relacje właśnie z niej. Dwie kopie + tej listy rozjechałyby się przy pierwszej zmianie i dały N+1 na każdej + stronie harvestu, czego żaden test by nie złapał. + """ + if model is Wydawnictwo_Ciagle: + return queryset.select_related( + *_SELECT_WYDAWNICTWA, "zrodlo", "zrodlo__jezyk" + ).prefetch_related(*_prefetche_wydawnictwa(model)) + + if model is Wydawnictwo_Zwarte: + return queryset.select_related( + *_SELECT_WYDAWNICTWA, + "wydawca", + "wydawnictwo_nadrzedne", + # `PartOf` osadza skrócone wydawnictwo nadrzędne, a to + # czyta typ i język rodzica. Bez tych dwóch pozycji + # rozdział kosztował dodatkowe zapytania na każdy rekord + # — niewidoczne w testach z jednym rozdziałem. + "wydawnictwo_nadrzedne__charakter_formalny", + "wydawnictwo_nadrzedne__jezyk", + "seria_wydawnicza", + ).prefetch_related( + *_prefetche_wydawnictwa(model), + Prefetch( + "wydawnictwo_nadrzedne__dodatkowe_tytuly", + queryset=Wydawnictwo_Zwarte_Tytul.objects.select_related("jezyk"), + ), + ) + + if model in MODELE_PRAC: + return ( + queryset.select_related(*_SELECT_PRACY) + .prefetch_related("slowa_kluczowe", *prefetche_pochodzenia()) + .annotate( + **{ADNOTACJA_COAR: Value(coar_pracy(model), output_field=CharField())} + ) + ) + + if model is Zrodlo: + return queryset.select_related( + "rodzaj", "jezyk", "openaccess_licencja", "pbn_uid" + ) + + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {const.SET_PUBLICATIONS}" + ) + + class ProviderPublikacji(ProviderEncji): """Publikacje wszystkich pięciu typów + kanały wydawnicze.""" @@ -287,60 +424,11 @@ class ProviderPublikacji(ProviderEncji): def queryset(self, uczelnia, model): wymagaj_uczelni(uczelnia) + return dekoruj(model, widoczne_dla_modelu(model, uczelnia)) - if model is Wydawnictwo_Ciagle: - return ( - widoczne_wydawnictwa(model, uczelnia) - .select_related(*_SELECT_WYDAWNICTWA, "zrodlo", "zrodlo__jezyk") - .prefetch_related(*_prefetche_wydawnictwa(model)) - ) - - if model is Wydawnictwo_Zwarte: - return ( - widoczne_wydawnictwa(model, uczelnia) - .select_related( - *_SELECT_WYDAWNICTWA, - "wydawca", - "wydawnictwo_nadrzedne", - # `PartOf` osadza skrócone wydawnictwo nadrzędne, a to - # czyta typ i język rodzica. Bez tych dwóch pozycji - # rozdział kosztował dodatkowe zapytania na każdy rekord - # — niewidoczne w testach z jednym rozdziałem. - "wydawnictwo_nadrzedne__charakter_formalny", - "wydawnictwo_nadrzedne__jezyk", - "seria_wydawnicza", - ) - .prefetch_related( - *_prefetche_wydawnictwa(model), - Prefetch( - "wydawnictwo_nadrzedne__dodatkowe_tytuly", - queryset=Wydawnictwo_Zwarte_Tytul.objects.select_related( - "jezyk" - ), - ), - ) - ) - - if model in MODELE_PRAC: - return ( - widoczne_prace(model, uczelnia) - .select_related(*_SELECT_PRACY) - .prefetch_related("slowa_kluczowe", *prefetche_pochodzenia()) - .annotate( - **{ - ADNOTACJA_COAR: Value( - coar_pracy(model), output_field=CharField() - ) - } - ) - ) - - if model is Zrodlo: - return widoczne_zrodla(uczelnia).select_related( - "rodzaj", "jezyk", "openaccess_licencja", "pbn_uid" - ) - - raise BlednyIdentyfikator(f"Model {model!r} nie należy do setu {self.set_spec}") + def przynaleznosc(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + return dekoruj(model, naleza_dla_modelu(model, uczelnia)) def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: """Prekomputuj widoczność encji osadzanych przy publikacjach. diff --git a/src/cerif_export/tests/pomocnicze.py b/src/cerif_export/tests/pomocnicze.py new file mode 100644 index 000000000..28d7c64d3 --- /dev/null +++ b/src/cerif_export/tests/pomocnicze.py @@ -0,0 +1,21 @@ +"""Pomocnicze dla suity ``cerif_export`` — wspólne dla kilku modułów testów. + +Nie w ``conftest.py``, bo to zwykłe funkcje, nie fixtury: ``conftest`` +nie jest importowalny po nazwie z poziomu testów. +""" + + +def strona_zywych(provider, uczelnia, **kwargs): + """``provider.strona(...)``, ale bez nagrobków — sama ekspozycja. + + Od fazy 05b ``strona()`` paginuje NADZBIÓR (przynależność) i zwraca pary + ``(obiekt, czy_nagrobek)``. Testy napisane wcześniej pytały o to, co + repozytorium *wystawia*, i to pytanie nadal jest sensowne — helper + odtwarza dokładnie dawną semantykę zwrotki, żeby nie trzeba było + przepisywać ich asercji. + + Testy samych nagrobków wołają ``strona()`` wprost — patrz + ``test_nagrobki.py``. + """ + oznaczone, kursor = provider.strona(uczelnia, **kwargs) + return [obiekt for obiekt, nagrobek in oznaczone if not nagrobek], kursor diff --git a/src/cerif_export/tests/test_eksport_projektow.py b/src/cerif_export/tests/test_eksport_projektow.py index def01e504..7147efc72 100644 --- a/src/cerif_export/tests/test_eksport_projektow.py +++ b/src/cerif_export/tests/test_eksport_projektow.py @@ -37,6 +37,7 @@ from cerif_export.oai import czasowniki from cerif_export.providers import provider_dla_modelu, provider_dla_setu from cerif_export.slowniki import typy_finansowania +from cerif_export.tests.pomocnicze import strona_zywych NAMESPACE = "cerif.example.org" BASE_URL = "https://cerif.example.org/cerif-oai/" @@ -155,7 +156,7 @@ def test_sety_projektow_i_finansowania_maja_wlasne_providery(): @pytest.mark.django_db def test_projekt_wychodzi_w_secie(uczelnia, projekt): provider = provider_dla_setu(const.SET_PROJECTS) - obiekty, _ = provider.strona(uczelnia, rozmiar=100) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=100) assert list(obiekty) == [projekt] @@ -172,7 +173,7 @@ def test_projekt_cudzej_uczelni_nie_wychodzi(uczelnia, projekt): baker.make(Projekt, tytul="Cudzy projekt", jednostka=obca_jednostka) provider = provider_dla_setu(const.SET_PROJECTS) - obiekty, _ = provider.strona(uczelnia, rozmiar=100) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=100) assert list(obiekty) == [projekt] @@ -192,7 +193,7 @@ def test_finansowanie_cudzej_uczelni_nie_wychodzi(uczelnia, projekt, grantodawca dodaj_finansowanie(obcy_projekt, grantodawca) provider = provider_dla_setu(const.SET_FUNDING) - obiekty, _ = provider.strona(uczelnia, rozmiar=100) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=100) assert list(obiekty) == [nasze] @@ -549,7 +550,7 @@ def test_projekt_bez_finansowania_serializuje_sie(uczelnia, projekt): assert el.find(q("Title")) is not None provider = provider_dla_setu(const.SET_PROJECTS) - obiekty, _ = provider.strona(uczelnia, rozmiar=100) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=100) assert projekt in obiekty @@ -608,7 +609,7 @@ def zapytania_dla(ile_projektow): ) with CaptureQueriesContext(connection) as licznik: - obiekty, _ = provider.strona(uczelnia, rozmiar=100) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=100) obiekty = list(obiekty) kontekst = KontekstSerializacji( namespace=NAMESPACE, diff --git a/src/cerif_export/tests/test_funderzy_orgunit.py b/src/cerif_export/tests/test_funderzy_orgunit.py index e388099f4..5ce05ebcb 100644 --- a/src/cerif_export/tests/test_funderzy_orgunit.py +++ b/src/cerif_export/tests/test_funderzy_orgunit.py @@ -27,6 +27,7 @@ from cerif_export.providers import provider_dla_modelu, provider_dla_setu from cerif_export.providers.jednostki import ProviderJednostek from cerif_export.slowniki import typy_finansowania +from cerif_export.tests.pomocnicze import strona_zywych NAMESPACE = "cerif.example.org" @@ -144,7 +145,7 @@ def test_funder_wychodzi_raz_mimo_wielu_finansowan(uczelnia, jednostka): # Także po przepuszczeniu przez stronicowanie keyset — ``DISTINCT`` # musi przeżyć adnotację ``_cerif_ts`` i ``ORDER BY`` po niej, inaczej # harvester dostałby ten sam rekord tyle razy, ile jest finansowań. - obiekty, _ = provider.strona(uczelnia, rozmiar=1000) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=1000) assert [o for o in obiekty if isinstance(o, Instytucja_Finansujaca)] == [instytucja] @@ -172,7 +173,7 @@ def test_funder_jest_w_secie_orgunits(uczelnia, jednostka): zbuduj_finansowanie(jednostka, instytucja) provider = provider_dla_setu(const.SET_ORGUNITS) - obiekty, _ = provider.strona(uczelnia, rozmiar=1000) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=1000) assert instytucja in obiekty assert provider_dla_modelu(Instytucja_Finansujaca) is provider diff --git a/src/cerif_export/tests/test_integralnosc.py b/src/cerif_export/tests/test_integralnosc.py index 06e44bdfc..9bfe857a0 100644 --- a/src/cerif_export/tests/test_integralnosc.py +++ b/src/cerif_export/tests/test_integralnosc.py @@ -87,8 +87,23 @@ def harvest(uczelnia): def identyfikatory_naglowkow(rekordy): + """Identyfikatory rekordów, które realnie coś wystawiają. + + Nagrobki (faza 05b) świadomie POMIJAMY. Ich identyfikator jest w + odpowiedzi, ale znaczy „usuń to u siebie", nie „oto rekord" — zaliczenie + go do zbioru wydanych osłabiłoby + ``test_kazde_powiazanie_da_sie_rozwiazac``: referencja do rekordu, który + właśnie kasujemy, przechodziłaby jako rozwiązywalna. Serializer i tak + osadza ``@id`` wyłącznie dla encji ze zbioru widoczności, więc żadne + powiązanie nie ma prawa wskazywać na nagrobek. + """ wynik = set() for rekord in rekordy: + naglowek = next( + (el for el in rekord.iter() if el.tag.endswith("}header")), None + ) + if naglowek is not None and naglowek.get("status") == "deleted": + continue for el in rekord.iter(): if el.tag.endswith("}identifier") and el.text: wynik.add(el.text) diff --git a/src/cerif_export/tests/test_integralnosc_projektow.py b/src/cerif_export/tests/test_integralnosc_projektow.py index 4dcc235bd..c561a1c8c 100644 --- a/src/cerif_export/tests/test_integralnosc_projektow.py +++ b/src/cerif_export/tests/test_integralnosc_projektow.py @@ -197,6 +197,11 @@ def harvest(uczelnia): for rekord in korzen.iter(p("record")): naglowek = rekord.find(p("header")) + if naglowek.get("status") == "deleted": + # Nagrobek (faza 05b) to z definicji sam nagłówek — nie ma + # ładunku, więc i nie ma referencji, które mogłyby zawisnąć. + # Integralność referencyjną sprawdzamy na rekordach żywych. + continue metadane = rekord.find(p("metadata")) assert metadane is not None and len(metadane), ( "rekord bez ładunku metadanych" diff --git a/src/cerif_export/tests/test_nagrobki.py b/src/cerif_export/tests/test_nagrobki.py new file mode 100644 index 000000000..4ad7e0bf2 --- /dev/null +++ b/src/cerif_export/tests/test_nagrobki.py @@ -0,0 +1,501 @@ +"""Faza 05b soft-delete: nagrobki dla konsumentów przyrostowych. + +Nagrobek = rekord, który NALEŻY do tenanta, ale nie jest już wystawiany. +Dopełniamy ekspozycję, nigdy przynależność — dopełnienie przynależności +wystawiłoby w multi-hosted rekordy cudzych uczelni. +""" + +import pytest + +from cerif_export.oai.czasowniki import rejestr_providerow + + +def test_kazdy_provider_deklaruje_przynaleznosc(): + """Kontrakt musi być kompletny, inaczej nagrobki milkną w losowym secie. + + Provider bez ``przynaleznosc`` wywaliłby się dopiero przy harveście + akurat tego setu — czyli u konsumenta, nie w testach. + """ + for set_spec, provider in rejestr_providerow().items(): + assert hasattr(provider, "przynaleznosc"), ( + f"Provider setu {set_spec} nie deklaruje przynaleznosc()" + ) + + +@pytest.fixture +def druga_uczelnia(db): + """Druga uczelnia z własną jednostką — multi-hosted.""" + from django.contrib.sites.models import Site + + from bpp.models import Jednostka, Uczelnia + + site = Site.objects.create(domain="druga.example.org", name="druga") + uczelnia = Uczelnia.objects.create(nazwa="Druga", skrot="DRU", site=site) + Jednostka.objects.create(nazwa="Jednostka Drugiej", skrot="JDR", uczelnia=uczelnia) + return uczelnia + + +@pytest.mark.django_db +def test_nagrobki_nie_wyciekaja_miedzy_uczelniami(uczelnia, druga_uczelnia): + """Dopełnienie NIE może objąć rekordów cudzego tenanta. + + To jedyne ryzyko, które dopełnienie widoczności wnosi wprost: + ``widoczne_jednostki()`` filtruje ``uczelnia=uczelnia``, więc naiwne + „wszystko minus widoczne" zamieniłoby każdą jednostkę drugiej uczelni + w nagrobek pierwszej — wyciek identyfikatorów i lawina szumu. + """ + from bpp.models import Jednostka + from cerif_export import const + + provider = rejestr_providerow()[const.SET_ORGUNITS] + nagrobki = provider.nagrobki(uczelnia, Jednostka) + + obce = Jednostka.objects.filter(uczelnia=druga_uczelnia) + assert obce.exists(), "fixture musi utworzyć jednostkę drugiej uczelni" + assert not nagrobki.filter(pk__in=obce.values("pk")).exists(), ( + "nagrobki uczelni A zawierają jednostkę uczelni B — wyciek tenanta" + ) + + +@pytest.mark.django_db +def test_konferencja_bez_widocznych_publikacji_to_nagrobek( + uczelnia, jednostka, typ_autor +): + """Provider pochodny: konferencja znika, gdy znikną jej publikacje. + + Widoczność konferencji jest wyprowadzona z publikacji. Gdy jedyna + publikacja wskazująca konferencję przestaje być widoczna, konferencja + też wypada z feedu — i musi dostać nagrobek, a nie zniknąć po cichu. + """ + from model_bakery import baker + + from bpp.models import Konferencja, Wydawnictwo_Ciagle + from cerif_export import const + + konferencja = baker.make(Konferencja) + praca = baker.make(Wydawnictwo_Ciagle, konferencja=konferencja) + # Nazwisko/imiona jawnie: baker generuje 500-znakowe losowe łańcuchy, + # a ``dodaj_autora`` skleja z nich ``zapisany_jako`` (max 512 znaków). + praca.dodaj_autora( + baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan"), jednostka + ) + + provider = rejestr_providerow()[const.SET_EVENTS] + assert ( + not provider.nagrobki(uczelnia, Konferencja).filter(pk=konferencja.pk).exists() + ), "konferencja z widoczną publikacją nie jest nagrobkiem" + + praca.nie_eksportuj_przez_api = True + praca.save() + + assert ( + provider.nagrobki(uczelnia, Konferencja).filter(pk=konferencja.pk).exists() + ), ( + "konferencja straciła jedyną widoczną publikację, a nie dostała " + "nagrobka — znika z feedu po cichu" + ) + + +@pytest.mark.django_db +def test_strona_miesza_zywe_i_nagrobki_w_porzadku_dat(uczelnia, jednostka, typ_autor): + """Jeden strumień, jeden kursor. + + Nagrobki NIE mogą iść osobnym przebiegiem po żywych rekordach: + ``resumptionToken`` niesie jeden kursor ``(datestamp, pk)`` i zakłada + jeden porządek. Dwa strumienie zepsułyby przyrostowość ``from``/``until``, + czyli dokładnie to, co ta faza naprawia. + """ + from model_bakery import baker + + from bpp.models import Jednostka + from cerif_export import const + + ukryta = baker.make( + Jednostka, uczelnia=uczelnia, nazwa="Ukryta", skrot="UKR", widoczna=False + ) + + provider = rejestr_providerow()[const.SET_ORGUNITS] + pary, _kursor = provider.strona(uczelnia, rozmiar=100) + + mapa = {obiekt.pk: nagrobek for obiekt, nagrobek in pary} + assert mapa.get(ukryta.pk) is True, "jednostka ukryta ma być nagrobkiem" + assert mapa.get(jednostka.pk) is False, "jednostka widoczna ma być żywa" + + +BASE_URL = "https://bpp.example.org/cerif/" +NS_PMH = "http://www.openarchives.org/OAI/2.0/" + + +def _zadanie_dla(uczelnia): + """Żądanie OAI oderwane od HTTP — jak ``wykonaj`` w ``test_oai.py``.""" + from cerif_export.oai import czasowniki + + return czasowniki.Zadanie(uczelnia, BASE_URL) + + +def _wykonaj(uczelnia, **argumenty): + from cerif_export.oai import czasowniki + + return czasowniki.odpowiedz(czasowniki.Zadanie(uczelnia, BASE_URL, argumenty)) + + +@pytest.mark.django_db +def test_listrecords_emituje_nagrobek_bez_metadanych(uczelnia, jednostka): + """Rekord usunięty to SAM nagłówek — dokładanie łamie schemat.""" + from model_bakery import baker + + from bpp.models import Jednostka + from cerif_export import const + + baker.make( + Jednostka, uczelnia=uczelnia, nazwa="Ukryta", skrot="UKR", widoczna=False + ) + + korzen = _wykonaj( + uczelnia, + verb="ListRecords", + metadataPrefix=const.METADATA_PREFIX, + set=const.SET_ORGUNITS, + ) + naglowki = korzen.findall(f".//{{{NS_PMH}}}header") + usuniete = [h for h in naglowki if h.get("status") == "deleted"] + assert usuniete, "brak nagrobka w ListRecords" + + for naglowek in usuniete: + rekord = naglowek.getparent() + assert rekord.find(f"{{{NS_PMH}}}metadata") is None, ( + "nagrobek nie może nieść " + ) + + +@pytest.mark.django_db +def test_getrecord_na_usunietym_zwraca_nagrobek(uczelnia): + """Usunięty rekord ma nagrobek, nie błąd. + + ``idDoesNotExist`` znaczy „nigdy o takim nie słyszałem" — dla rekordu, + który harvester dostał od nas wcześniej, to odpowiedź myląca. + """ + from model_bakery import baker + + from bpp.models import Jednostka + from cerif_export import const, identyfikatory + + ukryta = baker.make( + Jednostka, uczelnia=uczelnia, nazwa="Ukryta", skrot="UKR", widoczna=False + ) + zadanie = _zadanie_dla(uczelnia) + identyfikator = identyfikatory.zbuduj(zadanie.namespace, ukryta) + + korzen = _wykonaj( + uczelnia, + verb="GetRecord", + identifier=identyfikator, + metadataPrefix=const.METADATA_PREFIX, + ) + naglowek = korzen.find(f".//{{{NS_PMH}}}header") + assert naglowek is not None, "GetRecord nie zwrócił nagłówka (błąd protokołu?)" + assert naglowek.get("status") == "deleted" + assert korzen.find(f".//{{{NS_PMH}}}metadata") is None + + +@pytest.mark.django_db +def test_identify_deklaruje_transient(uczelnia): + """Deklaracja to obietnica wobec harvestera, nie kosmetyka. + + ``no`` znaczy „nie dowiesz się o usunięciach — rób pełny re-harvest". + ``transient`` znaczy „ogłaszam usunięcia, ale nie gwarantuję, że + nagrobek zostanie na zawsze" — i to jest prawda: husk może zniknąć przy + twardym kasowaniu albo czyszczeniu kosza w fazie 07. + """ + korzen = _wykonaj(uczelnia, verb="Identify") + element = korzen.find(f".//{{{NS_PMH}}}deletedRecord") + assert element is not None, "Identify nie zwrócił deletedRecord" + assert element.text == "transient" + + +# -- cztery drogi zniknięcia -------------------------------------------- + + +def _ukryj_kosz(praca, uczelnia): + praca.delete() + + +def _ukryj_opt_out(praca, uczelnia): + praca.nie_eksportuj_przez_api = True + praca.save() + + +def _ukryj_status(praca, uczelnia): + from bpp.models import Status_Korekty + from bpp.models.uczelnia import Ukryj_Status_Korekty + + status = Status_Korekty.objects.get_or_create(nazwa="wycofany z eksportu")[0] + # Fixture ``uczelnia`` nie ma żadnego statusu ukrytego w kanale ``cerif``, + # więc trzecią drogę zniknięcia musimy tu zbudować, a nie pominąć. + Ukryj_Status_Korekty.objects.create( + uczelnia=uczelnia, status_korekty=status, cerif=True + ) + praca.status_korekty = status + praca.save() + + assert list(uczelnia.ukryte_statusy("cerif")), ( + "test musi mieć status ukryty w kanale cerif — bez tego przypadek " + "nie odtwarza trzeciej drogi zniknięcia" + ) + + +def _ukryj_odpiecie_autora(praca, uczelnia): + # Skasowanie autorstwa to soft-delete (faza 02) — wiersz zostaje w koszu + # i to on trzyma historyczną atrybucję rekordu do uczelni. + praca.autorzy_set.first().delete() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "ukryj", + [_ukryj_kosz, _ukryj_opt_out, _ukryj_status, _ukryj_odpiecie_autora], + ids=["kosz", "opt_out", "ukryty_status", "odpiecie_autora"], +) +def test_kazda_droga_znikniecia_daje_nagrobek(uczelnia, jednostka, typ_autor, ukryj): + """Cztery drogi, jeden skutek dla harvestera — więc jeden nagrobek. + + Przypadek ``odpiecie_autora`` jest tu najważniejszy: dowodzi, że + ``przynaleznosc`` idzie przez ``global_objects`` modelu autorstwa. + Gdyby szła przez ``objects``, rekord wypadłby z nadzbioru i zniknął + po cichu — czyli wróciłaby dokładnie ta luka, którą faza zamyka. + + Przypadek ``kosz`` dowodzi tego samego o zewnętrznym managerze rekordu. + """ + from model_bakery import baker + + from bpp.models import Autor, Wydawnictwo_Ciagle + from cerif_export import const + + praca = baker.make(Wydawnictwo_Ciagle) + praca.dodaj_autora(baker.make(Autor, nazwisko="Kowalski", imiona="Jan"), jednostka) + + provider = rejestr_providerow()[const.SET_PUBLICATIONS] + assert ( + not provider.nagrobki(uczelnia, Wydawnictwo_Ciagle).filter(pk=praca.pk).exists() + ), "rekord widoczny nie może być nagrobkiem" + + ukryj(praca, uczelnia) + + assert ( + provider.nagrobki(uczelnia, Wydawnictwo_Ciagle).filter(pk=praca.pk).exists() + ), "rekord przestał być widoczny, a nie dostał nagrobka" + + +# -- okno from/until i granica strony ----------------------------------- + + +def _ustaw_datestamp(model, pk, wartosc): + """``ostatnio_zmieniony`` ma ``auto_now``, więc omijamy ``save()``.""" + model.objects.filter(pk=pk).update(ostatnio_zmieniony=wartosc) + + +def _dzien(numer): + import datetime + + return datetime.datetime(2024, 3, numer, 12, 0, 0, tzinfo=datetime.UTC) + + +@pytest.mark.django_db +def test_okno_od_do_obejmuje_nagrobki(uczelnia, jednostka): + """Nagrobek jest datowany i podlega ``from``/``until`` jak żywy rekord. + + Znacznik bierze się z ``ostatnio_zmieniony``, który soft-delete bumpuje + (kontrakt PINNED fazy 01) — bez tego harvest przyrostowy nigdy by + nagrobka nie zobaczył, bo konsument pyta zawsze o okno „od ostatniego + razu". + """ + from model_bakery import baker + + from bpp.models import Jednostka + from cerif_export import const + + ukryta = baker.make( + Jednostka, uczelnia=uczelnia, nazwa="Ukryta", skrot="UKR", widoczna=False + ) + _ustaw_datestamp(Jednostka, ukryta.pk, _dzien(10)) + + provider = rejestr_providerow()[const.SET_ORGUNITS] + + def pk_jednostek(**kwargs): + pary, _ = provider.strona(uczelnia, rozmiar=100, **kwargs) + return { + obiekt.pk for obiekt, _nagrobek in pary if isinstance(obiekt, Jednostka) + } + + assert ukryta.pk in pk_jednostek(od=_dzien(9)), ( + "nagrobek wypadł z okna `from` — harvest przyrostowy go nie zobaczy" + ) + assert ukryta.pk in pk_jednostek(od=_dzien(9), do=_dzien(11)) + assert ukryta.pk not in pk_jednostek(do=_dzien(9)), ( + "nagrobek z przyszłości wszedł w okno `until`" + ) + assert ukryta.pk not in pk_jednostek(od=_dzien(11)) + + +@pytest.mark.django_db +def test_harvest_po_tokenach_nie_gubi_i_nie_dubluje_na_granicy_nagrobka( + uczelnia, jednostka +): + """Strona kończy się DOKŁADNIE na nagrobku — bez duplikatu i bez luki. + + To tu żyły wcześniejsze bugi ``Trunc``/``tzinfo`` opisane + w ``z_datestampem``: kursor i wartość sortowania rozjeżdżały się + o mikrosekundy albo o offset strefy, więc rekord graniczny wracał na + następnej stronie (duplikat) albo znikał (luka). Nagrobki nie mogą tego + przywrócić, bo płyną tym samym strumieniem i tym samym kursorem. + """ + from model_bakery import baker + + from bpp.models import Jednostka + from cerif_export import const + + ROZMIAR = 3 + + _ustaw_datestamp(Jednostka, jednostka.pk, _dzien(1)) + oczekiwane = {jednostka.pk: False} + + # Pozycje 3 i 6 w porządku dat są nagrobkami — przy rozmiarze 3 pierwsza + # strona kończy się dokładnie na nagrobku. + for numer in range(2, 8): + widoczna = numer not in (3, 6) + obiekt = baker.make( + Jednostka, + uczelnia=uczelnia, + nazwa=f"Jednostka {numer}", + skrot=f"J{numer}", + widoczna=widoczna, + ) + _ustaw_datestamp(Jednostka, obiekt.pk, _dzien(numer)) + oczekiwane[obiekt.pk] = not widoczna + + provider = rejestr_providerow()[const.SET_ORGUNITS] + + pierwsza, kursor = provider.strona(uczelnia, rozmiar=ROZMIAR) + assert kursor is not None, "harvest musi być wielostronicowy" + assert pierwsza[-1][1] is True, ( + "setup się rozjechał: strona nie kończy się na nagrobku, " + "czyli test nie bada granicy, o którą chodzi" + ) + + zebrane = list(pierwsza) + for _ in range(50): + if kursor is None: + break + partia, kursor = provider.strona(uczelnia, kursor=kursor, rozmiar=ROZMIAR) + zebrane.extend(partia) + else: + raise AssertionError("harvest nie zakończył się po 50 stronach") + + klucze = [(type(obiekt).__name__, obiekt.pk) for obiekt, _nagrobek in zebrane] + assert len(klucze) == len(set(klucze)), ( + f"rekord wyszedł dwa razy na granicy strony: {klucze}" + ) + + otrzymane = { + obiekt.pk: nagrobek + for obiekt, nagrobek in zebrane + if isinstance(obiekt, Jednostka) + } + assert otrzymane == oczekiwane, ( + "harvest zgubił rekord albo pomylił żywego z nagrobkiem" + ) + + +# -- walidacja XSD odpowiedzi z nagrobkami ------------------------------ + + +@pytest.fixture(scope="module") +def schemat_koperty(): + """``XMLSchema`` koperty OAI-PMH razem z ładunkiem profilu CERIF. + + Testy serializerów walidują pojedyncze encje względem profilu; tu + walidujemy CAŁĄ odpowiedź, bo ``status="deleted"`` jest konstrukcją + koperty OAI-PMH, nie profilu. Bez tego łatwo wyemitować XML, który + agregator odrzuci — a dowiedzielibyśmy się o tym od niego. + """ + import pathlib + + from lxml import etree + + from cerif_export.tests.test_serializery import ResolverLokalny + + katalog = pathlib.Path(__file__).parent / "xsd" + parser = etree.XMLParser(no_network=True) + parser.resolvers.add(ResolverLokalny()) + return etree.XMLSchema(etree.parse(str(katalog / "oai-pmh-z-profilem.xsd"), parser)) + + +def _zwaliduj(schemat, korzen): + from lxml import etree + + dokument = etree.fromstring(etree.tostring(korzen)) + if not schemat.validate(dokument): + raise AssertionError( + "odpowiedź nie przechodzi XSD OAI-PMH:\n" + + "\n".join(str(b) for b in schemat.error_log) + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("czasownik", ["ListRecords", "ListIdentifiers"]) +def test_odpowiedz_z_nagrobkiem_przechodzi_xsd( + schemat_koperty, uczelnia, jednostka, czasownik +): + """Nagrobek obok żywego rekordu musi być poprawny wobec schematu.""" + from model_bakery import baker + + from bpp.models import Jednostka + from cerif_export import const + + baker.make( + Jednostka, uczelnia=uczelnia, nazwa="Ukryta", skrot="UKR", widoczna=False + ) + + korzen = _wykonaj( + uczelnia, + verb=czasownik, + metadataPrefix=const.METADATA_PREFIX, + set=const.SET_ORGUNITS, + ) + + naglowki = korzen.findall(f".//{{{NS_PMH}}}header") + assert [h for h in naglowki if h.get("status") == "deleted"], ( + "odpowiedź bez nagrobka nie testuje tego, o co chodzi" + ) + assert [h for h in naglowki if h.get("status") is None], ( + "odpowiedź bez żywego rekordu nie sprawdza sąsiedztwa obu rodzajów" + ) + + _zwaliduj(schemat_koperty, korzen) + + +@pytest.mark.django_db +def test_getrecord_z_nagrobkiem_przechodzi_xsd(schemat_koperty, uczelnia): + """GetRecord na nagrobku: ```` z samym ``
``. + + Schemat dopuszcza ```` jako ``minOccurs="0"``, więc rekord bez + ładunku jest poprawny — ale tylko dlatego, że NIE dokładamy pustego + ````. + """ + from model_bakery import baker + + from bpp.models import Jednostka + from cerif_export import const, identyfikatory + + ukryta = baker.make( + Jednostka, uczelnia=uczelnia, nazwa="Ukryta", skrot="UKR", widoczna=False + ) + zadanie = _zadanie_dla(uczelnia) + + korzen = _wykonaj( + uczelnia, + verb="GetRecord", + identifier=identyfikatory.zbuduj(zadanie.namespace, ukryta), + metadataPrefix=const.METADATA_PREFIX, + ) + _zwaliduj(schemat_koperty, korzen) diff --git a/src/cerif_export/tests/test_oai.py b/src/cerif_export/tests/test_oai.py index 3a886c681..970a3a21a 100644 --- a/src/cerif_export/tests/test_oai.py +++ b/src/cerif_export/tests/test_oai.py @@ -75,14 +75,18 @@ def strona(self, uczelnia, od=None, do=None, kursor=None, rozmiar=None): pozostale = [o for o in pozostale if self._klucz(o) > granica] partia = pozostale[:rozmiar] + # Od fazy 05b kontrakt to pary ``(obiekt, czy_nagrobek)``. Atrapa + # trzyma wyłącznie żywe rekordy — nagrobki mają własną suitę + # (``test_nagrobki.py``), która chodzi po REALNYCH providerach. + oznaczone = [(obiekt, False) for obiekt in partia] if len(pozostale) > rozmiar: ostatni = partia[-1] - return partia, Kursor( + return oznaczone, Kursor( slug=identyfikatory.slug_dla(ostatni), ts=na_datestamp(getattr(ostatni, ADNOTACJA_TS)), pk=ostatni.pk, ) - return partia, None + return oznaczone, None def pojedynczy(self, uczelnia, model, pk): for obiekt in self.obiekty: @@ -90,6 +94,11 @@ def pojedynczy(self, uczelnia, model, pk): return obiekt return None + def widoczne_pk_ze_strony(self, uczelnia, model, obiekty): + # Atrapa trzyma wyłącznie żywe rekordy — wszystko, co w niej jest, + # jest z definicji wystawiane. Nagrobki ma ``test_nagrobki.py``. + return frozenset(obiekt.pk for obiekt in obiekty) + def zbiory_widocznosci(self, uczelnia, obiekty): return ZbioryWidocznosci() diff --git a/src/cerif_export/tests/test_originates_from.py b/src/cerif_export/tests/test_originates_from.py index 97259d287..b81debb22 100644 --- a/src/cerif_export/tests/test_originates_from.py +++ b/src/cerif_export/tests/test_originates_from.py @@ -45,6 +45,7 @@ from cerif_export.kontekst import KontekstSerializacji from cerif_export.oai import czasowniki from cerif_export.providers import provider_dla_setu +from cerif_export.tests.pomocnicze import strona_zywych NAMESPACE = "cerif.example.org" BASE_URL = "https://cerif.example.org/cerif-oai/" @@ -529,7 +530,7 @@ def zapytania_dla(ile): przypnij_grant(rekord, f"NR/{numer}", projekt=wlasny) with CaptureQueriesContext(connection) as licznik: - obiekty, _ = provider.strona(uczelnia, rozmiar=100) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=100) obiekty = list(obiekty) kontekst = KontekstSerializacji( namespace=NAMESPACE, diff --git a/src/cerif_export/tests/test_providery.py b/src/cerif_export/tests/test_providery.py index d95af4247..4af20d0fc 100644 --- a/src/cerif_export/tests/test_providery.py +++ b/src/cerif_export/tests/test_providery.py @@ -38,6 +38,7 @@ ) from cerif_export.providers.base import ADNOTACJA_TS from cerif_export.providers.publikacje import ADNOTACJA_COAR, coar_pracy +from cerif_export.tests.pomocnicze import strona_zywych # -- helpery budujące dane ---------------------------------------------- @@ -170,15 +171,15 @@ def test_strona_zwraca_kursor_wewnatrz_modelu( rekord = fabryka_wydawnictw(Wydawnictwo_Ciagle) ustaw_datestamp(Wydawnictwo_Ciagle, rekord.pk, dt(1 + i)) - obiekty, kursor = provider_publikacji.strona(uczelnia_cerif, rozmiar=2) + obiekty, kursor = strona_zywych(provider_publikacji, uczelnia_cerif, rozmiar=2) assert len(obiekty) == 2 assert kursor is not None assert kursor.slug == slug_dla(Wydawnictwo_Ciagle) assert kursor.pk == obiekty[-1].pk - reszta, kolejny = provider_publikacji.strona( - uczelnia_cerif, kursor=kursor, rozmiar=2 + reszta, kolejny = strona_zywych( + provider_publikacji, uczelnia_cerif, kursor=kursor, rozmiar=2 ) assert len(reszta) == 1 assert kolejny is None @@ -225,15 +226,15 @@ def test_kursor_na_granicy_modelu_wskazuje_ostatni_wydany_rekord( for _ in range(2): fabryka_wydawnictw(Wydawnictwo_Zwarte) - obiekty, kursor = provider_publikacji.strona(uczelnia_cerif, rozmiar=2) + obiekty, kursor = strona_zywych(provider_publikacji, uczelnia_cerif, rozmiar=2) assert {slug_dla(o) for o in obiekty} == {slug_dla(Wydawnictwo_Ciagle)} assert kursor is not None assert kursor.slug == slug_dla(Wydawnictwo_Ciagle) assert kursor.pk == obiekty[-1].pk - reszta, kolejny = provider_publikacji.strona( - uczelnia_cerif, kursor=kursor, rozmiar=2 + reszta, kolejny = strona_zywych( + provider_publikacji, uczelnia_cerif, kursor=kursor, rozmiar=2 ) assert {slug_dla(o) for o in reszta} == {slug_dla(Wydawnictwo_Zwarte)} assert kolejny is None @@ -252,7 +253,7 @@ def test_brak_tokenu_gdy_kolejne_modele_sa_puste( for _ in range(2): fabryka_wydawnictw(Wydawnictwo_Ciagle) - obiekty, kursor = provider_publikacji.strona(uczelnia_cerif, rozmiar=2) + obiekty, kursor = strona_zywych(provider_publikacji, uczelnia_cerif, rozmiar=2) assert len(obiekty) == 2 assert kursor is None @@ -327,7 +328,7 @@ def test_rekord_z_nullowym_datestampem_nie_ginie( zwykly = fabryka_wydawnictw(Wydawnictwo_Ciagle) ustaw_datestamp(Wydawnictwo_Ciagle, zwykly.pk, dt(5)) - obiekty, _ = provider_publikacji.strona(uczelnia_cerif) + obiekty, _ = strona_zywych(provider_publikacji, uczelnia_cerif) pk_wc = [o.pk for o in obiekty if isinstance(o, Wydawnictwo_Ciagle)] assert z_nullem.pk in pk_wc @@ -344,7 +345,7 @@ def test_rekord_z_nullowym_datestampem_lapie_sie_w_zakres( z_nullem = fabryka_wydawnictw(Wydawnictwo_Ciagle) ustaw_datestamp(Wydawnictwo_Ciagle, z_nullem.pk, None) - obiekty, _ = provider_publikacji.strona(uczelnia_cerif, do=dt(1)) + obiekty, _ = strona_zywych(provider_publikacji, uczelnia_cerif, do=dt(1)) assert [o.pk for o in obiekty if isinstance(o, Wydawnictwo_Ciagle)] == [z_nullem.pk] @@ -361,13 +362,13 @@ def test_filtry_od_do_zawezaja_wynik( nowy = fabryka_wydawnictw(Wydawnictwo_Ciagle) ustaw_datestamp(Wydawnictwo_Ciagle, nowy.pk, dt(20)) - obiekty, _ = provider_publikacji.strona(uczelnia_cerif, od=dt(5), do=dt(15)) + obiekty, _ = strona_zywych(provider_publikacji, uczelnia_cerif, od=dt(5), do=dt(15)) assert [o.pk for o in obiekty] == [srodkowy.pk] - obiekty, _ = provider_publikacji.strona(uczelnia_cerif, od=dt(5)) + obiekty, _ = strona_zywych(provider_publikacji, uczelnia_cerif, od=dt(5)) assert [o.pk for o in obiekty] == [srodkowy.pk, nowy.pk] - obiekty, _ = provider_publikacji.strona(uczelnia_cerif, do=dt(15)) + obiekty, _ = strona_zywych(provider_publikacji, uczelnia_cerif, do=dt(15)) assert [o.pk for o in obiekty] == [stary.pk, srodkowy.pk] @@ -396,6 +397,13 @@ def test_najstarszy_datestamp_pustego_setu_to_none(uczelnia_cerif, provider_publ def test_pojedynczy_zwraca_obiekt_i_none( uczelnia_cerif, fabryka_wydawnictw, provider_publikacji ): + """``pojedynczy`` szuka w nadzbiorze; ``None`` znaczy „nie nasz". + + Od fazy 05b rekord ukryty JEST odnajdywany — GetRecord ma na niego + odpowiedzieć nagrobkiem, nie ``idDoesNotExist``. O tym, czy jest żywy, + rozstrzyga dopiero ``widoczne_pk_ze_strony``. ``None`` zostaje dla + rekordów spoza tenanta i nieistniejących. + """ widoczny = fabryka_wydawnictw(Wydawnictwo_Ciagle) ukryty = fabryka_wydawnictw(Wydawnictwo_Ciagle, nie_eksportuj_przez_api=True) @@ -405,8 +413,19 @@ def test_pojedynczy_zwraca_obiekt_i_none( ).pk == widoczny.pk ) + + znaleziony = provider_publikacji.pojedynczy( + uczelnia_cerif, Wydawnictwo_Ciagle, ukryty.pk + ) + assert znaleziony is not None, "ukryty rekord tenanta musi dać się odnaleźć" + zywe = provider_publikacji.widoczne_pk_ze_strony( + uczelnia_cerif, Wydawnictwo_Ciagle, [znaleziony] + ) + assert znaleziony.pk not in zywe, "ukryty rekord ma być oznaczony jako nagrobek" + + obcy_pk = max(widoczny.pk, ukryty.pk) + 1000 assert ( - provider_publikacji.pojedynczy(uczelnia_cerif, Wydawnictwo_Ciagle, ukryty.pk) + provider_publikacji.pojedynczy(uczelnia_cerif, Wydawnictwo_Ciagle, obcy_pk) is None ) @@ -446,7 +465,7 @@ def test_praca_niesie_gotowy_typ_coar_bez_dotykania_slownika( status_korekty=status_ok, rok=2020, ) - obiekty, _ = provider_publikacji.strona(uczelnia_cerif) + obiekty, _ = strona_zywych(provider_publikacji, uczelnia_cerif) praca = next(o for o in obiekty if isinstance(o, Praca_Doktorska)) assert getattr(praca, ADNOTACJA_COAR).startswith("http://purl.org/coar/") @@ -511,7 +530,7 @@ def test_liczba_zapytan_nie_rosnie_z_liczba_rekordow( fabryka_wydawnictw(Wydawnictwo_Zwarte) with django_assert_max_num_queries(BUDZET_ZAPYTAN_PUBLIKACJE): - obiekty, _ = provider_publikacji.strona(uczelnia_cerif, rozmiar=1000) + obiekty, _ = strona_zywych(provider_publikacji, uczelnia_cerif, rozmiar=1000) dotknij_publikacje(obiekty) assert len(obiekty) == 2 * ile + 1 @@ -532,7 +551,7 @@ def test_zbiory_widocznosci_to_stala_liczba_zapytan( for _ in range(ile): fabryka_wydawnictw(Wydawnictwo_Ciagle, zrodlo=zrodlo, konferencja=konferencja) - obiekty, _ = provider_publikacji.strona(uczelnia_cerif, rozmiar=1000) + obiekty, _ = strona_zywych(provider_publikacji, uczelnia_cerif, rozmiar=1000) with django_assert_max_num_queries(5): zbiory = provider_publikacji.zbiory_widocznosci(uczelnia_cerif, obiekty) @@ -586,7 +605,9 @@ def _przejdz_wszystkie_strony(provider, uczelnia, rozmiar): """Przejdź cały set stronami, pilnując, żeby pętla się skończyła.""" kursor = None for _ in range(50): - obiekty, kursor = provider.strona(uczelnia, kursor=kursor, rozmiar=rozmiar) + obiekty, kursor = strona_zywych( + provider, uczelnia, kursor=kursor, rozmiar=rozmiar + ) if obiekty: yield obiekty if kursor is None: diff --git a/src/cerif_export/tests/test_serializery.py b/src/cerif_export/tests/test_serializery.py index b1107911a..42d1cb5c8 100644 --- a/src/cerif_export/tests/test_serializery.py +++ b/src/cerif_export/tests/test_serializery.py @@ -31,6 +31,7 @@ ) from cerif_export.kontekst import KontekstSerializacji from cerif_export.providers import provider_dla_setu +from cerif_export.tests.pomocnicze import strona_zywych KATALOG_XSD = pathlib.Path(__file__).parent / "xsd" NAMESPACE = "cerif.example.org" @@ -81,7 +82,7 @@ def sprawdz(schemat, element): def kontekst_setu(uczelnia, set_spec): provider = provider_dla_setu(set_spec) - obiekty, _ = provider.strona(uczelnia, rozmiar=1000) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=1000) kontekst = KontekstSerializacji( namespace=NAMESPACE, uczelnia=uczelnia, diff --git a/src/cerif_export/tests/test_widocznosc.py b/src/cerif_export/tests/test_widocznosc.py index 16c1f0400..bd5405d9f 100644 --- a/src/cerif_export/tests/test_widocznosc.py +++ b/src/cerif_export/tests/test_widocznosc.py @@ -25,19 +25,20 @@ from cerif_export.kontekst import KontekstSerializacji from cerif_export.providers import provider_dla_setu from cerif_export.slowniki import coar +from cerif_export.tests.pomocnicze import strona_zywych NAMESPACE = "cerif.example.org" def pki(provider, uczelnia): - obiekty, _ = provider.strona(uczelnia, rozmiar=1000) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=1000) return {o.pk for o in obiekty} def zserializuj(uczelnia, rekord): """Zserializuj publikację w kontekście zbudowanym przez provider.""" provider = provider_dla_setu(const.SET_PUBLICATIONS) - obiekty, _ = provider.strona(uczelnia, rozmiar=1000) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=1000) swiezy = next(o for o in obiekty if o.pk == rekord.pk and type(o) is type(rekord)) kontekst = KontekstSerializacji( namespace=NAMESPACE, @@ -287,7 +288,7 @@ def test_typ_coar_pracy_dociera_do_xml(uczelnia, jednostka, fabryka_autorow, sta ) provider = provider_dla_setu(const.SET_PUBLICATIONS) - obiekty, _ = provider.strona(uczelnia, rozmiar=100) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=100) kontekst = KontekstSerializacji( namespace=NAMESPACE, uczelnia=uczelnia, @@ -389,7 +390,7 @@ def test_orcid_ukrytego_autora_nie_wycieka( def _kontekst(uczelnia, set_spec): provider = provider_dla_setu(set_spec) - obiekty, _ = provider.strona(uczelnia, rozmiar=100) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=100) return obiekty, KontekstSerializacji( namespace=NAMESPACE, uczelnia=uczelnia, @@ -451,7 +452,7 @@ def zserializuj_jednostke(uczelnia, jednostka): from cerif_export.cerif import orgunit provider = provider_dla_setu(const.SET_ORGUNITS) - obiekty, _ = provider.strona(uczelnia, rozmiar=1000) + obiekty, _ = strona_zywych(provider, uczelnia, rozmiar=1000) swiezy = next(o for o in obiekty if o.pk == jednostka.pk and type(o) is Jednostka) kontekst = KontekstSerializacji( namespace=NAMESPACE, diff --git a/src/cerif_export/tests/test_znak_towarowy_i_ror_modelu.py b/src/cerif_export/tests/test_znak_towarowy_i_ror_modelu.py index a35f2cff4..088edfa20 100644 --- a/src/cerif_export/tests/test_znak_towarowy_i_ror_modelu.py +++ b/src/cerif_export/tests/test_znak_towarowy_i_ror_modelu.py @@ -7,6 +7,7 @@ from bpp.models import Jednostka, Patent, Rodzaj_Prawa_Patentowego, Uczelnia from cerif_export import const from cerif_export.providers import provider_dla_setu +from cerif_export.tests.pomocnicze import strona_zywych @pytest.mark.django_db @@ -49,7 +50,9 @@ def test_znak_towarowy_nie_wychodzi_w_eksporcie( ) towarowy.dodaj_autora(autor, jednostka) - obiekty, _ = provider_dla_setu(const.SET_PATENTS).strona(uczelnia, rozmiar=100) + obiekty, _ = strona_zywych( + provider_dla_setu(const.SET_PATENTS), uczelnia, rozmiar=100 + ) pki = {o.pk for o in obiekty} assert prawdziwy.pk in pki @@ -72,7 +75,9 @@ def test_patent_bez_rodzaju_prawa_nadal_wychodzi( ) patent.dodaj_autora(fabryka_autorow("Autor"), jednostka) - obiekty, _ = provider_dla_setu(const.SET_PATENTS).strona(uczelnia, rozmiar=100) + obiekty, _ = strona_zywych( + provider_dla_setu(const.SET_PATENTS), uczelnia, rozmiar=100 + ) assert patent.pk in {o.pk for o in obiekty} diff --git a/src/cerif_export/tests/xsd/OAI-PMH.xsd b/src/cerif_export/tests/xsd/OAI-PMH.xsd new file mode 100644 index 000000000..3fce3b59d --- /dev/null +++ b/src/cerif_export/tests/xsd/OAI-PMH.xsd @@ -0,0 +1,317 @@ + + + + + XML Schema which can be used to validate replies to all OAI-PMH + v2.0 requests. Herbert Van de Sompel, 2002-05-13. + Validated with XML Spy v.4.3 on 2002-05-13. + Validated with XSV 1.203.2.45/1.106.2.22 on 2002-05-13. + Added definition of protocolVersionType instead of using anonymous + type. No change of function. Simeon Warner, 2004-03-29. + Tightened definition of UTCdatetimeType to enforce the restriction + to UTC Z notation. Simeon Warner, 2004-09-14. + Corrected pattern matches for setSpecType and metadataPrefixType + to agree with protocol specification. Simeon Warner, 2004-10-12. + Spelling correction. Simeon Warner, 2008-12-07. + $Date: 2004/10/12 15:20:29 $ + + + + + + + + + + + + + + + + + + + + + + + + Define requestType, indicating the protocol request that + led to the response. Element content is BASE-URL, attributes are arguments + of protocol request, attribute-values are values of arguments of protocol + request + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A record has a header, a metadata part, and + an optional about container + + + + + + + + + + + A header has a unique identifier, a datestamp, + and setSpec(s) in case the item from which + the record is disseminated belongs to set(s). + the header can carry a deleted status indicating + that the record is deleted. + + + + + + + + + + + + + + + + + + + + + + Metadata must be expressed in XML that complies + with another XML Schema (namespace=#other). Metadata must be + explicitly qualified in the response. + + + + + + + + + Data "about" the record must be expressed in XML + that is compliant with an XML Schema defined by a community. + + + + + + + + + A resumptionToken may have 3 optional attributes + and can be used in ListSets, ListIdentifiers, ListRecords + responses. + + + + + + + + + + + + + The descriptionType is used for the description + element in Identify and for setDescription element in ListSets. + Content must be compliant with an XML Schema defined by a + community. + + + + + + + + + Datestamps are to either day (type date) + or to seconds granularity (type oai:UTCdateTimeZType) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/cerif_export/tests/xsd/README.md b/src/cerif_export/tests/xsd/README.md index 4546277c5..9464eee85 100644 --- a/src/cerif_export/tests/xsd/README.md +++ b/src/cerif_export/tests/xsd/README.md @@ -46,6 +46,26 @@ czego w testach jednostkowych nie chcemy. typy finansowania i kompatybilności OpenAIRE), - `cached/xml.xsd` — schemat namespace `xml:` (dla atrybutu `xml:lang`). +### Koperta OAI-PMH (dołożone w fazie 05b — nagrobki) + +- `OAI-PMH.xsd` — schemat **protokołu** OAI-PMH 2.0 (namespace + `http://www.openarchives.org/OAI/2.0/`), pobrany z + dnia 2026-08-16. + Plik jest samowystarczalny (zero `import`/`include`). +- `oai-pmh-z-profilem.xsd` — nasz plik spinający, importuje oba namespace'y + do jednego `XMLSchema`. + +Po co: `status="deleted"` na `
` jest konstrukcją **koperty**, nie +profilu CERIF, więc testy serializerów (walidujące pojedyncze encje) nigdy by +go nie sprawdziły. `test_nagrobki.py` waliduje całą odpowiedź. + +Dlaczego plik spinający, a nie sam `OAI-PMH.xsd`: element `` jest +w nim zadeklarowany jako ``. +„Strict" znaczy, że walidator musi znać schemat ładunku — bez zaimportowanego +profilu CERIF każdy rekord **żywy** wywalałby się na „no matching global +declaration", czyli test przechodziłby tylko dla odpowiedzi złożonych z samych +nagrobków. + Katalog `vocabularies/00-preparations/` z repozytorium źródłowego **nie** został skopiowany — to materiały robocze do generowania słowników, nie są importowane przez żaden schemat. diff --git a/src/cerif_export/tests/xsd/oai-pmh-z-profilem.xsd b/src/cerif_export/tests/xsd/oai-pmh-z-profilem.xsd new file mode 100644 index 000000000..1a8477aa7 --- /dev/null +++ b/src/cerif_export/tests/xsd/oai-pmh-z-profilem.xsd @@ -0,0 +1,21 @@ + + + + + +