From 9900aa9b2f35d1c74bd22986ba85fc9939a08ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 08:43:30 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(autocomplete):=20wyszukiwanie=20wydawni?= =?UTF-8?q?ctwa=20nadrz=C4=99dnego=20po=20ISBN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trzy niezależne usterki sprawiały, że ISBN nie znajdował wydawnictwa nadrzędnego — ani lokalnie, ani w PBN. 1. `type="BOOK"` w zapytaniach do PBN. Wydawnictwo nadrzędne dla rozdziału to w PBN prawie zawsze `EDITED_BOOK` (praca zbiorowa pod redakcją); w danych referencyjnych stosunek wynosi 272 do 2 na korzyść EDITED_BOOK. Filtr odcinał więc niemal cały szukany zasób. ISBN i DOI idą teraz bez `type` (filtr `isbn` w API PBN sam z siebie trafia wyłącznie w rekordy książkowe — `type="CHAPTER"` z ISBN-em zwraca pustkę), a tytuł dwoma leniwymi zapytaniami: EDITED_BOOK, potem BOOK. 2. `isbnlib.notisbn()` do rozpoznawania ISBN-u. Ta funkcja liczy cyfrę kontrolną, więc ISBN z literówką przestawał być rozpoznawany jako ISBN i trafiał do gałęzi wyszukiwania po tytule — cicho, bez wyników. Rozpoznanie idzie teraz po kształcie ciągu (`bpp.util.isbn`), a suma kontrolna jest używana wyłącznie tam, gdzie coś liczy: przy przeliczaniu ISBN-10 na ISBN-13. 3. Pole „Wydawnictwo nadrzędne" nie szukało po ISBN w ogóle — ani w module redakcyjnym, ani w wyszukiwarce publicznej, ani w `zglos_publikacje`. ISBN-y zapisywane są tak, jak wpisał je użytkownik (w bazie referencyjnej 236 rekordów z myślnikami, 146 bez), więc porównanie normalizuje obie strony — wpisaną i bazodanową. Dodatkowo: PBN nie przelicza ISBN-10 na ISBN-13, więc wysyłamy obie formy, a etykieta opcji „Pobierz z PBN…" pokazuje to, co wpisał użytkownik, zamiast formy skanonizowanej. Zachowanie serwera PBN sprawdzone empirycznie na `/api/v1/search/publications`: ISBN dopasowywany jest dosłownie, po formie kanonicznej — zapytanie z myślnikami zwraca zero wyników nawet wtedy, gdy PBN przechowuje ISBN właśnie z myślnikami. Dlatego kanonizacja przed wysyłką zostaje. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FMffMAhZ91hfdoUCc7SsJN --- ...anie-isbn-wydawnictwo-nadrzedne.bugfix.rst | 10 ++ .../test_wydawnictwo_nadrzedne_isbn.py | 106 ++++++++++++ .../test_wydawnictwo_nadrzedne_w_pbn_isbn.py | 155 ++++++++++++++++++ src/bpp/tests/test_util/test_isbn.py | 79 +++++++++ src/bpp/util/isbn.py | 123 ++++++++++++++ src/bpp/views/autocomplete/publications.py | 15 +- .../wydawnictwo_nadrzedne_w_pbn.py | 71 ++++++-- src/zglos_publikacje/autocomplete.py | 7 +- .../tests/test_autocomplete_isbn.py | 54 ++++++ 9 files changed, 599 insertions(+), 21 deletions(-) create mode 100644 src/bpp/newsfragments/wyszukiwanie-isbn-wydawnictwo-nadrzedne.bugfix.rst create mode 100644 src/bpp/tests/test_autocomplete/test_wydawnictwo_nadrzedne_isbn.py create mode 100644 src/bpp/tests/test_autocomplete/test_wydawnictwo_nadrzedne_w_pbn_isbn.py create mode 100644 src/bpp/tests/test_util/test_isbn.py create mode 100644 src/bpp/util/isbn.py create mode 100644 src/zglos_publikacje/tests/test_autocomplete_isbn.py diff --git a/src/bpp/newsfragments/wyszukiwanie-isbn-wydawnictwo-nadrzedne.bugfix.rst b/src/bpp/newsfragments/wyszukiwanie-isbn-wydawnictwo-nadrzedne.bugfix.rst new file mode 100644 index 000000000..af47b2f5e --- /dev/null +++ b/src/bpp/newsfragments/wyszukiwanie-isbn-wydawnictwo-nadrzedne.bugfix.rst @@ -0,0 +1,10 @@ +Wyszukiwanie wydawnictwa nadrzędnego rozumie teraz ISBN. Pola „Wydawnictwo +nadrzędne" (w module redakcyjnym, w wyszukiwarce publicznej i w formularzu +zgłaszania publikacji) szukają po numerze ISBN i e-ISBN niezależnie od tego, +czy myślniki wpisano w wyszukiwarce, czy zapisano je w bazie. Pole +„Wydawnictwo nadrzędne w PBN" przestało wymagać poprawnej cyfry kontrolnej — +ISBN z literówką był dotąd po cichu traktowany jak tytuł i nie znajdował +niczego — oraz przestało ograniczać wyszukiwanie w PBN do monografii +autorskich, przez co nie znajdowało prac zbiorowych pod redakcją, czyli +najczęstszych wydawnictw nadrzędnych. Dodatkowo, jeżeli w PBN numer zapisano +jako ISBN-13, a wpisano ISBN-10 (lub odwrotnie), rekord zostanie znaleziony. diff --git a/src/bpp/tests/test_autocomplete/test_wydawnictwo_nadrzedne_isbn.py b/src/bpp/tests/test_autocomplete/test_wydawnictwo_nadrzedne_isbn.py new file mode 100644 index 000000000..6270a38e7 --- /dev/null +++ b/src/bpp/tests/test_autocomplete/test_wydawnictwo_nadrzedne_isbn.py @@ -0,0 +1,106 @@ +"""Wyszukiwanie wydawnictwa nadrzędnego po ISBN. + +ISBN-y w BPP zapisywane są tak, jak wpisał je użytkownik — część z myślnikami, +część bez. Wyszukiwanie musi znaleźć rekord niezależnie od tego, w której +formie jest zapisany i w której został wpisany. +""" + +import json + +import pytest +from django.urls import reverse +from model_bakery import baker + +from bpp.models import Wydawnictwo_Zwarte + +# ISBN-13 i jego odpowiednik ISBN-10 (poprawne sumy kontrolne). +ISBN13_Z_MYSLNIKAMI = "978-83-7430-700-0" +ISBN13_BEZ_MYSLNIKOW = "9788374307000" +ISBN10 = "8374307005" + + +def _tytuly(res): + return [x["text"] for x in json.loads(res.content)["results"]] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "zapisany,wpisany", + [ + (ISBN13_Z_MYSLNIKAMI, ISBN13_BEZ_MYSLNIKOW), + (ISBN13_BEZ_MYSLNIKOW, ISBN13_Z_MYSLNIKAMI), + (ISBN13_Z_MYSLNIKAMI, ISBN13_Z_MYSLNIKAMI), + (ISBN13_BEZ_MYSLNIKOW, ISBN13_BEZ_MYSLNIKOW), + ], +) +def test_wydawnictwo_nadrzedne_autocomplete_isbn_niezaleznie_od_myslnikow( + admin_client, ksiazka, zapisany, wpisany +): + ksiazka.tytul_oryginalny = "Zupełnie inny tytuł" + ksiazka.isbn = zapisany + ksiazka.save() + + res = admin_client.get( + reverse("bpp:wydawnictwo-nadrzedne-autocomplete") + f"?q={wpisany}" + ) + assert _tytuly(res) == ["Zupełnie inny tytuł"] + + +@pytest.mark.django_db +def test_wydawnictwo_nadrzedne_autocomplete_isbn10_znajduje_isbn13( + admin_client, ksiazka +): + """PBN ani baza nie przeliczają ISBN-10 na ISBN-13 — robi to wyszukiwarka.""" + ksiazka.tytul_oryginalny = "Zupełnie inny tytuł" + ksiazka.isbn = ISBN13_Z_MYSLNIKAMI + ksiazka.save() + + res = admin_client.get( + reverse("bpp:wydawnictwo-nadrzedne-autocomplete") + f"?q={ISBN10}" + ) + assert _tytuly(res) == ["Zupełnie inny tytuł"] + + +@pytest.mark.django_db +def test_wydawnictwo_nadrzedne_autocomplete_szuka_takze_po_e_isbn( + admin_client, ksiazka +): + ksiazka.tytul_oryginalny = "Zupełnie inny tytuł" + ksiazka.isbn = "" + ksiazka.e_isbn = ISBN13_Z_MYSLNIKAMI + ksiazka.save() + + res = admin_client.get( + reverse("bpp:wydawnictwo-nadrzedne-autocomplete") + f"?q={ISBN13_BEZ_MYSLNIKOW}" + ) + assert _tytuly(res) == ["Zupełnie inny tytuł"] + + +@pytest.mark.django_db +def test_wydawnictwo_nadrzedne_autocomplete_nie_isbn_szuka_po_tytule( + admin_client, ksiazka +): + """Zwykły tekst ma dalej trafiać w wyszukiwanie po tytule.""" + ksiazka.tytul_oryginalny = "Interna Szczeklika 2021" + ksiazka.isbn = ISBN13_Z_MYSLNIKAMI + ksiazka.save() + + res = admin_client.get( + reverse("bpp:wydawnictwo-nadrzedne-autocomplete") + "?q=Szczeklika" + ) + assert _tytuly(res) == ["Interna Szczeklika 2021"] + + +@pytest.mark.django_db +def test_public_wydawnictwo_nadrzedne_autocomplete_isbn(admin_client, ksiazka): + """Publiczny wariant też rozumie ISBN — zawężony do realnych nadrzędnych.""" + ksiazka.tytul_oryginalny = "Zupełnie inny tytuł" + ksiazka.isbn = ISBN13_Z_MYSLNIKAMI + ksiazka.save() + baker.make(Wydawnictwo_Zwarte, wydawnictwo_nadrzedne=ksiazka) + + res = admin_client.get( + reverse("bpp:public-wydawnictwo-nadrzedne-autocomplete") + + f"?q={ISBN13_BEZ_MYSLNIKOW}" + ) + assert _tytuly(res) == ["Zupełnie inny tytuł"] diff --git a/src/bpp/tests/test_autocomplete/test_wydawnictwo_nadrzedne_w_pbn_isbn.py b/src/bpp/tests/test_autocomplete/test_wydawnictwo_nadrzedne_w_pbn_isbn.py new file mode 100644 index 000000000..ab896ab6d --- /dev/null +++ b/src/bpp/tests/test_autocomplete/test_wydawnictwo_nadrzedne_w_pbn_isbn.py @@ -0,0 +1,155 @@ +"""Rozpoznawanie i wyszukiwanie ISBN w autocomplete „Wydawnictwo nadrzędne w PBN". + +Zachowanie serwera PBN sprawdzone empirycznie na ``/api/v1/search/publications``: + +* ISBN dopasowywany jest **dosłownie, po formie kanonicznej** — zapytanie + z myślnikami zwraca zero wyników nawet wtedy, gdy PBN przechowuje ISBN + właśnie z myślnikami; +* PBN nie przelicza ISBN-10 na ISBN-13 ani odwrotnie; +* ``type="BOOK"`` odcina ``EDITED_BOOK``, czyli praktycznie wszystkie realne + wydawnictwa nadrzędne (prace zbiorowe pod redakcją). +""" + +from unittest.mock import Mock, call + +import pytest +from model_bakery import baker + +from bpp.views.autocomplete.wydawnictwo_nadrzedne_w_pbn import ( + Wydawnictwo_Nadrzedne_W_PBNAutocomplete as Widok, +) +from pbn_api.models import Publication + +ISBN13_Z_MYSLNIKAMI = "978-83-7430-700-0" +ISBN13_BEZ_MYSLNIKOW = "9788374307000" +ISBN10 = "8374307005" + +MONGO_ID = "616e76ca2467f070ae3355dd" + + +def _klient(*wyniki): + client = Mock() + client.search_publications.side_effect = list(wyniki) or [[]] + return client + + +# --- qualify_query ------------------------------------------------------- + + +def test_qualify_query_rozpoznaje_isbn_z_myslnikami(): + assert Widok().qualify_query(ISBN13_Z_MYSLNIKAMI) == Widok.ISBN + + +def test_qualify_query_rozpoznaje_isbn_bez_myslnikow(): + assert Widok().qualify_query(ISBN13_BEZ_MYSLNIKOW) == Widok.ISBN + + +def test_qualify_query_rozpoznaje_isbn_ze_zla_suma_kontrolna(): + """Regresja: ``isbnlib.notisbn`` odrzucał taki numer, przez co wyszukiwarka + szukała go jako tytułu i cicho nie znajdowała niczego.""" + assert Widok().qualify_query("978-83-01-12345-6") == Widok.ISBN + + +def test_qualify_query_rozpoznaje_mongoid(): + assert Widok().qualify_query(MONGO_ID) == Widok.MONGO_ID + + +def test_qualify_query_rozpoznaje_doi(): + assert Widok().qualify_query("10.1234/abcd.2021") == Widok.DOI + + +def test_qualify_query_reszta_to_tytul(): + assert Widok().qualify_query("Interna Szczeklika 2021") == Widok.TITLE + + +# --- zapytania do serwera PBN ------------------------------------------- + + +def test_isbn_leci_do_pbn_bez_filtra_type(): + """``type="BOOK"`` odcinałby EDITED_BOOK — czyli realne okładki.""" + client = _klient([], []) + list(Widok()._get_pbn_search_results(client, Widok.ISBN, ISBN13_Z_MYSLNIKAMI)) + + for wywolanie in client.search_publications.call_args_list: + assert "type" not in wywolanie.kwargs + + +def test_isbn_leci_do_pbn_w_formie_kanonicznej_i_w_obu_dlugosciach(): + client = _klient([], []) + list(Widok()._get_pbn_search_results(client, Widok.ISBN, ISBN13_Z_MYSLNIKAMI)) + + assert client.search_publications.call_args_list == [ + call(isbn=ISBN13_BEZ_MYSLNIKOW), + call(isbn=ISBN10), + ] + + +def test_isbn_nie_pyta_o_drugi_wariant_gdy_pierwszy_wystarczyl(): + """Zapytania mają być leniwe — drugi wariant to dodatkowy round-trip.""" + client = _klient([{"mongoId": MONGO_ID}], []) + wyniki = Widok()._get_pbn_search_results(client, Widok.ISBN, ISBN13_Z_MYSLNIKAMI) + + assert next(iter(wyniki)) == {"mongoId": MONGO_ID} + assert client.search_publications.call_args_list == [ + call(isbn=ISBN13_BEZ_MYSLNIKOW) + ] + + +def test_tytul_leci_do_pbn_dla_edited_book_i_book(): + client = _klient([], []) + list(Widok()._get_pbn_search_results(client, Widok.TITLE, "Interna Szczeklika")) + + assert client.search_publications.call_args_list == [ + call(title="Interna Szczeklika", type="EDITED_BOOK"), + call(title="Interna Szczeklika", type="BOOK"), + ] + + +def test_doi_leci_do_pbn_bez_filtra_type(): + client = _klient([]) + list( + Widok()._get_pbn_search_results( + client, Widok.DOI, "https://doi.org/10.1234/abcd" + ) + ) + + assert client.search_publications.call_args_list == [call(doi="10.1234/abcd")] + + +# --- lokalny cache ------------------------------------------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize("wpisany", [ISBN13_Z_MYSLNIKAMI, ISBN13_BEZ_MYSLNIKOW, ISBN10]) +def test_lokalnie_isbn_znajduje_publikacje_w_kazdym_zapisie(wpisany): + baker.make(Publication, mongoId=MONGO_ID, title="Tytuł", isbn=ISBN13_BEZ_MYSLNIKOW) + + widok = Widok() + widok.q = wpisany + assert [x.pk for x in widok.get_queryset()] == [MONGO_ID] + + +@pytest.mark.django_db +def test_lokalnie_isbn_znajduje_gdy_pole_ma_dopisek(): + """PBN bywa zaśmiecony — ``9788374307000 (druk)`` to realny zapis.""" + baker.make( + Publication, + mongoId=MONGO_ID, + title="Tytuł", + isbn=f"{ISBN13_BEZ_MYSLNIKOW} (druk)", + ) + + widok = Widok() + widok.q = ISBN13_Z_MYSLNIKAMI + assert [x.pk for x in widok.get_queryset()] == [MONGO_ID] + + +# --- etykieta opcji „pobierz z PBN" ------------------------------------- + + +def test_create_option_pokazuje_to_co_wpisal_uzytkownik(): + """Pokazywanie formy skanonizowanej myliło — user nie poznawał swojego ISBN-u.""" + (opcja,) = Widok().get_create_option({}, ISBN13_Z_MYSLNIKAMI) + + assert ISBN13_Z_MYSLNIKAMI in opcja["text"] + assert opcja["id"] == ISBN13_Z_MYSLNIKAMI diff --git a/src/bpp/tests/test_util/test_isbn.py b/src/bpp/tests/test_util/test_isbn.py new file mode 100644 index 000000000..0a3e9faf1 --- /dev/null +++ b/src/bpp/tests/test_util/test_isbn.py @@ -0,0 +1,79 @@ +"""Testy pomocników ISBN dla wyszukiwarek (``bpp.util.isbn``).""" + +import pytest + +from bpp.util.isbn import kanoniczny_isbn, warianty_isbn, wyglada_jak_isbn + + +@pytest.mark.parametrize( + "wpisany,oczekiwany", + [ + ("978-83-7430-653-9", "9788374306539"), + ("9788374306539", "9788374306539"), + ("978 83 7430 653 9", "9788374306539"), + (" 978.83.7430.653.9 ", "9788374306539"), + ("83-7430-653-x", "837430653X"), + ("", ""), + (None, ""), + ], +) +def test_kanoniczny_isbn_zdejmuje_separatory(wpisany, oczekiwany): + assert kanoniczny_isbn(wpisany) == oczekiwany + + +@pytest.mark.parametrize( + "wpisany", + [ + "978-83-7430-653-9", + "9788374306539", + "83-7430-653-1", + "837430653X", + ], +) +def test_wyglada_jak_isbn_rozpoznaje_poprawne(wpisany): + assert wyglada_jak_isbn(wpisany) + + +def test_wyglada_jak_isbn_nie_waliduje_sumy_kontrolnej(): + """ISBN z literowką ma byc nadal traktowany jak ISBN, nie jak tytuł. + + ``isbnlib.notisbn`` liczy cyfrę kontrolną i odrzuca taki numer — przez co + wyszukiwarka szukała go jako tytułu i cicho nie znajdowała niczego. + """ + assert wyglada_jak_isbn("978-83-01-12345-6") + assert wyglada_jak_isbn("9788301123456") + + +@pytest.mark.parametrize( + "wpisany", + [ + "", + None, + "Interna Szczeklika 2021", + "10.1234/abcd", + "616e76ca2467f070ae3355dd", # mongoId — 24 znaki hex + "1234567890123", # 13 cyfr, ale nie prefiks 978/979 + "12345678", # za krótkie + ], +) +def test_wyglada_jak_isbn_odrzuca_nie_isbn(wpisany): + assert not wyglada_jak_isbn(wpisany) + + +def test_warianty_isbn_dodaje_odpowiednik_isbn10(): + """PBN nie konwertuje ISBN-10 na ISBN-13, więc musimy podać obie formy.""" + assert warianty_isbn("978-83-7430-700-0") == ["9788374307000", "8374307005"] + + +def test_warianty_isbn_dodaje_odpowiednik_isbn13(): + assert warianty_isbn("0306406152") == ["0306406152", "9780306406157"] + + +def test_warianty_isbn_bez_duplikatow_gdy_konwersja_niemozliwa(): + """Zła suma kontrolna — isbnlib nie przeliczy, zostaje sama forma kanoniczna.""" + assert warianty_isbn("978-83-01-12345-6") == ["9788301123456"] + + +def test_warianty_isbn_pusty_dla_smieci(): + assert warianty_isbn("") == [] + assert warianty_isbn(None) == [] diff --git a/src/bpp/util/isbn.py b/src/bpp/util/isbn.py new file mode 100644 index 000000000..09c41d0d0 --- /dev/null +++ b/src/bpp/util/isbn.py @@ -0,0 +1,123 @@ +"""Rozpoznawanie i normalizacja ISBN na potrzeby wyszukiwarek. + +Dlaczego nie ``isbnlib.notisbn`` do rozpoznawania: ta funkcja liczy cyfrę +kontrolną, więc ISBN z literówką (albo po prostu błędny w danych źródłowych, +czego w PBN jest sporo) przestaje być rozpoznawany jako ISBN. Wyszukiwarka +klasyfikowała go wtedy jako tytuł i cicho nie znajdowała niczego. Do +*rozpoznania intencji użytkownika* suma kontrolna jest nam obojętna — liczy +się kształt wpisanego ciągu. + +Sumy kontrolnej używamy natomiast tam, gdzie jest do czegoś potrzebna: przy +przeliczaniu ISBN-10 ↔ ISBN-13 (``warianty_isbn``), bo bez niej nie da się +policzyć poprawnego odpowiednika. +""" + +import re + +import isbnlib +from django.db.models import Q, TextField, Value +from django.db.models.functions import Replace, Upper + +#: Znaki-separatory spotykane w zapisie ISBN. Poza myślnikiem ASCII także +#: półpauza i pauza — użytkownicy wklejają ISBN z Worda i z PDF-ów. +SEPARATORY = "-‐‑‒–—.  \t" + +_USUWANE = str.maketrans("", "", SEPARATORY) + +_ISBN_10 = re.compile(r"[0-9]{9}[0-9X]\Z") +#: ISBN-13 zaczyna się od prefiksu GS1 978 albo 979 — bez tego warunku każdy +#: trzynastocyfrowy ciąg (np. numer zamówienia) byłby brany za ISBN. +_ISBN_13 = re.compile(r"97[89][0-9]{10}\Z") + + +def kanoniczny_isbn(txt) -> str: + """Zwróć ISBN bez separatorów, wielką literą X. + + Nie waliduje niczego — to czysta normalizacja zapisu. + """ + if not txt or not isinstance(txt, str): + return "" + return txt.strip().translate(_USUWANE).upper() + + +def wyglada_jak_isbn(txt) -> bool: + """Czy wpisany tekst ma kształt ISBN-u? BEZ walidacji sumy kontrolnej.""" + kanoniczny = kanoniczny_isbn(txt) + return bool(_ISBN_10.match(kanoniczny) or _ISBN_13.match(kanoniczny)) + + +def warianty_isbn(txt) -> list[str]: + """Formy ISBN-u, po których warto szukać: kanoniczna + ISBN-13 + ISBN-10. + + Ani PBN, ani nasza baza nie przeliczają ISBN-10 na ISBN-13 — rekord + zapisany w jednej formie nie znajdzie się po wpisaniu drugiej. Dlatego + szukamy po obu. Gdy suma kontrolna jest błędna, ``isbnlib`` zwraca pusty + ciąg i zostaje sama forma kanoniczna. + """ + kanoniczny = kanoniczny_isbn(txt) + if not kanoniczny: + return [] + + warianty = [kanoniczny] + for konwersja in (isbnlib.to_isbn13, isbnlib.to_isbn10): + inny = konwersja(kanoniczny) + if inny and inny not in warianty: + warianty.append(inny) + return warianty + + +def isbn_znormalizowany(pole: str): + """Wyrażenie ORM: wartość kolumny ``pole`` sprowadzona do formy kanonicznej. + + ISBN-y w BPP zapisywane są tak, jak wpisał je użytkownik — część z + myślnikami, część bez. Porównanie musi więc normalizować obie strony; + tu normalizujemy stronę bazodanową. + + Pokrewne, ale NIE to samo: ``import_common.core.normalized_db_isbn`` + (używane przez wyszukiwarkę globalną). Tamto wyrażenie ma zaszytą nazwę + kolumny ``isbn`` (więc nie obsłuży ``e_isbn``), zdejmuje wyłącznie myślnik + i sprowadza do małych liter. Tutaj potrzebujemy formy parametryzowanej + polem i zgodnej z ``kanoniczny_isbn`` po stronie Pythona. + + Uwaga wydajnościowa: takie porównanie nie użyje indeksu B-drzewa na + kolumnie. Ścieżka ta uruchamia się tylko wtedy, gdy wpisany tekst wygląda + jak ISBN, więc przy typowych rozmiarach tabeli wydawnictw jest to bez + znaczenia. Przy setkach tysięcy rekordów właściwym rozwiązaniem byłby + indeks funkcyjny na tym samym wyrażeniu. + """ + # ``output_field`` jest konieczne: ISBN bywa u nas raz ``CharField`` + # (``bpp.Wydawnictwo_Zwarte``), a raz ``TextField`` (``pbn_api.Publication``), + # i bez tego Django odmawia złożenia wyrażenia z ``Value("")``. + tekst = TextField() + wyrazenie = pole + for znak in SEPARATORY: + wyrazenie = Replace( + wyrazenie, + Value(znak, output_field=tekst), + Value("", output_field=tekst), + output_field=tekst, + ) + return Upper(wyrazenie, output_field=tekst) + + +def filtruj_tytul_lub_isbn(qs, txt, pole_tytulu, *pola_isbn): + """Zawęź ``qs`` do rekordów pasujących tytułem albo — gdy ``txt`` wygląda + jak ISBN — którymkolwiek z podanych pól ISBN. + + Warunki łączymy przez OR, a nie rozgałęziamy: wpisany ISBN praktycznie + nigdy nie wystąpi w tytule, więc dołożenie warunku po tytule nic nie + kosztuje, a ratuje rekordy z ISBN-em wklejonym w niewłaściwe pole. + Adnotacja normalizująca powstaje wyłącznie na ścieżce ISBN-owej. + """ + warunek = Q(**{f"{pole_tytulu}__icontains": txt}) + + if wyglada_jak_isbn(txt): + adnotacje = { + f"_isbn_norm_{pole}": isbn_znormalizowany(pole) for pole in pola_isbn + } + qs = qs.annotate(**adnotacje) + warianty = warianty_isbn(txt) + for nazwa in adnotacje: + warunek |= Q(**{f"{nazwa}__in": warianty}) + + return qs.filter(warunek) diff --git a/src/bpp/views/autocomplete/publications.py b/src/bpp/views/autocomplete/publications.py index 80c43e0af..6e92a361b 100644 --- a/src/bpp/views/autocomplete/publications.py +++ b/src/bpp/views/autocomplete/publications.py @@ -6,6 +6,7 @@ from bpp.const import CHARAKTER_OGOLNY_KSIAZKA from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte +from bpp.util.isbn import filtruj_tytul_lub_isbn from .mixins import SanitizedAutocompleteMixin @@ -13,7 +14,11 @@ class Wydawnictwo_NadrzedneAutocomplete( SanitizedAutocompleteMixin, autocomplete.Select2QuerySetView ): - """Autocomplete for parent publications (books only).""" + """Autocomplete for parent publications (books only). + + Rozumie zarówno tytuł, jak i ISBN — ten drugi niezależnie od tego, czy + użytkownik wpisał go z myślnikami i czy z myślnikami zapisano go w bazie. + """ def get_queryset(self): qs = Wydawnictwo_Zwarte.objects.filter( @@ -21,7 +26,9 @@ def get_queryset(self): ).order_by("tytul_oryginalny", "pk") if self.q: - qs = qs.filter(tytul_oryginalny__icontains=self.q) + qs = filtruj_tytul_lub_isbn( + qs, self.q, "tytul_oryginalny", "isbn", "e_isbn" + ) return qs @@ -72,5 +79,7 @@ def get_queryset(self): ).order_by("tytul_oryginalny", "pk") if self.q: - qs = qs.filter(tytul_oryginalny__icontains=self.q) + qs = filtruj_tytul_lub_isbn( + qs, self.q, "tytul_oryginalny", "isbn", "e_isbn" + ) return qs diff --git a/src/bpp/views/autocomplete/wydawnictwo_nadrzedne_w_pbn.py b/src/bpp/views/autocomplete/wydawnictwo_nadrzedne_w_pbn.py index 35326e251..c4861bf47 100644 --- a/src/bpp/views/autocomplete/wydawnictwo_nadrzedne_w_pbn.py +++ b/src/bpp/views/autocomplete/wydawnictwo_nadrzedne_w_pbn.py @@ -1,8 +1,13 @@ -import isbnlib +import functools +import itertools +import operator + from dal import autocomplete from django import http +from django.db.models import Q from bpp.models import Uczelnia +from bpp.util.isbn import warianty_isbn, wyglada_jak_isbn from import_common.util import check_if_doi, strip_doi_urls from pbn_api.client import PBNClient from pbn_api.exceptions import WillNotExportError @@ -23,27 +28,30 @@ class Wydawnictwo_Nadrzedne_W_PBNAutocomplete( TITLE = "tytuł" DOI = "DOI" + #: Rodzaje publikacji PBN, które mogą być wydawnictwem nadrzędnym. Pole + #: ``type`` w API PBN jest skalarem, więc każdy rodzaj to osobne zapytanie — + #: stąd kolejność od najczęstszego. Praca zbiorowa pod redakcją + #: (``EDITED_BOOK``) jest typową „okładką" dla rozdziału; monografia + #: autorska (``BOOK``) zdarza się rzadko. + RODZAJE_NADRZEDNYCH = ("EDITED_BOOK", "BOOK") + def qualify_query(self, txt): """Zwraca wartość klucza po którym wyszukiwać w PBN, ale i tym samym kwalifikuje wpisaną przez użytkownika wartość ze zmiennej txt jako ISBN, DOI lub tytuł""" - if not isbnlib.notisbn(txt): - return self.ISBN - if check_mongoId(txt): return self.MONGO_ID if check_if_doi(txt): return self.DOI + if wyglada_jak_isbn(txt): + return self.ISBN + return self.TITLE def get_create_option(self, context, q): qual = self.qualify_query(q) - if qual == self.DOI: - q = strip_doi_urls(q) - elif qual == self.ISBN: - q = isbnlib.canonical(q) create_option = [ { @@ -96,8 +104,20 @@ def get_queryset(self): case self.MONGO_ID: return Publication.objects.filter(pk=self.q) case self.ISBN: - isbn = isbnlib.canonical(self.q) - return Publication.objects.filter(isbn=isbn) + # ``Publication.pull_up_isbn`` zapisuje ISBN znormalizowany, więc + # normalizujemy tylko stronę wpisaną przez użytkownika. + # ``icontains``, a nie równość, bo w PBN trafiają się dopiski + # („9788374307338 (druk)"); indeks GIN trigram na ``UPPER(isbn)`` + # z migracji 0028 to obsługuje. + warunek = functools.reduce( + operator.or_, + (Q(isbn__icontains=wariant) for wariant in warianty_isbn(self.q)), + # Element neutralny dla OR — chroni przed pustą listą + # wariantów, gdyby ``qualify_query`` kiedyś się rozjechało + # z ``warianty_isbn``. + Q(pk__in=[]), + ) + return Publication.objects.filter(warunek) case self.TITLE: return Publication.objects.filter(title__icontains=self.q) case self.DOI: @@ -107,18 +127,37 @@ def get_queryset(self): raise NotImplementedError(self.q) def _get_pbn_search_results(self, client, query_type, text): - """Get search results from PBN based on query type""" + """Wyszukaj w PBN. Zwraca leniwy iterator wyników (albo ``None``). + + Zapytania są łączone leniwie: kolejne odpala się dopiero wtedy, gdy + poprzednie zostanie wyczerpane. Przy typowym trafieniu w pierwszy + wariant nie wykonuje się ani jedno dodatkowe zapytanie sieciowe. + """ match query_type: case self.MONGO_ID: return client.search_publications(objectId=text) case self.ISBN: - text = isbnlib.canonical(text) - return client.search_publications(isbn=text, type="BOOK") + # Bez ``type``: filtr ``isbn`` w API PBN i tak trafia wyłącznie + # w rekordy książkowe (``type="CHAPTER"`` + ISBN zwraca pustkę), + # a każde zawężenie odcinałoby EDITED_BOOK. + # + # ISBN musi iść w formie kanonicznej — PBN indeksuje ISBN bez + # separatorów, ale zapytania NIE normalizuje, więc wersja + # z myślnikami nie znajduje niczego, nawet gdy PBN przechowuje + # ISBN właśnie z myślnikami. Warianty ISBN-10/ISBN-13 podajemy + # osobno, bo PBN ich nie przelicza. + return itertools.chain.from_iterable( + client.search_publications(isbn=wariant) + for wariant in warianty_isbn(text) + ) case self.TITLE: - return client.search_publications(title=text, type="BOOK") + return itertools.chain.from_iterable( + client.search_publications(title=text, type=rodzaj) + for rodzaj in self.RODZAJE_NADRZEDNYCH + ) case self.DOI: - text = strip_doi_urls(text) - return client.search_publications(doi=text, type="BOOK") + # Bez ``type`` — DOI i tak identyfikuje pojedynczą pracę. + return client.search_publications(doi=strip_doi_urls(text)) case _: return None diff --git a/src/zglos_publikacje/autocomplete.py b/src/zglos_publikacje/autocomplete.py index 533b52947..c4049918c 100644 --- a/src/zglos_publikacje/autocomplete.py +++ b/src/zglos_publikacje/autocomplete.py @@ -13,6 +13,7 @@ from bpp.const import CHARAKTER_OGOLNY_KSIAZKA from bpp.models.wydawca import Wydawca from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte +from bpp.util.isbn import filtruj_tytul_lub_isbn from bpp.views.autocomplete.mixins import SanitizedAutocompleteMixin from pbn_api.models.publication import Publication as PBN_Publication from pbn_api.models.publisher import Publisher as PBN_Publisher @@ -88,8 +89,10 @@ def get_queryset(self): if self.q: q = self.q.strip() - wz = wz.filter(tytul_oryginalny__icontains=q)[:MAX_RESULTS] - pbn_pub = pbn_pub.filter(title__icontains=q)[:MAX_RESULTS] + wz = filtruj_tytul_lub_isbn(wz, q, "tytul_oryginalny", "isbn", "e_isbn")[ + :MAX_RESULTS + ] + pbn_pub = filtruj_tytul_lub_isbn(pbn_pub, q, "title", "isbn")[:MAX_RESULTS] else: wz = wz.none() pbn_pub = pbn_pub.none() diff --git a/src/zglos_publikacje/tests/test_autocomplete_isbn.py b/src/zglos_publikacje/tests/test_autocomplete_isbn.py new file mode 100644 index 000000000..0cceb46df --- /dev/null +++ b/src/zglos_publikacje/tests/test_autocomplete_isbn.py @@ -0,0 +1,54 @@ +"""Publiczne autocomplete wydawnictwa nadrzędnego rozumie ISBN. + +To samo zachowanie, co w adminie: ISBN znajduje rekord niezależnie od tego, +czy myślniki są po stronie wpisu, po stronie bazy, czy po żadnej. +""" + +import json + +import pytest +from django.urls import reverse +from model_bakery import baker + +from pbn_api.models.publication import Publication as PBN_Publication + +ISBN13_Z_MYSLNIKAMI = "978-83-7430-700-0" +ISBN13_BEZ_MYSLNIKOW = "9788374307000" + + +def _etykiety(res): + """Etykiety wyników. Select2QuerySetSequenceView grupuje je po modelu, + więc właściwe pozycje siedzą w ``children``.""" + etykiety = [] + for grupa in json.loads(res.content)["results"]: + etykiety += [dziecko["text"] for dziecko in grupa.get("children", [])] + return etykiety + + +@pytest.mark.django_db +def test_public_wn_autocomplete_znajduje_ksiazke_bpp_po_isbn(client, ksiazka): + ksiazka.tytul_oryginalny = "Zupełnie inny tytuł" + ksiazka.isbn = ISBN13_Z_MYSLNIKAMI + ksiazka.save() + + res = client.get( + reverse("zglos_publikacje:public-wydawnictwo-nadrzedne-autocomplete") + + f"?q={ISBN13_BEZ_MYSLNIKOW}" + ) + assert any("Zupełnie inny tytuł" in e for e in _etykiety(res)) + + +@pytest.mark.django_db +def test_public_wn_autocomplete_znajduje_rekord_pbn_po_isbn(client): + baker.make( + PBN_Publication, + mongoId="616e76ca2467f070ae3355dd", + title="Interna Szczeklika 2021", + isbn=ISBN13_BEZ_MYSLNIKOW, + ) + + res = client.get( + reverse("zglos_publikacje:public-wydawnictwo-nadrzedne-autocomplete") + + f"?q={ISBN13_Z_MYSLNIKAMI}" + ) + assert any("Interna Szczeklika 2021" in e for e in _etykiety(res)) From af76fc0000be2961c07114f036614b3565d125e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 09:58:30 +0200 Subject: [PATCH 2/2] fix(wyszukiwarka): znajduj rekordy po ISBN w obu wyszukiwarkach globalnych MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wyszukiwarka publiczna porównywała ISBN dosłownie: qry |= Q(pk__in=Rekord.objects.filter(isbn__iexact=self.q)) Żadnej normalizacji po którejkolwiek stronie — rekord zapisany jako `978-83-7430-700-0` nie znajdował się po wpisaniu `9788374307000` ani odwrotnie. Wyszukiwarka redakcyjna normalizowała, ale niejednakowo: strona zapytania (`normalize_isbn`) zdejmowała kropki, myślniki i spacje i nie ruszała wielkości liter, a strona bazy (`normalized_db_isbn`) zdejmowała wyłącznie myślnik i sprowadzała do małych liter. Skutki: ISBN zapisany w bazie ze spacjami nie był znajdowany, a ISBN-10 z cyfrą kontrolną „X" przepadał na samej różnicy wielkości litery. Żadna z wyszukiwarek nie zaglądała do `e_isbn` ani nie przeliczała ISBN-10 na ISBN-13. Obie używają teraz wspólnych `warunek_po_isbn` / `adnotacje_isbn` z `bpp.util.isbn` — normalizacja identyczna po obu stronach porównania, `isbn` razem z `e_isbn`, plus odpowiednik w drugiej długości. Zbiór wyników jest świadomie NADZBIOREM dotychczasowego: warunek ISBN-owy nie jest bramkowany na `wyglada_jak_isbn` (dopasowanie jest równościowe, więc zwykły tytuł i tak niczego nie trafi), a odziedziczony limit `len(ni) < 20` zniknął — w wersji publicznej takiego limitu nie było, więc jego wprowadzenie mogłoby coś zgubić. `normalized_db_isbn` zostało usunięte razem z eksportem z `import_common.core` — po tej zmianie nie miało już użytkowników, a zostawienie go groziło tym, że ktoś sięgnie po wersję z rozjazdem. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FMffMAhZ91hfdoUCc7SsJN --- .../wyszukiwarki-globalne-isbn.bugfix.rst | 7 ++ .../test_autocomplete/test_navigation_isbn.py | 112 ++++++++++++++++++ src/bpp/util/isbn.py | 55 +++++++-- src/bpp/views/autocomplete/navigation.py | 38 +++--- src/import_common/core/__init__.py | 2 - src/import_common/core/normalize_db.py | 3 - 6 files changed, 184 insertions(+), 33 deletions(-) create mode 100644 src/bpp/newsfragments/wyszukiwarki-globalne-isbn.bugfix.rst create mode 100644 src/bpp/tests/test_autocomplete/test_navigation_isbn.py diff --git a/src/bpp/newsfragments/wyszukiwarki-globalne-isbn.bugfix.rst b/src/bpp/newsfragments/wyszukiwarki-globalne-isbn.bugfix.rst new file mode 100644 index 000000000..2e644196d --- /dev/null +++ b/src/bpp/newsfragments/wyszukiwarki-globalne-isbn.bugfix.rst @@ -0,0 +1,7 @@ +Wyszukiwarki globalne — publiczna i redakcyjna — znajdują teraz prace po numerze +ISBN niezależnie od tego, czy myślniki lub spacje wpisano w wyszukiwarce, czy +zapisano je w bazie. Przeszukiwany jest także numer e-ISBN, a rekord zapisany +jako ISBN-13 zostanie znaleziony po wpisaniu ISBN-10 i odwrotnie. Wcześniej +wyszukiwarka publiczna porównywała numer dosłownie, bez żadnej normalizacji, +a redakcyjna normalizowała obie strony porównania niejednakowo — przez co +przepadał m.in. ISBN-10 z cyfrą kontrolną „X". diff --git a/src/bpp/tests/test_autocomplete/test_navigation_isbn.py b/src/bpp/tests/test_autocomplete/test_navigation_isbn.py new file mode 100644 index 000000000..ea02b28f3 --- /dev/null +++ b/src/bpp/tests/test_autocomplete/test_navigation_isbn.py @@ -0,0 +1,112 @@ +"""Wyszukiwarki globalne (publiczna i redakcyjna) znajdują rekordy po ISBN. + +ISBN-y w BPP zapisywane są tak, jak wpisał je użytkownik — z myślnikami, +ze spacjami albo bez niczego. Obie wyszukiwarki muszą normalizować obie strony +porównania, przeszukiwać także ``e_isbn`` i radzić sobie z sytuacją, w której +rekord zapisano jako ISBN-13, a wpisano ISBN-10 (albo odwrotnie). +""" + +import json + +import pytest +from django.urls import reverse +from django.utils.http import urlencode + +from bpp.models import Rekord + +# ISBN-13 i jego odpowiednik ISBN-10 (poprawne sumy kontrolne). +ISBN13_Z_MYSLNIKAMI = "978-83-7430-700-0" +ISBN13_ZE_SPACJAMI = "978 83 7430 700 0" +ISBN13_BEZ_SEPARATOROW = "9788374307000" +ISBN10 = "8374307005" + +# ISBN-10 z cyfrą kontrolną X — sprawdza zgodność wielkości liter po obu +# stronach porównania. +ISBN10_Z_X = "83-7430-653-X" +ISBN10_Z_X_BEZ_MYSLNIKOW = "837430653X" + +TYTUL = "Zupełnie niepowtarzalny tytuł kontrolny" + + +def _url(nazwa, q): + return reverse(nazwa) + "?" + urlencode({"q": q}) + + +def _etykiety(res): + """Etykiety wyników; obie wyszukiwarki grupują je po modelu.""" + etykiety = [] + for grupa in json.loads(res.content)["results"]: + etykiety += [dziecko["text"] for dziecko in grupa.get("children", [])] + return etykiety + + +@pytest.fixture +def zwarte_z_isbn(wydawnictwo_zwarte): + def ustaw(isbn="", e_isbn=""): + wydawnictwo_zwarte.tytul_oryginalny = TYTUL + wydawnictwo_zwarte.isbn = isbn + wydawnictwo_zwarte.e_isbn = e_isbn + wydawnictwo_zwarte.save() + Rekord.objects.full_refresh() + return wydawnictwo_zwarte + + return ustaw + + +PRZYPADKI = [ + pytest.param(ISBN13_Z_MYSLNIKAMI, ISBN13_BEZ_SEPARATOROW, id="myslniki-w-bazie"), + pytest.param(ISBN13_BEZ_SEPARATOROW, ISBN13_Z_MYSLNIKAMI, id="myslniki-we-wpisie"), + pytest.param(ISBN13_ZE_SPACJAMI, ISBN13_BEZ_SEPARATOROW, id="spacje-w-bazie"), + pytest.param(ISBN13_Z_MYSLNIKAMI, ISBN10, id="isbn10-szuka-isbn13"), + pytest.param(ISBN10, ISBN13_BEZ_SEPARATOROW, id="isbn13-szuka-isbn10"), + pytest.param(ISBN10_Z_X, ISBN10_Z_X_BEZ_MYSLNIKOW, id="cyfra-kontrolna-X"), +] + + +@pytest.mark.django_db +@pytest.mark.parametrize("zapisany,wpisany", PRZYPADKI) +def test_publiczna_wyszukiwarka_znajduje_po_isbn( + client, zwarte_z_isbn, zapisany, wpisany +): + zwarte_z_isbn(isbn=zapisany) + + res = client.get(_url("bpp:navigation-autocomplete", wpisany)) + assert any(TYTUL in e for e in _etykiety(res)) + + +@pytest.mark.django_db +@pytest.mark.parametrize("zapisany,wpisany", PRZYPADKI) +def test_redakcyjna_wyszukiwarka_znajduje_po_isbn( + admin_client, zwarte_z_isbn, zapisany, wpisany +): + zwarte_z_isbn(isbn=zapisany) + + res = admin_client.get(_url("bpp:admin-navigation-autocomplete", wpisany)) + assert any(TYTUL in e for e in _etykiety(res)) + + +@pytest.mark.django_db +def test_publiczna_wyszukiwarka_znajduje_po_e_isbn(client, zwarte_z_isbn): + zwarte_z_isbn(isbn="", e_isbn=ISBN13_Z_MYSLNIKAMI) + + res = client.get(_url("bpp:navigation-autocomplete", ISBN13_BEZ_SEPARATOROW)) + assert any(TYTUL in e for e in _etykiety(res)) + + +@pytest.mark.django_db +def test_redakcyjna_wyszukiwarka_znajduje_po_e_isbn(admin_client, zwarte_z_isbn): + zwarte_z_isbn(isbn="", e_isbn=ISBN13_Z_MYSLNIKAMI) + + res = admin_client.get( + _url("bpp:admin-navigation-autocomplete", ISBN13_BEZ_SEPARATOROW) + ) + assert any(TYTUL in e for e in _etykiety(res)) + + +@pytest.mark.django_db +def test_wyszukiwanie_po_tytule_dziala_dalej(client, zwarte_z_isbn): + """Kontrola: dołożenie ISBN-u nie może zepsuć wyszukiwania po tytule.""" + zwarte_z_isbn(isbn=ISBN13_Z_MYSLNIKAMI) + + res = client.get(_url("bpp:navigation-autocomplete", "niepowtarzalny")) + assert any(TYTUL in e for e in _etykiety(res)) diff --git a/src/bpp/util/isbn.py b/src/bpp/util/isbn.py index 09c41d0d0..a17f6dcde 100644 --- a/src/bpp/util/isbn.py +++ b/src/bpp/util/isbn.py @@ -73,11 +73,10 @@ def isbn_znormalizowany(pole: str): myślnikami, część bez. Porównanie musi więc normalizować obie strony; tu normalizujemy stronę bazodanową. - Pokrewne, ale NIE to samo: ``import_common.core.normalized_db_isbn`` - (używane przez wyszukiwarkę globalną). Tamto wyrażenie ma zaszytą nazwę - kolumny ``isbn`` (więc nie obsłuży ``e_isbn``), zdejmuje wyłącznie myślnik - i sprowadza do małych liter. Tutaj potrzebujemy formy parametryzowanej - polem i zgodnej z ``kanoniczny_isbn`` po stronie Pythona. + Zastąpiło ``import_common.core.normalized_db_isbn``, które miało zaszytą + nazwę kolumny ``isbn`` (więc nie obsługiwało ``e_isbn``), zdejmowało + wyłącznie myślnik i sprowadzało do małych liter — przez co rozjeżdżało się + z pythonową ``normalize_isbn`` po drugiej stronie porównania. Uwaga wydajnościowa: takie porównanie nie użyje indeksu B-drzewa na kolumnie. Ścieżka ta uruchamia się tylko wtedy, gdy wpisany tekst wygląda @@ -100,6 +99,41 @@ def isbn_znormalizowany(pole: str): return Upper(wyrazenie, output_field=tekst) +def _nazwa_adnotacji(pole: str) -> str: + return f"_isbn_norm_{pole}" + + +def adnotacje_isbn(*pola: str) -> dict: + """Adnotacje ORM normalizujące wskazane kolumny z ISBN-em. + + Idą w parze z ``warunek_po_isbn`` — ten sam queryset musi dostać jedno + i drugie. + """ + return {_nazwa_adnotacji(pole): isbn_znormalizowany(pole) for pole in pola} + + +def warunek_po_isbn(txt, *pola: str): + """Warunek dopasowania po znormalizowanym ISBN, albo ``None``. + + ``None`` oznacza „nie ma po czym szukać" (wpisano same separatory albo + pustkę) — wtedy w ogóle nie dokładaj adnotacji. To NIE jest ostrożnościowy + detal: pusty wariant zrównałby się z każdym rekordem bez ISBN-u. + + Świadomie NIE sprawdzamy tu, czy tekst wygląda jak ISBN. Dopasowanie jest + równościowe, więc zwykły tytuł i tak niczego nie trafi, a każde dodatkowe + kryterium mogłoby wyciąć rekord, który dziś się znajduje — a wyszukiwarki + globalne mają znajdować więcej ISBN-ów, nie mniej. + """ + warianty = warianty_isbn(txt) + if not warianty: + return None + + warunek = Q() + for pole in pola: + warunek |= Q(**{f"{_nazwa_adnotacji(pole)}__in": warianty}) + return warunek + + def filtruj_tytul_lub_isbn(qs, txt, pole_tytulu, *pola_isbn): """Zawęź ``qs`` do rekordów pasujących tytułem albo — gdy ``txt`` wygląda jak ISBN — którymkolwiek z podanych pól ISBN. @@ -112,12 +146,9 @@ def filtruj_tytul_lub_isbn(qs, txt, pole_tytulu, *pola_isbn): warunek = Q(**{f"{pole_tytulu}__icontains": txt}) if wyglada_jak_isbn(txt): - adnotacje = { - f"_isbn_norm_{pole}": isbn_znormalizowany(pole) for pole in pola_isbn - } - qs = qs.annotate(**adnotacje) - warianty = warianty_isbn(txt) - for nazwa in adnotacje: - warunek |= Q(**{f"{nazwa}__in": warianty}) + warunek_isbn = warunek_po_isbn(txt, *pola_isbn) + if warunek_isbn is not None: + qs = qs.annotate(**adnotacje_isbn(*pola_isbn)) + warunek |= warunek_isbn return qs.filter(warunek) diff --git a/src/bpp/views/autocomplete/navigation.py b/src/bpp/views/autocomplete/navigation.py index 8455715bf..3a5726035 100644 --- a/src/bpp/views/autocomplete/navigation.py +++ b/src/bpp/views/autocomplete/navigation.py @@ -13,7 +13,6 @@ from bpp.models import Uczelnia from bpp.models.autor import Autor -from bpp.permissions import moze_wprowadzac_dane from bpp.models.cache import Rekord from bpp.models.konferencja import Konferencja from bpp.models.patent import Patent @@ -22,9 +21,9 @@ from bpp.models.profile import BppUser from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte +from bpp.permissions import moze_wprowadzac_dane +from bpp.util.isbn import adnotacje_isbn, warunek_po_isbn from bpp.util.orm import build_fulltext_search_query -from import_common.core import normalized_db_isbn -from import_common.normalization import normalize_isbn from .mixins import SanitizedAutocompleteMixin from .search_services import ( @@ -157,7 +156,12 @@ def _rekord_querysets(self, uczelnia): ftx = scope_rekord_do_uczelni(Rekord.objects.fulltext_filter(self.q), uczelnia) qry = Q(pk__in=Rekord.objects.filter(doi__iexact=self.q).values_list("pk")) - qry |= Q(pk__in=Rekord.objects.filter(isbn__iexact=self.q)) + if (warunek_isbn := warunek_po_isbn(self.q, "isbn", "e_isbn")) is not None: + qry |= Q( + pk__in=Rekord.objects.annotate(**adnotacje_isbn("isbn", "e_isbn")) + .filter(warunek_isbn) + .values_list("pk") + ) if jest_pbn_uid(self.q): qry |= Q(pk__in=Rekord.objects.filter(pbn_uid_id=self.q).values_list("pk")) glowny = scope_rekord_do_uczelni( @@ -508,18 +512,20 @@ def _add_publication_querysets(self, querysets): ]: query_filter = self._build_publication_filter(klass) - # Handle ISBN normalization if applicable - annotate_isbn = False - if hasattr(klass, "isbn"): - ni = normalize_isbn(self.q) - if len(ni) < 20: - query_filter |= Q(normalized_isbn=ni) - annotate_isbn = True - - if annotate_isbn: - qset = klass.objects.annotate( - normalized_isbn=normalized_db_isbn - ).filter(query_filter) + # ISBN: porównanie po formie znormalizowanej PO OBU STRONACH. + # W bazie numery zapisane są tak, jak wpisał je użytkownik — raz + # z myślnikami, raz ze spacjami, raz bez niczego — więc samo + # zdjęcie separatorów z wpisanego tekstu nie wystarcza. Szukamy + # też po ``e_isbn`` i po odpowiedniku ISBN-10/ISBN-13, bo rekord + # bywa zapisany w innej długości niż ta, którą pamięta użytkownik. + pola_isbn = [pole for pole in ("isbn", "e_isbn") if hasattr(klass, pole)] + warunek_isbn = warunek_po_isbn(self.q, *pola_isbn) if pola_isbn else None + + if warunek_isbn is not None: + query_filter |= warunek_isbn + qset = klass.objects.annotate(**adnotacje_isbn(*pola_isbn)).filter( + query_filter + ) else: qset = klass.objects.filter(query_filter) diff --git a/src/import_common/core/__init__.py b/src/import_common/core/__init__.py index 85f85dd20..198793a9b 100644 --- a/src/import_common/core/__init__.py +++ b/src/import_common/core/__init__.py @@ -46,7 +46,6 @@ normalize_date, normalize_zrodlo_nazwa_for_db_lookup, normalize_zrodlo_skrot_for_db_lookup, - normalized_db_isbn, normalized_db_title, normalized_db_zrodlo_nazwa, normalized_db_zrodlo_skrot, @@ -112,7 +111,6 @@ "normalize_date", "normalize_zrodlo_nazwa_for_db_lookup", "normalize_zrodlo_skrot_for_db_lookup", - "normalized_db_isbn", "normalized_db_title", "normalized_db_zrodlo_nazwa", "normalized_db_zrodlo_skrot", diff --git a/src/import_common/core/normalize_db.py b/src/import_common/core/normalize_db.py index f1f13308e..e3fa76abd 100644 --- a/src/import_common/core/normalize_db.py +++ b/src/import_common/core/normalize_db.py @@ -73,6 +73,3 @@ def normalize_zrodlo_nazwa_for_db_lookup(s): # trygramowe i dopasowanie źródła znika (FD#321). s = html.unescape(s) return s.lower().replace(" ", "").strip() - - -normalized_db_isbn = Trim(Replace(Lower("isbn"), Value("-"), Value("")))