From 54c8f8a57579723b92b5dd1c28411a88132e6005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 11:15:36 +0200 Subject: [PATCH 01/20] =?UTF-8?q?docs(wcag):=20specyfikacja=20napraw=20stw?= =?UTF-8?q?ierdzonych=20lektur=C4=85=20kodu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wydziela z programu WCAG 2.2 AA (spec z 2026-08-05) te naprawy, które nie zależą od skanu szerokiego, audytu ani infrastruktury testowej: 1.1.1 (obraz bez alt, martwy szablon) oraz 3.1.2 (atrybuty lang na tytułach obcojęzycznych, oba wektory). Dokument koryguje dwa ustalenia poprzednika: - wektor 2 (opis_bibliograficzny_cache) NIE wymaga liveops per wdrożenie — nocny denorm_rebuild z Ofelii przelicza całą bazę niezależnie od tej zmiany, a szablon opisu wrócił na dysk migracją 0473. Rzeczywistym warunkiem koniecznym jest rozszerzenie allowlisty nh3 o span/lang, którego poprzednik nie wymieniał; - user_navigation_autocomplete.html jest martwy (widok autocomplete zwraca JSON, listę rysuje Select2) — usuwamy plik zamiast łatać alt. Bramka CI, baseline, audyt ręczny i raport zgodności pozostają poza zakresem do czasu pojawienia się odbiorcy raportu. 2.1.4 (skrót /) i 2.5.7 (graf powiązań) odroczone świadomie, z wpisem w wykazie niezgodności. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E8RjSZWMR6L7fuRDJJZW4L --- ...6-08-06-wcag-naprawy-stwierdzone-design.md | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md diff --git a/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md b/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md new file mode 100644 index 000000000..a5fa1723a --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md @@ -0,0 +1,371 @@ +# Naprawy naruszeń WCAG stwierdzonych lekturą kodu + +Data: 2026-08-06 +Poprzednik: `2026-08-05-wcag-22-aa-zgodnosc-frontendu-design.md` + +## Cel + +Usunąć te naruszenia WCAG 2.2 AA, które specyfikacja z 2026-08-05 +stwierdziła **na podstawie lektury kodu** — bez czekania na audyt, skan +szeroki ani infrastrukturę testową. + +To krok 3 z sekcji „Kolejność prac i zależności" tamtego dokumentu, +wykonywany jako samodzielna iteracja. Reszta programu (bramka CI, baseline, +audyt ręczny, raport zgodności) **nie wchodzi** — czeka na moment, w którym +pojawi się odbiorca raportu. Dziś takiego odbiorcy nie ma. + +Uzasadnienie takiego cięcia: naprawy stwierdzone poprawiają dostępność +realnie i natychmiast, nie wymagają żadnej infrastruktury i nie zależą od +pięciu otwartych decyzji, które blokują resztę programu. Budowanie bramki CI +i raportu bez odbiorcy byłoby produkcją artefaktu, którego nikt nie czyta. + +## Zakres + +**Wchodzi:** + +- 1.1.1 — obraz bez `alt` w `504.html` +- 1.1.1 — martwy szablon z obrazem bez `alt` (usunięcie, nie łatanie) +- 3.1.2 — atrybuty `lang` na tytułach obcojęzycznych, **oba wektory** +- warunek konieczny dla 3.1.2: rozszerzenie allowlisty sanityzatora +- wykaz świadomie odroczonych niezgodności +- korekta błędnego uzasadnienia w specyfikacji z 2026-08-05 + +**Nie wchodzi (odroczone świadomie, wpis w wykazie):** + +- 2.1.4 — skrót `/` +- 2.5.7 — nawigacja po grafie powiązań + +**Nie wchodzi (poza tą iteracją):** + +- bramka CI z axe-core, baseline, strona wzorników +- skan szeroki na dumpie, audyt ręczny WCAG-EM, raport zgodności +- hipotezy do zbadania (3.3.7, 3.3.8, CAPTCHA, 2.5.8) + +## Naprawa 1 — 1.1.1, obraz bez `alt` w `504.html` + +`src/bpp/templates/504.html:12` renderuje ikonę wewnątrz `

`: + +```html +

+ + Przekroczono dozwolony czas wykonywania zapytania +

+``` + +Obok obrazu stoi pełny tekst komunikatu. Ikona nie niesie żadnej informacji, +której nie ma w tekście — jest dekoracją. Właściwą wartością jest `alt=""` +(pusty), nie opis: pusty `alt` każe czytnikowi **pominąć** element, podczas +gdy brak atrybutu każe mu odczytać nazwę pliku („database dot es vee gee"). + +Opis w rodzaju `alt="ikona bazy danych"` byłby błędem — powtarzałby +dekorację jako treść i wydłużał odczyt strony błędu. + +## Naprawa 2 — 1.1.1, martwy szablon + +`src/bpp/templates/user_navigation_autocomplete.html:7` zawiera `` bez +`alt`. Szablon jest **nieużywany**: przeszukanie całego repozytorium (poza +`.git`, `node_modules`, `.venv`) znajduje jego nazwę wyłącznie w +specyfikacji z 2026-08-05. Żaden widok, tag ani `include` go nie renderuje. + +Widok pod `bpp:navigation-autocomplete` (`src/bpp/urls.py:496`) to +`GlobalNavigationAutocomplete` (`src/bpp/views/autocomplete/navigation.py:91`) +— podklasa `Select2QuerySetSequenceView` z django-autocomplete-light. Zwraca +JSON; listę podpowiedzi rysuje Select2 po stronie klienta. Szablon jest +pozostałością po wcześniejszej implementacji. + +**Decyzja: usuwamy plik**, nie dopisujemy `alt`. + +Uzasadnienie: naruszenie w kodzie, którego nikt nie renderuje, nie jest +naruszeniem — użytkownik nigdy tej strony nie zobaczy. Dopisanie `alt` +utrwaliłoby martwy plik i przy następnym audycie znowu trafiłby na listę +jako „widok do zbadania". Usunięcie zamyka sprawę. + +To zarazem korekta specyfikacji z 2026-08-05, która wymienia ten plik jako +jeden z dwóch widoków wymagających poprawki `alt`. Metoda (grep po `` +- kod pusty, brak relacji, lub relacja bez `kod_bcp47` → **nie dodawaj + atrybutu wcale** + +`lang=""` jest gorsze niż brak atrybutu: pusta wartość jest w HTML +traktowana jako „język nieznany" i unieważnia dziedziczenie z ``, czyli psuje odczyt fragmentu, który bez atrybutu byłby +odczytany poprawnie po polsku. + +Oba tytuły wymagają **osobnych** znaczników. Wspólny `` obejmujący +blok „tytuł oryginalny (przekład)" oznaczyłby jednym językiem dwa fragmenty +w różnych językach — gorzej niż nie oznaczyć nic. + +### Wektor 1 — szablony stron szczegółowych + +Trzy miejsca renderują tytuł bezpośrednio z pól modelu: + +- `src/bpp/templates/browse/praca_tabela_mono.html:17-21` +- `src/bpp/templates/browse/praca_tabela.html:7-10` +- `src/bpp/templates/browse/praca.html:49` (breadcrumb) + +Zmiana działa natychmiast po wdrożeniu — szablon renderuje się przy każdym +żądaniu z aktualnych danych relacyjnych. + +Uwaga na filtry: tytuły przechodzą przez `|safe_tytul` (mono), `|safe` +(tabela) i `|truncatewords_html:15|safe_tytul` (breadcrumb). Znacznik +`` dokładamy **wokół** wyniku filtra, w szablonie — nie wewnątrz +wartości pola. Sanityzator tytułu (`safe_tytul_html`) nie ma z nim +styczności. + +### Wektor 2 — generator opisu bibliograficznego + +Listy `browse`, wyniki multiseek i wydawnictwa powiązane (31 miejsc w +szablonach) nie renderują pól — wstawiają gotowy HTML z +`opis_bibliograficzny_cache`. Tytuł siedzi **wewnątrz** tego blobu, +wymieszany z polszczyzną: + +``` +Kowalski Jan, Nowak Anna. Effects of X on Y. Postępy Higieny +i Medycyny Doświadczalnej 2024, t. 78, s. 112-119. +``` + +Owinięcie całego blobu w `lang="en"` byłoby błędem — oznaczyłoby polskie +nazwiska i polską nazwę czasopisma jako angielskie. Wycinanie tytułu z blobu +przy renderowaniu odpada: dopasowanie stringu jest kruche przy tytułach +zawierających zamierzone ``/``. + +Zostaje jedyna droga: **znacznik wstawia generator**, czyli trafia do środka +cache'owanego HTML-u w chwili jego powstawania. + +Generator to szablon Django `src/bpp/templates/opis_bibliograficzny.html:7-11`, +renderowany przez `ModelZOpisemBibliograficznym.opis_bibliograficzny()` +(`src/bpp/models/util.py:91`). Zmiana jest tam symetryczna do wektora 1. + +## Warunek konieczny — allowlista sanityzatora + +`opis_bibliograficzny()` kończy się przepuszczeniem złożenia przez +`safe_opis_bibliograficzny_html` (`src/bpp/util/text.py:316`), czyli przez +nh3 z wąską allowlistą (`text.py:306-313`): + +```python +class safe_opis_bibliograficzny_defaults: + ALLOWED_TAGS = safe_tytul_defaults.ALLOWED_TAGS + ("a",) + ALLOWED_ATTRIBUTES = {"a": ["href", "title", "rel"]} +``` + +gdzie `safe_tytul_defaults.ALLOWED_TAGS == ("i", "em", "b", "strong", +"sub", "sup", "u")`. + +`span` nie jest dozwolonym tagiem, a atrybuty ma wyłącznie ``. Bez +zmiany allowlisty `Tytuł` zostałby zredukowany do +gołego `Tytuł` — poprawka wektora 2 cicho by nie zadziałała. + +**Zmiana:** dopisać `"span"` do `ALLOWED_TAGS` i `"span": ["lang"]` do +`ALLOWED_ATTRIBUTES`. + +Ocena ryzyka: `lang` jest atrybutem czysto deklaratywnym — nie wykonuje +kodu, nie ładuje zasobów, nie wpływa na układ. `span` bez `style` i bez +`class` nie pozwala na nadpisanie wyglądu. Rozszerzenie jest wąskie i nie +otwiera nowego wektora XSS. + +Obie wartości są nadpisywalne przez `settings.OPIS_BIBLIOGRAFICZNY_ALLOWED_TAGS` +i `..._ALLOWED_ATTRIBUTES`. Wdrożenie z własnym override'em dostanie starą, +wąską listę i straci znaczniki — to zachowanie akceptowalne (override jest +świadomą decyzją administratora), ale wymaga wzmianki w wykazie. + +## Rollout wektora 2 + +`opis_bibliograficzny_cache` to **dane**, nie kod: w bazie leżą gotowe +stringi HTML wygenerowane wcześniejszą wersją generatora. Deploy podmienia +szablon, ale nie dotyka zapisanych wierszy. + +Regeneracja dzieje się sama, w istniejącym cyklu nocnym — bez ręcznej +interwencji i bez okna serwisowego ponad to, które już istnieje: + +1. **22:00** — Ofelia (`bpp-deploy/docker-compose.application.yml:117-118`) + uruchamia `python src/manage.py denorm_rebuild --no-flush`. `rebuildall()` + oznacza wszystkie wiersze jako brudne. +2. **w nocy** — kontener `denorm-queue` przekazuje LISTEN/NOTIFY do kolejki + celery `denorm`; `flush_single` przelicza rekord i zapisuje nowy + `opis_bibliograficzny_cache` w tabeli źródłowej. +3. **natychmiast po tym UPDATE** — trigger `bpp_refresh_cache()` + (`src/bpp/migrations/107_cache_functions.sql:5`) robi `DELETE` + + `INSERT ... SELECT` z widoku, więc `bpp_rekord_mat` (materializacja, z + której czytają listy) dostaje nową treść. + +Istotne: **`denorm_rebuild` (nie `denorm_flush`)**. Flush opróżnia tylko +kolejkę brudnych wierszy, a podmiana kodu Pythona nie brudzi żadnego — +harmonogram oparty na `flush` nie zmieniłby nic. Rebuild sam oznacza +wszystko. + +Pierwsza noc po wdrożeniu jest cięższa niż zwykle: dziś rebuild zwykle +zastaje identyczne wartości, więc zapisów i strzałów triggera jest mało; po +zmianie generatora różni się każdy wiersz. Operacja mieści się w +istniejącym oknie (przed backupem o 2:30 — +`bpp-deploy/docker-compose.backup.yml:64`) i nie wymaga człowieka. + +**Szablon jest na dysku, nie w bazie.** Migracja `0295_instaluj_szablony.py` +kiedyś kopiowała `opis_bibliograficzny.html` do `dbtemplates`, ale +`0473_szablon_nazwa_szablonu.py` to odwróciła: usunęła FK +`SzablonDlaOpisuBibliograficznego.template`, wprowadziła zwykłe +`nazwa_szablonu` z domyślnym `"opis_bibliograficzny.html"`, a +`purge_opis_dbtemplate` skasowała wiersze dbtemplate dla wszystkich +używanych nazw. Zmiana pliku w repozytorium dociera więc do każdego +wdrożenia zwykłym deployem — bez migracji danych i bez ryzyka skasowania +customizacji uczelni. + +## Wpływ na konsumentów opisu + +`opis_bibliograficzny_cache` nie służy wyłącznie do publicznego HTML-a. +Dodanie `` zmienia to, co dostają: + +| konsument | plik | ocena | +|---|---|---| +| REST API `/api/v1/` | `src/api_v1/serializers/szukaj.py:28` | zwraca string HTML; już zawiera ``/`` | +| widget publikacji | `src/api_v1/viewsets/recent_publications_common.py:135` | jw. | +| wydruki PDF | `src/oswiadczenia/` (weasyprint) | `` nieszkodliwy | +| DjangoQL | `src/bpp/djangoql_schema.py:105-108` | `strip_tags` — bez zmian | +| filtr kolejki PBN | `src/pbn_export_queue/views/list_views.py:81` | **do testu** | + +Ostatnia pozycja to jedyna realna: `search_lower in +record.opis_bibliograficzny_cache.lower()` szuka podłańcucha w surowym +HTML-u, więc znacznik wstawiony między słowa może rozbić dopasowanie. Blob +już dziś zawiera znaczniki, więc klasa problemu nie jest nowa — ale wstawiamy +je w nowym miejscu (przed tytułem), co wymaga testu. + +## Testy + +Konwencja pytest (funkcje, `@pytest.mark.django_db`, `baker.make`). + +**3.1.2, wektor 1** — dla każdego z trzech szablonów: +- tytuł oryginalny z `kod_bcp47="en"` → w wyjściu jest `lang="en"` +- `kod_bcp47=""` → w wyjściu **nie ma** `lang=` przy tytule +- tytuł oryginalny i przekład w różnych językach → dwa różne `lang`, + nierozłączne (żaden nie obejmuje drugiego) + +**3.1.2, wektor 2** — na `opis_bibliograficzny()`: +- te same trzy przypadki na wygenerowanym opisie +- **znacznik przeżywa sanityzację** — asercja wprost na wyniku + `opis_bibliograficzny()`, nie na samym szablonie; to test broniący + allowlisty przed cofnięciem + +**Sanityzator** — regresja bezpieczeństwa na `safe_opis_bibliograficzny_html`: +- `` nadal usuwany razem z treścią +- `` → atrybut zdarzenia usunięty, `span` zostaje +- ``, `` → atrybuty usunięte +- `` → przechodzi w całości + +**Filtr kolejki PBN** — wyszukiwanie frazy w opisie zawierającym `` nadal znajduje rekord dla frazy niesąsiadującej ze znacznikiem; +przypadek frazy przeciętej znacznikiem odnotowany asercją zgodną z +zastanym zachowaniem (dokumentuje stan, nie udaje naprawy). + +**1.1.1** — asercja, że `504.html` renderuje ` Date: Thu, 6 Aug 2026 19:49:15 +0200 Subject: [PATCH 02/20] docs(wcag): korekty po recenzji adwersarialnej MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recenzja podważyła cztery ustalenia pierwszej wersji; wszystkie zweryfikowane w kodzie i przyjęte. - browse/praca_tabela.html NIE jest stroną szczegółów, tylko alternatywnym szablonem opisu bibliograficznego (instalowany przez migrację 0295 obok opis_bibliograficzny.html; praca.html:54 włącza wyłącznie wariant _mono). Przechodzi więc ścieżką wektora 2, nie 1. Pierwsza wersja popełniła dokładnie ten błąd, który wytykała poprzednikowi: uznała plik za żywy widok bez sprawdzenia, kto go renderuje. - Argument, że założenie o semantyce jezyk_alt jest "bezpieczne", był fałszywy: przy WYPEŁNIONYM polu o innym znaczeniu tytuł dostałby BŁĘDNY lang, co dokument sam nazywa gorszym niż brak. Zawężamy do tytułu oryginalnego (jezyk); przekład trafia do wykazu jako niezgodność częściowa. Fakt osłabiający hipotezę: eksport CERIF nie używa jezyk_alt do oznaczania tytułów. - Tabela konsumentów opisu miała 5 pozycji zamiast ~15 i błędnie etykietowała eksport oświadczeń jako PDF/weasyprint (to XLSX z surowym HTML w komórce). Uzupełniona i podzielona wg mechanizmu. - Wyszukiwanie po podłańcuchu w surowym HTML występuje w czterech miejscach, nie jednym — w tym w PUBLICZNEJ wyszukiwarce rekordów powiązanych (praca_tabela_mono.html:676,914), czyli w zakresie audytu. Gałąź w list_views.py to elif, aktywny tylko dla rekordów bez tytul_oryginalny — test musi ten warunek odtworzyć. Drobne: denorm nie zależy od bpp.Jezyk (uzupełnienie kod_bcp47 nie brudzi cache), zastrzeżenie dla wdrożeń bez harmonogramu Ofelii, wpis w wykazie dla instalacji z własnym szablonem opisu, testy breadcrumbu ograniczone do przypadków faktycznie tam występujących. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E8RjSZWMR6L7fuRDJJZW4L --- ...6-08-06-wcag-naprawy-stwierdzone-design.md | 256 ++++++++++++------ 1 file changed, 180 insertions(+), 76 deletions(-) diff --git a/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md b/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md index a5fa1723a..c09b12da9 100644 --- a/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md +++ b/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md @@ -25,15 +25,16 @@ i raportu bez odbiorcy byłoby produkcją artefaktu, którego nikt nie czyta. - 1.1.1 — obraz bez `alt` w `504.html` - 1.1.1 — martwy szablon z obrazem bez `alt` (usunięcie, nie łatanie) -- 3.1.2 — atrybuty `lang` na tytułach obcojęzycznych, **oba wektory** +- 3.1.2 — atrybuty `lang` na tytułach oryginalnych, **oba wektory** - warunek konieczny dla 3.1.2: rozszerzenie allowlisty sanityzatora - wykaz świadomie odroczonych niezgodności -- korekta błędnego uzasadnienia w specyfikacji z 2026-08-05 +- korekta błędnych ustaleń w specyfikacji z 2026-08-05 **Nie wchodzi (odroczone świadomie, wpis w wykazie):** - 2.1.4 — skrót `/` - 2.5.7 — nawigacja po grafie powiązań +- 3.1.2 dla tytułu przełożonego (`tytul`) — patrz „Decyzja o polu `jezyk_alt`" **Nie wchodzi (poza tą iteracją):** @@ -99,61 +100,83 @@ Dane są już w modelu: `Jezyk.kod_bcp47` czyli dokładnie to, czego wymaga atrybut `lang`. Pole dodano dla eksportu CERIF/OpenAIRE. -### Źródło kodu językowego +### Decyzja o polu `jezyk_alt` Model publikacji (`src/bpp/models/abstract/publication_base.py:46-66`) ma trzy pola językowe: | pole | znaczenie | użycie tutaj | |---|---|---| -| `jezyk` | Język (wymagany) | język `tytul_oryginalny` | -| `jezyk_alt` | Język alternatywny (opcjonalny) | język `tytul` (przekład) | +| `jezyk` | Język (wymagany) | **język `tytul_oryginalny`** | +| `jezyk_alt` | Język alternatywny (opcjonalny) | nie używamy — patrz niżej | | `jezyk_orig` | Język oryginalny, dla tłumaczeń, eksport do PBN | nie używamy | -`jezyk_orig` jest jawnie udokumentowany jako pole dla tłumaczeń -eksportowane do PBN i nie opisuje języka żadnego z dwóch wyświetlanych -tytułów — pomijamy. - -**Założenie do potwierdzenia:** `jezyk_alt` opisuje język tytułu -przełożonego (`tytul`). Pole nie ma `help_text`, a w kodzie występuje -wyłącznie jako pozycja w fieldsetach admina, serializerach API i eksporcie -CERIF — żadne z tych miejsc nie ujawnia semantyki. Jeżeli założenie jest -błędne, `tytul` po prostu nie dostaje atrybutu (patrz reguła niżej) i -poprawka pozostaje bezpieczna, tylko węższa. +`jezyk_orig` jest udokumentowany jako pole dla tłumaczeń eksportowane do +PBN i nie opisuje języka żadnego z dwóch wyświetlanych tytułów. + +Semantyka `jezyk_alt` jest **nieustalona**. Pole nie ma `help_text`, a w +kodzie występuje wyłącznie jako pozycja w fieldsetach admina +(`src/bpp/admin/helpers/fieldsets.py:104`), w serializerach API, w eksporcie +XLSX (`src/bpp/admin/xlsx_export/resources.py:82`) i w komendzie +`ukryj_nieuzywane_jezyki` — żadne z tych miejsc nie ujawnia, co pole +oznacza. Fakt osłabiający hipotezę „to język przekładu": eksport CERIF +oznacza tytuły językami z `jezyk` i `dodatkowe_tytuly.jezyk`, a `jezyk_alt` +bierze wyłącznie do `select_related` — gdyby opisywał język tytułu +przełożonego, byłby tam użyty. + +**Decyzja: w tej iteracji oznaczamy wyłącznie `tytul_oryginalny`, językiem +z `jezyk`. Tytuł przełożony (`tytul`) nie dostaje atrybutu.** + +Uzasadnienie: przy **wypełnionym** `jezyk_alt` o innej semantyce +oznaczylibyśmy tytuł błędnym językiem, a błędne oznaczenie jest gorsze niż +brak (patrz „Reguła"). Bez atrybutu tytuł przełożony dziedziczy `lang="pl"` +ze strony, co dla polskiego przekładu — przypadek dominujący w polskiej +bibliografii — jest prawdą. + +Koszt tej ostrożności: rekordy, w których przekład jest obcojęzyczny (tytuł +oryginalny polski, `tytul` angielski), pozostają nieoznaczone. Trafia to do +wykazu jako niezgodność częściowa, do domknięcia po ustaleniu semantyki +`jezyk_alt` z osobą znającą historię modelu. ### Reguła -Dla każdego z dwóch tytułów niezależnie: +Dla `tytul_oryginalny`: -- kod języka niepusty → owiń w `` -- kod pusty, brak relacji, lub relacja bez `kod_bcp47` → **nie dodawaj - atrybutu wcale** +- `kod_bcp47` niepusty → owiń w `` +- kod pusty, brak relacji `jezyk`, lub relacja bez `kod_bcp47` → **nie + dodawaj atrybutu wcale** `lang=""` jest gorsze niż brak atrybutu: pusta wartość jest w HTML traktowana jako „język nieznany" i unieważnia dziedziczenie z ``, czyli psuje odczyt fragmentu, który bez atrybutu byłby -odczytany poprawnie po polsku. +odczytany poprawnie po polsku. Z tego samego powodu nie zgadujemy języka, +gdy danych brak. -Oba tytuły wymagają **osobnych** znaczników. Wspólny `` obejmujący -blok „tytuł oryginalny (przekład)" oznaczyłby jednym językiem dwa fragmenty -w różnych językach — gorzej niż nie oznaczyć nic. +Znacznik obejmuje **wyłącznie** tytuł oryginalny — nigdy bloku „tytuł +oryginalny (przekład)" w całości. Wspólny `` oznaczyłby jednym +językiem dwa fragmenty w różnych językach. ### Wektor 1 — szablony stron szczegółowych -Trzy miejsca renderują tytuł bezpośrednio z pól modelu: +Dwa żywe miejsca renderują tytuł bezpośrednio z pól modelu: -- `src/bpp/templates/browse/praca_tabela_mono.html:17-21` -- `src/bpp/templates/browse/praca_tabela.html:7-10` -- `src/bpp/templates/browse/praca.html:49` (breadcrumb) +- `src/bpp/templates/browse/praca_tabela_mono.html:17-21` — treść strony + szczegółów publikacji +- `src/bpp/templates/browse/praca.html:49` — breadcrumb Zmiana działa natychmiast po wdrożeniu — szablon renderuje się przy każdym żądaniu z aktualnych danych relacyjnych. -Uwaga na filtry: tytuły przechodzą przez `|safe_tytul` (mono), `|safe` -(tabela) i `|truncatewords_html:15|safe_tytul` (breadcrumb). Znacznik -`` dokładamy **wokół** wyniku filtra, w szablonie — nie wewnątrz -wartości pola. Sanityzator tytułu (`safe_tytul_html`) nie ma z nim -styczności. +Uwaga na filtry: tytuły przechodzą przez `|safe_tytul` (mono) i +`|truncatewords_html:15|safe_tytul` (breadcrumb). Znacznik `` +dokładamy **wokół** wyniku filtra, w szablonie — nie wewnątrz wartości pola. +Sanityzator tytułu (`safe_tytul_html`) nie ma z nim styczności. + +Breadcrumb operuje na `Rekord` (materializacja), nie na modelu źródłowym. +`RekordBase` dziedziczy `ModelTypowany` (`src/bpp/models/cache/rekord.py:204`), +więc `jezyk` jest dostępne. `jezyk_alt` i `jezyk_orig` są tam ustawione na +sztywno na `None` (`rekord.py:262-263`) — bez znaczenia, skoro ich nie +używamy. ### Wektor 2 — generator opisu bibliograficznego @@ -175,9 +198,27 @@ zawierających zamierzone ``/``. Zostaje jedyna droga: **znacznik wstawia generator**, czyli trafia do środka cache'owanego HTML-u w chwili jego powstawania. -Generator to szablon Django `src/bpp/templates/opis_bibliograficzny.html:7-11`, -renderowany przez `ModelZOpisemBibliograficznym.opis_bibliograficzny()` -(`src/bpp/models/util.py:91`). Zmiana jest tam symetryczna do wektora 1. +Generator to szablon Django renderowany przez +`ModelZOpisemBibliograficznym.opis_bibliograficzny()` +(`src/bpp/models/util.py:91`). Nazwę szablonu wskazuje +`SzablonDlaOpisuBibliograficznego.nazwa_szablonu`, domyślnie +`opis_bibliograficzny.html`. + +**Dwa szablony do edycji**, oba są wariantami generatora opisu: + +- `src/bpp/templates/opis_bibliograficzny.html:7-11` — domyślny +- `src/bpp/templates/browse/praca_tabela.html:7-10` — wariant alternatywny + +Drugi bywa mylony ze stroną szczegółów, bo leży w katalogu `browse/`. Nie +jest stroną: `praca.html:54` włącza wyłącznie `praca_tabela_mono.html`, a +jedyne odwołanie do `praca_tabela.html` w repozytorium to migracja +`0295_instaluj_szablony.py:25`, która instalowała go do `dbtemplates` obok +`opis_bibliograficzny.html` — czyli jako drugi oferowany format opisu. +Wdrożenie, które ustawiło na niego `nazwa_szablonu`, renderuje przez niego +opisy; edytujemy go, żeby takie wdrożenia też dostały znaczniki. + +Ten plik przechodzi więc ścieżką wektora 2 (post-processing stringowy + +sanityzator + cache), nie wektora 1. ## Warunek konieczny — allowlista sanityzatora @@ -208,8 +249,13 @@ otwiera nowego wektora XSS. Obie wartości są nadpisywalne przez `settings.OPIS_BIBLIOGRAFICZNY_ALLOWED_TAGS` i `..._ALLOWED_ATTRIBUTES`. Wdrożenie z własnym override'em dostanie starą, -wąską listę i straci znaczniki — to zachowanie akceptowalne (override jest -świadomą decyzją administratora), ale wymaga wzmianki w wykazie. +wąską listę i straci znaczniki — zachowanie akceptowalne (override jest +świadomą decyzją administratora), ale wymaga wpisu w wykazie. + +Post-processing stringowy w `opis_bibliograficzny()` (`util.py:106-121` — +kolaps spacji i normalizacja interpunkcji: `" , "`, `" . "`, `". . "`, +`". , "`, `" ."`, `".["`) nie zawiera wzorca mogącego rozciąć +``. Znacznik przechodzi bez uszczerbku. ## Rollout wektora 2 @@ -222,10 +268,11 @@ interwencji i bez okna serwisowego ponad to, które już istnieje: 1. **22:00** — Ofelia (`bpp-deploy/docker-compose.application.yml:117-118`) uruchamia `python src/manage.py denorm_rebuild --no-flush`. `rebuildall()` - oznacza wszystkie wiersze jako brudne. -2. **w nocy** — kontener `denorm-queue` przekazuje LISTEN/NOTIFY do kolejki - celery `denorm`; `flush_single` przelicza rekord i zapisuje nowy - `opis_bibliograficzny_cache` w tabeli źródłowej. + oznacza wszystkie wiersze jako brudne, bez synchronicznego przeliczania. +2. **w nocy** — kontener `denorm-queue` + (`bpp-deploy/docker-compose.workers.yml:69`) przekazuje PostgreSQL + LISTEN/NOTIFY do kolejki celery `denorm`; zadanie przelicza rekord i + zapisuje nowy `opis_bibliograficzny_cache` w tabeli źródłowej. 3. **natychmiast po tym UPDATE** — trigger `bpp_refresh_cache()` (`src/bpp/migrations/107_cache_functions.sql:5`) robi `DELETE` + `INSERT ... SELECT` z widoku, więc `bpp_rekord_mat` (materializacja, z @@ -242,8 +289,13 @@ zmianie generatora różni się każdy wiersz. Operacja mieści się w istniejącym oknie (przed backupem o 2:30 — `bpp-deploy/docker-compose.backup.yml:64`) i nie wymaga człowieka. +**Zastrzeżenie:** powyższe zweryfikowano dla stacka `bpp-deploy`. +Harmonogram żyje w labelach Ofelii, nie w `CELERY_BEAT_SCHEDULE`, więc +wdrożenie prowadzone inaczej może go nie mieć — tam cache nie odświeży się +samo i wymaga jednorazowego `manage.py denorm_rebuild`. + **Szablon jest na dysku, nie w bazie.** Migracja `0295_instaluj_szablony.py` -kiedyś kopiowała `opis_bibliograficzny.html` do `dbtemplates`, ale +kiedyś kopiowała szablony opisu do `dbtemplates`, ale `0473_szablon_nazwa_szablonu.py` to odwróciła: usunęła FK `SzablonDlaOpisuBibliograficznego.template`, wprowadziła zwykłe `nazwa_szablonu` z domyślnym `"opis_bibliograficzny.html"`, a @@ -255,37 +307,68 @@ customizacji uczelni. ## Wpływ na konsumentów opisu `opis_bibliograficzny_cache` nie służy wyłącznie do publicznego HTML-a. -Dodanie `` zmienia to, co dostają: +Poniżej pełna lista konsumentów z oceną wpływu `` wstawionego +przed tytułem. + +**Neutralizują znacznik (usuwają HTML) — bez wpływu:** + +| konsument | plik | mechanizm | +|---|---|---| +| eksport multiseek XLSX/DOCX | `src/bpp/views/multiseek_export.py:113,149` | konwersja HTML → czysty tekst | +| raporty DOCX | `src/nowe_raporty/docx_export.py:15-35` | nh3 z własną allowlistą bez `span` | +| DjangoQL | `src/bpp/djangoql_schema.py:105-108` | `strip_tags` | +| indeks pełnotekstowy | `src/bpp/management/commands/rebuild_search_index.py:84`, `0428_weighted_publication_fulltext.sql` | SQL-owy `strip_tags` | + +**Przekazują HTML dalej — znacznik trafia do odbiorcy:** | konsument | plik | ocena | |---|---|---| -| REST API `/api/v1/` | `src/api_v1/serializers/szukaj.py:28` | zwraca string HTML; już zawiera ``/`` | +| REST API `/api/v1/` | `src/api_v1/serializers/szukaj.py:28` | string HTML; już zawiera ``/`` | | widget publikacji | `src/api_v1/viewsets/recent_publications_common.py:135` | jw. | -| wydruki PDF | `src/oswiadczenia/` (weasyprint) | `` nieszkodliwy | -| DjangoQL | `src/bpp/djangoql_schema.py:105-108` | `strip_tags` — bez zmian | -| filtr kolejki PBN | `src/pbn_export_queue/views/list_views.py:81` | **do testu** | +| modal globalnego wyszukiwania | `src/bpp/views/autocomplete/navigation.py:111` | etykieta renderowana jako HTML — znacznik **pomaga** 3.1.2 | +| eksport XLSX oświadczeń | `src/oswiadczenia/views.py:446` | surowy HTML w komórce arkusza (stan zastany, nie pogarszamy) | +| szablony przeglądania i admina | `bpp/zapytanie.html`, `deduplikator_autorow`, `rozbieznosci`, `przemapuj_zrodla_pbn/views.py:492` | renderowane `|safe` | + +**Wyszukiwanie po podłańcuchu w surowym HTML — wymaga testu:** + +| miejsce | plik | uwaga | +|---|---|---| +| filtr kolejki PBN (lista) | `src/pbn_export_queue/views/list_views.py:76-83` | gałąź `elif` — działa **tylko gdy rekord nie ma `tytul_oryginalny`** | +| filtr kolejki PBN (akcje) | `src/pbn_export_queue/views/action_views.py:383-386` | ten sam wzorzec | +| pomocnik tytułu | `src/pbn_export_queue/views/utils.py:188-200` | fallback na opis | +| **rekordy powiązane (publiczne)** | `src/bpp/templates/browse/praca_tabela_mono.html:676,914` | `data-records` z `\|safe\|escapejs`; JS szuka po surowym HTML i podświetla przez `replace` | -Ostatnia pozycja to jedyna realna: `search_lower in -record.opis_bibliograficzny_cache.lower()` szuka podłańcucha w surowym -HTML-u, więc znacznik wstawiony między słowa może rozbić dopasowanie. Blob -już dziś zawiera znaczniki, więc klasa problemu nie jest nowa — ale wstawiamy -je w nowym miejscu (przed tytułem), co wymaga testu. +Ostatnia pozycja jest najistotniejsza, bo leży w **zakresie audytu** — to +wyszukiwarka na publicznej stronie szczegółów, a nie narzędzie +administracyjne. Znacznik wstawiony przed tytułem może rozciąć frazę +szukaną przez użytkownika i zaburzyć podświetlanie. + +Blob już dziś zawiera znaczniki, więc klasa problemu nie jest nowa — ale +wstawiamy je w nowym miejscu, więc każda z czterech pozycji dostaje test. ## Testy Konwencja pytest (funkcje, `@pytest.mark.django_db`, `baker.make`). -**3.1.2, wektor 1** — dla każdego z trzech szablonów: -- tytuł oryginalny z `kod_bcp47="en"` → w wyjściu jest `lang="en"` +**3.1.2, wektor 1 — `praca_tabela_mono.html`:** +- `jezyk.kod_bcp47="en"` → w wyjściu `lang="en"` obejmujący tytuł oryginalny - `kod_bcp47=""` → w wyjściu **nie ma** `lang=` przy tytule -- tytuł oryginalny i przekład w różnych językach → dwa różne `lang`, - nierozłączne (żaden nie obejmuje drugiego) +- rekord z wypełnionym `tytul` (przekład) → znacznik obejmuje wyłącznie + tytuł oryginalny, przekład zostaje poza nim + +**3.1.2, wektor 1 — `praca.html` (breadcrumb):** +- dwa pierwsze przypadki jak wyżej. Breadcrumb renderuje wyłącznie + `tytul_oryginalny`, więc przypadek z przekładem go nie dotyczy. -**3.1.2, wektor 2** — na `opis_bibliograficzny()`: -- te same trzy przypadki na wygenerowanym opisie +**3.1.2, wektor 2** — na `opis_bibliograficzny()`, dla obu szablonów +(`opis_bibliograficzny.html` i `browse/praca_tabela.html`, przełączane +przez `SzablonDlaOpisuBibliograficznego.nazwa_szablonu`): +- te same przypadki na wygenerowanym opisie - **znacznik przeżywa sanityzację** — asercja wprost na wyniku - `opis_bibliograficzny()`, nie na samym szablonie; to test broniący - allowlisty przed cofnięciem + `opis_bibliograficzny()`, nie na renderze samego szablonu; to test + broniący allowlisty przed cofnięciem +- post-processing nie uszkadza znacznika (opis z tytułem sąsiadującym z + interpunkcją normalizowaną w `util.py:106-121`) **Sanityzator** — regresja bezpieczeństwa na `safe_opis_bibliograficzny_html`: - `` nadal usuwany razem z treścią @@ -293,15 +376,23 @@ Konwencja pytest (funkcje, `@pytest.mark.django_db`, `baker.make`). - ``, `` → atrybuty usunięte - `` → przechodzi w całości -**Filtr kolejki PBN** — wyszukiwanie frazy w opisie zawierającym `` nadal znajduje rekord dla frazy niesąsiadującej ze znacznikiem; -przypadek frazy przeciętej znacznikiem odnotowany asercją zgodną z -zastanym zachowaniem (dokumentuje stan, nie udaje naprawy). +**Wyszukiwanie po podłańcuchu** — po jednym teście na każdą z czterech +pozycji z tabeli: +- `list_views.py` i `action_views.py`: rekord **bez** `tytul_oryginalny` + (inaczej test nie trafi w gałąź `elif`), fraza z opisu niesąsiadująca ze + znacznikiem → rekord nadal znajdowany +- `utils.py`: fallback zwraca opis ze znacznikiem +- rekordy powiązane: test szablonowy sprawdzający, że `data-records` + zawiera znacznik, plus test JS (vitest) na wyszukiwanie i podświetlanie + w tekście ze znacznikiem + +Przypadek frazy przeciętej znacznikiem odnotowujemy asercją zgodną z +zastanym zachowaniem — dokumentuje stan, nie udaje naprawy. **1.1.1** — asercja, że `504.html` renderuje ` Date: Thu, 6 Aug 2026 21:21:40 +0200 Subject: [PATCH 03/20] =?UTF-8?q?docs(wcag):=20ustalona=20semantyka=20jezy?= =?UTF-8?q?k=5Falt=20=E2=80=94=20model=20nie=20ma=20jezyka=20przekladu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autor modelu potwierdza: jezyk_alt to odwzorowanie atrybutu z API PBN (pbn.nauka.gov.pl/api/v1/), skopiowane "na zapas", oznaczajace drugi jezyk PRACY (publikacja dwujezyczna) — nie jezyk tytulu przelozonego. Potwierdzenia w kodzie: adapter PBN wysyla mainLanguage z jezyk i originalLanguage z jezyk_orig, a jezyk_alt mimo pochodzenia z PBN nie jest tam eksportowane; zaden importer go nie wypelnia (wchodzi wylacznie recznie z admina); CERIF oznacza tytuly przez jezyk i dodatkowe_tytuly.jezyk. Decyzja bez zmian (oznaczamy tylko tytul_oryginalny), ale uzasadnienie sie zmienia: to nie ostroznosc wobec niepewnej semantyki, tylko brak danych. Zmienia to tez charakter wpisu w wykazie — domkniecie 3.1.2 dla przekladu wymaga NOWEGO pola w modelu (migracja + uzupelnianie danych przez uczelnie), a nie wykorzystania istniejacego. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E8RjSZWMR6L7fuRDJJZW4L --- ...6-08-06-wcag-naprawy-stwierdzone-design.md | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md b/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md index c09b12da9..3af83b95e 100644 --- a/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md +++ b/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md @@ -114,29 +114,42 @@ trzy pola językowe: `jezyk_orig` jest udokumentowany jako pole dla tłumaczeń eksportowane do PBN i nie opisuje języka żadnego z dwóch wyświetlanych tytułów. -Semantyka `jezyk_alt` jest **nieustalona**. Pole nie ma `help_text`, a w -kodzie występuje wyłącznie jako pozycja w fieldsetach admina -(`src/bpp/admin/helpers/fieldsets.py:104`), w serializerach API, w eksporcie -XLSX (`src/bpp/admin/xlsx_export/resources.py:82`) i w komendzie -`ukryj_nieuzywane_jezyki` — żadne z tych miejsc nie ujawnia, co pole -oznacza. Fakt osłabiający hipotezę „to język przekładu": eksport CERIF -oznacza tytuły językami z `jezyk` i `dodatkowe_tytuly.jezyk`, a `jezyk_alt` -bierze wyłącznie do `select_related` — gdyby opisywał język tytułu -przełożonego, byłby tam użyty. - -**Decyzja: w tej iteracji oznaczamy wyłącznie `tytul_oryginalny`, językiem -z `jezyk`. Tytuł przełożony (`tytul`) nie dostaje atrybutu.** - -Uzasadnienie: przy **wypełnionym** `jezyk_alt` o innej semantyce -oznaczylibyśmy tytuł błędnym językiem, a błędne oznaczenie jest gorsze niż +`jezyk_alt` **nie opisuje języka tytułu przełożonego** — ustalone z autorem +modelu: pole odwzorowuje atrybut z API PBN (`pbn.nauka.gov.pl/api/v1/`), +skopiowany do modelu „na zapas", i oznacza *drugi język pracy* (publikacja +dwujęzyczna), nie język przekładu tytułu. + +Potwierdzenia w kodzie: + +- adapter PBN (`src/pbn_api/adapters/wydawnictwo.py:312-319`) wysyła + `mainLanguage` z `jezyk` i `originalLanguage` z `jezyk_orig` — `jezyk_alt` + nie jest do PBN eksportowane mimo pochodzenia stamtąd; +- żaden importer ani adapter nie wypełnia tego pola; wchodzi wyłącznie + ręcznie z admina; +- eksport CERIF oznacza tytuły językami z `jezyk` i `dodatkowe_tytuly.jezyk`, + a `jezyk_alt` bierze tylko do `select_related`; +- pole nie ma `help_text`; poza tym występuje jedynie w fieldsetach admina + (`src/bpp/admin/helpers/fieldsets.py:104`), serializerach API, eksporcie + XLSX (`src/bpp/admin/xlsx_export/resources.py:82`) i komendzie + `ukryj_nieuzywane_jezyki`. + +**Wniosek: model nie zawiera danych o języku tytułu przełożonego.** + +**Decyzja: oznaczamy wyłącznie `tytul_oryginalny`, językiem z `jezyk`. +Tytuł przełożony (`tytul`) nie dostaje atrybutu.** + +Nie jest to ostrożność wobec niepewnej semantyki, tylko brak danych: użycie +`jezyk_alt` oznaczyłoby tytuł drugim językiem *pracy*, co przy publikacji +dwujęzycznej byłoby po prostu błędne, a błędne oznaczenie jest gorsze niż brak (patrz „Reguła"). Bez atrybutu tytuł przełożony dziedziczy `lang="pl"` -ze strony, co dla polskiego przekładu — przypadek dominujący w polskiej +ze strony, co dla polskiego przekładu — przypadku dominującego w polskiej bibliografii — jest prawdą. -Koszt tej ostrożności: rekordy, w których przekład jest obcojęzyczny (tytuł -oryginalny polski, `tytul` angielski), pozostają nieoznaczone. Trafia to do -wykazu jako niezgodność częściowa, do domknięcia po ustaleniu semantyki -`jezyk_alt` z osobą znającą historię modelu. +Koszt: rekordy, w których przekład jest obcojęzyczny (tytuł oryginalny +polski, `tytul` angielski), pozostają nieoznaczone. Domknięcie wymagałoby +**nowego pola** w modelu, nie wykorzystania istniejącego — to zmiana +schematu z migracją i uzupełnianiem danych przez uczelnie, więc jawnie poza +tą iteracją. ### Reguła @@ -423,9 +436,11 @@ wdrożeń wyłączona. **3.1.2 — tytuł przełożony (`tytul`).** Stan: **spełnione częściowo**. Oznaczamy tytuł oryginalny; przekład zostaje bez atrybutu i dziedziczy `lang="pl"`. Dla rekordów, w których przekład jest -obcojęzyczny, kryterium pozostaje niespełnione. Powód: semantyka pola -`jezyk_alt` jest nieustalona, a błędne oznaczenie byłoby gorsze niż brak. Do -domknięcia po potwierdzeniu, co pole oznacza. +obcojęzyczny, kryterium pozostaje niespełnione. Powód: **model nie zawiera +danych o języku przekładu** — `jezyk_alt` to odwzorowanie atrybutu z API PBN +oznaczające drugi język pracy, nie język tytułu przełożonego. Domknięcie +wymaga nowego pola (migracja schematu + uzupełnienie danych przez uczelnie), +więc jest osobnym zadaniem, nie doszlifowaniem tego. **3.1.2 — instalacje z niewypełnionym `kod_bcp47`.** Stan: **spełnione warunkowo**. Pole jest `blank=True`; poprawka oznacza From 8f000a25b56c78164f45111ed37a52b380b04d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 21:35:16 +0200 Subject: [PATCH 04/20] docs(wcag): plan wdrozenia napraw stwierdzonych MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dziewiec zadan w kolejnosci zaleznosci: filtr oznacz_jezyk → allowlista nh3 → wektor 1 (strony) → wektor 2 (generator opisu) → naprawa podswietlania → testy substring-search → 1.1.1 → korekty spec i newsfragmenty → weryfikacja. Dwa ustalenia dopisane do specyfikacji przy rozpisywaniu planu: - Podswietlanie w wyszukiwarce rekordow powiazanych (praca_tabela_mono.html:1140-1200) wymaga NAPRAWY, nie tylko testu. Regex podswietla na surowym HTML-u; dzis blob ma tylko /, wiec problem jest utajony, ale wnosi podciagi "en", "an", "lang" — pospolite w tytulach. Wpisanie "en" wstawia w srodek atrybutu i psuje markup przy .html(). Logika podswietlania zostaje wyekstrahowana do modulu JS (testowalnego vitestem) i poprawiona tak, by omijac zawartosc znacznikow. - 504.html:2 deklaruje przy polskiej tresci — WCAG 3.1.1 (poziom A), czytnik odczytuje CALA strone angielska fonetyka. Poprawka jednowyrazowa, w pliku i tak edytowanym w tej iteracji. Jedyny szablon z wlasnym ; reszta dziedziczy po base.html. Plan zweryfikowany wobec kodu: fixture jezyki juz ustawia kod_bcp47 (pl/en), _get_record_title czyta opis w gałęzi elif, praca_tabela_mono.html nie ma bloku extra_js ani {% load static %}, style .praca-mono__related-* siedza w praca_detail.scss:1001. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E8RjSZWMR6L7fuRDJJZW4L --- .../2026-08-06-wcag-naprawy-stwierdzone.md | 1639 +++++++++++++++++ ...6-08-06-wcag-naprawy-stwierdzone-design.md | 69 +- 2 files changed, 1695 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-06-wcag-naprawy-stwierdzone.md diff --git a/docs/superpowers/plans/2026-08-06-wcag-naprawy-stwierdzone.md b/docs/superpowers/plans/2026-08-06-wcag-naprawy-stwierdzone.md new file mode 100644 index 000000000..705da3728 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-wcag-naprawy-stwierdzone.md @@ -0,0 +1,1639 @@ +# Naprawy WCAG stwierdzone lekturą kodu — plan wdrożenia + +> **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:** Usunąć naruszenia WCAG 2.2 AA stwierdzone lekturą kodu (1.1.1 — +obrazy bez `alt`; 3.1.2 — brak atrybutów `lang` na tytułach obcojęzycznych) +bez budowania infrastruktury audytowej. + +**Architecture:** Jeden filtr szablonowy `|oznacz_jezyk:` owija tytuł +oryginalny w `` na podstawie `Jezyk.kod_bcp47`. Filtr jest +stosowany w czterech szablonach: dwóch renderujących stronę szczegółów +(efekt natychmiastowy) i dwóch będących wariantami generatora opisu +bibliograficznego (efekt po nocnym `denorm_rebuild`). Ponieważ opis +przechodzi przez sanityzator nh3 z wąską allowlistą, `span`/`lang` muszą +zostać do niej dopisane. Wstawienie znacznika do opisu psuje podświetlanie +w wyszukiwarce rekordów powiązanych (regex na surowym HTML) — logika +podświetlania zostaje wyekstrahowana do modułu JS i naprawiona. + +**Tech Stack:** Django 5.x, szablony Django, pytest + model_bakery, nh3 +(sanityzacja HTML), vitest (testy JS), towncrier (newsfragmenty). + +**Spec:** `docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md` + +## Global Constraints + +- Wszystkie polecenia Pythona przez `uv run` — nigdy gołe `python`/`pytest`. +- Max długość linii Pythona: 88 znaków (ruff). +- Testy: pytest, funkcje (nie klasy), `@pytest.mark.django_db` dla bazy, + `model_bakery.baker.make` do tworzenia obiektów. +- Komentarze w szablonach Django `{# … #}` **jedno-liniowe** — każda linia + ma własne otwarcie i zamknięcie. Wieloliniowe wyciekają do HTML-u. +- **NIE modyfikować istniejących migracji** w `src/*/migrations/`. +- Praca w worktree `/Volumes/SSD/Programowanie/bpp-wcag-naprawy`, gałąź + `fix-wcag-naprawy-stwierdzone`. +- Po zmianach uruchamiać testy lokalnie, nie zrzucać na CI. +- Newsfragmenty: `src/bpp/newsfragments/..rst` — **nie** + `changes/newsfragments/`. + +## Struktura plików + +**Tworzone:** + +| plik | odpowiedzialność | +|---|---| +| `src/bpp/static/bpp/js/related-records-highlight.js` | czysta funkcja podświetlania omijająca znaczniki HTML | +| `tests/js/related-records-highlight.test.js` | testy vitest tej funkcji | +| `src/bpp/tests/test_templatetags/test_oznacz_jezyk.py` | testy filtru | +| `src/bpp/tests/test_wcag/__init__.py` | pakiet testów WCAG | +| `src/bpp/tests/test_wcag/test_lang_szablony.py` | 3.1.2 w szablonach stron | +| `src/bpp/tests/test_wcag/test_lang_opis.py` | 3.1.2 w generatorze opisu | +| `src/bpp/tests/test_wcag/test_alt_obrazy.py` | 1.1.1 | +| `src/bpp/tests/test_util/test_sanityzator_span_lang.py` | regresja bezpieczeństwa allowlisty | +| `src/pbn_export_queue/tests/test_wyszukiwanie_opisu.py` | substring-search w kolejce PBN | +| `src/bpp/newsfragments/wcag-lang-tytulow.feature.rst` | newsfragment | +| `src/bpp/newsfragments/wcag-alt-obrazow.bugfix.rst` | newsfragment | + +**Modyfikowane:** + +| plik | zmiana | +|---|---| +| `src/bpp/templatetags/prace.py` | dodanie filtru `oznacz_jezyk`, korekta docstringu `safe_tytul` | +| `src/bpp/util/text.py:306-313` | `span` + `lang` w allowliście opisu | +| `src/bpp/templates/browse/praca_tabela_mono.html` | `lang` na tytule, podpięcie modułu JS podświetlania | +| `src/bpp/templates/browse/praca.html` | `lang` w breadcrumbie | +| `src/bpp/templates/opis_bibliograficzny.html` | `lang` w generatorze (domyślny) | +| `src/bpp/templates/browse/praca_tabela.html` | `lang` w generatorze (wariant) | +| `src/bpp/templates/504.html` | `alt=""` | +| `docs/superpowers/specs/2026-08-05-wcag-22-aa-zgodnosc-frontendu-design.md` | korekty + wykaz odroczonych | + +**Usuwane:** + +- `src/bpp/templates/user_navigation_autocomplete.html` (martwy szablon) + +--- + +### Task 1: Filtr `oznacz_jezyk` + +Filtr owija wartość w `` na podstawie obiektu `Jezyk`. +Stosowany **po** filtrach sanityzujących (`safe_tytul`, `safe`), bo sam nie +sanityzuje — tylko owija już bezpieczną wartość. + +**Files:** +- Modify: `src/bpp/templatetags/prace.py` +- Test: `src/bpp/tests/test_templatetags/test_oznacz_jezyk.py` + +**Interfaces:** +- Consumes: nic (pierwsze zadanie) +- Produces: filtr szablonowy `oznacz_jezyk` w bibliotece `prace`, sygnatura + `oznacz_jezyk(wartosc, jezyk) -> str`. Zwraca `SafeString` z opakowaniem + gdy `jezyk.kod_bcp47` jest niepuste; w każdym innym przypadku zwraca + `wartosc` bez zmian. Używany w Task 3 i Task 4. + +- [ ] **Step 1: Napisz test filtru** + +Utwórz `src/bpp/tests/test_templatetags/test_oznacz_jezyk.py`: + +```python +"""Filtr ``|oznacz_jezyk:`` — atrybut ``lang`` na tytule (WCAG 3.1.2). + +Kryterium 3.1.2 wymaga oznaczenia fragmentu w innym języku niż język +strony. ``lang=""`` (pusty) jest GORSZY niż brak atrybutu: unieważnia +dziedziczenie z ````, więc fragment, który bez atrybutu +zostałby odczytany poprawnie po polsku, staje się "językiem nieznanym". +Stąd wymóg, by przy braku danych atrybut nie pojawiał się wcale. +""" + +import pytest +from django.template import Context, Template +from django.utils.safestring import SafeString, mark_safe + +from bpp.templatetags.prace import oznacz_jezyk + + +class FakeJezyk: + """Zastępnik ``Jezyk`` — filtr czyta wyłącznie ``kod_bcp47``.""" + + def __init__(self, kod_bcp47): + self.kod_bcp47 = kod_bcp47 + + +def test_oznacz_jezyk_owija_gdy_kod_niepusty(): + assert oznacz_jezyk("Effects of X", FakeJezyk("en")) == ( + 'Effects of X' + ) + + +def test_oznacz_jezyk_obsluguje_kod_regionalny(): + assert oznacz_jezyk("Colour", FakeJezyk("en-GB")) == ( + 'Colour' + ) + + +def test_oznacz_jezyk_pomija_atrybut_gdy_kod_pusty(): + wynik = oznacz_jezyk("Tytuł", FakeJezyk("")) + assert wynik == "Tytuł" + assert "lang=" not in wynik + + +def test_oznacz_jezyk_pomija_atrybut_gdy_brak_jezyka(): + wynik = oznacz_jezyk("Tytuł", None) + assert wynik == "Tytuł" + assert "Candida w zakażeniach") + assert oznacz_jezyk(wartosc, FakeJezyk("pl")) == ( + 'Rola Candida w zakażeniach' + ) + + +def test_oznacz_jezyk_escapuje_wartosc_niebezpieczna(): + # Gdyby filtr trafił na wartość NIE-safe (pominięty |safe_tytul), + # nie wolno mu przepuścić surowego HTML-a. + wynik = oznacz_jezyk("", FakeJezyk("en")) + assert "' + ) + assert "Kowalski Jan' + ) + assert 'href="/bpp/autor/1/"' in wynik + + +def test_tag_spoza_allowlisty_nadal_usuwany(): + wynik = safe_opis_bibliograficzny_html("
blok
") + assert "
" not in wynik + assert "blok" in wynik +``` + +- [ ] **Step 2: Uruchom test — musi paść** + +```bash +uv run pytest src/bpp/tests/test_util/test_sanityzator_span_lang.py -v +``` + +Oczekiwane: FAIL na `test_span_z_lang_przechodzi` — wynik to `Effects` +(znacznik wycięty). Testy negatywne (`style`, `class`, `onmouseover`) mogą +przechodzić już teraz, bo dziś cały `span` jest usuwany. + +- [ ] **Step 3: Rozszerz allowlistę** + +W `src/bpp/util/text.py`, klasa `safe_opis_bibliograficzny_defaults` +(linie 306-313), zamień: + +```python +class safe_opis_bibliograficzny_defaults: + # Opis bibliograficzny składa się z tytułu (sanityzowanego jak wyżej) oraz + # inline'owego formatowania cytowania; w wariancie linkowanym niesie też + # odnośniki autorów (````). Dopuszczamy TE SAME tagi co tytuł plus + # ```` z samym ``href``/``rel``/``title`` — bez tagów blokowych, bez + # ``style``/``class`` i bez innych atrybutów. + ALLOWED_TAGS = safe_tytul_defaults.ALLOWED_TAGS + ("a",) + ALLOWED_ATTRIBUTES = {"a": ["href", "title", "rel"]} +``` + +na: + +```python +class safe_opis_bibliograficzny_defaults: + # Opis bibliograficzny składa się z tytułu (sanityzowanego jak wyżej) oraz + # inline'owego formatowania cytowania; w wariancie linkowanym niesie też + # odnośniki autorów (````). Dopuszczamy TE SAME tagi co tytuł plus + # ```` z samym ``href``/``rel``/``title`` oraz ```` WYŁĄCZNIE + # z ``lang`` — bez tagów blokowych, bez ``style``/``class``. + # + # ``span``/``lang`` są tu dla WCAG 3.1.2 (Language of Parts): generator + # opisu owija tytuł obcojęzyczny znacznikiem języka, a bez tego wpisu nh3 + # wyciąłby go i poprawka cicho by nie działała. ``lang`` jest atrybutem + # deklaratywnym — nie wykonuje kodu, nie ładuje zasobów, nie wpływa na + # układ; ``span`` bez ``style``/``class`` nie pozwala nadpisać wyglądu. + ALLOWED_TAGS = safe_tytul_defaults.ALLOWED_TAGS + ("a", "span") + ALLOWED_ATTRIBUTES = {"a": ["href", "title", "rel"], "span": ["lang"]} +``` + +- [ ] **Step 4: Uruchom test — musi przejść** + +```bash +uv run pytest src/bpp/tests/test_util/test_sanityzator_span_lang.py -v +``` + +Oczekiwane: 9 passed. + +- [ ] **Step 5: Uruchom istniejące testy sanityzacji (regresja)** + +```bash +uv run pytest src/bpp/tests/test_util/ -v +uv run pytest src/bpp/tests/ -k "sanity or safe_tytul or safe_opis" -v +``` + +Oczekiwane: wszystkie przechodzą — rozszerzenie jest addytywne. + +- [ ] **Step 6: Commit** + +```bash +uv run ruff format src/bpp/util/text.py \ + src/bpp/tests/test_util/test_sanityzator_span_lang.py +uv run ruff check src/bpp/util/text.py \ + src/bpp/tests/test_util/test_sanityzator_span_lang.py +git add src/bpp/util/text.py \ + src/bpp/tests/test_util/test_sanityzator_span_lang.py +git commit -m "feat(wcag): dopusc span[lang] w sanityzatorze opisu (3.1.2)" +``` + +--- + +### Task 3: Wektor 1 — `lang` na stronach szczegółów + +Dwa żywe szablony renderujące tytuł z pól modelu. Efekt natychmiast po +wdrożeniu, bez czekania na przeliczenie cache. + +**Files:** +- Modify: `src/bpp/templates/browse/praca_tabela_mono.html:16-22` +- Modify: `src/bpp/templates/browse/praca.html:48-50` +- Create: `src/bpp/tests/test_wcag/__init__.py` +- Test: `src/bpp/tests/test_wcag/test_lang_szablony.py` + +**Interfaces:** +- Consumes: filtr `oznacz_jezyk` z Task 1 +- Produces: nic dla dalszych zadań + +- [ ] **Step 1: Utwórz pakiet testów** + +```bash +mkdir -p src/bpp/tests/test_wcag +touch src/bpp/tests/test_wcag/__init__.py +``` + +- [ ] **Step 2: Napisz testy szablonów** + +Utwórz `src/bpp/tests/test_wcag/test_lang_szablony.py`: + +```python +"""WCAG 3.1.2 — atrybut ``lang`` na tytułach na stronach szczegółów. + +Tytuł oryginalny bierze język z ``rekord.jezyk``. Tytuł PRZEŁOŻONY +(``tytul``) świadomie NIE dostaje atrybutu: model nie zawiera pola +opisującego jego język (``jezyk_alt`` to odwzorowanie atrybutu z API PBN +oznaczające drugi język PRACY, nie język przekładu). Bez atrybutu przekład +dziedziczy ``lang="pl"`` ze strony, co dla polskiego tłumaczenia jest +prawdą — a błędne oznaczenie byłoby gorsze niż brak. +""" + +import pytest +from django.template.loader import render_to_string +from model_bakery import baker + +from bpp.models.system import Jezyk + +# Fixture ``jezyki`` (src/fixtures/conftest_system.py:65) tworzy słownik +# z WYPEŁNIONYM ``kod_bcp47``: polski → "pl", angielski → "en". Fixture +# ``wydawnictwo_ciagle`` już od niej zależy, więc wystarczy pobrać język +# po skrócie zamiast budować własny. + + +@pytest.fixture +def jezyk_angielski(jezyki): + return Jezyk.objects.get(skrot="ang.") + + +@pytest.fixture +def jezyk_bez_kodu(db): + return baker.make(Jezyk, nazwa="suahili", skrot="swa.", kod_bcp47="") + + +def _renderuj_mono(praca): + return render_to_string( + "browse/praca_tabela_mono.html", + {"praca": praca, "autorzy": [], "rekord": praca, "links": "normal"}, + ) + + +@pytest.mark.django_db +def test_mono_oznacza_tytul_oryginalny(wydawnictwo_ciagle, jezyk_angielski): + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X on Y" + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + tresc = _renderuj_mono(wydawnictwo_ciagle) + + assert 'lang="en"' in tresc + + +@pytest.mark.django_db +def test_mono_bez_atrybutu_gdy_kod_pusty(wydawnictwo_ciagle, jezyk_bez_kodu): + wydawnictwo_ciagle.tytul_oryginalny = "Tytuł bez kodu" + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_bez_kodu + wydawnictwo_ciagle.save() + + tresc = _renderuj_mono(wydawnictwo_ciagle) + + assert "Tytuł bez kodu" in tresc + assert 'lang=""' not in tresc + + +@pytest.mark.django_db +def test_mono_przeklad_zostaje_poza_znacznikiem( + wydawnictwo_ciagle, jezyk_angielski +): + # Znacznik obejmuje WYŁĄCZNIE tytuł oryginalny. Wspólny na bloku + # "oryginalny (przekład)" oznaczyłby jednym językiem dwa różne języki. + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X" + wydawnictwo_ciagle.tytul = "Wpływ X" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + tresc = _renderuj_mono(wydawnictwo_ciagle) + + assert 'Effects of X' in tresc + assert "Wpływ X" in tresc + assert 'Effects of X (Wpływ X)' not in tresc + + +@pytest.mark.django_db +def test_breadcrumb_oznacza_tytul(client, wydawnictwo_ciagle, jezyk_angielski): + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X on Y" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + res = client.get(wydawnictwo_ciagle.get_absolute_url()) + + assert res.status_code == 200 + assert b'lang="en"' in res.content + + +@pytest.mark.django_db +def test_breadcrumb_bez_atrybutu_gdy_kod_pusty( + client, wydawnictwo_ciagle, jezyk_bez_kodu +): + wydawnictwo_ciagle.tytul_oryginalny = "Tytuł bez kodu" + wydawnictwo_ciagle.jezyk = jezyk_bez_kodu + wydawnictwo_ciagle.save() + + res = client.get(wydawnictwo_ciagle.get_absolute_url()) + + assert res.status_code == 200 + assert b'lang=""' not in res.content +``` + +- [ ] **Step 3: Uruchom testy — muszą paść** + +```bash +uv run pytest src/bpp/tests/test_wcag/test_lang_szablony.py -v +``` + +Oczekiwane: FAIL — brak `lang="en"` w wyrenderowanej treści. + +Fixture `wydawnictwo_ciagle` jest zarejestrowana globalnie przez +`pytest_plugins` w `src/conftest.py:47` (moduł +`src/fixtures/conftest_publications.py:87`) — działa w każdym pakiecie +testowym bez importu. `get_absolute_url` pochodzi z +`ModelZAbsolutnymUrl` (`src/bpp/models/abstract/web.py:45`) i zwraca URL po +slugu albo po `(content_type, pk)`. + +- [ ] **Step 4: Zmień `praca_tabela_mono.html`** + +Linie 16-22, blok `

`. Zamień: + +```django + {% if praca.tytul %} + {{ praca.tytul_oryginalny|safe_tytul }} + ({{ praca.tytul|safe_tytul }}) + {% else %} + {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe_tytul }} + {% endif %} +``` + +na: + +```django + {# WCAG 3.1.2: znacznik jezyka TYLKO na tytule oryginalnym. #} + {# Przeklad nie ma w modelu pola z jezykiem, wiec dziedziczy #} + {# lang="pl" ze strony — patrz spec 2026-08-06. #} + {% if praca.tytul %} + {{ praca.tytul_oryginalny|safe_tytul|oznacz_jezyk:praca.jezyk }} + ({{ praca.tytul|safe_tytul }}) + {% else %} + {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe_tytul|oznacz_jezyk:praca.jezyk }} + {% endif %} +``` + +Biblioteka `prace` jest już załadowana w linii 1 — nie zmieniaj `{% load %}`. + +- [ ] **Step 5: Zmień breadcrumb w `praca.html`** + +Linia 49. Zamień: + +```django +
  • {{ rekord.tytul_oryginalny|truncatewords_html:15|safe_tytul }}
  • +``` + +na: + +```django + {# WCAG 3.1.2 — jezyk tytulu oryginalnego w okruszku nawigacyjnym. #} +
  • {{ rekord.tytul_oryginalny|truncatewords_html:15|safe_tytul|oznacz_jezyk:rekord.jezyk }}
  • +``` + +`praca.html` zaczyna się od `{% extends "base.html" %}` i **nie ma** +`{% load prace %}`. Dopisz go w drugiej linii pliku: + +```django +{% extends "base.html" %} +{% load prace %} +``` + +Jeżeli plik ma już inny `{% load %}` tuż po `extends`, dopisz `prace` do +tej listy zamiast dodawać nową linię. + +`rekord` to `Rekord` (materializacja) — dziedziczy `ModelTypowany`, więc ma +`jezyk`. Pola `jezyk_alt`/`jezyk_orig` są tam ustawione na `None` +(`src/bpp/models/cache/rekord.py:262-263`), ale ich nie używamy. + +- [ ] **Step 6: Uruchom testy — muszą przejść** + +```bash +uv run pytest src/bpp/tests/test_wcag/test_lang_szablony.py -v +``` + +Oczekiwane: 5 passed. + +- [ ] **Step 7: Regresja widoków browse** + +```bash +uv run pytest src/bpp/tests/test_views/test_browse -v +``` + +Oczekiwane: bez nowych błędów. + +- [ ] **Step 8: Commit** + +```bash +git add src/bpp/templates/browse/praca_tabela_mono.html \ + src/bpp/templates/browse/praca.html \ + src/bpp/tests/test_wcag/ +git commit -m "feat(wcag): lang na tytulach stron szczegolow (3.1.2 wektor 1)" +``` + +--- + +### Task 4: Wektor 2 — `lang` w generatorze opisu + +Dwa szablony będące wariantami generatora opisu bibliograficznego. Efekt na +listach i w wynikach wyszukiwania pojawia się po nocnym `denorm_rebuild`. + +**Files:** +- Modify: `src/bpp/templates/opis_bibliograficzny.html:7-11` +- Modify: `src/bpp/templates/browse/praca_tabela.html:7-11` +- Test: `src/bpp/tests/test_wcag/test_lang_opis.py` + +**Interfaces:** +- Consumes: filtr `oznacz_jezyk` (Task 1), allowlista `span[lang]` (Task 2) +- Produces: `opis_bibliograficzny_cache` zawiera `` wokół + tytułu oryginalnego. Wykorzystane w teście szablonowym Task 5. + +- [ ] **Step 1: Napisz testy generatora** + +Utwórz `src/bpp/tests/test_wcag/test_lang_opis.py`: + +```python +"""WCAG 3.1.2 w opisie bibliograficznym (wektor 2). + +Opis jest generowany serwerowo i cache'owany jako gotowy HTML, więc +znacznik języka musi trafić DO ŚRODKA tego HTML-u — poprawka na poziomie +szablonu strony go nie obejmuje. + +Asercje idą na wynik ``opis_bibliograficzny()``, a NIE na render samego +szablonu: metoda kończy się sanityzacją nh3, która wycięłaby ```` +gdyby allowlista nie została rozszerzona. Test broni tego rozszerzenia +przed cofnięciem. +""" + +import pytest +from model_bakery import baker + +from bpp.models.system import Jezyk +from bpp.models.szablondlaopisubibliograficznego import ( + SzablonDlaOpisuBibliograficznego, +) + + +@pytest.fixture +def jezyk_angielski(jezyki): + return Jezyk.objects.get(skrot="ang.") + + +@pytest.fixture +def jezyk_bez_kodu(db): + return baker.make(Jezyk, nazwa="suahili", skrot="swa.", kod_bcp47="") + + +@pytest.mark.django_db +def test_opis_zawiera_lang_tytulu(wydawnictwo_ciagle, jezyk_angielski): + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X on Y" + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert '' in opis + assert "Effects of X on Y" in opis + + +@pytest.mark.django_db +def test_opis_bez_lang_gdy_kod_pusty(wydawnictwo_ciagle, jezyk_bez_kodu): + wydawnictwo_ciagle.tytul_oryginalny = "Tytuł bez kodu" + wydawnictwo_ciagle.jezyk = jezyk_bez_kodu + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert "Tytuł bez kodu" in opis + assert "lang=" not in opis + + +@pytest.mark.django_db +def test_opis_nie_oznacza_przekladu(wydawnictwo_ciagle, jezyk_angielski): + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X" + wydawnictwo_ciagle.tytul = "Wpływ X" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert opis.count('lang="en"') == 1 + assert "Wpływ X" in opis + + +@pytest.mark.django_db +def test_znacznik_przezywa_post_processing(wydawnictwo_ciagle, jezyk_angielski): + # opis_bibliograficzny() normalizuje interpunkcję łańcuchem .replace() + # (" , ", " . ", ". . ", " .", ".
    [" — util.py:106-121). Tytuł + # kończący się kropką sąsiaduje ze znacznikiem, więc to najbliższy + # kontakt tych wzorców z . + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X." + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert '' in opis + assert "" in opis + assert "', "") + + +@pytest.mark.django_db +def test_znacznik_w_wariancie_praca_tabela(wydawnictwo_ciagle, jezyk_angielski): + # Drugi szablon opisu, instalowany kiedyś przez migrację 0295 obok + # domyślnego. Leży w katalogu browse/, ale NIE jest stroną — żaden widok + # go nie renderuje; wchodzi wyłącznie przez nazwa_szablonu. + SzablonDlaOpisuBibliograficznego.objects.update_or_create( + model=None, + defaults={"nazwa_szablonu": "browse/praca_tabela.html"}, + ) + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X on Y" + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert '' in opis + + +@pytest.mark.django_db +def test_kursywa_w_tytule_przezywa_obok_znacznika( + wydawnictwo_ciagle, jezyk_angielski +): + wydawnictwo_ciagle.tytul_oryginalny = "Role of Candida in X" + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert "Candida" in opis + assert 'lang="en"' in opis +``` + +- [ ] **Step 2: Uruchom testy — muszą paść** + +```bash +uv run pytest src/bpp/tests/test_wcag/test_lang_opis.py -v +``` + +Oczekiwane: FAIL — brak `` w opisie. + +- [ ] **Step 3: Zmień `opis_bibliograficzny.html`** + +Linie 7-11. Zamień: + +```django +{% if praca.tytul %} + {{ praca.tytul_oryginalny|safe }} ({{ praca.tytul|safe }}). +{% else %} + {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe }} +{% endif %} +``` + +na: + +```django +{# WCAG 3.1.2: znacznik jezyka TYLKO na tytule oryginalnym. Musi byc #} +{# w srodku cache'owanego HTML-u, bo listy i wyniki wyszukiwania #} +{# wstawiaja gotowy blob, nie renderuja pol modelu. #} +{% if praca.tytul %} + {{ praca.tytul_oryginalny|safe|oznacz_jezyk:praca.jezyk }} ({{ praca.tytul|safe }}). +{% else %} + {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe|oznacz_jezyk:praca.jezyk }} +{% endif %} +``` + +`{% load prace %}` jest już w linii 1. + +- [ ] **Step 4: Zmień `browse/praca_tabela.html`** + +Linie 7-11. Zamień: + +```django + {% if praca.tytul %} + {{ praca.tytul_oryginalny|safe }} ({{ praca.tytul|safe }}). + {% else %} + {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe }} + {% endif %} +``` + +na: + +```django + {# WCAG 3.1.2 — jak w opis_bibliograficzny.html. Ten plik to #} + {# WARIANT generatora opisu (migracja 0295), nie strona: #} + {# praca.html wlacza wylacznie praca_tabela_mono.html. #} + {% if praca.tytul %} + {{ praca.tytul_oryginalny|safe|oznacz_jezyk:praca.jezyk }} ({{ praca.tytul|safe }}). + {% else %} + {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe|oznacz_jezyk:praca.jezyk }} + {% endif %} +``` + +`{% load prace %}` jest już w linii 1. + +- [ ] **Step 5: Uruchom testy — muszą przejść** + +```bash +uv run pytest src/bpp/tests/test_wcag/test_lang_opis.py -v +``` + +Oczekiwane: 6 passed. + +- [ ] **Step 6: Regresja opisu bibliograficznego** + +```bash +uv run pytest src/bpp/tests/ -k "opis_bibliograficzny" -v +uv run pytest src/bpp/tests/test_models/ -v +``` + +Oczekiwane: bez nowych błędów. Testy asertujące dokładną treść opisu mogą +wymagać aktualizacji o `` — to zmiana oczekiwana, nie +regresja; popraw asercje. + +- [ ] **Step 7: Commit** + +```bash +git add src/bpp/templates/opis_bibliograficzny.html \ + src/bpp/templates/browse/praca_tabela.html \ + src/bpp/tests/test_wcag/test_lang_opis.py +git commit -m "feat(wcag): lang w generatorze opisu bibliograficznego (3.1.2 wektor 2)" +``` + +--- + +### Task 5: Naprawa podświetlania w rekordach powiązanych + +Znacznik z Task 4 trafia do `data-records` na publicznej stronie szczegółów. +Wyszukiwarka podświetla trafienia regexem na surowym HTML-u, więc wpisanie +`en` wstawia `` w środek `lang="en"` i psuje markup. + +**Files:** +- Create: `src/bpp/static/bpp/js/related-records-highlight.js` +- Create: `tests/js/related-records-highlight.test.js` +- Modify: `src/bpp/templates/browse/praca_tabela_mono.html` (podpięcie + modułu, podmiana ciała `rebuildList`) + +**Interfaces:** +- Consumes: opis ze znacznikiem (Task 4) +- Produces: globalna funkcja `window.bppHighlightOutsideTags(html, term)` + +- [ ] **Step 1: Napisz testy vitest** + +Utwórz `tests/js/related-records-highlight.test.js`: + +```javascript +// Podswietlanie frazy w opisie bibliograficznym (HTML) — wylacznie w +// segmentach TEKSTOWYCH. Po dodaniu (WCAG 3.1.2) naiwny +// regex na calym stringu podswietlal fragmenty ATRYBUTOW: fraza "en" +// trafiala w lang="en" i wstawiala w srodek znacznika, co przy +// .html(text) daje zepsuty markup. + +import { describe, it, expect, beforeAll } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; + +let highlight; + +beforeAll(() => { + const zrodlo = readFileSync( + resolve( + __dirname, + "../../src/bpp/static/bpp/js/related-records-highlight.js" + ), + "utf-8" + ); + const window = {}; + new Function("window", zrodlo)(window); + highlight = window.bppHighlightOutsideTags; +}); + +describe("bppHighlightOutsideTags", () => { + it("podswietla fraze w tekscie", () => { + expect(highlight("Effects of X", "effects")).toBe( + 'Effects of X' + ); + }); + + it("NIE podswietla frazy wystepujacej tylko w atrybucie", () => { + const html = 'Wpływ X'; + expect(highlight(html, "en")).toBe(html); + }); + + it("NIE podswietla nazwy atrybutu", () => { + const html = 'Tytuł'; + expect(highlight(html, "lang")).toBe(html); + }); + + it("NIE podswietla nazwy znacznika", () => { + const html = 'Tytuł'; + expect(highlight(html, "span")).toBe(html); + }); + + it("podswietla w tresci mimo wystapienia w atrybucie", () => { + const wynik = highlight('Energy', "en"); + expect(wynik).toBe( + 'Energy' + ); + }); + + it("nie podswietla frazy rozdzielonej znacznikiem", () => { + const html = "Rola Candida w X"; + expect(highlight(html, "rola candida")).toBe(html); + }); + + it("traktuje znaki specjalne regexa doslownie", () => { + expect(highlight("Wpływ (X) na Y", "(x)")).toBe( + 'Wpływ (X) na Y' + ); + }); + + it("podswietla wszystkie wystapienia w tresci", () => { + const wynik = highlight("Ala ma kota, ala ma psa", "ala"); + expect(wynik.match(/ { + expect(highlight("Effects", "EFFECTS")).toContain(">Effects<"); + }); + + it("zwraca wejscie bez zmian dla pustej frazy", () => { + const html = 'X'; + expect(highlight(html, "")).toBe(html); + }); + + it("nie psuje encji HTML w tresci", () => { + const html = "Kowalski & Nowak"; + expect(highlight(html, "nowak")).toBe( + 'Kowalski & Nowak' + ); + }); +}); +``` + +- [ ] **Step 2: Uruchom testy — muszą paść** + +```bash +npx vitest run tests/js/related-records-highlight.test.js +``` + +Oczekiwane: FAIL — brak pliku `related-records-highlight.js`. + +- [ ] **Step 3: Napisz moduł** + +Utwórz `src/bpp/static/bpp/js/related-records-highlight.js`: + +```javascript +/** + * Podswietlanie frazy w opisie bibliograficznym (HTML). + * + * Opis niesie zamierzony markup (, , ) oraz — od czasu wdrozenia + * WCAG 3.1.2 — wokol tytulu obcojezycznego. Naiwne + * `text.replace(regex, '$1')` nie odroznia tresci od + * znacznikow: fraza "en" trafiala w lang="en" i wstawiala w SRODEK + * atrybutu, produkujac zepsuty markup przy .html(text). + * + * Rozwiazanie: podziel wejscie na segmenty / tekst i podswietlaj + * wylacznie w tekstowych. + */ +(function (window) { + "use strict"; + + var TAG_LUB_TEKST = /(<[^>]*>)|([^<]+)/g; + + function escapeRegExp(ciag) { + return ciag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + /** + * @param {string} html - opis bibliograficzny (moze zawierac znaczniki) + * @param {string} fraza - szukany tekst (bez rozrozniania wielkosci liter) + * @returns {string} HTML z wokol trafien w tresci + */ + function bppHighlightOutsideTags(html, fraza) { + if (!fraza) { + return html; + } + + var regex = new RegExp("(" + escapeRegExp(fraza) + ")", "gi"); + + return html.replace(TAG_LUB_TEKST, function (_, znacznik, tekst) { + if (znacznik) { + return znacznik; + } + return tekst.replace(regex, '$1'); + }); + } + + window.bppHighlightOutsideTags = bppHighlightOutsideTags; +})(typeof window !== "undefined" ? window : this); +``` + +- [ ] **Step 4: Uruchom testy — muszą przejść** + +```bash +npx vitest run tests/js/related-records-highlight.test.js +``` + +Oczekiwane: 11 passed. + +- [ ] **Step 5: Podepnij moduł w szablonie** + +Szablon jest fragmentem włączanym przez `{% include %}` — nie ma bloków +`extra_js` ani żadnego ` +", FakeJezyk("en")) + assert "' + ) + assert "Kowalski Jan') + assert 'href="/bpp/autor/1/"' in wynik + + +def test_tag_spoza_allowlisty_nadal_usuwany(): + wynik = safe_opis_bibliograficzny_html("
    blok
    ") + assert "
    " not in wynik + assert "blok" in wynik diff --git a/src/bpp/util/text.py b/src/bpp/util/text.py index ba1e2e320..76d3397a0 100644 --- a/src/bpp/util/text.py +++ b/src/bpp/util/text.py @@ -307,10 +307,16 @@ class safe_opis_bibliograficzny_defaults: # Opis bibliograficzny składa się z tytułu (sanityzowanego jak wyżej) oraz # inline'owego formatowania cytowania; w wariancie linkowanym niesie też # odnośniki autorów (````). Dopuszczamy TE SAME tagi co tytuł plus - # ```` z samym ``href``/``rel``/``title`` — bez tagów blokowych, bez - # ``style``/``class`` i bez innych atrybutów. - ALLOWED_TAGS = safe_tytul_defaults.ALLOWED_TAGS + ("a",) - ALLOWED_ATTRIBUTES = {"a": ["href", "title", "rel"]} + # ```` z samym ``href``/``rel``/``title`` oraz ```` WYŁĄCZNIE + # z ``lang`` — bez tagów blokowych, bez ``style``/``class``. + # + # ``span``/``lang`` są tu dla WCAG 3.1.2 (Language of Parts): generator + # opisu owija tytuł obcojęzyczny znacznikiem języka, a bez tego wpisu nh3 + # wyciąłby go i poprawka cicho by nie działała. ``lang`` jest atrybutem + # deklaratywnym — nie wykonuje kodu, nie ładuje zasobów, nie wpływa na + # układ; ``span`` bez ``style``/``class`` nie pozwala nadpisać wyglądu. + ALLOWED_TAGS = safe_tytul_defaults.ALLOWED_TAGS + ("a", "span") + ALLOWED_ATTRIBUTES = {"a": ["href", "title", "rel"], "span": ["lang"]} def safe_opis_bibliograficzny_html(html): From 2b22f5034bbe56af73c4d0a50c2c2c4f44306b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 22:28:26 +0200 Subject: [PATCH 08/20] feat(wcag): lang na tytulach stron szczegolow (3.1.2 wektor 1) Co-Authored-By: Claude Opus 5 (1M context) --- src/bpp/templates/browse/praca.html | 3 +- .../templates/browse/praca_tabela_mono.html | 7 +- src/bpp/tests/test_wcag/__init__.py | 0 src/bpp/tests/test_wcag/test_lang_szablony.py | 106 ++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 src/bpp/tests/test_wcag/__init__.py create mode 100644 src/bpp/tests/test_wcag/test_lang_szablony.py diff --git a/src/bpp/templates/browse/praca.html b/src/bpp/templates/browse/praca.html index 5e0fa5206..d726f4f3b 100644 --- a/src/bpp/templates/browse/praca.html +++ b/src/bpp/templates/browse/praca.html @@ -46,7 +46,8 @@ {% block breadcrumbs %} {{ block.super }}
  • Wyszukiwanie
  • -
  • {{ rekord.tytul_oryginalny|truncatewords_html:15|safe_tytul }}
  • + {# WCAG 3.1.2 — jezyk tytulu oryginalnego w okruszku nawigacyjnym. #} +
  • {{ rekord.tytul_oryginalny|truncatewords_html:15|safe_tytul|oznacz_jezyk:rekord.jezyk }}
  • {% endblock %} diff --git a/src/bpp/templates/browse/praca_tabela_mono.html b/src/bpp/templates/browse/praca_tabela_mono.html index 432425b0a..5283bef06 100644 --- a/src/bpp/templates/browse/praca_tabela_mono.html +++ b/src/bpp/templates/browse/praca_tabela_mono.html @@ -14,11 +14,14 @@

    + {# WCAG 3.1.2: znacznik jezyka TYLKO na tytule oryginalnym. #} + {# Przeklad nie ma w modelu pola z jezykiem, wiec dziedziczy #} + {# lang="pl" ze strony — patrz spec 2026-08-06. #} {% if praca.tytul %} - {{ praca.tytul_oryginalny|safe_tytul }} + {{ praca.tytul_oryginalny|safe_tytul|oznacz_jezyk:praca.jezyk }} ({{ praca.tytul|safe_tytul }}) {% else %} - {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe_tytul }} + {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe_tytul|oznacz_jezyk:praca.jezyk }} {% endif %} {% if praca.charakter_formalny.charakter_ogolny != 'roz' %} {{ praca.oznaczenie_wydania|default:""|znak_na_koncu:"." }} diff --git a/src/bpp/tests/test_wcag/__init__.py b/src/bpp/tests/test_wcag/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/bpp/tests/test_wcag/test_lang_szablony.py b/src/bpp/tests/test_wcag/test_lang_szablony.py new file mode 100644 index 000000000..fa91902e9 --- /dev/null +++ b/src/bpp/tests/test_wcag/test_lang_szablony.py @@ -0,0 +1,106 @@ +"""WCAG 3.1.2 — atrybut ``lang`` na tytułach na stronach szczegółów. + +Tytuł oryginalny bierze język z ``rekord.jezyk``. Tytuł PRZEŁOŻONY +(``tytul``) świadomie NIE dostaje atrybutu: model nie zawiera pola +opisującego jego język (``jezyk_alt`` to odwzorowanie atrybutu z API PBN +oznaczające drugi język PRACY, nie język przekładu). Bez atrybutu przekład +dziedziczy ``lang="pl"`` ze strony, co dla polskiego tłumaczenia jest +prawdą — a błędne oznaczenie byłoby gorsze niż brak. +""" + +import pytest +from django.template.loader import render_to_string +from model_bakery import baker + +from bpp.models.system import Jezyk + +# Fixture ``jezyki`` (src/fixtures/conftest_system.py:65) tworzy słownik +# z WYPEŁNIONYM ``kod_bcp47``: polski → "pl", angielski → "en". Fixture +# ``wydawnictwo_ciagle`` już od niej zależy, więc wystarczy pobrać język +# po skrócie zamiast budować własny. + + +@pytest.fixture +def jezyk_angielski(jezyki): + return Jezyk.objects.get(skrot="ang.") + + +@pytest.fixture +def jezyk_bez_kodu(db): + return baker.make(Jezyk, nazwa="suahili", skrot="swa.", kod_bcp47="") + + +def _renderuj_mono(praca): + return render_to_string( + "browse/praca_tabela_mono.html", + {"praca": praca, "autorzy": [], "rekord": praca, "links": "normal"}, + ) + + +@pytest.mark.django_db +def test_mono_oznacza_tytul_oryginalny(wydawnictwo_ciagle, jezyk_angielski): + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X on Y" + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + tresc = _renderuj_mono(wydawnictwo_ciagle) + + assert 'lang="en"' in tresc + + +@pytest.mark.django_db +def test_mono_bez_atrybutu_gdy_kod_pusty(wydawnictwo_ciagle, jezyk_bez_kodu): + wydawnictwo_ciagle.tytul_oryginalny = "Tytuł bez kodu" + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_bez_kodu + wydawnictwo_ciagle.save() + + tresc = _renderuj_mono(wydawnictwo_ciagle) + + assert "Tytuł bez kodu" in tresc + assert 'lang=""' not in tresc + + +@pytest.mark.django_db +def test_mono_przeklad_zostaje_poza_znacznikiem( + wydawnictwo_ciagle, jezyk_angielski +): + # Znacznik obejmuje WYŁĄCZNIE tytuł oryginalny. Wspólny na bloku + # "oryginalny (przekład)" oznaczyłby jednym językiem dwa różne języki. + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X" + wydawnictwo_ciagle.tytul = "Wpływ X" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + tresc = _renderuj_mono(wydawnictwo_ciagle) + + assert 'Effects of X' in tresc + assert "Wpływ X" in tresc + assert 'Effects of X (Wpływ X)' not in tresc + + +@pytest.mark.django_db +def test_breadcrumb_oznacza_tytul(client, wydawnictwo_ciagle, jezyk_angielski): + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X on Y" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + res = client.get(wydawnictwo_ciagle.get_absolute_url()) + + assert res.status_code == 200 + assert b'lang="en"' in res.content + + +@pytest.mark.django_db +def test_breadcrumb_bez_atrybutu_gdy_kod_pusty( + client, wydawnictwo_ciagle, jezyk_bez_kodu +): + wydawnictwo_ciagle.tytul_oryginalny = "Tytuł bez kodu" + wydawnictwo_ciagle.jezyk = jezyk_bez_kodu + wydawnictwo_ciagle.save() + + res = client.get(wydawnictwo_ciagle.get_absolute_url()) + + assert res.status_code == 200 + assert b'lang=""' not in res.content From 50513fbfdce5a34c90fafd05ea8e14fc56ff074e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 23:02:56 +0200 Subject: [PATCH 09/20] feat(wcag): lang w generatorze opisu bibliograficznego (3.1.2 wektor 2) Co-Authored-By: Claude Opus 5 (1M context) --- src/bpp/templates/browse/praca_tabela.html | 7 +- src/bpp/templates/opis_bibliograficzny.html | 7 +- src/bpp/tests/test_wcag/test_lang_opis.py | 127 ++++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 src/bpp/tests/test_wcag/test_lang_opis.py diff --git a/src/bpp/templates/browse/praca_tabela.html b/src/bpp/templates/browse/praca_tabela.html index da122ed02..5a6caa564 100644 --- a/src/bpp/templates/browse/praca_tabela.html +++ b/src/bpp/templates/browse/praca_tabela.html @@ -4,10 +4,13 @@ Tytuł: + {# WCAG 3.1.2 — jak w opis_bibliograficzny.html. Ten plik to #} + {# WARIANT generatora opisu (migracja 0295), nie strona: #} + {# praca.html wlacza wylacznie praca_tabela_mono.html. #} {% if praca.tytul %} - {{ praca.tytul_oryginalny|safe }} ({{ praca.tytul|safe }}). + {{ praca.tytul_oryginalny|safe|oznacz_jezyk:praca.jezyk }} ({{ praca.tytul|safe }}). {% else %} - {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe }} + {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe|oznacz_jezyk:praca.jezyk }} {% endif %} {% if praca.charakter_formalny.charakter_ogolny != 'roz' %} {{ praca.oznaczenie_wydania|default:""|znak_na_koncu:"." }} diff --git a/src/bpp/templates/opis_bibliograficzny.html b/src/bpp/templates/opis_bibliograficzny.html index 003619702..1a028baa3 100644 --- a/src/bpp/templates/opis_bibliograficzny.html +++ b/src/bpp/templates/opis_bibliograficzny.html @@ -4,10 +4,13 @@ {# TYTUŁ PRACY #} {# *********** #} +{# WCAG 3.1.2: znacznik jezyka TYLKO na tytule oryginalnym. Musi byc #} +{# w srodku cache'owanego HTML-u, bo listy i wyniki wyszukiwania #} +{# wstawiaja gotowy blob, nie renderuja pol modelu. #} {% if praca.tytul %} - {{ praca.tytul_oryginalny|safe }} ({{ praca.tytul|safe }}). + {{ praca.tytul_oryginalny|safe|oznacz_jezyk:praca.jezyk }} ({{ praca.tytul|safe }}). {% else %} - {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe }} + {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe|oznacz_jezyk:praca.jezyk }} {% endif %} {% if praca.charakter_formalny.charakter_ogolny != 'roz' %} {{ praca.oznaczenie_wydania|default:""|znak_na_koncu:"." }} diff --git a/src/bpp/tests/test_wcag/test_lang_opis.py b/src/bpp/tests/test_wcag/test_lang_opis.py new file mode 100644 index 000000000..3e0b72318 --- /dev/null +++ b/src/bpp/tests/test_wcag/test_lang_opis.py @@ -0,0 +1,127 @@ +"""WCAG 3.1.2 w opisie bibliograficznym (wektor 2). + +Opis jest generowany serwerowo i cache'owany jako gotowy HTML, więc +znacznik języka musi trafić DO ŚRODKA tego HTML-u — poprawka na poziomie +szablonu strony go nie obejmuje. + +Asercje idą na wynik ``opis_bibliograficzny()``, a NIE na render samego +szablonu: metoda kończy się sanityzacją nh3, która wycięłaby ```` +gdyby allowlista nie została rozszerzona. Test broni tego rozszerzenia +przed cofnięciem. +""" + +import pytest +from django.core.management import call_command +from model_bakery import baker + +from bpp.models.system import Jezyk +from bpp.models.szablondlaopisubibliograficznego import ( + SzablonDlaOpisuBibliograficznego, +) + + +@pytest.fixture +def jezyk_angielski(jezyki): + return Jezyk.objects.get(skrot="ang.") + + +@pytest.fixture +def jezyk_bez_kodu(db): + return baker.make(Jezyk, nazwa="suahili", skrot="swa.", kod_bcp47="") + + +@pytest.mark.django_db +def test_opis_zawiera_lang_tytulu(wydawnictwo_ciagle, jezyk_angielski): + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X on Y" + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert '' in opis + assert "Effects of X on Y" in opis + + +@pytest.mark.django_db +def test_opis_bez_lang_gdy_kod_pusty(wydawnictwo_ciagle, jezyk_bez_kodu): + wydawnictwo_ciagle.tytul_oryginalny = "Tytuł bez kodu" + wydawnictwo_ciagle.jezyk = jezyk_bez_kodu + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert "Tytuł bez kodu" in opis + assert "lang=" not in opis + + +@pytest.mark.django_db +def test_opis_nie_oznacza_przekladu(wydawnictwo_ciagle, jezyk_angielski): + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X" + wydawnictwo_ciagle.tytul = "Wpływ X" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert opis.count('lang="en"') == 1 + assert "Wpływ X" in opis + + +@pytest.mark.django_db +def test_znacznik_przezywa_post_processing(wydawnictwo_ciagle, jezyk_angielski): + # opis_bibliograficzny() normalizuje interpunkcję łańcuchem .replace() + # (" , ", " . ", ". . ", " .", ".[" — util.py:106-121). Tytuł + # kończący się kropką sąsiaduje ze znacznikiem, więc to najbliższy + # kontakt tych wzorców z . + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X." + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert '' in opis + assert "" in opis + assert "', "") + + +@pytest.mark.django_db +def test_znacznik_w_wariancie_praca_tabela(wydawnictwo_ciagle, jezyk_angielski): + # Drugi szablon opisu, instalowany kiedyś przez migrację 0295 obok + # domyślnego. Leży w katalogu browse/, ale NIE jest stroną — żaden widok + # go nie renderuje; wchodzi wyłącznie przez nazwa_szablonu. + SzablonDlaOpisuBibliograficznego.objects.update_or_create( + model=None, + defaults={"nazwa_szablonu": "browse/praca_tabela.html"}, + ) + # Baseline nosi osierocony wiersz dbtemplates dla "browse/praca_tabela.html" + # (wgrany przez migrację 0295, nigdy nie wyczyszczony przez 0473 — jej + # guard czyści tylko nazwy AKTUALNIE referencjonowane przez + # SzablonDlaOpisuBibliograficznego, a nic w danych produkcyjnych nie + # wskazywało na ten wariant). Loader dbtemplates stoi przed loaderem + # plikowym, więc bez tego czyszczenia render dostałby starą treść z bazy, + # sprzed edycji WCAG na dysku — dokładnie scenariusz, na który istnieje + # komenda drop_dbtemplate (patrz test_management_commands_drop_dbtemplate.py). + call_command("drop_dbtemplate", "browse/praca_tabela.html", "--skip-rebuild") + wydawnictwo_ciagle.tytul_oryginalny = "Effects of X on Y" + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert '' in opis + + +@pytest.mark.django_db +def test_kursywa_w_tytule_przezywa_obok_znacznika(wydawnictwo_ciagle, jezyk_angielski): + wydawnictwo_ciagle.tytul_oryginalny = "Role of Candida in X" + wydawnictwo_ciagle.tytul = "" + wydawnictwo_ciagle.jezyk = jezyk_angielski + wydawnictwo_ciagle.save() + + opis = wydawnictwo_ciagle.opis_bibliograficzny() + + assert "Candida" in opis + assert 'lang="en"' in opis From fbd5dbf941a166d610709d4571b4c70864da042a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 23:34:47 +0200 Subject: [PATCH 10/20] fix(wcag): skasuj osierocony dbtemplate wariantu opisu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migracja 0295 zainstalowala browse/praca_tabela.html do dbtemplates jako drugi wariant generatora opisu. Migracja 0473 sprzatala dbtemplates tylko dla nazw wskazywanych przez SzablonDlaOpisuBibliograficznego, a ta nie byla wskazywana — wiersz przetrwal jako sierota. Loader dbtemplates stoi przed plikowym, wiec dopoki wiersz istnieje, kazda edycja tego szablonu na dysku (w tym znacznik lang z WCAG 3.1.2) nie ma zadnego efektu na instalacji, ktora przelaczy sie na ten wariant. Blad jest cichy: bez wyjatku, bez ostrzezenia, ze stara trescia sprzed lat. Kasowanie przez usun_dbtemplate_i_przebuduj — ten sam mechanizm co 0473, z guardem dysk-existence. Co-Authored-By: Claude Opus 5 (1M context) --- baseline-sql/baseline.meta.json | 6 +- baseline-sql/baseline.sql | 117 ++++++++---------- .../0488_purge_praca_tabela_dbtemplate.py | 47 +++++++ .../tests/test_wcag/test_purge_dbtemplate.py | 32 +++++ 4 files changed, 133 insertions(+), 69 deletions(-) create mode 100644 src/bpp/migrations/0488_purge_praca_tabela_dbtemplate.py create mode 100644 src/bpp/tests/test_wcag/test_purge_dbtemplate.py diff --git a/baseline-sql/baseline.meta.json b/baseline-sql/baseline.meta.json index 48261da7e..2c3deedfb 100644 --- a/baseline-sql/baseline.meta.json +++ b/baseline-sql/baseline.meta.json @@ -1,11 +1,11 @@ { - "git_sha": "95438a747b48a879ddbe061b6b42b0fc5ad22de3", + "git_sha": "50513fbfdce5a34c90fafd05ea8e14fc56ff074e", "last_migration": { "admin": "0003_logentry_add_action_flag_choices", "admin_dashboard": "0001_initial", "auth": "0012_alter_user_first_name_max_length", "axes": "0010_accessattemptexpiration", - "bpp": "0487_api_v1_przelaczniki", + "bpp": "0488_purge_praca_tabela_dbtemplate", "channels_broadcast": "0001_initial", "constance": "0003_drop_pickle", "contenttypes": "0002_remove_content_type_name", @@ -75,5 +75,5 @@ "zglos_publikacje": "0027_zgloszenie_zaimportowane" }, "meta_version": 1, - "postgres_version": "PostgreSQL 16.13 (Debian 16.13-1.pgdg13+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit" + "postgres_version": "PostgreSQL 16.13 (Debian 16.13-1.pgdg13+1) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit" } diff --git a/baseline-sql/baseline.sql b/baseline-sql/baseline.sql index 2d1dede2d..c8b873ae7 100644 --- a/baseline-sql/baseline.sql +++ b/baseline-sql/baseline.sql @@ -16449,6 +16449,55 @@ COPY public.django_countdown_sitecountdown (id, countdown_time, message, long_de -- COPY public.django_migrations (id, app, name, applied) FROM stdin; +593 denorm 0001_initial 2000-01-01 00:00:00+00 +20 axes 0001_initial 2000-01-01 00:00:00+00 +76 taggit 0001_initial 2000-01-01 00:00:00+00 +627 django_countdown 0003_sitecountdown_maintenance_until 2000-01-01 00:00:00+00 +628 django_countdown 0004_alter_sitecountdown_long_description 2000-01-01 00:00:00+00 +629 django_countdown 0005_alter_sitecountdown_options_and_more 2000-01-01 00:00:00+00 +630 dspace_api 0001_initial 2000-01-01 00:00:00+00 +631 dspace_api 0002_senttodspace 2000-01-01 00:00:00+00 +632 dspace_api 0003_senttodspace_bitstreams 2000-01-01 00:00:00+00 +633 dspace_api 0004_senttodspace_dspace_handle 2000-01-01 00:00:00+00 +634 easyaudit 0001_initial 2000-01-01 00:00:00+00 +635 easyaudit 0002_auto_20170125_0759 2000-01-01 00:00:00+00 +636 easyaudit 0003_auto_20170228_1505 2000-01-01 00:00:00+00 +637 easyaudit 0004_auto_20170620_1354 2000-01-01 00:00:00+00 +638 easyaudit 0005_auto_20170713_1155 2000-01-01 00:00:00+00 +639 easyaudit 0006_auto_20171018_1242 2000-01-01 00:00:00+00 +640 easyaudit 0007_auto_20180105_0838 2000-01-01 00:00:00+00 +641 easyaudit 0008_auto_20180220_1908 2000-01-01 00:00:00+00 +642 easyaudit 0009_auto_20180314_2225 2000-01-01 00:00:00+00 +644 easyaudit 0011_auto_20181101_1339 2000-01-01 00:00:00+00 +645 easyaudit 0012_auto_20181018_0012 2000-01-01 00:00:00+00 +646 easyaudit 0013_auto_20190723_0126 2000-01-01 00:00:00+00 +647 easyaudit 0014_auto_20200513_0008 2000-01-01 00:00:00+00 +648 easyaudit 0015_auto_20201019_1217 2000-01-01 00:00:00+00 +649 easyaudit 0016_alter_crudevent_event_type 2000-01-01 00:00:00+00 +650 easyaudit 0017_alter_requestevent_datetime 2000-01-01 00:00:00+00 +651 easyaudit 0018_rename_crudevent_object_id_content_type_index 2000-01-01 00:00:00+00 +652 easyaudit 0019_alter_crudevent_changed_fields_and_more 2000-01-01 00:00:00+00 +653 ewaluacja2021 0001_initial 2000-01-01 00:00:00+00 +654 ewaluacja2021 0002_auto_20211026_1137 2000-01-01 00:00:00+00 +655 ewaluacja2021 0003_auto_20211027_2320 2000-01-01 00:00:00+00 +656 ewaluacja2021 0004_importmaksymalnychslotow_ostatnia_zmiana 2000-01-01 00:00:00+00 +657 ewaluacja2021 0005_auto_20211028_0039 2000-01-01 00:00:00+00 +658 ewaluacja2021 0006_auto_20211110_0000 2000-01-01 00:00:00+00 +659 ewaluacja2021 0007_auto_20211110_0002 2000-01-01 00:00:00+00 +660 ewaluacja2021 0008_auto_20211122_0103 2000-01-01 00:00:00+00 +661 ewaluacja2021 0009_zamowienienaraport_status 2000-01-01 00:00:00+00 +662 ewaluacja2021 0010_django32 2000-01-01 00:00:00+00 +663 ewaluacja2021 0011_nullbooleanfield 2000-01-01 00:00:00+00 +664 ewaluacja2021 0012_liczbandlauczelni_2022_2025_and_more 2000-01-01 00:00:00+00 +665 ewaluacja2021 0013_alter_iloscudzialowdlaautora_ilosc_udzialow_and_more 2000-01-01 00:00:00+00 +666 ewaluacja2021 0014_iloscudzialowzarok 2000-01-01 00:00:00+00 +667 ewaluacja2021 0015_dyscyplina_nie_raportowana 2000-01-01 00:00:00+00 +668 ewaluacja2021 0016_alter_iloscudzialowdlaautora_2022_2025_unique_together_and_more 2000-01-01 00:00:00+00 +669 ewaluacja2021 0017_alter_liczbandlauczelni_dyscyplina_naukowa_and_more 2000-01-01 00:00:00+00 +670 ewaluacja2021 0018_move_files_to_protected 2000-01-01 00:00:00+00 +671 ewaluacja2021 0019_remove_null_from_string_fields 2000-01-01 00:00:00+00 +672 ewaluacja2021 0020_delete_ewaluacja2021_models 2000-01-01 00:00:00+00 +673 ewaluacja_liczba_n 0001_initial 2000-01-01 00:00:00+00 991 bpp 0448_rodzajjednostki 2000-01-01 00:00:00+00 371 bpp 0278_autorzy_profil_orcid 2000-01-01 00:00:00+00 487 bpp 0380_rekord_wydawca 2000-01-01 00:00:00+00 @@ -16541,7 +16590,6 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 758 import_list_ministerialnych 0002_alter_wierszimportudyscyplinzrodel_nr_wiersza 2000-01-01 00:00:00+00 759 import_list_ministerialnych 0003_rename_wierszimportudyscyplinzrodel_wierszimportulistyministerialnej 2000-01-01 00:00:00+00 760 import_list_ministerialnych 0004_importlistministerialnych_ignoruj_zrodla_bez_odpowiednika 2000-01-01 00:00:00+00 -593 denorm 0001_initial 2000-01-01 00:00:00+00 94 bpp 0017_typy_pbn 2000-01-01 00:00:00+00 970 bpp 0417_ensure_uczelnia_site_not_null 2000-01-01 00:00:00+00 704 favicon 0001_initial 2000-01-01 00:00:00+00 @@ -16819,7 +16867,6 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 1022 import_dyscyplin 0024_faza_b_ii2_repoint_wydzial 2000-01-01 00:00:00+00 1023 import_polon 0016_importplikupolon_uczelnia 2000-01-01 00:00:00+00 1024 import_punktacji_zrodel 0001_initial 2000-01-01 00:00:00+00 -20 axes 0001_initial 2000-01-01 00:00:00+00 831 nowe_raporty 0001_initial 2000-01-01 00:00:00+00 1025 importer_publikacji 0012_importsession_uczelnia 2000-01-01 00:00:00+00 1026 importer_publikacji 0013_merge_20260604_1952 2000-01-01 00:00:00+00 @@ -17012,7 +17059,6 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 73 pbn_api 0039_alter_oswiadczenieinstytucji_area 2000-01-01 00:00:00+00 74 sites 0001_initial 2000-01-01 00:00:00+00 75 dbtemplates 0001_initial 2000-01-01 00:00:00+00 -76 taggit 0001_initial 2000-01-01 00:00:00+00 77 taggit 0002_auto_20150616_2121 2000-01-01 00:00:00+00 78 taggit 0003_taggeditem_add_unique_index 2000-01-01 00:00:00+00 79 bpp 0002_auto_20141020_1738 2000-01-01 00:00:00+00 @@ -17533,52 +17579,7 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 624 sites 0002_alter_domain_unique 2000-01-01 00:00:00+00 625 django_countdown 0001_initial 2000-01-01 00:00:00+00 626 django_countdown 0002_alter_sitecountdown_countdown_time 2000-01-01 00:00:00+00 -627 django_countdown 0003_sitecountdown_maintenance_until 2000-01-01 00:00:00+00 -628 django_countdown 0004_alter_sitecountdown_long_description 2000-01-01 00:00:00+00 -629 django_countdown 0005_alter_sitecountdown_options_and_more 2000-01-01 00:00:00+00 -630 dspace_api 0001_initial 2000-01-01 00:00:00+00 -631 dspace_api 0002_senttodspace 2000-01-01 00:00:00+00 -632 dspace_api 0003_senttodspace_bitstreams 2000-01-01 00:00:00+00 -633 dspace_api 0004_senttodspace_dspace_handle 2000-01-01 00:00:00+00 -634 easyaudit 0001_initial 2000-01-01 00:00:00+00 -635 easyaudit 0002_auto_20170125_0759 2000-01-01 00:00:00+00 -636 easyaudit 0003_auto_20170228_1505 2000-01-01 00:00:00+00 -637 easyaudit 0004_auto_20170620_1354 2000-01-01 00:00:00+00 -638 easyaudit 0005_auto_20170713_1155 2000-01-01 00:00:00+00 -639 easyaudit 0006_auto_20171018_1242 2000-01-01 00:00:00+00 -640 easyaudit 0007_auto_20180105_0838 2000-01-01 00:00:00+00 -641 easyaudit 0008_auto_20180220_1908 2000-01-01 00:00:00+00 -642 easyaudit 0009_auto_20180314_2225 2000-01-01 00:00:00+00 -644 easyaudit 0011_auto_20181101_1339 2000-01-01 00:00:00+00 -645 easyaudit 0012_auto_20181018_0012 2000-01-01 00:00:00+00 -646 easyaudit 0013_auto_20190723_0126 2000-01-01 00:00:00+00 -647 easyaudit 0014_auto_20200513_0008 2000-01-01 00:00:00+00 -648 easyaudit 0015_auto_20201019_1217 2000-01-01 00:00:00+00 -649 easyaudit 0016_alter_crudevent_event_type 2000-01-01 00:00:00+00 -650 easyaudit 0017_alter_requestevent_datetime 2000-01-01 00:00:00+00 -651 easyaudit 0018_rename_crudevent_object_id_content_type_index 2000-01-01 00:00:00+00 -652 easyaudit 0019_alter_crudevent_changed_fields_and_more 2000-01-01 00:00:00+00 -653 ewaluacja2021 0001_initial 2000-01-01 00:00:00+00 -654 ewaluacja2021 0002_auto_20211026_1137 2000-01-01 00:00:00+00 -655 ewaluacja2021 0003_auto_20211027_2320 2000-01-01 00:00:00+00 -656 ewaluacja2021 0004_importmaksymalnychslotow_ostatnia_zmiana 2000-01-01 00:00:00+00 -657 ewaluacja2021 0005_auto_20211028_0039 2000-01-01 00:00:00+00 -658 ewaluacja2021 0006_auto_20211110_0000 2000-01-01 00:00:00+00 -659 ewaluacja2021 0007_auto_20211110_0002 2000-01-01 00:00:00+00 -660 ewaluacja2021 0008_auto_20211122_0103 2000-01-01 00:00:00+00 -661 ewaluacja2021 0009_zamowienienaraport_status 2000-01-01 00:00:00+00 -662 ewaluacja2021 0010_django32 2000-01-01 00:00:00+00 -663 ewaluacja2021 0011_nullbooleanfield 2000-01-01 00:00:00+00 -664 ewaluacja2021 0012_liczbandlauczelni_2022_2025_and_more 2000-01-01 00:00:00+00 -665 ewaluacja2021 0013_alter_iloscudzialowdlaautora_ilosc_udzialow_and_more 2000-01-01 00:00:00+00 -666 ewaluacja2021 0014_iloscudzialowzarok 2000-01-01 00:00:00+00 -667 ewaluacja2021 0015_dyscyplina_nie_raportowana 2000-01-01 00:00:00+00 -668 ewaluacja2021 0016_alter_iloscudzialowdlaautora_2022_2025_unique_together_and_more 2000-01-01 00:00:00+00 -669 ewaluacja2021 0017_alter_liczbandlauczelni_dyscyplina_naukowa_and_more 2000-01-01 00:00:00+00 -670 ewaluacja2021 0018_move_files_to_protected 2000-01-01 00:00:00+00 -671 ewaluacja2021 0019_remove_null_from_string_fields 2000-01-01 00:00:00+00 -672 ewaluacja2021 0020_delete_ewaluacja2021_models 2000-01-01 00:00:00+00 -673 ewaluacja_liczba_n 0001_initial 2000-01-01 00:00:00+00 +1136 bpp 0488_purge_praca_tabela_dbtemplate 2000-01-01 00:00:00+00 \. @@ -17596,7 +17597,6 @@ COPY public.django_site (id, domain, name) FROM stdin; -- COPY public.django_template (id, name, content, creation_date, last_changed) FROM stdin; -2 browse/praca_tabela.html {% load prace user_in_group %}\n\n \n \n \n \n \n \n \n \n {% if praca.zrodlo or praca.wydawnictwo_nadrzedne or praca.informacje or praca.szczegoly %}\n \n \n \n \n {% endif %}\n\n {% if praca.wydawca or praca.wydawca_opis %}\n \n \n \n \n {% endif %}\n\n {% if praca.streszczenia.exists %}\n \n \n \n \n {% endif %}\n\n {% if praca.isbn %}\n \n \n \n \n {% endif %}\n {% if praca.e_isbn %}\n \n \n \n \n {% endif %}\n\n {% if praca.issn %}\n \n \n \n \n {% endif %}\n {% if praca.e_issn %}\n \n \n \n \n {% endif %}\n\n {% if praca.charakter_formalny.skrot == "PAT" %}\n \n \n \n \n {% endif %}\n {% if praca.public_www or praca.www %}\n \n \n \n \n {% endif %}\n {% if praca.doi %}\n \n \n \n \n {% endif %}\n\n {% if praca.pubmed_id %}\n \n \n \n \n {% endif %}\n\n {% if praca.pmc_id %}\n \n \n \n \n {% endif %}\n\n \n \n \n \n \n \n \n \n {% if praca.pbn_uid_id %}\n \n \n \n \n {% endif %}\n {% if praca.pbn_id %}\n \n \n \n \n {% endif %}\n\n \n \n \n \n \n \n \n \n\n \n \n \n \n {% if praca.typ_kbn %}\n \n \n \n \n {% endif %}\n {% if praca.openaccess_tryb_dostepu or praca.openaccess_wersja_tekstu or praca.openaccess_licencja or praca.openaccess_czas_publikacji or praca.openaccess_ilosc_miesiecy %}\n \n \n \n \n \n {% endif %}\n \n \n \n \n\n \n \n \n \n {% if praca.kwartyl_w_scopus %}\n \n \n \n \n {% endif %}\n {% if praca.kwartyl_w_wos %}\n \n \n \n \n {% endif %}\n {% if praca.liczba_cytowan %}\n \n \n \n \n {% endif %}\n {% if praca.liczba_znakow_wydawniczych %}\n \n \n \n \n {% endif %}\n {% if uczelnia.pokazuj_punktacja_snip %}\n \n \n \n \n {% endif %}\n {% if uczelnia.pokazuj_index_copernicus %}\n \n \n \n \n {% endif %}\n\n {% if uczelnia.pokazuj_punktacje_wewnetrzna %}\n \n \n \n \n {% endif %}\n {% if uczelnia.pokazuj_status_korekty == "always" or uczelnia.pokazuj_status_korekty == "logged-in" and not request.user.is_anonymous %}\n \n \n \n \n {% endif %}\n {% if praca.wydawnictwa_powiazane_set.exists %}\n \n \n \n \n {% endif %}\n {% if uczelnia.pokazuj_praca_recenzowana == "always" or uczelnia.pokazuj_praca_recenzowana == "logged-in" and not request.user.is_anonymous %}\n \n \n \n \n {% endif %}\n {% if praca.ma_procenty %}\n \n \n \n \n {% endif %}\n\n \n \n \n \n\n \n \n \n \n {% if praca.zewnetrzna_baza_danych.exists %}\n \n \n \n \n {% endif %}\n
    Tytuł:\n {% if praca.tytul %}\n {{ praca.tytul_oryginalny|safe }} ({{ praca.tytul|safe }}).\n {% else %}\n {{ praca.tytul_oryginalny|znak_na_koncu:"."|safe }}\n {% endif %}\n {% if praca.charakter_formalny.charakter_ogolny != 'roz' %}\n {{ praca.oznaczenie_wydania|default:""|znak_na_koncu:"." }}\n {% endif %}\n
    \n Autorzy:\n \n {{ praca.tekst_przed_pierwszym_autorem|default:"" }}\n {% for autor in praca.autorzy_dla_opisu %}{% ifchanged autor.typ_odpowiedzialnosci %}\n [{{ autor.typ_odpowiedzialnosci.skrot|upper }}] {% endifchanged %}{% if links == "admin" %}\n \n {% else %}{% if links == "normal" %}{% else %}\n {% endif %}{% endif %}{% if links %}{{ autor.zapisany_jako }}{% else %}\n {{ autor.zapisany_jako|upper }}{% endif %}{% if links == "admin" or links == "normal" %}{% endif %}\n {% if not forloop.last %}, {% else %}{{ praca.tekst_po_ostatnim_autorze|default:"" }}.\n {% endif %}{% endfor %}\n
    \n Szczegóły:\n \n {% if praca.zrodlo %}\n {% if links == "admin" %}\n {{ praca.zrodlo }}\n {% else %}\n {{ praca.zrodlo }}\n {% endif %}\n {% endif %}\n {% if praca.wydawnictwo_nadrzedne %}\n \n {% endif %}\n {% if not praca.informacje and not praca.szczegoly %}\n {% if praca.wydawnictwo_nadrzedne.opis_bibliograficzny %}\n W: {{ praca.wydawnictwo_nadrzedne.opis_bibliograficzny|safe }}\n {% endif %}\n {% endif %}\n {{ praca.informacje|default:""|znak_na_koncu:", "|safe }}\n {{ praca.szczegoly|default:""|safe }}\n {% if praca.wydawnictwo_nadrzedne %}\n \n {% endif %}\n
    Wydawca:{{ praca.wydawca|default:"" }} {{ praca.wydawca_opis|default:"" }}
    Streszczenie:{% for streszczenie in praca.streszczenia.all %}\n \n

    {{ streszczenie.streszczenie|safe_streszczenie }}

    \n {% endfor %}\n
    ISBN:{{ praca.isbn }}
    e-ISBN:{{ praca.e_isbn }}
    ISSN:{{ praca.issn }}
    e-ISSN:{{ praca.e_issn }}
    Patent:\n - numer zgłoszenia: {{ praca.numer_zgloszenia|default:"brak" }}.
    \n - wydział: {{ praca.wydzial|default:"brak" }}
    \n - rodzaj prawa: {{ praca.rodzaj_prawa|default:"brak" }}
    \n - data zgłoszenia: {{ praca.data_zgloszenia|default:"brak" }}
    \n - numer zgłoszenia: {{ praca.numer_zgloszenia|default:"brak" }}
    \n - data decyzji: {{ praca.data_decyzji|default:"brak" }}
    \n - numer prawa wyłącznego: {{ praca.numer_prawa_wylacznego|default:"brak" }}
    \n - wdrożenie: {% if praca.wdrozenie %}tak{% else %}nie{% endif %}\n
    \n Strona WWW:\n \n {% if praca.public_www %}\n \n {{ praca.public_www|truncatechars:120 }}\n {% elif praca.www %}\n {{ praca.www|truncatechars:120 }}\n {% else %}\n Brak danych\n {% endif %}\n
    DOI{{ praca.doi }}
    PubMed ID:{{ praca.pubmed_id }}
    PMC ID:{{ praca.pmc_id }}
    \n BPP ID:\n \n {{ rekord.pk }} czyli {{ rekord.describe_content_type }} o ID = {{ praca.pk }}\n
    \n BibTeX:\n \n \n \n \n ✓ Skopiowane!\n \n \n
    PBN UID:\n \n {% if not request.user.is_anonymous and praca.link_do_pi %}\n \n {% endif %}\n
    \n PBN ID (historyczne):\n \n {{ praca.pbn_id }}\n
    \n Rok:\n \n {{ praca.rok }}\n
    \n Charakter formalny:\n \n {{ praca.charakter_formalny }}\n
    \n Język:\n \n {{ praca.jezyk }}\n
    \n Typ MNiSW/MEiN:\n \n {{ praca.typ_kbn }}\n
    OpenAccess:\n {% if praca.openaccess_tryb_dostepu %}\n - tryb dostępu: \n {{ praca.openaccess_tryb_dostepu|lower }}\n
    \n {% endif %}\n\n {% if praca.openaccess_wersja_tekstu %}\n\n - wersja tekstu: \n {{ praca.openaccess_wersja_tekstu|lower }}
    \n\n {% endif %}\n {% if praca.openaccess_licencja %}\n\n - licencja: \n {% if praca.openaccess_licencja.webname %}\n \n {% endif %}\n {{ praca.openaccess_licencja }}\n {% if praca.openaccess_licencja.webname %}\n \n {% endif %}\n
    \n\n {% endif %}\n {% if praca.openaccess_czas_publikacji %}\n\n - czas udostępnienia: \n {{ praca.openaccess_czas_publikacji|lower }}
    \n\n {% endif %}\n {% if praca.openaccess_ilosc_miesiecy %}\n\n - ilość miesięcy: \n {{ praca.openaccess_ilosc_miesiecy }}\n ilość miesięcy które upłynęły od momentu opublikowania do momentu udostępnienia\n \n
    \n\n {% endif %}\n
    \n Punkty MNiSW/MEiN:\n \n {{ praca.punkty_kbn }}\n
    \n Impact factor:\n \n {{ praca.impact_factor }}\n
    \n Kwartyl w SCOPUS:\n \n Q{{ praca.kwartyl_w_scopus }}\n
    \n Kwartyl w WoS:\n \n Q{{ praca.kwartyl_w_wos }}\n
    Liczba cytowań:{{ praca.liczba_cytowan }}
    Liczba arkuszy wydawniczych:{{ praca.wymiar_wydawniczy_w_arkuszach }}
    Punktacja SNIP:{{ praca.punktacja_snip }}
    \n Index Copernicus:\n \n {{ praca.index_copernicus }}\n
    \n Punktacja wewnętrzna:\n \n {{ praca.punktacja_wewnetrzna }}\n
    \n Status:\n \n {{ praca.status_korekty }}\n
    Rekordy powiązane\n
      \n {% for elem in praca.wydawnictwa_powiazane_posortowane.all %}\n
    1. \n {{ elem.opis_bibliograficzny_cache|safe }}\n
    2. \n {% endfor %}\n
    \n
    \n Praca recenzowana:\n \n {{ praca.recenzowana|yesno }}\n
    Odpowiedzialność za powstanie pracy\n {% for autor in praca.autorzy_set.all %}\n {% if autor.procent %}\n {{ autor.procent }}% {{ autor.zapisany_jako }}
    \n {% endif %}\n {% endfor %}\n\n
    \n Rekord utworzony:\n \n {{ praca.utworzono }}\n
    \n Rekord zaktualizowany:\n \n {{ praca.ostatnio_zmieniony }}\n
    Zewnętrzna
    baza danych:
    \n
      {% for db in praca.zewnetrzna_baza_danych.all %}\n
    • {{ db.baza.nazwa }}
    • \n {% endfor %}\n
    \n
    \n\n{% if rekord.ma_punktacje_sloty %}\n {% if uczelnia.pokazuj_tabele_slotow_na_stronie_rekordu == "always" or uczelnia.pokazuj_tabele_slotow_na_stronie_rekordu == "logged-in" and not request.user.is_anonymous %}\n

    Punkty i sloty autorów\n {% if uczelnia.drukuj_oswiadczenia %}\n {% if request.user.is_superuser or request.user|has_group:"wprowadzanie danych" %}\n \n \n \n {% endif %}\n {% endif %}\n\n

    \n \n \n \n \n \n \n {% if uczelnia.drukuj_oswiadczenia %}\n {% if request.user.is_superuser or request.user|has_group:"wprowadzanie danych" %}\n \n {% endif %}\n {% endif %}\n \n\n {% for pa in rekord.punktacja_autora.select_related %}\n \n \n \n \n \n {% if uczelnia.drukuj_oswiadczenia %}\n {% if request.user.is_superuser or request.user|has_group:"wprowadzanie danych" %}\n \n {% endif %}\n {% endif %}\n \n {% endfor %}\n
    AutorDyscyplinaPkD / PkDAutSlotOświadczenia
    {{ pa.autor }}{{ pa.dyscyplina.nazwa }}{{ pa.pkdaut }}{{ pa.slot }}\n \n \n \n \n \n {% if pa.czy_autor_ma_alternatywna_dyscypline and uczelnia.drukuj_alternatywne_oswiadczenia %}\n  \n \n \n \n {% endif %}\n
    \n

    Punkty i sloty dyscyplin

    \n \n \n \n \n \n \n {% for pd in rekord.punktacja_dyscypliny.select_related %}\n \n \n \n \n {% load user_in_group %}\n \n {% endfor %}\n
    DyscyplinaPkD / PkDAutSlot
    {{ pd.dyscyplina.nazwa }}{{ pd.pkd }}{{ pd.slot }}
    \n\n {% endif %}\n{% endif %}\n\n\n\n\n\n{% if rekord.ma_odpiete_dyscypliny %}\n {% if uczelnia.pokazuj_tabele_slotow_na_stronie_rekordu == "always" or uczelnia.pokazuj_tabele_slotow_na_stronie_rekordu == "logged-in" and not request.user.is_anonymous %}\n

    "Odpięte" dyscypliny:

    \n \n \n \n \n \n {% for pa in praca.odpiete_dyscypliny.select_related %}\n \n \n \n \n {% endfor %}\n
    AutorDyscyplina
    {{ pa.autor }}{{ pa.dyscyplina_naukowa.nazwa }}
    \n {% endif %}\n{% endif %}\n 2000-01-01 00:00:00+00 2000-01-01 00:00:00+00 3 oswiadczenia/tresc_jednego_oswiadczenia.html

    Oświadczenie upoważaniające podmiot do wykazania osiągnięć w ewaluacji jakości działalności naukowej

    \n
    \n

    {{ autor.nazwisko }} {{ autor.imiona }}

    \n

    ORCID: {{ autor.orcid }}

    \n

    Dyscypliny:

    \n
      \n
    • {{ dyscyplina_naukowa }}
    • \n {% if subdyscyplina_naukowa %}\n
    • {{ subdyscyplina_naukowa }}
    • \n {% endif %}\n
    \n

    \n Ja, {{ autor.nazwisko }} {{ autor.imiona }}, zgodnie z art. 265 ust. 13 ustawy z dnia\n 20 lipca 2018 r. – Prawo o szkolnictwie\n wyższym i nauce (Dz. U. z 2021 r. poz. 478, z późn. zm.) upoważniam do wykazania na potrzeby\n ewaluacji jakości działalności naukowej za lata 2022-2025 moich, wymienionych w niniejszym\n oświadczeniu osiągnięć przez {{ uczelnia.nazwa }} w dyscyplinie:\n {{ dyscyplina_pracy }}.\n

    \n

    \n Oświadczam, że osiągnięcia te powstały w związku z prowadzeniem przeze mnie działalności naukowej\n w tym podmiocie (nie dotyczy osiągnięć artystycznych).\n

    \n
    \n

    {{ data_oswiadczenia|default:"" }}...........................................................

    \n

    (data i podpis)

    \n
    \n
    \nDotyczy:
    \n{{ object.opis_bibliograficzny_cache|safe }}\n 2000-01-01 00:00:00+00 2000-01-01 00:00:00+00 \. @@ -17951,21 +17951,6 @@ COPY public.formdefaults_formfieldrepresentation (id, name, label, klass, "order 55 if_do do django.forms.fields.FloatField 7 nowe_raporty.forms_dynamiczne.RaportForm_raport_autorow 56 tylko_punktowane Tylko prace punktowane (pkt MNiSW > 0) django.forms.fields.BooleanField 8 nowe_raporty.forms_dynamiczne.RaportForm_raport_autorow 57 obiekt Autor django.forms.models.ModelChoiceField 9 nowe_raporty.forms_dynamiczne.RaportForm_raport_autorow -253 od_roku Od roku django.forms.fields.IntegerField 1 raport_slotow.forms.autor.AutorRaportSlotowForm -254 do_roku Do roku django.forms.fields.IntegerField 2 raport_slotow.forms.autor.AutorRaportSlotowForm -255 od_roku Od roku django.forms.fields.IntegerField 0 raport_slotow.forms.ewaluacja.ParametryRaportSlotowEwaluacjaForm -256 do_roku Do roku django.forms.fields.IntegerField 1 raport_slotow.forms.ewaluacja.ParametryRaportSlotowEwaluacjaForm -257 od_roku Od roku django.forms.fields.IntegerField 0 raport_slotow.forms.uczelnia.UtworzRaportSlotowUczelniaForm -258 do_roku Do roku django.forms.fields.IntegerField 1 raport_slotow.forms.uczelnia.UtworzRaportSlotowUczelniaForm -259 slot Slot django.forms.fields.DecimalField 3 raport_slotow.forms.uczelnia.UtworzRaportSlotowUczelniaForm -260 od_roku Od roku django.forms.fields.IntegerField 0 nowe_raporty.forms_dynamiczne.RaportForm_raport_uczelni -261 do_roku Do roku django.forms.fields.IntegerField 1 nowe_raporty.forms_dynamiczne.RaportForm_raport_uczelni -262 od_roku Od roku django.forms.fields.IntegerField 0 nowe_raporty.forms_dynamiczne.RaportForm_raport_wydzialow -263 do_roku Do roku django.forms.fields.IntegerField 1 nowe_raporty.forms_dynamiczne.RaportForm_raport_wydzialow -264 od_roku Od roku django.forms.fields.IntegerField 0 nowe_raporty.forms_dynamiczne.RaportForm_raport_jednostek -265 do_roku Do roku django.forms.fields.IntegerField 1 nowe_raporty.forms_dynamiczne.RaportForm_raport_jednostek -266 od_roku Od roku django.forms.fields.IntegerField 0 nowe_raporty.forms_dynamiczne.RaportForm_raport_autorow -267 do_roku Do roku django.forms.fields.IntegerField 1 nowe_raporty.forms_dynamiczne.RaportForm_raport_autorow \. @@ -19582,7 +19567,7 @@ SELECT pg_catalog.setval('public.django_countdown_sitecountdown_id_seq', 1, fals -- Name: django_migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - -- -SELECT pg_catalog.setval('public.django_migrations_id_seq', 1135, true); +SELECT pg_catalog.setval('public.django_migrations_id_seq', 1136, true); -- diff --git a/src/bpp/migrations/0488_purge_praca_tabela_dbtemplate.py b/src/bpp/migrations/0488_purge_praca_tabela_dbtemplate.py new file mode 100644 index 000000000..a6e732582 --- /dev/null +++ b/src/bpp/migrations/0488_purge_praca_tabela_dbtemplate.py @@ -0,0 +1,47 @@ +from django.db import migrations + +#: Wariant szablonu opisu bibliograficznego, zainstalowany do dbtemplates +#: migracją 0295. Migracja 0473 sprzątała dbtemplates tylko dla nazw +#: wskazywanych przez SzablonDlaOpisuBibliograficznego.nazwa_szablonu; +#: ta nazwa nie była wskazywana (domyślną jest opis_bibliograficzny.html), +#: więc jej wiersz przetrwał jako sierota. Dopóki istnieje, loader +#: dbtemplates — stojący przed plikowym — serwuje treść z bazy i edycje +#: pliku w repozytorium nie mają żadnego efektu. +NAZWA = "browse/praca_tabela.html" + + +def purge_wariant_dbtemplate(apps, schema_editor): + # Konkretne klasy modeli (denorm rebuild jak w 0473 i 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, + ] + + # Guard dysk-existence + log + delete + czyszczenie cache + dirty. + # flush=False → asynchroniczna kolejka denorm dokończy przeliczanie + # (migracja nieblokująca, jak 0473). + usun_dbtemplate_i_przebuduj(NAZWA, modele, flush=False, log=print) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("bpp", "0487_api_v1_przelaczniki"), + ("dbtemplates", "0002_alter_template_creation_date_and_more"), + ] + + operations = [ + migrations.RunPython(purge_wariant_dbtemplate, migrations.RunPython.noop), + ] diff --git a/src/bpp/tests/test_wcag/test_purge_dbtemplate.py b/src/bpp/tests/test_wcag/test_purge_dbtemplate.py new file mode 100644 index 000000000..1fe84600c --- /dev/null +++ b/src/bpp/tests/test_wcag/test_purge_dbtemplate.py @@ -0,0 +1,32 @@ +"""Osierocony wiersz dbtemplate dla wariantu opisu bibliograficznego. + +``browse/praca_tabela.html`` to alternatywny szablon opisu, zainstalowany +do ``dbtemplates`` migracją 0295. Migracja 0473 sprzątała dbtemplates, ale +tylko dla nazw wskazywanych przez ``SzablonDlaOpisuBibliograficznego`` +— ten wariant nie był wskazywany, więc jego wiersz przetrwał. + +Loader dbtemplates stoi przed plikowym, więc dopóki wiersz istnieje, +edycje szablonu na dysku (m.in. znacznik ``lang`` z WCAG 3.1.2) nie mają +żadnego efektu na instalacji, która przełączy się na ten wariant. +""" + +import pytest +from dbtemplates.models import Template +from django.template.loader import get_template + + +@pytest.mark.django_db +def test_wiersz_dbtemplate_wariantu_nie_istnieje(): + assert not Template.objects.filter(name="browse/praca_tabela.html").exists() + + +@pytest.mark.django_db +def test_wariant_laduje_sie_z_dysku_ze_znacznikiem_jezyka(): + # Po skasowaniu wiersza get_template czyta plik z repozytorium, więc + # widzi filtr oznacz_jezyk dodany w Task 4. + tpl = get_template("browse/praca_tabela.html") + + with open(tpl.origin.name, encoding="utf-8") as f: + zrodlo = f.read() + + assert "oznacz_jezyk" in zrodlo From d68590264425e97e8ad8784e3e79f838fc955d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 6 Aug 2026 23:46:22 +0200 Subject: [PATCH 11/20] fix(wcag): podswietlanie omija znaczniki HTML w rekordach powiazanych Co-Authored-By: Claude Opus 5 (1M context) --- .../bpp/js/related-records-highlight.js | 43 ++++++++++ src/bpp/static/scss/praca_detail.scss | 8 ++ .../templates/browse/praca_tabela_mono.html | 14 +-- src/bpp/tests/test_wcag/test_lang_opis.py | 29 +++++++ tests/js/related-records-highlight.test.js | 86 +++++++++++++++++++ 5 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 src/bpp/static/bpp/js/related-records-highlight.js create mode 100644 tests/js/related-records-highlight.test.js diff --git a/src/bpp/static/bpp/js/related-records-highlight.js b/src/bpp/static/bpp/js/related-records-highlight.js new file mode 100644 index 000000000..28e4b5906 --- /dev/null +++ b/src/bpp/static/bpp/js/related-records-highlight.js @@ -0,0 +1,43 @@ +/** + * Podswietlanie frazy w opisie bibliograficznym (HTML). + * + * Opis niesie zamierzony markup (, , ) oraz — od czasu wdrozenia + * WCAG 3.1.2 — wokol tytulu obcojezycznego. Naiwne + * `text.replace(regex, '$1')` nie odroznia tresci od + * znacznikow: fraza "en" trafiala w lang="en" i wstawiala w SRODEK + * atrybutu, produkujac zepsuty markup przy .html(text). + * + * Rozwiazanie: podziel wejscie na segmenty / tekst i podswietlaj + * wylacznie w tekstowych. + */ +(function (window) { + "use strict"; + + var TAG_LUB_TEKST = /(<[^>]*>)|([^<]+)/g; + + function escapeRegExp(ciag) { + return ciag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + /** + * @param {string} html - opis bibliograficzny (moze zawierac znaczniki) + * @param {string} fraza - szukany tekst (bez rozrozniania wielkosci liter) + * @returns {string} HTML z wokol trafien w tresci + */ + function bppHighlightOutsideTags(html, fraza) { + if (!fraza) { + return html; + } + + var regex = new RegExp("(" + escapeRegExp(fraza) + ")", "gi"); + + return html.replace(TAG_LUB_TEKST, function (_, znacznik, tekst) { + if (znacznik) { + return znacznik; + } + return tekst.replace(regex, '$1'); + }); + } + + window.bppHighlightOutsideTags = bppHighlightOutsideTags; +})(typeof window !== "undefined" ? window : this); diff --git a/src/bpp/static/scss/praca_detail.scss b/src/bpp/static/scss/praca_detail.scss index f23f64b2b..6892a6472 100644 --- a/src/bpp/static/scss/praca_detail.scss +++ b/src/bpp/static/scss/praca_detail.scss @@ -1175,3 +1175,11 @@ break-inside: avoid; } } + +// Podswietlenie trafienia w wyszukiwarce rekordow powiazanych. Kolor +// przeniesiony z inline'owego style="" — modul JS wstawia sam znacznik +// , bez atrybutow prezentacyjnych. +.bpp-highlight { + background-color: #fff59d; + padding: 2px; +} diff --git a/src/bpp/templates/browse/praca_tabela_mono.html b/src/bpp/templates/browse/praca_tabela_mono.html index 5283bef06..baee03c78 100644 --- a/src/bpp/templates/browse/praca_tabela_mono.html +++ b/src/bpp/templates/browse/praca_tabela_mono.html @@ -1,4 +1,4 @@ -{% load prace user_in_group dspace_links %} +{% load prace user_in_group dspace_links static %} {# Streszczenia pobierane RAZ: szablon odwołuje się do nich w kilku #} {# miejscach (warunki, pętla, licznik języków). Widok dodatkowo robi #} {# prefetch, więc całość to jedno zapytanie na stronę. #} @@ -974,6 +974,9 @@

    +{# Modul podswietlania — wydzielony z inline'owego JS ponizej, zeby dalo #} +{# sie go przetestowac jednostkowo (tests/js/). #} + "; + expect(highlight(html, "x")).toBe(html); + }); + }); }); describe("bppStripTags", () => { @@ -168,6 +215,40 @@ describe("bppStripTags", () => { it("laczy tresc z wielu segmentow tekstowych rozdzielonych znacznikiem", () => { expect(stripTags("Rola Candida w X")).toBe("Rola Candida w X"); }); + + // CodeQL js/incomplete-multi-character-sanitization, alert 156 (linia + // 145): "This string may still contain `"), ale to + // falszywy alarm co do WPLYWU: bppStripTags() ma jedno miejsce uzycia w + // calym repo (praca_tabela_mono.html:1185) i jego wynik trafia + // WYLACZNIE do .indexOf() przy filtrowaniu — nigdy do DOM. Zob. + // uzasadnienie w komentarzu nad bppStripTags w + // related-records-highlight.js. + describe("zalozenie bezpieczenstwa (CodeQL alert 156, falszywy alarm)", () => { + it("MOZE odtworzyc tekst '