Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6b6789a
docs(zapytanie): spec wachlarza wyjść dla wyszukiwania zapytaniem
mpasternak Jul 26, 2026
88f13e1
docs(zapytanie): self-review speca — 3 poprawki
mpasternak Jul 26, 2026
019a460
docs(zapytanie): plan wdrożenia w 12 zadaniach
mpasternak Jul 26, 2026
230df96
docs(plan): konwencje testowe repo + mocniejsze asercje
mpasternak Jul 26, 2026
0076d69
refactor(zapytanie): wykonaj_zapytanie() jako jedno źródło prawdy
mpasternak Jul 26, 2026
65945cd
test(zapytanie): test dedupu faktycznie produkuje duplikat wierszy
mpasternak Jul 26, 2026
8c0ed28
refactor(multiseek): document_export_response() jako funkcja
mpasternak Jul 26, 2026
bd30670
docs: postac to identyfikatory ReportType, nie polskie etykiety
mpasternak Jul 26, 2026
ca2d9de
feat(zapytanie): postać wyniku (lista/tabela/punktacja) z partiali mu…
mpasternak Jul 26, 2026
6927c62
docs(plan): pivot wchodzi do <select> dopiero gdy działa
mpasternak Jul 26, 2026
c4b7f71
feat(zapytanie): eksport CSV/XLSX/HTML/DOCX/BibTeX z zapytania DjangoQL
mpasternak Jul 26, 2026
e04a4e5
fix(zapytanie): review fixes — usun niedzialajacy pivot, uzyj partial…
mpasternak Jul 26, 2026
68300a5
fix(zapytanie): chowaj martwy przycisk kopiowania BibTeX poza multise…
mpasternak Jul 26, 2026
1f840eb
fix(zapytanie): eksport — 400 jako text/plain + escape() błędu Django…
mpasternak Jul 26, 2026
5e40e87
feat(zapytanie): pasek eksportu w UI + newsfragment
mpasternak Jul 26, 2026
f60911e
docs(plan): zadanie 11 przywraca pasek eksportu dla autorów
mpasternak Jul 26, 2026
ed411c4
fix(zapytanie): poprawki z końcowego review — asercja 403, martwe re-…
mpasternak Jul 26, 2026
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
2,752 changes: 2,752 additions & 0 deletions docs/superpowers/plans/2026-07-26-zapytanie-wachlarz-wyjsc.md

Large diffs are not rendered by default.

423 changes: 423 additions & 0 deletions docs/superpowers/specs/2026-07-26-zapytanie-wachlarz-wyjsc-design.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/bpp/newsfragments/zapytanie-eksporty.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Wyszukiwanie zapytaniem (DjangoQL) pozwala teraz wybrać postać wyniku
(rekordy, lista, tabela, punktacja, BibTeX) i wyeksportować wyniki do CSV,
XLSX, HTML, DOCX oraz BibTeX-a — tak jak multiwyszukiwarka.
4 changes: 2 additions & 2 deletions src/bpp/templates/bpp/_zapytanie_pager.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{% if results.has_other_pages %}
<ul class="pagination" role="navigation" aria-label="Paginacja">
{% if results.has_previous %}
<li><a href="?model={{ model_key }}&query={{ query|urlencode }}&page={{ results.previous_page_number }}">&laquo; poprzednia</a></li>
<li><a href="?model={{ model_key }}&query={{ query|urlencode }}&postac={{ postac }}&page={{ results.previous_page_number }}">&laquo; poprzednia</a></li>
{% else %}
<li class="disabled">&laquo; poprzednia</li>
{% endif %}
<li class="current">{{ results.number }} / {{ results.paginator.num_pages }}</li>
{% if results.has_next %}
<li><a href="?model={{ model_key }}&query={{ query|urlencode }}&page={{ results.next_page_number }}">nastepna &raquo;</a></li>
<li><a href="?model={{ model_key }}&query={{ query|urlencode }}&postac={{ postac }}&page={{ results.next_page_number }}">nastepna &raquo;</a></li>
{% else %}
<li class="disabled">nastepna &raquo;</li>
{% endif %}
Expand Down
124 changes: 86 additions & 38 deletions src/bpp/templates/bpp/zapytanie.html
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@
}
.zapytanie-toolbar .button { margin-right: 0.4rem; }

/* Pasek eksportu wynikow (CSV/XLSX/HTML/DOCX/BibTeX) */
.zapytanie-eksport-toolbar { margin-top: 0.5rem; }
.zapytanie-eksport-toolbar .button { margin-right: 0.4rem; }

/* Rozbicie na zadanie — drzewo licznosci galezi (djangoql explain) */
#zapytanie-explain-panel[hidden] { display: none; }
#zapytanie-explain-panel { margin-top: 1.2rem; }
Expand Down Expand Up @@ -372,6 +376,16 @@ <h1>

</details>

<label for="id_postac" style="margin-top: 0.6rem;">
Postać wyniku
<select name="postac" id="id_postac">
{% for value, label in postacie %}
<option value="{{ value }}"
{% if postac == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>

<p style="margin-top: 1rem;" class="zapytanie-toolbar">
<button type="submit" class="button primary">
<span class="fi-magnifying-glass" aria-hidden="true"></span> Szukaj
Expand Down Expand Up @@ -450,45 +464,79 @@ <h2>Wyniki ({{ count }} rekord{{ count|pluralize:"y,ow" }})</h2>
<p><em>Zapytanie:</em> <code>{{ query }}</code> na modelu <strong>{{ model_key }}</strong></p>
{% endif %}
{% if results %}
{% include "bpp/_zapytanie_pager.html" %}
<table class="hover stack">
<thead>
<tr>
<th>Reprezentacja</th>
<th style="width: 6rem;">ID</th>
<th style="width: 9rem;">Akcje</th>
</tr>
</thead>
<tbody>
{% for obj in results %}
{% if model_key == "rekord" %}
<tr data-href="{{ obj.get_absolute_url }}">
<td>{{ obj.opis_bibliograficzny_cache|safe }}</td>
<td class="rekord-id-cell">{{ obj.pk.0 }}-{{ obj.pk.1 }}</td>
<td>
<a href="{{ obj.get_absolute_url }}" target="_blank">Zobacz</a>
{% if request.user.is_staff and obj.admin_url %}
<br>
<a href="{{ obj.admin_url }}" target="_blank">Edytuj</a>
{% endif %}
</td>
</tr>
{% else %}
<tr data-href="{% url 'bpp:browse_autor' obj.slug %}">
<td>{{ obj }}</td>
<td class="rekord-id-cell">{{ obj.pk }}</td>
<td>
<a href="{% url 'bpp:browse_autor' obj.slug %}" target="_blank">Zobacz</a>
{% if request.user.is_staff %}
<br>
<a href="{% url 'admin:bpp_autor_change' obj.pk %}" target="_blank">Edytuj</a>
{% endif %}
</td>
</tr>
{% endif %}
{% if eksport_formaty %}
{# Bez tego "if": model=autor (eksport_formaty puste, patrz #}
{# zapytanie.py) pokazywalby "Eksport:" bez zadnego linku. #}
<p class="zapytanie-eksport-toolbar">
<strong>
<span class="fi-download" aria-hidden="true"></span>
Eksport:
</strong>
{% for fmt, label in eksport_formaty %}
<a class="button tiny secondary"
href="{% url 'bpp:zapytanie_eksport' export_format=fmt %}?model={{ model_key|urlencode }}&amp;query={{ query|urlencode }}&amp;postac={{ postac|urlencode }}">
<span class="fi-download" aria-hidden="true"></span>
{{ label }}
</a>
{% endfor %}
</tbody>
</table>
</p>
{% endif %}
{% include "bpp/_zapytanie_pager.html" %}
{% if postac == "rekordy" %}
{# dotychczasowa tabela redakcyjna — bez zmian #}
<table class="hover stack">
<thead>
<tr>
<th>Reprezentacja</th>
<th style="width: 6rem;">ID</th>
<th style="width: 9rem;">Akcje</th>
</tr>
</thead>
<tbody>
{% for obj in results %}
{% if model_key == "rekord" %}
<tr data-href="{{ obj.get_absolute_url }}">
<td>{{ obj.opis_bibliograficzny_cache|safe }}</td>
<td class="rekord-id-cell">{{ obj.pk.0 }}-{{ obj.pk.1 }}</td>
<td>
<a href="{{ obj.get_absolute_url }}" target="_blank">Zobacz</a>
{% if request.user.is_staff and obj.admin_url %}
<br>
<a href="{{ obj.admin_url }}" target="_blank">Edytuj</a>
{% endif %}
</td>
</tr>
{% else %}
<tr data-href="{% url 'bpp:browse_autor' obj.slug %}">
<td>{{ obj }}</td>
<td class="rekord-id-cell">{{ obj.pk }}</td>
<td>
<a href="{% url 'bpp:browse_autor' obj.slug %}" target="_blank">Zobacz</a>
{% if request.user.is_staff %}
<br>
<a href="{% url 'admin:bpp_autor_change' obj.pk %}" target="_blank">Edytuj</a>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% elif postac == "table" or postac == "pkt_wewn" or postac == "pkt_wewn_bez" %}
{# Postać tabelaryczna multiseeka — render tym samym partialem #}
{# co eksport dokumentu (document_export_response), zeby ekran #}
{# i eksport zawsze wygladaly identycznie. #}
{% include "multiseek/report-body-table.html" with object_list=results report_type=postac sumy=sumy export_mode=False hide_chrome=True pokaz_edycje=request.user.is_staff page_obj=results start_index=results.start_index|add:"-1" %}
{% elif postac == "bibtex" %}
{# Wlasny partial multiseeka (<pre> + przycisk kopiowania). #}
{# hide_chrome chowa przycisk "Skopiuj" — jego JS jest tylko #}
{# inline w multiseekowym common-results.html, tu byłby martwy. #}
{% include "multiseek/report-body-bibtex.html" with object_list=results hide_chrome=True %}
{% else %}
{# tylko "list" dociera tutaj — rekordy/tabela/bibtex maja #}
{# wlasne galezie wyzej. #}
{% include "multiseek/report-body-list.html" with object_list=results report_type=postac export_mode=False hide_chrome=True pokaz_edycje=request.user.is_staff start_index=results.start_index|add:"-1" %}
{% endif %}
{% include "bpp/_zapytanie_pager.html" %}
{% else %}
<p><em>Brak rekordow pasujacych do zapytania.</em></p>
Expand Down
69 changes: 69 additions & 0 deletions src/bpp/tests/test_multiseek_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import pytest
from django.contrib.auth.models import AnonymousUser

from bpp.models import Rekord


def _anonymous_request(rf):
# render_to_string(..., request=request) przechodzi przez context
# processory (m.in. pbn_token_aktualny), które czytają request.user.
# RequestFactory — w odróżnieniu od Client — nie przepuszcza żądania
# przez middleware, więc request.user trzeba dostawić ręcznie
# (ten sam wzorzec co w test_permissions.py / test_zapytanie.py).
request = rf.get("/")
request.user = AnonymousUser()
return request


@pytest.mark.django_db
def test_document_export_response_html_zawiera_opis(rf, wydawnictwo_ciagle, denorms):
# "list" to id ReportType z multiseek_registry.reports (etykieta w UI
# to "lista" — nie mylić kluczy raportu z ich polskimi etykietami).
from bpp.views.multiseek_export import document_export_response

denorms.flush()
request = _anonymous_request(rf)
response = document_export_response(
Rekord.objects.all(), request, "list", "Tytuł", "html"
)

assert response.status_code == 200
assert response["Content-Type"].startswith("text/html")
# sanitize_export_html (nh3.clean) zdejmuje class z <div> (patrz komentarz
# w export-document.html) — sprawdzamy więc strukturę partiala listy
# (<ol>, nie <table>) i realną treść opisu bibliograficznego rekordu.
assert b"<ol" in response.content
assert b"<table" not in response.content
assert wydawnictwo_ciagle.tytul_oryginalny.encode() in response.content


@pytest.mark.django_db
def test_document_export_response_tabela_ma_sumy(rf, wydawnictwo_ciagle, denorms):
wydawnictwo_ciagle.punkty_kbn = 40
wydawnictwo_ciagle.save()
denorms.flush()

# "table" to id ReportType (etykieta w UI: "tabela").
from bpp.views.multiseek_export import document_export_response

request = _anonymous_request(rf)
response = document_export_response(
Rekord.objects.all(), request, "table", "Tytuł", "html"
)

assert b"<table" in response.content
assert b"Suma" in response.content
assert b"40.00" in response.content


@pytest.mark.django_db
def test_document_export_response_docx(rf, wydawnictwo_ciagle, denorms):
from bpp.views.multiseek_export import document_export_response

denorms.flush()
request = _anonymous_request(rf)
response = document_export_response(
Rekord.objects.all(), request, "list", "Tytuł", "docx"
)

assert "wordprocessingml" in response["Content-Type"]
48 changes: 48 additions & 0 deletions src/bpp/tests/test_zapytanie.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,54 @@ def test_zapytanie_template_loads_query_ux_assets(superuser_client):
assert 'id="zapytanie-explain-panel"' in html


@pytest.mark.django_db
def test_wykonaj_zapytanie_zwraca_queryset(wydawnictwo_ciagle, denorms):
from bpp.views.zapytanie import wykonaj_zapytanie

denorms.flush()
wynik = wykonaj_zapytanie("rekord", f"rok = {wydawnictwo_ciagle.rok}")

assert wynik.error is None
assert wynik.queryset.count() == 1


@pytest.mark.django_db
def test_wykonaj_zapytanie_zwraca_blad_z_lokalizacja():
from bpp.views.zapytanie import wykonaj_zapytanie

wynik = wykonaj_zapytanie("rekord", "rok ===")

assert wynik.queryset is None
assert wynik.error
assert wynik.error_location["line"] >= 1


@pytest.mark.django_db
def test_wykonaj_zapytanie_dedupuje_po_relacji_do_wielu(
wydawnictwo_ciagle, autor_jan_nowak, jednostka, jednostka_podrzedna, denorms
):
"""Filtr po autorach mnoży wiersze rekordu — distinct() musi je zwinąć.

Ten sam autor przypisany DWA razy (raz na jednostkę) daje dwa wiersze
``Wydawnictwo_Ciagle_Autor`` dopasowane do filtra — bez tego JOIN po
``autorzy`` zwróciłby jeden wiersz nawet bez ``.distinct()`` i test nie
chroniłby przed niczym.
"""
from bpp.views.zapytanie import wykonaj_zapytanie

# Drugie wpisanie tego samego autora na ten sam rekord wymaga innego
# typ_odpowiedzialnosci_skrot — (rekord, autor, typ_odpowiedzialnosci)
# jest unique_together (Wydawnictwo_Ciagle.Meta), inaczej IntegrityError.
wydawnictwo_ciagle.dodaj_autora(autor_jan_nowak, jednostka)
wydawnictwo_ciagle.dodaj_autora(
autor_jan_nowak, jednostka_podrzedna, typ_odpowiedzialnosci_skrot="red."
)
denorms.flush()
wynik = wykonaj_zapytanie("rekord", 'autorzy.autor.nazwisko = "Nowak"')

assert wynik.queryset.count() == 1


def test_admin_djangoql_highlight_loaded():
"""Adminy z BppDjangoQLSearchMixin ładują nakładkę podświetlania + skrypt
falki błędu (przez własne ``media``, nie wbudowane ``djangoql_highlight``)."""
Expand Down
Loading