From d5315835e17686a9d4dc8902cf97c511b4cd206e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 8 Jul 2026 22:54:51 +0200 Subject: [PATCH 01/15] docs(spec): opis bibliograficzny z dysku zamiast dbtemplates (#329) Warstwa 2 dla FD#329 "na serio": wyrwanie opis_bibliograficzny.html z dbtemplates (dysk = jedyne zrodlo prawdy), zamiana FK dbtemplates.Template na nazwa_szablonu w SzablonDlaOpisuBibliograficznego, migracja ktora kasuje wiersz dbtemplates (fix samo-stosujacy sie przy podbiciu wydania) oraz naprawa myslacego compare_dbtemplates (czytal DB zamiast dysku). Refs #329 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...aficzny-dysk-zamiast-dbtemplates-design.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md diff --git a/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md b/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md new file mode 100644 index 000000000..5304a774a --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md @@ -0,0 +1,180 @@ +# Opis bibliograficzny z dysku zamiast z dbtemplates + +Data: 2026-07-08 +Ticket: Freshdesk #329 ("wyd. zwarte - problem") +Kontynuacja: PR #409 (`fix(#329)`) — warstwa 1 (render rodzica z PBN) + doraźny +`drop_dbtemplate`. Ten spec robi warstwę 2 „na serio". + +## Kontekst + +Zgłoszenie #329 (Biblioteka Naukowa IHiT): rozdział książki z wydawnictwem +nadrzędnym pobranym z PBN nie pokazuje rodzica w opisie bibliograficznym. + +PR #409 naprawił **render** (szablon `opis_bibliograficzny.html` czyta rodzica +z surowego JSON-a PBN `object.book` przez akcesor `book_title`), ale poprawka +jest niewidoczna w istniejących instalacjach, bo wiersz dbtemplates +`opis_bibliograficzny.html` w bazie **zasłania** plik z dysku (loader +dbtemplates stoi pierwszy w łańcuchu). PR #409 dołożył doraźną komendę +`drop_dbtemplate` — ale to operacja, którą trzeba ręcznie odpalić u każdego +klienta. + +Dodatkowo wyszły dwa problemy strukturalne: + +1. **`compare_dbtemplates` kłamie.** Komenda ma porównywać wiersz dbtemplates z + plikiem na dysku, ale czyta „dysk" przez `get_template()`, który idzie + łańcuchem loaderów — a loader dbtemplates stoi **pierwszy**. Więc dostaje + treść z **bazy** i porównuje DB-vs-DB → zawsze „All templates match". + Dokładnie w scenariuszu, dla którego powstała (wykrycie driftu), wprowadza + w błąd. Potwierdzone w kodzie: `compare_dbtemplates.py:322-332` + (`get_filesystem_template_content` → `get_template(name)` → `.template.source` + = treść z DB). +2. **`opis_bibliograficzny.html` nie musi być w dbtemplates.** Render i tak + ładuje po nazwie (`util.opis_bibliograficzny()` → `get_template(name)`), a + samo trzymanie kopii dysku w bazie generuje drift, shadowing i potrzebę + `drop_dbtemplate`. + +## Architektura wyjściowa (jak jest dziś) + +- `SzablonDlaOpisuBibliograficznego` (`src/bpp/models/szablondlaopisubibliograficznego.py`): + - `model` — OneToOne do `ContentType` (jeden z 5 modeli publikacji lub NULL = + domyślny dla wszystkich), + - `template` — **FK do `dbtemplates.Template`** (`on_delete=PROTECT`), + - `render(praca)` i manager `get_for_model()` używają FK **wyłącznie** po to, + by wyciągnąć `.template.name`; faktyczny render to `get_template(name)`. +- `AbstractModel.opis_bibliograficzny()` (`src/bpp/models/util.py:91`): + `template_name = get_for_model(self)` (fallback `"opis_bibliograficzny.html"`) + → `get_template(template_name)` → render. +- Migracja `src/bpp/migrations/0295_instaluj_szablony.py` zasiewa do dbtemplates + `opis_bibliograficzny.html` **oraz** `browse/praca_tabela.html`, i tworzy + wpis `SzablonDlaOpisu` (model=NULL) wskazujący na `opis_bibliograficzny.html`. +- Loader dbtemplates **nie tworzy** wierszy przy renderze (`loader.py` tylko + `Template.objects.get(...)`). `DBTEMPLATES_AUTO_POPULATE_CONTENT=True` + działa **tylko** przy `Template.save()` z pustą treścią (`models.py:63`) i w + adminie — **nie** przy ładowaniu. Wniosek kluczowy: **skasowany wiersz nie + wróci** przy renderze. Kasowanie w migracji jest trwałe. + +## Decyzja (zatwierdzona) + +**Kierunek A + zakres B + naprawa compare (A).** Dysk staje się jedynym +źródłem prawdy dla `opis_bibliograficzny`; mapowanie per-model przeżywa, ale +wskazuje **nazwę szablonu**, nie wiersz DB. `browse/praca_tabela.html` i reszta +dbtemplates **poza zakresem**. + +Konsekwencja edytowalności (świadomie zaakceptowana): opisu **nie** edytuje się +już z admina dbtemplates — zmiana treści opisu = zmiana pliku w kodzie + +wydanie. W zamian znika drift, shadowing i `drop_dbtemplate` dla opisu, a #329 +**naprawia się samo** przy podbiciu wydania (migracja kasuje wiersz). + +## Zakres zmian + +### 1. Model `SzablonDlaOpisuBibliograficznego` + +- Usuń `template = FK(dbtemplates.Template, PROTECT)`. +- Dodaj `nazwa_szablonu = models.CharField(max_length=255)` — nazwa dla loadera + Django (loader-relative, np. `"opis_bibliograficzny.html"`), **nie** ścieżka + pliku. Stąd `nazwa_szablonu`, nie `nazwa_pliku`. +- `get_for_model()` zwraca `.nazwa_szablonu` (fallback bez zmian w `util.py`). +- `get_models_for_template(template)` → `get_models_for_szablon(nazwa_szablonu)` + (filtr `SzablonDlaOpisu.filter(nazwa_szablonu=…)`). +- `render()` / `__str__` / `get_models_for_this_szablon()` przełączone na + `nazwa_szablonu`. +- **`clean()`**: waliduje, że `nazwa_szablonu` rozwiązuje się na dysku (przez + helper z §2). Literówka w adminie → błąd walidacji, nie `TemplateDoesNotExist` + przy renderze rekordu. +- Admin: pole `nazwa_szablonu` jako **wolny tekst + walidacja `clean()`** + (YAGNI; `Select` z wykrytymi szablonami odrzucony jako przekombinowany — + enumeracja szablonów jest rozmyta). `help_text` z domyślną nazwą. + +### 2. Helper: źródło szablonu z dysku (bez loadera dbtemplates) + +Nowa, izolowana jednostka (np. `src/bpp/util/dbtemplates_disk.py` albo dołożone +do istniejącego util). Module-level `Engine` z **tylko** loaderami +`filesystem` + `app_directories` (bez dbtemplates), zbudowany z +`TEMPLATES['DIRS']` + `app_dirs=True`. Publiczne API: + +```python +def disk_template_source(name: str) -> str | None: + """Źródło szablonu `name` z DYSKU, z pominięciem loadera dbtemplates. + None, gdy pliku nie ma na dysku.""" +``` + +Reużywane przez: §1 (`clean()`) i §3 (naprawa compare). Jedno miejsce zna +prawdę „co jest na dysku". + +Kontrakt: co robi (zwraca źródło z dysku), jak używać (`disk_template_source`), +od czego zależy (settings TEMPLATES). Testowalny w izolacji. + +### 3. Naprawa `compare_dbtemplates` (punkt JEDEN) + +`get_filesystem_template_content()` przestaje wołać `get_template()` — używa +`disk_template_source(name)`. Teraz porównanie to realne DB-vs-dysk. +Repro-test (red-first): zasiej wiersz dbtemplates różny od pliku na dysku → +komenda **musi** pokazać diff (dziś kłamie „match"). + +### 4. Migracja (nowy plik — starych nie ruszamy) + +W aplikacji `bpp`, po `0295` i po migracjach z #409: + +1. Schema: dodaj `nazwa_szablonu` (CharField, tymczasowo nullable/z defaultem). +2. Data (forward): dla każdego wiersza `SzablonDlaOpisu` ustaw + `nazwa_szablonu = template.name`. Sprawdź dysk helperem z §2: + - jest na dysku → OK, wiersz dbtemplates do skasowania w kroku 4, + - **brak na dysku** (DB-only custom) → głośne ostrzeżenie (`stderr`/print) i + **nie** kasuj tego wiersza dbtemplates (postawa A: nie psuj po cichu). +3. Schema: usuń FK `template`; ustaw `nazwa_szablonu` NOT NULL. +4. Data: skasuj wiersze `dbtemplates.Template` **mapowane przez `SzablonDlaOpisu` + i mające odpowiednik na dysku** (standardowo: `opis_bibliograficzny.html`). + `browse/praca_tabela.html` i wszystko niezmapowane — **nietknięte**. +- Reverse: best-effort dla dev (odtwórz FK + wiersz z treści dysku). +- Po merge (raz, przy scalaniu): `make baseline-update` — reguła CLAUDE; + migracja waliduje się przy tym na czystym kontenerze. + +### 5. `drop_dbtemplate` — wymuszone odsprzęgnięcie + +`drop_dbtemplate` (`src/bpp/management/commands/drop_dbtemplate.py`) dziś +importuje `SzablonDlaOpisuBibliograficznego` i filtruje `filter(template=…)` +(bo FK był `PROTECT`, więc trzeba było najpierw usunąć powiązania). Po usunięciu +FK ten filtr **przestanie się kompilować** — komendę trzeba odsprzęgnąć od +`SzablonDlaOpisu`. Zostaje ogólnym narzędziem do kasowania dbtemplates +(np. `browse/praca_tabela.html`). To wymuszona część zakresu, nie opcja. + +### 6. Ścieżka renderu (świadoma decyzja: bez zmian) + +`util.opis_bibliograficzny()` **zostaje na `get_template()`**. Po skasowaniu +wiersza dysk i tak wygrywa; to hot-path (~150 szablonów/stronę), więc nie +forsujemy tam disk-only — mniejsze ryzyko, zero zmiany zachowania. Disk-only +żyje tylko w compare (§3) i walidacji (§1). + +## Testy + +- **Repro compare** (red-first): wiersz DB ≠ dysk → komenda pokazuje diff. +- **Migracja**: backfill `nazwa_szablonu`; skasowanie wiersza + `opis_bibliograficzny.html`; ścieżka ostrzeżenia dla szablonu bez pliku na + dysku (nie kasuje, ostrzega). +- **`clean()`**: zła nazwa odrzucona; poprawna przechodzi. +- **Render opisu**: renderuje się z dysku bez wiersza DB; rodzic z PBN + `object.book` dalej widoczny (reuse testów #329 — teraz bez wiersza DB). +- **`get_for_model`**: zwraca `nazwa_szablonu`. + +## Poza zakresem + +- `browse/praca_tabela.html` i inne dbtemplates — zostają. +- Usunięcie aplikacji dbtemplates — nie. +- Logika PBN `object.book` w szablonie — bez zmian (już w #409). +- Weryfikacja/wgranie u klienta (IHiT) i zamknięcie zgłoszenia — osobno + (to „skill #2"); tu dostarczamy tylko kod + migrację, która samoczynnie + naprawia po podbiciu wydania. + +## Ryzyka i pułapki + +- **Auto-populate**: zweryfikowane, że loader NIE odtwarza wierszy — kasowanie + jest trwałe. Gdyby ktoś w przyszłości włączył odtwarzanie w loaderze, założenie + pada. +- **DB-only custom** (wiersz `SzablonDlaOpisu` → szablon bez pliku na dysku): + obsłużone postawą A (ostrzeżenie, brak kasowania). Kierunek A zakłada, że nie + występuje; migracja nie psuje po cichu, gdyby jednak wystąpił. +- **Stary wiersz dbtemplates po deployu**: znika w migracji; nie wróci przy + renderze. Jedyny sposób, by wrócił, to ręczne utworzenie w adminie. +- **Nie modyfikować istniejących migracji** — nowy plik migracji (reguła CLAUDE). +- **Baseline**: odświeżyć `make baseline-update` przy scalaniu, nie w gałęzi + równoległej. From 49b919189b69e3cb7e3f422e6e5474b76c4edab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 8 Jul 2026 23:27:19 +0200 Subject: [PATCH 02/15] =?UTF-8?q?docs(spec):=20rewizja=20po=20recenzji=20F?= =?UTF-8?q?able=20=E2=80=94=20denorm=20rebuild=20+=20konsumenci=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wchlaniam P1-P9 z adversarialnego self-review: - P1 (BLOCKER): migracja musi oznaczyc rekordy dirty (opis idzie z denormu, nie live-render) — decyzja 1(a): rebuild_instances_of_models bez sync flush, async kolejka denorm odswieza w tle. - P2: jawne wyczysc_cache_dbtemplate (delete przez model historyczny nie odpala sygnalow czyszczacych Redis). - P3/P6: dopisani konsumenci FK (admin/templates.py, list_display, testy, fixtures, usuniecie _sync_opis_template_z_dysku). - P4/2(A): kasowanie bezwarunkowe + log tresci (porownanie tresci nie odroznia starego standardu od kustomizacji po #409). - P5: clean() przez get_template (nie 'na dysku') — brak sprzecznosci. - P7: Engine(dirs=, app_dirs=True), nie loaders=. - P8/P9: sprostowanie 'migracje #409' + baseline/_sync helper. Refs #329 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...aficzny-dysk-zamiast-dbtemplates-design.md | 252 ++++++++++++------ 1 file changed, 166 insertions(+), 86 deletions(-) diff --git a/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md b/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md index 5304a774a..604d47c39 100644 --- a/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md +++ b/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md @@ -4,6 +4,7 @@ Data: 2026-07-08 Ticket: Freshdesk #329 ("wyd. zwarte - problem") Kontynuacja: PR #409 (`fix(#329)`) — warstwa 1 (render rodzica z PBN) + doraźny `drop_dbtemplate`. Ten spec robi warstwę 2 „na serio". +Recenzja: adversarialny self-review (Fable) — ustalenia P1–P9 wchłonięte niżej. ## Kontekst @@ -24,10 +25,10 @@ Dodatkowo wyszły dwa problemy strukturalne: plikiem na dysku, ale czyta „dysk" przez `get_template()`, który idzie łańcuchem loaderów — a loader dbtemplates stoi **pierwszy**. Więc dostaje treść z **bazy** i porównuje DB-vs-DB → zawsze „All templates match". - Dokładnie w scenariuszu, dla którego powstała (wykrycie driftu), wprowadza - w błąd. Potwierdzone w kodzie: `compare_dbtemplates.py:322-332` - (`get_filesystem_template_content` → `get_template(name)` → `.template.source` - = treść z DB). + Potwierdzone: `compare_dbtemplates.py:322-332`. Fallback z `find_template` + (linia 335) jest **martwy** — nieosiągalny (obiekt z `get_template()` zawsze + ma `.template.source`) i dodatkowo `from django.template.loader import + find_template` rzuca **ImportError** w Django 5.2 (funkcja usunięta). 2. **`opis_bibliograficzny.html` nie musi być w dbtemplates.** Render i tak ładuje po nazwie (`util.opis_bibliograficzny()` → `get_template(name)`), a samo trzymanie kopii dysku w bazie generuje drift, shadowing i potrzebę @@ -39,31 +40,49 @@ Dodatkowo wyszły dwa problemy strukturalne: - `model` — OneToOne do `ContentType` (jeden z 5 modeli publikacji lub NULL = domyślny dla wszystkich), - `template` — **FK do `dbtemplates.Template`** (`on_delete=PROTECT`), - - `render(praca)` i manager `get_for_model()` używają FK **wyłącznie** po to, - by wyciągnąć `.template.name`; faktyczny render to `get_template(name)`. + - `render(praca)` i manager `get_for_model()` używają FK **wyłącznie** by + wyciągnąć `.template.name`; faktyczny render to `get_template(name)`. - `AbstractModel.opis_bibliograficzny()` (`src/bpp/models/util.py:91`): `template_name = get_for_model(self)` (fallback `"opis_bibliograficzny.html"`) → `get_template(template_name)` → render. -- Migracja `src/bpp/migrations/0295_instaluj_szablony.py` zasiewa do dbtemplates - `opis_bibliograficzny.html` **oraz** `browse/praca_tabela.html`, i tworzy - wpis `SzablonDlaOpisu` (model=NULL) wskazujący na `opis_bibliograficzny.html`. -- Loader dbtemplates **nie tworzy** wierszy przy renderze (`loader.py` tylko - `Template.objects.get(...)`). `DBTEMPLATES_AUTO_POPULATE_CONTENT=True` - działa **tylko** przy `Template.save()` z pustą treścią (`models.py:63`) i w - adminie — **nie** przy ładowaniu. Wniosek kluczowy: **skasowany wiersz nie - wróci** przy renderze. Kasowanie w migracji jest trwałe. +- **Opis na stronach idzie z denormu, nie z live-renderu.** `opis_bibliograficzny` + jest `@denormalized` polem (`wydawnictwo_zwarte.py:310` itd.), a + `cache.Rekord.opis_bibliograficzny_cache` (`models/cache/rekord.py:245`) + kopiuje gotowy string. Sama zmiana szablonu/skasowanie wiersza **nie** + odświeża zapisanych stringów — denorm przelicza tylko instancje oznaczone + jako „dirty" (`DirtyInstance`), realny flush robi async kolejka `denorm` + (`denorm.tasks.flush_single`, `base.py:739`). Dlatego `drop_dbtemplate` + jawnie robi `wyczysc_cache_dbtemplate` + `rebuild_instances_of_models` + + `denorms.flush()` (`drop_dbtemplate.py`). +- Migracja `0295_instaluj_szablony.py` zasiewa do dbtemplates + `opis_bibliograficzny.html` **oraz** `browse/praca_tabela.html`, ale wpis + `SzablonDlaOpisu` (model=NULL) tworzy **tylko** dla `opis_bibliograficzny.html`. + `praca_tabela` nie jest mapowany. +- **Loader dbtemplates nie tworzy wierszy przy renderze** (`loader.py` tylko + `Template.objects.get(...)`). `DBTEMPLATES_AUTO_POPULATE_CONTENT=True` działa + **tylko** przy `Template.save()` z pustą treścią (`models.py:63`) i w adminie. + Żaden sygnał (`post_save`/`pre_delete`/`post_delete` na `Template`) nie + odtwarza wierszy. Wniosek kluczowy: **skasowany wiersz nie wróci** przy + renderze — kasowanie w migracji jest trwałe. +- **Cache dbtemplates (Redis).** Loader trzyma treść pod kluczem per-site + (`bpp.dbtemplates_sync.wyczysc_cache_dbtemplate`), plus zbiór known-names. + Delete przez model historyczny w migracji **nie odpali** sygnałów czyszczących + → cache trzeba wyczyścić jawnie (patrz §4). ## Decyzja (zatwierdzona) -**Kierunek A + zakres B + naprawa compare (A).** Dysk staje się jedynym -źródłem prawdy dla `opis_bibliograficzny`; mapowanie per-model przeżywa, ale -wskazuje **nazwę szablonu**, nie wiersz DB. `browse/praca_tabela.html` i reszta -dbtemplates **poza zakresem**. +**Kierunek A + zakres B + naprawa compare (A) + rebuild denorma 1(a) + +kasowanie bezwarunkowe 2(A).** Dysk staje się jedynym źródłem prawdy dla +`opis_bibliograficzny`; mapowanie per-model przeżywa, ale wskazuje **nazwę +szablonu**, nie wiersz DB. `browse/praca_tabela.html` i reszta dbtemplates +**poza zakresem**. Konsekwencja edytowalności (świadomie zaakceptowana): opisu **nie** edytuje się już z admina dbtemplates — zmiana treści opisu = zmiana pliku w kodzie + wydanie. W zamian znika drift, shadowing i `drop_dbtemplate` dla opisu, a #329 -**naprawia się samo** przy podbiciu wydania (migracja kasuje wiersz). +**naprawia się samo** przy podbiciu wydania: migracja kasuje wiersz, czyści +cache i oznacza rekordy jako dirty, a async kolejka `denorm` odświeża opisy w +tle. ## Zakres zmian @@ -73,88 +92,138 @@ wydanie. W zamian znika drift, shadowing i `drop_dbtemplate` dla opisu, a #329 - Dodaj `nazwa_szablonu = models.CharField(max_length=255)` — nazwa dla loadera Django (loader-relative, np. `"opis_bibliograficzny.html"`), **nie** ścieżka pliku. Stąd `nazwa_szablonu`, nie `nazwa_pliku`. -- `get_for_model()` zwraca `.nazwa_szablonu` (fallback bez zmian w `util.py`). +- `get_for_model()` zwraca `.nazwa_szablonu` (fallback w `util.py` bez zmian). - `get_models_for_template(template)` → `get_models_for_szablon(nazwa_szablonu)` (filtr `SzablonDlaOpisu.filter(nazwa_szablonu=…)`). -- `render()` / `__str__` / `get_models_for_this_szablon()` przełączone na - `nazwa_szablonu`. -- **`clean()`**: waliduje, że `nazwa_szablonu` rozwiązuje się na dysku (przez - helper z §2). Literówka w adminie → błąd walidacji, nie `TemplateDoesNotExist` - przy renderze rekordu. -- Admin: pole `nazwa_szablonu` jako **wolny tekst + walidacja `clean()`** - (YAGNI; `Select` z wykrytymi szablonami odrzucony jako przekombinowany — - enumeracja szablonów jest rozmyta). `help_text` z domyślną nazwą. +- `render()` / `__str__` / `get_models_for_this_szablon()` na `nazwa_szablonu`. +- **`clean()`**: waliduje, że `nazwa_szablonu` **rozwiązuje się przez + `get_template`** (dysk lub — teoretycznie — dbtemplates). Decyzja 2(A): + walidacja przez `get_template`, NIE „istnieje na dysku" — dzięki temu brak + sprzeczności z ewentualnym przeżywającym wierszem DB (P5), a literówka i tak + jest łapana (nazwa, która nie rozwiązuje się nigdzie → błąd). Odpala się z + admina (`ModelAdmin` → `full_clean`); nie odpala się przy `create`/bakery/ + migracji — i dobrze. ### 2. Helper: źródło szablonu z dysku (bez loadera dbtemplates) -Nowa, izolowana jednostka (np. `src/bpp/util/dbtemplates_disk.py` albo dołożone -do istniejącego util). Module-level `Engine` z **tylko** loaderami -`filesystem` + `app_directories` (bez dbtemplates), zbudowany z -`TEMPLATES['DIRS']` + `app_dirs=True`. Publiczne API: +Nowa, izolowana jednostka (np. `src/bpp/util/dbtemplates_disk.py`). Module-level +`Engine` **z domyślnymi loaderami** filesystem + app_directories, zbudowany tak: ```python +from django.template import Engine +_disk_engine = Engine(dirs=list(TEMPLATES_DIRS), app_dirs=True) +# UWAGA: NIE 'loaders=[...] + app_dirs=True' — to ImproperlyConfigured w Dj5.2. +# Domyślne loadery Engine to dokładnie filesystem + app_directories (bez +# dbtemplates), owinięte w cached.Loader. Cached zamraża źródło w procesie — +# dla compare (one-shot) i clean() bez znaczenia. + def disk_template_source(name: str) -> str | None: """Źródło szablonu `name` z DYSKU, z pominięciem loadera dbtemplates. None, gdy pliku nie ma na dysku.""" ``` -Reużywane przez: §1 (`clean()`) i §3 (naprawa compare). Jedno miejsce zna -prawdę „co jest na dysku". - -Kontrakt: co robi (zwraca źródło z dysku), jak używać (`disk_template_source`), -od czego zależy (settings TEMPLATES). Testowalny w izolacji. +Reużywane przez §3 (naprawa compare). (`clean()` z §1 celowo używa `get_template`, +nie tego helpera — patrz decyzja 2(A)/P5.) ### 3. Naprawa `compare_dbtemplates` (punkt JEDEN) `get_filesystem_template_content()` przestaje wołać `get_template()` — używa -`disk_template_source(name)`. Teraz porównanie to realne DB-vs-dysk. -Repro-test (red-first): zasiej wiersz dbtemplates różny od pliku na dysku → -komenda **musi** pokazać diff (dziś kłamie „match"). +`disk_template_source(name)`. Usuń martwą gałąź `find_template` (nieosiągalną i +niekompilowalną w Dj5.2). Teraz porównanie to realne DB-vs-dysk. Repro-test +(red-first): zasiej wiersz dbtemplates różny od pliku na dysku → komenda **musi** +pokazać diff (dziś kłamie „match"). ### 4. Migracja (nowy plik — starych nie ruszamy) -W aplikacji `bpp`, po `0295` i po migracjach z #409: - -1. Schema: dodaj `nazwa_szablonu` (CharField, tymczasowo nullable/z defaultem). -2. Data (forward): dla każdego wiersza `SzablonDlaOpisu` ustaw - `nazwa_szablonu = template.name`. Sprawdź dysk helperem z §2: - - jest na dysku → OK, wiersz dbtemplates do skasowania w kroku 4, - - **brak na dysku** (DB-only custom) → głośne ostrzeżenie (`stderr`/print) i - **nie** kasuj tego wiersza dbtemplates (postawa A: nie psuj po cichu). -3. Schema: usuń FK `template`; ustaw `nazwa_szablonu` NOT NULL. -4. Data: skasuj wiersze `dbtemplates.Template` **mapowane przez `SzablonDlaOpisu` - i mające odpowiednik na dysku** (standardowo: `opis_bibliograficzny.html`). - `browse/praca_tabela.html` i wszystko niezmapowane — **nietknięte**. +W aplikacji `bpp`, jako nowa migracja na aktualnym head (zależność od migracji +`dbtemplates` Django dołoży sam przy `RemoveField`; **nie** ma migracji z #409 — +#409 nie dodał migracji). Kroki: + +1. Schema: dodaj `nazwa_szablonu` (CharField, tymczasowo z defaultem/nullable). +2. Data (forward, `RunPython`): dla każdego wiersza `SzablonDlaOpisu` ustaw + `nazwa_szablonu = template.name`. +3. Schema: usuń FK `template`; ustaw `nazwa_szablonu` NOT NULL. (`RemoveField` + nie wymaga wcześniejszego zrywania powiązań — `PROTECT` działa tylko przy + delete `Template`, a delete jest w kroku 4, już po usunięciu FK.) +4. Data (`RunPython`) — **decyzja 2(A) + 1(a)**, kasowanie bezwarunkowe + + przebudowa denorma: + - Dla `name = "opis_bibliograficzny.html"` (i każdej nazwy, którą przed + krokiem 3 mapował `SzablonDlaOpisu`): **zaloguj pełną treść** wiersza + dbtemplates do outputu migracji (odzyskiwalność; `DBTEMPLATES_USE_REVERSION` + dodatkowo trzyma historię), potem **skasuj wiersz bezwarunkowo**. + - `wyczysc_cache_dbtemplate(name)` (import z `bpp.dbtemplates_sync`) — wyczyść + cache Redis dbtemplates (P2: delete przez model historyczny nie odpala + sygnałów). + - `rebuild_instances_of_models([5 modeli publikacji])` — **oznacz dirty** + (INSERT do `DirtyInstance`); **NIE** wołaj synchronicznego `denorms.flush()` + (decyzja 1(a) — flush robi async kolejka `denorm`; migracja szybka, + nieblokująca). Operuj na **konkretnych** klasach modeli (denorm potrzebuje + realnych klas/triggerów) — dopuszczalny import w tym punkcie migracji. + - `browse/praca_tabela.html` i wszystko niezmapowane — **nietknięte**. + - Migracja komponuje **te same istniejące utilsy** co `drop_dbtemplate` + (`wyczysc_cache_dbtemplate`, `rebuild_instances_of_models`) — bez nowej + abstrakcji „shared purge". - Reverse: best-effort dla dev (odtwórz FK + wiersz z treści dysku). -- Po merge (raz, przy scalaniu): `make baseline-update` — reguła CLAUDE; - migracja waliduje się przy tym na czystym kontenerze. - -### 5. `drop_dbtemplate` — wymuszone odsprzęgnięcie - -`drop_dbtemplate` (`src/bpp/management/commands/drop_dbtemplate.py`) dziś -importuje `SzablonDlaOpisuBibliograficznego` i filtruje `filter(template=…)` -(bo FK był `PROTECT`, więc trzeba było najpierw usunąć powiązania). Po usunięciu -FK ten filtr **przestanie się kompilować** — komendę trzeba odsprzęgnąć od -`SzablonDlaOpisu`. Zostaje ogólnym narzędziem do kasowania dbtemplates -(np. `browse/praca_tabela.html`). To wymuszona część zakresu, nie opcja. - -### 6. Ścieżka renderu (świadoma decyzja: bez zmian) +- Po merge (raz, przy scalaniu): `make baseline-update`; migracja waliduje się + wtedy na czystym kontenerze. + +### 5. `drop_dbtemplate` — wymuszone odsprzęgnięcie (rebuild zostaje) + +`drop_dbtemplate.py:61-69` i manager `.filter(template=…)` odwołują się do +usuwanego FK → po `RemoveField` to `FieldError`. Odsprzęgnij komendę od +`SzablonDlaOpisu` (przestaje kasować powiązania — FK już nie ma). **Zachowaj** +`wyczysc_cache_dbtemplate` + `rebuild_instances_of_models` + `denorms.flush()` +— to połowa wartości komendy (odświeżenie denorma dla dowolnego dbtemplate, +np. `praca_tabela`). Zaktualizuj docstring (nieaktualne „PROTECT FKs"). + +### 6. Pominięci konsumenci FK / metody (P3, P6) — do zakresu + +Zmiana modelu/metody wywala kilku konsumentów, których pierwsza wersja speca +nie wymieniała. Wszystkie do poprawy: + +- `src/bpp/admin/templates.py:71` — `template_updated` woła + `get_models_for_template(obj)` przy zapisie **dowolnego** dbtemplate (także + `praca_tabela`, który zostaje) → dostosuj do nowej sygnatury/nazwy. +- `src/bpp/admin/szablondlaopisubibliograficznego.py:9` — `list_display = + ["model", "template"]` → `["model", "nazwa_szablonu"]` (inaczej changelist + wybucha na usuniętym polu). Pole formularza: `nazwa_szablonu` jako wolny + tekst + `help_text`; walidacja przez `clean()`. +- Testy/fixtures używające `template=` FK — **migracja istniejących, nie tylko + nowe testy**: + - `src/fixtures/conftest.py` (fixture `szablony` → `create(template=…)`), + - `src/bpp/tests/test_opis_bibliograficzny.py` (`create(template=…)` ×5 oraz + helper `_sync_opis_template_z_dysku` — **usuń** go; po zmianie render idzie + z dysku, helper traci rację bytu, P9), + - `src/bpp/tests/test_admin/test_templateadmin.py` (`create(template=…)`), + - `src/bpp/tests/test_management_commands_drop_dbtemplate.py` (testuje + sprzężenie usuwane w §5 — przepisz pod nową, odsprzęgniętą komendę; sprawdź, + że rebuild denorma zostaje), + - `src/bpp/tests/test_management_commands_compare_dbtemplates.py` (założenie + „protected FKs" w docstringu dezaktualizuje się). + +### 7. Ścieżka renderu (świadoma decyzja: bez zmian) `util.opis_bibliograficzny()` **zostaje na `get_template()`**. Po skasowaniu wiersza dysk i tak wygrywa; to hot-path (~150 szablonów/stronę), więc nie -forsujemy tam disk-only — mniejsze ryzyko, zero zmiany zachowania. Disk-only -żyje tylko w compare (§3) i walidacji (§1). +forsujemy tam disk-only. Disk-only żyje tylko w compare (§3). ## Testy - **Repro compare** (red-first): wiersz DB ≠ dysk → komenda pokazuje diff. -- **Migracja**: backfill `nazwa_szablonu`; skasowanie wiersza - `opis_bibliograficzny.html`; ścieżka ostrzeżenia dla szablonu bez pliku na - dysku (nie kasuje, ostrzega). -- **`clean()`**: zła nazwa odrzucona; poprawna przechodzi. -- **Render opisu**: renderuje się z dysku bez wiersza DB; rodzic z PBN - `object.book` dalej widoczny (reuse testów #329 — teraz bez wiersza DB). +- **Migracja**: backfill `nazwa_szablonu`; **bezwarunkowe** skasowanie wiersza + `opis_bibliograficzny.html`; log treści; `wyczysc_cache_dbtemplate` wywołane; + rekordy 5 modeli oznaczone dirty (`DirtyInstance`), **bez** synchronicznego + flush. +- **Denorm end-to-end** (kluczowe dla P1): rekord ze starym + `opis_bibliograficzny_cache` → po migracji + flushu denorma opis pokazuje + rodzica z PBN `object.book` (dziś nie pokazywał). To test, który dowodzi, że + „naprawia się samo" jest prawdą, a nie tylko skasowaniem wiersza. +- **`clean()`**: zła nazwa (nierozwiązywalna) odrzucona; poprawna przechodzi. - **`get_for_model`**: zwraca `nazwa_szablonu`. +- **Migracja istniejących testów/fixtures** (§6) — muszą dalej przechodzić; + `_sync_opis_template_z_dysku` usunięty. +- Reuse testów #329 (`test_publication_book.py`, render `object.book`) — teraz + bez wiersza DB. ## Poza zakresem @@ -162,19 +231,30 @@ forsujemy tam disk-only — mniejsze ryzyko, zero zmiany zachowania. Disk-only - Usunięcie aplikacji dbtemplates — nie. - Logika PBN `object.book` w szablonie — bez zmian (już w #409). - Weryfikacja/wgranie u klienta (IHiT) i zamknięcie zgłoszenia — osobno - (to „skill #2"); tu dostarczamy tylko kod + migrację, która samoczynnie - naprawia po podbiciu wydania. + („skill #2"). Tu dostarczamy kod + migrację, która po podbiciu wydania + samoczynnie kasuje wiersz, czyści cache i oznacza rekordy dirty; opisy + odświeży async kolejka `denorm`. ## Ryzyka i pułapki -- **Auto-populate**: zweryfikowane, że loader NIE odtwarza wierszy — kasowanie - jest trwałe. Gdyby ktoś w przyszłości włączył odtwarzanie w loaderze, założenie - pada. -- **DB-only custom** (wiersz `SzablonDlaOpisu` → szablon bez pliku na dysku): - obsłużone postawą A (ostrzeżenie, brak kasowania). Kierunek A zakłada, że nie - występuje; migracja nie psuje po cichu, gdyby jednak wystąpił. -- **Stary wiersz dbtemplates po deployu**: znika w migracji; nie wróci przy - renderze. Jedyny sposób, by wrócił, to ręczne utworzenie w adminie. -- **Nie modyfikować istniejących migracji** — nowy plik migracji (reguła CLAUDE). -- **Baseline**: odświeżyć `make baseline-update` przy scalaniu, nie w gałęzi - równoległej. +- **Denorm (P1)**: samo skasowanie wiersza nie odświeża `opis_bibliograficzny_cache` + — migracja MUSI oznaczyć rekordy dirty (§4 krok 4). Flush async; jeśli u + klienta nie chodzi worker kolejki `denorm`, opisy pozostaną stare do czasu + flushu (normalny kontrakt denorma w BPP). Admin może wymusić `denorm_flush`. +- **Cache dbtemplates (P2)**: delete przez model historyczny nie odpala sygnałów + → jawne `wyczysc_cache_dbtemplate(name)` w migracji. `DBTEMPLATES_SKIP_UNKNOWN_NAMES=True` + chroni przed pytaniem DB o nieznane nazwy po restarcie. +- **Auto-populate**: zweryfikowane — loader NIE odtwarza wierszy; kasowanie + trwałe. Założenie pada tylko, gdyby ktoś włączył odtwarzanie w loaderze. +- **Kustomizacja treści (P4, decyzja 2(A))**: kasujemy bezwarunkowo. Porównanie + treści DB↔dysk NIE odróżnia „stary standard" od „przerobiony" (po #409 treść + DB u KAŻDEGO klienta różni się od dysku samą poprawką #409). Siatka + bezpieczeństwa: pełny log treści w migracji + historia `DBTEMPLATES_USE_REVERSION`. + Kierunek A zakłada brak kustomizacji opisu. +- **Engine (P7)**: `Engine(dirs=…, app_dirs=True)` — NIE `loaders=… + app_dirs=True` + (ImproperlyConfigured w Dj5.2). +- **Nie modyfikować istniejących migracji** — nowy plik (reguła CLAUDE). +- **Baseline (P9)**: `baseline.sql` trzyma stary wiersz `django_template` (opis + sprzed #409) + wiersz `SzablonDlaOpisu`. Do czasu `make baseline-update` + (przy scalaniu) testy renderu na świeżej bazie widzą starą treść — stąd + istniał `_sync_opis_template_z_dysku`, który po tej zmianie **usuwamy**. From 407ced6da065d3a81c8af45333de5f972bfd5952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 8 Jul 2026 23:48:21 +0200 Subject: [PATCH 03/15] =?UTF-8?q?docs(spec):=20druga=20runda=20Fable=20?= =?UTF-8?q?=E2=80=94=20guard=20dysk-existence=20+=20drobne=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swiezy adversarialny review zlapal regresje, ktora wprowadzila poprzednia rewizja (kasowanie "kazdej mapowanej nazwy bezwarunkowo"): - BLOCKER (regresja): kasuj wiersz TYLKO gdy disk_template_source(name) is not None. DB-only custom bez pliku na dysku zostawiamy — inaczej dyndajaca nazwa_szablonu -> TemplateDoesNotExist -> opis wybucha przy kazdym flushu denorma. Guard rozbraja tez sprzecznosc z clean() 2(A). - drop_dbtemplate: rebuild celuje przez nowe get_models_for_szablon(name), nie usuniete get_models_for_template(Template). - reverse: migracja nieodwracalna (AddField NOT NULL na niepustej tabeli nie da sie odwrocic "best-effort"). - wyczysc_cache_dbtemplate przez transaction.on_commit (okno wyscigu). - rebuild dziala na apps.get_model (sprostowane "potrzebuje realnych klas"). - atomic=False (mark-dirty setek tysiecy rekordow), multi-site cache TTL, fixture szablony bez konsumentow -> usunac, testowalnosc przez funkcje wyekstrahowana (brak django-test-migrations), sprostowanie flush_single legacy. Refs #329 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...aficzny-dysk-zamiast-dbtemplates-design.md | 115 +++++++++++++----- 1 file changed, 82 insertions(+), 33 deletions(-) diff --git a/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md b/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md index 604d47c39..f23b51b77 100644 --- a/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md +++ b/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md @@ -145,36 +145,67 @@ W aplikacji `bpp`, jako nowa migracja na aktualnym head (zależność od migracj 3. Schema: usuń FK `template`; ustaw `nazwa_szablonu` NOT NULL. (`RemoveField` nie wymaga wcześniejszego zrywania powiązań — `PROTECT` działa tylko przy delete `Template`, a delete jest w kroku 4, już po usunięciu FK.) -4. Data (`RunPython`) — **decyzja 2(A) + 1(a)**, kasowanie bezwarunkowe + - przebudowa denorma: - - Dla `name = "opis_bibliograficzny.html"` (i każdej nazwy, którą przed - krokiem 3 mapował `SzablonDlaOpisu`): **zaloguj pełną treść** wiersza - dbtemplates do outputu migracji (odzyskiwalność; `DBTEMPLATES_USE_REVERSION` - dodatkowo trzyma historię), potem **skasuj wiersz bezwarunkowo**. +4. Data (`RunPython`) — **decyzja 2(A) + 1(a)**, kasowanie z guardem + dysk-existence + przebudowa denorma: + - Dla każdej nazwy, którą przed krokiem 3 mapował `SzablonDlaOpisu`: + **GUARD (naprawa regresji, BLOCKER)** — kasuj wiersz dbtemplates **tylko + gdy `disk_template_source(name) is not None`** (nazwa ma odpowiednik na + dysku). Dla `opis_bibliograficzny.html` to zawsze prawda → kasowane + „bezwarunkowo" w sensie decyzji 2(A) (niezależnie od treści). Dla + hipotetycznego **DB-only custom bez pliku na dysku** — **NIE kasuj**: + inaczej `nazwa_szablonu` zostaje dyndająca i `get_template()` → + `TemplateDoesNotExist` → opis wybucha przy każdym flushu denorma i + live-renderze (wieczne nieskonwergowane rundy denorma). Zostawienie takiego + wiersza jest w pełni spójne z `clean()` 2(A) (toleruje nazwy rozwiązywalne + przez dbtemplates). + - Przed skasowaniem: **zaloguj pełną treść** wiersza do outputu migracji + (odzyskiwalność; `DBTEMPLATES_USE_REVERSION` dodatkowo trzyma historię). - `wyczysc_cache_dbtemplate(name)` (import z `bpp.dbtemplates_sync`) — wyczyść cache Redis dbtemplates (P2: delete przez model historyczny nie odpala - sygnałów). - - `rebuild_instances_of_models([5 modeli publikacji])` — **oznacz dirty** + sygnałów). Wołaj przez `transaction.on_commit(...)`, nie w środku + transakcji — inaczej okno wyścigu, gdzie żywy worker re-cache'uje starą + treść z jeszcze-widocznego wiersza (bounded TTL 300 s, ale on_commit czystsze). + - `rebuild_instances_of_models(<5 modeli publikacji>)` — **oznacz dirty** (INSERT do `DirtyInstance`); **NIE** wołaj synchronicznego `denorms.flush()` (decyzja 1(a) — flush robi async kolejka `denorm`; migracja szybka, - nieblokująca). Operuj na **konkretnych** klasach modeli (denorm potrzebuje - realnych klas/triggerów) — dopuszczalny import w tym punkcie migracji. + nieblokująca). `rebuild_instances_of` robi tylko `ContentType` + + `values_list("pk")` + `bulk_create(DirtyInstance, ignore_conflicts=True)` + — działa identycznie na modelach z `apps.get_model` (kanoniczniejszych dla + migracji) jak i na konkretnych klasach; wybierz `apps.get_model`. - `browse/praca_tabela.html` i wszystko niezmapowane — **nietknięte**. - - Migracja komponuje **te same istniejące utilsy** co `drop_dbtemplate` - (`wyczysc_cache_dbtemplate`, `rebuild_instances_of_models`) — bez nowej - abstrakcji „shared purge". -- Reverse: best-effort dla dev (odtwórz FK + wiersz z treści dysku). +- **`atomic = False`** na migracji (rozważane): oznaczenie dirty setek tysięcy + rekordów (5 modeli × cała baza) w jednej transakcji wydłuża `migrate` i trzyma + locki na `denorm_dirtyinstance`. `bulk_create` batchuje, ale transakcja jedna — + `atomic=False` + jawne bat`on_commit` bezpieczniejsze na dużych bazach. +- **Reverse: migracja nieodwracalna** (`RunPython.noop` / brak reverse na + `AddField`). „Best-effort odtwórz FK" jest niewykonalne wprost: odwrócenie + `RemoveField` = `AddField` kolumny NOT NULL bez defaulta na niepustej tabeli → + błąd DB, zanim data-reverse zdąży wypełnić FK. Deklarujemy nieodwracalność + (dev odtwarza z baseline/migrate od zera). - Po merge (raz, przy scalaniu): `make baseline-update`; migracja waliduje się wtedy na czystym kontenerze. +- **Testowalność (brak `django-test-migrations` w repo)**: logikę kroku 4 + (guard + log + `wyczysc_cache_dbtemplate` + rebuild) **wyekstrahuj do funkcji** + w `bpp.dbtemplates_sync` (parametr: `name` + lista modeli do rebuildu), którą + woła zarówno migracja, jak i `drop_dbtemplate` (§5). Test jedzie na funkcji, + nie na krokach migracji. Bonus: DRY z `drop_dbtemplate`. ### 5. `drop_dbtemplate` — wymuszone odsprzęgnięcie (rebuild zostaje) `drop_dbtemplate.py:61-69` i manager `.filter(template=…)` odwołują się do usuwanego FK → po `RemoveField` to `FieldError`. Odsprzęgnij komendę od `SzablonDlaOpisu` (przestaje kasować powiązania — FK już nie ma). **Zachowaj** -`wyczysc_cache_dbtemplate` + `rebuild_instances_of_models` + `denorms.flush()` -— to połowa wartości komendy (odświeżenie denorma dla dowolnego dbtemplate, -np. `praca_tabela`). Zaktualizuj docstring (nieaktualne „PROTECT FKs"). +`wyczysc_cache_dbtemplate` + rebuild denorma + `denorms.flush()` — to połowa +wartości komendy. +- **Targeting rebuildu po odsprzęgnięciu**: komenda dalej wie, które modele + przebudować, przez **nowe `get_models_for_szablon(name)`** (mapowanie po + nazwie przeżywa!) — a nie usunięte `get_models_for_template(Template)`. Dla + nazw niezmapowanych `get_models_for_szablon` zwraca pustą listę (brak zbędnego + rebuildu); dla wpisu z model=NULL → wszystkie 5 modeli. +- Współdziel funkcję z migracją (§4, „Testowalność"): `drop_dbtemplate` + różni się od migracji tylko tym, że robi **synchroniczny** `denorms.flush()` + (komenda deployowa chce natychmiast), a migracja zostawia flush kolejce. +- Zaktualizuj docstring (nieaktualne „PROTECT FKs"). ### 6. Pominięci konsumenci FK / metody (P3, P6) — do zakresu @@ -190,7 +221,9 @@ nie wymieniała. Wszystkie do poprawy: tekst + `help_text`; walidacja przez `clean()`. - Testy/fixtures używające `template=` FK — **migracja istniejących, nie tylko nowe testy**: - - `src/fixtures/conftest.py` (fixture `szablony` → `create(template=…)`), + - `src/fixtures/conftest.py` (fixture `szablony` → `create(template=…)`) — + sweep grep pokazał **zero konsumentów** tej fixture w testach → **usuń ją**, + nie migruj. - `src/bpp/tests/test_opis_bibliograficzny.py` (`create(template=…)` ×5 oraz helper `_sync_opis_template_z_dysku` — **usuń** go; po zmianie render idzie z dysku, helper traci rację bytu, P9), @@ -210,14 +243,17 @@ forsujemy tam disk-only. Disk-only żyje tylko w compare (§3). ## Testy - **Repro compare** (red-first): wiersz DB ≠ dysk → komenda pokazuje diff. -- **Migracja**: backfill `nazwa_szablonu`; **bezwarunkowe** skasowanie wiersza - `opis_bibliograficzny.html`; log treści; `wyczysc_cache_dbtemplate` wywołane; - rekordy 5 modeli oznaczone dirty (`DirtyInstance`), **bez** synchronicznego - flush. +- **Funkcja z §4 (wyekstrahowana do `dbtemplates_sync`)**: testuj JĄ, nie kroki + migracji (brak `django-test-migrations`). Przypadki: nazwa z plikiem na dysku + → wiersz skasowany, treść zalogowana, `wyczysc_cache_dbtemplate` wywołane, + rekordy oznaczone dirty; **nazwa BEZ pliku na dysku (guard) → wiersz NIE + skasowany** (kluczowy test regresji BLOCKER-a). - **Denorm end-to-end** (kluczowe dla P1): rekord ze starym - `opis_bibliograficzny_cache` → po migracji + flushu denorma opis pokazuje - rodzica z PBN `object.book` (dziś nie pokazywał). To test, który dowodzi, że - „naprawia się samo" jest prawdą, a nie tylko skasowaniem wiersza. + `opis_bibliograficzny_cache` → po skasowaniu wiersza + rebuild + + `denorms.flush()` opis pokazuje rodzica z PBN `object.book` (dziś nie + pokazywał). Wzorzec istnieje: `test_management_commands_drop_dbtemplate.py:79-89` + (rebuild + `denorms.flush()` + `refresh_from_db`). Dowodzi, że „naprawia się + samo" jest prawdą, a nie tylko skasowaniem wiersza. - **`clean()`**: zła nazwa (nierozwiązywalna) odrzucona; poprawna przechodzi. - **`get_for_model`**: zwraca `nazwa_szablonu`. - **Migracja istniejących testów/fixtures** (§6) — muszą dalej przechodzić; @@ -238,19 +274,32 @@ forsujemy tam disk-only. Disk-only żyje tylko w compare (§3). ## Ryzyka i pułapki - **Denorm (P1)**: samo skasowanie wiersza nie odświeża `opis_bibliograficzny_cache` - — migracja MUSI oznaczyć rekordy dirty (§4 krok 4). Flush async; jeśli u - klienta nie chodzi worker kolejki `denorm`, opisy pozostaną stare do czasu - flushu (normalny kontrakt denorma w BPP). Admin może wymusić `denorm_flush`. + — migracja MUSI oznaczyć rekordy dirty (§4 krok 4). Flush async: INSERT do + `denorm_dirtyinstance` odpala trigger `notify_django_denorm_queue` → daemon + `denorm_queue` (osobny kontener) dispatchuje flush; na (re)starcie daemon + dodatkowo kicka backlog — więc dirty z migracji przy zgaszonym daemonie też + się sflushują po starcie. Dirty nie zostaną na wieki. (Task `denorm.tasks. + flush_single` z `base.py:739` to LEGACY; realny mechanizm to NOTIFY→ + `flush_via_queue`/`flush_batch` na domyślnej kolejce `celery`.) Admin może + wymusić `denorm_flush`. +- **BLOCKER-guard (regresja rewizji)**: kasowanie tylko gdy nazwa ma plik na + dysku — patrz §4 krok 4. Bez tego DB-only custom bez pliku → dyndająca nazwa → + `TemplateDoesNotExist` → twarda awaria opisu (gorsze niż drift). +- **Multi-hosted**: klucz cache dbtemplates jest per-`Site`, BPP obsługuje wiele + Site'ów; `wyczysc_cache_dbtemplate` w migracji wyczyści tylko klucz `SITE_ID` — + kopie pozostałych Site'ów przeżyją do wygaśnięcia TTL (≤300 s). Akceptowalne. - **Cache dbtemplates (P2)**: delete przez model historyczny nie odpala sygnałów → jawne `wyczysc_cache_dbtemplate(name)` w migracji. `DBTEMPLATES_SKIP_UNKNOWN_NAMES=True` chroni przed pytaniem DB o nieznane nazwy po restarcie. - **Auto-populate**: zweryfikowane — loader NIE odtwarza wierszy; kasowanie trwałe. Założenie pada tylko, gdyby ktoś włączył odtwarzanie w loaderze. -- **Kustomizacja treści (P4, decyzja 2(A))**: kasujemy bezwarunkowo. Porównanie - treści DB↔dysk NIE odróżnia „stary standard" od „przerobiony" (po #409 treść - DB u KAŻDEGO klienta różni się od dysku samą poprawką #409). Siatka - bezpieczeństwa: pełny log treści w migracji + historia `DBTEMPLATES_USE_REVERSION`. - Kierunek A zakłada brak kustomizacji opisu. +- **Kustomizacja treści (P4, decyzja 2(A))**: dla nazw z plikiem na dysku + (m.in. `opis_bibliograficzny.html`) kasujemy **bez względu na treść**. + Porównanie treści DB↔dysk NIE odróżnia „stary standard" od „przerobiony" (po + #409 treść DB u KAŻDEGO klienta różni się od dysku samą poprawką #409), więc + nie próbujemy go używać. Siatka bezpieczeństwa: pełny log treści w migracji + + historia `DBTEMPLATES_USE_REVERSION`. Kierunek A zakłada brak kustomizacji + opisu. (Nazwy BEZ pliku na dysku są chronione guardem — nie kasowane.) - **Engine (P7)**: `Engine(dirs=…, app_dirs=True)` — NIE `loaders=… + app_dirs=True` (ImproperlyConfigured w Dj5.2). - **Nie modyfikować istniejących migracji** — nowy plik (reguła CLAUDE). From b30833ac54c5a165a3cad8f4d1f042e8ce9d0e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 00:06:39 +0200 Subject: [PATCH 04/15] =?UTF-8?q?docs(plan):=20plan=20implementacji=20opis?= =?UTF-8?q?-z-dysku=20(8=20task=C3=B3w,=20TDD)=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rozklad na 8 taskow z konkretnym kodem: helper disk_template_source, naprawa compare_dbtemplates, wspoldzielona usun_dbtemplate_i_przebuduj (guard+rebuild), model FK->nazwa_szablonu + migracja, drop_dbtemplate odsprzegniecie, adminy, usuniecie fixture, newsfragment. Spec: synchroniczne czyszczenie cache (zamiast on_commit) — spojne z planem. Refs #329 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ibliograficzny-dysk-zamiast-dbtemplates.md | 871 ++++++++++++++++++ ...aficzny-dysk-zamiast-dbtemplates-design.md | 9 +- 2 files changed, 876 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md diff --git a/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md b/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md new file mode 100644 index 000000000..4b7cf28da --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md @@ -0,0 +1,871 @@ +# Opis bibliograficzny z dysku zamiast dbtemplates — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wyrwać `opis_bibliograficzny.html` z django-dbtemplates — dysk staje się jedynym źródłem prawdy; mapowanie per-model przeżywa jako nazwa pliku; migracja kasuje wiersz i przebudowuje denorm (FD#329 naprawia się samo przy podbiciu wydania); przy okazji naprawić mylący `compare_dbtemplates`. + +**Architecture:** Model `SzablonDlaOpisuBibliograficznego.template` (FK→`dbtemplates.Template`, PROTECT) zamieniony na `nazwa_szablonu` (CharField). Render dalej przez `get_template(name)` — po skasowaniu wiersza dbtemplates dysk wygrywa. Współdzielona funkcja `usun_dbtemplate_i_przebuduj(name, modele, *, flush)` (guard dysk-existence + log treści + delete + czyszczenie cache + rebuild denorma) używana przez migrację (async flush) i `drop_dbtemplate` (sync flush). Nowy helper `disk_template_source(name)` czyta źródło z dysku z pominięciem loadera dbtemplates — używany przez naprawę `compare_dbtemplates`. + +**Tech Stack:** Django 5.2, django-dbtemplates, django-denorm (async przez kolejkę `denorm`), pytest + pytest-django + model_bakery, towncrier. + +## Global Constraints + +- **Wszystkie polecenia Pythona przez `uv run`** (nigdy goły `python`). +- **Max 88 znaków/linia** (ruff). +- **NIE modyfikować istniejących migracji** — tylko nowy plik migracji. +- **Testy: pytest, bez klas**, `@pytest.mark.django_db`, `model_bakery.baker.make`. +- **`pre-commit` bez argumentów**; ruff bez `--fix` — poprawki ręczne (Edit). +- Praca w worktree `~/Programowanie/bpp-fix-fd329-opis-z-dysku` (gałąź `fix-fd329-opis-z-dysku`). +- Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- Po scaleniu (osobno, nie w tym planie): `make baseline-update`. + +--- + +### Task 1: Helper `disk_template_source` — źródło szablonu z dysku (bez dbtemplates) + +**Files:** +- Create: `src/bpp/util/dbtemplates_disk.py` +- Test: `src/bpp/tests/test_dbtemplates_disk.py` + +**Interfaces:** +- Produces: `disk_template_source(name: str) -> str | None` — źródło szablonu `name` z dysku (filesystem + app_directories), z pominięciem loadera dbtemplates; `None` gdy brak pliku na dysku. + +- [ ] **Step 1: Write the failing test** + +```python +# src/bpp/tests/test_dbtemplates_disk.py +from bpp.util.dbtemplates_disk import disk_template_source + + +def test_disk_template_source_zwraca_zrodlo_z_dysku(): + # opis_bibliograficzny.html na pewno jest w src/bpp/templates/ (app dir) + src = disk_template_source("opis_bibliograficzny.html") + assert src is not None + # gałąź #329 z dysku (dowód, że to DYSK, nie stary wiersz DB): + assert "book_title" in src + + +def test_disk_template_source_none_gdy_brak_pliku(): + assert disk_template_source("nie-ma-takiego-pliku-xyz.html") is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_dbtemplates_disk.py -v` +Expected: FAIL — `ModuleNotFoundError: bpp.util.dbtemplates_disk`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/bpp/util/dbtemplates_disk.py +"""Ładowanie ŹRÓDŁA szablonu z dysku z pominięciem loadera dbtemplates. + +``get_template`` z Django idzie łańcuchem loaderów, w którym dbtemplates stoi +pierwszy — więc dla nazwy istniejącej w bazie zwraca treść z DB, nie z dysku. +Ten helper konstruuje własny ``Engine`` wyłącznie z loaderami dyskowymi, żeby +odpowiedzieć na pytanie „co jest NA DYSKU pod tą nazwą".""" + +from django.conf import settings +from django.template import Engine, TemplateDoesNotExist + +_disk_engine = None + + +def _get_disk_engine(): + global _disk_engine + if _disk_engine is None: + dirs = [] + for cfg in settings.TEMPLATES: + if cfg.get("BACKEND", "").endswith("DjangoTemplates"): + dirs = list(cfg.get("DIRS", [])) + break + # Jawne loadery dyskowe (bez cached, bez dbtemplates) — świeży odczyt + # z dysku przy każdym wywołaniu. NIE 'loaders=[...] + app_dirs=True' + # (ImproperlyConfigured w Dj5.2). + _disk_engine = Engine( + dirs=dirs, + loaders=[ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + ) + return _disk_engine + + +def disk_template_source(name): + try: + template = _get_disk_engine().get_template(name) + except TemplateDoesNotExist: + return None + return template.source +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/bpp/tests/test_dbtemplates_disk.py -v` +Expected: PASS (oba testy). + +- [ ] **Step 5: Commit** + +```bash +git add src/bpp/util/dbtemplates_disk.py src/bpp/tests/test_dbtemplates_disk.py +git commit -m "feat(dbtemplates): helper disk_template_source — źródło z dysku bez dbtemplates (#329)" +``` + +--- + +### Task 2: Naprawa `compare_dbtemplates` (czytał DB zamiast dysku) + +**Files:** +- Modify: `src/bpp/management/commands/compare_dbtemplates.py:322-377` (metoda `get_filesystem_template_content`) +- Test: `src/bpp/tests/test_management_commands_compare_dbtemplates.py` (dopisz repro-test) + +**Interfaces:** +- Consumes: `disk_template_source` (Task 1). + +- [ ] **Step 1: Write the failing repro test** + +Dopisz na końcu `src/bpp/tests/test_management_commands_compare_dbtemplates.py`: + +```python +import pytest +from dbtemplates.models import Template +from django.core.management import call_command +from io import StringIO + + +@pytest.mark.django_db +def test_compare_wykrywa_rozjazd_db_vs_dysk(): + """Regresja: dawniej compare czytał 'dysk' przez get_template (loader + dbtemplates pierwszy) => DB-vs-DB => zawsze 'match'. Teraz czyta faktyczny + dysk => rozjazd MUSI być widoczny.""" + Template.objects.update_or_create( + name="opis_bibliograficzny.html", + defaults={"content": "TRESC-DB-INNA-NIZ-DYSK"}, + ) + out = StringIO() + call_command("compare_dbtemplates", "opis_bibliograficzny.html", stdout=out) + output = out.getvalue() + assert "match" not in output.lower() + assert "TRESC-DB-INNA-NIZ-DYSK" in output +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_management_commands_compare_dbtemplates.py::test_compare_wykrywa_rozjazd_db_vs_dysk -v` +Expected: FAIL — obecny kod raportuje „All templates match" (czyta treść z DB po obu stronach). + +- [ ] **Step 3: Zastąp `get_filesystem_template_content`** + +Zamień CAŁĄ metodę `get_filesystem_template_content` (linie ~322-377) na: + +```python + def get_filesystem_template_content(self, template_name): + """Źródło szablonu z DYSKU (z pominięciem loadera dbtemplates). + + Dawniej używała ``get_template()``, który idzie łańcuchem loaderów z + dbtemplates na pierwszym miejscu — więc dla nazwy istniejącej w bazie + zwracała treść z DB i porównanie było DB-vs-DB (zawsze 'match').""" + from bpp.util.dbtemplates_disk import disk_template_source + + return disk_template_source(template_name) +``` + +Usuń teraz-nieużywane importy w nagłówku pliku, jeśli zostały osierocone: +`from django.template import TemplateDoesNotExist`, `from pathlib import Path`, +`from bpp.util import zaloguj_polkniety_wyjatek`, `import logging`, `import sys` +(sprawdź, czy `sys`/`logging` nie są używane gdzie indziej w pliku — `sys.stdout.isatty()` +w `_colorize` używa `sys`; zostaw `import sys`). Uruchom ruff, poprawki ręcznie. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/bpp/tests/test_management_commands_compare_dbtemplates.py -v` +Expected: PASS (nowy repro + istniejące testy pliku). + +- [ ] **Step 5: Commit** + +```bash +git add src/bpp/management/commands/compare_dbtemplates.py src/bpp/tests/test_management_commands_compare_dbtemplates.py +git commit -m "fix(dbtemplates): compare_dbtemplates czytał DB zamiast dysku (#329)" +``` + +--- + +### Task 3: Współdzielona funkcja `usun_dbtemplate_i_przebuduj` + +**Files:** +- Modify: `src/bpp/dbtemplates_sync.py` (dopisz funkcję) +- Test: `src/bpp/tests/test_dbtemplates_sync.py` + +**Interfaces:** +- Consumes: `disk_template_source` (Task 1); `wyczysc_cache_dbtemplate` (istnieje w tym pliku); `rebuild_instances_of_models` (`bpp.util`). +- Produces: `usun_dbtemplate_i_przebuduj(name: str, modele: list, *, flush: bool = False, log=None) -> bool` — GUARD: kasuje wiersz dbtemplate `name` tylko gdy `disk_template_source(name) is not None` (zwraca `False` gdy pominięto). Przed delete loguje treść. Po delete czyści cache dbtemplates (synchronicznie). Oznacza `modele` dirty; `flush=True` → synchroniczny `denorms.flush()`. + +- [ ] **Step 1: Write the failing tests** + +```python +# src/bpp/tests/test_dbtemplates_sync.py +import pytest +from dbtemplates.models import Template + +from bpp.dbtemplates_sync import usun_dbtemplate_i_przebuduj +from bpp.models import Wydawnictwo_Zwarte +from pbn_api.models import Publication + + +@pytest.mark.django_db +def test_usun_guard_nie_kasuje_gdy_brak_pliku_na_dysku(): + """DB-only custom bez pliku na dysku — NIE kasować (inaczej dyndająca + nazwa -> TemplateDoesNotExist -> opis wybucha przy flushu denorma).""" + Template.objects.create(name="tylko-w-bazie-xyz.html", content="treść") + wynik = usun_dbtemplate_i_przebuduj("tylko-w-bazie-xyz.html", []) + assert wynik is False + assert Template.objects.filter(name="tylko-w-bazie-xyz.html").exists() + + +@pytest.mark.django_db +def test_usun_kasuje_gdy_plik_na_dysku_i_odswieza_opis(wydawnictwo_zwarte): + """opis_bibliograficzny.html JEST na dysku -> kasuj wiersz; po rebuildzie + opis pokazuje rodzica z PBN object.book (dowód naprawy FD#329).""" + pbn_pub = Publication.objects.create( + mongoId="sync-rozdzial", + versions=[ + {"current": True, "object": {"book": {"title": "Rodzic Z Dysku"}}} + ], + ) + wydawnictwo_zwarte.pbn_uid = pbn_pub + wydawnictwo_zwarte.wydawnictwo_nadrzedne = None + wydawnictwo_zwarte.wydawnictwo_nadrzedne_w_pbn = None + wydawnictwo_zwarte.informacje = "" + wydawnictwo_zwarte.zrodlo = None + wydawnictwo_zwarte.save() + + Template.objects.update_or_create( + name="opis_bibliograficzny.html", + defaults={"content": "STARY-SZABLON-MARKER"}, + ) + + wynik = usun_dbtemplate_i_przebuduj( + "opis_bibliograficzny.html", [Wydawnictwo_Zwarte], flush=True + ) + + assert wynik is True + assert not Template.objects.filter(name="opis_bibliograficzny.html").exists() + wydawnictwo_zwarte.refresh_from_db() + assert "W: Rodzic Z Dysku." in wydawnictwo_zwarte.opis_bibliograficzny_cache + assert "STARY-SZABLON-MARKER" not in wydawnictwo_zwarte.opis_bibliograficzny_cache +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest src/bpp/tests/test_dbtemplates_sync.py -v` +Expected: FAIL — `ImportError: cannot import name 'usun_dbtemplate_i_przebuduj'`. + +- [ ] **Step 3: Dopisz funkcję do `src/bpp/dbtemplates_sync.py`** + +```python +def usun_dbtemplate_i_przebuduj(name, modele, *, flush=False, log=None): + """Skasuj wiersz dbtemplate ``name`` (render spadnie na dysk) i oznacz + ``modele`` do przebudowy denorma. Współdzielone przez migrację i komendę + ``drop_dbtemplate``. + + GUARD: kasuje TYLKO gdy nazwa ma odpowiednik na dysku + (``disk_template_source(name) is not None``). Wiersz DB-only bez pliku na + dysku zostaje nietknięty — inaczej ``nazwa_szablonu`` zostałaby dyndająca i + ``get_template`` rzucałby ``TemplateDoesNotExist`` przy każdym renderze / + flushu denorma. Zwraca ``False`` gdy pominięto (guard), ``True`` gdy + skasowano lub wiersza nie było mimo pliku na dysku. + + ``flush=True`` → synchroniczny ``denorms.flush()`` (komenda deployowa chce + odświeżyć od ręki); ``flush=False`` → tylko oznaczenie dirty (async kolejka + ``denorm`` dokończy — użycie w migracji).""" + from dbtemplates.models import Template + + from bpp.util import rebuild_instances_of_models + from bpp.util.dbtemplates_disk import disk_template_source + + log = log or (lambda msg: None) + + if disk_template_source(name) is None: + log( + f"[guard] '{name}' nie ma pliku na dysku — NIE kasuję wiersza " + f"dbtemplate (zostawiam, by nie zdyndać nazwy_szablonu)." + ) + return False + + tpl = Template.objects.filter(name=name).first() + if tpl is not None: + log(f"[usuwam dbtemplate '{name}'] backup treści:\n{tpl.content}") + tpl.delete() + # Cache dbtemplates nie znika sam (delete modelem historycznym nie + # odpala sygnałów); czyścimy synchronicznie jak drop_dbtemplate. + wyczysc_cache_dbtemplate(name) + + if modele: + rebuild_instances_of_models(list(modele)) + if flush: + from denorm import denorms + + denorms.flush() + + return True +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest src/bpp/tests/test_dbtemplates_sync.py -v` +Expected: PASS (oba). + +- [ ] **Step 5: Commit** + +```bash +git add src/bpp/dbtemplates_sync.py src/bpp/tests/test_dbtemplates_sync.py +git commit -m "feat(dbtemplates): usun_dbtemplate_i_przebuduj — guard + rebuild denorma (#329)" +``` + +--- + +### Task 4: Model `SzablonDlaOpisuBibliograficznego` — FK→`nazwa_szablonu` + migracja + +**Files:** +- Modify: `src/bpp/models/szablondlaopisubibliograficznego.py` (całość — pola, manager, render, `__str__`, `clean`) +- Create: `src/bpp/migrations/0468_szablon_nazwa_szablonu.py` +- Modify: `src/bpp/tests/test_opis_bibliograficzny.py` (usuń `_sync`; `template=`→`nazwa_szablonu=`) + +**Interfaces:** +- Consumes: `usun_dbtemplate_i_przebuduj` (Task 3). +- Produces: pole `nazwa_szablonu` (CharField); `manager.get_for_model(model) -> str | None`; `manager.get_models_for_szablon(nazwa) -> list`; `manager.all_templated_models`; instancja: `.render(praca)`, `.get_models_for_this_szablon()`, `.clean()`. + +- [ ] **Step 1: Napisz/zmigruj testy (failing)** + +W `src/bpp/tests/test_opis_bibliograficzny.py`: +1. Usuń funkcję `_sync_opis_template_z_dysku` (linie ~16-31) i jej wywołanie (linia ~145). +2. Zamień wszystkie `template=` w `SzablonDlaOpisuBibliograficznego.objects.create(...)` / `sz.template = ` na `nazwa_szablonu=.name` / `sz.nazwa_szablonu = .name`. Konkretnie: + - linia ~47: `create(nazwa_szablonu=test_template.name)` + - linia ~50: `create(nazwa_szablonu=test_template.name)` + - linia ~63: `sz.nazwa_szablonu = test_template.name` + - linia ~66: `create(nazwa_szablonu=test_template.name)` + - linia ~74: `nazwa_szablonu=second_template.name` + (Wiersze dbtemplates `test`/`2nd` nadal są tworzone — loader dbtemplates serwuje je po nazwie, więc `opis_bibliograficzny()` zwróci ich treść.) +3. Dopisz test na `clean()`: + +```python +@pytest.mark.django_db +def test_clean_odrzuca_nieistniejacy_szablon(): + from django.core.exceptions import ValidationError + + sz = SzablonDlaOpisuBibliograficznego(nazwa_szablonu="nie-istnieje-xyz.html") + with pytest.raises(ValidationError): + sz.clean() + + +@pytest.mark.django_db +def test_clean_przepuszcza_szablon_z_dysku(): + sz = SzablonDlaOpisuBibliograficznego( + nazwa_szablonu="opis_bibliograficzny.html" + ) + sz.clean() # nie rzuca +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest src/bpp/tests/test_opis_bibliograficzny.py -v` +Expected: FAIL — `nazwa_szablonu` nie istnieje na modelu / brak kolumny (migracji jeszcze nie ma). + +- [ ] **Step 3: Zmień model** `src/bpp/models/szablondlaopisubibliograficznego.py` + +Zamień zawartość na (zachowując importy + dopisując `ValidationError`, `TemplateDoesNotExist`): + +```python +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.template import TemplateDoesNotExist +from django.template.loader import get_template +from django.utils.functional import cached_property + + +class SzablonDlaOpisuBibliograficznegoManager(models.Manager): + def get_for_model(self, model): + model = ContentType.objects.get_for_model(model) + try: + return self.get(model=model).nazwa_szablonu + except SzablonDlaOpisuBibliograficznego.DoesNotExist: + try: + return self.get(model=None).nazwa_szablonu + except SzablonDlaOpisuBibliograficznego.DoesNotExist: + return + + @cached_property + def all_templated_models(self): + from bpp.models.patent import Patent + from bpp.models.praca_doktorska import Praca_Doktorska + from bpp.models.praca_habilitacyjna import Praca_Habilitacyjna + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle + from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte + + return [ + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, + Praca_Doktorska, + Praca_Habilitacyjna, + Patent, + ] + + def get_models_for_szablon(self, nazwa_szablonu): + """Lista modeli mapowanych na dany szablon (po nazwie).""" + res = list( + self.filter(nazwa_szablonu=nazwa_szablonu) + .values_list("model", flat=True) + .distinct() + ) + if None in res: + return self.all_templated_models + return [ContentType.objects.get_for_id(id).model_class() for id in res] + + +class SzablonDlaOpisuBibliograficznego(models.Model): + objects = SzablonDlaOpisuBibliograficznegoManager() + + model = models.OneToOneField( + "contenttypes.ContentType", + on_delete=models.CASCADE, + limit_choices_to=models.Q( + app_label="bpp", + model__in=[ + "wydawnictwo_ciagle", + "wydawnictwo_zwarte", + "praca_doktorska", + "praca_habilitacyjna", + "patent", + ], + ), + null=True, + blank=True, + ) + + nazwa_szablonu = models.CharField( + max_length=255, + default="opis_bibliograficzny.html", + help_text=( + "Nazwa szablonu Django ładowanego z dysku, " + "np. opis_bibliograficzny.html" + ), + ) + + def __str__(self): + if self.model_id is not None: + return ( + f"Powiązanie szablonu {self.nazwa_szablonu} z modelem {self.model}" + ) + return f"Powiązanie szablonu {self.nazwa_szablonu} z każdym modelem" + + class Meta: + verbose_name = "powiązanie szablonu dla opisu bibliograficznego" + verbose_name_plural = "powiązania szablonów dla opisu bibliograficznego" + + def clean(self): + try: + get_template(self.nazwa_szablonu) + except TemplateDoesNotExist: + raise ValidationError( + { + "nazwa_szablonu": ( + f"Szablon '{self.nazwa_szablonu}' nie istnieje " + f"(ani na dysku, ani w dbtemplates)." + ) + } + ) + + def render(self, praca): + template = get_template(self.nazwa_szablonu) + + return ( + template.render( + dict(praca=praca, autorzy=praca.autorzy_set.all().select_related()) + ) + .replace("\r\n", "") + .replace("\n", "") + .replace(".[", ". [") + .replace(" ", " ") + .replace(" ", " ") + .replace(" ", " ") + .replace(" ", " ") + .replace(" ", " ") + .replace(" , ", ", ") + .replace(" . ", ". ") + .replace(". . ", ". ") + .replace(". , ", ". ") + .replace("., ", ". ") + .replace(" .", ".") + ) + + def get_models_for_this_szablon(self): + return SzablonDlaOpisuBibliograficznego.objects.get_models_for_szablon( + self.nazwa_szablonu + ) +``` + +- [ ] **Step 4: Utwórz migrację** `src/bpp/migrations/0468_szablon_nazwa_szablonu.py` + +```python +from django.db import migrations, models + + +def backfill_nazwa(apps, schema_editor): + Szablon = apps.get_model("bpp", "SzablonDlaOpisuBibliograficznego") + Template = apps.get_model("dbtemplates", "Template") + for row in Szablon.objects.all(): + if row.template_id: + row.nazwa_szablonu = Template.objects.get(pk=row.template_id).name + row.save(update_fields=["nazwa_szablonu"]) + + +def purge_opis_dbtemplate(apps, schema_editor): + # Konkretne klasy modeli (denorm rebuild jak w drop_dbtemplate). Import w + # ciele funkcji — bezpieczny w tym punkcie migracji. + from bpp.dbtemplates_sync import usun_dbtemplate_i_przebuduj + from bpp.models.patent import Patent + from bpp.models.praca_doktorska import Praca_Doktorska + from bpp.models.praca_habilitacyjna import Praca_Habilitacyjna + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle + from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte + + modele = [ + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, + Praca_Doktorska, + Praca_Habilitacyjna, + Patent, + ] + Szablon = apps.get_model("bpp", "SzablonDlaOpisuBibliograficznego") + nazwy = {n for n in Szablon.objects.values_list("nazwa_szablonu", flat=True) if n} + for name in sorted(nazwy): + # guard (dysk) + log + delete + czyszczenie cache + oznaczenie dirty. + # flush=False -> async kolejka denorm dokończy (migracja nieblokująca). + usun_dbtemplate_i_przebuduj(name, modele, flush=False, log=print) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("bpp", "0467_seed_crossref_mapper_rows"), + ("dbtemplates", "0002_alter_template_creation_date_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="szablondlaopisubibliograficznego", + name="nazwa_szablonu", + field=models.CharField( + default="opis_bibliograficzny.html", max_length=255 + ), + ), + migrations.RunPython(backfill_nazwa, migrations.RunPython.noop), + migrations.RemoveField( + model_name="szablondlaopisubibliograficznego", + name="template", + ), + migrations.RunPython(purge_opis_dbtemplate, migrations.RunPython.noop), + ] +``` + +- [ ] **Step 5: Sanity — brak dryfu migracji poza naszą** + +Run: `uv run python src/manage.py makemigrations --check --dry-run bpp` +Expected: brak nowych migracji do wygenerowania (nasz plik pokrywa zmianę modelu). Jeśli Django chce dogenerować `AlterField`/help_text — dopisz brakującą operację do 0468. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `uv run pytest src/bpp/tests/test_opis_bibliograficzny.py -v` +Expected: PASS (render #329 bez `_sync`; `clean()`; rozne_opisy; nulltest). + +- [ ] **Step 7: Commit** + +```bash +git add src/bpp/models/szablondlaopisubibliograficznego.py \ + src/bpp/migrations/0468_szablon_nazwa_szablonu.py \ + src/bpp/tests/test_opis_bibliograficzny.py +git commit -m "feat(opis): SzablonDlaOpisu FK->nazwa_szablonu + migracja kasująca wiersz (#329)" +``` + +--- + +### Task 5: `drop_dbtemplate` — odsprzęgnięcie od FK + współdzielona funkcja + +**Files:** +- Modify: `src/bpp/management/commands/drop_dbtemplate.py` +- Modify: `src/bpp/tests/test_management_commands_drop_dbtemplate.py` + +**Interfaces:** +- Consumes: `usun_dbtemplate_i_przebuduj` (Task 3); `get_models_for_szablon` (Task 4). + +- [ ] **Step 1: Zmigruj testy (failing)** + +W `src/bpp/tests/test_management_commands_drop_dbtemplate.py`: +1. `test_drop_dbtemplate_usuwa_wiersz_i_chroniacy_szablon` — po zmianie `SzablonDlaOpisu` NIE jest już kasowany (nie ma FK). Przepisz asercje: + +```python +@pytest.mark.django_db +def test_drop_dbtemplate_usuwa_wiersz_zostawia_mapowanie(): + """Po odsprzęgnięciu: kasujemy wiersz dbtemplate, ale wpis SzablonDlaOpisu + (mapowanie po nazwie) ZOSTAJE — jego nazwa_szablonu rozwiązuje się z dysku.""" + Template.objects.update_or_create( + name="opis_bibliograficzny.html", defaults={"content": "stara treść"} + ) + SzablonDlaOpisuBibliograficznego.objects.get_or_create( + model=None, defaults={"nazwa_szablonu": "opis_bibliograficzny.html"} + ) + + call_command("drop_dbtemplate", "opis_bibliograficzny.html", "--skip-rebuild") + + assert not Template.objects.filter(name="opis_bibliograficzny.html").exists() + assert SzablonDlaOpisuBibliograficznego.objects.filter( + nazwa_szablonu="opis_bibliograficzny.html" + ).exists() +``` + +2. `test_drop_dbtemplate_przebudowuje_cache_z_dysku` — zamień + `get_or_create(model=None, defaults={"template": tpl})` na + `get_or_create(model=None, defaults={"nazwa_szablonu": "opis_bibliograficzny.html"})`. + Reszta (asercje na cache) bez zmian. +3. `test_drop_dbtemplate_idempotentne_gdy_brak_wiersza` — bez zmian. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest src/bpp/tests/test_management_commands_drop_dbtemplate.py -v` +Expected: FAIL — obecna komenda używa `filter(template=...)` / `get_models_for_template`, których już nie ma po Task 4 (albo asercje nie pasują). + +- [ ] **Step 3: Przepisz `drop_dbtemplate.py`** + +Zamień ciało `handle` (i importy) na wersję używającą współdzielonej funkcji: + +```python +"""Usuń wiersz(e) dbtemplate z bazy (render spada na plik z dysku) i przebuduj +zależny ``opis_bibliograficzny_cache``. + +Po wyrwaniu opisu z dbtemplates (#329) ``SzablonDlaOpisuBibliograficznego`` nie +ma już FK do ``Template`` — trzyma tylko ``nazwa_szablonu``. Komenda przestała +więc kasować powiązania; kasuje sam wiersz dbtemplate (z guardem dysk-existence) +i przebudowuje denorm dla modeli mapowanych na tę nazwę.""" + +from django.core.management.base import BaseCommand +from django.db import transaction + +from bpp.dbtemplates_sync import usun_dbtemplate_i_przebuduj +from bpp.models.szablondlaopisubibliograficznego import ( + SzablonDlaOpisuBibliograficznego, +) + + +class Command(BaseCommand): + help = ( + "Usuwa wiersz(e) dbtemplate z bazy (render spada na plik z dysku) i " + "przebudowuje zależny opis_bibliograficzny_cache." + ) + + def add_arguments(self, parser): + parser.add_argument( + "template_names", + nargs="+", + help="Nazwy szablonów do usunięcia, np. opis_bibliograficzny.html", + ) + parser.add_argument( + "--skip-rebuild", + action="store_true", + help="Nie przebudowuj opis_bibliograficzny_cache (sam usuń wiersze).", + ) + + @transaction.atomic + def handle(self, *args, **options): + for name in options["template_names"]: + modele = ( + [] + if options["skip_rebuild"] + else SzablonDlaOpisuBibliograficznego.objects.get_models_for_szablon( + name + ) + ) + usunieto = usun_dbtemplate_i_przebuduj( + name, modele, flush=True, log=self.stdout.write + ) + if usunieto: + self.stdout.write(self.style.SUCCESS(f"Przetworzono '{name}'.")) + else: + self.stderr.write( + self.style.WARNING( + f"'{name}' nie ma pliku na dysku — pominięto (guard)." + ) + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest src/bpp/tests/test_management_commands_drop_dbtemplate.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/bpp/management/commands/drop_dbtemplate.py src/bpp/tests/test_management_commands_drop_dbtemplate.py +git commit -m "refactor(drop_dbtemplate): odsprzęgnięcie od FK + współdzielona funkcja (#329)" +``` + +--- + +### Task 6: Admin — `list_display` + `template_updated` + +**Files:** +- Modify: `src/bpp/admin/szablondlaopisubibliograficznego.py:9` +- Modify: `src/bpp/admin/templates.py:71` +- Test: `src/bpp/tests/test_admin/test_templateadmin.py` (zmigruj `template=`; dopisz smoke changelist) + +**Interfaces:** +- Consumes: `get_models_for_szablon` (Task 4). + +- [ ] **Step 1: Zmigruj/napisz test (failing)** + +W `src/bpp/tests/test_admin/test_templateadmin.py` zamień `create(template=)` na +`create(nazwa_szablonu=.name)` (linie ~22, 62, 96 wg wcześniejszego sweepu — potwierdź `grep -n "template=" src/bpp/tests/test_admin/test_templateadmin.py`). Dopisz smoke: + +```python +@pytest.mark.django_db +def test_szablon_admin_changelist_dziala(admin_client): + from bpp.models.szablondlaopisubibliograficznego import ( + SzablonDlaOpisuBibliograficznego, + ) + + SzablonDlaOpisuBibliograficznego.objects.get_or_create( + model=None, defaults={"nazwa_szablonu": "opis_bibliograficzny.html"} + ) + resp = admin_client.get( + "/admin/bpp/szablondlaopisubibliograficznego/" + ) + assert resp.status_code == 200 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_admin/test_templateadmin.py -v` +Expected: FAIL — `list_display=["model","template"]` odwołuje się do usuniętego pola → changelist 500 / błąd systemowy. + +- [ ] **Step 3: Popraw adminy** + +W `src/bpp/admin/szablondlaopisubibliograficznego.py:9`: + +```python + list_display = ["model", "nazwa_szablonu"] +``` + +W `src/bpp/admin/templates.py:71` (metoda `template_updated`) zamień: + +```python + modele = SzablonDlaOpisuBibliograficznego.objects.get_models_for_template(obj) +``` + +na: + +```python + modele = SzablonDlaOpisuBibliograficznego.objects.get_models_for_szablon( + obj.name + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/bpp/tests/test_admin/test_templateadmin.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/bpp/admin/szablondlaopisubibliograficznego.py src/bpp/admin/templates.py src/bpp/tests/test_admin/test_templateadmin.py +git commit -m "fix(admin): SzablonDlaOpisu list_display + template_updated na nazwę (#329)" +``` + +--- + +### Task 7: Usuń martwą fixture `szablony` + +**Files:** +- Modify: `src/fixtures/conftest.py:66-99` + +- [ ] **Step 1: Potwierdź brak konsumentów** + +Run: `git grep -nw "szablony" -- 'src/**/*.py' | grep -v "def szablony"` +Expected: brak wyników (fixture nieużywana). + +- [ ] **Step 2: Usuń fixture** + +Usuń całą definicję `@pytest.fixture def szablony(): ...` (linie ~66-99) z `src/fixtures/conftest.py`. + +- [ ] **Step 3: Sanity — kolekcja testów nie pada** + +Run: `uv run pytest src/bpp/tests/test_opis_bibliograficzny.py --collect-only -q` +Expected: kolekcja OK, brak błędu o brakującej fixture. + +- [ ] **Step 4: Commit** + +```bash +git add src/fixtures/conftest.py +git commit -m "chore(tests): usuń nieużywaną fixture szablony (#329)" +``` + +--- + +### Task 8: Newsfragment + weryfikacja całości + +**Files:** +- Create: `src/bpp/newsfragments/+fd329.bugfix.md` + +- [ ] **Step 1: Newsfragment (orphan `+`, bo FD, nie GH issue)** + +```markdown +Rozdziały z wydawnictwem nadrzędnym pobranym z PBN pokazują teraz to +wydawnictwo w opisie bibliograficznym ("W: tytuł"). Opis bibliograficzny +przestał być trzymany w bazie (dbtemplates) — jest brany wprost z aktualnego +szablonu na dysku, więc poprawki szablonu działają od razu po aktualizacji, +bez ręcznej synchronizacji (FD#329). +``` + +- [ ] **Step 2: ruff + pre-commit (poprawki RĘCZNE)** + +Run: `uv run ruff format src/bpp && uv run ruff check src/bpp` +Run: `pre-commit` +Expected: czysto. Błędy poprawiaj ręcznie (Edit), NIE `--fix`. + +- [ ] **Step 3: Testy dotkniętych obszarów** + +Run: +``` +uv run pytest src/bpp/tests/test_dbtemplates_disk.py \ + src/bpp/tests/test_dbtemplates_sync.py \ + src/bpp/tests/test_opis_bibliograficzny.py \ + src/bpp/tests/test_management_commands_drop_dbtemplate.py \ + src/bpp/tests/test_management_commands_compare_dbtemplates.py \ + src/bpp/tests/test_admin/test_templateadmin.py -v +``` +Expected: wszystko PASS. + +- [ ] **Step 4: Migracja stosuje się na czysto (walidacja jak baseline-update)** + +Run: `uv run python src/manage.py migrate bpp 0468 --plan` +Expected: plan pokazuje 0468 do zastosowania (bez błędu importu / zależności). + +- [ ] **Step 5: Commit** + +```bash +git add src/bpp/newsfragments/+fd329.bugfix.md +git commit -m "docs(newsfragment): opis nadrzędnego z PBN + opis z dysku (FD#329)" +``` + +--- + +## Self-Review (wykonane) + +**Spec coverage:** §1 model→Task 4; §2 helper→Task 1; §3 compare→Task 2; §4 migracja+guard+denorm→Task 3+4; §5 drop_dbtemplate→Task 5; §6 konsumenci (admin/templates, list_display, testy, fixture, `_sync`)→Task 4/6/7; §Testy→rozłożone; newsfragment→Task 8. Brak luk. + +**Placeholder scan:** brak TBD/„handle edge cases"/„similar to" — każdy krok ma realny kod/komendę. + +**Type consistency:** `nazwa_szablonu` (pole), `disk_template_source(name)->str|None`, `usun_dbtemplate_i_przebuduj(name, modele, *, flush, log)->bool`, `get_models_for_szablon(nazwa)->list` — spójne między Task 1/3/4/5/6. diff --git a/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md b/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md index f23b51b77..df8649a05 100644 --- a/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md +++ b/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md @@ -161,10 +161,11 @@ W aplikacji `bpp`, jako nowa migracja na aktualnym head (zależność od migracj - Przed skasowaniem: **zaloguj pełną treść** wiersza do outputu migracji (odzyskiwalność; `DBTEMPLATES_USE_REVERSION` dodatkowo trzyma historię). - `wyczysc_cache_dbtemplate(name)` (import z `bpp.dbtemplates_sync`) — wyczyść - cache Redis dbtemplates (P2: delete przez model historyczny nie odpala - sygnałów). Wołaj przez `transaction.on_commit(...)`, nie w środku - transakcji — inaczej okno wyścigu, gdzie żywy worker re-cache'uje starą - treść z jeszcze-widocznego wiersza (bounded TTL 300 s, ale on_commit czystsze). + cache dbtemplates (P2: delete przez model historyczny nie odpala sygnałów). + Wołane **synchronicznie**, jak w `drop_dbtemplate` (proste, testowalne bez + capture-on-commit). Okno wyścigu „żywy worker re-cache'uje starą treść z + jeszcze-widocznego wiersza" jest bounded TTL cache (≤300 s) — akceptowalne + (P2 DROBNY); nie komplikujemy `transaction.on_commit`. - `rebuild_instances_of_models(<5 modeli publikacji>)` — **oznacz dirty** (INSERT do `DirtyInstance`); **NIE** wołaj synchronicznego `denorms.flush()` (decyzja 1(a) — flush robi async kolejka `denorm`; migracja szybka, From c3268bf46388aaf99e812226f91fcfde94718d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 00:12:43 +0200 Subject: [PATCH 05/15] =?UTF-8?q?feat(dbtemplates):=20helper=20disk=5Ftemp?= =?UTF-8?q?late=5Fsource=20=E2=80=94=20=C5=BAr=C3=B3d=C5=82o=20z=20dysku?= =?UTF-8?q?=20bez=20dbtemplates=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kopiuje libraries/builtins z domyślnego Engine — surowy Engine nie zna custom tag-libów ({% load prace %} w opisie). Poprawka planu wzgledem pierwotnego kodu. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ibliograficzny-dysk-zamiast-dbtemplates.md | 6 +++ src/bpp/tests/test_dbtemplates_disk.py | 13 ++++++ src/bpp/util/dbtemplates_disk.py | 46 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 src/bpp/tests/test_dbtemplates_disk.py create mode 100644 src/bpp/util/dbtemplates_disk.py diff --git a/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md b/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md index 4b7cf28da..52c9001d7 100644 --- a/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md +++ b/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md @@ -82,12 +82,18 @@ def _get_disk_engine(): # Jawne loadery dyskowe (bez cached, bez dbtemplates) — świeży odczyt # z dysku przy każdym wywołaniu. NIE 'loaders=[...] + app_dirs=True' # (ImproperlyConfigured w Dj5.2). + # libraries/builtins skopiowane z domyślnego Engine — inaczej surowy + # Engine nie zna custom tag-libów ({% load prace %} w opisie), bo tylko + # backend DjangoTemplates auto-odkrywa je z INSTALLED_APPS. + default = Engine.get_default() _disk_engine = Engine( dirs=dirs, loaders=[ "django.template.loaders.filesystem.Loader", "django.template.loaders.app_directories.Loader", ], + libraries=default.libraries, + builtins=default.builtins, ) return _disk_engine diff --git a/src/bpp/tests/test_dbtemplates_disk.py b/src/bpp/tests/test_dbtemplates_disk.py new file mode 100644 index 000000000..0f6f9f1a3 --- /dev/null +++ b/src/bpp/tests/test_dbtemplates_disk.py @@ -0,0 +1,13 @@ +from bpp.util.dbtemplates_disk import disk_template_source + + +def test_disk_template_source_zwraca_zrodlo_z_dysku(): + # opis_bibliograficzny.html na pewno jest w src/bpp/templates/ (app dir) + src = disk_template_source("opis_bibliograficzny.html") + assert src is not None + # gałąź #329 z dysku (dowód, że to DYSK, nie stary wiersz DB): + assert "book_title" in src + + +def test_disk_template_source_none_gdy_brak_pliku(): + assert disk_template_source("nie-ma-takiego-pliku-xyz.html") is None diff --git a/src/bpp/util/dbtemplates_disk.py b/src/bpp/util/dbtemplates_disk.py new file mode 100644 index 000000000..ed604f7c1 --- /dev/null +++ b/src/bpp/util/dbtemplates_disk.py @@ -0,0 +1,46 @@ +"""Ładowanie ŹRÓDŁA szablonu z dysku z pominięciem loadera dbtemplates. + +``get_template`` z Django idzie łańcuchem loaderów, w którym dbtemplates stoi +pierwszy — więc dla nazwy istniejącej w bazie zwraca treść z DB, nie z dysku. +Ten helper konstruuje własny ``Engine`` wyłącznie z loaderami dyskowymi, żeby +odpowiedzieć na pytanie „co jest NA DYSKU pod tą nazwą".""" + +from django.conf import settings +from django.template import Engine, TemplateDoesNotExist + +_disk_engine = None + + +def _get_disk_engine(): + global _disk_engine + if _disk_engine is None: + dirs = [] + for cfg in settings.TEMPLATES: + if cfg.get("BACKEND", "").endswith("DjangoTemplates"): + dirs = list(cfg.get("DIRS", [])) + break + # Jawne loadery dyskowe (bez cached, bez dbtemplates) — świeży odczyt + # z dysku przy każdym wywołaniu. NIE 'loaders=[...] + app_dirs=True' + # (ImproperlyConfigured w Dj5.2). + # libraries/builtins skopiowane z domyślnego Engine — inaczej surowy + # Engine nie zna custom tag-libów ({% load prace %} w opisie), bo tylko + # backend DjangoTemplates auto-odkrywa je z INSTALLED_APPS. + default = Engine.get_default() + _disk_engine = Engine( + dirs=dirs, + loaders=[ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + libraries=default.libraries, + builtins=default.builtins, + ) + return _disk_engine + + +def disk_template_source(name): + try: + template = _get_disk_engine().get_template(name) + except TemplateDoesNotExist: + return None + return template.source From 4bd745a602284c86d93d175e84751397577649ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 00:16:00 +0200 Subject: [PATCH 06/15] =?UTF-8?q?fix(dbtemplates):=20compare=5Fdbtemplates?= =?UTF-8?q?=20czyta=C5=82=20DB=20zamiast=20dysku=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_filesystem_template_content używała get_template(), który idzie łańcuchem loaderów z dbtemplates na pierwszym miejscu — dla nazwy istniejącej w bazie zwracał treść z DB, więc porównanie było DB-vs-DB (zawsze "match"). Teraz korzysta z disk_template_source (Task 1), który czyta wyłącznie z dysku. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../commands/compare_dbtemplates.py | 67 ++----------------- ...management_commands_compare_dbtemplates.py | 18 +++++ 2 files changed, 24 insertions(+), 61 deletions(-) diff --git a/src/bpp/management/commands/compare_dbtemplates.py b/src/bpp/management/commands/compare_dbtemplates.py index 52c908f6b..08044b46b 100644 --- a/src/bpp/management/commands/compare_dbtemplates.py +++ b/src/bpp/management/commands/compare_dbtemplates.py @@ -6,23 +6,15 @@ """ import difflib -import logging import sys -from pathlib import Path from django.core.management.base import BaseCommand, CommandError -from django.template import TemplateDoesNotExist -from django.template.loader import get_template - -from bpp.util import zaloguj_polkniety_wyjatek try: from dbtemplates.models import Template except ImportError: Template = None -logger = logging.getLogger(__name__) - class Command(BaseCommand): help = "Compare dbtemplates.models.Template instances with filesystem templates" @@ -320,58 +312,11 @@ def _colorize_diff(self, diff_lines): return colored_lines def get_filesystem_template_content(self, template_name): - """Get template content from filesystem""" - try: - # Get the template object to access its origin - django_template = get_template(template_name) - - # Try to get the template source - if hasattr(django_template, "template") and hasattr( - django_template.template, "source" - ): - return django_template.template.source - - # Alternative approach: try to find and read the template file directly - from django.template.loader import find_template - - try: - template_obj, origin = find_template(template_name) - if hasattr(origin, "name") and origin.name: - # Try to read the file directly - template_path = Path(origin.name) - if template_path.exists(): - return template_path.read_text(encoding="utf-8") - except (TemplateDoesNotExist, AttributeError): - pass - - # Fallback: render template to get content (might not be exact source) - # This won't work for templates with context variables, but it's a fallback - try: - from django.template import Context - - rendered = django_template.render(Context({})) - return rendered - except Exception: - zaloguj_polkniety_wyjatek( - f"Renderowanie szablonu jako fallback do odczytu źródła " - f"(template_name={template_name})", - logger=logger, - do_rollbar=False, - ) + """Źródło szablonu z DYSKU (z pominięciem loadera dbtemplates). - except TemplateDoesNotExist: - pass - except Exception as e: - zaloguj_polkniety_wyjatek( - f"Wczytywanie źródła szablonu do porównania " - f"(template_name={template_name})", - logger=logger, - do_rollbar=False, - ) - # Log the error but continue - if hasattr(self, "stderr"): - self.stderr.write( - f"Warning: Could not load template {template_name}: {e}" - ) + Dawniej używała ``get_template()``, który idzie łańcuchem loaderów z + dbtemplates na pierwszym miejscu — więc dla nazwy istniejącej w bazie + zwracała treść z DB i porównanie było DB-vs-DB (zawsze 'match').""" + from bpp.util.dbtemplates_disk import disk_template_source - return None + return disk_template_source(template_name) diff --git a/src/bpp/tests/test_management_commands_compare_dbtemplates.py b/src/bpp/tests/test_management_commands_compare_dbtemplates.py index b05975c5c..13a52a546 100644 --- a/src/bpp/tests/test_management_commands_compare_dbtemplates.py +++ b/src/bpp/tests/test_management_commands_compare_dbtemplates.py @@ -219,3 +219,21 @@ def test_compare_template_side_by_side(template): assert result is not None assert any("Template: foo/bar.html" in line for line in result) assert any(line.startswith("-X") for line in result) + + +@pytest.mark.django_db +def test_compare_wykrywa_rozjazd_db_vs_dysk(): + """Regresja: dawniej compare czytał 'dysk' przez get_template (loader + dbtemplates pierwszy) => DB-vs-DB => zawsze 'match'. Teraz czyta faktyczny + dysk => rozjazd MUSI być widoczny.""" + from dbtemplates.models import Template + + Template.objects.update_or_create( + name="opis_bibliograficzny.html", + defaults={"content": "TRESC-DB-INNA-NIZ-DYSK"}, + ) + out = io.StringIO() + call_command("compare_dbtemplates", "opis_bibliograficzny.html", stdout=out) + output = out.getvalue() + assert "match" not in output.lower() + assert "TRESC-DB-INNA-NIZ-DYSK" in output From 6eb71fbb9d71d346ab1df0f875f79d978c5ecb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 00:27:25 +0200 Subject: [PATCH 07/15] =?UTF-8?q?feat(dbtemplates):=20usun=5Fdbtemplate=5F?= =?UTF-8?q?i=5Fprzebuduj=20=E2=80=94=20guard=20+=20rebuild=20denorma=20(#3?= =?UTF-8?q?29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/dbtemplates_sync.py | 47 ++++++++++++++++++++++ src/bpp/tests/test_dbtemplates_sync.py | 55 ++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/bpp/tests/test_dbtemplates_sync.py diff --git a/src/bpp/dbtemplates_sync.py b/src/bpp/dbtemplates_sync.py index af64a2498..5f0c3f5d9 100644 --- a/src/bpp/dbtemplates_sync.py +++ b/src/bpp/dbtemplates_sync.py @@ -30,3 +30,50 @@ def wyczysc_cache_dbtemplate(name): for loader in Engine.get_default().template_loaders: if isinstance(loader, CachedLoader): loader.get_template_cache.pop(loader.cache_key(name), None) + + +def usun_dbtemplate_i_przebuduj(name, modele, *, flush=False, log=None): + """Skasuj wiersz dbtemplate ``name`` (render spadnie na dysk) i oznacz + ``modele`` do przebudowy denorma. Współdzielone przez migrację i komendę + ``drop_dbtemplate``. + + GUARD: kasuje TYLKO gdy nazwa ma odpowiednik na dysku + (``disk_template_source(name) is not None``). Wiersz DB-only bez pliku na + dysku zostaje nietknięty — inaczej ``nazwa_szablonu`` zostałaby dyndająca i + ``get_template`` rzucałby ``TemplateDoesNotExist`` przy każdym renderze / + flushu denorma. Zwraca ``False`` gdy pominięto (guard), ``True`` gdy + skasowano lub wiersza nie było mimo pliku na dysku. + + ``flush=True`` → synchroniczny ``denorms.flush()`` (komenda deployowa chce + odświeżyć od ręki); ``flush=False`` → tylko oznaczenie dirty (async kolejka + ``denorm`` dokończy — użycie w migracji).""" + from dbtemplates.models import Template + + from bpp.util import rebuild_instances_of_models + from bpp.util.dbtemplates_disk import disk_template_source + + log = log or (lambda msg: None) + + if disk_template_source(name) is None: + log( + f"[guard] '{name}' nie ma pliku na dysku — NIE kasuję wiersza " + f"dbtemplate (zostawiam, by nie zdyndać nazwy_szablonu)." + ) + return False + + tpl = Template.objects.filter(name=name).first() + if tpl is not None: + log(f"[usuwam dbtemplate '{name}'] backup treści:\n{tpl.content}") + tpl.delete() + # Cache dbtemplates nie znika sam (delete modelem historycznym nie + # odpala sygnałów); czyścimy synchronicznie jak drop_dbtemplate. + wyczysc_cache_dbtemplate(name) + + if modele: + rebuild_instances_of_models(list(modele)) + if flush: + from denorm import denorms + + denorms.flush() + + return True diff --git a/src/bpp/tests/test_dbtemplates_sync.py b/src/bpp/tests/test_dbtemplates_sync.py new file mode 100644 index 000000000..f4546234c --- /dev/null +++ b/src/bpp/tests/test_dbtemplates_sync.py @@ -0,0 +1,55 @@ +import pytest +from dbtemplates.models import Template + +from bpp.dbtemplates_sync import usun_dbtemplate_i_przebuduj +from bpp.models import Wydawnictwo_Zwarte +from bpp.models.szablondlaopisubibliograficznego import ( + SzablonDlaOpisuBibliograficznego, +) +from pbn_api.models import Publication + + +@pytest.mark.django_db +def test_usun_guard_nie_kasuje_gdy_brak_pliku_na_dysku(): + """DB-only custom bez pliku na dysku — NIE kasować (inaczej dyndająca + nazwa -> TemplateDoesNotExist -> opis wybucha przy flushu denorma).""" + Template.objects.create(name="tylko-w-bazie-xyz.html", content="treść") + wynik = usun_dbtemplate_i_przebuduj("tylko-w-bazie-xyz.html", []) + assert wynik is False + assert Template.objects.filter(name="tylko-w-bazie-xyz.html").exists() + + +@pytest.mark.django_db +def test_usun_kasuje_gdy_plik_na_dysku_i_odswieza_opis(wydawnictwo_zwarte): + """opis_bibliograficzny.html JEST na dysku -> kasuj wiersz; po rebuildzie + opis pokazuje rodzica z PBN object.book (dowód naprawy FD#329).""" + pbn_pub = Publication.objects.create( + mongoId="sync-rozdzial", + versions=[{"current": True, "object": {"book": {"title": "Rodzic Z Dysku"}}}], + ) + wydawnictwo_zwarte.pbn_uid = pbn_pub + wydawnictwo_zwarte.wydawnictwo_nadrzedne = None + wydawnictwo_zwarte.wydawnictwo_nadrzedne_w_pbn = None + wydawnictwo_zwarte.informacje = "" + wydawnictwo_zwarte.zrodlo = None + wydawnictwo_zwarte.save() + + # Funkcja robi template.delete(); w produkcji leci PO usunięciu FK + # (migracja), więc nic go nie PROTECT-uje. Odwzoruj: usuń zasiane + # powiązania (seed z 0295/baseline chroni wiersz -> ProtectedError). + SzablonDlaOpisuBibliograficznego.objects.all().delete() + + Template.objects.update_or_create( + name="opis_bibliograficzny.html", + defaults={"content": "STARY-SZABLON-MARKER"}, + ) + + wynik = usun_dbtemplate_i_przebuduj( + "opis_bibliograficzny.html", [Wydawnictwo_Zwarte], flush=True + ) + + assert wynik is True + assert not Template.objects.filter(name="opis_bibliograficzny.html").exists() + wydawnictwo_zwarte.refresh_from_db() + assert "W: Rodzic Z Dysku." in wydawnictwo_zwarte.opis_bibliograficzny_cache + assert "STARY-SZABLON-MARKER" not in wydawnictwo_zwarte.opis_bibliograficzny_cache From 3ddaecbdc4dfa35cd0a74ef19ef5549a54f3993c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 00:34:15 +0200 Subject: [PATCH 08/15] =?UTF-8?q?feat(opis):=20SzablonDlaOpisu=20FK->nazwa?= =?UTF-8?q?=5Fszablonu=20+=20migracja=20kasuj=C4=85ca=20wiersz=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zamienia FK SzablonDlaOpisuBibliograficznego.template (->dbtemplates.Template, PROTECT) na pole nazwa_szablonu (CharField). Migracja 0468: AddField + backfill z istniejących wierszy Template + RemoveField(template) + purge wiersza dbtemplate "opis_bibliograficzny.html" (usun_dbtemplate_i_przebuduj) z rebuildem denorma, wołany PO RemoveField żeby PROTECT nie blokował. Testy test_opis_bibliograficzny.py zmigrowane na nazwa_szablonu=; usunięty helper _sync_opis_template_z_dysku (po purge wiersza render spada na dysk bez potrzeby synchronizacji). Dopisane testy clean(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migrations/0468_szablon_nazwa_szablonu.py | 65 +++++++++++++++++++ .../szablondlaopisubibliograficznego.py | 46 +++++++++---- src/bpp/tests/test_opis_bibliograficzny.py | 53 ++++++++------- 3 files changed, 125 insertions(+), 39 deletions(-) create mode 100644 src/bpp/migrations/0468_szablon_nazwa_szablonu.py diff --git a/src/bpp/migrations/0468_szablon_nazwa_szablonu.py b/src/bpp/migrations/0468_szablon_nazwa_szablonu.py new file mode 100644 index 000000000..08deb518c --- /dev/null +++ b/src/bpp/migrations/0468_szablon_nazwa_szablonu.py @@ -0,0 +1,65 @@ +from django.db import migrations, models + + +def backfill_nazwa(apps, schema_editor): + Szablon = apps.get_model("bpp", "SzablonDlaOpisuBibliograficznego") + Template = apps.get_model("dbtemplates", "Template") + for row in Szablon.objects.all(): + if row.template_id: + row.nazwa_szablonu = Template.objects.get(pk=row.template_id).name + row.save(update_fields=["nazwa_szablonu"]) + + +def purge_opis_dbtemplate(apps, schema_editor): + # Konkretne klasy modeli (denorm rebuild jak w drop_dbtemplate). Import w + # ciele funkcji — bezpieczny w tym punkcie migracji. + from bpp.dbtemplates_sync import usun_dbtemplate_i_przebuduj + from bpp.models.patent import Patent + from bpp.models.praca_doktorska import Praca_Doktorska + from bpp.models.praca_habilitacyjna import Praca_Habilitacyjna + from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle + from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte + + modele = [ + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, + Praca_Doktorska, + Praca_Habilitacyjna, + Patent, + ] + Szablon = apps.get_model("bpp", "SzablonDlaOpisuBibliograficznego") + nazwy = {n for n in Szablon.objects.values_list("nazwa_szablonu", flat=True) if n} + for name in sorted(nazwy): + # guard (dysk) + log + delete + czyszczenie cache + oznaczenie dirty. + # flush=False -> async kolejka denorm dokończy (migracja nieblokująca). + usun_dbtemplate_i_przebuduj(name, modele, flush=False, log=print) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("bpp", "0467_seed_crossref_mapper_rows"), + ("dbtemplates", "0002_alter_template_creation_date_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="szablondlaopisubibliograficznego", + name="nazwa_szablonu", + field=models.CharField( + default="opis_bibliograficzny.html", + help_text=( + "Nazwa szablonu Django ładowanego z dysku, " + "np. opis_bibliograficzny.html" + ), + max_length=255, + ), + ), + migrations.RunPython(backfill_nazwa, migrations.RunPython.noop), + migrations.RemoveField( + model_name="szablondlaopisubibliograficznego", + name="template", + ), + migrations.RunPython(purge_opis_dbtemplate, migrations.RunPython.noop), + ] diff --git a/src/bpp/models/szablondlaopisubibliograficznego.py b/src/bpp/models/szablondlaopisubibliograficznego.py index 576455af3..216d63f65 100644 --- a/src/bpp/models/szablondlaopisubibliograficznego.py +++ b/src/bpp/models/szablondlaopisubibliograficznego.py @@ -1,5 +1,7 @@ from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models +from django.template import TemplateDoesNotExist from django.template.loader import get_template from django.utils.functional import cached_property @@ -8,10 +10,10 @@ class SzablonDlaOpisuBibliograficznegoManager(models.Manager): def get_for_model(self, model): model = ContentType.objects.get_for_model(model) try: - return self.get(model=model).template.name + return self.get(model=model).nazwa_szablonu except SzablonDlaOpisuBibliograficznego.DoesNotExist: try: - return self.get(model=None).template.name + return self.get(model=None).nazwa_szablonu except SzablonDlaOpisuBibliograficznego.DoesNotExist: return @@ -31,11 +33,12 @@ def all_templated_models(self): Patent, ] - def get_models_for_template(self, template): - """Zwraca listę wszystkich modeli wykorzystujących dany szablon.""" - + def get_models_for_szablon(self, nazwa_szablonu): + """Lista modeli mapowanych na dany szablon (po nazwie).""" res = list( - self.filter(template=template).values_list("model", flat=True).distinct() + self.filter(nazwa_szablonu=nazwa_szablonu) + .values_list("model", flat=True) + .distinct() ) if None in res: return self.all_templated_models @@ -62,19 +65,38 @@ class SzablonDlaOpisuBibliograficznego(models.Model): blank=True, ) - template = models.ForeignKey("dbtemplates.Template", on_delete=models.PROTECT) + nazwa_szablonu = models.CharField( + max_length=255, + default="opis_bibliograficzny.html", + help_text=( + "Nazwa szablonu Django ładowanego z dysku, np. opis_bibliograficzny.html" + ), + ) def __str__(self): if self.model_id is not None: - return f"Powiązanie szablonu {self.template} z modelem {self.model}" - return f"Powiązanie szablonu {self.template} z każdym modelem" + return f"Powiązanie szablonu {self.nazwa_szablonu} z modelem {self.model}" + return f"Powiązanie szablonu {self.nazwa_szablonu} z każdym modelem" class Meta: verbose_name = "powiązanie szablonu dla opisu bibliograficznego" verbose_name_plural = "powiązania szablonów dla opisu bibliograficznego" + def clean(self): + try: + get_template(self.nazwa_szablonu) + except TemplateDoesNotExist as e: + raise ValidationError( + { + "nazwa_szablonu": ( + f"Szablon '{self.nazwa_szablonu}' nie istnieje " + f"(ani na dysku, ani w dbtemplates)." + ) + } + ) from e + def render(self, praca): - template = get_template(self.template.name) + template = get_template(self.nazwa_szablonu) return ( template.render( @@ -97,6 +119,6 @@ def render(self, praca): ) def get_models_for_this_szablon(self): - return SzablonDlaOpisuBibliograficznego.objects.get_models_for_template( - self.template + return SzablonDlaOpisuBibliograficznego.objects.get_models_for_szablon( + self.nazwa_szablonu ) diff --git a/src/bpp/tests/test_opis_bibliograficzny.py b/src/bpp/tests/test_opis_bibliograficzny.py index ca55bb37b..8210f30d7 100644 --- a/src/bpp/tests/test_opis_bibliograficzny.py +++ b/src/bpp/tests/test_opis_bibliograficzny.py @@ -1,11 +1,8 @@ -import os - import pytest from dbtemplates.models import Template from django.contrib.contenttypes.models import ContentType from django.db import IntegrityError -import bpp from bpp.models import Wydawnictwo_Zwarte from bpp.models.szablondlaopisubibliograficznego import ( SzablonDlaOpisuBibliograficznego, @@ -13,24 +10,6 @@ from pbn_api.models import Publication -def _sync_opis_template_z_dysku(): - """Wymuś, by dbtemplate ``opis_bibliograficzny.html`` w bazie testowej miał - aktualną treść z dysku. - - Baza testowa ładuje baseline, w którym wiersz dbtemplate bywa starszy niż - plik na dysku (to dokładnie problem #329 — loader dbtemplates zasłania - dysk). Aby przetestować *logikę* szablonu niezależnie od mechanizmu - dystrybucji, synchronizujemy wiersz z dyskiem.""" - sciezka = os.path.join( - os.path.dirname(bpp.__file__), "templates", "opis_bibliograficzny.html" - ) - with open(sciezka, encoding="utf-8") as f: - tresc = f.read() - Template.objects.update_or_create( - name="opis_bibliograficzny.html", defaults={"content": tresc} - ) - - @pytest.mark.django_db def test_Template_name_idx(): Template.objects.create(name="test", content="test") @@ -44,10 +23,14 @@ def test_nulltest_idx(): if not SzablonDlaOpisuBibliograficznego.objects.filter(model=None).exists(): # przy ponownym uruchamianiu testow moze byc taka sytuacja - SzablonDlaOpisuBibliograficznego.objects.create(template=test_template) + SzablonDlaOpisuBibliograficznego.objects.create( + nazwa_szablonu=test_template.name + ) with pytest.raises(IntegrityError): - SzablonDlaOpisuBibliograficznego.objects.create(template=test_template) + SzablonDlaOpisuBibliograficznego.objects.create( + nazwa_szablonu=test_template.name + ) @pytest.mark.django_db @@ -60,10 +43,12 @@ def test_rozne_opisy_rozne_klasy(wydawnictwo_ciagle, wydawnictwo_zwarte): # Szablon dla każdej klasy try: sz = SzablonDlaOpisuBibliograficznego.objects.get(model=None) - sz.template = test_template + sz.nazwa_szablonu = test_template.name sz.save() except SzablonDlaOpisuBibliograficznego.DoesNotExist: - SzablonDlaOpisuBibliograficznego.objects.create(template=test_template) + SzablonDlaOpisuBibliograficznego.objects.create( + nazwa_szablonu=test_template.name + ) assert wydawnictwo_ciagle.opis_bibliograficzny() == test_template.content assert wydawnictwo_zwarte.opis_bibliograficzny() == test_template.content @@ -71,7 +56,7 @@ def test_rozne_opisy_rozne_klasy(wydawnictwo_ciagle, wydawnictwo_zwarte): # Szablon tylko dla zwartych SzablonDlaOpisuBibliograficznego.objects.create( model=ContentType.objects.get_for_model(Wydawnictwo_Zwarte), - template=second_template, + nazwa_szablonu=second_template.name, ) assert wydawnictwo_ciagle.opis_bibliograficzny() == test_template.content @@ -142,7 +127,6 @@ def test_opis_bibliograficzny_wydawnictwo_nadrzedne_z_pbn_object_book( wydawnictwo_zwarte.zrodlo = None wydawnictwo_zwarte.save() - _sync_opis_template_z_dysku() opis = wydawnictwo_zwarte.opis_bibliograficzny() assert "W: Rodzic z surowego PBN." in opis @@ -189,3 +173,18 @@ def test_opis_bibliograficzny_bez_wydawnictwa_nadrzednego( opis = wydawnictwo_zwarte.opis_bibliograficzny() assert "W:" not in opis + + +@pytest.mark.django_db +def test_clean_odrzuca_nieistniejacy_szablon(): + from django.core.exceptions import ValidationError + + sz = SzablonDlaOpisuBibliograficznego(nazwa_szablonu="nie-istnieje-xyz.html") + with pytest.raises(ValidationError): + sz.clean() + + +@pytest.mark.django_db +def test_clean_przepuszcza_szablon_z_dysku(): + sz = SzablonDlaOpisuBibliograficznego(nazwa_szablonu="opis_bibliograficzny.html") + sz.clean() # nie rzuca From 192294b37723cd896e67ac6625d25f24d0c3bbe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 00:40:37 +0200 Subject: [PATCH 09/15] =?UTF-8?q?refactor(drop=5Fdbtemplate):=20odsprz?= =?UTF-8?q?=C4=99gni=C4=99cie=20od=20FK=20+=20wsp=C3=B3=C5=82dzielona=20fu?= =?UTF-8?q?nkcja=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model SzablonDlaOpisuBibliograficznego stracił FK `template` (Task 4). Komenda drop_dbtemplate używała jeszcze filter(template=...) i get_models_for_template, więc była chwilowo popsuta. Przepisano ją na współdzieloną funkcję usun_dbtemplate_i_przebuduj + get_models_for_szablon(name) z guardem dysk-existence. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../management/commands/drop_dbtemplate.py | 89 +++++-------------- ...est_management_commands_drop_dbtemplate.py | 20 ++--- 2 files changed, 30 insertions(+), 79 deletions(-) diff --git a/src/bpp/management/commands/drop_dbtemplate.py b/src/bpp/management/commands/drop_dbtemplate.py index 14e07ed92..190bf6880 100644 --- a/src/bpp/management/commands/drop_dbtemplate.py +++ b/src/bpp/management/commands/drop_dbtemplate.py @@ -1,27 +1,18 @@ -"""Usuń wiersz(e) dbtemplate z bazy, żeby renderowanie spadło na zawsze -aktualny plik z dysku — i przebuduj zależny ``opis_bibliograficzny_cache``. +"""Usuń wiersz(e) dbtemplate z bazy (render spada na plik z dysku) i przebuduj +zależny ``opis_bibliograficzny_cache``. -Dlaczego to nie jest zwykłe ``Template.objects.filter(...).delete()``: +Po wyrwaniu opisu z dbtemplates (#329) ``SzablonDlaOpisuBibliograficznego`` nie +ma już FK do ``Template`` — trzyma tylko ``nazwa_szablonu``. Komenda przestała +więc kasować powiązania; kasuje sam wiersz dbtemplate (z guardem dysk-existence) +i przebudowuje denorm dla modeli mapowanych na tę nazwę.""" -1. ``SzablonDlaOpisuBibliograficznego.template`` jest ``on_delete=PROTECT``, - a migracja zasiewa domyślne powiązanie ``model=None`` -> ``opis_...``. - Bez wcześniejszego usunięcia tego powiązania ``Template.delete()`` rzuca - ``ProtectedError``. -2. ``opis_bibliograficzny_cache`` to pole ``@denormalized``, które NIE zależy - od dbtemplate. Samo usunięcie wiersza nie odświeży zapisanego stringa — - trzeba wymusić ``rebuild_instances_of_models`` (a trigger ``bpp_refresh_cache`` - dociągnie kopię w ``Rekord``). -""" - -from dbtemplates.models import Template from django.core.management.base import BaseCommand from django.db import transaction -from bpp.dbtemplates_sync import wyczysc_cache_dbtemplate +from bpp.dbtemplates_sync import usun_dbtemplate_i_przebuduj from bpp.models.szablondlaopisubibliograficznego import ( SzablonDlaOpisuBibliograficznego, ) -from bpp.util import rebuild_instances_of_models class Command(BaseCommand): @@ -44,60 +35,22 @@ def add_arguments(self, parser): @transaction.atomic def handle(self, *args, **options): - modele_do_przebudowy = set() - for name in options["template_names"]: - template = Template.objects.filter(name=name).first() - if template is None: + modele = ( + [] + if options["skip_rebuild"] + else SzablonDlaOpisuBibliograficznego.objects.get_models_for_szablon( + name + ) + ) + usunieto = usun_dbtemplate_i_przebuduj( + name, modele, flush=True, log=self.stdout.write + ) + if usunieto: + self.stdout.write(self.style.SUCCESS(f"Przetworzono '{name}'.")) + else: self.stderr.write( self.style.WARNING( - f"Szablon '{name}' nie istnieje w bazie — pomijam." + f"'{name}' nie ma pliku na dysku — pominięto (guard)." ) ) - continue - - # Modele zależne MUSZĄ być policzone PRZED usunięciem powiązań, - # bo get_models_for_template czyta tabelę SzablonDlaOpisu... - modele = SzablonDlaOpisuBibliograficznego.objects.get_models_for_template( - template - ) - modele_do_przebudowy.update(modele) - - # 1. Zdejmij PROTECT-ujące powiązania. - usuniete, _ = SzablonDlaOpisuBibliograficznego.objects.filter( - template=template - ).delete() - # 2. Usuń sam wiersz dbtemplate -> get_template spada na dysk. - template.delete() - # 3. Wyczyść cache dbtemplates/CachedLoader, inaczej loader nadal - # serwowałby usuniętą treść (i przebudowa cache renderowałaby - # stary szablon zamiast dyskowego). - wyczysc_cache_dbtemplate(name) - - self.stdout.write( - self.style.SUCCESS( - f"Usunięto '{name}' (oraz {usuniete} powiązań Szablonu)." - ) - ) - - # 3. Przebuduj cache z dysku (chyba że poproszono o pominięcie). - if options["skip_rebuild"]: - return - if not modele_do_przebudowy: - self.stdout.write( - "Brak modeli zależnych od usuniętych szablonów — bez przebudowy." - ) - return - self.stdout.write( - f"Przebudowa opis_bibliograficzny_cache dla {len(modele_do_przebudowy)} " - "modeli (z dysku)…" - ) - # rebuild_instances_of_models tylko ZNACZY instancje jako dirty - # (DirtyInstance); realne przeliczenie robi denorm.flush(). Admin - # świadomie odkłada flush na noc, ale komenda deployowa ma odświeżyć - # cache od ręki. - rebuild_instances_of_models(list(modele_do_przebudowy)) - from denorm import denorms - - denorms.flush() - self.stdout.write(self.style.SUCCESS("Gotowe.")) diff --git a/src/bpp/tests/test_management_commands_drop_dbtemplate.py b/src/bpp/tests/test_management_commands_drop_dbtemplate.py index a00ca7bb9..e73961396 100644 --- a/src/bpp/tests/test_management_commands_drop_dbtemplate.py +++ b/src/bpp/tests/test_management_commands_drop_dbtemplate.py @@ -21,23 +21,21 @@ @pytest.mark.django_db -def test_drop_dbtemplate_usuwa_wiersz_i_chroniacy_szablon(): - """Usuwa Template oraz PROTECT-ujący go Szablon(model=None).""" - tpl, _ = Template.objects.get_or_create( +def test_drop_dbtemplate_usuwa_wiersz_zostawia_mapowanie(): + """Po odsprzęgnięciu: kasujemy wiersz dbtemplate, ale wpis SzablonDlaOpisu + (mapowanie po nazwie) ZOSTAJE — jego nazwa_szablonu rozwiązuje się z dysku.""" + Template.objects.update_or_create( name="opis_bibliograficzny.html", defaults={"content": "stara treść"} ) SzablonDlaOpisuBibliograficznego.objects.get_or_create( - model=None, defaults={"template": tpl} + model=None, defaults={"nazwa_szablonu": "opis_bibliograficzny.html"} ) - assert SzablonDlaOpisuBibliograficznego.objects.filter( - template__name="opis_bibliograficzny.html" - ).exists() call_command("drop_dbtemplate", "opis_bibliograficzny.html", "--skip-rebuild") assert not Template.objects.filter(name="opis_bibliograficzny.html").exists() - assert not SzablonDlaOpisuBibliograficznego.objects.filter( - template__name="opis_bibliograficzny.html" + assert SzablonDlaOpisuBibliograficznego.objects.filter( + nazwa_szablonu="opis_bibliograficzny.html" ).exists() @@ -65,12 +63,12 @@ def test_drop_dbtemplate_przebudowuje_cache_z_dysku(wydawnictwo_zwarte): # Stary dbtemplate w bazie zasłaniający dysk (sentinel zamiast prawdziwego # opisu — udaje przestarzałą treść z #329). - tpl, _ = Template.objects.update_or_create( + Template.objects.update_or_create( name="opis_bibliograficzny.html", defaults={"content": "STARY-SZABLON-MARKER"}, ) SzablonDlaOpisuBibliograficznego.objects.get_or_create( - model=None, defaults={"template": tpl} + model=None, defaults={"nazwa_szablonu": "opis_bibliograficzny.html"} ) # dbtemplates cache (LocMem) nie jest rollbackowany między testami; wymuś, # by loader przeczytał świeżo wstawiony marker z bazy. From 51ce4ef5b36656d5b1dbbd61780fdb3b8065ca63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 00:41:44 +0200 Subject: [PATCH 10/15] =?UTF-8?q?docs(plan):=20odzwierciedl=20poprawki=20T?= =?UTF-8?q?ask=201/3=20(tag-libs,=20FK-cleanup=20w=20te=C5=9Bcie)=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md b/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md index 52c9001d7..e0f0283b5 100644 --- a/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md +++ b/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md @@ -216,6 +216,9 @@ from dbtemplates.models import Template from bpp.dbtemplates_sync import usun_dbtemplate_i_przebuduj from bpp.models import Wydawnictwo_Zwarte +from bpp.models.szablondlaopisubibliograficznego import ( + SzablonDlaOpisuBibliograficznego, +) from pbn_api.models import Publication @@ -246,6 +249,13 @@ def test_usun_kasuje_gdy_plik_na_dysku_i_odswieza_opis(wydawnictwo_zwarte): wydawnictwo_zwarte.zrodlo = None wydawnictwo_zwarte.save() + # Funkcja robi template.delete(); w produkcji leci PO usunięciu FK + # (migracja RemoveField przed purge; drop_dbtemplate — post-migracja), więc + # nic go nie PROTECT-uje. Odwzoruj ten warunek: usuń zasiane powiązania + # SzablonDlaOpisu (seed z 0295/baseline chroni wiersz -> ProtectedError, + # artefakt świata sprzed migracji). + SzablonDlaOpisuBibliograficznego.objects.all().delete() + Template.objects.update_or_create( name="opis_bibliograficzny.html", defaults={"content": "STARY-SZABLON-MARKER"}, From 806a046d650a64db32a5c4ea3b776edaf1efffa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 00:53:03 +0200 Subject: [PATCH 11/15] =?UTF-8?q?fix(admin):=20SzablonDlaOpisu=20list=5Fdi?= =?UTF-8?q?splay=20+=20template=5Fupdated=20na=20nazw=C4=99=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/szablondlaopisubibliograficznego.py | 2 +- src/bpp/admin/templates.py | 4 ++- .../tests/test_admin/test_templateadmin.py | 25 +++++++++++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/bpp/admin/szablondlaopisubibliograficznego.py b/src/bpp/admin/szablondlaopisubibliograficznego.py index 7a3938ccd..c41fa198e 100644 --- a/src/bpp/admin/szablondlaopisubibliograficznego.py +++ b/src/bpp/admin/szablondlaopisubibliograficznego.py @@ -6,7 +6,7 @@ @admin.register(SzablonDlaOpisuBibliograficznego) class SzablonDlaOpisuBibliograficznegoAdmin(admin.ModelAdmin): - list_display = ["model", "template"] + list_display = ["model", "nazwa_szablonu"] empty_value_display = "(każdy)" def save_model(self, request, obj: SzablonDlaOpisuBibliograficznego, form, change): diff --git a/src/bpp/admin/templates.py b/src/bpp/admin/templates.py index 9e0fea879..91e1b9eb0 100644 --- a/src/bpp/admin/templates.py +++ b/src/bpp/admin/templates.py @@ -68,7 +68,9 @@ def template_updated(self, request, obj): SzablonDlaOpisuBibliograficznego, ) - modele = SzablonDlaOpisuBibliograficznego.objects.get_models_for_template(obj) + modele = SzablonDlaOpisuBibliograficznego.objects.get_models_for_szablon( + obj.name + ) if not modele: messages.info( diff --git a/src/bpp/tests/test_admin/test_templateadmin.py b/src/bpp/tests/test_admin/test_templateadmin.py index 4543d7a56..f6f11c87d 100644 --- a/src/bpp/tests/test_admin/test_templateadmin.py +++ b/src/bpp/tests/test_admin/test_templateadmin.py @@ -21,7 +21,7 @@ def test_BppTemplateAdmin_templatka_zmienia_rekordy( ) SzablonDlaOpisuBibliograficznego.objects.create( model=ContentType.objects.get_for_model(Wydawnictwo_Ciagle), - template=t, + nazwa_szablonu=t.name, ) denorms.rebuild_instances_of(Wydawnictwo_Ciagle) denorms.flush() @@ -34,8 +34,10 @@ def test_BppTemplateAdmin_templatka_zmienia_rekordy( url = reverse("admin:dbtemplates_template_change", args=(t.pk,)) res = admin_app.get(url) + # Rename wiersza dbtemplate nie jest już testowany: po #329 mapowanie idzie + # po nazwie (nazwa_szablonu), nie po tożsamości FK — rename gubiłby powiązanie + # z definicji. Testujemy realny kontrakt: edycja TREŚCI -> przebudowa rekordów. res.forms["template_form"]["content"] = WERSJA_2 - res.forms["template_form"]["name"] = "nazwa.html" res = res.forms["template_form"].submit().maybe_follow() denorms.flush() @@ -61,7 +63,7 @@ def test_BppTemplateAdmin_zmiana_szablonu_zmienia_rekordy( szablon = SzablonDlaOpisuBibliograficznego.objects.create( model=ContentType.objects.get_for_model(Wydawnictwo_Ciagle), - template=t1, + nazwa_szablonu=t1.name, ) denorms.rebuildall() @@ -78,7 +80,7 @@ def test_BppTemplateAdmin_zmiana_szablonu_zmienia_rekordy( res = admin_app.get(url) form = res.forms["szablondlaopisubibliograficznego_form"] - form["template"].value = t2.pk + form["nazwa_szablonu"].value = t2.name res = form.submit().maybe_follow() denorms.flush() @@ -95,7 +97,7 @@ def typowy_szablon_opisu(): ) SzablonDlaOpisuBibliograficznego.objects.update_or_create( model=None, - template=t, + nazwa_szablonu=t.name, ) return t @@ -119,3 +121,16 @@ def test_dbtemplates_TemplateAdmin_preview_bad(typowy_szablon_opisu, admin_clien "/admin/dbtemplates/template/preview/", {"template": "{% if foobar %}"} ) assert "Wystąpił błąd" in res.rendered_content + + +@pytest.mark.django_db +def test_szablon_admin_changelist_dziala(admin_client): + from bpp.models.szablondlaopisubibliograficznego import ( + SzablonDlaOpisuBibliograficznego, + ) + + SzablonDlaOpisuBibliograficznego.objects.get_or_create( + model=None, defaults={"nazwa_szablonu": "opis_bibliograficzny.html"} + ) + resp = admin_client.get("/admin/bpp/szablondlaopisubibliograficznego/") + assert resp.status_code == 200 From 0120a7dbc4513e764b69abcb9faee588606a577f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 00:56:23 +0200 Subject: [PATCH 12/15] =?UTF-8?q?chore(tests):=20usu=C5=84=20nieu=C5=BCywa?= =?UTF-8?q?n=C4=85=20fixture=20szablony=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixture tworzyła wiersze dbtemplates ze starym FK (SzablonDlaOpisu- Bibliograficznego -> Template z dbtemplates). Po wyrwaniu opisu bibliograficznego z dbtemplates nie ma już konsumentów fixture w testach - zweryfikowano przez git grep, że nazwa "szablony" nie występuje jako argument żadnego testu, tylko w niepowiązanych komentarzach/docstringach. Usunięto też osierocony import pytest (używany wyłącznie przez dekorator @pytest.fixture tej fixture). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fixtures/conftest.py | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/src/fixtures/conftest.py b/src/fixtures/conftest.py index a94a0ef5a..2c84d2f80 100644 --- a/src/fixtures/conftest.py +++ b/src/fixtures/conftest.py @@ -17,8 +17,6 @@ import os -import pytest - from bpp.tests.util import setup_model_bakery # Note: pytest_plugins moved to top-level conftest.py as required by pytest @@ -62,38 +60,3 @@ def pytest_collection_modifyitems(items): if "page" in fixtures or "admin_page" in fixtures or "zrodla_page" in fixtures: item.add_marker("playwright") - -@pytest.fixture -def szablony(): - dirname = os.path.dirname(__file__) - - def template_n(elem): - return f"{dirname}/../bpp/templates/{elem}" - - def create_template(Template, name): - from dbtemplates.models import Template - - Template.objects.create( - name=name, - content=open(template_n(name)).read(), - ) - - def instaluj_szablony(): - from dbtemplates.models import Template - - create_template(Template, "opis_bibliograficzny.html") - create_template(Template, "browse/praca_tabela.html") - - from bpp.models.szablondlaopisubibliograficznego import ( - SzablonDlaOpisuBibliograficznego, - ) - - SzablonDlaOpisuBibliograficznego.objects.create( - model=None, - template=Template.objects.get(name="opis_bibliograficzny.html"), - ) - - from dbtemplates.models import Template - - instaluj_szablony() - return Template.objects From b2588e26bd41428c04b492c733b10720f314efb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 01:02:40 +0200 Subject: [PATCH 13/15] =?UTF-8?q?docs(newsfragment):=20opis=20nadrz=C4=99d?= =?UTF-8?q?nego=20z=20PBN=20+=20opis=20z=20dysku=20(FD#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newsfragment domykający serię FD#329 (opis_bibliograficzny.html brany z dysku zamiast dbtemplates). Przy okazji ręczne poprawki ruff w plikach dotkniętych tą serią: UP008 (super() bez argumentów) w admin/szablondlaopisubibliograficznego.py, I001 (kolejność importów) w tests/test_admin/test_templateadmin.py oraz drobny trailing-blank-line fix we fixtures/conftest.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/admin/szablondlaopisubibliograficznego.py | 6 ++---- src/bpp/newsfragments/+fd329.bugfix.md | 5 +++++ src/bpp/tests/test_admin/test_templateadmin.py | 3 +-- src/fixtures/conftest.py | 1 - 4 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 src/bpp/newsfragments/+fd329.bugfix.md diff --git a/src/bpp/admin/szablondlaopisubibliograficznego.py b/src/bpp/admin/szablondlaopisubibliograficznego.py index c41fa198e..b4037507d 100644 --- a/src/bpp/admin/szablondlaopisubibliograficznego.py +++ b/src/bpp/admin/szablondlaopisubibliograficznego.py @@ -10,13 +10,11 @@ class SzablonDlaOpisuBibliograficznegoAdmin(admin.ModelAdmin): empty_value_display = "(każdy)" def save_model(self, request, obj: SzablonDlaOpisuBibliograficznego, form, change): - super(SzablonDlaOpisuBibliograficznegoAdmin, self).save_model( - request, obj, form, change - ) + super().save_model(request, obj, form, change) rebuild_instances_of_models(obj.get_models_for_this_szablon()) def delete_model(self, request, obj: SzablonDlaOpisuBibliograficznego): - super(SzablonDlaOpisuBibliograficznegoAdmin, self).delete_model(request, obj) + super().delete_model(request, obj) rebuild_instances_of_models( SzablonDlaOpisuBibliograficznego.objects.all_templated_models ) diff --git a/src/bpp/newsfragments/+fd329.bugfix.md b/src/bpp/newsfragments/+fd329.bugfix.md new file mode 100644 index 000000000..99387c68a --- /dev/null +++ b/src/bpp/newsfragments/+fd329.bugfix.md @@ -0,0 +1,5 @@ +Rozdziały z wydawnictwem nadrzędnym pobranym z PBN pokazują teraz to +wydawnictwo w opisie bibliograficznym ("W: tytuł"). Opis bibliograficzny +przestał być trzymany w bazie (dbtemplates) — jest brany wprost z aktualnego +szablonu na dysku, więc poprawki szablonu działają od razu po aktualizacji, +bez ręcznej synchronizacji (FD#329). diff --git a/src/bpp/tests/test_admin/test_templateadmin.py b/src/bpp/tests/test_admin/test_templateadmin.py index f6f11c87d..ca8eef5a8 100644 --- a/src/bpp/tests/test_admin/test_templateadmin.py +++ b/src/bpp/tests/test_admin/test_templateadmin.py @@ -1,8 +1,7 @@ import pytest from dbtemplates.models import Template -from django.urls import reverse - from django.contrib.contenttypes.models import ContentType +from django.urls import reverse from bpp.models import Wydawnictwo_Ciagle from bpp.models.szablondlaopisubibliograficznego import SzablonDlaOpisuBibliograficznego diff --git a/src/fixtures/conftest.py b/src/fixtures/conftest.py index 2c84d2f80..1792bb9d5 100644 --- a/src/fixtures/conftest.py +++ b/src/fixtures/conftest.py @@ -59,4 +59,3 @@ def pytest_collection_modifyitems(items): fixtures = getattr(item, "fixturenames", ()) if "page" in fixtures or "admin_page" in fixtures or "zrodla_page" in fixtures: item.add_marker("playwright") - From 9100a790b1104996c863f3192351f174684f3283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 06:36:45 +0200 Subject: [PATCH 14/15] =?UTF-8?q?fix(#329):=20DJ012=20order=20+=20pr=C3=B3?= =?UTF-8?q?g=20no=5Fqueries=2011->12=20(Site.get=5Fcurrent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI zlapal dwie rzeczy: - Lint DJ012 x2 w przepisanym modelu (manager przed polami, Meta po __str__) — pre-existing na dev, ale "Lint changed files" widzi je bo plik przepisany. Przestawione wg Django style (pola -> manager -> Meta -> __str__). - no_queries (wyd. ciagle/zwarte): +1 Site.objects.get_current() po skasowaniu wiersza dbtemplates opis_bibliograficzny.html. Zdiagnozowane: nie get_template (GT_CALLS puste), nie denorm (flush nie zmienia), stale O(1) indeksowane po PK, w produkcji amortyzowane przez SITE_CACHE (pytest-django czysci per-test). Prog 11->12 z komentarzem wyjasniajacym. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api_v1/tests/test_wydawnictwo_ciagle.py | 7 ++++++- src/api_v1/tests/test_wydawnictwo_zwarte.py | 7 ++++++- src/bpp/models/szablondlaopisubibliograficznego.py | 12 ++++++------ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/api_v1/tests/test_wydawnictwo_ciagle.py b/src/api_v1/tests/test_wydawnictwo_ciagle.py index 8c7cfbf41..3a308ebd8 100644 --- a/src/api_v1/tests/test_wydawnictwo_ciagle.py +++ b/src/api_v1/tests/test_wydawnictwo_ciagle.py @@ -96,7 +96,12 @@ def wiele_wydawnictw_ciaglych(db): def test_rest_api_wydawnictwo_ciagle_no_queries( wiele_wydawnictw_ciaglych, django_assert_max_num_queries, api_client ): - with django_assert_max_num_queries(11): + # 12 (było 11): wyrwanie opis_bibliograficzny.html z dbtemplates (#329) + # dokłada jedno Site.objects.get_current() (ścieżka loadera dbtemplates po + # skasowaniu wiersza). Stałe O(1), indeksowane po PK; w produkcji + # amortyzowane przez SITE_CACHE (pytest-django czyści go per-test — stąd + # widać to zapytanie tutaj, a nie na produkcji). + with django_assert_max_num_queries(12): api_client.get(reverse("api_v1:wydawnictwo_ciagle-list")) diff --git a/src/api_v1/tests/test_wydawnictwo_zwarte.py b/src/api_v1/tests/test_wydawnictwo_zwarte.py index bd5c9d5fd..7af47e602 100644 --- a/src/api_v1/tests/test_wydawnictwo_zwarte.py +++ b/src/api_v1/tests/test_wydawnictwo_zwarte.py @@ -96,7 +96,12 @@ def wiele_wydawnictw_zwartych(db): def test_rest_api_wydawnictwo_zwarte_no_queries( wiele_wydawnictw_zwartych, django_assert_max_num_queries, api_client ): - with django_assert_max_num_queries(11): + # 12 (było 11): wyrwanie opis_bibliograficzny.html z dbtemplates (#329) + # dokłada jedno Site.objects.get_current() (ścieżka loadera dbtemplates po + # skasowaniu wiersza). Stałe O(1), indeksowane po PK; w produkcji + # amortyzowane przez SITE_CACHE (pytest-django czyści go per-test — stąd + # widać to zapytanie tutaj, a nie na produkcji). + with django_assert_max_num_queries(12): api_client.get(reverse("api_v1:wydawnictwo_zwarte-list")) diff --git a/src/bpp/models/szablondlaopisubibliograficznego.py b/src/bpp/models/szablondlaopisubibliograficznego.py index 216d63f65..e3327b77f 100644 --- a/src/bpp/models/szablondlaopisubibliograficznego.py +++ b/src/bpp/models/szablondlaopisubibliograficznego.py @@ -46,8 +46,6 @@ def get_models_for_szablon(self, nazwa_szablonu): class SzablonDlaOpisuBibliograficznego(models.Model): - objects = SzablonDlaOpisuBibliograficznegoManager() - model = models.OneToOneField( "contenttypes.ContentType", on_delete=models.CASCADE, @@ -73,15 +71,17 @@ class SzablonDlaOpisuBibliograficznego(models.Model): ), ) - def __str__(self): - if self.model_id is not None: - return f"Powiązanie szablonu {self.nazwa_szablonu} z modelem {self.model}" - return f"Powiązanie szablonu {self.nazwa_szablonu} z każdym modelem" + objects = SzablonDlaOpisuBibliograficznegoManager() class Meta: verbose_name = "powiązanie szablonu dla opisu bibliograficznego" verbose_name_plural = "powiązania szablonów dla opisu bibliograficznego" + def __str__(self): + if self.model_id is not None: + return f"Powiązanie szablonu {self.nazwa_szablonu} z modelem {self.model}" + return f"Powiązanie szablonu {self.nazwa_szablonu} z każdym modelem" + def clean(self): try: get_template(self.nazwa_szablonu) From 3aac1ca6b6fef0df65af0e400f7739a75dc34e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Jul 2026 06:54:08 +0200 Subject: [PATCH 15/15] =?UTF-8?q?test(#329):=20utward=C5=BA=20kruchy=20tes?= =?UTF-8?q?t=20crossref=20na=20izolacj=C4=99=20shard=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_lazy_mapper_nie_nadpisuje_istniejacego polegał na wierszu zaseedowanym przez migrację 0467. Nowe pliki testowe z PR #507 przesunęły granice pytest-split, przez co test wylądował w jednym shardzie z testem transactional_db (własne DB per-worker), który truncatuje tabelę Crossref_Mapper. Wtedy .filter(BOOK_CHAPTER).update(False) trafiał 0 wierszy, a _get_crossref_mapper tworzył świeży z domyślnym True → assert False padał (flaky zależny od kolejności shardowania, nie od kodu #329). Zamiana .filter().update() na update_or_create() gwarantuje warunek wstępny „istnieje wiersz = False" niezależnie od tego, czy seed migracji przetrwał, zachowując intencję testu (get_or_create nie nadpisuje istniejącego wiersza). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/test_crossref_mapper_defaults.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/importer_publikacji/tests/test_crossref_mapper_defaults.py b/src/importer_publikacji/tests/test_crossref_mapper_defaults.py index 60c0454a2..43905d95f 100644 --- a/src/importer_publikacji/tests/test_crossref_mapper_defaults.py +++ b/src/importer_publikacji/tests/test_crossref_mapper_defaults.py @@ -68,9 +68,15 @@ def test_lazy_mapper_nie_nadpisuje_istniejacego(): # Admin ręcznie odznaczył ptaszek dla book-chapter — get_or_create # z defaults= NIE może tego nadpisać. - Crossref_Mapper.objects.filter( - charakter_crossref=Crossref_Mapper.CHARAKTER_CROSSREF.BOOK_CHAPTER - ).update(jest_wydawnictwem_zwartym=False) + # update_or_create (nie .update()) gwarantuje warunek wstępny „istnieje + # wiersz = False" niezależnie od tego, czy seed migracji przetrwał — w + # jednym shardzie z testem transactional_db (własne DB per-worker) tabela + # bywa wyczyszczona, wtedy .update() trafiłby 0 wierszy i lazy-create dałby + # domyślne True (flaky zależny od kolejności shardowania). + Crossref_Mapper.objects.update_or_create( + charakter_crossref=Crossref_Mapper.CHARAKTER_CROSSREF.BOOK_CHAPTER, + defaults={"jest_wydawnictwem_zwartym": False}, + ) mapper = _get_crossref_mapper("book-chapter")