diff --git a/src/bpp/newsfragments/sec-widocznosc-ukrytych.bugfix.rst b/src/bpp/newsfragments/sec-widocznosc-ukrytych.bugfix.rst new file mode 100644 index 000000000..38b77a246 --- /dev/null +++ b/src/bpp/newsfragments/sec-widocznosc-ukrytych.bugfix.rst @@ -0,0 +1 @@ +Wzmocniono kontrolę widoczności w publicznych widokach (BibTeX rekordu, strona autora, przeglądanie po latach, strona główna, podpowiedzi autocomplete) oraz dostępu do zapisanych formularzy wyszukiwania — rekordy o ukrytych statusach korekty, ukryci autorzy i cudze prywatne formularze nie są już dostępne dla osób bez uprawnień. diff --git a/src/bpp/tests/test_views/test_widocznosc_ukrytych.py b/src/bpp/tests/test_views/test_widocznosc_ukrytych.py new file mode 100644 index 000000000..04e47faa9 --- /dev/null +++ b/src/bpp/tests/test_views/test_widocznosc_ukrytych.py @@ -0,0 +1,350 @@ +"""Publiczne widoki nie pokazują rekordów o statusach korekty ukrytych w kanale +``podglad`` ani autorów z ``pokazuj=False`` — spójnie ze stroną rekordu. +Użytkownik z uprawnieniami redaktorskimi widzi wszystko.""" + +import json + +import pytest +from django.contrib.auth.models import Group +from django.urls import reverse +from model_bakery import baker + +from bpp.const import CHARAKTER_OGOLNY_KSIAZKA, GR_WPROWADZANIE_DANYCH +from bpp.models import ( + Autor, + Autor_Dyscyplina, + Charakter_Formalny, + Rekord, + Wydawnictwo_Ciagle, + Wydawnictwo_Zwarte, +) +from bpp.models.fields import OpcjaWyswietlaniaField + +TOKEN = "kwazarniewidzialny" +ROK = 2031 + + +@pytest.fixture +def rekord_ukryty( + uczelnia, przed_korekta, autor_jan_kowalski, jednostka, typ_odpowiedzialnosci_autor +): + pub = baker.make( + Wydawnictwo_Ciagle, + tytul_oryginalny=f"Tajny {TOKEN} artykul", + rok=ROK, + status_korekty=przed_korekta, + ) + pub.dodaj_autora(autor_jan_kowalski, jednostka, zapisany_jako="Kowalski Jan") + Rekord.objects.full_refresh() + uczelnia.ukryj_status_korekty_set.create(status_korekty=przed_korekta) + return pub + + +def _uzytkownik(django_user_model, nazwa, *, wprowadzajacy): + user = django_user_model.objects.create_user(nazwa, password="haslo123") + if wprowadzajacy: + grupa, _ = Group.objects.get_or_create(name=GR_WPROWADZANIE_DANYCH) + user.groups.add(grupa) + return user + + +ROLE = ["anonim", "zwykly", "redaktor"] + + +def _zaloguj(client, django_user_model, rola): + if rola == "anonim": + return + client.force_login( + _uzytkownik(django_user_model, rola, wprowadzajacy=rola == "redaktor") + ) + + +# --- bibtex --------------------------------------------------------------- + + +def _url_bibtex(pub): + return reverse("bpp:bibtex_view", args=("wydawnictwo_ciagle", pub.pk)) + + +@pytest.mark.django_db +@pytest.mark.parametrize("rola,oczekiwany", [("anonim", 403), ("zwykly", 403)]) +def test_bibtex_ukryty_rekord_zabroniony( + client, django_user_model, rekord_ukryty, rola, oczekiwany +): + _zaloguj(client, django_user_model, rola) + res = client.get(_url_bibtex(rekord_ukryty)) + assert res.status_code == oczekiwany + assert TOKEN.encode() not in res.content + + +@pytest.mark.django_db +def test_bibtex_ukryty_rekord_dostepny_dla_redaktora( + client, django_user_model, rekord_ukryty +): + _zaloguj(client, django_user_model, "redaktor") + res = client.get(_url_bibtex(rekord_ukryty)) + assert res.status_code == 200 + assert TOKEN in res.json()["bibtex"] + + +# --- AutorView ------------------------------------------------------------ + + +@pytest.fixture +def autor_ukryty(uczelnia): + return baker.make(Autor, imiona="Ukryty", nazwisko=f"Autor{TOKEN}", pokazuj=False) + + +@pytest.mark.django_db +@pytest.mark.parametrize("rola", ROLE) +@pytest.mark.parametrize("po", ["pk", "slug"]) +def test_autor_ukryty(client, django_user_model, autor_ukryty, rola, po): + _zaloguj(client, django_user_model, rola) + arg = autor_ukryty.pk if po == "pk" else autor_ukryty.slug + res = client.get(reverse("bpp:browse_autor", args=(arg,))) + assert res.status_code == (200 if rola == "redaktor" else 404) + + +# --- RokView / LataView / strona główna ---------------------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize("rola", ROLE) +def test_rok_view(client, django_user_model, rekord_ukryty, rola): + _zaloguj(client, django_user_model, rola) + res = client.get(reverse("bpp:browse_rok", args=(ROK,))) + assert res.status_code == 200 + widac = TOKEN.encode() in res.content + assert widac == (rola == "redaktor") + + +@pytest.mark.django_db +def test_rok_view_sasiedni_rok_z_samymi_ukrytymi(client, rekord_ukryty): + res = client.get(reverse("bpp:browse_rok", args=(ROK - 1,))) + assert res.context["next_year"] is None + + +@pytest.mark.django_db +@pytest.mark.parametrize("rola", ROLE) +def test_lata_view(client, django_user_model, rekord_ukryty, rola): + _zaloguj(client, django_user_model, rola) + res = client.get(reverse("bpp:browse_lata")) + lata = [x["year"] for x in res.context["years"]] + assert (ROK in lata) == (rola == "redaktor") + + +@pytest.mark.django_db +def test_strona_glowna_bez_ukrytych(uczelnia, rekord_ukryty): + from bpp.views.browse import get_uczelnia_context_data + + get_uczelnia_context_data.invalidate() + ctx = get_uczelnia_context_data(uczelnia) + assert TOKEN not in " ".join(r.tytul_oryginalny for r in ctx["recently_updated"]) + assert ctx["total_rekord_count"] == 0 + + +# --- autocomplete'y -------------------------------------------------------- + + +@pytest.fixture +def ksiazka_ukryta(uczelnia, przed_korekta): + cf = baker.make(Charakter_Formalny, charakter_ogolny=CHARAKTER_OGOLNY_KSIAZKA) + ksiazka = baker.make( + Wydawnictwo_Zwarte, + tytul_oryginalny=f"Ksiazka {TOKEN}", + charakter_formalny=cf, + status_korekty=przed_korekta, + rok=ROK, + ) + # rozdział — żeby książka była „wydawnictwem nadrzędnym" + baker.make(Wydawnictwo_Zwarte, wydawnictwo_nadrzedne=ksiazka) + uczelnia.ukryj_status_korekty_set.create(status_korekty=przed_korekta) + return ksiazka + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "url", + [ + "bpp:wydawnictwo-nadrzedne-autocomplete", + "bpp:public-wydawnictwo-nadrzedne-autocomplete", + "zglos_publikacje:public-wydawnictwo-nadrzedne-autocomplete", + ], +) +@pytest.mark.parametrize("rola", ROLE) +def test_autocomplete_wydawnictwo_nadrzedne( + client, django_user_model, ksiazka_ukryta, url, rola +): + _zaloguj(client, django_user_model, rola) + res = client.get(reverse(url), {"q": TOKEN}) + assert res.status_code == 200 + assert (TOKEN.encode() in res.content) == (rola == "redaktor") + + +@pytest.mark.django_db +@pytest.mark.parametrize("rola", ROLE) +def test_autocomplete_podrzedna_habilitacyjna( + client, django_user_model, rekord_ukryty, autor_jan_kowalski, rola +): + _zaloguj(client, django_user_model, rola) + res = client.get( + reverse("bpp:podrzedna-publikacja-habilitacyjna-autocomplete"), + {"q": TOKEN, "forward": json.dumps({"autor": str(autor_jan_kowalski.pk)})}, + ) + assert res.status_code == 200 + assert (TOKEN.encode() in res.content) == (rola == "redaktor") + + +@pytest.fixture +def przypisanie_dyscypliny(autor_jan_kowalski, dyscyplina1): + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, rok=ROK, dyscyplina_naukowa=dyscyplina1 + ) + return json.dumps({"autor": str(autor_jan_kowalski.pk), "rok": str(ROK)}) + + +def _dyscypliny(client, forward): + return client.get( + reverse("bpp:dyscyplina-naukowa-przypisanie-autocomplete"), + {"forward": forward}, + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("rola", ROLE) +def test_dyscyplina_przypisanie_formularz_zglaszania_nigdy( + client, django_user_model, uczelnia, przypisanie_dyscypliny, dyscyplina1, rola +): + uczelnia.pokazuj_formularz_zglaszania_publikacji = ( + OpcjaWyswietlaniaField.POKAZUJ_NIGDY + ) + uczelnia.save() + _zaloguj(client, django_user_model, rola) + res = _dyscypliny(client, przypisanie_dyscypliny) + if rola == "redaktor": + assert res.status_code == 200 + assert dyscyplina1.nazwa in res.content.decode() + else: + assert res.status_code == 403 + + +@pytest.mark.django_db +def test_dyscyplina_przypisanie_formularz_zglaszania_zawsze_anonim( + client, uczelnia, przypisanie_dyscypliny, dyscyplina1 +): + uczelnia.pokazuj_formularz_zglaszania_publikacji = ( + OpcjaWyswietlaniaField.POKAZUJ_ZAWSZE + ) + uczelnia.save() + res = _dyscypliny(client, przypisanie_dyscypliny) + assert res.status_code == 200 + assert dyscyplina1.nazwa in res.content.decode() + + +@pytest.mark.django_db +def test_dyscyplina_przypisanie_ukryty_autor_dla_anonima( + client, uczelnia, autor_ukryty, dyscyplina1 +): + Autor_Dyscyplina.objects.create( + autor=autor_ukryty, rok=ROK, dyscyplina_naukowa=dyscyplina1 + ) + res = _dyscypliny( + client, json.dumps({"autor": str(autor_ukryty.pk), "rok": str(ROK)}) + ) + assert dyscyplina1.nazwa not in res.content.decode() + + +# --- multiseek: zapisane formularze ---------------------------------------- + + +@pytest.fixture +def wlasciciel(django_user_model): + return django_user_model.objects.create_user( + "wlasciciel", password="x", is_staff=True + ) + + +@pytest.fixture +def prywatny_formularz(wlasciciel): + from multiseek.models import SearchForm + + return SearchForm.objects.create( + name="prywatny", public=False, data='{"form_data": [null]}', owner=wlasciciel + ) + + +@pytest.mark.django_db +def test_load_form_cudzy_prywatny_zabroniony( + client, django_user_model, prywatny_formularz +): + inny = django_user_model.objects.create_user("inny", password="x") + client.force_login(inny) + res = client.get(reverse("multiseek:load_form", args=(prywatny_formularz.pk,))) + assert res.status_code == 403 + + +@pytest.mark.django_db +def test_load_form_anonim_prywatny_zabroniony(client, prywatny_formularz): + res = client.get(reverse("multiseek:load_form", args=(prywatny_formularz.pk,))) + assert res.status_code == 403 + + +@pytest.mark.django_db +def test_load_form_wlasciciel_i_superuser( + client, admin_user, wlasciciel, prywatny_formularz +): + url = reverse("multiseek:load_form", args=(prywatny_formularz.pk,)) + client.force_login(wlasciciel) + assert client.get(url).status_code == 302 + client.force_login(admin_user) + assert client.get(url).status_code == 302 + + +@pytest.mark.django_db +def test_load_form_publiczny_dla_innego(client, django_user_model, wlasciciel): + from multiseek.models import SearchForm + + sf = SearchForm.objects.create( + name="publiczny", public=True, data='{"form_data": [null]}', owner=wlasciciel + ) + client.force_login(django_user_model.objects.create_user("inny", password="x")) + assert client.get(reverse("multiseek:load_form", args=(sf.pk,))).status_code == 302 + + +def _zapisz(client, nazwa, overwrite): + return client.post( + reverse("multiseek:save_form"), + { + "json": '{"form_data": [null]}', + "name": nazwa, + "public": "false", + "overwrite": "true" if overwrite else "false", + }, + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("overwrite", [True, False]) +def test_save_form_nie_nadpisuje_cudzego( + client, django_user_model, prywatny_formularz, wlasciciel, overwrite +): + inny = django_user_model.objects.create_user("inny", password="x", is_staff=True) + client.force_login(inny) + res = _zapisz(client, "prywatny", overwrite) + assert res.status_code == 200 + assert res.json()["result"] not in ("saved", "overwrite-prompt") + prywatny_formularz.refresh_from_db() + assert prywatny_formularz.owner == wlasciciel + + +@pytest.mark.django_db +def test_save_form_wlasciciel_nadpisuje_swoj(client, prywatny_formularz, wlasciciel): + client.force_login(wlasciciel) + assert _zapisz(client, "prywatny", False).json()["result"] == "overwrite-prompt" + assert _zapisz(client, "prywatny", True).json()["result"] == "saved" + + +@pytest.mark.django_db +def test_save_form_nowy(client, wlasciciel): + client.force_login(wlasciciel) + assert _zapisz(client, "nowy", False).json()["result"] == "saved" diff --git a/src/bpp/util/ukryte_statusy.py b/src/bpp/util/ukryte_statusy.py new file mode 100644 index 000000000..3dc320680 --- /dev/null +++ b/src/bpp/util/ukryte_statusy.py @@ -0,0 +1,44 @@ +"""Wykluczanie rekordów o statusach korekty ukrytych dla danego kanału. + +Wspólny helper dla publicznych widoków, które listują rekordy (przeglądanie +po latach, strona główna, autocomplete'y). Reguła jest ta sama, co na +stronie rekordu (``PracaViewMixin``): statusy ukryte w kanale (domyślnie +``"podglad"``) są niewidoczne dla wszystkich POZA użytkownikami +z uprawnieniami redaktorskimi (``moze_wprowadzac_dane``). +""" + + +def ukryte_statusy_dla(request, kanal="podglad", uczelnia=None): + """Lista PK statusów korekty ukrytych przed oglądającym ``request``. + + Pusta lista, gdy oglądający ma uprawnienia redaktorskie albo gdy nie da + się ustalić uczelni. ``request=None`` oznacza odbiorcę publicznego + (np. treść cache'owana wspólnie dla wszystkich). + """ + from bpp.models import Uczelnia + from bpp.permissions import moze_wprowadzac_dane + + user = getattr(request, "user", None) + if user is not None and moze_wprowadzac_dane(user): + return [] + + if uczelnia is None: + uczelnia = Uczelnia.objects.get_for_request(request) + if uczelnia is None: + return [] + + return list(uczelnia.ukryte_statusy(kanal)) + + +def wyklucz_ukryte_statusy( + qs, request, kanal="podglad", uczelnia=None, lookup="status_korekty_id" +): + """Zwróć ``qs`` bez rekordów o statusach ukrytych przed oglądającym. + + ``lookup`` to ścieżka ORM do PK statusu korekty (np. + ``"rekord__status_korekty_id"`` dla modeli podrzędnych). + """ + statusy = ukryte_statusy_dla(request, kanal=kanal, uczelnia=uczelnia) + if not statusy: + return qs + return qs.exclude(**{f"{lookup}__in": statusy}) diff --git a/src/bpp/views/autocomplete/authors.py b/src/bpp/views/autocomplete/authors.py index e5d270f93..8511d16b3 100644 --- a/src/bpp/views/autocomplete/authors.py +++ b/src/bpp/views/autocomplete/authors.py @@ -18,6 +18,8 @@ from bpp.models.patent import Patent, Patent_Autor from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte, Wydawnictwo_Zwarte_Autor +from bpp.permissions import moze_wprowadzac_dane +from bpp.util.ukryte_statusy import wyklucz_ukryte_statusy from pbn_api.models import OsobaZInstytucji from .base import autocomplete_create_error @@ -405,20 +407,41 @@ def get_queryset(self): if autor_id is None: return qs.none() + request = getattr(self, "request", None) + redaktor = request is not None and moze_wprowadzac_dane(request.user) + + autorzy = ( + Autor.objects.all() if redaktor else Autor.objects.filter(pokazuj=True) + ) try: - autor = Autor.objects.get(pk=int(autor_id)) + autor = autorzy.get(pk=int(autor_id)) except (TypeError, ValueError, Autor.DoesNotExist): return qs.none() - wydawnictwa_zwarte = Wydawnictwo_Zwarte.objects.filter( - pk__in=Wydawnictwo_Zwarte_Autor.objects.filter(autor=autor).only("rekord") + # Rekordy o statusach ukrytych w kanale "podglad" — tylko dla + # redaktorów (spójnie ze stroną rekordu). + wydawnictwa_zwarte = wyklucz_ukryte_statusy( + Wydawnictwo_Zwarte.objects.filter( + pk__in=Wydawnictwo_Zwarte_Autor.objects.filter(autor=autor).values( + "rekord" + ) + ), + request, ) - wydawnictwa_ciagle = Wydawnictwo_Ciagle.objects.filter( - pk__in=Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor).only("rekord") + wydawnictwa_ciagle = wyklucz_ukryte_statusy( + Wydawnictwo_Ciagle.objects.filter( + pk__in=Wydawnictwo_Ciagle_Autor.objects.filter(autor=autor).values( + "rekord" + ) + ), + request, ) - patenty = Patent.objects.filter( - pk__in=Patent_Autor.objects.filter(autor=autor).only("rekord") + patenty = wyklucz_ukryte_statusy( + Patent.objects.filter( + pk__in=Patent_Autor.objects.filter(autor=autor).values("rekord") + ), + request, ) qs = QuerySetSequence(wydawnictwa_ciagle, wydawnictwa_zwarte, patenty) @@ -434,7 +457,34 @@ def get_queryset(self): class Dyscyplina_Naukowa_PrzypisanieAutocomplete( SanitizedAutocompleteMixin, autocomplete.Select2ListView ): - """Autocomplete for discipline assignment based on author and year.""" + """Autocomplete for discipline assignment based on author and year. + + Używany w adminie i importerze (redaktorzy) oraz w publicznym formularzu + zgłaszania publikacji. Poza redaktorami dostęp ma tylko ten, komu + uczelnia udostępnia formularz zgłaszania (ta sama reguła, co + ``Zgloszenie_PublikacjiWizard``), i tylko dla autorów z ``pokazuj=True``. + """ + + def _redaktor(self): + request = getattr(self, "request", None) + return request is not None and moze_wprowadzac_dane(request.user) + + def _dostep_dozwolony(self, request): + from bpp.models import Uczelnia + + if moze_wprowadzac_dane(request.user): + return True + uczelnia = Uczelnia.objects.get_for_request(request) + if uczelnia is None: + return True + return uczelnia.sprawdz_uprawnienie( + "formularz_zglaszania_publikacji", request, ignoruj_grupe="ignoruj_grupe" + ) + + def dispatch(self, request, *args, **kwargs): + if not self._dostep_dozwolony(request): + return http.HttpResponseForbidden() + return super().dispatch(request, *args, **kwargs) def results(self, results): """Return data for the 'results' key of the response.""" @@ -470,8 +520,11 @@ def _validate_rok(self, rok): def _get_disciplines_for_autor(self, autor, rok): """Get disciplines for the given autor and rok.""" + przypisania = Autor_Dyscyplina.objects.all() + if not self._redaktor(): + przypisania = przypisania.filter(autor__pokazuj=True) try: - ad = Autor_Dyscyplina.objects.get(rok=rok, autor=autor) + ad = przypisania.get(rok=rok, autor=autor) except Autor_Dyscyplina.DoesNotExist: return None, f"Brak przypisania dla roku {rok}" diff --git a/src/bpp/views/autocomplete/publications.py b/src/bpp/views/autocomplete/publications.py index 6e92a361b..29ab423ef 100644 --- a/src/bpp/views/autocomplete/publications.py +++ b/src/bpp/views/autocomplete/publications.py @@ -7,6 +7,7 @@ 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 bpp.util.ukryte_statusy import wyklucz_ukryte_statusy from .mixins import SanitizedAutocompleteMixin @@ -24,6 +25,7 @@ def get_queryset(self): qs = Wydawnictwo_Zwarte.objects.filter( charakter_formalny__charakter_ogolny=CHARAKTER_OGOLNY_KSIAZKA ).order_by("tytul_oryginalny", "pk") + qs = wyklucz_ukryte_statusy(qs, getattr(self, "request", None)) if self.q: qs = filtruj_tytul_lub_isbn( @@ -77,6 +79,7 @@ def get_queryset(self): .values_list("wydawnictwo_nadrzedne_id") .distinct() ).order_by("tytul_oryginalny", "pk") + qs = wyklucz_ukryte_statusy(qs, getattr(self, "request", None)) if self.q: qs = filtruj_tytul_lub_isbn( diff --git a/src/bpp/views/browse.py b/src/bpp/views/browse.py index 9758ccea8..4883f1b05 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -59,6 +59,7 @@ scope_rekord_do_uczelni, tylko_jedna_uczelnia, ) +from bpp.util.ukryte_statusy import ukryte_statusy_dla, wyklucz_ukryte_statusy from bpp.views.cache_publiczny import DOMYSLNY_TTL, _generacja, cache_publiczny logger = logging.getLogger(__name__) @@ -111,13 +112,24 @@ def get_uczelnia_context_data(uczelnia, article_slug=None): # ``scope_rekord_do_uczelni``: JOIN po M2M ``autorzy`` (jedyne źródło # duplikatów tutaj) istnieje wyłącznie w trybie multi-host i helper # dokłada tam ``.distinct()`` sam. + # Treść cache'owana wspólnie dla wszystkich (bez requestu) — statusy + # ukryte w kanale "podglad" wykluczamy bezwarunkowo. + widoczne_rekordy = wyklucz_ukryte_statusy( + Rekord.objects.all(), None, uczelnia=uczelnia + ) context["recently_updated"] = scope_rekord_do_uczelni( - Rekord.objects.all(), uczelnia + widoczne_rekordy, uczelnia ).order_by("-ostatnio_zmieniony")[:12] recent_abstracts = Wydawnictwo_Ciagle_Streszczenie.objects.exclude( streszczenie__isnull=True ).exclude(streszczenie__exact="") + recent_abstracts = wyklucz_ukryte_statusy( + recent_abstracts, + None, + uczelnia=uczelnia, + lookup="rekord__status_korekty_id", + ) # Ten sam guard co scope_rekord_do_uczelni, ale na querysecie Streszczeń # (helper przyjmuje qs Rekordów). Single-host / brak uczelni => bez filtra. # ``.distinct()`` TYLKO w tej gałęzi: ``rekord__autorzy_set__…`` to @@ -135,7 +147,7 @@ def get_uczelnia_context_data(uczelnia, article_slug=None): # Bez ``.distinct()`` COUNT może pójść index-only scanem zamiast # hashować 49 kolumn na wiersz; dedup — jak wyżej — jest w helperze. context["total_rekord_count"] = scope_rekord_do_uczelni( - Rekord.objects.all(), uczelnia + widoczne_rekordy, uczelnia ).count() context["current_year"] = timezone.now().date().year @@ -234,6 +246,16 @@ class AutorView(DetailView): template_name = "browse/autor.html" model = Autor + def get_queryset(self): + # Autor z ``pokazuj=False`` jest ukryty ze stron publicznych (listing + # ``AutorzyView`` też go pomija) — pod bezpośrednim URL-em widzi go + # tylko użytkownik z uprawnieniami redaktorskimi, reszta dostaje 404. + qs = super().get_queryset() + request = getattr(self, "request", None) + if request is not None and moze_wprowadzac_dane(request.user): + return qs + return qs.filter(pokazuj=True) + def get_context_data(self, **kwargs): from powiazania_autorow.models import AuthorConnection @@ -876,7 +898,12 @@ class LataView(ListView): def get_queryset(self): uczelnia = Uczelnia.objects.get_for_request(self.request) - qs = scope_rekord_do_uczelni(Rekord.objects.all(), uczelnia) + qs = scope_rekord_do_uczelni( + wyklucz_ukryte_statusy( + Rekord.objects.all(), self.request, uczelnia=uczelnia + ), + uczelnia, + ) # ``Count("id", distinct=True)``, NIE ``Count("*")``: w multi-host # ``scope_rekord_do_uczelni`` dokłada JOIN po M2M ``autorzy``, więc # ``Count("*")`` liczył wiersze złączenia — rekord z dwoma @@ -941,7 +968,12 @@ def get_queryset(self): raise Http404("Nieprawidłowy rok") uczelnia = Uczelnia.objects.get_for_request(self.request) - qs = scope_rekord_do_uczelni(Rekord.objects.filter(rok=year), uczelnia) + qs = scope_rekord_do_uczelni( + wyklucz_ukryte_statusy( + Rekord.objects.filter(rok=year), self.request, uczelnia=uczelnia + ), + uczelnia, + ) # Rok spoza typowego zakresu (np. literówka „212" w danych) wpuszczamy # tylko, gdy są z niego rekordy — ``lata.html`` linkuje każdy rok @@ -964,7 +996,12 @@ def get_context_data(self, **kwargs): uczelnia = Uczelnia.objects.get_for_request(self.request) sasiednie_lata = set( scope_rekord_do_uczelni( - Rekord.objects.filter(rok__in=(year - 1, year + 1)), uczelnia + wyklucz_ukryte_statusy( + Rekord.objects.filter(rok__in=(year - 1, year + 1)), + self.request, + uczelnia=uczelnia, + ), + uczelnia, ) .values_list("rok", flat=True) .distinct() @@ -1307,6 +1344,11 @@ def bibtex_view(request, model, pk): except Rekord.DoesNotExist: return JsonResponse({"error": "Publication not found"}, status=404) + # Rekord o statusie ukrytym w kanale "podglad" — ta sama reguła, + # co na stronie rekordu (PracaViewMixin): 403 poza redaktorami. + if rekord.status_korekty_id in ukryte_statusy_dla(request): + return JsonResponse({"error": "Brak uprawnień do rekordu"}, status=403) + # Get the actual publication object praca = rekord.original diff --git a/src/bpp/views/mymultiseek.py b/src/bpp/views/mymultiseek.py index 11f9aa69a..af8fece1d 100644 --- a/src/bpp/views/mymultiseek.py +++ b/src/bpp/views/mymultiseek.py @@ -5,13 +5,22 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.core.cache import cache from django.db.models import Count, Sum -from django.http import HttpResponseBadRequest, JsonResponse +from django.http import ( + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseNotFound, + JsonResponse, +) +from django.shortcuts import redirect from django.views.decorators.cache import never_cache from django.views.generic import View from multiseek.logic import get_registry +from multiseek.models import SearchForm from multiseek.views import ( + MULTISEEK_SESSION_KEY, MULTISEEK_SESSION_KEY_REMOVED, MultiseekResults, + MultiseekSaveForm, manually_add_or_remove, ) @@ -376,6 +385,51 @@ def bpp_remove_from_removed_by_hand(request, pk): return manually_add_or_remove(request, pk, add=False) +def _moze_uzyc_formularza(user, sf): + """Zapisany formularz multiseek: publiczny dla wszystkich, prywatny tylko + dla właściciela (superuser — każdy).""" + if sf.public: + return True + if not user.is_authenticated: + return False + return user.is_superuser or sf.owner_id == user.pk + + +@never_cache +def bpp_load_form(request, search_form_pk): + """Nadpisanie ``multiseek.views.load_form``: prywatny formularz może + wczytać wyłącznie jego właściciel (lub superuser).""" + try: + sf = SearchForm.objects.get(pk=search_form_pk) + except SearchForm.DoesNotExist: + return HttpResponseNotFound() + + if not _moze_uzyc_formularza(request.user, sf): + return HttpResponseForbidden() + + request.session[MULTISEEK_SESSION_KEY] = sf.data + return redirect("..") + + +BLAD_FORMULARZ_INNEGO_UZYTKOWNIKA = ( + "Formularz o tej nazwie należy do innego użytkownika — wybierz inną nazwę." +) + + +class BppMultiseekSaveForm(MultiseekSaveForm): + """Nadpisanie ``MultiseekSaveForm``: nie pozwala nadpisać (ani przejąć) + formularza należącego do innego użytkownika.""" + + def get_context_data(self): + name = self.request.POST.get("name") + user = self.request.user + if name and not user.is_superuser: + istniejacy = SearchForm.objects.filter(name=name).first() + if istniejacy is not None and istniejacy.owner_id != user.pk: + return dict(result=BLAD_FORMULARZ_INNEGO_UZYTKOWNIKA) + return super().get_context_data() + + class MultiseekToDjangoQLView(WprowadzanieDanychOrSuperuserMixin, View): """Tlumaczy biezacy formularz Multiseek (POST 'json') na zapytanie DjangoQL nad Rekord. Zwraca {query, warnings, editor_url}.""" diff --git a/src/django_bpp/urls.py b/src/django_bpp/urls.py index 9412e9893..c6ea5e419 100644 --- a/src/django_bpp/urls.py +++ b/src/django_bpp/urls.py @@ -25,9 +25,11 @@ ) from bpp.views.global_nav import global_nav_redir from bpp.views.mymultiseek import ( + BppMultiseekSaveForm, MultiseekToDjangoQLView, MyMultiseekExport, MyMultiseekResults, + bpp_load_form, bpp_remove_by_hand, bpp_remove_from_removed_by_hand, ) @@ -346,6 +348,18 @@ def protected_media_serve(request, path, document_root=None): ), name="multiseek-export", ), + # Nadpisania upstreamowych load_form/save_form (kontrola właściciela + # zapisanego formularza) — MUSZĄ być PRZED include("multiseek.urls"). + url( + r"^multiseek/load_form/(?P\d+)$", + bpp_load_form, + name="bpp-multiseek-load-form", + ), + url( + r"^multiseek/save_form/$", + BppMultiseekSaveForm.as_view(registry=settings.MULTISEEK_REGISTRY), + name="bpp-multiseek-save-form", + ), url( r"^multiseek/", include(("multiseek.urls", "multiseek"), namespace="multiseek"), diff --git a/src/zglos_publikacje/autocomplete.py b/src/zglos_publikacje/autocomplete.py index c4049918c..c94dc2f58 100644 --- a/src/zglos_publikacje/autocomplete.py +++ b/src/zglos_publikacje/autocomplete.py @@ -14,6 +14,7 @@ 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.util.ukryte_statusy import wyklucz_ukryte_statusy 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 @@ -85,6 +86,7 @@ def get_queryset(self): wz = Wydawnictwo_Zwarte.objects.filter( charakter_formalny__charakter_ogolny=(CHARAKTER_OGOLNY_KSIAZKA) ) + wz = wyklucz_ukryte_statusy(wz, getattr(self, "request", None)) pbn_pub = PBN_Publication.objects.all() if self.q: