diff --git a/docs/deweloper/runbook-soft-delete-faza-01.md b/docs/deweloper/runbook-soft-delete-faza-01.md index cc51058da..175719135 100644 --- a/docs/deweloper/runbook-soft-delete-faza-01.md +++ b/docs/deweloper/runbook-soft-delete-faza-01.md @@ -98,6 +98,23 @@ ProgrammingError: kolumna old.deleted_at nie istnieje To jedyny nieudany rewers w całej serii — pozostałe wracają do stanu wyjściowego bit-w-bit (zweryfikowane zrzutem katalogu przed/po). +> **Faza 02 rozszerza to na tabele publikacji.** Migracja `0496` dokłada +> `deleted_at` do pięciu tabel publikacji, więc **każdy** zjazd poniżej +> `0496` — nie tylko poniżej `0488` — wpada w ten sam mechanizm. Zasada +> pozostaje ta sama i nie zmienia procedury: rollback wycofuje KOD razem ze +> schematem, a stare modele nie deklarują `deleted_at`, więc `denorm` +> wygeneruje poprawne triggery. Kombinacja „nowy kod + stary schemat" +> powstaje wyłącznie sztucznie. +> +> Skutek uboczny w testach: testy odwracalności fazy 01 +> (`test_migracja_0489_rewers`, `test_migracje_0490_0493_rewers`) schodzą +> poniżej `0496`, trzymając kod na miejscu — czyli produkują dokładnie tę +> zakazaną kombinację. Dlatego korzystają z fixtury +> `bez_reinstalacji_denorma` (`src/bpp/tests/test_soft_delete/conftest.py`), +> która odpina handler `post_migrate` na czas testu i przebudowuje triggery +> ręcznie na końcu. To obejście **testowe** — nie zmienia niczego we +> wdrożeniu. + Dodatkowo: rewers `0493` przywraca **bezwarunkowe** `UNIQUE (rekord_id, kolejnosc)`, które padnie, jeśli ktokolwiek zdążył skorzystać z soft-delete (soft-deletowane wiersze wyglądają wtedy jak duplikaty). Rollback po realnym diff --git a/docs/superpowers/HANDOFF-soft-delete-faza-03.md b/docs/superpowers/HANDOFF-soft-delete-faza-03.md new file mode 100644 index 000000000..45bc5ee94 --- /dev/null +++ b/docs/superpowers/HANDOFF-soft-delete-faza-03.md @@ -0,0 +1,186 @@ +# Handoff: soft-delete, start fazy 03 + +> Dokument przekazania po zamknięciu **fazy 02** (publikacje), 2026-08-07. +> +> **Czytaj to zamiast odtwarzania historii z gita.** Zawiera rzeczy, których +> nie widać w diffie, a które w fazie 02 kosztowały rundy poprawek. + +--- + +## 1. Gdzie jesteśmy + +| | | +|---|---| +| Gałąź | `feat/soft-delete-02`, worktree `~/Programowanie/bpp-soft-delete-02` | +| Baza | `feat/soft-delete` (faza 01, PR #312) — PR fazy 02 jest **stackowany**, nie na `dev` | +| Migracje | `bpp/0496`…`0499`, `rozbieznosci_dyscyplin/0023` | +| Testy | 306 passed / 1 xfailed (soft-delete+cache+rozbieżności), 416 passed (regresja publikacji) | + +**Faza 02 objęła 5 modeli publikacji** — `Wydawnictwo_Ciagle`, +`Wydawnictwo_Zwarte`, `Patent`, `Praca_Doktorska`, `Praca_Habilitacyjna`. + +Dokumenty warte otwarcia zanim cokolwiek zaczniesz: + +- **inwentaryzacja ORM (wejście fazy 03)**: + `docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md` + — z self-review, który obala część własnych wniosków. Czytaj RAZEM z nim. +- inwentaryzacja widoków: `…/2026-08-07-faza-02-inwentaryzacja-widokow.md` +- zgłoszenie upstream do `django-easy-audit`: + `…/2026-08-07-easyaudit-upstream-zgloszenie.md` + +--- + +## 2. Najważniejsza lekcja fazy 02 + +Faza 01 zostawiła listę **siedmiu** mechanizmów zakładających, że skasowany +wiersz znika. Faza 02 dołożyła **dwa kolejne** — oba spoza kodu, który +zmienialiśmy: + +| # | Mechanizm | Jak się objawił | +|---|---|---| +| 8 | `django-easy-audit` | pobiera poprzedni wiersz przez `sender.objects` → `restore()` leci `DoesNotExist`. **Błąd ZASTANY** — `Zgloszenie_Publikacji` było nieprzywracalne na `dev` już przed fazą 02 | +| 9 | odwrotne `OneToOne` | `autor.praca_habilitacyjna` idzie przez `_base_manager` (nieprzefiltrowany) → skasowana habilitacja nadal osiągalna, widok zwracał 200 zamiast 404 | + +**Wniosek dla fazy 03:** szukaj konsumentów NIE tylko w kodzie, który +zmieniasz, i NIE tylko w tym repo. Kronikę zweryfikowaliśmy dopiero po +sprawdzeniu repozytoriów siostrzanych (`bpp-deploy` faktycznie ją +referencuje — na szczęście tylko w komentarzach i jednej kontrolce). + +--- + +## 3. Co faza 03 dziedziczy — i czego NIE ma dziedziczyć + +### 3.1 `xfail(strict=True)`, który sam się upomni + +`test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at` jest +`xfail(strict=True)` z powodem „faza 03". Gdy naprawisz wycieki, test +zacznie padać jako XPASS i **wymusi** zdjęcie markera. Nie „napraw testu" — +zdejmij marker. + +Analogicznie działa `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`: +padnie, gdy upstream easyauditu scali poprawkę, i każe skasować +`src/bpp/easyaudit_shim.py`. + +### 3.2 ⚠️ Tabelka 10 wycieków NIE jest gotową listą zadań + +To najważniejsze zastrzeżenie całego handoffu. Kanarek ORM dopasowuje +**NAZWY**, nie modele. W fazie 01 działał świetnie, bo nazwy były +dystynktywne (`autorzy_set`). W fazie 02 to zwykłe słowa (`patent`, +`rekord`, `wydawnictwo_ciagle`) i precyzja się załamuje: + +- lista 5 nazw daje 14 znalezisk, +- dołożenie `rekord` i `wydawnictwo_nadrzedne` daje **120**, +- z czego większość to fałszywe trafienia, bo `rekord` oznacza raz + publikację, raz widok `Rekord` (**już przefiltrowany** przez `0497`), + a raz parametr GET albo klucz formularza. + +**Nie rozszerzaj listy `RELACJE` i nie triażuj 120 pozycji ręcznie.** +Zbuduj narzędzie **model-aware**: rozwiązujące ścieżkę lookupu wobec +`_meta` Django i pytające, czy faktycznie dochodzi do tabeli soft-delete — +czyli robiące dla ORM to, co kanarek katalogowy robi na `pg_depend`. + +### 3.3 Klasa wycieku, której kanarek NIE MOŻE złapać + +Dostęp **atrybutowy** do relacji (`autor.praca_habilitacyjna`, +`*.wydawnictwo_nadrzedne`) omija soft-delete i nie jest wywołaniem ORM, więc +AST-owy skaner go nie widzi. Potrzebny osobny przegląd. Naprawa centralna +odpada: `Meta.base_manager_name` na menedżer filtrujący jest przez Django +jawnie odradzany (rozwaliłby deserializację i `refresh_from_db`). + +--- + +## 4. Fakty o kodzie, które w fazie 02 kosztowały rundę poprawek + +- **`_base_manager` jest NIEprzefiltrowany** i Django tworzy go sam, gdy + model nie ustawia `Meta.base_manager_name`. Ratuje to easyaudit i psuje + trawersowanie relacji — ta sama właściwość, dwa przeciwne skutki. +- **`slug` zawiera `pk`** (`models/util.py:246-254`: + `tytuł-źródło-autorzy--`). Kolizja slugów jest + konstrukcyjnie niemożliwa — dlatego Task 3 planu został świadomie + niewykonany (patrz box w planie fazy 02). +- **`slugify_function` BPP NIE obniża wielkości liter.** +- **Menedżer w ciele klasy przesłania ten z bazy abstrakcyjnej.** Dlatego + `Wydawnictwo_Ciagle.objects` pokazywało kosz aż do Taska 4, mimo że model + dziedziczył `BppPublikacjaSoftDeleteMixin`. +- **`ManagerModeliZOplataZaPublikacjeMixin` NIE jest menedżerem** — to czysty + mixin bez `get_queryset()`, więc kolejność baz w MRO menedżera NIE jest + nośna (sprawdzone mutacyjnie). Nośna jest druga baza. +- **`DJANGO_EASY_AUDIT_REGISTERED_CLASSES`** obejmuje 5 modeli publikacji + i `Zgloszenie_Publikacji`, ale NIE through-modele `*_Autor` — stąd faza 01 + nigdy nie trafiła na błąd easyauditu. +- **Denorm: tylko DWIE zależności celują w publikacje**, obie + `("self", "wydawnictwo_nadrzedne")` bez `only=`. Brak `only=` = wszystkie + kolumny w bramce, więc `deleted_at` wchodzi sam. Nie dokładaj + `denorm_always_only` — byłby martwym kodem. +- **Widoki publikacji mają TRZY różne kształty** (patrz docstring `0497`). + Wzorzec z `0489` pasuje tylko do dwóch z siedmiu. +- **Postgres normalizuje predykaty w definicjach widoków** — przy jednej + tabeli w zasięgu usuwa kwalifikację. Wywróciło to najpierw asercję testu, + potem funkcję `backward` migracji. + +--- + +## 5. Jak pracować (co się sprawdziło) + +### Mutacja jest tu jedynym dowodem, że test cokolwiek pilnuje + +W fazie 02 mutacje obaliły **cztery** rzeczy — w tym dwie moje własne: + +1. test kaskady przechodził częściowo przez efekt uboczny (odroczone + ograniczenie fazy 01 + `baker` nadający obu autorstwom `kolejnosc=0`), +2. test na pułapkę agregatu był bezwartościowy — odpytywał **cache**, którego + soft-delete autorstwa nie przelicza, więc nieświeży wiersz maskował + zdegenerowany widok. Wyrocznią musi być **widok**, +3. test wymiaru publikacji w sumach przechodził mimo wyłączenia OBU migracji, + bo kaskada `delete()` kasuje też autorstwa, a te są filtrowane od fazy 01. + Trzeba było testu **izolującego wymiar** (surowy UPDATE), +4. docstring o kolejności baz w MRO menedżera brzmiał wiarygodnie i był + nieprawdziwy. + +**Zasada:** przy każdym teście zapytaj, co musisz zepsuć, żeby spadł na +czerwono — i zepsuj to naprawdę. + +### Rozszerz kanarka NA STARCIE — to znowu się opłaciło + +Inwentaryzacja widoków na starcie fazy zajęła **19 sekund** i dała pełną +listę 15 widoków w 5 kategoriach, w tym **zadanie, którego nie było w planie** +(Task 2d: 6 widoków agregujących, w planie tylko jako ostrzeżenie „sprawdź"). +Kanarek fazy 01 dało się wywołać na rozszerzonej liście tabel BEZ zmiany +stałej modułowej — czyli bez commitowania czerwonego testu. + +### Czego nie powtarzać + +- ⚠️ **`ruff format` na całym katalogu** przeformatował 25 plików, których nie + dotykałem. Formatuj TYLKO swoje pliki, inaczej PR staje się nieczytelny. +- ⚠️ **`git stash` przy czystym drzewie to cichy no-op** — do baseline'owania + używaj `git checkout --detach `. +- ⚠️ **Zero zdarzeń audytu ≠ zepsuty kod.** Dwie niezależne bramki + (`dont_log_anonymous_crud_events` bez requestu, brak `settings.TEST` → + `on_commit` w rollbackowanej transakcji) dają ten sam objaw. +- ⚠️ Na tym hoście biegają cudze stacki. Kontenery ubijaj po ID, po + sprawdzeniu, czy ryuk jest martwy. + +--- + +## 6. Otwarte decyzje + +| Sprawa | Stan | +|---|---| +| **PR upstream do easyauditu** | gałąź gotowa w `~/Programowanie/django-easy-audit` (`fix/175-use-base-manager-in-pre-save`), przetestowana na Django 5.2 i 6.1. **Nie wypchnięta** — czeka na decyzję o koncie/forku. Tekst zgłoszenia gotowy | +| **Strategia wydania** | rekomendacja z fazy 01 bez zmian: scalać fazami, **wydać dopiero po 04**. Faza 03 jest obowiązkowa razem z 02 (bez niej re-import tworzy duplikaty) | +| Pomiar `0492` i narzutu GiST na kopii produkcyjnej | wciąż nikt nie zmierzył (dług fazy 01) | +| `bpp-deploy`: kontrolka „kronika views: N" | po `0499` wypisze 0 i może zmylić operatora. Nieblokujące, opisane w docstringu `0499` | + +## 7. Długi pozostałych faz + +- **03**: decyzja #14 („pomiń + zaraportuj") niewpięta w taski 2-5; test ma + literalny placeholder; `deduplikator_autorow/utils/merge.py` + zrefaktoryzowany. **Plus wszystko z §3 tego dokumentu.** +- **04**: nikt nie przeplata `AutorManager`; `Autor.restore()` musi nadpisać + `strict=False`; FK flips `CASCADE→PROTECT`. Uwaga: soft-skasowana + publikacja NADAL trzyma referencję O2O PROTECT do autora, więc autora nie + da się skasować — to zachowanie poprawne, ale faza 04 musi je obsłużyć w UI. +- **06**: `MetrykaAutora` trzyma listy ID prac w JSON-ach; Task 5 (shim + `zakolejkuj_*`) martwy po ustaleniach fazy 05 — usunąć. +- **07**: admin (kosz / przywróć / usuń trwale) — konsument `user`/`reason`, + które faza 02 już przepuszcza w sygnaturach. diff --git a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md index ef1fccc9f..d913674ac 100644 --- a/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md +++ b/docs/superpowers/plans/2026-06-04-soft-delete-02-publikacje.md @@ -105,8 +105,9 @@ Taski dopisywane po rewizjach wylądowały poza numeracją. Wykonuj w TEJ kolejn | 2 | Task 2 — 5 modeli + migracja pól | kolumny muszą istnieć przed DDL | | 3 | **Task 2b** — widoki + gałąź kasująca + bramka `WHEN` | czyta `deleted_at` z kroku 2 | | 4 | **Task 2c** — bramka denorm | drugi system triggerów | -| 5 | Task 3 — `slug` warunkowy unique | niezależny | +| ~~5~~ | ~~Task 3 — `slug` warunkowy unique~~ | **NIEWYKONANY świadomie** — slug zawiera `pk`, kolizja niemożliwa; patrz box przy tasku | | 6 | **Task 3b** — `unique_together` na `*_Autor` | niezależny | +| **3.5** | **Task 2d — sumy + rozbieżności (6 widoków)** | **DOPISANY** — inwentaryzacja kanarka na starcie fazy; w planie był tylko ostrzeżeniem (c) | | 7 | Task 4 — przeplecenie menedżerów | — | | 8 | Task 5 — testy integracyjne | wymaga 1-7 | | 9 | Task 6 — weryfikacja fazy | ostatni | @@ -377,7 +378,34 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " --- -## Task 3: `slug` — warunkowy `UniqueConstraint` (reuse slug po soft-delete) +## ~~Task 3: `slug` — warunkowy `UniqueConstraint`~~ → ŚWIADOMIE NIEWYKONANY + +> 🛑 **DECYZJA WŁAŚCICIELA, 2026-08-07: tego zadania NIE robimy.** +> `slug` zostaje `unique=True`, bezwarunkowo. To NIE jest dług ani +> przeoczenie — poniżej powód, żeby nikt nie „dokończył" tego w dobrej wierze. +> +> **Przesłanka zadania nie zachodzi.** `get_slug()` +> (`src/bpp/models/util.py:246-254`) skleja slug jako +> `tytuł-źródło-autorzy--`. Slug **zawiera klucz +> główny**, więc dwa różne wiersze nie mogą mieć tego samego slugu, a +> skasowany rekord nie zablokuje nowego — nowy dostaje nowe `pk`, czyli inny +> slug. Kolizja, którą to zadanie miało rozwiązać, jest konstrukcyjnie +> niemożliwa. ID jest w slugu CELOWO, właśnie po to, żeby slug był unikalny. +> +> Plan pisano najwyraźniej przy założeniu slugu opartego wyłącznie na +> tytule. Przy obecnej formule zamiana `unique=True` na +> `UniqueConstraint(condition=Q(deleted_at__isnull=True))` **osłabiłaby** +> gwarancję (unikalność tylko wśród żywych) i wymagała przebudowy indeksu na +> pięciu dużych tabelach — w zamian za rozwiązanie nieistniejącego problemu. +> +> Wyszło przy pisaniu testów TDD do tego zadania: padły wszystkie dziesięć, +> w tym ten, który powinien przechodzić JESZCZE PRZED zmianą. Denorm +> dodatkowo nadpisuje ręcznie ustawiony slug, więc kolizji nie da się nawet +> wywołać sztucznie. +> +> Poniższa treść zostaje wyłącznie jako kontekst historyczny. + +
oryginalna treść tasku (nieaktualna) **Files:** - Modify: `src/bpp/models/wydawnictwo_ciagle.py:246` (denorm `slug`: `unique=True`→brak unique; `Meta.constraints`) @@ -446,6 +474,8 @@ Zamiana `unique=True` na `models.UniqueConstraint(fields=["slug"], condition=Q(d Co-Authored-By: Claude Opus 4.8 (1M context) " ``` +
+ --- ## Task 2b: Widoki + gałąź kasująca + bramka `WHEN` dla 5 tabel publikacji (LUKA) @@ -892,7 +922,7 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " - [ ] 5 modeli publikacji to `SoftDeleteModel` (przez `BppPublikacjaSoftDeleteMixin`); migracje `deleted_at`/`restored_at`/`transaction_id` + indeks per model (`0490_`). - [ ] `delete(self, *args, user=None, reason="", **kwargs)` / `restore(self, *args, user=None, **kwargs)` — per-instancja `save()`, wąska kaskada na `autorzy_set` pod wspólnym `transaction_id`, BEZ refleksyjnej kaskady pakietu, BEZ bulk `update(deleted_at=)`. - [ ] `*_Streszczenie` (i pozostałe nie-soft dzieci) nietknięte; `delete()` nie rzuca `SoftDeleteException`. -- [ ] `slug` → warunkowy `UniqueConstraint(condition=Q(deleted_at__isnull=True))` (`0491_`); reuse slug po soft-delete działa. +- [x] ~~`slug` → warunkowy `UniqueConstraint`~~ — **świadomie NIEwykonane.** `slug` zostaje `unique=True`; slug zawiera `pk`, więc kolizja jest konstrukcyjnie niemożliwa i warunkowanie tylko osłabiłoby gwarancję. Uzasadnienie w boxie przy Tasku 3. - [ ] `Wydawnictwo_*_Manager` przeplecione: `objects` filtruje `deleted_at` ORAZ ma `rekordy_z_oplata()`/`wydawnictwa_nadrzedne_dla_innych()`; `global_objects`/`deleted_objects` dostępne na wszystkich 5 modelach. - [ ] Testy: kaskada wspólny txid, znika z Rekord/Autorzy + restore, restore `*_Autor`, `post_soft_delete`, `*_Streszczenie` nietknięte, gate bulk-update — zielone. - [ ] `makemigrations --check --dry-run bpp` → `No changes detected`. Istniejące migracje NIE modyfikowane. diff --git a/docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md b/docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md new file mode 100644 index 000000000..f54f85d55 --- /dev/null +++ b/docs/superpowers/reviews/2026-08-07-easyaudit-upstream-zgloszenie.md @@ -0,0 +1,113 @@ +# Zgłoszenie do upstreamu `django-easy-audit` — gotowy tekst + +**Gdzie wkleić:** komentarz do istniejącego +[issue #175](https://github.com/soynatan/django-easy-audit/issues/175) +(otwarte od 2021-02, dokładnie ten problem). Nowego issue NIE zakładamy — +byłby piątym równoległym wątkiem o tej samej sprawie. + +**Dlaczego warto mimo czterech nieudanych prób:** poprzednie zgłoszenia +(#168, #176, #318, #342) nie zawierały informacji, która jest tu kluczowa — +że obejście sugerowane przez maintainera jest **nieosiągalne**. To jedyna +nowa przesłanka, jaką wnosimy do dyskusji. + +**Po scaleniu:** skasować `src/bpp/easyaudit_shim.py` + wywołanie +`zainstaluj()` w `BppConfig.ready()` + `test_easyaudit_shim.py`. Przypomni +o tym test `test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny`, który +wtedy zacznie padać. + +--- + +## Treść komentarza (EN) + +> ### The documented workaround for this issue is unreachable +> +> We hit this on a Django 5.2 project with soft-deletable models +> (`django-soft-delete`), on `django-easy-audit==1.3.9`. I want to add one +> piece of information that I don't think has been stated in this thread or +> in the related PRs (#168, #176, #318, #342), because I believe it explains +> why the fix keeps stalling. +> +> In #168 the suggestion was to handle this via +> `DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS`, letting the application +> decide whether to create the `CRUDEvent`. +> +> **That workaround cannot work for this bug.** The exception is raised +> *before* the callbacks are ever consulted. In `signals/model_signals.py` +> (1.3.9): +> +> ```python +> # line 102 — raises DoesNotExist +> old_model = sender.objects.get(pk=instance.pk) +> delta = model_delta(old_model, instance) +> ... +> # line 113 — callbacks are only reached here +> create_crud_event = call_callbacks(...) +> ``` +> +> So an application cannot opt out of the failing lookup: by the time it is +> asked, the lookup has already thrown. With +> `DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = True` (which we need, so that +> audit failures are not silently swallowed) the exception then propagates +> and aborts the user's write. +> +> ### Minimal reproduction +> +> ```python +> class Article(SoftDeleteModel): # objects filters deleted_at IS NULL +> title = models.CharField(max_length=100) +> +> # settings.py +> DJANGO_EASY_AUDIT_REGISTERED_CLASSES = ["myapp.Article"] +> DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = True +> +> a = Article.objects.create(title="x") +> a.delete() # soft delete +> Article.global_objects.get(pk=a.pk).restore() +> # -> Article.DoesNotExist: Article matching query does not exist +> ``` +> +> The row exists the whole time; it is merely hidden from `objects`. Any +> `save()` on a currently-hidden row fails the same way — `restore()` is +> just the case that always hits it, because the row is by definition still +> flagged as deleted at `pre_save` time. +> +> ### Suggested fix +> +> One line: +> +> ```diff +> - old_model = sender.objects.get(pk=instance.pk) +> + old_model = sender._base_manager.get(pk=instance.pk) +> ``` +> +> `_base_manager` rather than `_default_manager`, for the reason +> @sgordon16 already gave in this thread: the *default* manager may itself +> filter, so it does not remove the failure mode. `_base_manager` is the +> one Django documents for exactly this purpose — retrieving related/ +> internal objects — and +> [the docs state it must not filter out any results](https://docs.djangoproject.com/en/5.2/topics/db/managers/#django.db.models.Manager.base_manager_name). +> +> Worth noting: when a model does not set `Meta.base_manager_name`, Django +> creates a plain unfiltered `Manager` for `_base_manager` automatically. +> So this change is a no-op for every project that does not deliberately +> override it, and it does not require model authors to configure anything. +> We verified this on our models — `_base_manager` sees soft-deleted rows, +> `objects` does not. +> +> This is a semantic correction, not a workaround: `pre_save` wants "the row +> currently stored under this pk", which is precisely what `_base_manager` +> means, and is not what `objects` means for any project with a filtering +> default manager. +> +> ### Scope +> +> Grepping 1.3.9, this is the **only** place in the package where an audited +> model's manager is used to fetch an instance; every other `.objects` usage +> is on `CRUDEvent`, `LoginEvent`, `ContentType` or `User`. So the change is +> contained. +> +> We are currently carrying this as a local patch that replaces the +> `pre_save` receiver via its `dispatch_uid`. Happy to open a PR (with a +> regression test using a filtering default manager) if that would help move +> this along — just let me know whether you would prefer it on top of #318 +> or as a fresh branch. diff --git a/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md new file mode 100644 index 000000000..15595962b --- /dev/null +++ b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md @@ -0,0 +1,193 @@ +# Faza 02 — inwentaryzacja wycieków ORM po dołączeniu publikacji + +> Wynik rozszerzenia `RELACJE` w kanarku ORM +> (`src/bpp/tests/test_soft_delete/test_kanarek_orm.py`) o pięć modeli +> publikacji, 2026-08-07. Kanarek zgłosił **14 miejsc**; triage niżej. + +--- + +## ⚠️ SELF-REVIEW (2026-08-07, po napisaniu reszty dokumentu) + +**Ten audyt jest niekompletny i w jednym miejscu wprost nieprawdziwy.** +Zostawiam go w całości — poniższe zastrzeżenia są ważniejsze niż tabelka. + +### 1. Premisa dokumentu jest FAŁSZYWA na obecnym stanie gałęzi + +Sekcja niżej twierdzi, że „zapytania startujące OD publikacji są bezpieczne, +bo `objects` to `BppSoftDeleteManager`". Sprawdzone empirycznie: + +| model | `objects` | widzi skasowane? | +|---|---|---| +| `Wydawnictwo_Ciagle` | `Wydawnictwo_Ciagle_Manager` | **TAK** | +| `Wydawnictwo_Zwarte` | `Wydawnictwo_Zwarte_Manager` | **TAK** | +| `Patent` | `BppSoftDeleteManager` | nie | +| `Praca_Doktorska` | `BppSoftDeleteManager` | nie | +| `Praca_Habilitacyjna` | `BppSoftDeleteManager` | nie | + +Dwa najważniejsze modele mają wciąż menedżera z fazy 01 (mixin opłat + +`models.Manager`), bo **Task 4 — przeplecenie menedżerów — nie jest jeszcze +zrobiony**. Premisa stanie się prawdziwa dopiero po nim. Do tego czasu +KAŻDE `Wydawnictwo_Ciagle.objects...` w kodzie zwraca też rekordy z kosza. + +### 2. Lista nazw relacji jest niekompletna → „14 miejsc" to DOLNA GRANICA + +Audyt użył pięciu nazw modeli. Pominął co najmniej: + +- **`rekord`** — nazwa FK z modeli-dzieci do publikacji + (`*_Streszczenie`, `*_Dodatkowy_Tytul`, `*_Zewnetrzna_Baza_Danych`), +- **`wydawnictwo_nadrzedne`** — self-FK rozdział → książka-matka, +- `wydawnictwa_powiazane_set` — relacja odwrotna do powyższej (0 trafień). + +Po dołożeniu tych nazw skan daje **120 znalezisk zamiast 14**. Zdanie +„lista jest gotowa, nikt nie musi jej odtwarzać" było więc nieuprawnione. + +### 3. …ale te 120 to w większości FAŁSZYWE trafienia — i to jest wniosek o NARZĘDZIU + +Nazwa `rekord` jest wieloznaczna i kanarek nie umie tych znaczeń rozróżnić: + +1. `*_Autor.rekord` → publikacja (zakres fazy 01), +2. `*_Streszczenie.rekord` i pokrewne → publikacja (**realny zakres fazy 02**), +3. `Cache_Punktacja_Autora.rekord` → widok `Rekord`, **już przefiltrowany** + migracją 0497 — czyli bezpieczne, +4. `request.GET.get("rekord__id__exact")`, `cleaned_data.get("rekord")` — + parametry HTTP i formularzy, w ogóle nie ORM. + +**Sedno:** kanarek ORM jest matcherem NAZW, a nie modeli. W fazie 01 działał +świetnie, bo nazwy były dystynktywne (`autorzy_set`, `wydawnictwo_ciagle_autor` +— nic innego się tak nie nazywa). W fazie 02 nazwy to zwykłe słowa (`patent`, +`rekord`, `wydawnictwo_ciagle`), więc precyzja narzędzia się załamuje. To nie +jest usterka do załatania listą wyjątków — to granica metody. + +Uczciwe narzędzie dla fazy 02 musiałoby rozwiązywać ścieżkę lookupu wobec +metadanych Django (`_meta`) i pytać, czy faktycznie dochodzi do tabeli objętej +soft-delete — czyli działać tak, jak kanarek KATALOGOWY działa na `pg_depend`. +To jest osobne narzędzie, nie parametr istniejącego. + +### 4. Błąd w tabeli: pozycja #1 ma odwróconą wymowę + +Wpis dla `usun_zrodla_bez_publikacji` jest zatytułowany **„KASUJE ŹRÓDŁA"**, +co sugeruje utratę danych. Faktyczny kierunek jest odwrotny i łagodny: +`filter(wydawnictwo_ciagle__isnull=True)` **nie znajdzie** źródła, którego +publikacje są w koszu, więc takie źródło NIE zostanie skasowane. Skutek to +zalegające śmieci, nie utrata danych. + +### 5. DOPISANE PÓŹNIEJ: klasa wycieku, której kanarek NIE MOŻE złapać + +Wyszła przy weryfikacji fazy (Task 6), z padającego testu +`test_rok_habilitacji_view` — nie z kanarka. + +**Trawersowanie relacji przez ATRYBUT omija soft-delete.** Widok robił: + +```python +habilitacja = autor.praca_habilitacyjna # odwrotne OneToOne +``` + +Django rozwiązuje to przez `ReverseOneToOneDescriptor`, który pyta +`_base_manager` — z definicji nieprzefiltrowany. Skasowana habilitacja jest +więc tą ścieżką **nadal osiągalna**, mimo że `Praca_Habilitacyjna.objects` +jej nie pokazuje. Widok zwracał 200 zamiast 404. + +To odwrotna strona faktu, który przy okazji easyauditu wyglądał na dobrą +wiadomość: nieprzefiltrowany `_base_manager` ratuje audyt i psuje +trawersowanie relacji. + +⚠️ **Kanarek ORM nie ma szans tego wykryć.** Skanuje ARGUMENTY wywołań ORM +(`filter`, `annotate`, `Count`…), a tutaj nie ma żadnego wywołania — jest +dostęp do atrybutu. Żadne rozszerzanie listy `RELACJE` tego nie zmieni; to +inna oś problemu niż ta, którą kanarek pokrywa. + +Nie da się tego naprawić centralnie: `Meta.base_manager_name` wskazujący +menedżer filtrujący jest przez Django jawnie odradzany (rozwaliłby m.in. +deserializację i `refresh_from_db`). Zostaje sprawdzanie `deleted_at` +w miejscach użycia — tak zrobiono w `RokHabilitacjiView`. + +**Dla fazy 03:** potrzebny osobny przegląd dostępów atrybutowych do relacji +O2O/FK celujących w modele soft-delete. Kandydaci to `autor.praca_habilitacyjna` +i każde `*.wydawnictwo_nadrzedne`. Narzędziem nie może być obecny kanarek. + +### Co z tego wynika dla decyzji o fazie 03 + +Sam podział (faza 02 = warstwa bazodanowa, faza 03 = wywołania ORM) uważam +nadal za słuszny, a `xfail(strict=True)` spełnia swoją rolę. Ale faza 03 NIE +powinna traktować tabelki niżej jako gotowej listy zadań — powinna zacząć od +zbudowania narzędzia model-aware, bo inaczej utonie w fałszywych trafieniach. + +--- + +## Dlaczego to nie jest to samo ryzyko, co w fazie 01 + +Dla publikacji zagrożenie jest skierowane **odwrotnie** niż dla autorstw. +Zapytania startujące OD publikacji są bezpieczne — `Wydawnictwo_Ciagle.objects` +to `BppSoftDeleteManager`, który sam dokłada `deleted_at__isnull=True`. +Przecieka dołączenie **DO** publikacji od strony słownika lub relacji +(`Zrodlo`, `Charakter_Formalny`, `Wydawca`…), bo tam żaden nasz manager się +nie włącza — liczy się surowa tabela. + +## Prawdziwe wycieki (10) + +Wszystkie to `Count()`/`filter()` po odwrotnej relacji do publikacji, bez +predykatu `deleted_at`. + +| # | Miejsce | Co robi | Skutek wycieku | +|---|---|---|---| +| 1 | `bpp/management/commands/usun_zrodla_bez_publikacji.py:23` | `Zrodlo.objects.filter(wydawnictwo_ciagle__isnull=True)` | **KASUJE ŹRÓDŁA.** Źródło, którego wszystkie publikacje są w koszu, nie wygląda na puste → NIE zostanie skasowane. Odwrotnie niż groźnie, ale wynik i tak niezgodny z intencją | +| 2 | `bpp/admin/zrodlo.py:188` | to samo, akcja adminowa „usuń źródła bez prac" | jw. — decyzja o kasowaniu na podstawie zawyżonego licznika | +| 3 | `bpp/admin/zrodlo.py:217` | `Count("wydawnictwo_ciagle", distinct=True)` — kolumna „liczba prac" | operator widzi zawyżoną liczbę | +| 4 | `bpp/admin/filters.py:270` | `Count("wydawnictwo_ciagle")` — filtr „ma prace / nie ma prac" | źródło z samymi skasowanymi pracami trafia do „ma prace" | +| 5 | `admin_dashboard/views/charakter_stats.py:68` | `Count("wydawnictwo_ciagle")` + `Count("wydawnictwo_zwarte")` | statystyki charakterów zawyżone | +| 6 | `deduplikator_zrodel/operations.py:65` | `Count("wydawnictwo_ciagle")` — dobór kandydatów | do deduplikacji wchodzą źródła bez żywych prac | +| 7 | `deduplikator_zrodel/utils.py:72` | jw. | jw. | +| 8 | `deduplikator_zrodel/views.py:83` | `Count("main_zrodlo__wydawnictwo_ciagle")` do pól `_main_live_pub` / `_dup_live_pub` | **nazwa pola mówi `live`, a liczy też skasowane** | +| 9 | `komparator_pbn/views.py:163` | `Count("wydawnictwo_ciagle")` przy źródłach `DELETED` w PBN | zawyżony raport | +| 10 | `przemapuj_zrodla_pbn/views.py:409` | `Count("wydawnictwo_ciagle")` → `liczba_rekordow` | jw. | + +Wzorzec naprawy jest jednolity i znany z fazy 01: + +```python +Count("wydawnictwo_ciagle", filter=Q(wydawnictwo_ciagle__deleted_at__isnull=True)) +``` + +⚠️ Dla wariantu `filter(wydawnictwo_ciagle__isnull=True)` (#1, #2) `Count(...) +FILTER` nie wystarcza — trzeba przejść na `annotate(...)` + `filter(licznik=0)`, +inaczej warunek na odwrotnej relacji zawęzi zbiór źródeł zamiast policzyć zero. + +## Fałszywe trafienia (4) — do wpisu w `DOZWOLONE` albo do zawężenia matchera + +Powstały dlatego, że nazwy relacji fazy 02 są **zwykłymi słowami** (`patent`, +`wydawnictwo_ciagle`), a nie dystynktywnymi jak `autorzy_set` czy +`wydawnictwo_ciagle_autor` z fazy 01. + +| Miejsce | Dlaczego fałszywe | +|---|---| +| `django_bpp/sitemaps.py:110` | pętla po liście etykiet URL-i; literał `"patent"` trafia w gałąź „pętla po nazwach relacji" | +| `ewaluacja_optymalizacja/utils.py:183` | `dict(wydawnictwo_ciagle=isinstance(...), …)` — słownik flag, nie lookup ORM (`dict` jest w `WYWOLANIA_ORM`) | +| `pbn_api/…/pbn_test_wysylka_interaktywna.py:122` | `options.get("wydawnictwo_zwarte")` — opcja CLI (`get` jest w `WYWOLANIA_ORM`) | +| `pbn_api/…/pbn_test_wysylka_interaktywna.py:123` | jw., `wydawnictwo_ciagle` | + +## Rozstrzygnięcie zakresu + +Plan fazy 02 obejmuje warstwę **bazodanową** (modele, widoki, triggery, +ograniczenia, menedżery). Audyt wywołań ORM w imporcie/dedup/PBN jest w +podsumowaniu planu przypisany **fazie 03** („audyt `global_objects` +w imporcie/dedup/PBN → faza 03”), a handoff §7 wymienia dla fazy 03 dług +dotyczący `deduplikator_autorow`. + +Rozwiązanie: kanarek ORM ma teraz **dwa** testy zamiast jednego. + +| Test | Relacje | Stan | +|---|---|---| +| `test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at` | faza 01 (`autorzy_set`, `*_autor`) | ZIELONY — ochrona nienaruszona | +| `test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at` | faza 02 (5 modeli publikacji) | `xfail(strict=True)` — dług fazy 03 | + +Podział jest istotny: wrzucenie wszystkiego do jednego `xfail`-a wyłączyłoby +także ochronę wywalczoną w fazie 01. + +`strict=True` jest tu mechanizmem wymuszającym, nie ozdobą: gdy faza 03 +naprawi te miejsca, test zacznie padać jako XPASS i zmusi do zdjęcia +markera. Bez `strict` naprawa przeszłaby niezauważona, a kanarek zostałby +wyłączony na zawsze — dokładnie ta pułapka („pusty wynik ≠ potwierdzenie”) +jest opisana w handoffie fazy 01. Ten sam wzorzec faza 01 zastosowała wobec +fazy 02 w `test_cache/test_soft_delete_preconditions.py`. + +Kanarek **katalogowy** (widoki) został rozszerzony i jest w pełni ZIELONY — +to zakres fazy 02 i został domknięty. diff --git a/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md new file mode 100644 index 000000000..4e2d6906f --- /dev/null +++ b/docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md @@ -0,0 +1,97 @@ +# Faza 02 — inwentaryzacja widoków czytających tabele publikacji + +> Wykonana **2026-08-07, na starcie fazy 02**, zanim powstała jakakolwiek +> migracja — dokładnie tak, jak nakazuje handoff (§2) i ostrzeżenie (a) +> w planie fazy 02. Faza 01 odkrywała takich winowajców pojedynczo, przez +> awarie; ten dokument jest wynikiem jednego przebiegu, trwającego 19 sekund. + +## Metoda + +Kanarek katalogowy fazy 01 (`src/bpp/tests/test_soft_delete/ +test_kanarek_katalogowy.py`) eksportuje `znajdz_winowajcow(cur, tabele=...)` +przyjmujące **dowolną** listę tabel. Dzięki temu dało się uzyskać pełną listę +winowajców fazy 02 **bez** modyfikowania stałej `TABELE_SOFT_DELETE`, czyli bez +commitowania czerwonego testu na starcie gałęzi. + +Wywołanie: `TABELE_SOFT_DELETE + _TABELE_PUBLIKACJI_FAZA_02` (8 tabel) na +żywym katalogu Postgresa (testcontainer: baseline + wszystkie migracje). + +**Źródłem prawdy jest `pg_depend` na poziomie kolumny, nie tekst SQL-a.** To +istotne: analiza tekstowa `baseline.sql` przemilczałaby część widoków (baseline +jest snapshotem sprzed części migracji), a matcher substringowy dawał fałszywą +zieleń tam, gdzie widok dziedziczył słowo `deleted_at` z JOIN-a po *innej* +tabeli. + +## Wynik: 15 par (widok, tabela), 15 unikalnych widoków + +### Kategoria A — widoki rdzenia rekordu (5) → Task 2b + +Bez filtra publikacja nie zniknie z `bpp_rekord_mat`, czyli z całego serwisu. + +- `bpp_wydawnictwo_ciagle_view` +- `bpp_wydawnictwo_zwarte_view` +- `bpp_patent_view` +- `bpp_praca_doktorska_view` +- `bpp_praca_habilitacyjna_view` + +### Kategoria B — widoki autorstw bez through-modelu (2) → Task 2b + +Autor leży na wierszu publikacji, więc filtr jest tu „po własnej kolumnie". + +- `bpp_praca_doktorska_autorzy` +- `bpp_praca_habilitacyjna_autorzy` + +### Kategoria C — sumy (5) → **Task 2d (NOWY — brak w tabeli zadań planu)** + +Plan miał dla nich wyłącznie ostrzeżenie (c) „sprawdź, czy wymagają poprawki". +Sprawdzone: **wymagają, wszystkie pięć.** Faza 01 poprawiła w tych widokach +wyłącznie wymiar *autora* (migracja `0495`); wymiar *publikacji* nadal +przecieka. + +- `bpp_nowe_sumy_wydawnictwo_ciagle_view` +- `bpp_nowe_sumy_wydawnictwo_zwarte_view` +- `bpp_nowe_sumy_patent_view` +- `bpp_nowe_sumy_praca_doktorska_view` +- `bpp_nowe_sumy_praca_habilitacyjna_view` + +⚠️ To są widoki **agregujące** — obowiązuje w nich pułapka z handoffu §3.2: +warunek `deleted_at IS NULL` w `WHERE`/`ON` degeneruje `LEFT JOIN` do `INNER +JOIN`. Właściwy wzorzec to `agregat(...) FILTER (WHERE ... deleted_at IS +NULL)`. Sprawdzić **każdy** agregat, nie tylko `count` (`sum`, `min`, `max`, +`array_agg`, `string_agg`, `bool_*`). + +### Kategoria D — rozbieżności dyscyplin (1) → **Task 2d** + +- `rozbieznosci_dyscyplin_rozbieznoscizrodelview` + +Analogicznie do C: faza 01 poprawiła wymiar autora (`rozbieznosci_dyscyplin/ +0022`), wymiar publikacji został. + +### Kategoria E — kronika (2) → zadanie „Sprzątanie `bpp_kronika_*`" + +- `bpp_kronika_praca_doktorska_view` +- `bpp_kronika_praca_habilitacyjna_view` + +Dokładnie te dwa, których żywotności — jak zapowiadał handoff §3.4 — **nikt +jeszcze nie zweryfikował**. Pozostałe trzy z rodziny (`wydawnictwo_ciagle`, +`wydawnictwo_zwarte`, `patent`) nie pojawiają się na liście tylko dlatego, że +siedzą w `WYJATKI` jako zweryfikowanie martwe. + +## Wynik negatywny, który też jest wynikiem + +**Zero winowajców wśród trzech tabel `*_autor` fazy 01** +(`bpp_wydawnictwo_ciagle_autor`, `bpp_wydawnictwo_zwarte_autor`, +`bpp_patent_autor`) — suma par per tabela (2+4+4+3+2) wyczerpuje wszystkie 15, +więc dla tabel fazy 01 nie ma ani jednej. DDL fazy 01 trzyma się na żywym +katalogu. + +## Wniosek dla planu + +Tabela kolejności wykonania w planie fazy 02 dostaje **jedno zadanie więcej**: + +| # | Task | Źródło | +|---|---|---| +| … | **Task 2d — sumy + rozbieżności (6 widoków)** | ta inwentaryzacja; w planie tylko jako ostrzeżenie (c), bez zadania | + +Zakres Taska 2b jest potwierdzony jako dokładnie 7 widoków (5 rdzenia + 2 +autorstw), a nie „5 plus może coś jeszcze". diff --git a/src/bpp/apps.py b/src/bpp/apps.py index eb859be49..370ab210b 100644 --- a/src/bpp/apps.py +++ b/src/bpp/apps.py @@ -46,6 +46,16 @@ def ready(self): if apps.is_installed("favicon"): self._patch_favicon_save_per_site() + # django-easy-audit pobiera poprzednia wersje wiersza przez + # `sender.objects`, wiec na modelach soft-delete wywraca `restore()` + # (wiersz jest wtedy jeszcze skasowany -> DoesNotExist). Podmieniamy + # handler na wersje uzywajaca `_base_manager`. Pelne uzasadnienie, + # zakres bledu i stan zgloszenia upstream: bpp/easyaudit_shim.py. + if apps.is_installed("easyaudit"): + from bpp.easyaudit_shim import zainstaluj + + zainstaluj() + # Initialize Rollbar with global hostname handler from bpp.rollbar_config import configure_rollbar diff --git a/src/bpp/data/autor_djangoql_schema.compact.txt b/src/bpp/data/autor_djangoql_schema.compact.txt index 143e95871..4c9cd65c5 100644 --- a/src/bpp/data/autor_djangoql_schema.compact.txt +++ b/src/bpp/data/autor_djangoql_schema.compact.txt @@ -27,7 +27,7 @@ start model: bpp.autor bpp.autor: - aktualna_funkcja -> bpp.funkcja_autora? match nazwa + aktualna_funkcja -> bpp.funkcja_autora? aktualna_jednostka -> bpp.jednostka? autor_dyscyplina -> bpp.autor_dyscyplina? autor_jednostka -> bpp.autor_jednostka? @@ -48,7 +48,7 @@ bpp.autor: patent -> bpp.patent? patent_autor -> bpp.patent_autor? pbn_uid -> pbn_api.scientist? - plec -> bpp.plec? match nazwa + plec -> bpp.plec? pokazuj bool pokazuj_opis bool pokazuj_poprzednie_nazwiska bool @@ -62,7 +62,7 @@ bpp.autor: slug str sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni - tytul -> bpp.tytul? match nazwa + tytul -> bpp.tytul? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -107,7 +107,7 @@ bpp.jednostka: praca_doktorska -> bpp.praca_doktorska? praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int - rodzaj -> bpp.rodzajjednostki? match nazwa + rodzaj -> bpp.rodzajjednostki? ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" @@ -133,7 +133,7 @@ bpp.autor_dyscyplina: id int procent_dyscypliny float? procent_subdyscypliny float? - rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa + rodzaj_autora -> ewaluacja_common.rodzaj_autora? rok int subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? @@ -142,7 +142,7 @@ bpp.autor_dyscyplina: bpp.autor_jednostka: autor -> bpp.autor - funkcja -> bpp.funkcja_autora? match nazwa + funkcja -> bpp.funkcja_autora? grupa_pracownicza -> bpp.grupa_pracownicza? id int jednostka -> bpp.jednostka @@ -164,7 +164,7 @@ bpp.autorzy: profil_orcid bool przypieta bool rekord -> bpp.rekord - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci upowaznienie_pbn bool zapisany_jako str zatrudniony bool @@ -184,11 +184,12 @@ bpp.patent: cached_punkty_dyscyplin unknown? data_decyzji date? data_zgloszenia date? "Data zgłoszenia" + deleted_at datetime? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str nie_eksportuj_przez_api bool numer_prawa_wylacznego str? "Numer prawa wyłącznego" @@ -203,18 +204,20 @@ bpp.patent: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool - rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? match nazwa + restored_at datetime? + rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str + transaction_id str? tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str utworzono datetime? @@ -242,7 +245,7 @@ bpp.patent_autor: rekord -> bpp.patent restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -272,19 +275,20 @@ bpp.plec: bpp.praca_doktorska: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -312,18 +316,20 @@ bpp.praca_doktorska: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -336,19 +342,20 @@ bpp.praca_doktorska: bpp.praca_habilitacyjna: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -375,18 +382,20 @@ bpp.praca_habilitacyjna: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -407,7 +416,8 @@ bpp.wydawnictwo_ciagle: autorzy -> bpp.autor autorzy_set -> bpp.wydawnictwo_ciagle_autor? cached_punkty_dyscyplin unknown? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_ciagle_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -415,12 +425,12 @@ bpp.wydawnictwo_ciagle: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 @@ -431,12 +441,12 @@ bpp.wydawnictwo_ciagle: nie_eksportuj_przez_api bool nr_zeszytu str numer_odbitki str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? match nazwa "OpenAccess: tryb dostępu" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? "OpenAccess: tryb dostępu" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -463,19 +473,21 @@ bpp.wydawnictwo_ciagle: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_ciagle_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -506,7 +518,7 @@ bpp.wydawnictwo_ciagle_autor: rekord -> bpp.wydawnictwo_ciagle restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -517,7 +529,8 @@ bpp.wydawnictwo_zwarte: cached_punkty_dyscyplin unknown? calkowita_liczba_autorow int? calkowita_liczba_redaktorow int? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_zwarte_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -526,13 +539,13 @@ bpp.wydawnictwo_zwarte: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również @@ -543,12 +556,12 @@ bpp.wydawnictwo_zwarte: nie_eksportuj_przez_api bool numer_odbitki str numer_w_serii str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? match nazwa - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -578,20 +591,22 @@ bpp.wydawnictwo_zwarte: recenzowana bool redakcja str rekord -> bpp.rekord? + restored_at datetime? rok int search_index unknown seria_wydawnicza -> bpp.seria_wydawnicza? slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_zwarte_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -629,7 +644,7 @@ bpp.wydawnictwo_zwarte_autor: rekord -> bpp.wydawnictwo_zwarte restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -712,7 +727,7 @@ bpp.wymiar_etatu: bpp.rekord: autorzy -> bpp.autorzy? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" @@ -721,18 +736,18 @@ bpp.rekord: index_copernicus float informacje str isbn str - jezyk -> bpp.jezyk match nazwa "Język" + jezyk -> bpp.jezyk "Język" konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 liczba_autorow int liczba_cytowan int liczba_znakow_wydawniczych int? "Liczba znaków wydawniczych" - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str @@ -749,9 +764,9 @@ bpp.rekord: slowa_kluczowe -> taggit.tag? "Słowa kluczowe" — A comma-separated list of tags. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty szczegoly str "Szczegóły" — Np. str. 23-45 - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str tytul_oryginalny str tytul_oryginalny_sort str @@ -854,7 +869,7 @@ pbn_api.publication: bpp.typ_kbn: artykul_pbn bool "Artykuł w PBN" — Wydawnictwa ciągłe posiadające ten typ MNiSW/MEiN zostaną włączone do eksportu PBN jako artykuły - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? id int nazwa str praca_doktorska -> bpp.praca_doktorska? @@ -884,7 +899,7 @@ bpp.wydawca: bpp.charakter_formalny: charakter_ogolny str choices: Artykuł | Książka | Rozdział | Inne - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. @@ -893,7 +908,7 @@ bpp.charakter_formalny: lft int nazwa str nazwa_w_primo str choices: | Artykuł | Książka | Zasób tekstowy | Rozprawa naukowa | Recenzja | Artykuł prasowy | Rozdział | Czasopismo | Dane badawcze | Materiał konferencyjny | Obraz | Baza | Zestaw danych statystycznych | Multimedia | Inny - parent -> bpp.charakter_formalny? match nazwa "Charakter nadrzędny" + parent -> bpp.charakter_formalny? "Charakter nadrzędny" publikacja bool rekord -> bpp.rekord? rght int @@ -911,7 +926,7 @@ bpp.charakter_formalny: bpp.wydawnictwo_ciagle_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_ciagle tytul str "Tytuł" @@ -970,12 +985,12 @@ bpp.wersja_tekstu_openaccess: bpp.wydawnictwo_ciagle_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_ciagle streszczenie str bpp.wydawnictwo_ciagle_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_ciagle @@ -986,16 +1001,16 @@ bpp.zrodlo: e_issn str "e-ISSN" id int issn str - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? nazwa str nazwa_alternatywna str? - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" openaccess_tryb_dostepu str "OpenAccess: tryb dostępu" choices: pełny | częściowy ostatnio_zmieniony datetime? pbn_uid -> pbn_api.journal? "Odpowiednik w PBN" poprzednia_nazwa str? "Poprzedni tytuł" rekord -> bpp.rekord? - rodzaj -> bpp.rodzaj_zrodla match nazwa + rodzaj -> bpp.rodzaj_zrodla search unknown skrot str "Skrót" skrot_nazwy_alternatywnej str? @@ -1003,11 +1018,11 @@ bpp.zrodlo: www str? wydawca str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? - zasieg -> bpp.zasieg_zrodla? match nazwa + zasieg -> bpp.zasieg_zrodla? bpp.wydawnictwo_zwarte_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_zwarte tytul str "Tytuł" @@ -1035,18 +1050,18 @@ bpp.seria_wydawnicza: bpp.wydawnictwo_zwarte_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_zwarte streszczenie str bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_zwarte bpp.zewnetrznebazydanychview: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str rekord -> bpp.rekord @@ -1136,47 +1151,3 @@ bpp.organprzyznajacynagrody: nagroda -> bpp.nagroda? nazwa str skrot str - -dictionaries (shared relation values, referenced above): - bpp.funkcja_autora - nazwa: "adiunkt", "asystent", "kierownik", "młodszy asystent", "starszy asystent" - bpp.plec - nazwa: "kobieta", "mężczyzna" - bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" - bpp.rodzajjednostki - nazwa: "Koło naukowe", "Standard", "Wydział" - ewaluacja_common.rodzaj_autora - nazwa: "doktorant", "inny zatrudniony, nie naukowy", "pracownik badawczy spoza N", "pracownik naukowy w liczbie N" - bpp.typ_odpowiedzialnosci - nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" - bpp.zrodlo_informacji - nazwa: "od autora", "od redakcji", "wartość szacunkowa" - bpp.rodzaj_prawa_patentowego - nazwa: "odmiana rośliny", "wynalazek", "wzór przemysłowy", "wzór użytkowy", "znak towarowy" - bpp.status_korekty - nazwa: "po korekcie", "przed korektą", "w trakcie korekty" - bpp.jezyk - nazwa: "angielski", "brak danych", "francuski", "hiszpański", "inny", "niemiecki", "polski", "rosyjski", "włoski" - bpp.typ_kbn - nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" - bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" - bpp.czas_udostepnienia_openaccess - nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" - bpp.licencja_openaccess - nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" - bpp.tryb_openaccess_wydawnictwo_ciagle - nazwa: "Inne", "Otwarte czasopismo", "Otwarte repositorium" - bpp.wersja_tekstu_openaccess - nazwa: "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" - bpp.tryb_openaccess_wydawnictwo_zwarte - nazwa: "Inne", "Otwarte repositorium", "Witryna wydawcy" - bpp.charakter_pbn - help_text: "", " ", "Artykuł przeglądowy stanowi podsumowanie aktualnego stanu badań w danym obszarze tematycznym.", "Hasło encykolpedyczne/słownikowe/leksykonowe", "Monografia naukowa to spójne tematycznie, wyczerpujące opracowanie naukowe. W PBN za monografie naukowe uważane są również zbiorowe książki pokonferencyjne (tzw. conference proceedings). Jeśli dodajesz zbiorową książkę pokonferencyjną, pamiętaj o tym, by zaznaczyć również pola "Publikacja zbiorowa" oraz "Publikacja konferencyjna"", "Np. katalog zabytków", "Oryginalny artykuł naukowy przedstawia rezultaty oryginalnych badań naukowych lub eksperymentu.", "Publikacja podlegała recenzji naukowej", "Wstęp/Wprowadzenie/Przedmowa" - bpp.zewnetrzna_baza_danych - nazwa: "Scopus", "Web of Science" - bpp.rodzaj_zrodla - nazwa: "periodyk", "wydawnictwo", "źródło nieindeksowane" - bpp.zasieg_zrodla - nazwa: "krajowy", "międzynarodowy" diff --git a/src/bpp/data/autorzy_djangoql_schema.compact.txt b/src/bpp/data/autorzy_djangoql_schema.compact.txt index 0b226ad46..de8f82ba3 100644 --- a/src/bpp/data/autorzy_djangoql_schema.compact.txt +++ b/src/bpp/data/autorzy_djangoql_schema.compact.txt @@ -39,13 +39,13 @@ bpp.autorzy: profil_orcid bool przypieta bool rekord -> bpp.rekord - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci upowaznienie_pbn bool zapisany_jako str zatrudniony bool bpp.autor: - aktualna_funkcja -> bpp.funkcja_autora? match nazwa + aktualna_funkcja -> bpp.funkcja_autora? aktualna_jednostka -> bpp.jednostka? autor_dyscyplina -> bpp.autor_dyscyplina? autor_jednostka -> bpp.autor_jednostka? @@ -66,7 +66,7 @@ bpp.autor: patent -> bpp.patent? patent_autor -> bpp.patent_autor? pbn_uid -> pbn_api.scientist? - plec -> bpp.plec? match nazwa + plec -> bpp.plec? pokazuj bool pokazuj_opis bool pokazuj_poprzednie_nazwiska bool @@ -80,7 +80,7 @@ bpp.autor: slug str sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni - tytul -> bpp.tytul? match nazwa + tytul -> bpp.tytul? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -130,7 +130,7 @@ bpp.jednostka: praca_doktorska -> bpp.praca_doktorska? praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int - rodzaj -> bpp.rodzajjednostki? match nazwa + rodzaj -> bpp.rodzajjednostki? ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" @@ -164,7 +164,7 @@ bpp.kierunek_studiow: bpp.rekord: autorzy -> bpp.autorzy? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" @@ -173,18 +173,18 @@ bpp.rekord: index_copernicus float informacje str isbn str - jezyk -> bpp.jezyk match nazwa "Język" + jezyk -> bpp.jezyk "Język" konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 liczba_autorow int liczba_cytowan int liczba_znakow_wydawniczych int? "Liczba znaków wydawniczych" - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str @@ -201,9 +201,9 @@ bpp.rekord: slowa_kluczowe -> taggit.tag? "Słowa kluczowe" — A comma-separated list of tags. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty szczegoly str "Szczegóły" — Np. str. 23-45 - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str tytul_oryginalny str tytul_oryginalny_sort str @@ -242,7 +242,7 @@ bpp.autor_dyscyplina: id int procent_dyscypliny float? procent_subdyscypliny float? - rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa + rodzaj_autora -> ewaluacja_common.rodzaj_autora? rok int subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? @@ -251,7 +251,7 @@ bpp.autor_dyscyplina: bpp.autor_jednostka: autor -> bpp.autor - funkcja -> bpp.funkcja_autora? match nazwa + funkcja -> bpp.funkcja_autora? grupa_pracownicza -> bpp.grupa_pracownicza? id int jednostka -> bpp.jednostka @@ -275,11 +275,12 @@ bpp.patent: cached_punkty_dyscyplin unknown? data_decyzji date? data_zgloszenia date? "Data zgłoszenia" + deleted_at datetime? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str nie_eksportuj_przez_api bool numer_prawa_wylacznego str? "Numer prawa wyłącznego" @@ -294,18 +295,20 @@ bpp.patent: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool - rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? match nazwa + restored_at datetime? + rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str + transaction_id str? tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str utworzono datetime? @@ -333,7 +336,7 @@ bpp.patent_autor: rekord -> bpp.patent restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -363,19 +366,20 @@ bpp.plec: bpp.praca_doktorska: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -403,18 +407,20 @@ bpp.praca_doktorska: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -427,19 +433,20 @@ bpp.praca_doktorska: bpp.praca_habilitacyjna: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -466,18 +473,20 @@ bpp.praca_habilitacyjna: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -498,7 +507,8 @@ bpp.wydawnictwo_ciagle: autorzy -> bpp.autor autorzy_set -> bpp.wydawnictwo_ciagle_autor? cached_punkty_dyscyplin unknown? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_ciagle_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -506,12 +516,12 @@ bpp.wydawnictwo_ciagle: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 @@ -522,12 +532,12 @@ bpp.wydawnictwo_ciagle: nie_eksportuj_przez_api bool nr_zeszytu str numer_odbitki str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? match nazwa "OpenAccess: tryb dostępu" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? "OpenAccess: tryb dostępu" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -554,19 +564,21 @@ bpp.wydawnictwo_ciagle: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_ciagle_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -597,7 +609,7 @@ bpp.wydawnictwo_ciagle_autor: rekord -> bpp.wydawnictwo_ciagle restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -608,7 +620,8 @@ bpp.wydawnictwo_zwarte: cached_punkty_dyscyplin unknown? calkowita_liczba_autorow int? calkowita_liczba_redaktorow int? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_zwarte_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -617,13 +630,13 @@ bpp.wydawnictwo_zwarte: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również @@ -634,12 +647,12 @@ bpp.wydawnictwo_zwarte: nie_eksportuj_przez_api bool numer_odbitki str numer_w_serii str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? match nazwa - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -669,20 +682,22 @@ bpp.wydawnictwo_zwarte: recenzowana bool redakcja str rekord -> bpp.rekord? + restored_at datetime? rok int search_index unknown seria_wydawnicza -> bpp.seria_wydawnicza? slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_zwarte_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -720,7 +735,7 @@ bpp.wydawnictwo_zwarte_autor: rekord -> bpp.wydawnictwo_zwarte restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -759,7 +774,7 @@ bpp.rodzajjednostki: bpp.charakter_formalny: charakter_ogolny str choices: Artykuł | Książka | Rozdział | Inne - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. @@ -768,7 +783,7 @@ bpp.charakter_formalny: lft int nazwa str nazwa_w_primo str choices: | Artykuł | Książka | Zasób tekstowy | Rozprawa naukowa | Recenzja | Artykuł prasowy | Rozdział | Czasopismo | Dane badawcze | Materiał konferencyjny | Obraz | Baza | Zestaw danych statystycznych | Multimedia | Inny - parent -> bpp.charakter_formalny? match nazwa "Charakter nadrzędny" + parent -> bpp.charakter_formalny? "Charakter nadrzędny" publikacja bool rekord -> bpp.rekord? rght int @@ -891,7 +906,7 @@ bpp.status_korekty: bpp.typ_kbn: artykul_pbn bool "Artykuł w PBN" — Wydawnictwa ciągłe posiadające ten typ MNiSW/MEiN zostaną włączone do eksportu PBN jako artykuły - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? id int nazwa str praca_doktorska -> bpp.praca_doktorska? @@ -920,7 +935,7 @@ bpp.wydawca: wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? bpp.zewnetrznebazydanychview: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str rekord -> bpp.rekord @@ -931,16 +946,16 @@ bpp.zrodlo: e_issn str "e-ISSN" id int issn str - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? nazwa str nazwa_alternatywna str? - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" openaccess_tryb_dostepu str "OpenAccess: tryb dostępu" choices: pełny | częściowy ostatnio_zmieniony datetime? pbn_uid -> pbn_api.journal? "Odpowiednik w PBN" poprzednia_nazwa str? "Poprzedni tytuł" rekord -> bpp.rekord? - rodzaj -> bpp.rodzaj_zrodla match nazwa + rodzaj -> bpp.rodzaj_zrodla search unknown skrot str "Skrót" skrot_nazwy_alternatywnej str? @@ -948,7 +963,7 @@ bpp.zrodlo: www str? wydawca str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? - zasieg -> bpp.zasieg_zrodla? match nazwa + zasieg -> bpp.zasieg_zrodla? ewaluacja_common.rodzaj_autora: autor_dyscyplina -> bpp.autor_dyscyplina? @@ -987,7 +1002,7 @@ bpp.rodzaj_prawa_patentowego: bpp.wydawnictwo_ciagle_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_ciagle tytul str "Tytuł" @@ -1001,19 +1016,19 @@ bpp.tryb_openaccess_wydawnictwo_ciagle: bpp.wydawnictwo_ciagle_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_ciagle streszczenie str bpp.wydawnictwo_ciagle_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_ciagle bpp.wydawnictwo_zwarte_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_zwarte tytul str "Tytuł" @@ -1041,12 +1056,12 @@ bpp.seria_wydawnicza: bpp.wydawnictwo_zwarte_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_zwarte streszczenie str bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_zwarte @@ -1136,47 +1151,3 @@ bpp.organprzyznajacynagrody: nagroda -> bpp.nagroda? nazwa str skrot str - -dictionaries (shared relation values, referenced above): - bpp.typ_odpowiedzialnosci - nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" - bpp.funkcja_autora - nazwa: "adiunkt", "asystent", "kierownik", "młodszy asystent", "starszy asystent" - bpp.plec - nazwa: "kobieta", "mężczyzna" - bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" - bpp.rodzajjednostki - nazwa: "Koło naukowe", "Standard", "Wydział" - bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" - bpp.jezyk - nazwa: "angielski", "brak danych", "francuski", "hiszpański", "inny", "niemiecki", "polski", "rosyjski", "włoski" - bpp.czas_udostepnienia_openaccess - nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" - bpp.licencja_openaccess - nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" - bpp.wersja_tekstu_openaccess - nazwa: "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" - bpp.status_korekty - nazwa: "po korekcie", "przed korektą", "w trakcie korekty" - bpp.typ_kbn - nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" - ewaluacja_common.rodzaj_autora - nazwa: "doktorant", "inny zatrudniony, nie naukowy", "pracownik badawczy spoza N", "pracownik naukowy w liczbie N" - bpp.zrodlo_informacji - nazwa: "od autora", "od redakcji", "wartość szacunkowa" - bpp.rodzaj_prawa_patentowego - nazwa: "odmiana rośliny", "wynalazek", "wzór przemysłowy", "wzór użytkowy", "znak towarowy" - bpp.tryb_openaccess_wydawnictwo_ciagle - nazwa: "Inne", "Otwarte czasopismo", "Otwarte repositorium" - bpp.tryb_openaccess_wydawnictwo_zwarte - nazwa: "Inne", "Otwarte repositorium", "Witryna wydawcy" - bpp.charakter_pbn - help_text: "", " ", "Artykuł przeglądowy stanowi podsumowanie aktualnego stanu badań w danym obszarze tematycznym.", "Hasło encykolpedyczne/słownikowe/leksykonowe", "Monografia naukowa to spójne tematycznie, wyczerpujące opracowanie naukowe. W PBN za monografie naukowe uważane są również zbiorowe książki pokonferencyjne (tzw. conference proceedings). Jeśli dodajesz zbiorową książkę pokonferencyjną, pamiętaj o tym, by zaznaczyć również pola "Publikacja zbiorowa" oraz "Publikacja konferencyjna"", "Np. katalog zabytków", "Oryginalny artykuł naukowy przedstawia rezultaty oryginalnych badań naukowych lub eksperymentu.", "Publikacja podlegała recenzji naukowej", "Wstęp/Wprowadzenie/Przedmowa" - bpp.zewnetrzna_baza_danych - nazwa: "Scopus", "Web of Science" - bpp.rodzaj_zrodla - nazwa: "periodyk", "wydawnictwo", "źródło nieindeksowane" - bpp.zasieg_zrodla - nazwa: "krajowy", "międzynarodowy" diff --git a/src/bpp/data/rekord_djangoql_schema.compact.txt b/src/bpp/data/rekord_djangoql_schema.compact.txt index 36b5d6ee3..410fd98b0 100644 --- a/src/bpp/data/rekord_djangoql_schema.compact.txt +++ b/src/bpp/data/rekord_djangoql_schema.compact.txt @@ -28,7 +28,7 @@ start model: bpp.rekord bpp.rekord: autorzy -> bpp.autorzy? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" @@ -37,18 +37,18 @@ bpp.rekord: index_copernicus float informacje str isbn str - jezyk -> bpp.jezyk match nazwa "Język" + jezyk -> bpp.jezyk "Język" konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 liczba_autorow int liczba_cytowan int liczba_znakow_wydawniczych int? "Liczba znaków wydawniczych" - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str @@ -65,9 +65,9 @@ bpp.rekord: slowa_kluczowe -> taggit.tag? "Słowa kluczowe" — A comma-separated list of tags. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty szczegoly str "Szczegóły" — Np. str. 23-45 - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str tytul_oryginalny str tytul_oryginalny_sort str @@ -93,14 +93,14 @@ bpp.autorzy: profil_orcid bool przypieta bool rekord -> bpp.rekord - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci upowaznienie_pbn bool zapisany_jako str zatrudniony bool bpp.charakter_formalny: charakter_ogolny str choices: Artykuł | Książka | Rozdział | Inne - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? charakter_sloty int? "Charakter dla slotów" — Jak potraktować ten charakter przy kalkulacji slotów dla wydawnictwa zwartego? choices: Książka | Rozdział | Referat children -> bpp.charakter_formalny? coar_type str "Typ COAR" — Pełny identyfikator typu zasobu ze słownika COAR Resource Types, np. http://purl.org/coar/resource_type/c_6501 dla artykułu naukowego. Używany w eksporcie CERIF/OpenAIRE; gdy pusty, prace o tym charakterze formalnym zostaną wyeksportowane bez typu zasobu. @@ -109,7 +109,7 @@ bpp.charakter_formalny: lft int nazwa str nazwa_w_primo str choices: | Artykuł | Książka | Zasób tekstowy | Rozprawa naukowa | Recenzja | Artykuł prasowy | Rozdział | Czasopismo | Dane badawcze | Materiał konferencyjny | Obraz | Baza | Zestaw danych statystycznych | Multimedia | Inny - parent -> bpp.charakter_formalny? match nazwa "Charakter nadrzędny" + parent -> bpp.charakter_formalny? "Charakter nadrzędny" publikacja bool rekord -> bpp.rekord? rght int @@ -232,7 +232,7 @@ bpp.status_korekty: bpp.typ_kbn: artykul_pbn bool "Artykuł w PBN" — Wydawnictwa ciągłe posiadające ten typ MNiSW/MEiN zostaną włączone do eksportu PBN jako artykuły - charakter_pbn -> bpp.charakter_pbn? match help_text + charakter_pbn -> bpp.charakter_pbn? id int nazwa str praca_doktorska -> bpp.praca_doktorska? @@ -266,7 +266,8 @@ bpp.wydawnictwo_zwarte: cached_punkty_dyscyplin unknown? calkowita_liczba_autorow int? calkowita_liczba_redaktorow int? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_zwarte_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -275,13 +276,13 @@ bpp.wydawnictwo_zwarte: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również @@ -292,12 +293,12 @@ bpp.wydawnictwo_zwarte: nie_eksportuj_przez_api bool numer_odbitki str numer_w_serii str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? match nazwa - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_zwarte? + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -327,20 +328,22 @@ bpp.wydawnictwo_zwarte: recenzowana bool redakcja str rekord -> bpp.rekord? + restored_at datetime? rok int search_index unknown seria_wydawnicza -> bpp.seria_wydawnicza? slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_zwarte_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -361,7 +364,7 @@ bpp.wydawnictwo_zwarte: zewnetrzna_baza_danych -> bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych? bpp.zewnetrznebazydanychview: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str rekord -> bpp.rekord @@ -372,16 +375,16 @@ bpp.zrodlo: e_issn str "e-ISSN" id int issn str - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? nazwa str nazwa_alternatywna str? - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" openaccess_tryb_dostepu str "OpenAccess: tryb dostępu" choices: pełny | częściowy ostatnio_zmieniony datetime? pbn_uid -> pbn_api.journal? "Odpowiednik w PBN" poprzednia_nazwa str? "Poprzedni tytuł" rekord -> bpp.rekord? - rodzaj -> bpp.rodzaj_zrodla match nazwa + rodzaj -> bpp.rodzaj_zrodla search unknown skrot str "Skrót" skrot_nazwy_alternatywnej str? @@ -389,10 +392,10 @@ bpp.zrodlo: www str? wydawca str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? - zasieg -> bpp.zasieg_zrodla? match nazwa + zasieg -> bpp.zasieg_zrodla? bpp.autor: - aktualna_funkcja -> bpp.funkcja_autora? match nazwa + aktualna_funkcja -> bpp.funkcja_autora? aktualna_jednostka -> bpp.jednostka? autor_dyscyplina -> bpp.autor_dyscyplina? autor_jednostka -> bpp.autor_jednostka? @@ -413,7 +416,7 @@ bpp.autor: patent -> bpp.patent? patent_autor -> bpp.patent_autor? pbn_uid -> pbn_api.scientist? - plec -> bpp.plec? match nazwa + plec -> bpp.plec? pokazuj bool pokazuj_opis bool pokazuj_poprzednie_nazwiska bool @@ -427,7 +430,7 @@ bpp.autor: slug str sort str system_kadrowy_id int? "Identyfikator w systemie kadrowym" — Identyfikator cyfrowy, używany do matchowania autora z danymi z systemu kadrowego Uczelni - tytul -> bpp.tytul? match nazwa + tytul -> bpp.tytul? www str wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? wydawnictwo_ciagle_autor -> bpp.wydawnictwo_ciagle_autor? @@ -477,7 +480,7 @@ bpp.jednostka: praca_doktorska -> bpp.praca_doktorska? praca_habilitacyjna -> bpp.praca_habilitacyjna? rght int - rodzaj -> bpp.rodzajjednostki? match nazwa + rodzaj -> bpp.rodzajjednostki? ror_id str "Identyfikator ROR" — Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Używany w eksporcie CERIF/OpenAIRE jako identyfikator zewnętrzny jednostki organizacyjnej; gdy pusty, nie zostanie wyeksportowany. search unknown? skrot str "Skrót" @@ -537,7 +540,8 @@ bpp.wydawnictwo_ciagle: autorzy -> bpp.autor autorzy_set -> bpp.wydawnictwo_ciagle_autor? cached_punkty_dyscyplin unknown? - charakter_formalny -> bpp.charakter_formalny match nazwa + charakter_formalny -> bpp.charakter_formalny + deleted_at datetime? dodatkowe_tytuly -> bpp.wydawnictwo_ciagle_tytul? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. @@ -545,12 +549,12 @@ bpp.wydawnictwo_ciagle: id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str issn str - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. konferencja -> bpp.konferencja? kwartyl_w_scopus int? choices: brak | Q1 | Q2 | Q3 | Q4 kwartyl_w_wos int? choices: brak | Q1 | Q2 | Q3 | Q4 @@ -561,12 +565,12 @@ bpp.wydawnictwo_ciagle: nie_eksportuj_przez_api bool nr_zeszytu str numer_odbitki str - openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? match nazwa "OpenAccess: czas udostępnienia" + openaccess_czas_publikacji -> bpp.czas_udostepnienia_openaccess? "OpenAccess: czas udostępnienia" openaccess_data_opublikowania date? "OpenAccess: data publikacji" openaccess_ilosc_miesiecy int? "OpenAccess: ilość miesięcy" — Ilość miesięcy jakie upłynęły od momentu opublikowania do momentu udostępnienia - openaccess_licencja -> bpp.licencja_openaccess? match nazwa "OpenAccess: licencja" - openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? match nazwa "OpenAccess: tryb dostępu" - openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? match nazwa "OpenAccess: wersja tekstu" + openaccess_licencja -> bpp.licencja_openaccess? "OpenAccess: licencja" + openaccess_tryb_dostepu -> bpp.tryb_openaccess_wydawnictwo_ciagle? "OpenAccess: tryb dostępu" + openaccess_wersja_tekstu -> bpp.wersja_tekstu_openaccess? "OpenAccess: wersja tekstu" opis_bibliograficzny_autorzy_cache unknown? opis_bibliograficzny_cache str opis_bibliograficzny_zapisani_autorzy_cache str? @@ -593,19 +597,21 @@ bpp.wydawnictwo_ciagle: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty streszczenia -> bpp.wydawnictwo_ciagle_streszczenie? strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -627,19 +633,20 @@ pbn_api.language: bpp.praca_doktorska: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -667,18 +674,20 @@ bpp.praca_doktorska: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -691,19 +700,20 @@ bpp.praca_doktorska: bpp.praca_habilitacyjna: autor -> bpp.autor + deleted_at datetime? doi str? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. e_isbn str "E-ISBN" id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str isbn str jednostka -> bpp.jednostka - jezyk -> bpp.jezyk match nazwa "Język" - jezyk_alt -> bpp.jezyk? match nazwa "Język alternatywny" - jezyk_orig -> bpp.jezyk? match nazwa "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. + jezyk -> bpp.jezyk "Język" + jezyk_alt -> bpp.jezyk? "Język alternatywny" + jezyk_orig -> bpp.jezyk? "Język oryginalny" — Dla tłumaczeń. Wartość eksportowana do PBN. liczba_cytowan int? "Liczba cytowań" — Wartość aktualizowana jest automatycznie raz na kilka dni w przypadku skonfigurowania dostępu do API WOS AMR (przez obiekt 'Uczelnia'). Możesz również czaktualizować tą wartość ręcznie, naciskając przycisk. @@ -730,18 +740,20 @@ bpp.praca_habilitacyjna: punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool redakcja str + restored_at datetime? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str - typ_kbn -> bpp.typ_kbn match nazwa "typ MNiSW/MEiN" + transaction_id str? + typ_kbn -> bpp.typ_kbn "typ MNiSW/MEiN" tytul str "Tytuł" tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str @@ -754,26 +766,26 @@ bpp.praca_habilitacyjna: bpp.wydawnictwo_ciagle_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_ciagle streszczenie str bpp.wydawnictwo_ciagle_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_ciagle tytul str "Tytuł" bpp.wydawnictwo_zwarte_streszczenie: id int - jezyk_streszczenia -> bpp.jezyk? match nazwa + jezyk_streszczenia -> bpp.jezyk? rekord -> bpp.wydawnictwo_zwarte streszczenie str bpp.wydawnictwo_zwarte_tytul: id int - jezyk -> bpp.jezyk? match nazwa + jezyk -> bpp.jezyk? kod_jezyka_pbn str "Kod języka wg PBN" rekord -> bpp.wydawnictwo_zwarte tytul str "Tytuł" @@ -794,11 +806,12 @@ bpp.patent: cached_punkty_dyscyplin unknown? data_decyzji date? data_zgloszenia date? "Data zgłoszenia" + deleted_at datetime? dostep_dnia date? "Dostęp dnia (płatny dostęp)" — Data dostępu do strony WWW. id int impact_factor float index_copernicus float - informacja_z -> bpp.zrodlo_informacji? match nazwa + informacja_z -> bpp.zrodlo_informacji? informacje str nie_eksportuj_przez_api bool numer_prawa_wylacznego str? "Numer prawa wyłącznego" @@ -813,18 +826,20 @@ bpp.patent: punktacja_wewnetrzna float "Punktacja wewnętrzna" punkty_kbn float "punkty MNiSW/MEiN" recenzowana bool - rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? match nazwa + restored_at datetime? + rodzaj_prawa -> bpp.rodzaj_prawa_patentowego? rok int search_index unknown slowa_kluczowe -> taggit.tag? "Słowa kluczowe -- język polski" — Lista słów kluczowych -- język polski. slowa_kluczowe_eng unknown? "Słowa kluczowe -- język angielski" — Lista słów kluczowych -- język angielski slug str? - status_korekty -> bpp.status_korekty match nazwa + status_korekty -> bpp.status_korekty strony str szczegoly str "Szczegóły" — Np. str. 23-45 tekst_po_ostatnim_autorze str? tekst_przed_pierwszym_autorem str? tom str + transaction_id str? tytul_oryginalny str "Tytuł oryginalny" tytul_oryginalny_sort str utworzono datetime? @@ -870,7 +885,7 @@ bpp.wydawnictwo_zwarte_autor: rekord -> bpp.wydawnictwo_zwarte restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -906,7 +921,7 @@ bpp.seria_wydawnicza: wydawnictwo_zwarte -> bpp.wydawnictwo_zwarte? bpp.wydawnictwo_zwarte_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_zwarte @@ -958,7 +973,7 @@ bpp.autor_dyscyplina: id int procent_dyscypliny float? procent_subdyscypliny float? - rodzaj_autora -> ewaluacja_common.rodzaj_autora? match nazwa + rodzaj_autora -> ewaluacja_common.rodzaj_autora? rok int subdyscyplina_naukowa -> bpp.dyscyplina_naukowa? wymiar_etatu float? @@ -967,7 +982,7 @@ bpp.autor_dyscyplina: bpp.autor_jednostka: autor -> bpp.autor - funkcja -> bpp.funkcja_autora? match nazwa + funkcja -> bpp.funkcja_autora? grupa_pracownicza -> bpp.grupa_pracownicza? id int jednostka -> bpp.jednostka @@ -1003,7 +1018,7 @@ bpp.patent_autor: rekord -> bpp.patent restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -1055,7 +1070,7 @@ bpp.wydawnictwo_ciagle_autor: rekord -> bpp.wydawnictwo_ciagle restored_at datetime? transaction_id str? - typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci match nazwa "Typ odpowiedzialności" + typ_odpowiedzialnosci -> bpp.typ_odpowiedzialnosci "Typ odpowiedzialności" upowaznienie_pbn bool "Upoważnienie PBN" — Tik w polu "upoważnienie PBN" oznacza, że dany autor upoważnił Uczelnię do sprawozdania tej publikacji w ocenie parametrycznej Uczelni zapisany_jako str zatrudniony bool @@ -1100,7 +1115,7 @@ bpp.tryb_openaccess_wydawnictwo_ciagle: wydawnictwo_ciagle -> bpp.wydawnictwo_ciagle? bpp.wydawnictwo_ciagle_zewnetrzna_baza_danych: - baza -> bpp.zewnetrzna_baza_danych match nazwa + baza -> bpp.zewnetrzna_baza_danych id int info str "Informacje dodatkowe" rekord -> bpp.wydawnictwo_ciagle @@ -1136,47 +1151,3 @@ bpp.wymiar_etatu: autor_jednostka -> bpp.autor_jednostka? id int nazwa str - -dictionaries (shared relation values, referenced above): - bpp.charakter_formalny - nazwa: "Artykuł w czasopismie", "Broszura", "Czasopismo", "Dokument elektroniczny", "Fragment", "inne", "Komentarz", "Książka", "Książka w języku obcym", "Książka w języku polskim", "List do redakcji", "Patent", "Podręcznik akademicki", "Polski Referat Zjazdowy", "Polskie streszczenie zjazdowe", "Poradnik zawodowy", "Praca doktorska", "Praca habilitacyjna", "Projekt wynalazczy", "Publikacja w suplemencie", "Recenzja", "Referat zjazdowy konferencji miedzynarodowej", "Rozdział książki", "Rozdział skryptu", "Skrypt", "Streszczenie zjazdowe konferencji międzynarodowej", "Tłumaczenie" - bpp.jezyk - nazwa: "angielski", "brak danych", "francuski", "hiszpański", "inny", "niemiecki", "polski", "rosyjski", "włoski" - bpp.czas_udostepnienia_openaccess - nazwa: "po opublikowaniu", "przed opublikowaniem", "w momencie opublikowania" - bpp.licencja_openaccess - nazwa: "Creative Commons - Universal - Przekazanie do Domeny Publicznej (CC0 1.0)", "Creative Commons - Uznanie Autorstwa - Bez utworów zależnych (CC-BY-ND)", "Creative Commons - Uznanie Autorstwa - Na Tych Samych Warunkach (CC-BY-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Bez utworów zależnych (CC-BY-NC-ND)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne - Na tych samych warunkach (CC-BY-NC-SA)", "Creative Commons - Uznanie Autorstwa - Użycie niekomercyjne (CC-BY-NC);", "Creative Commons - Uznanie Autorstwa (CC-BY)", "inna otwarta licencja" - bpp.wersja_tekstu_openaccess - nazwa: "Oryginalna wersja autorska", "Ostateczna wersja autorska", "Ostateczna wersja opublikowana" - bpp.status_korekty - nazwa: "po korekcie", "przed korektą", "w trakcie korekty" - bpp.typ_kbn - nazwa: "inne", "Opis Przypadku", "Podręcznik akademicki", "praca monograficzna", "Praca Oryginalna", "Praca Przeglądowa", "Praca wieloośrodkowa", "Publikacja popularnonaukowa", "Redakcja czasopisma" - bpp.typ_odpowiedzialnosci - nazwa: "autor", "autor korespondencyjny", "komentator", "opracował", "recenzent", "redaktor", "redaktor naukowy wydania polskiego", "tłumacz", "tłumacz z języka angielskiego" - bpp.charakter_pbn - help_text: "", " ", "Artykuł przeglądowy stanowi podsumowanie aktualnego stanu badań w danym obszarze tematycznym.", "Hasło encykolpedyczne/słownikowe/leksykonowe", "Monografia naukowa to spójne tematycznie, wyczerpujące opracowanie naukowe. W PBN za monografie naukowe uważane są również zbiorowe książki pokonferencyjne (tzw. conference proceedings). Jeśli dodajesz zbiorową książkę pokonferencyjną, pamiętaj o tym, by zaznaczyć również pola "Publikacja zbiorowa" oraz "Publikacja konferencyjna"", "Np. katalog zabytków", "Oryginalny artykuł naukowy przedstawia rezultaty oryginalnych badań naukowych lub eksperymentu.", "Publikacja podlegała recenzji naukowej", "Wstęp/Wprowadzenie/Przedmowa" - bpp.zrodlo_informacji - nazwa: "od autora", "od redakcji", "wartość szacunkowa" - bpp.tryb_openaccess_wydawnictwo_zwarte - nazwa: "Inne", "Otwarte repositorium", "Witryna wydawcy" - bpp.zewnetrzna_baza_danych - nazwa: "Scopus", "Web of Science" - bpp.rodzaj_zrodla - nazwa: "periodyk", "wydawnictwo", "źródło nieindeksowane" - bpp.zasieg_zrodla - nazwa: "krajowy", "międzynarodowy" - bpp.funkcja_autora - nazwa: "adiunkt", "asystent", "kierownik", "młodszy asystent", "starszy asystent" - bpp.plec - nazwa: "kobieta", "mężczyzna" - bpp.tytul - nazwa: "doktor", "doktor habilitowany", "doktor habilitowany medycyny", "doktor habilitowany nauk farmaceutycznych", "doktor habilitowany nauk humanistycznych", "doktor habilitowany nauk przyrodniczych", "doktor nauk chemicznych", "doktor nauk farmaceutycznych", "doktor nauk fizycznych", "doktor nauk humanistycznych", "doktor nauk medycznych", "doktor nauk o kulturze fizycznej", "doktor nauk prawnych", "doktor nauk przyrodniczych", "doktor nauk rolniczych", "doktor nauk weterynaryjnych", "lekarz", "lekarz dentysta", "lekarz medycyny", "lekarz stomatolog", "lekarz weterynarii", "magister", "magister inżynier", "magister pielęgniarstwa", "magister położnictwa", "profesor", "profesor doktor habilitowany medycyny", "profesor doktor habilitowany nauk biologicznych", "profesor doktor habilitowany nauk chemicznych", "profesor doktor habilitowany nauk farmaceutycznych", "profesor doktor habilitowany nauk humanistycznych" - bpp.rodzajjednostki - nazwa: "Koło naukowe", "Standard", "Wydział" - bpp.tryb_openaccess_wydawnictwo_ciagle - nazwa: "Inne", "Otwarte czasopismo", "Otwarte repositorium" - bpp.rodzaj_prawa_patentowego - nazwa: "odmiana rośliny", "wynalazek", "wzór przemysłowy", "wzór użytkowy", "znak towarowy" - ewaluacja_common.rodzaj_autora - nazwa: "doktorant", "inny zatrudniony, nie naukowy", "pracownik badawczy spoza N", "pracownik naukowy w liczbie N" diff --git a/src/bpp/demo_data/orchestrator.py b/src/bpp/demo_data/orchestrator.py index b0da5ea00..03fbda2c3 100644 --- a/src/bpp/demo_data/orchestrator.py +++ b/src/bpp/demo_data/orchestrator.py @@ -302,10 +302,24 @@ def run_cleanup(opts: CleanupOptions, *, stdin=None, stdout=None): total=n_batches, disable=opts.disable_progress, ) + # Manager widzący RÓWNIEŻ kosz i kasowanie NIEODWRACALNE — patrz + # komentarz niżej. `getattr` zamiast `isinstance`, bo lista modeli + # przychodzi z manifestu i miesza modele soft-delete z resztą. + manager = getattr(model, "global_objects", model.objects) + for start in pbar: chunk = sorted_pks[start : start + opts.batch_size] with transaction.atomic(): - model.objects.filter(pk__in=chunk).delete() + qs = manager.filter(pk__in=chunk) + # ⚠️ Na modelu soft-delete `QuerySet.delete()` jest MIĘKKIE: + # wiersz zostaje. Cleanup danych demo musi kasować trwale, + # i to z dwóch powodów. Po pierwsze zostawione publikacje + # trzymają FK z PROTECT (np. `Wydawnictwo_Zwarte.wydawca`), + # więc kasowanie słowników dalej w manifeście wywala się na + # `ProtectedError`. Po drugie „posprzątane" dane demo, które + # nadal siedzą w bazie, mijają się z celem komendy. + hard = getattr(qs, "hard_delete", None) + (hard or qs.delete)() ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") applied = opts.manifest.with_suffix(opts.manifest.suffix + f".applied.{ts}") diff --git a/src/bpp/easyaudit_shim.py b/src/bpp/easyaudit_shim.py new file mode 100644 index 000000000..36fc35723 --- /dev/null +++ b/src/bpp/easyaudit_shim.py @@ -0,0 +1,163 @@ +"""Poprawka do ``django-easy-audit``: nie zakładaj, że ``objects`` widzi +wszystkie wiersze. + +PROBLEM +------- +``easyaudit.signals.model_signals.pre_save`` pobiera poprzednią wersję +zapisywanego wiersza przez DOMYŚLNEGO menedżera:: + + old_model = sender.objects.get(pk=instance.pk) + +Dla modeli soft-delete ``objects`` filtruje ``deleted_at IS NULL``, a przy +``restore()`` wiersz w bazie jest w tym momencie WCIĄŻ skasowany — więc +lookup rzuca ``DoesNotExist``. Ponieważ BPP ma +``DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = True``, wyjątek wychodzi na +zewnątrz i wywraca cały ``restore()``. + +Poprawne jest ``_base_manager``: Django dokumentuje go jako menedżera, który +MUSI zwracać wszystkie obiekty, i sam tworzy dla niego zwykły, nieodfiltrowany +``Manager``, gdy model nie ustawia ``Meta.base_manager_name``. Sprawdzone na +naszych modelach — ``_base_manager`` widzi rekordy z kosza, ``objects`` nie. + +ZAKRES BŁĘDU — SZERSZY NIŻ FAZA 02 +---------------------------------- +To NIE jest problem wprowadzony przez soft-delete publikacji. +``Zgloszenie_Publikacji`` jest ``SoftDeleteModel`` od dawna i figuruje +w ``DJANGO_EASY_AUDIT_REGISTERED_CLASSES``, więc na ``dev`` **nie da się dziś +przywrócić skasowanego zgłoszenia**. Faza 02 rozszerza zasięg z jednego +modelu na sześć. Ten shim naprawia oba przypadki naraz. + +Through-modele ``*_Autor`` (faza 01) nie były dotknięte, bo nie ma ich +w ``REGISTERED_CLASSES``. + +DLACZEGO SHIM, A NIE FORK +------------------------- +Wadliwe wywołanie jest w całym pakiecie DOKŁADNIE JEDNO, a sygnały są +podpinane z ``dispatch_uid`` — można więc czysto podmienić sam handler, +bez forka i bez monkeypatchowania wnętrzności modułu. + +Upstream (``soynatan/django-easy-audit``) zna ten błąd jako issue #175, +otwarte od 2021-02. Cztery próby naprawy (PR #168, #176, #318, #342) w sześć +lat, żadna nie scalona — mimo że projekt jest aktywnie wydawany. Z wątku +przy #168 wynika, dlaczego: maintainer odsyła do obejścia przez +``DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS``, które NIE DZIAŁA, bo +wyjątek leci zanim callbacki zostaną w ogóle sprawdzone. + +Gdy poprawka wejdzie upstream — skasować ten moduł i wywołanie +``zainstaluj()`` w ``BppConfig.ready()``. Pilnuje tego +``test_easyaudit_shim.py``, który pada, gdy upstream się zmieni. +""" + +import json +from functools import partial + +from django.conf import settings +from django.core import serializers +from django.db import transaction +from django.db.models import signals + +#: ``dispatch_uid``, którym easyaudit podpina swój handler +#: (``model_signals.py``, na dole modułu). Używamy TEGO SAMEGO klucza, więc +#: podmiana jest czysta w obie strony — patrz ``zainstaluj()``. +DISPATCH_UID = "easy_audit_signals_pre_save" + +#: Wersja pakietu, na której kopiowano ciało handlera. Nie jest sprawdzana +#: w runtime — pilnuje jej test, żeby aktualizacja pakietu była GŁOŚNA. +WERSJA_UPSTREAM = "1.3.9" + + +def pre_save(sender, instance, raw, using, update_fields, **kwargs): + """Kopia ``easyaudit.signals.model_signals.pre_save`` (1.3.9) z JEDNĄ + zmianą: ``sender.objects`` -> ``sender._base_manager``. + + Cała reszta — łącznie z obsługą wyjątków, kolejnością callbacków + i ``transaction.on_commit`` — jest importowana z upstreamu, a nie + przepisana. Powielamy wyłącznie strukturę funkcji, bo jedyny sposób + na podmianę jednej linii w cudzym ciele to podmiana całej funkcji. + """ + # Import lokalny: moduł easyaudit ma być ładowany dopiero wtedy, gdy + # aplikacja jest faktycznie zainstalowana (w BPP wchodzi tylko + # w local.py/production.py). + from easyaudit.signals.crud_flows import pre_save_crud_flow + from easyaudit.signals.model_signals import ( + call_callbacks, + handle_signal_exception, + should_audit, + ) + from easyaudit.utils import model_delta + + if raw: + # Return if loading Fixtures + return None + + try: + if not should_audit(instance): + return False + + with transaction.atomic(using=using): + try: + object_json_repr = serializers.serialize("json", [instance]) + except Exception: + # We need a better way for this to work. ManyToMany will fail on + # pre_save on create + return None + + # Determine if the instance is a create + created = instance.pk is None or instance._state.adding + + # created or updated? + delta = {} + if not created: + # ↓↓↓ JEDYNA ZMIANA WOBEC UPSTREAMU ↓↓↓ + # Upstream: sender.objects.get(pk=instance.pk) + old_model = sender._base_manager.get(pk=instance.pk) + delta = model_delta(old_model, instance) + + if not delta and getattr( + settings, + "DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP", + False, + ): + return False + + # callbacks + create_crud_event = call_callbacks( + instance, object_json_repr, created, raw, using, update_fields, **kwargs + ) + + # Create crud event only if all callbacks returned True + if create_crud_event and not created: + crud_flow = partial( + pre_save_crud_flow, + instance=instance, + object_json_repr=object_json_repr, + changed_fields=json.dumps(delta), + ) + + if getattr(settings, "TEST", False): + crud_flow() + else: + transaction.on_commit(crud_flow, using=using) + except Exception: + handle_signal_exception("pre_save") + + +def zainstaluj(): + """Podmienia handler ``pre_save`` easyauditu na nasz. + + Działa NIEZALEŻNIE od kolejności ``ready()`` aplikacji, bo używamy tego + samego ``dispatch_uid`` co upstream: + + - gdy nasz ``ready()`` biegnie PIERWSZY (tak jest dziś: ``bpp`` stoi + w ``INSTALLED_APPS`` przed ``easyaudit``, który jest doklejany na + końcu w ``local.py``/``production.py``) — ``disconnect`` jest no-opem, + a nasz ``connect`` zajmuje klucz. Późniejszy ``connect`` easyauditu + jest wtedy pomijany, bo ``Signal.connect`` ignoruje duplikat + ``dispatch_uid``; + - gdy kolejność się kiedyś odwróci — ``disconnect`` zdejmuje ich + handler, a ``connect`` wstawia nasz. + + Idempotentne: powtórne wywołanie nie dokłada drugiego odbiorcy. + """ + signals.pre_save.disconnect(dispatch_uid=DISPATCH_UID) + signals.pre_save.connect(pre_save, dispatch_uid=DISPATCH_UID) diff --git a/src/bpp/management/commands/wyczysc_publikacje_importu.py b/src/bpp/management/commands/wyczysc_publikacje_importu.py index f3b31b2e2..c368dd5db 100644 --- a/src/bpp/management/commands/wyczysc_publikacje_importu.py +++ b/src/bpp/management/commands/wyczysc_publikacje_importu.py @@ -286,16 +286,39 @@ def _author_counts(self, publication_models): for model in publication_models ] + @staticmethod + def _wszystkie_wiersze(model): + """Manager widzący RÓWNIEŻ kosz. + + Od fazy 02 soft-delete ``model.objects`` ukrywa wiersze z + ``deleted_at``. Ta komenda ma CZYŚCIĆ, a nie chować — pominięcie kosza + zostawiłoby publikacje, które operator uznał już za usunięte, a które + przy kolejnym imporcie kolidowałyby jako niewidoczne duplikaty. + """ + return getattr(model, "global_objects", model.objects) + + @staticmethod + def _skasuj_trwale(queryset): + """Kasowanie NIEODWRACALNE, także gdy model jest soft-delete. + + ``QuerySet.delete()`` na modelu soft-delete jest MIĘKKIE — wiersze + zostają, a razem z nimi ich dzieci (bo CASCADE nie ma czego kasować). + Dla komendy czyszczącej to nie byłoby kasowanie, tylko oznaczanie. + """ + hard = getattr(queryset, "hard_delete", None) + return hard() if hard is not None else queryset.delete() + def _delete_publications(self, publication_models, batch_size): deleted = {} for model in publication_models: - pks = list(model.objects.order_by("pk").values_list("pk", flat=True)) + manager = self._wszystkie_wiersze(model) + pks = list(manager.order_by("pk").values_list("pk", flat=True)) total = len(pks) self._progress(f" - {model._meta.label}: usuwam {total} rekordow") for start in range(0, total, batch_size): batch = pks[start : start + batch_size] - _, details = model.objects.filter(pk__in=batch).delete() + _, details = self._skasuj_trwale(manager.filter(pk__in=batch)) for model_label, count in details.items(): deleted[model_label] = deleted.get(model_label, 0) + count @@ -323,7 +346,7 @@ def _delete_generic_references(self, specs, content_types, *, progress_label): f" [{index}/{len(grouped_queries)}] {model._meta.label}: " f"usuwam {planned_count}" ) - count, _ = queryset.delete() + count, _ = self._skasuj_trwale(queryset) results.append(GenericReferenceResult(model._meta.label, "usunieto", count)) return results diff --git a/src/bpp/migrations/0496_publikacje_soft_delete_fields.py b/src/bpp/migrations/0496_publikacje_soft_delete_fields.py new file mode 100644 index 000000000..fa7a9a8ea --- /dev/null +++ b/src/bpp/migrations/0496_publikacje_soft_delete_fields.py @@ -0,0 +1,110 @@ +# Generated by Django 5.2.16 on 2026-08-07 12:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0495_nowe_sumy_bez_skasowanych'), + ('pbn_api', '0079_constraint_publikacja_instytucji'), + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ] + + operations = [ + migrations.AddField( + model_name='patent', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='patent', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='patent', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_doktorska', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_doktorska', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_doktorska', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_habilitacyjna', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_habilitacyjna', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='praca_habilitacyjna', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_ciagle', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_ciagle', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_ciagle', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_zwarte', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_zwarte', + name='restored_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='wydawnictwo_zwarte', + name='transaction_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddIndex( + model_name='patent', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='patent_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='praca_doktorska', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='pdok_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='praca_habilitacyjna', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='phab_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='wydawnictwo_ciagle', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='wc_deleted_at_idx'), + ), + migrations.AddIndex( + model_name='wydawnictwo_zwarte', + index=models.Index(condition=models.Q(('deleted_at__isnull', False)), fields=['deleted_at'], name='wz_deleted_at_idx'), + ), + ] diff --git a/src/bpp/migrations/0497_soft_delete_rekord_views.py b/src/bpp/migrations/0497_soft_delete_rekord_views.py new file mode 100644 index 000000000..2bba70950 --- /dev/null +++ b/src/bpp/migrations/0497_soft_delete_rekord_views.py @@ -0,0 +1,283 @@ +"""Soft-delete PUBLIKACJI: filtr w widokach + gałąź kasująca + bramka WHEN. + +Odpowiednik migracji ``0489`` (faza 01) dla drugiej ścieżki: tamta objęła +3 tabele ``*_autor``, ta obejmuje 5 tabel publikacji. Kolejność operacji jest +WYMUSZONA z dokładnie tego samego powodu: + + 1) widoki (wprowadzają ``deleted_at`` do drzewa zapytania) -> + 2) funkcje ``bpp_refresh_rekord_*`` (gałąź kasująca) -> + 3) regeneracja bramki WHEN (czyta ``pg_depend`` PO kroku 1) + +Odwrócenie 1<->3 daje bramkę bez ``deleted_at``, czyli cichy staleness: +soft-deletowana publikacja zostaje w ``bpp_rekord_mat``. + +Żadna z trzech zmian nie wystarcza sama — uzasadnienie jak w ``0489``: +funkcja refresh po ``0432`` robi wyłącznie ``INSERT ... ON CONFLICT DO +UPDATE``, więc wypadnięcie wiersza ze źródła jest dla niej no-opem; a bez +``deleted_at`` w bramce ``WHEN`` UPDATE soft-delete w ogóle nie dochodzi do +funkcji triggera. + +DLACZEGO NIE DA SIĘ UŻYĆ ``_filtruj_widok`` Z ``0489`` +------------------------------------------------------ +Tamta funkcja dopisuje ``WHERE`` na KOŃCU definicji i asertuje, że definicja +kończy się gołym ``FROM ``. Dla widoków publikacji to założenie jest +FAŁSZYWE — występują tu trzy różne kształty (zweryfikowane introspekcją +żywego katalogu, nie lekturą plików ``.sql``): + +- **A**: ``bpp_wydawnictwo_ciagle_view``, ``bpp_wydawnictwo_zwarte_view``, + ``bpp_patent_view`` — kończą się ``LEFT JOIN _autor ... GROUP BY + .id`` i NIE mają ``WHERE`` najwyższego poziomu. Dopisanie ``WHERE`` + na końcu byłoby błędem składni (``WHERE`` po ``GROUP BY``), więc predykat + wstawiamy PRZED ``GROUP BY``. +- **B**: ``bpp_praca_doktorska_view``, ``bpp_praca_habilitacyjna_view`` — + kończą się gołym ``FROM ``; tu wzorzec z ``0489`` działa wprost. +- **C**: ``bpp_praca_doktorska_autorzy``, + ``bpp_praca_habilitacyjna_autorzy`` — mają WŁASNY ``WHERE`` najwyższego + poziomu (join po przecinku z ``bpp_autor``), więc predykat wstawiamy do + niego, zaraz za słowem ``WHERE``. + +⚠️ Rozpoznawanie kształtu NIE może iść po samym wystąpieniu słowa ``WHERE``: +widoki rodziny A zawierają ``count(...) FILTER (WHERE ... deleted_at IS +NULL)`` (migracja ``0494``), a wszystkie zawierają ``WHERE`` w skalarnych +podzapytaniach o ``django_content_type``. Dyskryminatorem jest ``WHERE``/ +``GROUP BY`` na WCIĘCIU DWÓCH SPACJI, czyli na poziomie klauzuli głównej +w formacie ``pg_get_viewdef(..., pretty=true)``. + +⚠️ AGREGATY: w rodzinie A filtrujemy tabelę PUBLIKACJI, która jest lewą +(napędzającą) stroną ``LEFT JOIN``-a — to jest bezpieczne. Pułapka opisana w +handoffie (§3.2) dotyczy filtrowania strony PRAWEJ (``*_autor``): warunek na +niej zdegenerowałby ``LEFT JOIN`` do ``INNER JOIN`` i publikacja, której +wszystkich autorów skasowano, wypadłaby z widoku. Dlatego ``liczba_autorow`` +jest liczona przez ``count(...) FILTER (...)`` (``0494``) i tej konstrukcji +tutaj NIE ruszamy. + +⚠️ KLUCZ w rodzinie C: ``object_id_raw`` to id PUBLIKACJI (autor leży na jej +wierszu), więc filtr po własnej kolumnie ``deleted_at`` tabeli publikacji +jest poprawny. To INNA sytuacja niż w widokach ``*_autorzy`` trzech typów +z through-modelem (faza 01), gdzie filtrowano po kolumnie wiersza through. +Nie kopiować klucza między fazami bez sprawdzenia, co dana kolumna znaczy. + +Definicje są GENEROWANE z introspekcji (jak w ``0432``/``0433``/``0489``), +a nie przepisane do ``.sql`` — kopia rozjechałaby się przy najbliższej +zmianie kolumn. +""" + +import importlib + +from django.db import connection, migrations + +_p0432 = importlib.import_module("bpp.migrations.0432_cache_trigger_plpgsql") +_p0433 = importlib.import_module("bpp.migrations.0433_cache_trigger_when_gate") + +REKORD_SITES = _p0432.REKORD_SITES # [(tabela, model, autor_na_wierszu), ...] + +# Klauzule główne w formacie pg_get_viewdef(pretty=true) stoją na wcięciu +# dwóch spacji. To odróżnia je od WHERE-ów w podzapytaniach skalarnych +# (wcięcie 10) i od FILTER (WHERE ...) w liście SELECT. +MARKER_GROUP_BY = "\n GROUP BY " +MARKER_WHERE = "\n WHERE " + + +def _widoki(tabela, autor_na_wierszu): + """Widoki danej publikacji objęte filtrem: rekordowy + ewentualny + autorski (tylko gdy autor leży na wierszu publikacji).""" + yield tabela + "_view" + if autor_na_wierszu: + yield tabela + "_autorzy" + + +def _viewdef(cur, widok): + cur.execute("SELECT pg_get_viewdef(%s::regclass, true)", [widok]) + return cur.fetchone()[0].rstrip().rstrip(";") + + +def _z_filtrem(orig, tabela, widok): + """Definicja widoku wzbogacona o ``.deleted_at IS NULL``. + + Kształt rozpoznajemy JAWNIE i każdy nieoczekiwany wariant kończy się + ``RuntimeError`` — cicha akceptacja dałaby widok bez filtra, czyli + dokładnie ten rodzaj przecieku, który ta migracja ma zamknąć. + """ + predykat = f"{tabela}.deleted_at IS NULL" + n_group_by = orig.count(MARKER_GROUP_BY) + n_where = orig.count(MARKER_WHERE) + + if n_group_by == 1 and n_where == 0: # rodzina A + glowa, ogon = orig.split(MARKER_GROUP_BY) + return f"{glowa}\n WHERE {predykat}{MARKER_GROUP_BY}{ogon}" + + if n_group_by == 0 and n_where == 1: # rodzina C + glowa, ogon = orig.split(MARKER_WHERE) + # Wstawiamy ZARAZ ZA "WHERE", a nie na końcu definicji — dzięki temu + # jesteśmy odporni na to, co po WHERE jeszcze następuje. + return f"{glowa}{MARKER_WHERE}{predykat} AND {ogon}" + + if n_group_by == 0 and n_where == 0: # rodzina B + if not orig.endswith(f"FROM {tabela}"): + raise RuntimeError( + f"{widok}: brak GROUP BY i WHERE, ale definicja nie konczy " + f"sie na 'FROM {tabela}' -- dopisanie WHERE bylo by " + f"niepoprawne. Definicja: ...{orig[-160:]!r}" + ) + return f"{orig}\n WHERE {predykat}" + + raise RuntimeError( + f"{widok}: nierozpoznany ksztalt definicji (GROUP BY x{n_group_by}, " + f"WHERE x{n_where}) -- nie zgaduje, gdzie wstawic filtr" + ) + + +def _bez_filtra(orig, tabela, widok): + """Odwrotność ``_z_filtrem`` — usuwa DOKŁADNIE ten tekst, który tamta + wstawiła. Symetria jest tu ważniejsza od elegancji: odtwarzanie + oryginału z plików ``.sql`` (jak w ``0489.backward``) nie zadziała, bo + widoki publikacji były redefiniowane w kilku migracjach (m.in. ``0494`` + dokładająca ``count(...) FILTER``) i nie ma jednego pliku źródłowego. + + ⚠️ Szukamy DWÓCH form predykatu, bo katalog nie przechowuje tekstu, który + wstawiliśmy, tylko jego postać ZNORMALIZOWANĄ. Gdy w zasięgu zapytania + jest jedna tabela (rodzina B), Postgres usuwa zbędną kwalifikację i + ``bpp_praca_doktorska.deleted_at IS NULL`` wraca jako gołe + ``deleted_at IS NULL``. + + ⚠️ KOLEJNOŚĆ KANDYDATÓW JEST NOŚNA, nie kosmetyczna. Wariant z ``AND`` + (rodzina C) MUSI być sprawdzany pierwszy: w definicji + ``WHERE AND bpp_autor.id = ...`` wzorzec + ``\\n WHERE `` pasuje jako PREFIKS, a usunięcie samego prefiksu + zostawiłoby ``WHERE`` zaczynające się od ``AND`` — składniowy gruz. + """ + kandydaci = [] + for predykat in (f"{tabela}.deleted_at IS NULL", "deleted_at IS NULL"): + kandydaci.append(f"{predykat} AND ") # rodzina C — NAJPIERW + for predykat in (f"{tabela}.deleted_at IS NULL", "deleted_at IS NULL"): + kandydaci.append(f"\n WHERE {predykat}") # rodziny A i B + + for wstawka in kandydaci: + if orig.count(wstawka) == 1: + return orig.replace(wstawka, "", 1) + raise RuntimeError( + f"{widok}: nie znalazlem (dokladnie jednej) wstawki z filtrem " + f"soft-delete -- definicja zmieniona poza ta migracja?" + ) + + +def _funkcja_z_galezia_kasujaca(cur, table, model, autor_na_wierszu): + """``bpp_refresh_rekord_()`` z ``0432`` + gałąź kasująca. + + Ciało generowane identycznie jak ``_p0432._create_rekord_function`` + (ten sam upsert z pozycyjnym mapowaniem kolumn), z jedną różnicą: gdy + wiersz jest soft-deletowany, kasujemy go z tabel ``_mat`` i wychodzimy. + + ``pg_advisory_xact_lock`` stoi PRZED rozgałęzieniem — kasowanie musi brać + ten sam lock co upsert (uzasadnienie w ``0489``). + + ⚠️ Doktorat i habilitacja (``autor_na_wierszu=True``) dotykają OBU tabel + ``_mat``, bo autor leży na wierszu publikacji. Nazwy kluczy są RÓŻNE: + w ``bpp_rekord_mat`` kolumna nazywa się ``id``, w ``bpp_autorzy_mat`` — + ``rekord_id`` (patrz ``_create_rekord_function`` i + ``_create_delete_rekord_function`` w ``0432``). + + Restore (``deleted_at`` -> NULL) leci normalną ścieżką upsertu — wiersz + wraca do widoku źródłowego, więc nic dodatkowego nie trzeba. + """ + body_autorzy = "" + kasuj_autorzy = "" + if autor_na_wierszu: + upsert_autorzy = _p0432._upsert_sql( + cur, "bpp_autorzy_mat", table + "_autorzy", "object_id_raw = NEW.id" + ) + body_autorzy = ( + "\n DELETE FROM bpp_autorzy_mat " + "WHERE rekord_id = ARRAY[ct, NEW.id]::integer[];\n" + f" {upsert_autorzy};" + ) + kasuj_autorzy = ( + "\n DELETE FROM bpp_autorzy_mat " + "WHERE rekord_id = ARRAY[ct, NEW.id]::integer[];" + ) + upsert_rekord = _p0432._upsert_sql( + cur, "bpp_rekord_mat", table + "_view", "object_id_raw = NEW.id" + ) + return f""" +CREATE OR REPLACE FUNCTION bpp_refresh_rekord_{model}() RETURNS trigger +LANGUAGE plpgsql AS $bpp_body$ +DECLARE ct integer; +BEGIN + {_p0432._ct_lookup(model)} + PERFORM pg_advisory_xact_lock(ct, NEW.id); + IF NEW.deleted_at IS NOT NULL THEN + DELETE FROM bpp_rekord_mat WHERE id = ARRAY[ct, NEW.id]::integer[];\ +{kasuj_autorzy} + RETURN NULL; + END IF; + {upsert_rekord};{body_autorzy} + RETURN NULL; +END $bpp_body$; +""" + + +def _regeneruj_bramke(): + """Ta sama logika co ``0433.forward``, ale tylko dla tabel PUBLIKACJI. + + Bramka jest wyliczana z ``pg_depend``, więc po zmianie definicji widoku + (krok 1) sama wciągnie ``deleted_at``; po cofnięciu widoku (backward) + sama ją zgubi. Tabele ``*_autor`` obsłużyła faza 01 — tu ich nie ruszamy. + """ + with connection.cursor() as cur: + for tabela, refresh_fn, widoki in _p0433.GATED: + if tabela.endswith("_autor"): + continue + kolumny = _p0433._gate_columns(cur, tabela, widoki) + if not kolumny: + # Bezpiecznik z 0433: bez kolumn powstałby niebramkowany + # UPDATE. Jeśli tu jesteśmy, krok 1 nie zadziałał. + raise RuntimeError( + f"bramka dla {tabela}: pg_depend nie zwrocil zadnej " + f"kolumny (widoki={widoki}) -- nie tworze " + f"niezbramkowanego UPDATE" + ) + when = _p0433._when_clause(kolumny) + cur.execute(f"DROP TRIGGER IF EXISTS {tabela}_cache_upd ON {tabela};") + cur.execute( + f"CREATE TRIGGER {tabela}_cache_upd AFTER UPDATE ON {tabela} " + f"FOR EACH ROW WHEN ({when}) " + f"EXECUTE PROCEDURE {refresh_fn}();" + ) + + +def _przebuduj_widoki(cur, transformacja): + for tabela, _model, autor_na_wierszu in REKORD_SITES: + for widok in _widoki(tabela, autor_na_wierszu): + orig = _viewdef(cur, widok) + nowa = transformacja(orig, tabela, widok) + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {nowa}") + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + _przebuduj_widoki(cur, _z_filtrem) # 1) widoki + for tabela, model, autor_na_wierszu in REKORD_SITES: # 2) funkcje + cur.execute( + _funkcja_z_galezia_kasujaca(cur, tabela, model, autor_na_wierszu) + ) + _regeneruj_bramke() # 3) bramka + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + _przebuduj_widoki(cur, _bez_filtra) # 1) widoki bez filtra + for tabela, model, autor_na_wierszu in REKORD_SITES: # 2) funkcje + cur.execute( + _p0432._create_rekord_function(cur, tabela, model, autor_na_wierszu) + ) + _regeneruj_bramke() # 3) bramka (deleted_at zniknie z pg_depend samo) + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0496_publikacje_soft_delete_fields"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/migrations/0498_nowe_sumy_bez_skasowanych_publikacji.py b/src/bpp/migrations/0498_nowe_sumy_bez_skasowanych_publikacji.py new file mode 100644 index 000000000..28bc4249c --- /dev/null +++ b/src/bpp/migrations/0498_nowe_sumy_bez_skasowanych_publikacji.py @@ -0,0 +1,74 @@ +"""Ranking autorów przestaje punktować soft-deletowane PUBLIKACJE. + +Dopełnienie migracji ``0495``, która zamknęła w tych widokach wymiar +AUTORSTWA (``*_autor.deleted_at``). Wymiar PUBLIKACJI został wtedy otwarty, +bo publikacje stały się soft-delete dopiero w fazie 02 (``0496``). + +Bez tego filtra soft-deletowana publikacja dalej wnosi punkty do sum: +``bpp_nowe_sumy__view`` -> UNION ALL ``bpp_nowe_sumy_view`` -> modele +``Nowe_Sumy_View``/``Sumy`` (``bpp/models/sumy_views.py``) -> +``ranking_autorow/views.py``. + +Zakres jest SZERSZY niż w ``0495``: tamta objęła 3 widoki (tylko typy +z through-modelem), ta obejmuje wszystkie 5. ``praca_doktorska`` i +``praca_habilitacyjna`` nie mają tabeli ``*_autor`` — autor leży na wierszu +publikacji — więc ``0495`` nie miała tam czego filtrować, ale soft-delete +samej pracy dotyczy ich tak samo jak reszty. + +Widoki ustalone INWENTARYZACJĄ kanarka katalogowego na starcie fazy 02 +(``docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md``), +nie zgadywaniem — plan fazy 02 miał dla nich wyłącznie ostrzeżenie +„sprawdź, czy wymagają poprawki". Wymagają, wszystkie pięć. + +PUŁAPKA AGREGATU TU NIE WYSTĘPUJE (sprawdzone, nie założone). Handoff (§3.2) +ostrzega, że warunek w ``WHERE`` degeneruje ``LEFT JOIN`` do ``INNER JOIN``, +przez co wiersz z zerem znika zamiast wyzerować licznik. Te widoki: + +- nie mają ANI JEDNEGO ``LEFT JOIN``-a (złączenia po przecinku albo jawne + ``JOIN``, czyli semantyka wewnętrzna od początku), +- nie mają ``GROUP BY`` ani żadnej funkcji agregującej — sumowanie dzieje + się dopiero w modelach Django, nad ``UNION ALL``. + +Semantyka jest tu zresztą odwrotna niż przy ``liczba_autorow``: skasowana +publikacja MA wypaść z rankingu, a nie zostać w nim z zerem. Zwykły warunek +w ``WHERE`` jest więc poprawną konstrukcją, a nie skrótem. +""" + +from django.db import connection, migrations + +from bpp.migration_util import widok_dopisz_warunek, widok_usun_warunek + +# (widok sum, tabela PUBLIKACJI, którą joinuje) +SUMY = [ + ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), + ("bpp_nowe_sumy_wydawnictwo_zwarte_view", "bpp_wydawnictwo_zwarte"), + ("bpp_nowe_sumy_patent_view", "bpp_patent"), + ("bpp_nowe_sumy_praca_doktorska_view", "bpp_praca_doktorska"), + ("bpp_nowe_sumy_praca_habilitacyjna_view", "bpp_praca_habilitacyjna"), +] + + +def _warunek(tabela): + return f"{tabela}.deleted_at IS NULL" + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + for widok, tabela in SUMY: + widok_dopisz_warunek(cur, widok, _warunek(tabela)) + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + for widok, tabela in SUMY: + widok_usun_warunek(cur, widok, _warunek(tabela)) + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0497_soft_delete_rekord_views"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/migrations/0499_drop_kronika_views.py b/src/bpp/migrations/0499_drop_kronika_views.py new file mode 100644 index 000000000..ffbfe8b1a --- /dev/null +++ b/src/bpp/migrations/0499_drop_kronika_views.py @@ -0,0 +1,98 @@ +"""Kasuje MARTWĄ rodzinę siedmiu widoków ``bpp_kronika_*``. + +DLACZEGO WSZYSTKIE SIEDEM NARAZ. Faza 01 chciała skasować trzy z nich +(``wydawnictwo_ciagle``/``wydawnictwo_zwarte``/``patent``) i została +zablokowana: zależą od nich dwa widoki nadrzędne, więc goły ``DROP VIEW`` +bez ``CASCADE`` nie przechodzi, a ``CASCADE`` po cichu zabrałby też te +nadrzędne. Rozbicie na kilka migracji nic nie daje — graf trzeba rozciąć +w jednym miejscu albo wcale. + +DOWÓD MARTWOTY (zweryfikowany 2026-08-07, faza 02): + +1. ``pg_depend``: JEDYNE zależności od tych siedmiu widoków są WEWNĄTRZ + rodziny — ``bpp_kronika_view`` <- ``bpp_kronika_all_unsorted_view`` <- + pięć widoków liści. Nic spoza rodziny na nich nie stoi. To mocniejszy + dowód niż grep: obejmuje też widoki i reguły, których nazwa nie zawiera + słowa „kronika". +2. Kod: zero trafień „kronika" w ``.py``/``.html``/``.json``/``.js``/SQL-u + poza katalogami migracji i ``baseline-sql/`` (czyli poza definicjami + samych widoków). Zero ``Meta.db_table`` wskazujących na którykolwiek. +3. Repozytoria SIOSTRZANE (to są obiekty bazodanowe, więc konsument nie + musi mieszkać w repo aplikacji — self-review, 2026-08-07): ``bpp-mcp`` + i ``bpp-skills`` czyste; ``bpp-deploy`` odwołuje się do nich WYŁĄCZNIE + w komentarzach i w jednej kontrolce diagnostycznej — patrz uwaga niżej. +4. ``flexible_reports``: definicje raportów żyją jako wiersze w bazie + produkcyjnej, więc z repo nie da się ich sprawdzić. **Potwierdzone przez + właściciela systemu (2026-08-07): żaden raport nie odpytuje tych + widoków.** To był jedyny element dowodu oparty na założeniu. + +Fazę 01 zweryfikowała tak trzy widoki; faza 02 dołożyła +``bpp_kronika_praca_{doktorska,habilitacyjna}_view``, których żywotności +nikt wcześniej nie sprawdzał — wyszły na jaw, gdy kanarek katalogowy +dostał do zakresu tabele publikacji. + +⚠️ DO ZROBIENIA W ``bpp-deploy`` (poza tym repo, nieblokujące): +``scripts/pg-collation-migrate-3-load.sh`` drukuje po załadowaniu bazy +kontrolkę ``SELECT 'kronika views: '||count(*) ... LIKE 'bpp_kronika%'``. +Po tej migracji wypisze ``0`` — a to sanity-check po odtworzeniu bazy, więc +operator ma prawo odczytać zero jako nieudany load. Do zdjęcia razem +z komentarzami w ``lib-pg-collation-migrate.sh`` i +``pg-collation-migrate-2-fix.sh``, które uzasadniają migrację kolacji przez +„5 widoków ``bpp_kronika_*``". Sam ``sed`` celuje we wzorzec ``COLLATE``, +a nie w nazwy widoków, więc skrypty NIE przestają działać. + +ODWRACALNOŚĆ: ``backward`` odtwarza całą siódemkę z sidecara +``0499_drop_kronika_views.sql``. Plik został WYGENEROWANY z +``pg_get_viewdef()`` na żywym katalogu, a nie przepisany ręcznie — przy +7 KB SQL-a przepisywanie byłoby proszeniem się o cichą literówkę. + +⚠️ Po tej migracji trzeba usunąć trzy wpisy ``WYJATKI`` z kanarka +katalogowego (``test_kanarek_katalogowy.py``) — wskazują na widoki, których +już nie ma. +""" + +from django.db import connection, migrations + +from bpp.migration_util import load_custom_sql + +# Kolejnosc KASOWANIA: od szczytu w dol (parasole przed liscmi). +# Odwrotna kolejnosc (odtwarzania) siedzi w sidecarze .sql. +RODZINA_OD_SZCZYTU = [ + "bpp_kronika_view", + "bpp_kronika_all_unsorted_view", + "bpp_kronika_wydawnictwo_ciagle_view", + "bpp_kronika_wydawnictwo_zwarte_view", + "bpp_kronika_patent_view", + "bpp_kronika_praca_doktorska_view", + "bpp_kronika_praca_habilitacyjna_view", +] + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + for widok in RODZINA_OD_SZCZYTU: + # Dwie NIEZALEŻNE decyzje w jednej instrukcji: + # + # bez CASCADE — gdyby cokolwiek spoza rodziny zdążyło się na + # którymś oprzeć, chcemy głośnego błędu, a nie cichego zabrania + # tego czegoś razem z widokiem; + # + # z IF EXISTS — bo to piętnastoletnie, martwe widoki i któryś + # DBA mógł je już ręcznie sprzątnąć; bez tego migracja twardo + # pada na takiej bazie. IF EXISTS NIE osłabia głośności: + # przy istniejącej zależności DROP dalej rzuca błąd. + cur.execute(f"DROP VIEW IF EXISTS {widok}") + + +def backward(apps, schema_editor): + load_custom_sql("0499_drop_kronika_views") + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0498_nowe_sumy_bez_skasowanych_publikacji"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/bpp/migrations/0499_drop_kronika_views.sql b/src/bpp/migrations/0499_drop_kronika_views.sql new file mode 100644 index 000000000..93f9f47f2 --- /dev/null +++ b/src/bpp/migrations/0499_drop_kronika_views.sql @@ -0,0 +1,196 @@ +-- Odtworzenie rodziny widokow bpp_kronika_* (backward migracji 0499). +-- +-- WYGENEROWANE z pg_get_viewdef() na zywym katalogu, nie przepisane recznie. +-- Kolejnosc jest WYMUSZONA grafem zaleznosci: piec widokow lisci, potem +-- bpp_kronika_all_unsorted_view (UNION po nich), na koncu bpp_kronika_view. +-- +-- Ta rodzina jest MARTWA (zero konsumentow) i migracja 0499 ja kasuje. +-- Plik istnieje wylacznie po to, zeby ta migracja byla odwracalna. + +CREATE VIEW bpp_kronika_wydawnictwo_ciagle_view AS + SELECT bpp_autor.id AS autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_jednostka.id AS jednostka_id, + bpp_wydawnictwo_ciagle.tytul_oryginalny, + bpp_wydawnictwo_ciagle.tytul_oryginalny_sort, + bpp_wydawnictwo_ciagle.rok, + bpp_wydawnictwo_ciagle_autor.kolejnosc, + 'bpp_wydawnictwo_ciagle'::text AS object, + bpp_wydawnictwo_ciagle.id AS object_pk, + bpp_wydawnictwo_ciagle.id, + bpp_wydawnictwo_ciagle.zrodlo_id + FROM bpp_wydawnictwo_ciagle, + bpp_wydawnictwo_ciagle_autor, + bpp_jednostka, + bpp_zrodlo, + bpp_autor + WHERE bpp_wydawnictwo_ciagle_autor.autor_id = bpp_autor.id AND bpp_wydawnictwo_ciagle_autor.rekord_id = bpp_wydawnictwo_ciagle.id AND bpp_wydawnictwo_ciagle_autor.jednostka_id = bpp_jednostka.id AND bpp_zrodlo.id = bpp_wydawnictwo_ciagle.zrodlo_id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_wydawnictwo_zwarte_view AS + SELECT bpp_autor.id AS autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_jednostka.id AS jednostka_id, + bpp_wydawnictwo_zwarte.tytul_oryginalny, + bpp_wydawnictwo_zwarte.tytul_oryginalny_sort, + bpp_wydawnictwo_zwarte.rok, + bpp_wydawnictwo_zwarte_autor.kolejnosc, + 'bpp_wydawnictwo_zwarte'::text AS object, + bpp_wydawnictwo_zwarte.id AS object_pk, + bpp_wydawnictwo_zwarte.id, + NULL::integer AS zrodlo_id + FROM bpp_wydawnictwo_zwarte, + bpp_wydawnictwo_zwarte_autor, + bpp_jednostka, + bpp_autor + WHERE bpp_wydawnictwo_zwarte_autor.autor_id = bpp_autor.id AND bpp_wydawnictwo_zwarte_autor.rekord_id = bpp_wydawnictwo_zwarte.id AND bpp_wydawnictwo_zwarte_autor.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_patent_view AS + SELECT bpp_autor.id AS autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_jednostka.id AS jednostka_id, + bpp_patent.tytul_oryginalny, + bpp_patent.tytul_oryginalny_sort, + bpp_patent.rok, + bpp_patent_autor.kolejnosc, + 'bpp_patent'::text AS object, + bpp_patent.id AS object_pk, + bpp_patent.id, + NULL::integer AS zrodlo_id + FROM bpp_patent, + bpp_autor, + bpp_patent_autor, + bpp_jednostka + WHERE bpp_patent_autor.autor_id = bpp_autor.id AND bpp_patent_autor.rekord_id = bpp_patent.id AND bpp_patent_autor.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_praca_doktorska_view AS + SELECT bpp_praca_doktorska.autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_praca_doktorska.jednostka_id, + bpp_praca_doktorska.tytul_oryginalny, + bpp_praca_doktorska.tytul_oryginalny_sort, + bpp_praca_doktorska.rok, + 1 AS kolejnosc, + 'bpp_praca_doktorska'::text AS object, + bpp_praca_doktorska.id AS object_pk, + bpp_praca_doktorska.id, + NULL::integer AS zrodlo_id + FROM bpp_praca_doktorska, + bpp_jednostka, + bpp_autor + WHERE bpp_praca_doktorska.autor_id = bpp_autor.id AND bpp_praca_doktorska.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_praca_habilitacyjna_view AS + SELECT bpp_praca_habilitacyjna.autor_id, + bpp_autor.imiona, + bpp_autor.nazwisko, + bpp_praca_habilitacyjna.jednostka_id, + bpp_praca_habilitacyjna.tytul_oryginalny, + bpp_praca_habilitacyjna.tytul_oryginalny_sort, + bpp_praca_habilitacyjna.rok, + 1 AS kolejnosc, + 'bpp_praca_habilitacyjna'::text AS object, + bpp_praca_habilitacyjna.id AS object_pk, + bpp_praca_habilitacyjna.id, + NULL::integer AS zrodlo_id + FROM bpp_praca_habilitacyjna, + bpp_jednostka, + bpp_autor + WHERE bpp_praca_habilitacyjna.autor_id = bpp_autor.id AND bpp_praca_habilitacyjna.jednostka_id = bpp_jednostka.id AND bpp_jednostka.wchodzi_do_rankingu_autorow = true +; + +CREATE VIEW bpp_kronika_all_unsorted_view AS + SELECT bpp_kronika_patent_view.autor_id, + bpp_kronika_patent_view.imiona, + bpp_kronika_patent_view.nazwisko, + bpp_kronika_patent_view.jednostka_id, + bpp_kronika_patent_view.tytul_oryginalny, + bpp_kronika_patent_view.tytul_oryginalny_sort, + bpp_kronika_patent_view.rok, + bpp_kronika_patent_view.kolejnosc, + bpp_kronika_patent_view.object, + bpp_kronika_patent_view.object_pk, + bpp_kronika_patent_view.id, + bpp_kronika_patent_view.zrodlo_id + FROM bpp_kronika_patent_view +UNION + SELECT bpp_kronika_praca_habilitacyjna_view.autor_id, + bpp_kronika_praca_habilitacyjna_view.imiona, + bpp_kronika_praca_habilitacyjna_view.nazwisko, + bpp_kronika_praca_habilitacyjna_view.jednostka_id, + bpp_kronika_praca_habilitacyjna_view.tytul_oryginalny, + bpp_kronika_praca_habilitacyjna_view.tytul_oryginalny_sort, + bpp_kronika_praca_habilitacyjna_view.rok, + bpp_kronika_praca_habilitacyjna_view.kolejnosc, + bpp_kronika_praca_habilitacyjna_view.object, + bpp_kronika_praca_habilitacyjna_view.object_pk, + bpp_kronika_praca_habilitacyjna_view.id, + bpp_kronika_praca_habilitacyjna_view.zrodlo_id + FROM bpp_kronika_praca_habilitacyjna_view +UNION + SELECT bpp_kronika_praca_doktorska_view.autor_id, + bpp_kronika_praca_doktorska_view.imiona, + bpp_kronika_praca_doktorska_view.nazwisko, + bpp_kronika_praca_doktorska_view.jednostka_id, + bpp_kronika_praca_doktorska_view.tytul_oryginalny, + bpp_kronika_praca_doktorska_view.tytul_oryginalny_sort, + bpp_kronika_praca_doktorska_view.rok, + bpp_kronika_praca_doktorska_view.kolejnosc, + bpp_kronika_praca_doktorska_view.object, + bpp_kronika_praca_doktorska_view.object_pk, + bpp_kronika_praca_doktorska_view.id, + bpp_kronika_praca_doktorska_view.zrodlo_id + FROM bpp_kronika_praca_doktorska_view +UNION + SELECT bpp_kronika_wydawnictwo_ciagle_view.autor_id, + bpp_kronika_wydawnictwo_ciagle_view.imiona, + bpp_kronika_wydawnictwo_ciagle_view.nazwisko, + bpp_kronika_wydawnictwo_ciagle_view.jednostka_id, + bpp_kronika_wydawnictwo_ciagle_view.tytul_oryginalny, + bpp_kronika_wydawnictwo_ciagle_view.tytul_oryginalny_sort, + bpp_kronika_wydawnictwo_ciagle_view.rok, + bpp_kronika_wydawnictwo_ciagle_view.kolejnosc, + bpp_kronika_wydawnictwo_ciagle_view.object, + bpp_kronika_wydawnictwo_ciagle_view.object_pk, + bpp_kronika_wydawnictwo_ciagle_view.id, + bpp_kronika_wydawnictwo_ciagle_view.zrodlo_id + FROM bpp_kronika_wydawnictwo_ciagle_view +UNION + SELECT bpp_kronika_wydawnictwo_zwarte_view.autor_id, + bpp_kronika_wydawnictwo_zwarte_view.imiona, + bpp_kronika_wydawnictwo_zwarte_view.nazwisko, + bpp_kronika_wydawnictwo_zwarte_view.jednostka_id, + bpp_kronika_wydawnictwo_zwarte_view.tytul_oryginalny, + bpp_kronika_wydawnictwo_zwarte_view.tytul_oryginalny_sort, + bpp_kronika_wydawnictwo_zwarte_view.rok, + bpp_kronika_wydawnictwo_zwarte_view.kolejnosc, + bpp_kronika_wydawnictwo_zwarte_view.object, + bpp_kronika_wydawnictwo_zwarte_view.object_pk, + bpp_kronika_wydawnictwo_zwarte_view.id, + bpp_kronika_wydawnictwo_zwarte_view.zrodlo_id + FROM bpp_kronika_wydawnictwo_zwarte_view +; + +CREATE VIEW bpp_kronika_view AS + SELECT autor_id, + imiona, + nazwisko, + jednostka_id, + tytul_oryginalny, + tytul_oryginalny_sort, + rok, + kolejnosc, + object, + object_pk, + id, + zrodlo_id + FROM bpp_kronika_all_unsorted_view + ORDER BY nazwisko, imiona, tytul_oryginalny_sort +; diff --git a/src/bpp/models/patent.py b/src/bpp/models/patent.py index 859536b9e..d2f2b728b 100644 --- a/src/bpp/models/patent.py +++ b/src/bpp/models/patent.py @@ -26,7 +26,10 @@ RekordBPPBaza, ) from bpp.models.autor import Autor -from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin +from bpp.models.soft_delete import ( + BppAutorstwoSoftDeleteMixin, + BppPublikacjaSoftDeleteMixin, +) from bpp.models.system import Charakter_Formalny, Jezyk from bpp.util import safe_tytul_html @@ -124,6 +127,7 @@ def jezyk(self): class Patent( + BppPublikacjaSoftDeleteMixin, RekordBPPBaza, ModelZRokiem, ModelZeStatusem, @@ -177,6 +181,15 @@ class Meta: verbose_name = "patent" verbose_name_plural = "patenty" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="patent_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def __str__(self): return self.tytul_oryginalny diff --git a/src/bpp/models/praca_doktorska.py b/src/bpp/models/praca_doktorska.py index 53edbb894..1e6c593cb 100644 --- a/src/bpp/models/praca_doktorska.py +++ b/src/bpp/models/praca_doktorska.py @@ -1,7 +1,7 @@ from denorm import denormalized, depend_on_fields, depend_on_related from django.contrib.postgres.fields import ArrayField from django.db import models -from django.db.models import CASCADE, SET_NULL +from django.db.models import CASCADE, SET_NULL, Q from django.utils.functional import cached_property from bpp.models import ( @@ -11,6 +11,7 @@ NieMaProcentowMixin, ) from bpp.models.abstract import DwaTytuly +from bpp.models.soft_delete import BppPublikacjaSoftDeleteMixin from .autor import Autor from .struktura import Jednostka @@ -149,7 +150,7 @@ def typ_odpowiedzialnosci_autor(self): _Praca_Doktorska_PropertyCache = _Praca_Doktorska_PropertyCache() -class Praca_Doktorska(Praca_Doktorska_Baza): +class Praca_Doktorska(BppPublikacjaSoftDeleteMixin, Praca_Doktorska_Baza): autor = models.ForeignKey(Autor, CASCADE) promotor = models.ForeignKey( @@ -165,6 +166,15 @@ class Meta: verbose_name_plural = "prace doktorskie" app_label = "bpp" ordering = ("rok", "tytul_oryginalny") + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="pdok_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def clean(self): DwaTytuly.clean(self) diff --git a/src/bpp/models/praca_habilitacyjna.py b/src/bpp/models/praca_habilitacyjna.py index 14b07310d..2b61c9864 100644 --- a/src/bpp/models/praca_habilitacyjna.py +++ b/src/bpp/models/praca_habilitacyjna.py @@ -1,11 +1,12 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models -from django.db.models import CASCADE, PROTECT +from django.db.models import CASCADE, PROTECT, Q from django.utils.functional import cached_property from bpp.models import Autor, Charakter_Formalny, DwaTytuly, ModelZOplataZaPublikacje from bpp.models.praca_doktorska import Praca_Doktorska_Baza +from bpp.models.soft_delete import BppPublikacjaSoftDeleteMixin class Publikacja_Habilitacyjna(models.Model): @@ -43,7 +44,7 @@ def charakter_formalny(self): _Praca_Habilitacyjna_PropertyCache = _Praca_Habilitacyjna_PropertyCache() -class Praca_Habilitacyjna(Praca_Doktorska_Baza): +class Praca_Habilitacyjna(BppPublikacjaSoftDeleteMixin, Praca_Doktorska_Baza): autor = models.OneToOneField(Autor, PROTECT) publikacje_habilitacyjne = GenericRelation(Publikacja_Habilitacyjna) @@ -56,6 +57,15 @@ class Meta: verbose_name = "praca habilitacyjna" verbose_name_plural = "prace habilitacyjne" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="phab_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def clean(self): DwaTytuly.clean(self) diff --git a/src/bpp/models/soft_delete.py b/src/bpp/models/soft_delete.py index 1e759ec72..bf0a46f08 100644 --- a/src/bpp/models/soft_delete.py +++ b/src/bpp/models/soft_delete.py @@ -37,7 +37,11 @@ Model.deleted_objects.filter(ostatnio_zmieniony__gte=X) """ +import uuid + from django.core.exceptions import FieldDoesNotExist +from django.db import transaction +from django.utils import timezone from django_softdelete.managers import ( DeletedManager, DeletedQuerySet, @@ -46,6 +50,13 @@ SoftDeleteQuerySet, ) from django_softdelete.models import SoftDeleteModel +from django_softdelete.signals import post_restore, post_soft_delete + +#: Akcesor relacji odwrotnej publikacja -> wiersze ``*_Autor``. Istnieje +#: jako PRAWDZIWA relacja tylko dla trzech typów z through-modelem; +#: ``Praca_Doktorska``/``Praca_Habilitacyjna`` mają pod tą nazwą property +#: zwracającą atrapy — patrz ``BppPublikacjaSoftDeleteMixin``. +NAZWA_RELACJI_AUTORSTW = "autorzy_set" #: Nazwa pola-znacznika czasu modyfikacji. Ta sama w #: ``BazaModeluOdpowiedzialnosciAutorow`` (``abstract/authors.py``) i w @@ -225,3 +236,152 @@ def restore( ``obj.restore(False, txid)``. """ return super().restore(strict, transaction_id, *args, **kwargs) + + +class BppPublikacjaSoftDeleteMixin(SoftDeleteModel): + """SoftDeleteModel dla 5 modeli PUBLIKACJI (faza 02) z **wąską, + kontrolowaną** kaskadą na własne wiersze ``*_Autor`` pod wspólnym + ``transaction_id``. + + DLACZEGO NIE REFLEKSYJNA KASKADA PAKIETU: ``SoftDeleteModel.delete()`` + przechodzi po wszystkich relacjach zwrotnych. Zjechałaby więc po + ``*_Streszczenie``, ``*_Zewnetrzna_Baza_Danych`` i + ``Publikacja_Habilitacyjna`` — modelach, które soft-delete NIE obejmuje. + Co gorsza zrobiłaby to CICHO: ``delete()`` pakietu ma domyślnie + ``strict=False``, więc nie usłyszelibyśmy ``SoftDeleteException``. + Dlatego kaskadę piszemy sami i zatrzymujemy ją na ``*_Autor``. + + Kaskada NIE jest jednolita dla wszystkich pięciu modeli — patrz + ``_relacja_autorstw()``. + """ + + # Managery z gate'em na .update() (faza 01). ``deleted_objects`` to + # NASZ ``BppDeletedManager``, nie pakietowy ``DeletedManager`` — + # pakietowy zwraca queryset z ``strict=True`` w ``restore()``, co łamie + # inwariant z docstringu modułu. + objects = BppSoftDeleteManager() + global_objects = BppGlobalManager() + deleted_objects = BppDeletedManager() + + class Meta: + abstract = True + + # --- rozpoznanie kształtu kaskady ---------------------------------- + + def _relacja_autorstw(self): + """Relacja odwrotna ``autorzy_set`` (``ForeignObjectRel``) albo + ``None``, gdy publikacja nie ma through-modelu autorstw. + + DLACZEGO PYTAMY ``_meta``, A NIE TYPU ATRYBUTU: pod nazwą + ``autorzy_set`` kryją się dwie różne rzeczy. Dla + ``Wydawnictwo_Ciagle``/``Wydawnictwo_Zwarte``/``Patent`` to + prawdziwy related manager (``related_name`` FK w modelu + ``*_Autor``). Dla ``Praca_Doktorska``/``Praca_Habilitacyjna`` to + PROPERTY zwracająca ``FakeSet`` z atrapami (autor leży na wierszu + samej publikacji) — ``FakeSet`` jest podklasą ``list``, nie ma + ``.model``, a atrapy nie mają ``.delete()``. + + Rozpoznawanie tego przez ``type(self).__dict__.get("autorzy_set")`` + NIE DZIAŁA: property jest zadeklarowana na abstrakcyjnej bazie + ``Praca_Doktorska_Baza``, a dziedziczenie abstrakcyjne w Django + kopiuje do klasy potomnej POLA, nie zwykłe atrybuty Pythona — te + zostają na bazie i są znajdowane dopiero przez MRO. ``__dict__`` + klasy konkretnej jest więc pusty i test na ``property`` dawałby + fałszywe „to jest through-model", a zaraz potem ``AttributeError`` + na ``FakeSet.model``. + + ``_meta.related_objects`` zna wyłącznie PRAWDZIWE relacje, więc + odpowiada na pytanie, które faktycznie zadajemy. + """ + for rel in self._meta.related_objects: + if rel.get_accessor_name() == NAZWA_RELACJI_AUTORSTW: + return rel + return None + + def _model_through(self): + """Model ``*_Autor`` tej publikacji albo ``None``.""" + rel = self._relacja_autorstw() + return rel.related_model if rel is not None else None + + def _autorstwa_do_kaskady(self): + """Wiersze ``*_Autor`` do soft-delete; pusto bez through-modelu. + + Czyta przez domyślny manager (``objects``, więc już-skasowane są + pominięte) — kasowanie drugi raz nie szkodzi, ale i nie ma po co. + """ + if self._relacja_autorstw() is None: + return [] + return list(getattr(self, NAZWA_RELACJI_AUTORSTW).all()) + + # --- kontrakt zapisu ------------------------------------------------ + + def save(self, *args, **kwargs): + """Bump ``ostatnio_zmieniony`` przy soft-delete i restore. + + Ten sam kontrakt (PINNED), co + ``BppAutorstwoSoftDeleteMixin.save()`` — pełne uzasadnienie + mechanizmu jest w docstringu ``dopisz_znacznik_zmiany()``. Bez + tego nagrobki dla harvestu przyrostowego (OAI-PMH, CERIF, REST + API) byłyby nieodpytywalne po ``ostatnio_zmieniony__gte``, bo + ``auto_now`` nie rusza pola nieobecnego w ``update_fields``. + """ + update_fields = kwargs.get("update_fields") + if update_fields: + kwargs["update_fields"] = dopisz_znacznik_zmiany(self, update_fields) + return super().save(*args, **kwargs) + + # --- soft-delete / restore ------------------------------------------ + + def delete(self, *args, user=None, reason="", **kwargs): + """Soft-delete publikacji + wąska kaskada na ``*_Autor``. + + ``user``/``reason`` są na razie wyłącznie przepuszczane — konsumuje + je ``SoftDeleteLog`` z fazy 06. W sygnaturze MUSZĄ być już teraz + (kontrakt PINNED), żeby wołający kod nie wymagał później zmiany. + """ + txid = kwargs.pop("transaction_id", None) or uuid.uuid4() + with transaction.atomic(): + # 1. kaskada per-instancja — NIGDY bulk update(deleted_at=...), + # bo omijałby post_save, sygnały i reversion (gate w + # BppSoftDeleteQuerySet.update() egzekwuje to fail-fast). + for autorstwo in self._autorstwa_do_kaskady(): + autorstwo.delete(transaction_id=txid) + # 2. własny wiersz + self.deleted_at = timezone.now() + self.restored_at = None + self.transaction_id = txid + self.save(update_fields=["deleted_at", "restored_at", "transaction_id"]) + post_soft_delete.send(sender=self.__class__, instance=self) + return 1, {self._meta.label: 1} + + delete.alters_data = True + + def restore(self, *args, strict: bool = False, user=None, **kwargs): + """Przywrócenie publikacji + tych ``*_Autor``, które zniknęły RAZEM + z nią (ten sam ``transaction_id``). + + Filtr po ``transaction_id`` jest istotny: autorstwo skasowane + wcześniej, osobną decyzją operatora, ma POZOSTAĆ w koszu. + + ``strict`` przyjmujemy i ignorujemy świadomie — przekazują go + ścieżki queryset-owe (``global_objects``/``deleted_objects``), a my + i tak nie wołamy ``super().restore()``, więc pakietowy + ``SoftDeleteException`` nie ma jak polecieć (inwariant z docstringu + modułu). + """ + txid = self.transaction_id + rel = self._relacja_autorstw() + with transaction.atomic(): + if txid is not None and rel is not None: + # Nazwa pola FK z metadanych relacji — bez zaszywania + # "rekord" na sztywno. + filtr = {rel.field.name: self, "transaction_id": txid} + for autorstwo in rel.related_model.deleted_objects.filter(**filtr): + autorstwo.restore(transaction_id=txid) + self.deleted_at = None + self.restored_at = timezone.now() + self.transaction_id = None + self.save(update_fields=["deleted_at", "restored_at", "transaction_id"]) + post_restore.send(sender=self.__class__, instance=self, transaction_id=txid) + + restore.alters_data = True diff --git a/src/bpp/models/wydawnictwo_ciagle.py b/src/bpp/models/wydawnictwo_ciagle.py index de559857a..cfe9409fe 100644 --- a/src/bpp/models/wydawnictwo_ciagle.py +++ b/src/bpp/models/wydawnictwo_ciagle.py @@ -47,7 +47,11 @@ ModelZWWW, Wydawnictwo_Baza, ) -from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin +from bpp.models.soft_delete import ( + BppAutorstwoSoftDeleteMixin, + BppPublikacjaSoftDeleteMixin, + BppSoftDeleteManager, +) from bpp.models.system import Zewnetrzna_Baza_Danych from bpp.models.util import ZapobiegajNiewlasciwymCharakterom @@ -169,11 +173,30 @@ class Meta: abstract = True -class Wydawnictwo_Ciagle_Manager(ManagerModeliZOplataZaPublikacjeMixin, models.Manager): - pass +class Wydawnictwo_Ciagle_Manager( + ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager +): + """Menedżer opłat PRZEPLECIONY z filtrem soft-delete (faza 02). + + Nośna jest DRUGA BAZA, nie kolejność. Do fazy 02 stało tu + ``models.Manager``, więc ``objects`` w ogóle nie znało ``deleted_at`` + i pokazywało kosz — mimo że model dziedziczył już + ``BppPublikacjaSoftDeleteMixin`` (menedżer zadeklarowany w ciele klasy + przesłania ten wniesiony przez bazę abstrakcyjną). Podmiana na + ``BppSoftDeleteManager`` to naprawia. + + Kolejność baz jest natomiast WYŁĄCZNIE konwencją (mixiny przed klasą + bazową) — sprawdzone mutacyjnie: odwrócenie jej nie zmienia zachowania. + Powód: ``ManagerModeliZOplataZaPublikacjeMixin`` NIE jest menedżerem, + tylko czystym mixinem z jedną metodą (``self.exclude(...)``), więc nie + wnosi własnego ``get_queryset()`` i nie ma o co konkurować w MRO. + Dzięki temu ``rekordy_z_oplata()`` operuje na już-przefiltrowanym + querysecie bez jednej linijki kodu o soft-delete. + """ class Wydawnictwo_Ciagle( + BppPublikacjaSoftDeleteMixin, ZapobiegajNiewlasciwymCharakterom, Wydawnictwo_Baza, DwaTytuly, @@ -229,6 +252,22 @@ class Meta: verbose_name = "wydawnictwo ciągłe" verbose_name_plural = "wydawnictwa ciągłe" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY (`WHERE deleted_at IS NOT NULL`) — ten sam + # wzorzec i to samo uzasadnienie, co `wc_autor_deleted_at_idx` + # wyżej (faza 01): predykat `deleted_at IS NULL` pasuje do ~100% + # wierszy, więc planner nigdy nie wybrałby pod niego indeksu, a + # pełny btree byłby wyłącznie kosztem (rozmiar + wpis przy + # każdym INSERT/UPDATE publikacji). Selektywne jest zapytanie + # ODWROTNE — kosz/audyt (`deleted_objects`) — i to ono dostaje + # tu mikroskopijny indeks. KANONICZNE UZASADNIENIE dla + # wszystkich pięciu tabel publikacji. + models.Index( + fields=["deleted_at"], + name="wc_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def punktacja_zrodla(self): """Funkcja - skrót do użycia w templatkach, zwraca punktację zrodla diff --git a/src/bpp/models/wydawnictwo_zwarte.py b/src/bpp/models/wydawnictwo_zwarte.py index 6bc800b4b..cb53df3d7 100644 --- a/src/bpp/models/wydawnictwo_zwarte.py +++ b/src/bpp/models/wydawnictwo_zwarte.py @@ -54,7 +54,11 @@ ) from bpp.models.autor import Autor from bpp.models.nagroda import Nagroda -from bpp.models.soft_delete import BppAutorstwoSoftDeleteMixin +from bpp.models.soft_delete import ( + BppAutorstwoSoftDeleteMixin, + BppPublikacjaSoftDeleteMixin, + BppSoftDeleteManager, +) from bpp.models.system import Zewnetrzna_Baza_Danych from bpp.models.util import ZapobiegajNiewlasciwymCharakterom from bpp.models.wydawca import Wydawca @@ -232,7 +236,18 @@ class Meta: rok_regex = re.compile(r"\s[12]\d\d\d") -class Wydawnictwo_Zwarte_Manager(ManagerModeliZOplataZaPublikacjeMixin, models.Manager): +class Wydawnictwo_Zwarte_Manager( + ManagerModeliZOplataZaPublikacjeMixin, BppSoftDeleteManager +): + """Jak ``Wydawnictwo_Ciagle_Manager`` — uzasadnienie doboru bazy + (i tego, dlaczego kolejność NIE jest nośna) w jego docstringu + (``wydawnictwo_ciagle.py``). + + ``wydawnictwa_nadrzedne_dla_innych()`` też korzysta na przepleceniu: + po fazie 02 nie zwróci już książki-matki, której jedyne rozdziały + trafiły do kosza. + """ + def wydawnictwa_nadrzedne_dla_innych(self): return ( self.exclude(wydawnictwo_nadrzedne_id=None) @@ -242,6 +257,7 @@ def wydawnictwa_nadrzedne_dla_innych(self): class Wydawnictwo_Zwarte( + BppPublikacjaSoftDeleteMixin, ZapobiegajNiewlasciwymCharakterom, Wydawnictwo_Zwarte_Baza, ModelZCharakterem, @@ -317,6 +333,15 @@ class Meta: verbose_name = "wydawnictwo zwarte" verbose_name_plural = "wydawnictwa zwarte" app_label = "bpp" + indexes = [ + # Indeks CZĘŚCIOWY — uzasadnienie przy `wc_deleted_at_idx` + # (`wydawnictwo_ciagle.py`, Meta klasy Wydawnictwo_Ciagle). + models.Index( + fields=["deleted_at"], + name="wz_deleted_at_idx", + condition=Q(deleted_at__isnull=False), + ), + ] def wydawnictwa_powiazane_posortowane(self): """ diff --git a/src/bpp/tests/test_cache/test_soft_delete_preconditions.py b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py index 2033494bb..13403102a 100644 --- a/src/bpp/tests/test_cache/test_soft_delete_preconditions.py +++ b/src/bpp/tests/test_cache/test_soft_delete_preconditions.py @@ -14,24 +14,24 @@ Faza 01 objela WYLACZNIE tabele ``*_Autor`` (migracja 0489: widok filtruje, funkcja refresh ma galaz kasujaca, bramka WHEN zna ``deleted_at`` -- patrz -``test_soft_delete/test_views_sql.py``). Tabele publikacji (np. -``bpp_wydawnictwo_ciagle``) sa poza zakresem -- to faza 02. Dlatego: - -- oryginalne dwa testy ponizej sa ODWROCONE na docelowe asercje (to co - soft-delete MA robic), ale zostawione jako ``xfail`` -- to JEDYNY - regresyjny dowod, ze bramka i galaz kasujaca dzialaja poprawnie DOPIERO - po fazie 02 (gdy przestana byc xfail, to znak, ze ktos wdrozyl mechanizm - dla publikacji i NIE zaktualizowal tego markera -- patrz uwaga przy - ``xfail`` nizej); -- ponizej dopisane sa ich ODPOWIEDNIKI dla ``bpp_wydawnictwo_ciagle_autor`` - / ``bpp_autorzy_mat``, ktore juz DZIALAJA (faza 01) -- to one sa realnym - dowodem regresyjnym na CO DZIEN, nie oryginaly. +``test_soft_delete/test_views_sql.py``), a faza 02 -- 5 tabel PUBLIKACJI +(migracje 0496 i 0497, patrz ``test_soft_delete/test_views_sql_publikacje.py``). + +Historia tego pliku, bo tlumaczy jego ksztalt: + +- oryginalne dwa testy zostaly po fazie 01 ODWROCONE na docelowe asercje (to, + co soft-delete MA robic) i oznaczone ``xfail(strict=True)``, bo dla + publikacji mechanizmu jeszcze nie bylo. Zeby w ogole dalo sie je napisac, + dokladaly kolumne ``deleted_at`` ALTER-em i owijaly widok filtrem WEWNATRZ + transakcji testowej; +- faza 02 dostarczyla jedno i drugie NAPRAWDE, wiec symulacja zostala + usunieta, a wraz z nia markery ``xfail``. Testy sa teraz zwyklymi testami + regresyjnymi; +- ich ODPOWIEDNIKI dla ``bpp_wydawnictwo_ciagle_autor`` / ``bpp_autorzy_mat`` + (dopisane w fazie 01) zostaja -- pokrywaja druga sciezke. Surowy SQL, zeby izolowac sam trigger bazodanowy (bez denorm / sygnalow -Django). Dla oryginalnych (publikacja) testow kolumne ``deleted_at`` -dokladamy ALTER-em wewnatrz transakcji testowej -- DDL w Postgresie jest -transakcyjny, wiec rollback ja sprzata. Dla nowych (``*_Autor``) testow -ALTER nie jest potrzebny -- kolumna jest realna od migracji 0488. +Django). """ import pytest @@ -62,48 +62,18 @@ def _ctid(cur, ct, pk): return row[0] if row else None -def _dodaj_deleted_at(cur): - cur.execute("ALTER TABLE bpp_wydawnictwo_ciagle ADD COLUMN deleted_at timestamptz") - - -def _filtruj_widok_po_deleted_at(cur): - """Owija bpp_wydawnictwo_ciagle_view filtrem deleted_at IS NULL. - - Odpowiednik "mechanizmu #1" ze specu, bez ruszania oryginalnej definicji - (CREATE OR REPLACE zachowuje liste kolumn -- bpp_rekord, ktory ten widok - UNION-uje, pozostaje wazny). - """ - cur.execute("SELECT pg_get_viewdef('bpp_wydawnictwo_ciagle_view'::regclass, true)") - orig = cur.fetchone()[0].rstrip().rstrip(";") - cur.execute( - f"CREATE OR REPLACE VIEW bpp_wydawnictwo_ciagle_view AS " - f"SELECT * FROM ({orig}) _orig " - f"WHERE _orig.object_id_raw NOT IN (" - f" SELECT id FROM bpp_wydawnictwo_ciagle WHERE deleted_at IS NOT NULL)" - ) - - -@pytest.mark.xfail( - reason="faza 02 -- soft-delete publikacji (bpp_wydawnictwo_ciagle)", - strict=True, -) @pytest.mark.django_db def test_update_samego_deleted_at_odpala_trigger(): - """Docelowo: bramka WHEN MA znac deleted_at -> UPDATE soft-delete MA - odpalac trigger, ktory kasuje wiersz z bpp_rekord_mat (ctid znika). + """Bramka WHEN zna deleted_at -> UPDATE soft-delete odpala trigger, + ktory kasuje wiersz z bpp_rekord_mat (ctid znika). Odwrocenie ``test_update_samego_deleted_at_nie_odpala_triggera`` (nazwa - i asercja sprzed fazy 01). Dla ``bpp_wydawnictwo_ciagle`` samej to - dalej NIE dziala -- faza 01 dotknela wylacznie tabel ``*_Autor`` - (migracja 0489). Ten test ma pozostac xfail az do fazy 02; gdy - zazieleni sie SAM (bez zmiany kodu tego pliku), oznacza to niezamierzona - regresje zakresu -- zbadaj, co dotknelo bramki ``bpp_wydawnictwo_ciagle``. + i asercja sprzed fazy 01). Do fazy 02 test byl ``xfail(strict=True)``, + a kolumne ``deleted_at`` dokladal ALTER-em w transakcji testowej -- + faza 01 dotknela wylacznie tabel ``*_Autor``. Faza 02 (migracje 0496 + i 0497) dodala kolumne i bramke NAPRAWDE, wiec symulacja zniknela, + a marker ``xfail`` razem z nia. """ - # DDL PRZED utworzeniem rekordu: ALTER TABLE nie przejdzie, gdy tabela ma - # zakolejkowane zdarzenia wyzwalaczy z INSERT-a w tej samej transakcji. - with connection.cursor() as cur: - _dodaj_deleted_at(cur) - wc = any_ciagle(tytul_oryginalny="Bramka a soft-delete") pk = wc.pk @@ -125,27 +95,18 @@ def test_update_samego_deleted_at_odpala_trigger(): ) -@pytest.mark.xfail( - reason="faza 02 -- soft-delete publikacji (bpp_wydawnictwo_ciagle)", - strict=True, -) @pytest.mark.django_db def test_soft_delete_usuwa_wiersz_z_mat(): - """Docelowo: goly UPDATE ... SET deleted_at MA usunac wiersz z - bpp_rekord_mat (mechanizm #1 -- filtr widoku -- wystarcza, bo funkcja - refresh ma galaz kasujaca uruchamiana PRZED upsertem). + """Goly ``UPDATE ... SET deleted_at`` usuwa wiersz z bpp_rekord_mat. Odwrocenie ``test_filtr_widoku_sam_nie_usuwa_wiersza_z_mat`` (nazwa i - asercja sprzed fazy 01). Jak wyzej: dla publikacji to faza 02, ten test - ma zostac xfail do tego czasu. - """ - # Cale DDL przed INSERT-em (patrz test wyzej). Filtr widoku jest juz - # aktywny przy tworzeniu rekordu, ale deleted_at jest wtedy NULL, wiec - # wiersz normalnie wchodzi do bpp_rekord_mat. - with connection.cursor() as cur: - _dodaj_deleted_at(cur) - _filtruj_widok_po_deleted_at(cur) + asercja sprzed fazy 01). Jak w tescie wyzej: do fazy 02 byl to + ``xfail(strict=True)`` z symulacja (ALTER + owijka widoku); faza 02 + dostarczyla filtr i galaz kasujaca naprawde (migracja 0497). + Filtr widoku jest aktywny juz przy tworzeniu rekordu, ale ``deleted_at`` + jest wtedy NULL, wiec wiersz normalnie wchodzi do bpp_rekord_mat. + """ wc = any_ciagle(tytul_oryginalny="Filtr widoku bez DELETE", rok=2020) pk = wc.pk diff --git a/src/bpp/tests/test_soft_delete/test_denorm_gate.py b/src/bpp/tests/test_soft_delete/test_denorm_gate.py index 33ccff871..650a739c7 100644 --- a/src/bpp/tests/test_soft_delete/test_denorm_gate.py +++ b/src/bpp/tests/test_soft_delete/test_denorm_gate.py @@ -13,6 +13,7 @@ """ import pytest +from model_bakery import baker @pytest.mark.django_db @@ -38,3 +39,73 @@ def test_soft_delete_autorstwa_odswieza_opis_biblio( "denorm-cache nieświeży — bramka WHEN triggera denorm nie zna " "deleted_at (denorm_always_only)" ) + + +# --- Faza 02: publikacje -------------------------------------------------- +# +# INWENTARYZACJA (2026-08-07). W całym kodzie produkcyjnym są DOKŁADNIE DWIE +# zależności ``@depend_on_related`` celujące w model publikacji — obie to +# ``("self", "wydawnictwo_nadrzedne")`` na ``Wydawnictwo_Zwarte`` +# (``wydawnictwo_zwarte.py:384`` i ``:434``), obie BEZ ``only=``. +# +# Brak ``only=`` oznacza, że denorm buduje bramkę WHEN ze WSZYSTKICH kolumn, +# więc ``deleted_at`` wchodzi do niej automatycznie — bez żadnej zmiany +# w kodzie. Potwierdzone niezależnie i przypadkiem: testy odwracalności +# migracji padały na ``CREATE TRIGGER ... WHEN (OLD."deleted_at" IS DISTINCT +# FROM ...)`` dla ``bpp_patent``, czyli denorm tę kolumnę widzi. +# +# Dlatego faza 02 NIE dokłada tu ``denorm_always_only`` ani list ``only=`` — +# byłby to martwy kod. Zostaje jedna rzecz warta przypięcia testem: +# semantyka poniżej. + + +@pytest.mark.django_db +def test_soft_delete_ksiazki_matki_nie_psuje_cache_rozdzialu(denorms): + """Soft-delete książki-matki NIE może uszkodzić rozdziału ani jego cache'u. + + Kaskada fazy 02 jest WĄSKA — zatrzymuje się na ``*_Autor`` rodzica i nie + rusza rozdziałów. Rozdział zostaje żywy, a jego opis nadal odwołuje się + do tytułu matki, bo soft-delete NIE zmienia tytułu: usunięty rekord dalej + istnieje i dalej ma swoje dane. + + To jest odpowiedź na pytanie postawione w planie fazy 02 („czy soft-delete + książki-matki ma unieważniać denorm-cache rozdziałów"): NIE ma czego + unieważniać. Gdyby cache miał się tu zmieniać, znaczyłoby to, że opis + rozdziału zależy od tego, czy matka jest w koszu — a nie zależy i nie + powinien. + + Faza 04 doda guard PROTECT, który prawdopodobnie w ogóle zablokuje + skasowanie książki mającej rozdziały. Do tego czasu ta ścieżka jest + osiągalna i ten test opisuje, co się na niej dzieje. + """ + from bpp.models import Wydawnictwo_Zwarte + + matka = baker.make(Wydawnictwo_Zwarte, tytul_oryginalny="Ksiazka Matka Zzz") + rozdzial = baker.make( + Wydawnictwo_Zwarte, + tytul_oryginalny="Rozdzial Podrzedny Yyy", + wydawnictwo_nadrzedne=matka, + ) + denorms.flush() + + rozdzial.refresh_from_db() + slug_przed = rozdzial.slug + # ``slugify_function`` BPP NIE obniża wielkości liter — porównujemy bez + # rozróżniania, żeby test nie zależał od tego szczegółu. + assert "matka" in slug_przed.lower(), ( + f"setup zepsuty — slug rozdzialu nie zawiera tytulu matki: {slug_przed!r}" + ) + + matka.delete() # soft + denorms.flush() + + assert Wydawnictwo_Zwarte.objects.filter(pk=rozdzial.pk).exists(), ( + "rozdzial znikl razem z matka — kaskada NIE jest waska" + ) + + rozdzial.refresh_from_db() + assert rozdzial.deleted_at is None, "rozdzial zostal soft-skasowany kaskadowo" + assert rozdzial.slug == slug_przed, ( + f"denorm-cache rozdzialu zmienil sie po skasowaniu matki: " + f"{slug_przed!r} -> {rozdzial.slug!r}" + ) diff --git a/src/bpp/tests/test_soft_delete/test_easyaudit_shim.py b/src/bpp/tests/test_soft_delete/test_easyaudit_shim.py new file mode 100644 index 000000000..11c58d578 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_easyaudit_shim.py @@ -0,0 +1,168 @@ +"""Shim naprawiający ``django-easy-audit`` na modelach soft-delete. + +Pełne uzasadnienie w ``src/bpp/easyaudit_shim.py``. Tu pilnujemy trzech +rzeczy: że shim jest wpięty, że naprawia realny objaw i że zniknie, gdy +przestanie być potrzebny. +""" + +import inspect +import weakref + +import pytest +from django.apps import apps +from django.db.models import signals +from model_bakery import baker + +from bpp.easyaudit_shim import DISPATCH_UID, WERSJA_UPSTREAM +from bpp.easyaudit_shim import pre_save as nasz_pre_save +from bpp.models import Wydawnictwo_Ciagle + +pytestmark = pytest.mark.skipif( + not apps.is_installed("easyaudit"), + reason="easyaudit wchodzi tylko w local.py/production.py", +) + + +def _odbiorcy_pod_uid(): + """Funkcje podpięte pod ``pre_save`` naszym ``dispatch_uid``. + + ``Signal.receivers`` (Django 5.2) trzyma krotki + ``((dispatch_uid, id_nadawcy), odbiorca, is_async)``, a ``odbiorca`` bywa + słabą referencją (``connect(weak=True)`` jest domyślne) — stąd + rozpakowanie i ewentualne rozwinięcie ``weakref``. + """ + znalezione = [] + for klucz, odbiorca, *_ in signals.pre_save.receivers: + if klucz[0] != DISPATCH_UID: + continue + if isinstance(odbiorca, weakref.ReferenceType): + odbiorca = odbiorca() + znalezione.append(odbiorca) + return znalezione + + +def test_shim_jest_wpiety_zamiast_handlera_upstreamu(): + """Nasz handler zajmuje ``dispatch_uid`` easyauditu, nie stoi obok niego. + + Gdyby stał obok, oba by się wykonywały i audyt dublowałby wpisy. + """ + odbiorcy = _odbiorcy_pod_uid() + assert odbiorcy, "nikt nie jest podpiety pod dispatch_uid easyauditu" + assert len(odbiorcy) == 1, ( + f"handler podpiety {len(odbiorcy)} razy — zainstaluj() nie jest " + f"idempotentne albo obok naszego stoi handler upstreamu" + ) + assert odbiorcy[0] is nasz_pre_save, ( + f"pod {DISPATCH_UID} siedzi {odbiorcy[0]!r}, a nie nasz shim — " + "sprawdz kolejnosc ready() aplikacji" + ) + + +@pytest.mark.django_db +def test_restore_publikacji_nie_wywala_sie_na_audycie(): + """OBJAW, dla którego shim powstał. + + Bez niego ``restore()`` leci ``DoesNotExist``: easyaudit szuka + poprzedniej wersji wiersza przez ``objects``, a ten w trakcie + przywracania jest jeszcze odfiltrowany jako skasowany. + """ + wc = baker.make(Wydawnictwo_Ciagle) + pk = wc.pk + wc.delete() + + wc.restore() + + assert Wydawnictwo_Ciagle.objects.filter(pk=pk).exists(), ( + "po restore publikacja nie wrocila do objects" + ) + + +@pytest.mark.django_db +def test_restore_zgloszenia_publikacji_czyli_blad_ZASTANY(): + """``Zgloszenie_Publikacji`` jest ``SoftDeleteModel`` OD DAWNA i również + figuruje w ``DJANGO_EASY_AUDIT_REGISTERED_CLASSES``. + + Ten test dowodzi, że naprawiamy błąd ZASTANY, a nie wyłącznie skutek + uboczny fazy 02: na ``dev`` (bez shimu) przywrócenie skasowanego + zgłoszenia jest niemożliwe. + + ``strict=False`` jest tu potrzebne z INNEGO powodu — pakietowy + ``restore()`` domyślnie wymaga, żeby każdy model powiązany też był + ``SoftDeleteModel`` (``Zgloszenie_Publikacji_Autor`` nie jest). To ten + sam inwariant, który dla naszych modeli domyka ``soft_delete.py``. + """ + from zglos_publikacje.models import Zgloszenie_Publikacji + + z = baker.make(Zgloszenie_Publikacji) + pk = z.pk + z.delete() + + Zgloszenie_Publikacji.global_objects.get(pk=pk).restore(strict=False) + + assert Zgloszenie_Publikacji.objects.filter(pk=pk).exists() + + +@pytest.mark.django_db +def test_audyt_dalej_dziala_czyli_shim_niczego_nie_wycisza(admin_user, rf, settings): + """Shim ma naprawiać lookup, a nie wyłączać audyt. + + Bez tego testu „zielono" znaczyłoby tylko tyle, że nic nie wybucha — + a najprostszym sposobem, żeby nic nie wybuchało, byłoby przestać + audytować. + + Zanim ten test cokolwiek mierzył, trzeba było zdjąć DWIE bramki, z + których żadna nie ma związku z shimem — obie po kolei dawały „zero + zdarzeń", czyli objaw nieodróżnialny od zepsutego shimu: + + 1. **Request z użytkownikiem.** BPP ma + ``DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS = + ["bpp.util.dont_log_anonymous_crud_events"]``, który świadomie + odrzuca zdarzenia bez zalogowanego użytkownika. + 2. **``settings.TEST``.** Bez niego easyaudit odkłada zapis zdarzenia + na ``transaction.on_commit``, a w teście transakcja jest + rollbackowana — callback nigdy nie odpala. To własny przełącznik + pakietu, przewidziany dokładnie na tę sytuację. + """ + from easyaudit.middleware.easyaudit import _thread_locals + from easyaudit.models import CRUDEvent + + settings.TEST = True + request = rf.get("/") + request.user = admin_user + _thread_locals.request = request + try: + wc = baker.make(Wydawnictwo_Ciagle) + przed = CRUDEvent.objects.count() + + wc.tytul_oryginalny = "Zmieniony tytul dla audytu" + wc.save() + + assert CRUDEvent.objects.count() > przed, ( + "zapis nie wygenerowal zdarzenia audytu — shim wycisza easyaudit" + ) + finally: + del _thread_locals.request + + +def test_upstream_nadal_ma_blad_czyli_shim_jest_potrzebny(): + """STRAŻNIK ODWROTNY: pada, gdy shim przestanie być potrzebny. + + Sprawdza, czy ``easyaudit`` NADAL pobiera poprzedni wiersz przez + ``objects``. Gdy upstream scali poprawkę (issue #175, otwarte od 2021), + ten test spadnie na czerwono — i to jest sygnał, żeby skasować + ``bpp/easyaudit_shim.py`` razem z wywołaniem w ``BppConfig.ready()``, + a nie żeby test „naprawić". + + Bez tego strażnika shim zostałby w kodzie na zawsze, cicho duplikując + logikę, którą pakiet już by miał poprawną. + """ + from easyaudit.signals import model_signals + + zrodlo = inspect.getsource(model_signals.pre_save) + + assert "sender.objects.get(pk=instance.pk)" in zrodlo, ( + f"easyaudit (kopiowano z {WERSJA_UPSTREAM}) NIE uzywa juz " + f"`sender.objects` — poprawka prawdopodobnie weszla upstream. " + f"SKASUJ bpp/easyaudit_shim.py i wywolanie zainstaluj() " + f"w BppConfig.ready(), zamiast poprawiac ten test." + ) diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py index 7ea033aff..743eb0fa5 100644 --- a/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py +++ b/src/bpp/tests/test_soft_delete/test_kanarek_katalogowy.py @@ -93,41 +93,45 @@ naprawa faktycznie łapie to, co stary kod przepuszczał. """ +import importlib import re import pytest from django.db import connection +from bpp.migration_util import viewdef + # Tabele objęte soft-delete (django-soft-delete SoftDeleteModel) — jedyne # źródło niezmiennika, świadomie utrzymywane jako lista, nie regex-owa # heurystyka po nazwie. # # Faza 01 (2026-08-06): trzy tabele through autorstwa. +# Faza 02 (2026-08-07): pięć tabel publikacji. +# +# Rozszerzenie o fazę 02 zrobiono NA STARCIE tej fazy, jeszcze przed +# napisaniem jakiegokolwiek DDL — i to jest cała wartość tego kanarka. +# Zamiast odkrywać konsumentów pojedynczo, przez awarie (tak przebiegła +# faza 01), dostaliśmy pełną listę 15 widoków jednym przebiegiem trwającym +# 19 sekund. Rdzeń (pg_depend) jest generyczny względem tej listy, więc było +# to dopisanie pięciu linijek i nic więcej. # -# Faza 02 MUSI DOPISAĆ TU 5 TABEL PUBLIKACJI: -# "bpp_wydawnictwo_ciagle", -# "bpp_wydawnictwo_zwarte", -# "bpp_patent", -# "bpp_praca_doktorska", -# "bpp_praca_habilitacyjna", -# To WCIĄŻ jest jednolinijkowa (a ściślej: pięciolinijkowa) zmiana W TYM -# MIEJSCU — rdzeń kanarka (pg_depend) jest generyczny względem listy i nie -# wymaga żadnych innych zmian kodu. ALE: po dopisaniu tych 5 tabel kanarek -# realnie zaczerwieni się na WIĘCEJ niż na 3 znane winowajców z raportu — -# złapie też ``bpp_kronika_praca_doktorska_view`` i -# ``bpp_kronika_praca_habilitacyjna_view`` (czytają swoje tabele bez -# żadnego filtra i, w odróżnieniu od pozostałych trzech ``bpp_kronika_*``, -# NIE są jeszcze zweryfikowane jako martwe ani wpisane do ``WYJATKI`` — -# tego akurat nikt jeszcze nie sprawdzał, bo dotąd nie było powodu). To -# NIE jest fałszywy alarm do wyciszenia odruchowo — to dokładnie ta klasa -# odkrycia, dla której ten kanarek istnieje. Faza 02 ma: -# a) albo zweryfikować, że są martwe (jak pozostałe trzy) i dopisać je -# do ``WYJATKI`` z uzasadnieniem i datą, -# b) albo je naprawić wzorcem z sekcji „Wzorzec naprawy" wyżej. +# Inwentaryzacja i to, co z niej wynikło: +# docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md +# +# Przewidywanie fazy 01 się sprawdziło: rozszerzenie listy złapało też +# ``bpp_kronika_praca_{doktorska,habilitacyjna}_view``, których żywotności +# nikt wcześniej nie badał. Nie zostały wyciszone wyjątkiem — okazały się +# martwe i cała siedmioelementowa rodzina ``bpp_kronika_*`` została +# skasowana migracją ``0499``. TABELE_SOFT_DELETE = [ "bpp_wydawnictwo_ciagle_autor", "bpp_wydawnictwo_zwarte_autor", "bpp_patent_autor", + "bpp_wydawnictwo_ciagle", + "bpp_wydawnictwo_zwarte", + "bpp_patent", + "bpp_praca_doktorska", + "bpp_praca_habilitacyjna", ] # Widoki-wyjątki: nazwa widoku -> uzasadnienie + data weryfikacji. @@ -145,26 +149,20 @@ # (metadane pakietowania, nie kod). Naprawianie widoku, którego nikt nie # czyta, byłoby czystym churnem — zostawione świadomie, do ewentualnego # DROP-u przy innej okazji. -WYJATKI = { - "bpp_kronika_wydawnictwo_ciagle_view": ( - "martwy widok (migracja 0001_widoki_kronika.sql) — zero " - "konsumentów w kodzie/szablonach/modelach; jedyne trafienia " - "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane " - "2026-08-06, naprawa-finalna-report.md faza 01." - ), - "bpp_kronika_wydawnictwo_zwarte_view": ( - "martwy widok (migracja 0001_widoki_kronika.sql) — zero " - "konsumentów w kodzie/szablonach/modelach; jedyne trafienia " - "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane " - "2026-08-06, naprawa-finalna-report.md faza 01." - ), - "bpp_kronika_patent_view": ( - "martwy widok (migracja 0001_widoki_kronika.sql) — zero " - "konsumentów w kodzie/szablonach/modelach; jedyne trafienia " - "'kronika' poza migracjami to SOURCES.txt. Zweryfikowane " - "2026-08-06, naprawa-finalna-report.md faza 01." - ), -} +# PUSTE — i to jest stan docelowy, nie przeoczenie. +# +# Faza 01 trzymała tu trzy widoki ``bpp_kronika_*``, zweryfikowane jako +# martwe, ale niemożliwe do skasowania w tamtej fazie: zależały od nich dwa +# widoki nadrzędne, więc goły ``DROP VIEW`` nie przechodził, a ``CASCADE`` +# zabrałby po cichu także je. Faza 02 rozcięła cały graf jedną migracją +# (``0499_drop_kronika_views``) — po siódemce nie ma śladu, więc wyjątki +# straciły przedmiot. +# +# Jeśli kanarek zacznie zgłaszać NOWY widok, to jest FAIL do zbadania, a nie +# sygnał, żeby dopisać go tutaj. Wyjątek wpisuje się dopiero po weryfikacji +# martwoty (``pg_depend`` + grep po kodzie/szablonach/``Meta.db_table``) +# i zawsze z uzasadnieniem oraz datą. +WYJATKI = {} def _regex_dla_tabeli(tabela): @@ -439,20 +437,13 @@ def test_matcher_prefiks_tabeli_publikacji_kontra_tabela_autorstwa(tekst, oczeki assert widok_uzywa_tabeli(tekst, "bpp_wydawnictwo_ciagle") is oczekiwane -# --- Test dowodowy: symulacja fazy 02 ----------------------------------- +# --- Test dowodowy: fałszywa zieleń starego rdzenia --------------------- -# Pięć tabel publikacji, które faza 02 dopisze do TABELE_SOFT_DELETE. -# Zduplikowane tu ŚWIADOMIE (a nie zaimportowane) — ten test ma przetrwać -# NIEZMIENIONY moment, w którym faza 02 faktycznie dopisze je do stałej -# modułowej; do tego czasu musi budować symulację sam, niezależnie od -# TABELE_SOFT_DELETE. -_TABELE_PUBLIKACJI_FAZA_02 = [ - "bpp_wydawnictwo_ciagle", - "bpp_wydawnictwo_zwarte", - "bpp_patent", - "bpp_praca_doktorska", - "bpp_praca_habilitacyjna", -] +# Funkcje transformujące definicję widoku, wzięte WPROST z migracji 0497 — +# zamiast kopii, która rozjechałaby się przy pierwszej zmianie kształtu +# widoku. Nazwy modułów migracji zaczynają się od cyfry, więc zwykły import +# nie przejdzie. +_p0497 = importlib.import_module("bpp.migrations.0497_soft_delete_rekord_views") def _winowajcy_starym_algorytmem(widoki, tabele, wyjatki): @@ -474,66 +465,68 @@ def _winowajcy_starym_algorytmem(widoki, tabele, wyjatki): @pytest.mark.django_db -def test_symulacja_fazy_02_kanarek_lapie_widoki_ktore_stara_wersja_przepuszczala(): - """DOWÓD, nie test dzisiejszego stanu bazy: symuluje przyszły stan po - fazie 02 (soft-delete PUBLIKACJI), gdzie ``TABELE_SOFT_DELETE`` rośnie - o 5 tabel publikacji, i udowadnia, że naprawiony (pg_depend) rdzeń - kanarka łapie DOKŁADNIE to, co stary (tekstowy) rdzeń przepuszczał. - - ``TABELE_SOFT_DELETE`` w tym pliku NIE jest tu modyfikowane — budujemy - lokalną, rozszerzoną listę i wołamy oba warianty algorytmu na tych - samych, żywych danych z ``pg_views``/``pg_depend``. Ten test ma - zostać na stałe (nie jest tymczasowym scratchem): to on dowodzi, że - faza 02 dostanie od kanarka prawdziwą, kompletną listę winowajców - jednym przebiegiem, zamiast — jak faza 01 — odkrywać ich pojedynczo - przez awarie produkcyjne. - - Trzy konkretne pary (widok, tabela) niżej pochodzą z recenzji - ``kanarek-fix-report.md`` (żywy katalog, 2026-08-06): widoki - odziedziczyły tekstowo ``deleted_at`` z filtra po - ``bpp_wydawnictwo_ciagle_autor`` (ten sam JOIN), mimo że nie filtrują - wcale po WŁASNYM ``deleted_at`` tabeli ``bpp_wydawnictwo_ciagle``. +def test_stary_rdzen_przepuszczal_widok_dziedziczacy_deleted_at_z_joina(): + """DOWÓD, że przejście na ``pg_depend`` zamknęło REALNĄ dziurę. + + Poprzednia wersja tego testu (faza 01) była symulacją: brała trzy + widoki, które NAPRAWDĘ czytały wtedy ``bpp_wydawnictwo_ciagle`` bez + filtra, i pokazywała, że stary (tekstowy) rdzeń ich nie widzi. Faza 02 + te widoki naprawiła, więc dowód stracił materiał — nie dlatego, że + przestał być prawdziwy, tylko dlatego, że opierał się na usterce, której + już nie ma. + + Wersja obecna wytwarza tę usterkę SAMA, w transakcji testowej, więc + zostaje prawdziwa niezależnie od stanu bazy. Zdejmujemy z + ``bpp_wydawnictwo_ciagle_view`` filtr po WŁASNYM ``deleted_at`` + (dokładnie tę wstawkę, którą zakłada migracja ``0497``) i pytamy oba + warianty algorytmu. + + Sedno fałszywej zieleni: po zdjęciu filtra w definicji NADAL jest słowo + ``deleted_at`` — bo widok liczy ``count(...) FILTER (WHERE + bpp_wydawnictwo_ciagle_autor.deleted_at IS NULL)`` (migracja ``0494``). + Matcher tekstowy widzi więc „jest deleted_at, jest OK", mimo że filtr + dotyczy INNEJ tabeli — tej po drugiej stronie JOIN-a. Zależność + kolumnowa w ``pg_depend`` takiej pomyłki nie popełnia. """ - tabele_symulowane = TABELE_SOFT_DELETE + _TABELE_PUBLIKACJI_FAZA_02 - - oczekiwane_falszywa_zielen = { - ("bpp_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), - ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), - ( - "rozbieznosci_dyscyplin_rozbieznoscizrodelview", - "bpp_wydawnictwo_ciagle", - ), - } + widok = "bpp_wydawnictwo_ciagle_view" + tabela = "bpp_wydawnictwo_ciagle" + para = (widok, tabela) with connection.cursor() as cur: - widoki = _pobierz_widoki_publiczne(cur) - stary_winowajcy = set( - _winowajcy_starym_algorytmem(widoki, tabele_symulowane, WYJATKI) + oryginal = viewdef(cur, widok) + zepsuta = _p0497._bez_filtra(oryginal, tabela, widok) + + assert "deleted_at" in zepsuta, ( + "po zdjęciu filtra w definicji NIE MA już słowa 'deleted_at' — " + "cały dowód opiera się na tym, że ono zostaje (przez FILTER po " + "tabeli *_autor). Widok się zmienił, zaktualizuj dowód." ) - nowy_winowajcy = set(znajdz_winowajcow(cur, tabele=tabele_symulowane)) - - # Dowód #1 (ISTNIENIE DZIURY): stary algorytm PRZEPUSZCZAŁ te 3 pary — - # nie ma ich wśród jego winowajców, mimo że widoki naprawdę czytają - # bpp_wydawnictwo_ciagle bez filtra po jej deleted_at. To jest - # regression-proof samego buga opisanego w brief-ie/raporcie: jeśli - # ten assert kiedyś zacznie padać, znaczy że któryś z tych widoków się - # zmienił (np. ktoś usunął z jego tekstu literalne "deleted_at") i - # test wymaga aktualizacji dowodu, nie że naprawa przestała działać. - zlapane_przez_stary = stary_winowajcy & oczekiwane_falszywa_zielen - assert not zlapane_przez_stary, ( - "Stary (tekstowy) algorytm złapał widoki, które wg raportu miał " - f"PRZEPUSZCZAĆ (fałszywa zieleń): {zlapane_przez_stary}. Dane " - "widoki się zmieniły — dowód wymaga aktualizacji, sprawdź " - "kanarek-fix-report.md." + + try: + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {zepsuta}") + + widoki = _pobierz_widoki_publiczne(cur) + stary = set( + _winowajcy_starym_algorytmem(widoki, TABELE_SOFT_DELETE, WYJATKI) + ) + nowy = set(znajdz_winowajcow(cur, tabele=TABELE_SOFT_DELETE)) + finally: + # Przywracamy NIEZALEŻNIE od wyniku asercji (rollback transakcji + # testowej i tak by to cofnął, ale test ma dowodzić całego cyklu). + cur.execute(f"CREATE OR REPLACE VIEW {widok} AS {oryginal}") + + # Dowód #1 — ISTNIENIE DZIURY: stary rdzeń przepuszcza zepsuty widok. + assert para not in stary, ( + "Stary (tekstowy) rdzeń ZŁAPAŁ widok bez filtra, choć miał go " + "przepuścić — czyli fałszywa zieleń, którą ten test dokumentuje, " + "już nie zachodzi. Sprawdź, czy widok nadal zawiera 'deleted_at' " + "wyłącznie przez FILTER po tabeli *_autor." ) - # Dowód #2 (NAPRAWA DZIAŁA): nowy (pg_depend) algorytm łapie wszystkie - # 3 pary, których stary nie widział. - niezlapane_przez_nowy = oczekiwane_falszywa_zielen - nowy_winowajcy - assert not niezlapane_przez_nowy, ( - "Nowy (pg_depend) algorytm NIE złapał widoków z symulacji fazy " - f"02, które miał złapać: {niezlapane_przez_nowy}. Naprawa nie " - "działa albo się cofnęła." + # Dowód #2 — NAPRAWA DZIAŁA: rdzeń oparty o pg_depend go łapie. + assert para in nowy, ( + "Rdzeń oparty o pg_depend NIE złapał widoku, który czyta " + f"{tabela} bez zależności od jej deleted_at. Naprawa się cofnęła." ) diff --git a/src/bpp/tests/test_soft_delete/test_kanarek_orm.py b/src/bpp/tests/test_soft_delete/test_kanarek_orm.py index f8ba106b4..ecacf01ab 100644 --- a/src/bpp/tests/test_soft_delete/test_kanarek_orm.py +++ b/src/bpp/tests/test_soft_delete/test_kanarek_orm.py @@ -78,9 +78,18 @@ #: Katalog ``src/`` — korzeń skanowania. KORZEN = pathlib.Path(__file__).resolve().parents[3] -#: Nazwy relacji do trzech soft-delete'owanych through-modeli. ``autorzy_set`` -#: to strona publikacji; pozostałe to nazwy odwrotne od strony ``Autor``, +#: Nazwy relacji do soft-delete'owanych modeli. +#: +#: Faza 01 — trzy through-modele autorstwa. ``autorzy_set`` to strona +#: publikacji; pozostałe to nazwy odwrotne od strony ``Autor``, #: ``Jednostka`` i słowników (brak ``related_name`` → nazwa modelu małymi). +#: +#: Faza 02 — pięć modeli publikacji. Tu ryzyko jest odwrotnie skierowane: +#: zapytania startujące OD publikacji są bezpieczne, bo ``objects`` to +#: ``BppSoftDeleteManager`` i sam filtruje ``deleted_at``. Przecieka +#: dołączenie DO publikacji od strony słownika/relacji (``Zrodlo``, +#: ``Wydawca``, ``Charakter_Formalny``, ``Autor``…), gdzie żaden manager +#: się nie włącza — i to łapią nazwy niżej. RELACJE = ( "autorzy_set", "wydawnictwo_ciagle_autor", @@ -88,6 +97,16 @@ "patent_autor", ) +#: Relacje do pięciu modeli PUBLIKACJI (faza 02). Trzymane OSOBNO od +#: ``RELACJE``, bo pilnuje ich osobny test — patrz ``xfail`` niżej. +RELACJE_PUBLIKACJI = ( + "wydawnictwo_ciagle", + "wydawnictwo_zwarte", + "patent", + "praca_doktorska", + "praca_habilitacyjna", +) + #: Wywołania, w których nazwa argumentu / literał napisowy jest ścieżką ORM. #: ``prefetch_related``/``select_related``/``Prefetch`` CELOWO poza listą. WYWOLANIA_ORM = frozenset( @@ -158,13 +177,13 @@ def _pliki_produkcyjne(): yield sciezka, wzgledna -def _jest_sciezka_relacji(tekst: str) -> bool: +def _jest_sciezka_relacji(tekst: str, relacje=RELACJE) -> bool: """Czy ``tekst`` (nazwa kwargu albo literał) jest ścieżką ORM do through-modelu? ``autorzy_set__autor`` tak, ``patent_autor`` (nazwa endpointu w routerze) — tylko gdy stoi samo albo z ``__``.""" if not isinstance(tekst, str): return False - for relacja in RELACJE: + for relacja in relacje: if tekst == relacja or tekst.startswith(relacja + "__"): return True if "__" + relacja + "__" in tekst or tekst.endswith("__" + relacja): @@ -180,19 +199,19 @@ def _nazwa_wywolania(wezel: ast.Call) -> str | None: return None -def _wywolanie_dotyka_relacji(wezel: ast.Call) -> bool: +def _wywolanie_dotyka_relacji(wezel: ast.Call, relacje=RELACJE) -> bool: if _nazwa_wywolania(wezel) not in WYWOLANIA_ORM: return False for kw in wezel.keywords: - if kw.arg and _jest_sciezka_relacji(kw.arg): + if kw.arg and _jest_sciezka_relacji(kw.arg, relacje): return True for arg in wezel.args: - if isinstance(arg, ast.Constant) and _jest_sciezka_relacji(arg.value): + if isinstance(arg, ast.Constant) and _jest_sciezka_relacji(arg.value, relacje): return True return False -def _podejrzane_instrukcje(zrodlo: str, wzgledna: str): +def _podejrzane_instrukcje(zrodlo: str, wzgledna: str, relacje=RELACJE): """Zwraca [(linia, pierwsza linia instrukcji)] — instrukcje z wywołaniem ORM po relacji do ``*_Autor``, bez ``deleted_at``, spoza ``DOZWOLONE``.""" try: @@ -224,10 +243,10 @@ def _podejrzane_instrukcje(zrodlo: str, wzgledna: str): literaly = [ w.value for w in ast.walk(wezel.iter) if isinstance(w, ast.Constant) ] - if not any(_jest_sciezka_relacji(x) for x in literaly): + if not any(_jest_sciezka_relacji(x, relacje) for x in literaly): continue elif not any( - isinstance(w, ast.Call) and _wywolanie_dotyka_relacji(w) + isinstance(w, ast.Call) and _wywolanie_dotyka_relacji(w, relacje) for w in ast.walk(wezel) ): continue @@ -243,6 +262,18 @@ def _podejrzane_instrukcje(zrodlo: str, wzgledna: str): return wynik +def _skanuj(relacje): + """``[plik:linia: fragment]`` dla wszystkich podejrzanych instrukcji.""" + znaleziska = [] + for sciezka, wzgledna in _pliki_produkcyjne(): + zrodlo = sciezka.read_text(encoding="utf-8") + if not any(relacja in zrodlo for relacja in relacje): + continue + for lineno, fragment in _podejrzane_instrukcje(zrodlo, wzgledna, relacje): + znaleziska.append(f"{wzgledna}:{lineno}: {fragment}") + return znaleziska + + def test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at(): """Żadne produkcyjne wywołanie ORM nie JOIN-uje ``*_Autor`` bez ``deleted_at``. @@ -252,13 +283,7 @@ def test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at(): semantycznej — dowodzi tylko, że autor kodu w ogóle pomyślał o soft-delete. Semantyki pilnują testy w ``test_orm_wyciek_join.py``. """ - znaleziska = [] - for sciezka, wzgledna in _pliki_produkcyjne(): - zrodlo = sciezka.read_text(encoding="utf-8") - if not any(relacja in zrodlo for relacja in RELACJE): - continue - for lineno, fragment in _podejrzane_instrukcje(zrodlo, wzgledna): - znaleziska.append(f"{wzgledna}:{lineno}: {fragment}") + znaleziska = _skanuj(RELACJE) assert not znaleziska, ( "JOIN po soft-delete'owanym through-modelu bez predykatu " @@ -269,6 +294,48 @@ def test_kanarek_orm_join_po_autorstwie_ma_predykat_deleted_at(): ) +@pytest.mark.xfail( + reason="faza 03 — audyt wywolan ORM w imporcie/dedup/PBN", + strict=True, +) +def test_kanarek_orm_join_po_publikacji_ma_predykat_deleted_at(): + """To samo, ale dla pięciu modeli PUBLIKACJI (faza 02). + + ⚠️ ``xfail(strict=True)`` JEST TU CELOWY i ma się utrzymać do fazy 03. + + Ryzyko jest skierowane odwrotnie niż przy autorstwach: zapytania + startujące OD publikacji są bezpieczne, bo ``objects`` to + ``BppSoftDeleteManager``. Przecieka dołączenie DO publikacji od strony + słownika (``Zrodlo``, ``Charakter_Formalny``…), gdzie żaden manager się + nie włącza. + + Faza 02 zamknęła warstwę BAZODANOWĄ (modele, widoki, triggery, + ograniczenia, menedżery). Audyt wywołań ORM w imporcie/dedup/PBN plan + przypisuje jawnie FAZIE 03 — dlatego ten test dokumentuje dług, zamiast + go po cichu ukrywać albo wymuszać naprawę poza zakresem fazy. + + Pełna, przetriagowana lista (10 prawdziwych wycieków + 4 fałszywe + trafienia, z uzasadnieniem każdego) jest w + ``docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-orm.md`` — + faza 03 nie musi jej odtwarzać. + + ``strict=True`` znaczy, że gdy faza 03 naprawi te miejsca, test zacznie + PADAĆ jako XPASS i zmusi do zdjęcia markera. Bez ``strict`` naprawa + przeszłaby niezauważona, a kanarek zostałby wyłączony na zawsze — tę + dokładnie pułapkę odnotowuje handoff fazy 01 („pusty wynik ≠ + potwierdzenie"). + """ + znaleziska = _skanuj(RELACJE_PUBLIKACJI) + + assert not znaleziska, ( + "JOIN po soft-delete'owanej PUBLIKACJI bez predykatu " + "``deleted_at``:\n " + + "\n ".join(znaleziska) + + "\n\nTriage: docs/superpowers/reviews/" + "2026-08-07-faza-02-inwentaryzacja-orm.md" + ) + + def test_kanarek_orm_wykrywa_swiezy_wyciek(): """Meta-test: kanarek naprawdę łapie wzorce, a nie zawsze przechodzi. diff --git a/src/bpp/tests/test_soft_delete/test_kronika_usunieta.py b/src/bpp/tests/test_soft_delete/test_kronika_usunieta.py new file mode 100644 index 000000000..bceb4e614 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_kronika_usunieta.py @@ -0,0 +1,65 @@ +"""Rodzina ``bpp_kronika_*`` została skasowana (migracja ``0499``). + +Siedem martwych widoków, kasowanych jednym cięciem, bo graf zależności +(``bpp_kronika_view`` <- ``bpp_kronika_all_unsorted_view`` <- pięć liści) +nie daje się rozciąć na raty bez ``CASCADE``. +""" + +import pytest +from django.core.management import call_command +from django.db import connection + +RODZINA = [ + "bpp_kronika_view", + "bpp_kronika_all_unsorted_view", + "bpp_kronika_wydawnictwo_ciagle_view", + "bpp_kronika_wydawnictwo_zwarte_view", + "bpp_kronika_patent_view", + "bpp_kronika_praca_doktorska_view", + "bpp_kronika_praca_habilitacyjna_view", +] + + +def _istniejace(cur): + cur.execute( + "SELECT viewname FROM pg_views " + "WHERE schemaname = 'public' AND viewname = ANY(%s)", + [RODZINA], + ) + return {r[0] for r in cur.fetchall()} + + +@pytest.mark.django_db +def test_rodzina_kronika_nie_istnieje(): + with connection.cursor() as cur: + zostale = _istniejace(cur) + assert not zostale, f"widoki kronika nadal istnieja: {sorted(zostale)}" + + +@pytest.mark.django_db +def test_0499_odwracalna(): + """``backward`` odtwarza całą siódemkę z sidecara ``.sql``. + + Sidecar był generowany maszynowo z ``pg_get_viewdef()``, ale to nie + dowodzi, że da się go ODTWORZYĆ — kolejność ``CREATE VIEW`` musi + respektować graf zależności, a ``pg_get_viewdef`` nie zwraca jej sam + z siebie. Ten test jest jedynym sprawdzeniem tej kolejności. + """ + try: + call_command("migrate", "bpp", "0498", verbosity=0) + + with connection.cursor() as cur: + odtworzone = _istniejace(cur) + brakujace = set(RODZINA) - odtworzone + assert not brakujace, ( + f"backward nie odtworzyl: {sorted(brakujace)} — sprawdz kolejnosc " + f"CREATE VIEW w sidecarze 0499_drop_kronika_views.sql" + ) + finally: + # ZAWSZE wracamy na szczyt: baza testowa jest wspoldzielona przez + # caly przebieg, wiec zostawienie jej z odtworzona kronika psuloby + # test_rodzina_kronika_nie_istnieje w kolejnych plikach. + call_command("migrate", "bpp", verbosity=0) + + with connection.cursor() as cur: + assert not _istniejace(cur), "po ponownym forward kronika nie znikla" diff --git a/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py b/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py index 94d6c6d67..54066b76b 100644 --- a/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py +++ b/src/bpp/tests/test_soft_delete/test_migracja_0489_rewers.py @@ -59,7 +59,7 @@ def _stan_ddl(cur): @pytest.mark.django_db -def test_migracja_0489_odwracalna(): +def test_migracja_0489_odwracalna(bez_reinstalacji_denorma): with connection.cursor() as cur: przed = _stan_ddl(cur) diff --git a/src/bpp/tests/test_soft_delete/test_migracja_0497_rewers.py b/src/bpp/tests/test_soft_delete/test_migracja_0497_rewers.py new file mode 100644 index 000000000..ef94b22bc --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_migracja_0497_rewers.py @@ -0,0 +1,74 @@ +"""Odwracalność ``0497``: filtr soft-delete w widokach publikacji. + +``0497.backward`` nie odtwarza definicji widoków z plików ``.sql`` (jak robi +to ``0489.backward`` dla ścieżki autorstwa), bo widoki publikacji były +redefiniowane w kilku migracjach — m.in. ``0494`` dokładająca +``count(...) FILTER`` — i nie ma jednego pliku źródłowego. Zamiast tego +usuwa DOKŁADNIE ten tekst, który wstawiła ``_z_filtrem``. + +To rozwiązanie symetryczne, ale i kruche: literówka w usuwanym fragmencie +albo zmiana kształtu definicji sprawia, że ``_bez_filtra`` albo rzuci +wyjątkiem, albo (gorzej) zostawi widok z filtrem. Ten test jest jedynym +dowodem, że cykl forward->backward->forward faktycznie domyka się na żywym +katalogu. + +Test zjeżdża do ``0496`` i wraca do NAJNOWSZEJ migracji ``bpp`` +(``migrate bpp`` bez numeru) — powrót „do 0497" byłby błędem, bo baza +testowa jest współdzielona przez cały przebieg i migracje późniejsze niż cel +zostałyby niezastosowane, psując kolejne testy. +""" + +import pytest +from django.core.management import call_command +from django.db import connection + +from bpp.tests.test_soft_delete.test_kanarek_katalogowy import ( + _widoki_zalezne_od_deleted_at, +) + +TABELE = [ + "bpp_wydawnictwo_ciagle", + "bpp_wydawnictwo_zwarte", + "bpp_patent", + "bpp_praca_doktorska", + "bpp_praca_habilitacyjna", +] + +# 7 widokow: 5 rekordowych + 2 autorskie (doktorat/habilitacja maja autora +# na wlasnym wierszu, wiec ich widoki *_autorzy tez czytaja tabele publikacji) +OCZEKIWANE_PARY = {(t + "_view", t) for t in TABELE} | { + (t + "_autorzy", t) for t in ("bpp_praca_doktorska", "bpp_praca_habilitacyjna") +} + + +def _pary_zalezne(): + with connection.cursor() as cur: + return _widoki_zalezne_od_deleted_at(cur, TABELE) + + +@pytest.mark.django_db +def test_0497_odwracalna(bez_reinstalacji_denorma): + # Stan wyjsciowy: wszystkie 7 widokow zalezy od deleted_at swojej tabeli. + assert OCZEKIWANE_PARY <= _pary_zalezne(), ( + "stan przed testem juz jest niepoprawny — migracja 0497 nie jest " + "zastosowana albo widoki zostaly zmienione" + ) + + try: + call_command("migrate", "bpp", "0496", verbosity=0) + + po_cofnieciu = _pary_zalezne() + nadmiarowe = OCZEKIWANE_PARY & po_cofnieciu + assert not nadmiarowe, ( + f"backward NIE zdjal filtra z: {sorted(nadmiarowe)} — " + "_bez_filtra nie usunelo wstawki" + ) + finally: + # ZAWSZE wracamy do najnowszej, nawet gdy asercja wyzej padnie — + # inaczej zostawiamy wspoldzielona baze testowa w stanie sprzed 0497 + # i psujemy kazdy kolejny test w przebiegu. + call_command("migrate", "bpp", verbosity=0) + + assert OCZEKIWANE_PARY <= _pary_zalezne(), ( + "po ponownym forward filtr nie wrocil do wszystkich 7 widokow" + ) diff --git a/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py b/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py index dbef53e48..aaa6aee7b 100644 --- a/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py +++ b/src/bpp/tests/test_soft_delete/test_migracje_0490_0493_rewers.py @@ -73,7 +73,7 @@ def _sprawdz_stan_po(cur): @pytest.mark.django_db -def test_migracje_0490_0493_odwracalne(): +def test_migracje_0490_0493_odwracalne(bez_reinstalacji_denorma): with connection.cursor() as cur: _sprawdz_stan_po(cur) diff --git a/src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py b/src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py new file mode 100644 index 000000000..25c490ba3 --- /dev/null +++ b/src/bpp/tests/test_soft_delete/test_views_sql_publikacje.py @@ -0,0 +1,251 @@ +"""Kontrakt DDL po fazie 02: widok filtruje, funkcja kasuje, bramka przepuszcza. + +Odpowiednik ``test_views_sql.py`` (faza 01, ścieżka autorstwa) dla ścieżki +PUBLIKACJI. Podział ról jest ten sam: testy „substringowe" +(``pg_get_viewdef``/``pg_get_functiondef``/``pg_get_triggerdef``) pilnują +wyłącznie tego, że migracja ``0497`` w ogóle się odbyła, a wyrocznią +zachowania są testy SEMANTYCZNE na końcu pliku. + +Najważniejszy z nich to ``test_publikacja_bez_zywych_autorow_ZOSTAJE``. Broni +przed „poprawką", która wygląda naturalnie i niszczy dane: dopisaniem +``deleted_at IS NULL`` tabeli ``*_autor`` do ``WHERE``/``ON`` widoku +rekordowego. Taki warunek degeneruje ``LEFT JOIN`` do ``INNER JOIN``, więc +publikacja, której WSZYSTKICH autorów soft-skasowano, wypada z +``bpp_rekord_mat`` — czyli znika z całego serwisu, mimo że sama nie została +skasowana. Testy substringowe tego NIE łapią: ``deleted_at`` jest w definicji +w obu wariantach. +""" + +import pytest +from django.db import connection +from model_bakery import baker + +from bpp.models import Jednostka, Praca_Doktorska, Wydawnictwo_Ciagle +from bpp.tests.test_soft_delete.test_kanarek_katalogowy import ( + _widoki_zalezne_od_deleted_at, +) + +# (tabela publikacji, czy autor lezy na jej wierszu) +PUBLIKACJE = [ + ("bpp_wydawnictwo_ciagle", False), + ("bpp_wydawnictwo_zwarte", False), + ("bpp_patent", False), + ("bpp_praca_doktorska", True), + ("bpp_praca_habilitacyjna", True), +] + +WIDOKI = [(t + "_view", t) for t, _ in PUBLIKACJE] + [ + (t + "_autorzy", t) for t, autor_na_wierszu in PUBLIKACJE if autor_na_wierszu +] + +FUNKCJE = [ + ("bpp_refresh_rekord_wydawnictwo_ciagle", False), + ("bpp_refresh_rekord_wydawnictwo_zwarte", False), + ("bpp_refresh_rekord_patent", False), + ("bpp_refresh_rekord_praca_doktorska", True), + ("bpp_refresh_rekord_praca_habilitacyjna", True), +] + +TRIGGERY = [(t, f"{t}_cache_upd") for t, _ in PUBLIKACJE] + + +# --- kontrakt DDL (dowód, że migracja się odbyła) ----------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize("widok,tabela", WIDOKI) +def test_widok_zalezy_od_wlasnego_deleted_at(widok, tabela): + """Widok musi zależeć od kolumny ``deleted_at`` WŁASNEJ tabeli. + + Pytamy ``pg_depend`` (przez helper kanarka katalogowego), a NIE tekst + ``pg_get_viewdef``, z dwóch niezależnych powodów: + + 1. Samo ``"deleted_at" in defn`` daje FAŁSZYWĄ ZIELEŃ: widoki rekordowe + trzech typów z through-modelem zawierają ``count(...) FILTER (WHERE + _autor.deleted_at IS NULL)`` z migracji ``0494``, więc to + słowo jest w nich obecne niezależnie od tej fazy. + 2. Dopisanie kwalifikacji (``.deleted_at``) też nie działa: + w widokach rodziny B (jedna tabela w zasięgu, bez JOIN-a) Postgres + NORMALIZUJE predykat do gołego ``deleted_at IS NULL`` — kwalifikacja + jest zbędna, więc ``pg_get_viewdef`` jej nie zwraca. Test tekstowy + padałby mimo poprawnego filtra. + + Zależność kolumnowa w katalogu jest odporna na jedno i drugie. + """ + with connection.cursor() as cur: + zalezne = _widoki_zalezne_od_deleted_at(cur, [tabela]) + assert (widok, tabela) in zalezne, ( + f"{widok} nie zalezy od kolumny {tabela}.deleted_at — " + f"filtr soft-delete nie zostal wpiety" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("fn,autor_na_wierszu", FUNKCJE) +def test_funkcja_refresh_ma_galaz_kasujaca(fn, autor_na_wierszu): + """Bez DELETE odfiltrowanie z widoku jest no-opem (upsert nic nie usuwa).""" + with connection.cursor() as cur: + cur.execute("SELECT pg_get_functiondef(%s::regproc)", [fn]) + src = cur.fetchone()[0] + assert "NEW.deleted_at IS NOT NULL" in src, f"{fn}: brak gałęzi kasującej" + assert "DELETE FROM bpp_rekord_mat" in src, f"{fn}: brak DELETE z rekord_mat" + if autor_na_wierszu: + # Autor leży na wierszu publikacji, więc soft-delete publikacji musi + # wyczyścić OBIE tabele _mat. Uwaga na różne nazwy klucza: + # bpp_rekord_mat.id vs bpp_autorzy_mat.rekord_id. + assert "DELETE FROM bpp_autorzy_mat" in src, ( + f"{fn}: autor na wierszu, a brak DELETE z autorzy_mat" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("tabela,trigger", TRIGGERY) +def test_bramka_when_zna_deleted_at(tabela, trigger): + """Bez deleted_at w bramce UPDATE soft-delete nie dochodzi do funkcji.""" + with connection.cursor() as cur: + cur.execute( + "SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t " + "WHERE t.tgrelid = %s::regclass AND t.tgname = %s", + [tabela, trigger], + ) + row = cur.fetchone() + assert row is not None, f"brak triggera {trigger}" + assert "deleted_at" in row[0], f"{trigger}: bramka WHEN nie zna deleted_at" + + +# --- testy semantyczne (wyrocznia zachowania) --------------------------- + + +def _ct(cur, model): + cur.execute( + "SELECT id FROM django_content_type WHERE app_label='bpp' AND model=%s", + [model], + ) + return cur.fetchone()[0] + + +def _jest_w_rekord_mat(cur, model, pk): + cur.execute( + "SELECT count(*) FROM bpp_rekord_mat WHERE id = ARRAY[%s, %s]::integer[]", + [_ct(cur, model), pk], + ) + return cur.fetchone()[0] > 0 + + +def _liczba_w_autorzy_mat(cur, model, pk): + cur.execute( + "SELECT count(*) FROM bpp_autorzy_mat " + "WHERE rekord_id = ARRAY[%s, %s]::integer[]", + [_ct(cur, model), pk], + ) + return cur.fetchone()[0] + + +@pytest.mark.django_db +def test_soft_delete_publikacji_znika_z_rekord_mat_restore_wraca(): + jednostka = baker.make(Jednostka) + autor = baker.make("bpp.Autor", imiona="Jan", nazwisko="Kowalski") + wc = baker.make(Wydawnictwo_Ciagle, szczegoly="sz", uwagi="u") + wc.dodaj_autora(autor, jednostka) + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "publikacja nie trafiła do bpp_rekord_mat jeszcze przed kasowaniem " + "— setup testu jest zepsuty, nie sprawdzamy niczego" + ) + + wc.delete() + + with connection.cursor() as cur: + assert not _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "soft-skasowana publikacja została w bpp_rekord_mat" + ) + assert _liczba_w_autorzy_mat(cur, "wydawnictwo_ciagle", wc.pk) == 0, ( + "autorstwa soft-skasowanej publikacji zostały w bpp_autorzy_mat" + ) + + wc.restore() + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "po restore publikacja nie wróciła do bpp_rekord_mat" + ) + assert _liczba_w_autorzy_mat(cur, "wydawnictwo_ciagle", wc.pk) == 1, ( + "po restore autorstwo nie wróciło do bpp_autorzy_mat" + ) + + +@pytest.mark.django_db +def test_publikacja_bez_zywych_autorow_ZOSTAJE(): + """REGRESJA na pułapkę agregatu — patrz docstring modułu. + + Publikacja, której WSZYSTKIE autorstwa są soft-skasowane, ma zostać w + ``bpp_rekord_mat`` z ``liczba_autorow = 0``. Sama publikacja nie została + skasowana, więc jej zniknięcie z serwisu byłoby utratą danych. + """ + jednostka = baker.make(Jednostka) + autor = baker.make("bpp.Autor", imiona="Jan", nazwisko="Jedyny") + wc = baker.make(Wydawnictwo_Ciagle, szczegoly="sz", uwagi="u") + wca = wc.dodaj_autora(autor, jednostka) + + wca.delete() # kasujemy AUTORSTWO, nie publikację + + # WYROCZNIĄ JEST WIDOK, NIE CACHE. Wiersz w ``bpp_rekord_mat`` pochodzi + # z chwili utworzenia publikacji, a soft-delete autorstwa odpala trigger + # ścieżki autorstwa (``bpp_autorzy_mat``) — ``bpp_rekord_mat`` nie jest + # wtedy przeliczany. Sprawdzanie samego cache'u przepuszczało zepsuty + # widok: nieświeży wiersz maskował degenerację LEFT JOIN-a (potwierdzone + # mutacyjnie przy pisaniu tego testu). + with connection.cursor() as cur: + cur.execute( + "SELECT liczba_autorow FROM bpp_wydawnictwo_ciagle_view " + "WHERE object_id_raw = %s", + [wc.pk], + ) + wiersz = cur.fetchone() + assert wiersz is not None, ( + "publikacja bez żywych autorów WYPADŁA z bpp_wydawnictwo_ciagle_view " + "— LEFT JOIN zdegenerował do INNER JOIN (utrata danych!)" + ) + assert wiersz[0] == 0, f"liczba_autorow = {wiersz[0]}, oczekiwano 0" + + # Dopiero teraz cache: wymuszamy przeliczenie rekordu i sprawdzamy, że + # projekcja widoku faktycznie do niego dociera. + wc.save() + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "wydawnictwo_ciagle", wc.pk), ( + "publikacja bez żywych autorów WYPADŁA z bpp_rekord_mat po przeliczeniu" + ) + cur.execute( + "SELECT liczba_autorow FROM bpp_rekord_mat " + "WHERE id = ARRAY[%s, %s]::integer[]", + [_ct(cur, "wydawnictwo_ciagle"), wc.pk], + ) + assert cur.fetchone()[0] == 0, "liczba_autorow w cache nie zeszła do zera" + + +@pytest.mark.django_db +def test_soft_delete_doktoratu_czysci_obie_tabele_mat(): + """Doktorat ma autora na WŁASNYM wierszu, więc jego soft-delete musi + wyczyścić zarówno ``bpp_rekord_mat``, jak i ``bpp_autorzy_mat`` — to + jedyne dwa modele, dla których gałąź kasująca dotyka obu tabel.""" + pd = baker.make(Praca_Doktorska, szczegoly="sz", uwagi="u") + + with connection.cursor() as cur: + assert _jest_w_rekord_mat(cur, "praca_doktorska", pd.pk), ( + "doktorat nie trafił do bpp_rekord_mat — setup testu zepsuty" + ) + assert _liczba_w_autorzy_mat(cur, "praca_doktorska", pd.pk) == 1, ( + "autor doktoratu nie trafił do bpp_autorzy_mat — setup zepsuty" + ) + + pd.delete() + + with connection.cursor() as cur: + assert not _jest_w_rekord_mat(cur, "praca_doktorska", pd.pk), ( + "soft-skasowany doktorat został w bpp_rekord_mat" + ) + assert _liczba_w_autorzy_mat(cur, "praca_doktorska", pd.pk) == 0, ( + "autor soft-skasowanego doktoratu został w bpp_autorzy_mat" + ) diff --git a/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py b/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py index a2764f40a..c45443a47 100644 --- a/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py +++ b/src/bpp/tests/test_soft_delete/test_widoki_pochodne.py @@ -19,9 +19,24 @@ """ import pytest +from django.db import connection from bpp.models.cache import Rekord from bpp.models.sumy_views import Sumy +from bpp.tests.test_soft_delete.test_kanarek_katalogowy import ( + _widoki_zalezne_od_deleted_at, +) + +# (widok sum, tabela PUBLIKACJI) — wymiar domkniety w fazie 02 (bpp.0498). +# Wymiar AUTORSTWA (0495) obejmowal tylko pierwsze trzy, bo pozostale dwa +# nie maja tabeli *_autor. +SUMY_PUBLIKACJE = [ + ("bpp_nowe_sumy_wydawnictwo_ciagle_view", "bpp_wydawnictwo_ciagle"), + ("bpp_nowe_sumy_wydawnictwo_zwarte_view", "bpp_wydawnictwo_zwarte"), + ("bpp_nowe_sumy_patent_view", "bpp_patent"), + ("bpp_nowe_sumy_praca_doktorska_view", "bpp_praca_doktorska"), + ("bpp_nowe_sumy_praca_habilitacyjna_view", "bpp_praca_habilitacyjna"), +] @pytest.mark.django_db @@ -92,3 +107,77 @@ def test_sumy_rankingu_wracaja_po_restore(wydawnictwo_ciagle_z_autorem): Wydawnictwo_Ciagle_Autor.global_objects.get(pk=pk).restore() assert Sumy.objects.filter(autor_id=autor_id).exists() + + +# --- wymiar PUBLIKACJI (faza 02, migracja 0498) ------------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize("widok,tabela", SUMY_PUBLIKACJE) +def test_sumy_zaleza_od_deleted_at_publikacji(widok, tabela): + """Kontrakt DDL: dowód, że ``0498`` w ogóle się wykonała. + + Pytamy ``pg_depend`` o zależność KOLUMNOWĄ, nie tekst definicji — trzy + z tych widoków zawierają już ``deleted_at`` z migracji ``0495`` (wymiar + autorstwa), więc test substringowy dawałby dla nich fałszywą zieleń. + """ + with connection.cursor() as cur: + assert (widok, tabela) in _widoki_zalezne_od_deleted_at(cur, [tabela]), ( + f"{widok} nie zalezy od kolumny {tabela}.deleted_at" + ) + + +@pytest.mark.django_db +def test_sumy_pomijaja_soft_deletowana_publikacje_IZOLOWANE( + wydawnictwo_ciagle_z_autorem, +): + """Wyrocznia dla SAMEGO wymiaru publikacji. + + ⚠️ Kasujemy publikację SUROWYM UPDATE-em, a nie ``wc.delete()``, i jest + to celowe. ``delete()`` kaskaduje na autorstwa, więc wiersz zniknąłby + z sum z DWÓCH niezależnych powodów: przez filtr publikacji (``0498``, + czyli to, co ten test ma sprawdzać) ORAZ przez filtr autorstwa + (``0495``). Test przechodziłby wtedy nawet po cofnięciu ``0498`` — czyli + nie byłby wyrocznią niczego. + + Surowy SQL omija też gate na ``.update(deleted_at=...)`` + (``BppSoftDeleteQuerySet``), co poza testem izolującym wymiar jest + zakazane. + """ + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + + assert Sumy.objects.filter(autor_id=autor_id).exists() + + with connection.cursor() as cur: + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s", + [wc.pk], + ) + + assert not Sumy.objects.filter(autor_id=autor_id).exists(), ( + "soft-skasowana publikacja dalej wnosi punkty do rankingu" + ) + + +@pytest.mark.django_db +def test_sumy_pomijaja_soft_deletowana_publikacje_end_to_end( + wydawnictwo_ciagle_z_autorem, +): + """Realna ścieżka (``delete()`` z kaskadą) + powrót po ``restore()``. + + Nie izoluje wymiaru (patrz test wyżej), ale pokrywa to, co faktycznie + robi operator, łącznie z odwracalnością. + """ + wc = wydawnictwo_ciagle_z_autorem + autor_id = wc.autorzy_set.first().autor_id + + assert Sumy.objects.filter(autor_id=autor_id).exists() + + wc.delete() + assert not Sumy.objects.filter(autor_id=autor_id).exists() + + wc.restore() + assert Sumy.objects.filter(autor_id=autor_id).exists(), ( + "po restore publikacja nie wrocila do rankingu" + ) diff --git a/src/bpp/tests/test_soft_delete_publikacje.py b/src/bpp/tests/test_soft_delete_publikacje.py new file mode 100644 index 000000000..c9c44631c --- /dev/null +++ b/src/bpp/tests/test_soft_delete_publikacje.py @@ -0,0 +1,207 @@ +"""Soft-delete PUBLIKACJI (faza 02). + +Testy wąskiej, kontrolowanej kaskady `publikacja -> *_Autor` pod wspólnym +`transaction_id`, bez refleksyjnej kaskady pakietu `django-soft-delete` +(która ruszyłaby `*_Streszczenie` i inne nie-soft dzieci). +""" + +import pytest +from model_bakery import baker + +from bpp.models import ( + Patent, + Praca_Doktorska, + Praca_Habilitacyjna, + Wydawnictwo_Ciagle, + Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte, +) + +#: Wszystkie 5 modeli publikacji objętych fazą 02. +MODELE_PUBLIKACJI = [ + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, + Patent, + Praca_Doktorska, + Praca_Habilitacyjna, +] + + +@pytest.mark.django_db +def test_soft_delete_publikacji_kaskaduje_na_autor_wspolny_txid(): + wc = baker.make(Wydawnictwo_Ciagle) + # `kolejnosc` JAWNIE różna. Bez tego `baker` nadaje obu wierszom 0, co + # łamie ograniczenie wykluczające `wc_autor_excl_rekord_kolejnosc` + # (faza 01). Ograniczenie jest DEFERRABLE i warunkowane + # `deleted_at IS NULL`, więc soft-delete wyprowadzał oba wiersze poza + # jego zakres, zanim zdążyło zadziałać przy COMMIT — test przechodził + # wtedy CZĘŚCIOWO z powodu efektu ubocznego, a nie samej kaskady. + a1 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=0) + a2 = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=1) + + wc.delete() + + wc.refresh_from_db() + assert wc.deleted_at is not None + assert wc.transaction_id is not None + + for a in (a1, a2): + row = Wydawnictwo_Ciagle_Autor.global_objects.get(pk=a.pk) + assert row.deleted_at is not None, "autorstwo nie zostało soft-skasowane" + assert row.transaction_id == wc.transaction_id, "różny transaction_id" + + +# --- Task 4: przeplecenie menedżerów ------------------------------------ + + +@pytest.mark.django_db +@pytest.mark.parametrize("klasa", MODELE_PUBLIKACJI) +def test_objects_ukrywa_skasowane_global_widzi(klasa): + """``objects`` musi ukrywać kosz na WSZYSTKICH pięciu modelach. + + Do fazy 02 ``Wydawnictwo_Ciagle`` i ``Wydawnictwo_Zwarte`` miały własne + menedżery (mixin opłat + goły ``models.Manager``), które przesłaniały + menedżer wniesiony przez ``BppPublikacjaSoftDeleteMixin`` — i NIE + filtrowały ``deleted_at``. Pozostałe trzy modele dostawały filtr + „z urodzenia". Ta asymetria była niewidoczna, dopóki nie sprawdzono jej + wprost: żaden wcześniejszy test fazy 02 na nią nie trafiał, bo wszystkie + szły przez widoki albo przez surowy SQL. + """ + zywy = baker.make(klasa) + kosz = baker.make(klasa) + kosz.delete() + + widoczne = set(klasa.objects.values_list("pk", flat=True)) + assert zywy.pk in widoczne + assert kosz.pk not in widoczne, ( + f"{klasa.__name__}.objects ({type(klasa.objects).__name__}) " + f"NIE ukrywa skasowanych" + ) + + wszystkie = set(klasa.global_objects.values_list("pk", flat=True)) + assert kosz.pk in wszystkie, f"{klasa.__name__}.global_objects nie widzi kosza" + + skasowane = set(klasa.deleted_objects.values_list("pk", flat=True)) + assert skasowane == {kosz.pk} & skasowane and kosz.pk in skasowane, ( + f"{klasa.__name__}.deleted_objects nie zwraca skasowanego" + ) + assert zywy.pk not in skasowane, ( + f"{klasa.__name__}.deleted_objects zwraca ŻYWY rekord" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "klasa", [Wydawnictwo_Ciagle, Wydawnictwo_Zwarte], ids=["ciagle", "zwarte"] +) +def test_metoda_oplat_zachowana_i_tez_filtruje(klasa): + """``rekordy_z_oplata()`` (mixin opłat) ma DALEJ działać po przepleceniu + menedżerów — i sama z siebie pomijać kosz. + + To jest sedno Taska 4: filtr soft-delete i metody opłat muszą współżyć + bez nadpisywania tych drugich. ``ManagerModeliZOplataZaPublikacjeMixin`` + woła ``self.exclude(...)``, więc działa nad KAŻDYM querysetem — o ile + stoi w MRO PRZED menedżerem dostarczającym queryset. + """ + zywy = baker.make(klasa, opl_pub_cost_free=True) + kosz = baker.make(klasa, opl_pub_cost_free=True) + kosz.delete() + + z_oplata = set(klasa.objects.rekordy_z_oplata().values_list("pk", flat=True)) + assert zywy.pk in z_oplata, "rekordy_z_oplata() zgubiło żywy rekord" + assert kosz.pk not in z_oplata, "rekordy_z_oplata() pokazuje kosz" + + +@pytest.mark.django_db +def test_wydawnictwa_nadrzedne_dla_innych_zachowane(): + """Druga metoda menedżera ``Wydawnictwo_Zwarte`` przeżywa przeplecenie.""" + matka = baker.make(Wydawnictwo_Zwarte) + baker.make(Wydawnictwo_Zwarte, wydawnictwo_nadrzedne=matka) + + nadrzedne = set(Wydawnictwo_Zwarte.objects.wydawnictwa_nadrzedne_dla_innych()) + assert matka.pk in nadrzedne + + +# --- Task 5: integracja -------------------------------------------------- + + +@pytest.mark.django_db +def test_restore_przywraca_autorstwa_po_tym_samym_txid(): + """``restore()`` podnosi wyłącznie autorstwa skasowane RAZEM z publikacją. + + Autorstwo skasowane WCZEŚNIEJ, osobną decyzją operatora, ma zostać + w koszu — inaczej przywrócenie publikacji cofałoby też decyzje, których + nikt nie cofał. + """ + wc = baker.make(Wydawnictwo_Ciagle) + wczesniej = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=0) + razem = baker.make(Wydawnictwo_Ciagle_Autor, rekord=wc, kolejnosc=1) + + wczesniej.delete() # osobna decyzja, INNY transaction_id + wc.delete() # kaskada obejmuje tylko `razem` + + wc.restore() + + assert Wydawnictwo_Ciagle_Autor.objects.filter(pk=razem.pk).exists(), ( + "autorstwo skasowane RAZEM z publikacja nie wrocilo" + ) + assert not Wydawnictwo_Ciagle_Autor.objects.filter(pk=wczesniej.pk).exists(), ( + "restore podniosl autorstwo skasowane WCZESNIEJ, osobna decyzja" + ) + + +@pytest.mark.django_db +def test_post_soft_delete_emitowany(): + """Sygnał musi lecieć — konsumuje go ``SoftDeleteLog`` z fazy 06.""" + from django_softdelete.signals import post_soft_delete + + odebrane = [] + + def odbiorca(sender, instance, **kwargs): + odebrane.append(instance) + + post_soft_delete.connect(odbiorca, sender=Wydawnictwo_Ciagle) + try: + wc = baker.make(Wydawnictwo_Ciagle) + wc.delete() + finally: + post_soft_delete.disconnect(odbiorca, sender=Wydawnictwo_Ciagle) + + assert len(odebrane) == 1 + assert odebrane[0].pk == wc.pk + + +@pytest.mark.django_db +def test_kaskada_nie_rusza_streszczenia(): + """Kaskada zatrzymuje się na ``*_Autor``. + + Gdybyśmy użyli refleksyjnej kaskady pakietu, zjechałaby po + ``*_Streszczenie``. Co gorsza CICHO: ``delete()`` pakietu ma domyślnie + ``strict=False``, więc nie usłyszelibyśmy ``SoftDeleteException`` — + stąd asercja na SAM BRAK wyjątku nie wystarcza i sprawdzamy też, że + streszczenie fizycznie zostało. + """ + from bpp.models import Wydawnictwo_Ciagle_Streszczenie + + wc = baker.make(Wydawnictwo_Ciagle) + strz = baker.make(Wydawnictwo_Ciagle_Streszczenie, rekord=wc) + + wc.delete() # NIE moze rzucic SoftDeleteException + + assert Wydawnictwo_Ciagle_Streszczenie.objects.filter(pk=strz.pk).exists(), ( + "kaskada zjechala po streszczeniu" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("klasa", MODELE_PUBLIKACJI) +def test_bulk_update_deleted_at_zabroniony(klasa): + """Kontrakt z reversion: soft-delete idzie WYŁĄCZNIE per-instancja. + + Bulk ``update(deleted_at=...)`` omija ``post_save``, kaskadę na + ``*_Autor``, sygnały i przyszły ``SoftDeleteLog``. Gate z fazy 01 ma to + blokować fail-fast, a nie „na ogół". + """ + baker.make(klasa) + with pytest.raises(RuntimeError): + klasa.objects.update(deleted_at="2026-01-01") diff --git a/src/bpp/tests/test_views/test_api.py b/src/bpp/tests/test_views/test_api.py index 2adeda83b..4723087c4 100644 --- a/src/bpp/tests/test_views/test_api.py +++ b/src/bpp/tests/test_views/test_api.py @@ -267,11 +267,18 @@ def test_rok_habilitacji_view(): assert str(CURRENT_YEAR) in res.content.decode() assert json.loads(res.content)["rok"] == CURRENT_YEAR - h.delete() + h.delete() # od fazy 02 MIĘKKO res = rhv.post(request) assert res.status_code == 404 assert "Habilitacja" in res.content.decode() + # ⚠️ Soft-delete ZOSTAWIA wiersz, a `Praca_Habilitacyjna.autor` to O2O + # z PROTECT — więc dopóki habilitacja siedzi w koszu, autora nie da się + # skasować (`ProtectedError`). To NIE jest regresja: rekord nadal + # istnieje i nie wolno go osierocić. Ten test sprawdza ścieżki 404 + # widoku, a nie semantykę kasowania, więc usuwamy habilitację trwale, + # żeby dojść do gałęzi „nie ma autora". + h.hard_delete() a.delete() res = rhv.post(request) assert res.status_code == 404 diff --git a/src/bpp/views/api/__init__.py b/src/bpp/views/api/__init__.py index 141ab17c3..7e5c633ef 100644 --- a/src/bpp/views/api/__init__.py +++ b/src/bpp/views/api/__init__.py @@ -37,6 +37,20 @@ def post(self, request, *args, **kw): except Praca_Habilitacyjna.DoesNotExist: return HttpResponseNotFound("Habilitacja") + # ⚠️ Odwrotne OneToOne NIE respektuje soft-delete. Django rozwiązuje + # `autor.praca_habilitacyjna` przez `ReverseOneToOneDescriptor`, a ten + # pyta `_base_manager` — z definicji NIEprzefiltrowany (Django wymaga, + # żeby zwracał wszystkie wiersze, bo służy do pobierania obiektów + # powiązanych). Skasowana habilitacja jest więc tą ścieżką nadal + # osiągalna, mimo że `Praca_Habilitacyjna.objects` jej nie pokazuje. + # + # Nie da się tego naprawić centralnie bez ustawienia + # `Meta.base_manager_name` na menedżer filtrujący — a tego Django + # jawnie odradza (rozwaliłoby m.in. deserializację i + # `refresh_from_db`). Dlatego sprawdzamy tu jawnie. + if habilitacja.deleted_at is not None: + return HttpResponseNotFound("Habilitacja") + return JsonResponse({"rok": habilitacja.rok}) diff --git a/src/conftest.py b/src/conftest.py index 92fd6fe7a..4a46346f1 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -11,6 +11,7 @@ from django.core.management import call_command from django.core.management.base import CommandError from django.db import connections +from django.db.models.signals import post_migrate from django.db.utils import OperationalError from django.test import TransactionTestCase from django.test.client import Client, RequestFactory @@ -1259,3 +1260,54 @@ def django_db_setup(django_db_setup, django_db_blocker): for sn, lv, ic in rows ] cursor.execute("\n".join(alter_stmts)) + + +@pytest.fixture +def bez_reinstalacji_denorma(): + """Odpina globalną przebudowę triggerów ``django-denorm`` na czas testu. + + PO CO: ``denorm`` podpina się pod ``post_migrate`` i po KAŻDYM ``migrate`` + odbudowuje wszystkie swoje triggery — zawsze z AKTUALNYCH definicji + modeli (``denorm/apps.py``: ``denorm_install_triggers_after_migrate``). + Test, który zejdzie migracjami poniżej ``bpp.0496``, ma wtedy bazę bez + kolumny ``deleted_at`` na tabelach publikacji, ale modele Pythona nadal ją + deklarują — więc ``denorm`` generuje bramkę + ``WHEN (OLD."deleted_at" IS DISTINCT FROM ...)``, a ``CREATE TRIGGER`` + pada na ``UndefinedColumn``. + + KTO TEGO POTRZEBUJE — dwie różne rodziny testów, stąd fixture siedzi + w GLOBALNYM conftescie, a nie przy testach soft-delete: + + 1. testy odwracalności migracji (``bpp/tests/test_soft_delete/``), które + schodzą poniżej ``0496`` świadomie; + 2. testy e2e migracji w INNYCH aplikacjach (``pbn_api``), które cofają + swoją aplikację ``MigrationExecutor``-em — a że migracje ``bpp`` + zależą od ``pbn_api``, Django cofa razem z nimi także ``0496``. + Tam wybuchało to dopiero w TEARDOWNIE i o przyczynie nie mówiło nic. + + DLACZEGO TO NIE JEST PROBLEM PRODUKCYJNY: w prawdziwym rollbacku wycofuje + się KOD razem ze schematem, a stare modele nie mają ``deleted_at`` — + ``denorm`` wygeneruje wtedy poprawne triggery. Kombinacja „nowy kod + + stary schemat" powstaje wyłącznie w teście, który rusza sam schemat. + + Po teście wpinamy handler z powrotem i odpalamy przebudowę RĘCZNIE — baza + testowa jest współdzielona przez cały przebieg, więc nie wolno zostawić + jej z triggerami niepasującymi do modeli. + """ + from denorm import denorms + from denorm.apps import denorm_install_triggers_after_migrate + + sender = apps.get_app_config("denorm") + post_migrate.disconnect(denorm_install_triggers_after_migrate, sender=sender) + try: + yield + finally: + post_migrate.connect(denorm_install_triggers_after_migrate, sender=sender) + # Doprowadzamy schemat z powrotem do najnowszej migracji SAMI, zamiast + # ufać, że test zdążył to zrobić: gdy asercja padnie w połowie, test + # przerywa przed swoim `migrate` i baza zostaje w stanie sprzed + # docelowej migracji. Wywołanie jest idempotentne (no-op, gdy już + # jesteśmy na szczycie), a bez niego przebudowa niżej wywaliłaby się + # na brakującej kolumnie, przykrywając PRAWDZIWY powód porażki testu. + call_command("migrate", "bpp", verbosity=0) + denorms.install_triggers() diff --git a/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py b/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py index 2811d6989..2eb6a6be0 100644 --- a/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py +++ b/src/pbn_api/tests/test_migracja_dyscypliny_uuid_e2e.py @@ -45,7 +45,7 @@ def _policz(sql, *params): @pytest.mark.django_db(transaction=True) -def test_migracja_przechodzi_na_bazie_z_duplikatami(): +def test_migracja_przechodzi_na_bazie_z_duplikatami(bez_reinstalacji_denorma): uuid_slownika, uuid_dyscypliny = uuid4(), uuid4() MigrationExecutor(connection).migrate([PRZED]) diff --git a/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py b/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py index b3f5a111f..6d747c83e 100644 --- a/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py +++ b/src/pbn_api/tests/test_migracja_publikacja_instytucji_e2e.py @@ -29,7 +29,7 @@ def _policz(sql, *params): @pytest.mark.django_db(transaction=True) -def test_migracja_przechodzi_na_bazie_z_duplikatami(): +def test_migracja_przechodzi_na_bazie_z_duplikatami(bez_reinstalacji_denorma): # cofnij się PRZED constraint — dopiero wtedy baza wpuści duplikaty MigrationExecutor(connection).migrate([PRZED]) diff --git a/src/pbn_export_queue/models.py b/src/pbn_export_queue/models.py index 3fdd17eea..e5f2fc77f 100644 --- a/src/pbn_export_queue/models.py +++ b/src/pbn_export_queue/models.py @@ -177,11 +177,21 @@ def check_if_record_still_exists(self): return False try: - if self.content_type.get_object_for_this_type(pk=self.object_id): - return True - + obiekt = self.content_type.get_object_for_this_type(pk=self.object_id) except ObjectDoesNotExist: return False + + # ⚠️ `get_object_for_this_type` pyta `_base_manager`, który z + # definicji NIE filtruje (Django wymaga, żeby zwracał wszystkie + # wiersze). Rekord soft-skasowany jest więc tą drogą nadal + # znajdowany, mimo że `objects` go nie pokazuje. Dla kolejki PBN + # „w koszu" ma znaczyć „nie ma go" — inaczej wysyłalibyśmy do PBN + # publikację, którą operator usunął. + if getattr(obiekt, "deleted_at", None) is not None: + return False + + if obiekt: + return True except self.content_type.model_class().DoesNotExist: return False diff --git a/src/rozbieznosci_dyscyplin/migrations/0023_rozbieznosci_zrodel_bez_skasowanych_publikacji.py b/src/rozbieznosci_dyscyplin/migrations/0023_rozbieznosci_zrodel_bez_skasowanych_publikacji.py new file mode 100644 index 000000000..a5dda8a6c --- /dev/null +++ b/src/rozbieznosci_dyscyplin/migrations/0023_rozbieznosci_zrodel_bez_skasowanych_publikacji.py @@ -0,0 +1,52 @@ +"""Raport rozbieżności dyscyplin źródeł pomija soft-deletowane PUBLIKACJE. + +Dopełnienie migracji ``0022``, która zamknęła w tym widoku wymiar AUTORSTWA +(``bpp_wydawnictwo_ciagle_autor.deleted_at``). Wymiar PUBLIKACJI został +wtedy otwarty, bo publikacje stały się soft-delete dopiero w fazie 02 +(``bpp.0496``). + +Bez tego warunku skasowane wydawnictwo ciągłe dalej generuje pozycje +w raporcie rozbieżności — operator dostaje do rozstrzygnięcia rekord, +którego w serwisie już nie ma. + +Widok wskazany INWENTARYZACJĄ kanarka katalogowego na starcie fazy 02 +(``docs/superpowers/reviews/2026-08-07-faza-02-inwentaryzacja-widokow.md``). + +Definicja żyjąca w bazie pochodzi z pliku ``.sql`` przeładowywanego przez +kilka migracji (patrz ``0022``), więc — tak samo jak tam — pliku nie +ruszamy, tylko dokładamy warunek osobną migracją, przez introspekcję. + +Pozostałe widoki tej aplikacji czytają ``bpp_autorzy``, a nie surowe tabele +publikacji — potwierdzone inwentaryzacją (``pg_depend``), nie gremem. +""" + +from django.db import connection, migrations + +from bpp.migration_util import widok_dopisz_warunek, widok_usun_warunek + +WIDOK = "rozbieznosci_dyscyplin_rozbieznoscizrodelview" +WARUNEK = "bpp_wydawnictwo_ciagle.deleted_at IS NULL" + + +def forward(apps, schema_editor): + with connection.cursor() as cur: + widok_dopisz_warunek(cur, WIDOK, WARUNEK) + + +def backward(apps, schema_editor): + with connection.cursor() as cur: + widok_usun_warunek(cur, WIDOK, WARUNEK) + + +class Migration(migrations.Migration): + dependencies = [ + ("rozbieznosci_dyscyplin", "0022_rozbieznosci_zrodel_bez_skasowanych"), + # Kolumna `deleted_at` na tabelach publikacji powstaje w bpp.0496; + # bez tej zależności migracja mogłaby pójść przed nią i wywalić się + # na nieznanej kolumnie. + ("bpp", "0496_publikacje_soft_delete_fields"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py b/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py index 5854c9f0f..b60b8ee3b 100644 --- a/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py +++ b/src/rozbieznosci_dyscyplin/tests/test_soft_delete.py @@ -8,6 +8,7 @@ """ import pytest +from django.db import connection from model_bakery import baker from bpp.models import Dyscyplina_Zrodla, Wydawnictwo_Ciagle @@ -47,3 +48,28 @@ def test_rozbieznosci_zrodel_pomijaja_soft_deletowane(rozbieznosc_zrodla): assert not RozbieznosciZrodelView.objects.filter( autor_id=rozbieznosc_zrodla.autor_id ).exists() + + +@pytest.mark.django_db +def test_rozbieznosci_zrodel_pomijaja_soft_deletowana_publikacje(rozbieznosc_zrodla): + """Wymiar PUBLIKACJI (faza 02, migracja 0023 tej aplikacji). + + ⚠️ Kasujemy publikację SUROWYM UPDATE-em, nie ``rekord.delete()``, i jest + to celowe: ``delete()`` kaskaduje na autorstwa, więc wiersz raportu + zniknąłby z DWÓCH niezależnych powodów — przez filtr publikacji (to, co + ten test sprawdza) ORAZ przez filtr autorstwa z migracji ``0022``. Test + przechodziłby wtedy nawet po cofnięciu ``0023``, czyli nie byłby + wyrocznią niczego. + """ + autor_id = rozbieznosc_zrodla.autor_id + assert RozbieznosciZrodelView.objects.filter(autor_id=autor_id).exists() + + with connection.cursor() as cur: + cur.execute( + "UPDATE bpp_wydawnictwo_ciagle SET deleted_at = now() WHERE id = %s", + [rozbieznosc_zrodla.rekord_id], + ) + + assert not RozbieznosciZrodelView.objects.filter(autor_id=autor_id).exists(), ( + "soft-skasowana publikacja dalej generuje wiersz raportu rozbieznosci" + ) diff --git a/src/rozbieznosci_dyscyplin/util.py b/src/rozbieznosci_dyscyplin/util.py index 6dd0ef5a8..39436366e 100644 --- a/src/rozbieznosci_dyscyplin/util.py +++ b/src/rozbieznosci_dyscyplin/util.py @@ -1,7 +1,9 @@ from django.core.exceptions import ObjectDoesNotExist -def object_or_something(model, attrname, default_pk=-1, default_attr="nazwa", default_value="--", **kwargs): +def object_or_something( + model, attrname, default_pk=-1, default_attr="nazwa", default_value="--", **kwargs +): """ Funkcja do rozsądnego zwracania czegoś w przypadku, gdy obiekt powiązany (ForeignKey) może być None/NULL. W takich sytuacjach Django zwraca błąd.