Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Importer publikacji podpowiada teraz język pracy również dla angielskiego
i pozostałych języków z danych referencyjnych BPP. Dotąd dopasowanie szło
wyłącznie przez pole „Skrót nazwy języka wg API CrossRef", które wypełnione
jest tylko dla polskiego — więc import pracy z ``language="en"`` zostawiał
pole „Język" puste. Teraz, gdy to pole jest niewypełnione, importer sięga po
kod BCP 47 języka (uzupełniony dla polskiego, angielskiego, niemieckiego,
francuskiego, hiszpańskiego, rosyjskiego i włoskiego), pomijając oznaczenie
regionu. Ta sama poprawka dotyczy języka streszczeń.
6 changes: 6 additions & 0 deletions src/bpp/newsfragments/importer-wstecz-stary-uklad.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Przycisk „Wstecz" w importerze publikacji nie przywraca już nieaktualnego
układu kroku 1 (wybór źródła przyciskami radio i lista sesji pod spodem).
Przeglądarki, które odwiedziły importer przed wprowadzeniem kafelków,
trzymały jego zrzut w pamięci podręcznej historii HTMX-a — a że nowa wersja
strony celowo nic już tam nie zapisuje, nieaktualny zrzut nie miał jak
zostać nadpisany. Strona kasuje go teraz przy wejściu.
6 changes: 6 additions & 0 deletions src/bpp/newsfragments/kafelki-importera-wyglad.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Kafelki wyboru źródła danych w importerze publikacji wyglądają teraz tak
samo jak kafelki kreatora „Zgłoś publikację": ikona, pogrubiony tytuł i opis
zwykłą czcionką. Wcześniej globalna reguła dla linków w ramkach ``callout``
pogrubiała cały kafelek i podkreślała po najechaniu myszą każdą linię jego
tekstu. Kafelki na obu ekranach unoszą się teraz delikatnie pod kursorem
(z poszanowaniem ustawienia „ogranicz ruch" w systemie).
47 changes: 46 additions & 1 deletion src/bpp/static/scss/_wizard_forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,17 @@
padding: 1.5rem;
cursor: pointer;
text-align: center;
transition: border-color 0.2s, box-shadow 0.2s;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
background: #fff;

&:hover {
text-decoration: none;
border-color: $primary-color;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
// Delikatne uniesienie — ten sam sygnał „to jest klikalne", co na
// pozostałych kafelkowych ekranach BPP. Wyłączane niżej przy
// `prefers-reduced-motion`.
transform: translateY(-3px);
}

&:focus {
Expand All @@ -89,6 +93,7 @@
display: block;
font-size: 3rem;
line-height: 1.2;
color: $primary-color;
}

.tile-title {
Expand Down Expand Up @@ -121,6 +126,46 @@
}
}

// Kafel-LINK w calloutcie: zdejmij z niego globalne style linków.
//
// Kafle importera publikacji to `<a class="tile-card">` stojące wewnątrz
// `.callout`, więc łapie je globalna reguła z `flash_messages.css`:
//
// .callout a:not(.close-button) { font-weight: bolder; … }
// .callout a:not(.close-button):hover { text-decoration: underline; }
//
// Efekt uboczny: pogrubiony CAŁY kafel (razem z opisem, który ma być zwykłą
// czcionką) i podkreślenie wszystkich linii tekstu po najechaniu myszą.
// Kafle kreatora zgłoszeń tego nie mają, bo są `<label>`, a nie `<a>` —
// i to jest wygląd docelowy dla obu ekranów.
//
// Specyficzność musi wyjść REMISOWA (0,2,1) z regułą wyżej — samo
// `.tile-card` (0,1,0) by przegrało. Remis rozstrzyga kolejność w pliku
// wynikowym, a `wizard_forms` jest importowane po `common` (czyli po
// `flash_messages`) we WSZYSTKICH motywach — patrz `app-*.scss`.
.callout a.tile-card {
font-weight: normal;
color: $body-font-color;

&:hover,
&:focus {
color: $body-font-color;
text-decoration: none;
}
}

// Uniesienie kafla to dekoracja — przy `prefers-reduced-motion` zostaje sama
// zmiana obramowania i cienia (nadal czytelny stan hover, bez ruchu).
@media (prefers-reduced-motion: reduce) {
.tile-card {
transition: border-color 0.2s, box-shadow 0.2s;

&:hover {
transform: none;
}
}
}

.wizard-nav {
display: grid;
grid-template-columns: 1fr 1fr;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Sprzątanie NIEŚWIEŻYCH snapshotów HTMX dla stron importera publikacji.
//
// Dlaczego to w ogóle istnieje
// ----------------------------
// `index.html` ma `hx-history="false"` na `#importer-wizard`, więc HTMX nigdy
// NIE ZAPISZE snapshotu tej strony do localStorage — przy Back/Forward pyta
// serwer o pełną stronę (patrz `_is_htmx_partial` w views/helpers.py).
//
// Problem: `restoreHistory()` w HTMX czyta cache BEZ oglądania się na
// `hx-history` — ta flaga blokuje tylko ZAPIS (`saveCurrentPageToHistory`).
// Wpisy zapisane ZANIM flaga się pojawiła (układ kroku 1 sprzed kafelków:
// radiowy wybór źródła + lista sesji pod spodem) siedzą w localStorage
// przeglądarki bezterminowo i to je HTMX przywraca po naciśnięciu „Wstecz".
// Użytkownik widzi wtedy layout, którego nie ma już nawet w kodzie.
//
// Samo się to nie naprawi: skoro zapisu nigdy nie będzie, nic tych wpisów nie
// nadpisze (HTMX podmienia wpis o tym samym URL-u dopiero przy zapisie), a
// LRU wyrzuci je dopiero po 10 innych stronach z HTMX-em. Dlatego kasujemy je
// jawnie przy każdym wejściu na stronę importera.
(function (window) {
"use strict";

var KLUCZ = "htmx-history-cache";

// Usuń z cache HTMX-a wpisy, których URL zaczyna się od `prefiks`.
// Zwraca liczbę usuniętych wpisów (0 gdy nie było czego usuwać).
function usunWpisy(storage, prefiks) {
var surowe = storage.getItem(KLUCZ);
if (!surowe) {
return 0;
}

var cache;
try {
cache = JSON.parse(surowe);
} catch (e) {
// Uszkodzony JSON (obcy kod pisał pod ten klucz, przerwany zapis).
// HTMX sam traktuje taki cache jak pusty (`parseJSON` zwraca null),
// więc kasujemy wpis w całości — inaczej zostawilibyśmy śmieć,
// którego i tak nikt nie odczyta.
storage.removeItem(KLUCZ);
return 0;
}

if (!Array.isArray(cache)) {
storage.removeItem(KLUCZ);
return 0;
}

var zostaje = cache.filter(function (wpis) {
return !(
wpis &&
typeof wpis.url === "string" &&
wpis.url.indexOf(prefiks) === 0
);
});

var usuniete = cache.length - zostaje.length;
if (usuniete > 0) {
storage.setItem(KLUCZ, JSON.stringify(zostaje));
}
return usuniete;
}

window.bppImporterHistoryCache = { usunWpisy: usunWpisy, KLUCZ: KLUCZ };

// Auto-start: prefiks URL-a importera wstrzykuje szablon (`data-prefiks`),
// żeby nie zaszywać tu ścieżki na sztywno.
var skrypt = window.document.currentScript;
if (!skrypt) {
return;
}
var prefiks = skrypt.getAttribute("data-prefiks");
if (!prefiks) {
return;
}
try {
usunWpisy(window.localStorage, prefiks);
} catch (e) {
// localStorage niedostępny (tryb prywatny, zablokowane dane witryn) —
// wtedy HTMX też go nie odczyta, więc nieświeży snapshot nie ma jak
// się pojawić i nie ma czego sprzątać. Strona działa normalnie.
}
})(window);
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
animation: wizardFadeIn 0.3s ease-out;
}
</style>
{# Kasuje nieświeże snapshoty HTMX-a dla stron importera z localStorage. #}
{# Musi pójść PRZED htmx.min.js: `restoreHistory()` czyta cache mimo #}
{# `hx-history="false"` (ta flaga blokuje tylko zapis), więc bez tego #}
{# „Wstecz" przywracał układ kroku 1 sprzed kafelków. Szczegóły w pliku. #}
<script src="{% static 'importer_publikacji/js/history_cache.js' %}"
data-prefiks="{% url 'importer_publikacji:index' %}"></script>
{# HTMX self-hostowany — bez zależności od zewnętrznego CDN-a. #}
<script src="{% static 'liveops/vendor/htmx.min.js' %}"></script>
<script>
Expand Down
31 changes: 31 additions & 0 deletions src/importer_publikacji/tests/test_index_history_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Strona importera dokleja skrypt czyszczący cache historii HTMX-a.

``#importer-wizard`` ma ``hx-history="false"``, co blokuje wyłącznie ZAPIS
snapshotu do localStorage — ``restoreHistory()`` w HTMX czyta cache tak czy
inaczej. Wpis zapisany przed zmianą layoutu kroku 1 (radiowy wybór źródła +
lista sesji) przeżywał więc bezterminowo i wracał po naciśnięciu „Wstecz".
``history_cache.js`` kasuje takie wpisy — ale tylko jeśli wykona się PRZED
htmx.min.js, stąd asercja na kolejność.

Logika samego czyszczenia jest testowana jednostkowo w
``tests/js/importer-history-cache.test.js`` (vitest).
"""

import pytest
from django.urls import reverse

# Nazwy plików statycznych niosą hash treści (ManifestStaticFilesStorage),
# więc szukamy po rdzeniu nazwy, nie po pełnym „history_cache.js".


@pytest.mark.django_db
def test_index_laduje_czyszczenie_historii_przed_htmx(importer_client):
content = importer_client.get(reverse("importer_publikacji:index")).content.decode()

assert "history_cache" in content, "brak skryptu czyszczącego cache historii"
assert 'data-prefiks="/importer_publikacji/"' in content, (
"skrypt bez prefiksu URL-a nie wie, które wpisy skasować"
)
assert content.index("history_cache") < content.index("htmx.min"), (
"czyszczenie musi wykonać się przed załadowaniem HTMX-a"
)
143 changes: 143 additions & 0 deletions src/importer_publikacji/tests/test_jezyk_po_kod_bcp47.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Autodetekcja języka musi działać na DANYCH REFERENCYJNYCH BPP.

FD#389 dorzucił fallback ``_resolve_jezyk`` dla kodów spoza enuma
``Jezyk.SKROT_CROSSREF`` = {en, es, pl}, ale oba tory — ``Komparator`` i sam
fallback — pytają wyłącznie o ``Jezyk.skrot_crossref``. A tę kolumnę
wypełnia tylko migracja ``0410``, i tylko dla polskiego; enum ogranicza też
listę wyboru w adminie do trzech pozycji, więc redakcja nie ustawi tam np.
„de". Efekt: na czystej instalacji import pracy z ``language="en"``
(np. DOI 10.1007/s00005-017-0485-3 z CrossRef) zostawiał pole „Język" puste,
a testy FD#389 tego nie łapały, bo same zakładały brakujący rekord.

Poprawne kody ISO są w BPP od dawna — w ``Jezyk.kod_bcp47``, wypełnianym
kuratorsko przez fixture ``jezyk.json`` i migrację ``0480`` (pl, en, de, fr,
es, ru, it). Fallback korzysta więc również z tej kolumny.
"""

import pytest

from importer_publikacji.providers import FetchedPublication
from importer_publikacji.tasks import _auto_match_type_and_language
from importer_publikacji.views.publikacja import _resolve_jezyk


def _session(importer_user):
from importer_publikacji.models import ImportSession

return ImportSession.objects.create(
created_by=importer_user,
provider_name="CrossRef",
identifier="10.1007/s00005-017-0485-3",
raw_data={},
normalized_data={"title": "x"},
)


@pytest.mark.django_db
def test_import_angielskiej_pracy_ustawia_jezyk_bez_dosypywania_danych(importer_user):
"""Sedno zgłoszenia: dane referencyjne BPP wystarczają, żeby ``en``
z CrossRef trafiło na rekord „angielski" — bez ręcznego ustawiania
``skrot_crossref`` w Danych systemowych."""
from bpp.models import Jezyk

angielski = Jezyk.objects.filter(kod_bcp47="en").order_by("pk").first()
assert angielski is not None, (
"dane referencyjne powinny mieć język z kod_bcp47='en'"
)

session = _session(importer_user)
result = FetchedPublication(
raw_data={},
title="The PD-1/PD-L1 Inhibitory Pathway is Altered in Primary Glomerulonephritides",
language="en",
)

_auto_match_type_and_language(session, result)

assert session.jezyk == angielski


@pytest.mark.django_db
def test_resolve_jezyk_dopasowuje_po_kod_bcp47():
from bpp.models import Jezyk

jez = Jezyk.objects.create(nazwa="TEST-klingoński", skrot="tlh.", kod_bcp47="tlh")

assert _resolve_jezyk("tlh") == jez


@pytest.mark.django_db
def test_resolve_jezyk_woli_skrot_crossref_od_kod_bcp47():
"""``skrot_crossref`` to jawna decyzja redakcji — ma pierwszeństwo przed
kodem BCP 47, gdy oba wskazują na różne rekordy."""
from bpp.models import Jezyk

jawny = Jezyk.objects.create(
nazwa="TEST-jawny", skrot="tj.", skrot_crossref="es", kod_bcp47=""
)
Jezyk.objects.filter(kod_bcp47="es").exclude(pk=jawny.pk).update(
kod_bcp47="es-TESTOWY"
)
posredni = Jezyk.objects.create(nazwa="TEST-posredni", skrot="tp.", kod_bcp47="es")

assert _resolve_jezyk("es") == jawny
assert _resolve_jezyk("es") != posredni


@pytest.mark.django_db
def test_resolve_jezyk_ignoruje_region_w_kodzie():
"""CrossRef i langdetect potrafią zwrócić kod z regionem (``en-GB``,
``zh-cn``); dopasowujemy po podstawowym podtagu, w obie strony."""
from bpp.models import Jezyk

jez = Jezyk.objects.create(nazwa="TEST-region", skrot="tr.", kod_bcp47="qaa-QX")

assert _resolve_jezyk("qaa") == jez
assert _resolve_jezyk("qaa-QY") == jez


@pytest.mark.django_db
def test_resolve_jezyk_nieznany_kod_zwraca_none():
assert _resolve_jezyk("qqq") is None
assert _resolve_jezyk("") is None
assert _resolve_jezyk(None) is None


@pytest.mark.django_db
def test_krok_weryfikacji_pokazuje_wykryty_jezyk_jako_wybrany(
importer_user, importer_client
):
"""Domknięcie ścieżki: nie wystarczy ustawić ``session.jezyk`` — krok
„Weryfikacja" musi wyrenderować ten język jako ``selected`` w liście
wyboru. Bez tej asercji poprawka mapowania mogłaby być zielona, a pole
w formularzu i tak zostawałoby puste."""
from django.urls import reverse

from bpp.models import Jezyk
from importer_publikacji.models import ImportSession

angielski = Jezyk.objects.filter(kod_bcp47="en").order_by("pk").first()
assert angielski is not None

session = ImportSession.objects.create(
created_by=importer_user,
provider_name="CrossRef",
identifier="10.1007/s00005-017-0485-3",
raw_data={},
normalized_data={"title": "x", "year": 2017},
)
result = FetchedPublication(
raw_data={},
title="The PD-1/PD-L1 Inhibitory Pathway is Altered in Primary Glomerulonephritides",
language="en",
)
_auto_match_type_and_language(session, result)
session.save()

content = importer_client.get(
reverse("importer_publikacji:verify", args=[session.pk])
).content.decode()

assert f'<option value="{angielski.pk}" selected>' in content, (
"krok weryfikacji nie zaznaczył wykrytego języka w liście wyboru"
)
Loading