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..e0f0283b5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates.md @@ -0,0 +1,887 @@ +# 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). + # 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 +``` + +- [ ] **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 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 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"}, + ) + + 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 new file mode 100644 index 000000000..df8649a05 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-opis-bibliograficzny-dysk-zamiast-dbtemplates-design.md @@ -0,0 +1,310 @@ +# 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". +Recenzja: adversarialny self-review (Fable) — ustalenia P1–P9 wchłonięte niżej. + +## 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". + 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ę + `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** 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. +- **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) + 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, czyści +cache i oznacza rekordy jako dirty, a async kolejka `denorm` odświeża opisy w +tle. + +## 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 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()` 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`). 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 §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)`. 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`, 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 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 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, + 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**. +- **`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 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 + +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=…)`) — + 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), + - `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. Disk-only żyje tylko w compare (§3). + +## Testy + +- **Repro compare** (red-first): wiersz DB ≠ dysk → komenda pokazuje diff. +- **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 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ć; + `_sync_opis_template_z_dysku` usunięty. +- Reuse testów #329 (`test_publication_book.py`, render `object.book`) — teraz + bez wiersza DB. + +## 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 + („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 + +- **Denorm (P1)**: samo skasowanie wiersza nie odświeża `opis_bibliograficzny_cache` + — 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))**: 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). +- **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**. 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/admin/szablondlaopisubibliograficznego.py b/src/bpp/admin/szablondlaopisubibliograficznego.py index 7a3938ccd..b4037507d 100644 --- a/src/bpp/admin/szablondlaopisubibliograficznego.py +++ b/src/bpp/admin/szablondlaopisubibliograficznego.py @@ -6,17 +6,15 @@ @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): - 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/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/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/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/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/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..e3327b77f 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 @@ -43,8 +46,6 @@ def get_models_for_template(self, template): class SzablonDlaOpisuBibliograficznego(models.Model): - objects = SzablonDlaOpisuBibliograficznegoManager() - model = models.OneToOneField( "contenttypes.ContentType", on_delete=models.CASCADE, @@ -62,19 +63,40 @@ 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" + 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) + 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/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 4543d7a56..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 @@ -21,7 +20,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 +33,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 +62,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 +79,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 +96,7 @@ def typowy_szablon_opisu(): ) SzablonDlaOpisuBibliograficznego.objects.update_or_create( model=None, - template=t, + nazwa_szablonu=t.name, ) return t @@ -119,3 +120,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 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/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 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 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. 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 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 diff --git a/src/fixtures/conftest.py b/src/fixtures/conftest.py index a94a0ef5a..1792bb9d5 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 @@ -61,39 +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") - - -@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 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")