diff --git a/.env.docker b/.env.docker index e2e265d57..357a24b77 100644 --- a/.env.docker +++ b/.env.docker @@ -5,7 +5,17 @@ STATIC_ROOT=/staticroot DEBUG=true # DJANGO_BPP_DB_PASSWORD="" + +# Hostname (single-host deployment, backward compat). +# Dla multi-hosted użyj DJANGO_BPP_HOSTNAMES (poniżej) i pomiń tę zmienną. DJANGO_BPP_HOSTNAME="bpp.localnet" + +# Multi-hosted: comma-separated lista nazw hostów (jedna instalacja BPP +# obsługuje wiele uczelni/domen). Pierwsza pozycja jest używana jako +# canonical hostname (m.in. identyfikacja deployment'u w Rollbarze). +# Jeśli ustawisz DJANGO_BPP_HOSTNAMES, DJANGO_BPP_HOSTNAME jest ignorowany. +# Przykład: +# DJANGO_BPP_HOSTNAMES="bpp.uczelnia1.pl,bpp.uczelnia2.pl" DJANGO_BPP_SECRET_KEY="ZMIEN_KONIECZNIE_PRZED_URUCHOMIENIEM_PRODUKCJI" DJANGO_BPP_DB_NAME="bpp" diff --git a/.env.example b/.env.example index cfc40c35b..6b51df314 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,19 @@ # Moduł ustawień Django (w docker-compose devowym używamy settings.local). # DJANGO_SETTINGS_MODULE="django_bpp.settings.local" +# +# Konfiguracja hostów +# + +# Single-host (backward compat). Pojedyncza nazwa hosta serwowanego przez BPP. +# DJANGO_BPP_HOSTNAME="bpp.example.org" + +# Multi-hosted: comma-separated lista nazw hostów (jedna instalacja BPP +# obsługuje wiele uczelni/domen). Pierwsza pozycja jest używana jako +# canonical hostname (m.in. identyfikacja deployment'u w Rollbarze). +# Jeśli ustawisz DJANGO_BPP_HOSTNAMES, DJANGO_BPP_HOSTNAME jest ignorowany. +# DJANGO_BPP_HOSTNAMES="bpp.uczelnia1.pl,bpp.uczelnia2.pl" + # Jeżeli w pliku konfiguracyjnym podany zostanie URI do serwera LDAP, # włączona zostanie autoryzacja LDAP. Będzie ona miała pierwszeństwo # wobec autoryzacji z serwera bazowego tzn z bazy danych. diff --git a/.github/workflows/build-docker-images.yml b/.github/workflows/build-docker-images.yml index df09679e2..a8505e5ed 100644 --- a/.github/workflows/build-docker-images.yml +++ b/.github/workflows/build-docker-images.yml @@ -108,6 +108,11 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REF_NAME: ${{ github.ref_name }} + # head_ref jest ustawione tylko dla pull_request eventów — to nazwa + # branchu zrodlowego PR-a (ref_name na PR to "/merge" ktorego + # workflow_dispatch nie akceptuje). Uzywane do hintu w komunikacie + # "jak wymusic build". + HEAD_REF: ${{ github.head_ref }} EVENT_NAME: ${{ github.event_name }} REPO: ${{ github.repository }} ACTOR: ${{ github.actor }} @@ -165,7 +170,8 @@ jobs: else echo "should_build=false" >> "$GITHUB_OUTPUT" echo "::notice::Pomijam Docker build — brak flagi [docker-build] w commit message" - echo "::notice::Aby wymusic build, dodaj [docker-build] do commit message lub uruchom: gh workflow run build-docker-images.yml --ref ${REF_NAME}" + DISPATCH_REF="${HEAD_REF:-$REF_NAME}" + echo "::notice::Aby wymusic build, dodaj [docker-build] do commit message lub uruchom: gh workflow run build-docker-images.yml --ref ${DISPATCH_REF}" fi docker: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dba550628..78d51e5a2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -39,6 +39,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf1a8d54c..946a9ed09 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,23 @@ repos: # diff-y indentacji na pre-commit, blokujac niezmienione pliki). args: ["--lint"] files: \.html$ - exclude: ^(node_modules/|.*/migrations/|.*/staticroot/|src/django_bpp/static/500\.html$|src/bpp/static/500\.html$) + # Tech-debt z dev (dochodzi w merge'u origin/dev): templaty + # z wzorcem `{% if %}{% else %}{% endif %}content` + # ktorego djlint nie potrafi sparsowac (H025 orphan tags) + + # pojedyncze H020 empty-tag. Pre-existing na dev, follow-up + # PR rozwiaze refactorem href-y do jednego wezla + # ``. + exclude: | + (?x)^( + node_modules/ + | .*/migrations/ + | .*/staticroot/ + | src/django_bpp/static/500\.html$ + | src/bpp/static/500\.html$ + | src/rozbieznosci_if/templates/rozbieznosci_if/index\.html$ + | src/rozbieznosci_pk/templates/rozbieznosci_pk/index\.html$ + | src/snapshot_odpiec/templates/snapshot_odpiec/snapshotodpiec_list\.html$ + ) - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v6.0.0' @@ -35,7 +51,11 @@ repos: # base file. PyYAML's safe loader doesn't know # Compose-specific tags, so check-yaml chokes on a file # that `docker compose config` parses fine. - exclude: ^docker-compose\.test\.ci\.yml$ + # + # mkdocs.yml uses pymdownx.slugs.slugify Python object via + # `!!python/object/apply:` tag — same problem, MkDocs's own + # loader handles it but PyYAML safe loader nie umie. + exclude: ^(docker-compose\.test\.ci\.yml|mkdocs\.yml)$ - id: check-toml - id: end-of-file-fixer exclude: ^baseline-sql/baseline\.sql$ diff --git a/HISTORY.md b/HISTORY.md index 31a916c60..b0b90e79f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3012,7 +3012,7 @@ Poniżej znajduje się lista zmian w formacie sprzed używania narzędzia `townc - usuń zbędny tekst "jest nadrzędną jednostką dla" (#1074) -- powiązania autorów z dyscyplinami z modułu redagowania: +- powiązania autorów z dyscyplinami z modułu redagowania: - wyświetlają PBN UID i umożliwiają filtrowanie po nim (#1072), - eksportują poprawnie wartość ORCID i PBN UID do formatu XLS/CSV (#1072), diff --git a/conftest.py b/conftest.py index ac46f1061..11230ca79 100644 --- a/conftest.py +++ b/conftest.py @@ -61,6 +61,7 @@ def pytest_configure(config): # Load fixtures from submodules - must be at top-level conftest per pytest requirements pytest_plugins = [ "fixtures.conftest_models", + "fixtures.conftest_multisite", "fixtures.conftest_publications", "fixtures.conftest_system", "fixtures.conftest_browser", diff --git a/docker/bpp_base/Dockerfile b/docker/bpp_base/Dockerfile index a82a4e02a..ba86ff758 100644 --- a/docker/bpp_base/Dockerfile +++ b/docker/bpp_base/Dockerfile @@ -84,8 +84,11 @@ COPY src/bpp/static/scss/ src/bpp/static/scss/ COPY src/bpp/static/bpp/scss/ src/bpp/static/bpp/scss/ # JS for esbuild bundle COPY src/bpp/static/bpp/js/ src/bpp/static/bpp/js/ -COPY src/notifications/static/notifications/js/ \ - src/notifications/static/notifications/js/ +# Notifications JS jest teraz dostarczane przez pakiet django-channels-broadcast +# (zainstalowany przez uv) — collectstatic w runtime stage zaciągnie pliki +# z venv-a. Stara apka src/notifications/ usunięta na dev w commicie +# 048c2cfa2 (refactor: usun src/notifications/ — dostarczane przez +# django-channels-broadcast). # App-specific SCSS (keep alphabetical) COPY src/bpp_setup_wizard/static/bpp_setup_wizard/scss/ \ src/bpp_setup_wizard/static/bpp_setup_wizard/scss/ diff --git a/docs/edycja_autor.md b/docs/edycja_autor.md index 4a9b48aa6..18488f2d3 100644 --- a/docs/edycja_autor.md +++ b/docs/edycja_autor.md @@ -29,4 +29,3 @@ Algorytm ustalania aktualnego miejsca pracy działa w sposób następujący: !!! warning dany autor może mieć tylko jedno powiązanie oznaczone jako *Podstawowe miejsce pracy*. - diff --git a/docs/edycja_jednostka.md b/docs/edycja_jednostka.md index 0b1a16dbd..d45fc932d 100644 --- a/docs/edycja_jednostka.md +++ b/docs/edycja_jednostka.md @@ -27,4 +27,3 @@ odznaczyć to pole. W obecnym kształcie systemu BPP, pole to używane jest przy [imporcie pracowników](import_pracownikow.md) przez procedurę [odpinania nieaktualnych miejsc pracy](import_pracownikow.md#odpinanie-nieaktualnych-miejsc-pracy). - diff --git a/docs/edycja_uczelnia.md b/docs/edycja_uczelnia.md index 8d1a133ba..de5f6d248 100644 --- a/docs/edycja_uczelnia.md +++ b/docs/edycja_uczelnia.md @@ -49,4 +49,3 @@ następnie wybrać jednostkę w polu *Obca jednostka* i zapisać taki rekord. !!! note warto, aby *Obca jednostka* miała odznaczone [Pole *Skupia pracowników*](edycja_jednostka.md#pole-skupia-pracowników) oraz [Pole *Zarządzaj automatycznie*](edycja_jednostka.md#pole-zarządzaj-automatycznie) - diff --git a/docs/import_pracownikow.md b/docs/import_pracownikow.md index 255da3b7f..3d7dcd9bc 100644 --- a/docs/import_pracownikow.md +++ b/docs/import_pracownikow.md @@ -13,7 +13,7 @@ głównego wybrać opcję operacje➡import pracowników. ## Kontrola dostępu -Dostęp do funkcji importu pracowników mają: +Dostęp do funkcji importu pracowników mają: - członkowie grupy *wprowadzanie danych* - superużytkownicy. @@ -27,13 +27,13 @@ kodu źródłowego BPP -- [plik wzorcowy na GitHub](https://github.com/iplweb/bp ## Warunki importu danych -Warunkiem importu jest, aby: +Warunkiem importu jest, aby: - każda jednostka występująca w pliku XLS miała jeden i tylko jeden pasujący po nazwie odpowiednik po stronie systemu BPP, - każdy autor występujący w pliku XLS miał jeden i tylko jeden pasujący do niego odpowiednik, po kodzie ORCID lub po imieniu, nazwisku i tytule. -Import osób rozwiązany jest w ten sposób, ponieważ: +Import osób rozwiązany jest w ten sposób, ponieważ: - format XLS oprogramowania [Egeria](https://egeria.comarch.pl) nie zawiera danych które jednoznacznie identyfikują jednostki, stąd dopasowanie odbywa się po nazwie. W sytuacji, gdyby w pliku XLS znajdowały się jednostki o choćby minimalnie róznej nazwie, system mógłby nie dopasować ich i utworzyć nowe @@ -83,4 +83,3 @@ Powiązania Autor+Jednostka na takiej liście charakteryzują się następujący procedura "odpinająca" miejsca pracy jest szczególnie przydatna, jeżeli chcemy mieć zaktualizowane informacje dla pola — por. [Pole *Aktualne miejsce pracy* dla autora](edycja_autor.md#pole-aktualne-miejsce-pracy-dla-autora) - diff --git a/docs/konfiguracja_pbn.md b/docs/konfiguracja_pbn.md index 28cc8f5c9..0b8a00bb6 100644 --- a/docs/konfiguracja_pbn.md +++ b/docs/konfiguracja_pbn.md @@ -30,26 +30,26 @@ W formularzu edycji uczelni/instytucji znajdziesz następujące pola związane z ### Podstawowe ustawienia API -**Adres API w PBN** +**Adres API w PBN** - **Pole:** `pbn_api_root` - **Domyślna wartość:** `https://pbn-micro-alpha.opi.org.pl` - **Opis:** Adres serwera testowego API PBN. W wersji produkcyjnej należy ustawić `https://pbn.nauka.gov.pl/` - **Format:** Pełny adres URL (np. `https://pbn-micro-alpha.opi.org.pl`) -**Nazwa aplikacji w PBN** +**Nazwa aplikacji w PBN** - **Pole:** `pbn_app_name` - **Wymagane:** Tak - **Opis:** Identyfikator aplikacji otrzymany przy rejestracji w PBN - **Maksymalna długość:** 128 znaków -**Token aplikacji w PBN** +**Token aplikacji w PBN** - **Pole:** `pbn_app_token` - **Wymagane:** Tak - **Opis:** Token bezpieczeństwa aplikacji otrzymany z PBN - **Maksymalna długość:** 128 znaków - **Uwaga:** Pole to zawiera dane poufne -**Odpowiednik w PBN** +**Odpowiednik w PBN** - **Pole:** `pbn_uid` - **Opis:** Instytucja w bazie PBN odpowiadająca Twojej uczelni - **Uwaga:** Pole to zostanie automatycznie wypełnione po zaimportowaniu danych instytucji z PBN @@ -57,29 +57,29 @@ W formularzu edycji uczelni/instytucji znajdziesz następujące pola związane z Opcje eksportu danych -------------------- -**Kasuj oświadczenia rekordu przed wysłaniem do PBN** +**Kasuj oświadczenia rekordu przed wysłaniem do PBN** - **Pole:** `pbn_api_kasuj_przed_wysylka` - **Domyślnie:** Nie zaznaczone - **Opis:** Gdy zaznaczone, system usunie wszystkie istniejące oświadczenia publikacji w PBN przed przesłaniem nowych danych -**Nie wysyłaj do PBN prac z punktami MNISW = 0** +**Nie wysyłaj do PBN prac z punktami MNISW = 0** - **Pole:** `pbn_api_nie_wysylaj_prac_bez_pk` - **Domyślnie:** Nie zaznaczone - **Opis:** Blokuje wysyłanie do PBN publikacji bez punktów MNiSW -**Wysyłaj prace bez oświadczeń** +**Wysyłaj prace bez oświadczeń** - **Pole:** `pbn_wysylaj_bez_oswiadczen` - **Domyślnie:** Nie zaznaczone - **Opis:** Umożliwia wysyłanie do PBN publikacji bez oświadczeń dyscyplinowych. Takie publikacje trafiają do repozytorium PBN zamiast do systemu ewaluacyjnego i nie zawierają informacji o dyscyplinach naukowych autorów -**Wysyłaj zawsze PBN UID uczelni jako afiliację** +**Wysyłaj zawsze PBN UID uczelni jako afiliację** - **Pole:** `pbn_api_afiliacja_zawsze_na_uczelnie` - **Domyślnie:** Zaznaczone - **Opis:** Gdy zaznaczone, wszystkie publikacje będą afiliowane do uczelni, a nie do konkretnych jednostek organizacyjnych; zachowanie to jest obecnie domyślne - pole używane było w czasach, gdy publikacja mogła być afiliowana na konkretną jednostkę uczelni/instytucji w PBN (na Klinikę, Dział, Katedrę itp...). -**Użytkownik BPP dla PBN API** +**Użytkownik BPP dla PBN API** - **Pole:** `pbn_api_user` - **Opis:** Użytkownik systemu BPP odpowiedzialny za automatyczne operacje z PBN wykonywane przez procesy systemowe - **Uwaga:** Ten użytkownik musi wykonać autoryzację w PBN, aby umożliwić automatyczne operacje (w tle) @@ -123,35 +123,35 @@ Po skonfigurowaniu integracji zaleca się import podstawowych danych słownikowy ## Typowe problemy i rozwiązania -**Problem:** Komunikat "Brak nazwy aplikacji dla API PBN" +**Problem:** Komunikat "Brak nazwy aplikacji dla API PBN" - **Rozwiązanie:** Wypełnij pole "Nazwa aplikacji w PBN" w ustawieniach uczelni -**Problem:** Komunikat "Brak tokena aplikacji dla API PBN" +**Problem:** Komunikat "Brak tokena aplikacji dla API PBN" - **Rozwiązanie:** Wypełnij pole "Token aplikacji w PBN" w ustawieniach uczelni -**Problem:** Komunikat "Token aplikacji PBN nieprawidłowy" +**Problem:** Komunikat "Token aplikacji PBN nieprawidłowy" - **Rozwiązanie:** Sprawdź poprawność skopiowanego tokena w PBN, upewnij się że nie ma dodatkowych spacji -**Problem:** Komunikat "Najpierw wykonaj autoryzację w PBN API" +**Problem:** Komunikat "Najpierw wykonaj autoryzację w PBN API" - **Rozwiązanie:** Wykonaj proces autoryzacji opisany w sekcji "Autoryzacja w systemie PBN" -**Problem:** Brak możliwości wysyłania publikacji do PBN +**Problem:** Brak możliwości wysyłania publikacji do PBN - **Rozwiązanie:** Upewnij się, że pole "Odpowiednik w PBN" jest wypełnione i że wykonano autoryzację użytkownika ## Operacje na publikacjach Po skonfigurowaniu integracji możesz: -**Wysyłać pojedyncze publikacje do PBN:** +**Wysyłać pojedyncze publikacje do PBN:** 1. Otwórz publikację w panelu administracyjnym 2. Użyj przycisku **Wyślij do PBN** (jeśli dostępny) 3. System automatycznie wyśle publikację i pobierze z powrotem dane wraz z PBN UID -**Importować dane publikacji z PBN:** +**Importować dane publikacji z PBN:** - System może automatycznie pobierać informacje o publikacjach już istniejących w PBN - Możliwe jest też pobieranie abstraktów i innych metadanych -**Zarządzać oświadczeniami dyscyplin:** +**Zarządzać oświadczeniami dyscyplin:** - System automatycznie wysyła oświadczenia dotyczące dyscyplin naukowych autorów - Możliwa jest również wysyłka samych oświadczeń bez całej publikacji diff --git a/docs/sloty.md b/docs/sloty.md index 08deaad2c..d63e793bf 100644 --- a/docs/sloty.md +++ b/docs/sloty.md @@ -16,22 +16,22 @@ System rozróżnia cztery rodzaje autorów w powiązaniach autor-dyscyplina: -**N - naukowiec w N** +**N - naukowiec w N** - Wliczany do liczby N (parametr ewaluacyjny) - Liczone są dla niego sloty - Standardowy pracownik naukowy zaliczany do ewaluacji -**D - doktorant** +**D - doktorant** - Nie jest wliczany do liczby N - Liczone są dla niego sloty - Osoba na studiach doktoranckich -**B - badawczy** +**B - badawczy** - Nie jest wliczany do liczby N - Liczone są dla niego sloty - Pracownik o charakterze badawczym (np. post-doc, badacz bez etatu naukowego) -**Z - inny lub techniczny** +**Z - inny lub techniczny** - Nie jest wliczany do liczby N - Nie liczą się dla niego sloty - Pracownik techniczny, administracyjny lub inny niebędący pracownikiem badawczym diff --git a/docs/usage_admin.md b/docs/usage_admin.md index d4505bbf5..498558c15 100644 --- a/docs/usage_admin.md +++ b/docs/usage_admin.md @@ -110,7 +110,7 @@ formularza, następnie zaznacz lub odznacz opcję "Publiczny" i zapisz rekord powiązań autor + rekord dla danego roku, dla danego autora - we wszystkich rekordach, które mają „starą” subdyscyplinę - na pustą. -4. **usunięcie przypisania** autora do dyscypliny (rekord `Autor_Dyscyplina`) powoduje ustawienie +4. **usunięcie przypisania** autora do dyscypliny (rekord `Autor_Dyscyplina`) powoduje ustawienie wartości pustej (`NULL`) dla danego roku, dla danego autora - we wszystkich rekordach, do których przypisany jest dany autor. diff --git a/src/bpp/admin/__init__.py b/src/bpp/admin/__init__.py index 55a814360..6f7279490 100644 --- a/src/bpp/admin/__init__.py +++ b/src/bpp/admin/__init__.py @@ -254,6 +254,14 @@ class BppUserAdmin(UserAdmin): "PBN API", {"fields": ("przedstawiaj_w_pbn_jako",)}, ), + ( + "Dostęp do uczelni", + { + "fields": ("accessible_uczelnie",), + "description": "Superużytkownicy mają automatycznie dostęp " + "do wszystkich uczelni.", + }, + ), ) autocomplete_fields = ["autor"] diff --git a/src/bpp/admin/autor.py b/src/bpp/admin/autor.py index e47143ffb..a35f3f6a9 100644 --- a/src/bpp/admin/autor.py +++ b/src/bpp/admin/autor.py @@ -28,6 +28,7 @@ PBNIDObecnyFilter, ) from .helpers.fieldsets import ADNOTACJE_FIELDSET, ZapiszZAdnotacjaMixin +from .helpers.site_filtered import SiteFilteredAdminMixin from .helpers.widgets import CHARMAP_SINGLE_LINE from .xlsx_export import resources from .xlsx_export.mixins import EksportDanychMixin @@ -190,6 +191,7 @@ class Meta: class AutorAdmin( + SiteFilteredAdminMixin, DjangoQLSearchMixin, ZapiszZAdnotacjaMixin, EksportDanychMixin, @@ -197,6 +199,7 @@ class AutorAdmin( DynamicColumnsMixin, admin.ModelAdmin, ): + uczelnia_field_path = "aktualna_jednostka__uczelnia" djangoql_completion_enabled_by_default = False djangoql_completion = True diff --git a/src/bpp/admin/core.py b/src/bpp/admin/core.py index 8b73daaa0..eb9b25579 100644 --- a/src/bpp/admin/core.py +++ b/src/bpp/admin/core.py @@ -66,7 +66,8 @@ def filter_count_view(self, request): query_string = request.GET.urlencode() query_hash = md5(query_string.encode()).hexdigest() model_label = self.model._meta.label - cache_key = f"filter_count_{model_label}_{query_hash}" + site_pk = getattr(getattr(request, "site", None), "pk", 0) + cache_key = f"filter_count_{site_pk}_{model_label}_{query_hash}" # Sprawdź czy wynik jest już w cache count = cache.get(cache_key) diff --git a/src/bpp/admin/helpers/constance_field_mixin.py b/src/bpp/admin/helpers/constance_field_mixin.py index d232e622b..ca4acee69 100644 --- a/src/bpp/admin/helpers/constance_field_mixin.py +++ b/src/bpp/admin/helpers/constance_field_mixin.py @@ -1,44 +1,42 @@ """ -Mixin do dynamicznego ukrywania pól w panelu admina na podstawie ustawień constance. +Mixin do dynamicznego ukrywania pól w panelu admina na podstawie ustawień uczelni. Umożliwia ukrywanie pól punktacji (index_copernicus, punktacja_snip, punktacja_wewnetrzna) -w formularzach edycji publikacji, gdy odpowiednie ustawienia constance są wyłączone. +w formularzach edycji publikacji, gdy odpowiednie ustawienia uczelni są wyłączone. """ import copy -def get_constance_scoring_settings(): +def get_scoring_settings(uczelnia=None): """ - Pobiera ustawienia dotyczące widoczności pól punktacji z constance. + Pobiera ustawienia dotyczące widoczności pól punktacji z obiektu Uczelnia. + + Args: + uczelnia: Obiekt Uczelnia (opcjonalny). Jeśli None, zwraca domyślne wartości. Returns: dict: Słownik z ustawieniami widoczności pól """ - try: - from constance import config - - # bool() coerces real values *and* constance's AsyncValueProxy - # (returned when an asyncio loop is running in this thread — e.g. - # tests sharing an xdist worker with prior async tests) to a true - # Python bool, so callers can rely on `isinstance(_, bool)`. + if uczelnia is not None: return { - "POKAZUJ_INDEX_COPERNICUS": bool(config.POKAZUJ_INDEX_COPERNICUS), - "POKAZUJ_PUNKTACJA_SNIP": bool(config.POKAZUJ_PUNKTACJA_SNIP), - "UZYWAJ_PUNKTACJI_WEWNETRZNEJ": bool( - config.UZYWAJ_PUNKTACJI_WEWNETRZNEJ - ), - } - except (ImportError, AttributeError): - # Fallback - wszystkie widoczne - return { - "POKAZUJ_INDEX_COPERNICUS": True, - "POKAZUJ_PUNKTACJA_SNIP": True, - "UZYWAJ_PUNKTACJI_WEWNETRZNEJ": True, + "POKAZUJ_INDEX_COPERNICUS": uczelnia.pokazuj_index_copernicus, + "POKAZUJ_PUNKTACJA_SNIP": uczelnia.pokazuj_punktacja_snip, + "UZYWAJ_PUNKTACJI_WEWNETRZNEJ": uczelnia.pokazuj_punktacje_wewnetrzna, } + # Fallback - wszystkie widoczne + return { + "POKAZUJ_INDEX_COPERNICUS": True, + "POKAZUJ_PUNKTACJA_SNIP": True, + "UZYWAJ_PUNKTACJI_WEWNETRZNEJ": True, + } + + +# Backward compatibility alias +get_constance_scoring_settings = get_scoring_settings -# Mapowanie ustawień constance na nazwy pól w modelach +# Mapowanie ustawień na nazwy pól w modelach CONSTANCE_TO_FIELD_MAP = { "POKAZUJ_INDEX_COPERNICUS": ("index_copernicus", "pokazuj_index_copernicus"), "POKAZUJ_PUNKTACJA_SNIP": ("punktacja_snip", "pokazuj_punktacja_snip"), @@ -83,16 +81,13 @@ class ConstanceScoringFieldsMixin: Mixin do dynamicznego ukrywania pól punktacji w adminie publikacji. Ukrywa pola index_copernicus, punktacja_snip, punktacja_wewnetrzna - na podstawie ustawień constance. + na podstawie ustawień uczelni. """ def get_fieldsets(self, request, obj=None): - """ - Dynamicznie modyfikuje fieldsets, ukrywając pola punktacji - które są wyłączone w constance. - """ fieldsets = super().get_fieldsets(request, obj) - settings = get_constance_scoring_settings() + uczelnia = getattr(request, "_uczelnia", None) + settings = get_scoring_settings(uczelnia) fields_to_remove = set() for constance_key, field_names in CONSTANCE_TO_FIELD_MAP.items(): @@ -108,16 +103,13 @@ class ConstanceUczelniaFieldsMixin: Mixin do dynamicznego ukrywania pól pokazuj_* w adminie Uczelnia. Ukrywa pola pokazuj_index_copernicus, pokazuj_punktacja_snip, - pokazuj_punktacje_wewnetrzna na podstawie ustawień constance. + pokazuj_punktacje_wewnetrzna na podstawie ustawień uczelni. """ def get_fieldsets(self, request, obj=None): - """ - Dynamicznie modyfikuje fieldsets, ukrywając pola pokazuj_* - które są zbędne gdy dana punktacja jest globalnie wyłączona. - """ fieldsets = super().get_fieldsets(request, obj) - settings = get_constance_scoring_settings() + uczelnia = getattr(request, "_uczelnia", None) + settings = get_scoring_settings(uczelnia) fields_to_remove = set() for constance_key, field_names in CONSTANCE_TO_FIELD_MAP.items(): diff --git a/src/bpp/admin/helpers/mixins.py b/src/bpp/admin/helpers/mixins.py index 3dfbd8e6f..4b5f7de6e 100644 --- a/src/bpp/admin/helpers/mixins.py +++ b/src/bpp/admin/helpers/mixins.py @@ -50,7 +50,7 @@ def render_change_form( ): from bpp.models import Uczelnia - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(request) if uczelnia is not None: if uczelnia.pbn_integracja and uczelnia.pbn_aktualizuj_na_biezaco: context.update({"show_save_and_pbn": True}) diff --git a/src/bpp/admin/helpers/pbn_api/cli.py b/src/bpp/admin/helpers/pbn_api/cli.py index a39143c84..6cbcc554e 100644 --- a/src/bpp/admin/helpers/pbn_api/cli.py +++ b/src/bpp/admin/helpers/pbn_api/cli.py @@ -33,15 +33,17 @@ def as_list(self): return self.output -def sprobuj_wyslac_do_pbn_celery(user, obj, force_upload=False, pbn_client=None): +def sprobuj_wyslac_do_pbn_celery( + user, obj, force_upload=False, pbn_client=None, uczelnia=None +): sprawdz_czy_ustawiono_wysylke_tego_charakteru_formalnego(obj.charakter_formalny) try: uczelnia = sprawdz_wysylke_do_pbn_w_parametrach_uczelni( - Uczelnia.objects.get_default() + uczelnia or Uczelnia.objects.get_default() ) - except BrakZdefiniowanegoObiektuUczelniaWSystemieError: - raise ValueError("W systemie brak obiektu Uczelnia.") + except BrakZdefiniowanegoObiektuUczelniaWSystemieError as e: + raise ValueError("W systemie brak obiektu Uczelnia.") from e if uczelnia is False: raise ValueError("Wysyłka do PBN nie skonfigurowana w obiekcie Uczelnia") diff --git a/src/bpp/admin/helpers/site_filtered.py b/src/bpp/admin/helpers/site_filtered.py new file mode 100644 index 000000000..d332e02fd --- /dev/null +++ b/src/bpp/admin/helpers/site_filtered.py @@ -0,0 +1,41 @@ +""" +Mixin do filtrowania danych w panelu admina na podstawie aktualnej uczelni. + +W trybie multi-hosted zwykły admin widzi tylko dane swojej uczelni, +superuser widzi wszystko. +""" + + +class SiteFilteredAdminMixin: + """Filtruje queryset w adminie do danych aktualnej uczelni. + + Klasy pochodne ustawiają ``uczelnia_field_path`` na ścieżkę FK + do Uczelni, np. ``"uczelnia"`` lub ``"jednostka__uczelnia"``. + + Superuserzy widzą wszystkie dane (brak filtrowania). + """ + + uczelnia_field_path = None + + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia and self.uczelnia_field_path: + return qs.filter(**{self.uczelnia_field_path: uczelnia}) + return qs + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + """Filtruje dropdown FK do obiektów z aktualnej uczelni.""" + if not request.user.is_superuser: + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia and db_field.name == "wydzial": + from bpp.models import Wydzial + + kwargs["queryset"] = Wydzial.objects.filter(uczelnia=uczelnia) + elif uczelnia and db_field.name == "jednostka": + from bpp.models import Jednostka + + kwargs["queryset"] = Jednostka.objects.filter(uczelnia=uczelnia) + return super().formfield_for_foreignkey(db_field, request, **kwargs) diff --git a/src/bpp/admin/jednostka.py b/src/bpp/admin/jednostka.py index 61256fa24..6bcf475e1 100644 --- a/src/bpp/admin/jednostka.py +++ b/src/bpp/admin/jednostka.py @@ -4,6 +4,7 @@ from django.contrib import admin from django.utils.html import format_html from djangoql.admin import DjangoQLSearchMixin +from import_export.admin import ImportMixin from mptt.admin import DraggableMPTTAdmin from bpp.models import Autor_Jednostka, Uczelnia @@ -14,6 +15,8 @@ from .helpers import LimitingFormset from .helpers.fieldsets import ADNOTACJE_FIELDSET from .helpers.mixins import ZapiszZAdnotacjaMixin +from .helpers.site_filtered import SiteFilteredAdminMixin +from .jednostka_import import JednostkaImportResource class Jednostka_WydzialInline(admin.TabularInline): @@ -37,15 +40,20 @@ class Autor_JednostkaInline(admin.TabularInline): class JednostkaAdmin( + ImportMixin, + SiteFilteredAdminMixin, DjangoQLSearchMixin, RestrictDeletionToAdministracjaGroupMixin, ZapiszZAdnotacjaMixin, BaseBppAdminMixin, DraggableMPTTAdmin, ): + uczelnia_field_path = "uczelnia" djangoql_completion_enabled_by_default = False djangoql_completion = True + resource_classes = [JednostkaImportResource] + change_list_template = "admin/grappelli_mptt_change_list.html" list_display_links = ["indented_title"] @@ -118,7 +126,7 @@ def get_changeform_initial_data(self, request): # Zobacz na komentarz do Jednostka.uczelnia.default data = super().get_changeform_initial_data(request) if "uczelnia" not in data: - data["uczelnia"] = Uczelnia.objects.first() + data["uczelnia"] = Uczelnia.objects.get_for_request(request) return data def changelist_view(self, request, *args, **kwargs): diff --git a/src/bpp/admin/jednostka_import.py b/src/bpp/admin/jednostka_import.py new file mode 100644 index 000000000..c2637ad8f --- /dev/null +++ b/src/bpp/admin/jednostka_import.py @@ -0,0 +1,197 @@ +"""Import wydziałów i jednostek z pliku XLSX (django-import-export). + +Spodziewane kolumny: + + * ``Uczelnia`` + * ``Wydział`` + * ``Katedra/Zakład/Klinika`` + +Założenia: + +* Uczelnie muszą już istnieć w bazie (lookup po ``nazwa``). Jeżeli + uczelnia z wiersza nie istnieje, import wiersza zgłasza błąd + widoczny w GUI. +* Tam, gdzie wiersz nie ma wydziału, tworzony jest jeden wydział + o domyślnej nazwie ``"Wydział "``. +* Tam, gdzie wiersz nie ma jednostki, tworzona jest jednostka + ``"Jednostka Wydziału "`` (gdzie ```` to fragment nazwy + wydziału po prefiksie ``"Wydział "``). +* Skróty (``Wydzial.skrot`` -- max 10, ``Wydzial.skrot_nazwy`` -- max + 250, ``Jednostka.skrot`` -- max 128) są generowane jako unikalne. +""" + +from __future__ import annotations + +from import_export import fields, resources +from import_export.widgets import ForeignKeyWidget + +from bpp.models.jednostka import Jednostka +from bpp.models.uczelnia import Uczelnia +from bpp.models.wydzial import Wydzial + +COLUMN_UCZELNIA = "Uczelnia" +COLUMN_WYDZIAL = "Wydział" +COLUMN_JEDNOSTKA = "Katedra/Zakład/Klinika" + + +def abbreviate_wydzial(name: str) -> str: + """Akronim z dużych liter w nazwie wydziału (max 10 znaków).""" + out: list[str] = [] + for token in name.split(): + if not token: + continue + ch = token[0] + if ch.isupper(): + out.append(ch) + elif ch.isalpha(): + out.append(ch.lower()) + if not out: + out = [name[:1] or "X"] + return "".join(out)[:10] or "X" + + +def unique_skrot(base: str, used: set[str], max_len: int) -> str: + """Skrót unikalny w obrębie ``used``, ograniczony do ``max_len``.""" + candidate = base[:max_len] + if candidate and candidate not in used: + used.add(candidate) + return candidate + + n = 2 + while True: + suffix = str(n) + prefix_len = max(1, max_len - len(suffix)) + candidate = f"{base[:prefix_len]}{suffix}" + if candidate not in used: + used.add(candidate) + return candidate + n += 1 + + +def domyslna_nazwa_wydzialu(uczelnia: Uczelnia) -> str: + return f"Wydział {uczelnia.skrot}" + + +def domyslna_nazwa_jednostki(wydzial_nazwa: str) -> str: + for prefix in ("Wydział ", "Wydzial "): + if wydzial_nazwa.startswith(prefix): + return f"Jednostka Wydziału {wydzial_nazwa[len(prefix) :]}" + return f"Jednostka {wydzial_nazwa}" + + +class WydzialGetOrCreateWidget(ForeignKeyWidget): + """ForeignKey widget z get_or_create po ``nazwa``. + + Tworzy nowy ``Wydzial`` (z auto-generowanym ``skrot``/``skrot_nazwy``), + jeżeli wydział o tej nazwie nie istnieje. Uczelnia odczytywana jest + z kolumny ``Uczelnia`` w danym wierszu -- obiekt ``Jednostka`` + dopiero powstaje, więc nie można sięgnąć przez FK na obiekcie. + """ + + def __init__(self, **kwargs): + super().__init__(Wydzial, field="nazwa", **kwargs) + + def clean(self, value, row=None, **kwargs): + if not value: + return None + nazwa = str(value).strip() + if not nazwa: + return None + + existing = Wydzial.objects.filter(nazwa=nazwa).first() + if existing is not None: + return existing + + uczelnia_value = (row or {}).get(COLUMN_UCZELNIA) + if not uczelnia_value: + raise ValueError( + f"Brak kolumny '{COLUMN_UCZELNIA}' dla wydziału '{nazwa}'." + ) + uczelnia_nazwa = str(uczelnia_value).strip() + try: + uczelnia = Uczelnia.objects.get(nazwa=uczelnia_nazwa) + except Uczelnia.DoesNotExist as exc: + raise ValueError( + f"Uczelnia '{uczelnia_nazwa}' nie istnieje. " + "Utwórz ją ręcznie i ponów import." + ) from exc + + used_skroty = set(Wydzial.objects.values_list("skrot", flat=True)) + used_skrot_nazwy = set( + Wydzial.objects.exclude(skrot_nazwy=None).values_list( + "skrot_nazwy", flat=True + ) + ) + skrot = unique_skrot(abbreviate_wydzial(nazwa), used_skroty, max_len=10) + skrot_nazwy = unique_skrot(nazwa, used_skrot_nazwy, max_len=250) + return Wydzial.objects.create( + uczelnia=uczelnia, + nazwa=nazwa, + skrot=skrot, + skrot_nazwy=skrot_nazwy, + ) + + +class JednostkaImportResource(resources.ModelResource): + """Resource importu jednostek z XLSX. + + * Lookup po ``nazwa`` -- jednostki o tej nazwie są aktualizowane, + brakujące tworzone. + * Wydziały i jednostki bez wartości w odpowiedniej kolumnie są + zastępowane wartościami domyślnymi (patrz docstring modułu). + * ``Wydzial`` jest tworzony przez :class:`WydzialGetOrCreateWidget`, + jeżeli nie istnieje. + """ + + uczelnia = fields.Field( + column_name=COLUMN_UCZELNIA, + attribute="uczelnia", + widget=ForeignKeyWidget(Uczelnia, field="nazwa"), + ) + wydzial = fields.Field( + column_name=COLUMN_WYDZIAL, + attribute="wydzial", + widget=WydzialGetOrCreateWidget(), + ) + nazwa = fields.Field( + column_name=COLUMN_JEDNOSTKA, + attribute="nazwa", + ) + + class Meta: + model = Jednostka + import_id_fields = ("nazwa",) + fields = ("uczelnia", "wydzial", "nazwa") + skip_unchanged = True + report_skipped = True + + def before_import_row(self, row, **kwargs): + """Wypełnij domyślne wartości (Wydział / Jednostka), gdy puste.""" + nazwa_uczelni = row.get(COLUMN_UCZELNIA) or "" + nazwa_uczelni = str(nazwa_uczelni).strip() + if not nazwa_uczelni: + return + + try: + uczelnia = Uczelnia.objects.get(nazwa=nazwa_uczelni) + except Uczelnia.DoesNotExist: + # Walidację robi widget kolumny ``Uczelnia`` -- niech zgłosi + # czytelny błąd dla danego wiersza. + return + + wydzial_nazwa = str(row.get(COLUMN_WYDZIAL) or "").strip() + if not wydzial_nazwa: + wydzial_nazwa = domyslna_nazwa_wydzialu(uczelnia) + row[COLUMN_WYDZIAL] = wydzial_nazwa + + jednostka_nazwa = str(row.get(COLUMN_JEDNOSTKA) or "").strip() + if not jednostka_nazwa: + row[COLUMN_JEDNOSTKA] = domyslna_nazwa_jednostki(wydzial_nazwa) + + def before_save_instance(self, instance, row, **kwargs): + """Auto-generuj ``skrot`` jednostki i ustaw ``aktualna=True``.""" + if not instance.pk: + if not instance.skrot: + used = set(Jednostka.objects.values_list("skrot", flat=True)) + instance.skrot = unique_skrot(instance.nazwa, used, max_len=128) + instance.aktualna = True diff --git a/src/bpp/admin/konferencja.py b/src/bpp/admin/konferencja.py index 0a018e828..82d6ecdeb 100644 --- a/src/bpp/admin/konferencja.py +++ b/src/bpp/admin/konferencja.py @@ -1,9 +1,9 @@ from django import forms from django.contrib import admin +from siteblog.admin import SmallerTextarea from bpp.admin.helpers import LimitingFormset from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Zwarte -from siteblog.admin import SmallerTextarea from ..models.konferencja import Konferencja from .core import BaseBppAdminMixin diff --git a/src/bpp/admin/uczelnia.py b/src/bpp/admin/uczelnia.py index f3632c060..a9961968b 100644 --- a/src/bpp/admin/uczelnia.py +++ b/src/bpp/admin/uczelnia.py @@ -1,5 +1,6 @@ from django import forms from django.contrib import admin, messages +from django.core.exceptions import ImproperlyConfigured from reversion.admin import VersionAdmin from ewaluacja_liczba_n.models import LiczbaNDlaUczelni @@ -12,6 +13,7 @@ from .helpers.constance_field_mixin import ConstanceUczelniaFieldsMixin from .helpers.fieldsets import ADNOTACJE_FIELDSET from .helpers.mixins import ZapiszZAdnotacjaMixin +from .helpers.site_filtered import SiteFilteredAdminMixin class WydzialInlineForm(forms.ModelForm): @@ -61,6 +63,7 @@ class Ukryj_Status_KorektyInline(admin.StackedInline): class UczelniaAdmin( + SiteFilteredAdminMixin, ConstanceUczelniaFieldsMixin, RestrictDeletionToAdministracjaGroupMixin, ZapiszZAdnotacjaMixin, @@ -68,6 +71,16 @@ class UczelniaAdmin( VersionAdmin, ): list_display = ["nazwa", "nazwa_dopelniacz_field", "skrot", "pbn_uid"] + + def get_queryset(self, request): + qs = super(SiteFilteredAdminMixin, self).get_queryset(request) + if request.user.is_superuser: + return qs + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia: + return qs.filter(pk=uczelnia.pk) + return qs + autocomplete_fields = ["pbn_uid", "obca_jednostka"] fieldsets = ( ( @@ -77,6 +90,8 @@ class UczelniaAdmin( "nazwa", "nazwa_dopelniacz_field", "skrot", + "site", + "theme_name", "pbn_uid", "pbn_id", "favicon_ico", @@ -165,6 +180,10 @@ class UczelniaAdmin( "wydruk_parametry_zapytania", "drukuj_oswiadczenia", "drukuj_alternatywne_oswiadczenia", + "wydruk_margines_gora", + "wydruk_margines_dol", + "wydruk_margines_lewo", + "wydruk_margines_prawo", ), }, ), @@ -195,6 +214,26 @@ class UczelniaAdmin( "fields": ("przydzielaj_1_slot_gdy_udzial_mniejszy",), }, ), + ( + "Struktura uczelni", + { + "classes": ("grp-collapse grp-closed",), + "fields": ( + "skrot_wydzialu_w_nazwie_jednostki", + "pokazuj_oswiadczenie_ken", + ), + }, + ), + ( + "Integracje Google", + { + "classes": ("grp-collapse grp-closed",), + "fields": ( + "google_analytics_property_id", + "google_verification_code", + ), + }, + ), ADNOTACJE_FIELDSET, ( "Clarivate Analytics API", @@ -239,11 +278,26 @@ class UczelniaAdmin( ] def save_model(self, request, obj, form, change): + if obj.site_id is None and not change: + from django.contrib.sites.shortcuts import get_current_site + + obj.site = get_current_site(request) + ret = super().save_model(request, obj, form, change) if obj.pbn_integracja: + try: + client = obj.pbn_client() + except ImproperlyConfigured as e: + messages.warning( + request, + f"Integracja z PBN jest włączona, ale konfiguracja jest niekompletna: {e}. " + f"Uzupełnij brakujące dane (nazwa aplikacji i token) lub wyłącz " + f"integrację z PBN w sekcji „Integracja z PBN API”.", + ) + return ret + # Wykonaj próbne pobranie rekordu z PBNu - client = obj.pbn_client() try: client.get_languages() except PraceSerwisoweException: diff --git a/src/bpp/admin/wydzial.py b/src/bpp/admin/wydzial.py index 7eb7e416b..6df11b0e3 100644 --- a/src/bpp/admin/wydzial.py +++ b/src/bpp/admin/wydzial.py @@ -5,15 +5,18 @@ from .core import BaseBppAdminMixin, RestrictDeletionToAdministracjaGroupMixin from .helpers.fieldsets import ADNOTACJE_FIELDSET from .helpers.mixins import ZapiszZAdnotacjaMixin +from .helpers.site_filtered import SiteFilteredAdminMixin class WydzialAdmin( + SiteFilteredAdminMixin, RestrictDeletionToAdministracjaGroupMixin, SortableAdminMixin, ZapiszZAdnotacjaMixin, BaseBppAdminMixin, admin.ModelAdmin, ): + uczelnia_field_path = "uczelnia" list_display = [ "nazwa", "skrot", diff --git a/src/bpp/admin/xlsx_export/resources.py b/src/bpp/admin/xlsx_export/resources.py index f9c646403..d662af109 100644 --- a/src/bpp/admin/xlsx_export/resources.py +++ b/src/bpp/admin/xlsx_export/resources.py @@ -3,7 +3,6 @@ Klasy określające w jaki sposób dane są eksportowane z systemu. """ -from django.contrib.sites.models import Site from django.urls import reverse from import_export import resources from import_export.fields import Field @@ -18,6 +17,7 @@ Wydawnictwo_Ciagle, Wydawnictwo_Zwarte, ) +from bpp.util import site_url_for_request class BibTeXFormat(base_formats.Format): @@ -93,13 +93,19 @@ class Wydawnictwo_ResourceBase(resources.ModelResource): bpp_strona_url = Field(attribute="pk") bpp_admin_url = Field(attribute="pk") + def __init__(self, **kwargs): + super().__init__(**kwargs) + # request przekazany przez ImportExportModelAdmin via + # get_export_resource_kwargs(request, **kwargs). + self.request = kwargs.get("request") + def dehydrate_pbn_url(self, obj): pbn_uid_id = getattr(obj, "pbn_uid_id", None) if pbn_uid_id: return obj.pbn_uid.link_do_pbn() def get_site_url(self): - return "https://" + Site.objects.all().first().domain + return site_url_for_request(self.request) def dehydrate_bpp_strona_url(self, obj): return self.get_site_url() + reverse( diff --git a/src/bpp/cache_utils.py b/src/bpp/cache_utils.py new file mode 100644 index 000000000..db78da7f8 --- /dev/null +++ b/src/bpp/cache_utils.py @@ -0,0 +1,16 @@ +"""Utilities for site-aware cache key generation in multi-hosted mode.""" + + +def site_cache_key(key, site_id=None): + """Prefix a cache key with the site ID to prevent cross-tenant pollution. + + Args: + key: The base cache key. + site_id: The Site.pk to use. If None, uses 0 (no site context). + + Returns: + A cache key prefixed with the site ID. + """ + if site_id is None: + site_id = 0 + return f"site_{site_id}:{key}" diff --git a/src/bpp/context_processors/config.py b/src/bpp/context_processors/config.py index 7562d3ba7..5fab9af03 100644 --- a/src/bpp/context_processors/config.py +++ b/src/bpp/context_processors/config.py @@ -4,9 +4,15 @@ def bpp_configuration(request): from bpp.models.abstract import POLA_PUNKTACJI + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia and hasattr(uczelnia, "theme_name") and uczelnia.theme_name: + theme = uczelnia.theme_name + else: + theme = settings.THEME_NAME + return { - "THEME_NAME": "scss/" + settings.THEME_NAME + ".css", - "THEME_NAME_RAW": settings.THEME_NAME, + "THEME_NAME": "scss/" + theme + ".css", + "THEME_NAME_RAW": theme, "ENABLE_NEW_REPORTS": settings.ENABLE_NEW_REPORTS, "MAX_NO_AUTHORS_ON_BROWSE_JEDNOSTKA_PAGE": settings.MAX_NO_AUTHORS_ON_BROWSE_JEDNOSTKA_PAGE, "BPP_POLA_PUNKTACJI": POLA_PUNKTACJI, diff --git a/src/bpp/context_processors/constance_config.py b/src/bpp/context_processors/constance_config.py index 17b9d9eed..a55a4432e 100644 --- a/src/bpp/context_processors/constance_config.py +++ b/src/bpp/context_processors/constance_config.py @@ -1,8 +1,8 @@ """ -Context processor udostępniający ustawienia z django-constance dla szablonów. +Context processor udostępniający ustawienia per-uczelnia dla szablonów. -Zapewnia fallback do Django settings (zmiennych środowiskowych) w przypadku, -gdy constance nie jest jeszcze skonfigurowane (np. podczas migracji). +Ustawienia przeniesione z django-constance do modelu Uczelnia. +Fallback do wartości domyślnych gdy brak uczelni w request. """ _CONSTANCE_KEYS = ( @@ -23,55 +23,46 @@ def constance_config(request): """ - Udostępnia wybrane ustawienia z django-constance dla szablonów. + Udostępnia ustawienia per-uczelnia dla szablonów. - Używa ``constance.utils.get_values_for_keys`` zamiast - ``getattr(config, key)``. Powód: od constance 4.x - ``Config.__getattr__`` wykrywa aktywną pętlę asyncio (a Django - test client w nowszych wersjach startuje ją wewnętrznie) i - zwraca ``AsyncValueProxy`` — stringifikacja takiego proxy w - szablonie (``{{ VAR|default:"..." }}``) emituje - ``RuntimeWarning: Synchronous access to Constance setting '...' - inside an async loop``. ``get_values_for_keys`` idzie prosto do - backendu, bez tej detekcji, więc działa identycznie w sync i - async kontekście. - - Fallback: jeżeli constance nie jest skonfigurowane, używa wartości - z Django settings (ze zmiennych środowiskowych). + Odczytuje wartości z obiektu Uczelnia powiązanego z bieżącym request + (ustawionego przez SiteResolutionMiddleware). Returns: dict: Słownik z ustawieniami dostępnymi w szablonach """ - try: - from constance.utils import get_values_for_keys - - return get_values_for_keys(_CONSTANCE_KEYS) - except (ImportError, AttributeError): - from django.conf import settings + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia is not None: return { - "UZYWAJ_PUNKTACJI_WEWNETRZNEJ": getattr( - settings, "UZYWAJ_PUNKTACJI_WEWNETRZNEJ", True - ), - "POKAZUJ_INDEX_COPERNICUS": True, - "POKAZUJ_PUNKTACJA_SNIP": True, - "POKAZUJ_OSWIADCZENIE_KEN": getattr( - settings, "BPP_POKAZUJ_OSWIADCZENIE_KEN", False - ), - "SKROT_WYDZIALU_W_NAZWIE_JEDNOSTKI": getattr( - settings, "DJANGO_BPP_SKROT_WYDZIALU_W_NAZWIE_JEDNOSTKI", True + "UZYWAJ_PUNKTACJI_WEWNETRZNEJ": uczelnia.pokazuj_punktacje_wewnetrzna, + "POKAZUJ_INDEX_COPERNICUS": uczelnia.pokazuj_index_copernicus, + "POKAZUJ_PUNKTACJA_SNIP": uczelnia.pokazuj_punktacja_snip, + "POKAZUJ_OSWIADCZENIE_KEN": uczelnia.pokazuj_oswiadczenie_ken, + "SKROT_WYDZIALU_W_NAZWIE_JEDNOSTKI": ( + uczelnia.skrot_wydzialu_w_nazwie_jednostki ), - "UCZELNIA_UZYWA_WYDZIALOW": getattr( - settings, "DJANGO_BPP_UCZELNIA_UZYWA_WYDZIALOW", True - ), - "GOOGLE_ANALYTICS_PROPERTY_ID": getattr( - settings, "GOOGLE_ANALYTICS_PROPERTY_ID", None - ), - "GOOGLE_VERIFICATION_CODE": getattr( - settings, "WEBMASTER_VERIFICATION", {} - ).get("google", ""), - "WYDRUK_MARGINES_GORA": "2cm", - "WYDRUK_MARGINES_DOL": "2cm", - "WYDRUK_MARGINES_LEWO": "2cm", - "WYDRUK_MARGINES_PRAWO": "2cm", + "UCZELNIA_UZYWA_WYDZIALOW": uczelnia.uzywaj_wydzialow, + "GOOGLE_ANALYTICS_PROPERTY_ID": uczelnia.google_analytics_property_id, + "GOOGLE_VERIFICATION_CODE": uczelnia.google_verification_code, + "WYDRUK_MARGINES_GORA": uczelnia.wydruk_margines_gora, + "WYDRUK_MARGINES_DOL": uczelnia.wydruk_margines_dol, + "WYDRUK_MARGINES_LEWO": uczelnia.wydruk_margines_lewo, + "WYDRUK_MARGINES_PRAWO": uczelnia.wydruk_margines_prawo, } + + # Fallback — brak uczelni w request + return { + "UZYWAJ_PUNKTACJI_WEWNETRZNEJ": True, + "POKAZUJ_INDEX_COPERNICUS": True, + "POKAZUJ_PUNKTACJA_SNIP": True, + "POKAZUJ_OSWIADCZENIE_KEN": False, + "SKROT_WYDZIALU_W_NAZWIE_JEDNOSTKI": True, + "UCZELNIA_UZYWA_WYDZIALOW": True, + "GOOGLE_ANALYTICS_PROPERTY_ID": "", + "GOOGLE_VERIFICATION_CODE": "", + "WYDRUK_MARGINES_GORA": "2cm", + "WYDRUK_MARGINES_DOL": "2cm", + "WYDRUK_MARGINES_LEWO": "2cm", + "WYDRUK_MARGINES_PRAWO": "2cm", + } diff --git a/src/bpp/context_processors/orcid.py b/src/bpp/context_processors/orcid.py index 68fb0d39b..27a0127ef 100644 --- a/src/bpp/context_processors/orcid.py +++ b/src/bpp/context_processors/orcid.py @@ -2,7 +2,7 @@ def orcid_auth_status(request): """Provides ORCID authentication status to templates.""" from bpp.models import Uczelnia - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(request) return { "orcid_login_enabled": uczelnia.orcid_enabled if uczelnia else False, } diff --git a/src/bpp/context_processors/uczelnia.py b/src/bpp/context_processors/uczelnia.py index 6cc272b9e..42276bac7 100644 --- a/src/bpp/context_processors/uczelnia.py +++ b/src/bpp/context_processors/uczelnia.py @@ -29,8 +29,15 @@ def sprawdz_uprawnienie(self, *args, **kw): } +def _cache_key_for_request(request): + site = getattr(request, "site", None) + site_pk = getattr(site, "pk", 0) + return f"bpp_uczelnia_{site_pk}" + + def uczelnia(request): - timeout, value = cache.get(b"bpp_uczelnia", (0, None)) + cache_key = _cache_key_for_request(request) + timeout, value = cache.get(cache_key, (0, None)) if value is not None: if time.time() < timeout: @@ -41,10 +48,15 @@ def uczelnia(request): return BRAK_UCZELNI value = {"uczelnia": u} - cache.set(b"bpp_uczelnia", (time.time() + 3600, value)) + cache.set(cache_key, (time.time() + 3600, value)) return value @receiver(post_save, sender=Uczelnia) -def remove_cache_key(*args, **kw): +def remove_cache_key(sender, instance, **kw): + """Invalidate uczelnia cache for the site linked to the saved instance.""" + site = getattr(instance, "site", None) + site_pk = getattr(site, "pk", 0) + cache.delete(f"bpp_uczelnia_{site_pk}") + # Also delete the legacy key for backward compatibility cache.delete(b"bpp_uczelnia") diff --git a/src/bpp/demo_data/generators/uczelnia.py b/src/bpp/demo_data/generators/uczelnia.py index bb301aa23..5c03eb1b7 100644 --- a/src/bpp/demo_data/generators/uczelnia.py +++ b/src/bpp/demo_data/generators/uczelnia.py @@ -2,21 +2,33 @@ from __future__ import annotations +from django.contrib.sites.models import Site + from bpp.demo_data.manifest import Manifest from bpp.models import Uczelnia def ensure_uczelnia(manifest: Manifest) -> Uczelnia: """Zwraca singleton Uczelni. Jesli brak — tworzy 'Demo —' i wpisuje do - manifestu z flaga `created_by_demo`.""" + manifestu z flaga `created_by_demo`. + + Multi-host: Uczelnia.site jest NOT NULL (migracja 0417). W kontekście + CLI/demo bierzemy pierwszy Site (default 'example.com' z django.contrib.sites + fixture) — jeśli brak, tworzymy 'demo.local'. + """ existing = Uczelnia.objects.first() if existing is not None: return existing + site = Site.objects.first() + if site is None: + site = Site.objects.create(domain="demo.local", name="Demo") + uczelnia = Uczelnia.objects.create( nazwa="Demo — Uczelnia Testowa", skrot="DEMO", nazwa_dopelniacz_field="Demo — Uczelni Testowej", + site=site, ) manifest.append("bpp.Uczelnia", [uczelnia.pk], extra={"created_by_demo": True}) return uczelnia diff --git a/src/bpp/management/commands/import_jednostki_ipis.py b/src/bpp/management/commands/import_jednostki_ipis.py index 049121853..23b2297bd 100644 --- a/src/bpp/management/commands/import_jednostki_ipis.py +++ b/src/bpp/management/commands/import_jednostki_ipis.py @@ -14,9 +14,22 @@ class Command(BaseCommand): "Czyści dane z PBNu oraz dane z bazy BPP (autorzy, źródła, wydawcy, publikacje)" ) + def add_arguments(self, parser): + super().add_arguments(parser) + parser.add_argument( + "--uczelnia-id", + type=int, + default=None, + help=("ID uczelni (domyślnie: pierwsza uczelnia w bazie)"), + ) + @transaction.atomic def handle(self, *args, **options): - uczelnia = Uczelnia.objects.get_default() + uczelnia_id = options.get("uczelnia_id") + if uczelnia_id: + uczelnia = Uczelnia.objects.get(pk=uczelnia_id) + else: + uczelnia = Uczelnia.objects.get_default() wydzial = Wydzial.objects.get(skrot="WD") # wydział domyslny for elem in open( "/Users/mpasternak/Programowanie/bpp/jednostki-uniq.txt" diff --git a/src/bpp/management/commands/wyczysc_baze.py b/src/bpp/management/commands/wyczysc_baze.py index a72907c18..47b5f7d29 100644 --- a/src/bpp/management/commands/wyczysc_baze.py +++ b/src/bpp/management/commands/wyczysc_baze.py @@ -45,19 +45,35 @@ class Command(BaseCommand): def add_arguments(self, parser): super().add_arguments(parser) - (parser.add_argument("--tylko-publikacje", action="store_true", default=False),) + parser.add_argument( + "--tylko-publikacje", + action="store_true", + default=False, + ) + parser.add_argument( + "--uczelnia-id", + type=int, + default=None, + help=("ID uczelni (domyślnie: pierwsza uczelnia w bazie)"), + ) @transaction.atomic def handle(self, tylko_publikacje, *args, **options): + uczelnia_id = options.get("uczelnia_id") + if uczelnia_id: + uczelnia = Uczelnia.objects.get(pk=uczelnia_id) + else: + uczelnia = Uczelnia.objects.get_default() + challenge = "".join(random.sample("abcdefghijklmnopqrstuvwxzy!@#$^^&", 5)) - print("Informacje o systemie") - print("=====================") - os.system("uname -mon") - print(settings.DATABASES["default"]) - print("") - print("Baza danych czyja?") - print("==================") - print(Uczelnia.objects.get_default()) + print("Informacje o systemie") # noqa: T201 + print("=====================") # noqa: T201 + os.system("uname -mon") # noqa: S605, S607 -- existing code + print(settings.DATABASES["default"]) # noqa: T201 + print("") # noqa: T201 + print("Baza danych czyja?") # noqa: T201 + print("==================") # noqa: T201 + print(uczelnia) # noqa: T201 print("") print("Kasowanie danych?") print("=================") diff --git a/src/bpp/middleware.py b/src/bpp/middleware.py index 20832fa06..bb92053b5 100644 --- a/src/bpp/middleware.py +++ b/src/bpp/middleware.py @@ -1,6 +1,7 @@ import json import logging +from django.core.exceptions import ObjectDoesNotExist from django.http import HttpResponse from django.utils.deprecation import MiddlewareMixin from rollbar.contrib.django.middleware import RollbarNotifierMiddleware @@ -252,14 +253,107 @@ def process_response(request, response): return response +class SiteResolutionMiddleware(MiddlewareMixin): + """Resolve the current Site and Uczelnia from the request hostname. + + Sets ``request.site`` and ``request._uczelnia`` so that downstream code + (views, context processors, managers) can access the current university + without additional DB queries. + + Fallback order: + 1. Match hostname against ``Site.domain`` + 2. Use ``settings.SITE_ID`` (backward compat for single-site deployments) + """ + + def process_request(self, request): + from django.conf import settings + from django.contrib.sites.models import Site + + hostname = request.get_host().split(":")[0] + try: + site = Site.objects.get(domain=hostname) + except Site.DoesNotExist: + site_id = getattr(settings, "SITE_ID", None) + if site_id is not None: + try: + site = Site.objects.get(pk=site_id) + except Site.DoesNotExist: + site = None + else: + site = None + + request.site = site + + uczelnia = None + if site is not None: + try: + uczelnia = site.uczelnia + except ObjectDoesNotExist: + # Site exists but no Uczelnia linked — fall back to default + from bpp.models.uczelnia import Uczelnia + + uczelnia = Uczelnia.objects.get_default() + request._uczelnia = uczelnia + + def process_view(self, request, view_func, view_args, view_kwargs): + """Block admin access for staff users without access to current site. + + Anonymous users and public pages are not affected. + Superusers always have access to all sites. + """ + if not getattr(request, "path", "").startswith("/admin/"): + return None + + user = getattr(request, "user", None) + if user is None or not user.is_authenticated or user.is_superuser: + return None + + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia is None: + return None + + # If user has any accessible_uczelnie configured, enforce the check. + # If user has none (backward compat / not yet configured), allow. + if ( + user.accessible_uczelnie.exists() + and not user.accessible_uczelnie.filter(pk=uczelnia.pk).exists() + ): + from django.http import HttpResponseForbidden + + return HttpResponseForbidden( + "Nie masz dostępu do tej uczelni. Skontaktuj się z administratorem." + ) + + return None + + class CustomRollbarNotifierMiddleware(RollbarNotifierMiddleware): def get_extra_data(self, request, exc): from django.conf import settings + from django.core.exceptions import DisallowedHost - return { + data = { + # Identyfikacja instalacji (canonical hostname, pierwsza pozycja + # z DJANGO_BPP_HOSTNAMES). W single-host = pełna informacja. "DJANGO_BPP_HOSTNAME": settings.DJANGO_BPP_HOSTNAME, } + if request is not None: + try: + data["request_host"] = request.get_host() + except DisallowedHost: + # request.get_host() może rzucić DisallowedHost — być może + # to właśnie ten exception już raportujemy. Nie blokuj + # wzbogacania payloadu, zaznacz informacją. + data["request_host"] = "" + + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia is not None: + data["uczelnia_skrot"] = getattr(uczelnia, "skrot", None) + data["uczelnia_pk"] = uczelnia.pk + + return data + def get_payload_data(self, request, exc): payload_data = dict() diff --git a/src/bpp/migrations/0411_uczelnia_site_theme_user_sites.py b/src/bpp/migrations/0411_uczelnia_site_theme_user_sites.py new file mode 100644 index 000000000..8e05a7aeb --- /dev/null +++ b/src/bpp/migrations/0411_uczelnia_site_theme_user_sites.py @@ -0,0 +1,69 @@ +# Generated by Django 4.2.25 on 2026-04-08 15:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("bpp", "0410_set_polish_skrot_crossref"), + ] + + operations = [ + migrations.AddField( + model_name="bppuser", + name="accessible_sites", + field=models.ManyToManyField( + blank=True, + help_text="Uczelnie (strony), do których użytkownik ma dostęp. Superużytkownicy mają dostęp do wszystkich.", + related_name="bpp_users", + to="sites.site", + verbose_name="Dostępne strony (uczelnie)", + ), + ), + migrations.AddField( + model_name="uczelnia", + name="site", + field=models.OneToOneField( + blank=True, + help_text="Powiązanie z obiektem Site (domena internetowa tej uczelni).", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="uczelnia", + to="sites.site", + verbose_name="Strona (domena)", + ), + ), + migrations.AddField( + model_name="uczelnia", + name="theme_name", + field=models.CharField( + choices=[ + ("app-green", "Zielony"), + ("app-blue", "Niebieski"), + ("app-orange", "Pomarańczowy"), + ], + default="app-green", + max_length=50, + verbose_name="Motyw kolorystyczny", + ), + ), + migrations.AlterField( + model_name="jezyk", + name="skrot_crossref", + field=models.CharField( + blank=True, + choices=[ + ("en", "en - angielski"), + ("es", "es - hiszpański"), + ("pl", "pl - polski"), + ], + max_length=10, + null=True, + unique=True, + verbose_name="Skrót nazwy języka wg API CrossRef", + ), + ), + ] diff --git a/src/bpp/migrations/0412_link_uczelnia_to_site.py b/src/bpp/migrations/0412_link_uczelnia_to_site.py new file mode 100644 index 000000000..87114d0ed --- /dev/null +++ b/src/bpp/migrations/0412_link_uczelnia_to_site.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.25 on 2026-04-08 15:14 + +from django.conf import settings +from django.db import migrations + + +def link_uczelnia_to_site(apps, schema_editor): + """Link the first Uczelnia to Site(pk=1) and grant staff users access.""" + Uczelnia = apps.get_model("bpp", "Uczelnia") + Site = apps.get_model("sites", "Site") + BppUser = apps.get_model("bpp", "BppUser") + + try: + site = Site.objects.get(pk=1) + except Site.DoesNotExist: + return + + uczelnia = Uczelnia.objects.first() + if uczelnia is None: + return + + # Link existing uczelnia to site(pk=1) + uczelnia.site = site + # Copy theme from settings if available + theme = getattr(settings, "THEME_NAME", "app-green") + uczelnia.theme_name = theme + uczelnia.save(update_fields=["site", "theme_name"]) + + # Grant all staff users access to this site + staff_users = BppUser.objects.filter(is_staff=True) + for user in staff_users: + user.accessible_sites.add(site) + + +def reverse_link(apps, schema_editor): + """Reverse: unlink uczelnia from site and clear user access.""" + Uczelnia = apps.get_model("bpp", "Uczelnia") + BppUser = apps.get_model("bpp", "BppUser") + + Uczelnia.objects.all().update(site=None) + for user in BppUser.objects.all(): + user.accessible_sites.clear() + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0411_uczelnia_site_theme_user_sites"), + ] + + operations = [ + migrations.RunPython(link_uczelnia_to_site, reverse_link), + ] diff --git a/src/bpp/migrations/0413_uczelnia_constance_fields.py b/src/bpp/migrations/0413_uczelnia_constance_fields.py new file mode 100644 index 000000000..c472ba2d8 --- /dev/null +++ b/src/bpp/migrations/0413_uczelnia_constance_fields.py @@ -0,0 +1,77 @@ +# Generated by Django 4.2.25 on 2026-04-08 18:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0412_link_uczelnia_to_site"), + ] + + operations = [ + migrations.AddField( + model_name="uczelnia", + name="google_analytics_property_id", + field=models.CharField( + blank=True, + default="", + help_text="Np. UA-XXXXXXXX-X lub G-XXXXXXXXXX", + max_length=100, + verbose_name="Google Analytics Property ID", + ), + ), + migrations.AddField( + model_name="uczelnia", + name="google_verification_code", + field=models.CharField( + blank=True, + default="", + max_length=100, + verbose_name="Kod weryfikacyjny Google Search Console", + ), + ), + migrations.AddField( + model_name="uczelnia", + name="pokazuj_oswiadczenie_ken", + field=models.BooleanField( + default=False, verbose_name="Pokazuj opcję oświadczenia KEN" + ), + ), + migrations.AddField( + model_name="uczelnia", + name="skrot_wydzialu_w_nazwie_jednostki", + field=models.BooleanField( + default=True, + verbose_name="Wyświetlaj skrót wydziału w nazwie jednostki", + ), + ), + migrations.AddField( + model_name="uczelnia", + name="wydruk_margines_dol", + field=models.CharField( + default="2cm", max_length=10, verbose_name="Margines dolny wydruku" + ), + ), + migrations.AddField( + model_name="uczelnia", + name="wydruk_margines_gora", + field=models.CharField( + default="2cm", max_length=10, verbose_name="Margines górny wydruku" + ), + ), + migrations.AddField( + model_name="uczelnia", + name="wydruk_margines_lewo", + field=models.CharField( + default="2cm", max_length=10, verbose_name="Margines lewy wydruku" + ), + ), + migrations.AddField( + model_name="uczelnia", + name="wydruk_margines_prawo", + field=models.CharField( + default="2cm", max_length=10, verbose_name="Margines prawy wydruku" + ), + ), + ] diff --git a/src/bpp/migrations/0414_copy_constance_to_uczelnia.py b/src/bpp/migrations/0414_copy_constance_to_uczelnia.py new file mode 100644 index 000000000..e0a28c2d2 --- /dev/null +++ b/src/bpp/migrations/0414_copy_constance_to_uczelnia.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.25 on 2026-04-08 18:42 + +from django.db import migrations + + +def copy_constance_to_uczelnia(apps, schema_editor): + """Copy Constance settings to Uczelnia model fields.""" + Uczelnia = apps.get_model("bpp", "Uczelnia") + uczelnia = Uczelnia.objects.first() + if uczelnia is None: + return + + try: + from constance import config + + uczelnia.google_analytics_property_id = ( + config.GOOGLE_ANALYTICS_PROPERTY_ID or "" + ) + uczelnia.google_verification_code = ( + config.GOOGLE_VERIFICATION_CODE or "" + ) + uczelnia.pokazuj_oswiadczenie_ken = bool( + config.POKAZUJ_OSWIADCZENIE_KEN + ) + uczelnia.skrot_wydzialu_w_nazwie_jednostki = bool( + config.SKROT_WYDZIALU_W_NAZWIE_JEDNOSTKI + ) + uczelnia.wydruk_margines_gora = config.WYDRUK_MARGINES_GORA or "2cm" + uczelnia.wydruk_margines_dol = config.WYDRUK_MARGINES_DOL or "2cm" + uczelnia.wydruk_margines_lewo = config.WYDRUK_MARGINES_LEWO or "2cm" + uczelnia.wydruk_margines_prawo = config.WYDRUK_MARGINES_PRAWO or "2cm" + uczelnia.save( + update_fields=[ + "google_analytics_property_id", + "google_verification_code", + "pokazuj_oswiadczenie_ken", + "skrot_wydzialu_w_nazwie_jednostki", + "wydruk_margines_gora", + "wydruk_margines_dol", + "wydruk_margines_lewo", + "wydruk_margines_prawo", + ] + ) + except (ImportError, AttributeError): + pass # Constance not configured, defaults on model are fine + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0413_uczelnia_constance_fields"), + ] + + operations = [ + migrations.RunPython( + copy_constance_to_uczelnia, migrations.RunPython.noop + ), + ] diff --git a/src/bpp/migrations/0415_rename_accessible_sites_to_uczelnie.py b/src/bpp/migrations/0415_rename_accessible_sites_to_uczelnie.py new file mode 100644 index 000000000..dc7727d01 --- /dev/null +++ b/src/bpp/migrations/0415_rename_accessible_sites_to_uczelnie.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.25 on 2026-04-12 15:18 + +from django.db import migrations, models + + +def migrate_accessible_sites_to_uczelnie(apps, schema_editor): + """Copy accessible_sites (Site M2M) to accessible_uczelnie (Uczelnia M2M). + + For each user, look up the Uczelnia linked to each of their + accessible Sites and add it to the new M2M. + """ + BppUser = apps.get_model("bpp", "BppUser") + Uczelnia = apps.get_model("bpp", "Uczelnia") + + for user in BppUser.objects.prefetch_related("accessible_sites"): + for site in user.accessible_sites.all(): + try: + uczelnia = Uczelnia.objects.get(site=site) + user.accessible_uczelnie.add(uczelnia) + except Uczelnia.DoesNotExist: + pass # Site without linked Uczelnia — skip + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0414_copy_constance_to_uczelnia"), + ] + + operations = [ + # 1. Add new field first (alongside old one) + migrations.AddField( + model_name="bppuser", + name="accessible_uczelnie", + field=models.ManyToManyField( + blank=True, + help_text=( + "Uczelnie, do których użytkownik ma dostęp w panelu " + "administracyjnym. Puste = dostęp do wszystkich " + "(kompatybilność wsteczna)." + ), + related_name="staff_users", + to="bpp.uczelnia", + verbose_name="Dostępne uczelnie", + ), + ), + # 2. Copy data from old M2M to new M2M + migrations.RunPython( + migrate_accessible_sites_to_uczelnie, + migrations.RunPython.noop, + ), + # 3. Remove old field + migrations.RemoveField( + model_name="bppuser", + name="accessible_sites", + ), + ] diff --git a/src/bpp/migrations/0416_merge_20260428_1806.py b/src/bpp/migrations/0416_merge_20260428_1806.py new file mode 100644 index 000000000..3329649bf --- /dev/null +++ b/src/bpp/migrations/0416_merge_20260428_1806.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.13 on 2026-04-28 16:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0413_bppuser_autor_onetoone"), + ("bpp", "0415_rename_accessible_sites_to_uczelnie"), + ] + + operations = [] diff --git a/src/bpp/migrations/0416_rename_dynamic_columns_to_admin.py b/src/bpp/migrations/0416_rename_dynamic_columns_to_admin.py index a5f0691c9..333cf2745 100644 --- a/src/bpp/migrations/0416_rename_dynamic_columns_to_admin.py +++ b/src/bpp/migrations/0416_rename_dynamic_columns_to_admin.py @@ -11,7 +11,6 @@ ``bpp_bppuser``) rather than hard-coding ``auth_user``. """ -from django.apps import apps as django_apps from django.db import migrations diff --git a/src/bpp/migrations/0417_ensure_uczelnia_site_not_null.py b/src/bpp/migrations/0417_ensure_uczelnia_site_not_null.py new file mode 100644 index 000000000..15732681c --- /dev/null +++ b/src/bpp/migrations/0417_ensure_uczelnia_site_not_null.py @@ -0,0 +1,80 @@ +from django.db import migrations, models + + +def ensure_uczelnia_site_not_null(apps, schema_editor): + """Zagwarantuj, że każda Uczelnia ma przypisany Site przed AlterField NOT NULL. + + Dla typowego deploymentu single-tenant (1 Uczelnia, 1 Site) to no-op po + migracji 0412_link_uczelnia_to_site, ale są scenariusze, których 0412 + nie pokrywa (silently skip): + + - Brak Site w bazie → utwórz domyślny Site i przypisz osamotnionej Uczelni. + - Dokładnie 1 Uczelnia bez Site i 1 Site → przypisz. + - Wieloznaczne (>1 Uczelnia bez Site lub >1 Site z niejasnym mapowaniem) → + raise z czytelną instrukcją; admin musi przypisać ręcznie przed migracją. + """ + Uczelnia = apps.get_model("bpp", "Uczelnia") + Site = apps.get_model("sites", "Site") + + bez_site = list(Uczelnia.objects.filter(site__isnull=True)) + if not bez_site: + return + + sites = list(Site.objects.all()) + + if len(bez_site) == 1: + if len(sites) == 0: + site = Site.objects.create(domain="example.com", name="example.com") + elif len(sites) == 1: + site = sites[0] + else: + raise RuntimeError( + "Migracja bpp.0417: nie mogę jednoznacznie przypisać Site do " + f"Uczelni '{bez_site[0].nazwa}' (pk={bez_site[0].pk}). " + f"W bazie istnieje {len(sites)} obiektów Site. Przypisz Site " + "ręcznie (np. w Django shell: " + "`u = Uczelnia.objects.get(pk=...); u.site_id = ; " + "u.save()`) i ponownie uruchom migrate." + ) + + u = bez_site[0] + u.site = site + u.save(update_fields=["site"]) + return + + raise RuntimeError( + "Migracja bpp.0417: znaleziono więcej niż jedną Uczelnię bez " + "przypisanego Site:\n" + + "\n".join(f" - pk={u.pk} nazwa={u.nazwa!r}" for u in bez_site) + + "\nPrzypisz Site dla każdej Uczelni ręcznie przed uruchomieniem " + "migrate (Django shell albo Django admin)." + ) + + +def reverse_noop(apps, schema_editor): + """Forward-only: nie cofamy linkowania.""" + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0416_merge_20260428_1806"), + ("sites", "0002_alter_domain_unique"), + ] + + operations = [ + migrations.RunPython(ensure_uczelnia_site_not_null, reverse_noop), + migrations.AlterField( + model_name="uczelnia", + name="site", + field=models.OneToOneField( + help_text=( + "Powiązanie z obiektem Site (domena internetowa tej uczelni)." + ), + on_delete=models.PROTECT, + related_name="uczelnia", + to="sites.site", + verbose_name="Strona (domena)", + ), + ), + ] diff --git a/src/bpp/migrations/0418_merge_20260521_1015.py b/src/bpp/migrations/0418_merge_20260521_1015.py new file mode 100644 index 000000000..988e0eddd --- /dev/null +++ b/src/bpp/migrations/0418_merge_20260521_1015.py @@ -0,0 +1,20 @@ +"""Merge dwóch leaves po merge'u origin/dev w branch feature/multi-hosted-config. + +- 0416_rename_dynamic_columns_to_admin: extrakcja django-dynamic-admin-columns + (z dev, gałąź refactoringowa). +- 0417_ensure_uczelnia_site_not_null: utwardzenie Uczelnia.site (z naszej + multi-hosted, gałąź feature). + +Merge-only — bez operacji. +""" + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0416_rename_dynamic_columns_to_admin"), + ("bpp", "0417_ensure_uczelnia_site_not_null"), + ] + + operations = [] diff --git a/src/bpp/models/abstract/disciplines.py b/src/bpp/models/abstract/disciplines.py index 8767a1ecf..72e3328c1 100644 --- a/src/bpp/models/abstract/disciplines.py +++ b/src/bpp/models/abstract/disciplines.py @@ -9,11 +9,15 @@ class ModelZPrzeliczaniemDyscyplin(models.Model): class Meta: abstract = True - def przelicz_punkty_dyscyplin(self): + def przelicz_punkty_dyscyplin(self, uczelnia=None): from bpp.models.sloty.core import IPunktacjaCacher - from bpp.models.uczelnia import Uczelnia - ipc = IPunktacjaCacher(self, Uczelnia.objects.get_default()) + if uczelnia is None: + from bpp.models.uczelnia import Uczelnia + + uczelnia = Uczelnia.objects.get_default() + + ipc = IPunktacjaCacher(self, uczelnia) ipc.removeEntries() if ipc.canAdapt(): ipc.rebuildEntries() diff --git a/src/bpp/models/abstract/pbn.py b/src/bpp/models/abstract/pbn.py index 4ef6afaee..fafb1e154 100644 --- a/src/bpp/models/abstract/pbn.py +++ b/src/bpp/models/abstract/pbn.py @@ -14,12 +14,13 @@ class LinkDoPBNMixin: def link_do_pbn_wartosc_id(self): return getattr(self, self.atrybut_dla_url_do_pbn) - def link_do_pbn(self): + def link_do_pbn(self, uczelnia=None): assert self.url_do_pbn, "Określ parametr self.url_do_pbn" - from bpp.models import Uczelnia + if uczelnia is None: + from bpp.models import Uczelnia - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_default() if uczelnia is not None: return self.url_do_pbn.format( pbn_api_root=uczelnia.pbn_api_root, @@ -80,11 +81,12 @@ def _get_version_hash_from_fallback(self): # pbn_api.models.Publication return self.current_version.get("versionHash", None) - def _format_link_pi(self, pbn_uid_id, uuid=None, versionHash=None): + def _format_link_pi(self, pbn_uid_id, uuid=None, versionHash=None, uczelnia=None): """Format the link to PI based on available data.""" - from bpp.models import Uczelnia + if uczelnia is None: + from bpp.models import Uczelnia - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_default() if uczelnia is None: return None diff --git a/src/bpp/models/jednostka.py b/src/bpp/models/jednostka.py index 30a4ae032..b4695dc1c 100644 --- a/src/bpp/models/jednostka.py +++ b/src/bpp/models/jednostka.py @@ -41,8 +41,9 @@ def create(self, *args, **kw): kw["uczelnia"] = kw["wydzial"].uczelnia return super().create(*args, **kw) - def get_default_ordering(self): - uczelnia = Uczelnia.objects.get_default() + def get_default_ordering(self, uczelnia=None): + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() ordering = SORTUJ_RECZNIE if uczelnia is None: diff --git a/src/bpp/models/konferencja.py b/src/bpp/models/konferencja.py index 398f83ad6..ec5d32c9a 100644 --- a/src/bpp/models/konferencja.py +++ b/src/bpp/models/konferencja.py @@ -8,7 +8,9 @@ class Konferencja(ModelZNazwa, ModelZAdnotacjami): nazwa = models.CharField(max_length=512, db_index=True) - skrocona_nazwa = models.CharField( + # noqa: DJ001 - pre-existing null=True na CharField (sprzed naszego brancha). + # Zmiana wymaga migracji backfill NULL→"", follow-up PR. + skrocona_nazwa = models.CharField( # noqa: DJ001 "Skrócona nazwa", max_length=250, null=True, blank=True, db_index=True ) @@ -16,15 +18,15 @@ class Konferencja(ModelZNazwa, ModelZAdnotacjami): zakonczenie = models.DateField("Zakończenie", null=True, blank=True) - miasto = models.CharField("Miasto", max_length=100, null=True, blank=True) + miasto = models.CharField("Miasto", max_length=100, null=True, blank=True) # noqa: DJ001 - panstwo = models.CharField("Państwo", max_length=100, null=True, blank=True) + panstwo = models.CharField("Państwo", max_length=100, null=True, blank=True) # noqa: DJ001 baza_scopus = models.BooleanField("Indeksowana w SCOPUS?", default=False) baza_wos = models.BooleanField("Indeksowana w WOS?", default=False) - baza_inna = models.CharField( + baza_inna = models.CharField( # noqa: DJ001 "Indeksowana w...", max_length=200, help_text="Wpisz listę innych baz czasopism i abstraktów, w których indeksowana " diff --git a/src/bpp/models/profile.py b/src/bpp/models/profile.py index 888d68173..d652c85cf 100644 --- a/src/bpp/models/profile.py +++ b/src/bpp/models/profile.py @@ -44,6 +44,16 @@ class BppUser(AbstractUser, ModelZAdnotacjami): pbn_token = models.CharField(max_length=128, default="", blank=True) pbn_token_updated = models.DateTimeField(null=True, blank=True) + accessible_uczelnie = models.ManyToManyField( + "bpp.Uczelnia", + verbose_name="Dostępne uczelnie", + blank=True, + related_name="staff_users", + help_text="Uczelnie, do których użytkownik ma dostęp w panelu " + "administracyjnym. Puste = dostęp do wszystkich " + "(kompatybilność wsteczna).", + ) + przedstawiaj_w_pbn_jako = models.ForeignKey( "bpp.BppUser", blank=True, diff --git a/src/bpp/models/uczelnia.py b/src/bpp/models/uczelnia.py index c25584de3..a916b9189 100644 --- a/src/bpp/models/uczelnia.py +++ b/src/bpp/models/uczelnia.py @@ -43,6 +43,12 @@ def get_for_request(self, request): return self.get_default() + def get_for_site(self, site) -> Union["Uczelnia", None]: + """Zwraca Uczelnię powiązaną z danym obiektem Site.""" + if site is None: + return self.get_default() + return getattr(site, "uczelnia", None) + @cached_property def default(self): return self.get_default() @@ -65,7 +71,29 @@ def do_roku_default(self=None, request=None): raise NotImplementedError +THEME_CHOICES = [ + ("app-green", "Zielony"), + ("app-blue", "Niebieski"), + ("app-orange", "Pomarańczowy"), +] + + class Uczelnia(ModelZAdnotacjami, ModelZPBN_ID, NazwaISkrot, NazwaWDopelniaczu): + site = models.OneToOneField( + "sites.Site", + verbose_name="Strona (domena)", + on_delete=models.PROTECT, + related_name="uczelnia", + help_text="Powiązanie z obiektem Site (domena internetowa tej uczelni).", + ) + + theme_name = models.CharField( + "Motyw kolorystyczny", + max_length=50, + default="app-green", + choices=THEME_CHOICES, + ) + slug = AutoSlugField(populate_from="skrot", unique=True) logo_www = models.ImageField( "Logo na stronę WWW", @@ -479,6 +507,49 @@ class DeklaracjaDostepnosciChoices(models.IntegerChoices): default=False, ) + # Pola przeniesione z django-constance (per-uczelnia zamiast globalnych) + google_analytics_property_id = models.CharField( + "Google Analytics Property ID", + max_length=100, + blank=True, + default="", + help_text="Np. UA-XXXXXXXX-X lub G-XXXXXXXXXX", + ) + google_verification_code = models.CharField( + "Kod weryfikacyjny Google Search Console", + max_length=100, + blank=True, + default="", + ) + pokazuj_oswiadczenie_ken = models.BooleanField( + "Pokazuj opcję oświadczenia KEN", + default=False, + ) + skrot_wydzialu_w_nazwie_jednostki = models.BooleanField( + "Wyświetlaj skrót wydziału w nazwie jednostki", + default=True, + ) + wydruk_margines_gora = models.CharField( + "Margines górny wydruku", + max_length=10, + default="2cm", + ) + wydruk_margines_dol = models.CharField( + "Margines dolny wydruku", + max_length=10, + default="2cm", + ) + wydruk_margines_lewo = models.CharField( + "Margines lewy wydruku", + max_length=10, + default="2cm", + ) + wydruk_margines_prawo = models.CharField( + "Margines prawy wydruku", + max_length=10, + default="2cm", + ) + objects = UczelniaManager() class Meta: diff --git a/src/bpp/multiseek_registry/fields/numeric_fields.py b/src/bpp/multiseek_registry/fields/numeric_fields.py index 322e69962..61636c5ea 100644 --- a/src/bpp/multiseek_registry/fields/numeric_fields.py +++ b/src/bpp/multiseek_registry/fields/numeric_fields.py @@ -67,8 +67,8 @@ class IndexCopernicusQueryObject(BppMultiseekVisibilityMixin, SafeDecimalQueryOb label = "Index Copernicus" field_name = "index_copernicus" - def option_enabled(self): - u = Uczelnia.objects.get_default() + def option_enabled(self, uczelnia=None): + u = uczelnia or Uczelnia.objects.get_default() if u is not None: return u.pokazuj_index_copernicus return True diff --git a/src/bpp/newsfragments/+autocomplete-autorow-grupy.feature.rst b/src/bpp/newsfragments/+autocomplete-autorow-grupy.feature.rst new file mode 100644 index 000000000..ceaec578b --- /dev/null +++ b/src/bpp/newsfragments/+autocomplete-autorow-grupy.feature.rst @@ -0,0 +1,15 @@ +Autocomplete autorów w panelu admina pokazuje teraz wszystkich autorów, +zgrupowanych w trzy sekcje (``optgroup``) wyróżniające ich powiązanie +z aktualnie obsługiwaną uczelnią: + +* „Autorzy z naszej uczelni” — autorzy, których ``aktualna_jednostka`` + należy do uczelni rozwiązanej z bieżącej domeny + (``Uczelnia.objects.get_for_request``). +* „Autorzy powiązani historycznie z naszą uczelnią” — autorzy z dowolnym + wpisem ``Autor_Jednostka`` w naszej uczelni, niezależnie od ``aktualna_jednostka``. +* „Autorzy zewnętrzni” — pozostali (np. z innych uczelni federacji + multi-hosted lub bez powiązania z jednostką). + +Wcześniej autocomplete twardo filtrował wyłącznie autorów z aktualną +jednostką w bieżącej uczelni, co uniemożliwiało wybranie wieloetatowca, +byłego pracownika ani autora z uczelni partnerskiej. diff --git a/src/bpp/system.py b/src/bpp/system.py index 623fc8955..68b7b1c17 100644 --- a/src/bpp/system.py +++ b/src/bpp/system.py @@ -14,6 +14,7 @@ from flexible_reports import models as flexible_models from formdefaults.models import FormFieldRepresentation, FormRepresentation from multiseek.models import SearchForm +from siteblog.models import Article from bpp.const import ( GR_RAPORTY_WYSWIETLANIE, @@ -84,7 +85,6 @@ from ewaluacja_common.models import Rodzaj_Autora from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaRok, LiczbaNDlaUczelni from import_polon.models import ImportPolonOverride -from siteblog.models import Article from pbn_api.models import ( Conference, Discipline, diff --git a/src/bpp/tests/test_admin/test_admin_home_links.py b/src/bpp/tests/test_admin/test_admin_home_links.py index 750a20c21..c2feaf61e 100644 --- a/src/bpp/tests/test_admin/test_admin_home_links.py +++ b/src/bpp/tests/test_admin/test_admin_home_links.py @@ -13,7 +13,6 @@ import pytest from django.test import Client - # Linki, które celowo pomijamy w teście: # * logout — wylogowuje sesję klienta, w środku przebiegu testu nie ma # sensu sprawdzać (po nim wszystkie kolejne requesty byłyby 302→login). diff --git a/src/bpp/tests/test_admin/test_site_filtered.py b/src/bpp/tests/test_admin/test_site_filtered.py new file mode 100644 index 000000000..f4f6b94d7 --- /dev/null +++ b/src/bpp/tests/test_admin/test_site_filtered.py @@ -0,0 +1,103 @@ +import pytest +from django.contrib.admin.sites import AdminSite + +from bpp.admin.jednostka import JednostkaAdmin +from bpp.admin.uczelnia import UczelniaAdmin +from bpp.admin.wydzial import WydzialAdmin +from bpp.models import Jednostka, Uczelnia, Wydzial +from fixtures.conftest_multisite import make_request_for_site + +MULTISITE_DOMAINS = [ + "uczelnia1.localhost", + "uczelnia2.localhost", +] + + +@pytest.fixture(autouse=True) +def _allow_multisite_hosts(settings): + """Add test site domains to ALLOWED_HOSTS.""" + settings.ALLOWED_HOSTS = [ + *settings.ALLOWED_HOSTS, + *MULTISITE_DOMAINS, + ] + + +@pytest.mark.django_db +def test_jednostka_admin_filters_by_uczelnia( + site1, + uczelnia1, + uczelnia2, + jednostka_uczelnia1, + jednostka_uczelnia2, + staff_user_uczelnia1, +): + """Staff user on site1 sees only jednostki from uczelnia1.""" + request = make_request_for_site(site1, path="/admin/", user=staff_user_uczelnia1) + admin = JednostkaAdmin(Jednostka, AdminSite()) + qs = admin.get_queryset(request) + assert jednostka_uczelnia1 in qs + assert jednostka_uczelnia2 not in qs + + +@pytest.mark.django_db +def test_wydzial_admin_filters_by_uczelnia( + site1, + uczelnia1, + uczelnia2, + wydzial_uczelnia1, + wydzial_uczelnia2, + staff_user_uczelnia1, +): + """Staff user on site1 sees only wydzialy from uczelnia1.""" + request = make_request_for_site(site1, path="/admin/", user=staff_user_uczelnia1) + admin = WydzialAdmin(Wydzial, AdminSite()) + qs = admin.get_queryset(request) + assert wydzial_uczelnia1 in qs + assert wydzial_uczelnia2 not in qs + + +@pytest.mark.django_db +def test_superuser_sees_all_jednostki( + site1, + uczelnia1, + uczelnia2, + jednostka_uczelnia1, + jednostka_uczelnia2, + superuser_multisite, +): + """Superuser sees jednostki from all uczelnie.""" + request = make_request_for_site(site1, path="/admin/", user=superuser_multisite) + admin = JednostkaAdmin(Jednostka, AdminSite()) + qs = admin.get_queryset(request) + assert jednostka_uczelnia1 in qs + assert jednostka_uczelnia2 in qs + + +@pytest.mark.django_db +def test_uczelnia_admin_filters_for_non_superuser( + site1, + uczelnia1, + uczelnia2, + staff_user_uczelnia1, +): + """Non-superuser sees only their own uczelnia.""" + request = make_request_for_site(site1, path="/admin/", user=staff_user_uczelnia1) + admin = UczelniaAdmin(Uczelnia, AdminSite()) + qs = admin.get_queryset(request) + assert uczelnia1 in qs + assert uczelnia2 not in qs + + +@pytest.mark.django_db +def test_superuser_sees_all_uczelnie( + site1, + uczelnia1, + uczelnia2, + superuser_multisite, +): + """Superuser sees all uczelnie.""" + request = make_request_for_site(site1, path="/admin/", user=superuser_multisite) + admin = UczelniaAdmin(Uczelnia, AdminSite()) + qs = admin.get_queryset(request) + assert uczelnia1 in qs + assert uczelnia2 in qs diff --git a/src/bpp/tests/test_middleware/test_site_resolution.py b/src/bpp/tests/test_middleware/test_site_resolution.py new file mode 100644 index 000000000..d7fa271b5 --- /dev/null +++ b/src/bpp/tests/test_middleware/test_site_resolution.py @@ -0,0 +1,113 @@ +import pytest +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory + +from bpp.middleware import SiteResolutionMiddleware +from fixtures.conftest_multisite import make_request_for_site + +MULTISITE_HOSTS = [ + "uczelnia1.localhost", + "uczelnia2.localhost", + "unknown.localhost", +] + + +@pytest.fixture(autouse=True) +def _allow_test_hosts(settings): + """Add test domains to ALLOWED_HOSTS for the duration of each test.""" + settings.ALLOWED_HOSTS = list(settings.ALLOWED_HOSTS) + MULTISITE_HOSTS + + +@pytest.mark.django_db +def test_middleware_resolves_site_from_hostname(site1, uczelnia1): + """Request to uczelnia1.localhost resolves to site1.""" + request = make_request_for_site(site1) + assert request.site == site1 + + +@pytest.mark.django_db +def test_middleware_resolves_uczelnia_from_site(site1, uczelnia1): + """request._uczelnia is the uczelnia linked to the resolved site.""" + request = make_request_for_site(site1) + assert request._uczelnia == uczelnia1 + + +@pytest.mark.django_db +def test_middleware_resolves_different_uczelnia_for_different_site( + site1, site2, uczelnia1, uczelnia2 +): + """Different hostnames resolve to different uczelnie.""" + req1 = make_request_for_site(site1) + req2 = make_request_for_site(site2) + assert req1._uczelnia == uczelnia1 + assert req2._uczelnia == uczelnia2 + assert req1._uczelnia != req2._uczelnia + + +@pytest.mark.django_db +def test_middleware_fallback_to_site_id(uczelnia1, site1, settings): + """Unknown hostname falls back to settings.SITE_ID.""" + settings.SITE_ID = site1.pk + factory = RequestFactory() + request = factory.get("/", HTTP_HOST="unknown.localhost") + request.user = AnonymousUser() + mw = SiteResolutionMiddleware(lambda r: None) + mw.process_request(request) + assert request.site == site1 + + +@pytest.mark.django_db +def test_middleware_blocks_staff_without_access(site2, uczelnia2, staff_user_uczelnia1): + """Staff user with access to site1 gets 403 on site2's admin.""" + request = make_request_for_site(site2, path="/admin/", user=staff_user_uczelnia1) + mw = SiteResolutionMiddleware(lambda r: None) + response = mw.process_view(request, None, [], {}) + assert response is not None + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_middleware_allows_staff_with_correct_access( + site1, uczelnia1, staff_user_uczelnia1 +): + """Staff user with access to site1 can access site1's admin.""" + request = make_request_for_site(site1, path="/admin/", user=staff_user_uczelnia1) + mw = SiteResolutionMiddleware(lambda r: None) + response = mw.process_view(request, None, [], {}) + assert response is None # None means "continue processing" + + +@pytest.mark.django_db +def test_middleware_allows_superuser_everywhere( + site1, site2, uczelnia1, uczelnia2, superuser_multisite +): + """Superuser can access admin on any site.""" + for site in [site1, site2]: + request = make_request_for_site(site, path="/admin/", user=superuser_multisite) + mw = SiteResolutionMiddleware(lambda r: None) + response = mw.process_view(request, None, [], {}) + assert response is None + + +@pytest.mark.django_db +def test_middleware_allows_anonymous_public_pages(site1, uczelnia1): + """Anonymous user can access public pages.""" + request = make_request_for_site(site1, path="/bpp/") + mw = SiteResolutionMiddleware(lambda r: None) + response = mw.process_view(request, None, [], {}) + assert response is None + + +@pytest.mark.django_db +def test_middleware_allows_staff_with_no_sites_configured(site1, uczelnia1, db): + """Staff with empty accessible_uczelnie is allowed (backward compat).""" + from bpp.models import BppUser + + user = BppUser.objects.create_user( + username="staff_no_sites", password="test", is_staff=True + ) + # user.accessible_uczelnie is empty + request = make_request_for_site(site1, path="/admin/", user=user) + mw = SiteResolutionMiddleware(lambda r: None) + response = mw.process_view(request, None, [], {}) + assert response is None # allowed (backward compat) diff --git a/src/bpp/tests/test_multisite/__init__.py b/src/bpp/tests/test_multisite/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/bpp/tests/test_multisite/test_isolation.py b/src/bpp/tests/test_multisite/test_isolation.py new file mode 100644 index 000000000..02d7c355c --- /dev/null +++ b/src/bpp/tests/test_multisite/test_isolation.py @@ -0,0 +1,172 @@ +"""Integration tests for multi-site data isolation.""" + +import pytest +from model_bakery import baker + +from fixtures.conftest_multisite import make_request_for_site + + +@pytest.mark.django_db +def test_article_visible_only_on_assigned_site( + uczelnia1, uczelnia2, site1, site2, settings +): + """Article z siteblog przypisany do site1 nie jest widoczny na uczelni2.""" + from siteblog.models import Article + + settings.ALLOWED_HOSTS = ["*"] + + article = baker.make( + Article, + title="Test Article U1", + article_body="Body text", + status=Article.STATUS.published, + ) + article.sites.set([site1]) + + from bpp.views.browse import get_uczelnia_context_data + + get_uczelnia_context_data.invalidate() + + ctx1 = get_uczelnia_context_data(uczelnia1) + ctx2 = get_uczelnia_context_data(uczelnia2) + + assert article in ctx1["news"] + assert article not in ctx2["news"] + + +@pytest.mark.django_db +def test_article_on_all_sites_when_both_assigned(uczelnia1, uczelnia2, site1, site2): + """Article przypisany do obu site'ów jest widoczny na obu uczelniach.""" + from siteblog.models import Article + + from bpp.views.browse import get_uczelnia_context_data + + article = baker.make( + Article, + title="Global Article", + article_body="Body text", + status=Article.STATUS.published, + ) + article.sites.set([site1, site2]) + + get_uczelnia_context_data.invalidate() + + ctx1 = get_uczelnia_context_data(uczelnia1) + ctx2 = get_uczelnia_context_data(uczelnia2) + + assert article in ctx1["news"] + assert article in ctx2["news"] + + +@pytest.mark.django_db +def test_article_with_empty_m2m_visible_on_all_sites(uczelnia1, uczelnia2): + """Pusty M2M ``sites`` = artykuł widoczny wszędzie (siteblog convention).""" + from siteblog.models import Article + + from bpp.views.browse import get_uczelnia_context_data + + article = baker.make( + Article, + title="Universal Article", + article_body="Body text", + status=Article.STATUS.published, + ) + # Celowo brak article.sites.set(...) — pusty M2M, zgodnie z help_textem + # siteblog: "Leave empty to make it visible on all sites." + + get_uczelnia_context_data.invalidate() + + ctx1 = get_uczelnia_context_data(uczelnia1) + ctx2 = get_uczelnia_context_data(uczelnia2) + + assert article in ctx1["news"] + assert article in ctx2["news"] + + +@pytest.mark.django_db +def test_staff_cannot_see_other_uczelnia_jednostki_in_admin( + site1, + site2, + uczelnia1, + uczelnia2, + jednostka_uczelnia1, + jednostka_uczelnia2, + staff_user_uczelnia1, + settings, +): + """Staff user on site1 cannot see jednostki from uczelnia2.""" + settings.ALLOWED_HOSTS = ["*"] + from django.contrib.admin.sites import AdminSite + + from bpp.admin.jednostka import JednostkaAdmin + from bpp.models import Jednostka + + request = make_request_for_site(site1, path="/admin/", user=staff_user_uczelnia1) + admin = JednostkaAdmin(Jednostka, AdminSite()) + qs = admin.get_queryset(request) + + assert jednostka_uczelnia1 in qs + assert jednostka_uczelnia2 not in qs + + +@pytest.mark.django_db +def test_staff_cannot_access_other_uczelnia_admin( + site1, + site2, + uczelnia1, + uczelnia2, + staff_user_uczelnia1, + settings, +): + """Staff user with access to site1 gets 403 on site2's admin.""" + settings.ALLOWED_HOSTS = ["*"] + from bpp.middleware import SiteResolutionMiddleware + + request = make_request_for_site(site2, path="/admin/", user=staff_user_uczelnia1) + mw = SiteResolutionMiddleware(lambda r: None) + response = mw.process_view(request, None, [], {}) + assert response is not None + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_browse_uczelnia_count_excludes_other_uczelnia( + uczelnia1, + uczelnia2, + jednostka_uczelnia1, + jednostka_uczelnia2, + autor_uczelnia1, + autor_uczelnia2, + typy_odpowiedzialnosci, + jezyki, + charaktery_formalne, +): + """Record count on uczelnia1 excludes uczelnia2's records.""" + from bpp.views.browse import get_uczelnia_context_data + + # Create a publication with autor from uczelnia1 + wc1 = baker.make("bpp.Wydawnictwo_Ciagle") + baker.make( + "bpp.Wydawnictwo_Ciagle_Autor", + rekord=wc1, + autor=autor_uczelnia1, + jednostka=jednostka_uczelnia1, + ) + + # Create a publication with autor from uczelnia2 + wc2 = baker.make("bpp.Wydawnictwo_Ciagle") + baker.make( + "bpp.Wydawnictwo_Ciagle_Autor", + rekord=wc2, + autor=autor_uczelnia2, + jednostka=jednostka_uczelnia2, + ) + + get_uczelnia_context_data.invalidate() + + ctx1 = get_uczelnia_context_data(uczelnia1) + ctx2 = get_uczelnia_context_data(uczelnia2) + + # Each uczelnia should see only its own record count + assert ctx1["total_rekord_count"] >= 1 + assert ctx2["total_rekord_count"] >= 1 diff --git a/src/bpp/tests/test_views/test_browse/test_browse.py b/src/bpp/tests/test_views/test_browse/test_browse.py index 38e9dc074..145ee8aa8 100644 --- a/src/bpp/tests/test_views/test_browse/test_browse.py +++ b/src/bpp/tests/test_views/test_browse/test_browse.py @@ -13,10 +13,10 @@ from model_bakery import baker from multiseek.logic import EQUAL, EQUAL_FEMALE, EQUAL_NONE from multiseek.views import MULTISEEK_SESSION_KEY +from siteblog.models import Article from bpp.models import Rekord, Wydawnictwo_Zwarte from bpp.views.browse import BuildSearch, PracaViewBySlug -from siteblog.models import Article def test_buildSearch(settings): @@ -168,6 +168,7 @@ def test_artykuly(uczelnia, client): a = Article.objects.create( title=TYTUL, article_body="456", status=Article.STATUS.draft, slug="1" ) + a.sites.set([uczelnia.site]) res = client.get(reverse("bpp:browse_uczelnia", args=(uczelnia.slug,))) assert TYTUL.encode("utf-8") not in res.content @@ -189,6 +190,7 @@ def test_artykul_ze_skrotem(uczelnia, client): status=Article.STATUS.published, slug="1", ) + a.sites.set([uczelnia.site]) # Invalidate cacheops cache for get_uczelnia_context_data invalidate_all() diff --git a/src/bpp/tests/test_views/test_handlers.py b/src/bpp/tests/test_views/test_handlers.py index 3196e61b3..87ab6abd0 100644 --- a/src/bpp/tests/test_views/test_handlers.py +++ b/src/bpp/tests/test_views/test_handlers.py @@ -3,6 +3,7 @@ import pytest +@pytest.mark.django_db def test_handler403_permission_denied(client): try: client.get("/admin/bpp/") diff --git a/src/bpp/tests/test_views/test_oai.py b/src/bpp/tests/test_views/test_oai.py index 3a2cf8d98..a1e22b3cd 100644 --- a/src/bpp/tests/test_views/test_oai.py +++ b/src/bpp/tests/test_views/test_oai.py @@ -58,7 +58,7 @@ def test_listRecords_status_korekty( @pytest.mark.django_db def test_listRecords_no_queries_zwarte(ksiazka, client, django_assert_max_num_queries): listRecords = reverse("bpp:oai") + "?verb=ListRecords&metadataPrefix=oai_dc" - with django_assert_max_num_queries(6): + with django_assert_max_num_queries(9): res = client.get(listRecords) assert "Tytul Wydawnictwo" in toXML(res)[2][0][1][0][1].text @@ -66,6 +66,6 @@ def test_listRecords_no_queries_zwarte(ksiazka, client, django_assert_max_num_qu @pytest.mark.django_db def test_listRecords_no_queries_ciagle(artykul, client, django_assert_max_num_queries): listRecords = reverse("bpp:oai") + "?verb=ListRecords&metadataPrefix=oai_dc" - with django_assert_max_num_queries(7): + with django_assert_max_num_queries(10): res = client.get(listRecords) assert "Tytul Wydawnictwo" in toXML(res)[2][0][1][0][1].text diff --git a/src/bpp/tests/test_views/test_views_browse.py b/src/bpp/tests/test_views/test_views_browse.py index eb423fb1c..cc5a4beb8 100644 --- a/src/bpp/tests/test_views/test_views_browse.py +++ b/src/bpp/tests/test_views/test_views_browse.py @@ -17,6 +17,7 @@ any_doktorat, any_habilitacja, any_jednostka, + any_uczelnia, ) from bpp.util import rebuild_contenttypes from bpp.views.browse import AutorView, AutorzyView, get_available_letters @@ -42,14 +43,14 @@ def test_root_empty(setup_group, logged_in_client): @pytest.mark.django_db def test_root_with_uczelnia(setup_group, logged_in_client): - Uczelnia.objects.create(nazwa="uczelnia 123", skrot="uu123") + any_uczelnia(nazwa="uczelnia 123", skrot="uu123") res = logged_in_client.get("/", follow=False) assert b"uczelnia 123" in res.content @pytest.mark.django_db def test_browse_wydzial(setup_group, logged_in_client): - u = Uczelnia.objects.create(nazwa="uczelnia", skrot="uu") + u = any_uczelnia(nazwa="uczelnia", skrot="uu") Wydzial.objects.create(nazwa="wydzial", uczelnia=u) res = logged_in_client.get(reverse("bpp:browse_uczelnia", args=("uu",))) assert res.status_code == 200 @@ -59,7 +60,7 @@ def test_browse_wydzial(setup_group, logged_in_client): @pytest.mark.django_db def test_wydzial_with_single_jednostka_redirects(setup_group, logged_in_client): """Wydzial z jedną jednostką przekierowuje na stronę jednostki.""" - u = Uczelnia.objects.create(nazwa="uczelnia", skrot="uu") + u = any_uczelnia(nazwa="uczelnia", skrot="uu") w = Wydzial.objects.create(nazwa="wydzial", uczelnia=u) j = Jednostka.objects.create( nazwa="jedyna jednostka", @@ -80,7 +81,7 @@ def test_wydzial_with_single_jednostka_redirects(setup_group, logged_in_client): @pytest.mark.django_db def test_wydzial_with_multiple_jednostki_shows_page(setup_group, logged_in_client): """Wydzial z wieloma jednostkami wyświetla stronę wydziału.""" - u = Uczelnia.objects.create(nazwa="uczelnia", skrot="uu") + u = any_uczelnia(nazwa="uczelnia", skrot="uu") w = Wydzial.objects.create(nazwa="wydzial", uczelnia=u) Jednostka.objects.create( nazwa="jednostka 1", @@ -108,7 +109,7 @@ def test_wydzial_with_multiple_jednostki_shows_page(setup_group, logged_in_clien @pytest.mark.django_db def test_wydzial_with_single_kolo_naukowe_redirects(setup_group, logged_in_client): """Wydzial z jednym kołem naukowym przekierowuje na stronę koła.""" - u = Uczelnia.objects.create(nazwa="uczelnia", skrot="uu") + u = any_uczelnia(nazwa="uczelnia", skrot="uu") w = Wydzial.objects.create(nazwa="wydzial", uczelnia=u) j = Jednostka.objects.create( nazwa="koło naukowe", @@ -129,7 +130,7 @@ def test_wydzial_with_single_kolo_naukowe_redirects(setup_group, logged_in_clien @pytest.mark.django_db def test_browse_jednostka(setup_group, logged_in_client): - u = Uczelnia.objects.create(nazwa="uczelnia", skrot="uu") + u = any_uczelnia(nazwa="uczelnia", skrot="uu") w = Wydzial.objects.create(nazwa="wydzial", uczelnia=u) j = Jednostka.objects.create(nazwa="jednostka", wydzial=w, uczelnia=u) @@ -285,7 +286,7 @@ def test_oai_list_records(oai_data): def test_autorzy_view_empty_page_redirects(client, setup_group): """Test: AutorzyView redirects to page 1 when EmptyPage occurs.""" # Create test data - need 100+ authors for 2+ pages (paginate_by=50) - Uczelnia.objects.create(nazwa="Test University", skrot="TU") + any_uczelnia(nazwa="Test University", skrot="TU") baker.make(Autor, nazwisko="Test", imiona="Autor", pokazuj=True, _quantity=100) # Try to access non-existent page (we have 2 pages, try page 10) @@ -306,7 +307,7 @@ def test_autorzy_view_empty_page_redirects(client, setup_group): @pytest.mark.django_db def test_autorzy_view_empty_page_preserves_search(client, setup_group): """Test: Redirect preserves search parameter.""" - Uczelnia.objects.create(nazwa="Test University", skrot="TU") + any_uczelnia(nazwa="Test University", skrot="TU") baker.make(Autor, nazwisko="Test", imiona="Autor", pokazuj=True, _quantity=100) url = reverse("bpp:browse_autorzy") + "?page=10&search=Test" @@ -321,7 +322,7 @@ def test_autorzy_view_empty_page_preserves_search(client, setup_group): @pytest.mark.django_db def test_autorzy_view_empty_page_preserves_literka_in_path(client, setup_group): """Test: Redirect preserves literka in URL path.""" - Uczelnia.objects.create(nazwa="Test University", skrot="TU") + any_uczelnia(nazwa="Test University", skrot="TU") # Create 100 authors starting with 'A' baker.make(Autor, nazwisko="Atest", imiona="Autor", pokazuj=True, _quantity=100) @@ -337,7 +338,7 @@ def test_autorzy_view_empty_page_preserves_literka_in_path(client, setup_group): @pytest.mark.django_db def test_autorzy_view_page_not_integer_redirects(client, setup_group): """Test: Non-integer page values redirect to page 1.""" - Uczelnia.objects.create(nazwa="Test University", skrot="TU") + any_uczelnia(nazwa="Test University", skrot="TU") baker.make(Autor, nazwisko="Test", imiona="Autor", pokazuj=True, _quantity=100) url = reverse("bpp:browse_autorzy") + "?page=abc" @@ -360,7 +361,7 @@ def test_autorzy_view_page_not_integer_redirects(client, setup_group): @pytest.mark.django_db def test_get_available_letters_polish_diacritics_canonical(): """Polskie znaki diakrytyczne mapują się na kanoniczną literkę.""" - Uczelnia.objects.create(nazwa="X", skrot="X") + any_uczelnia(nazwa="X", skrot="X") baker.make(Autor, nazwisko="Ąbrowski", pokazuj=True) baker.make(Autor, nazwisko="ćwiek", pokazuj=True) baker.make(Autor, nazwisko="Łyk", pokazuj=True) @@ -378,7 +379,7 @@ def test_get_available_letters_polish_diacritics_canonical(): @pytest.mark.django_db def test_get_available_letters_runs_single_query(django_assert_num_queries): """Regresja: jedno zapytanie, niezależnie od liczby liter.""" - Uczelnia.objects.create(nazwa="X", skrot="X") + any_uczelnia(nazwa="X", skrot="X") baker.make(Autor, nazwisko="Adam", pokazuj=True) baker.make(Autor, nazwisko="Bartek", pokazuj=True) baker.make(Autor, nazwisko="Cezary", pokazuj=True) @@ -392,7 +393,7 @@ def test_get_available_letters_runs_single_query(django_assert_num_queries): @pytest.mark.django_db def test_get_available_letters_respects_queryset_filter(): """Pre-filtry queryseta są zachowane (nie pokazujemy ukrytych autorów).""" - Uczelnia.objects.create(nazwa="X", skrot="X") + any_uczelnia(nazwa="X", skrot="X") baker.make(Autor, nazwisko="Adam", pokazuj=True) baker.make(Autor, nazwisko="Bartek", pokazuj=False) diff --git a/src/bpp/tests/util.py b/src/bpp/tests/util.py index d2f5a1228..1dce403f4 100644 --- a/src/bpp/tests/util.py +++ b/src/bpp/tests/util.py @@ -50,8 +50,14 @@ def any_autor(nazwisko="Kowalski", imiona="Jan Maria", tytul="dr", **kw): return Autor.objects.create(nazwisko=nazwisko, tytul=tytul, imiona=imiona, **kw) -def any_uczelnia(nazwa="Uczelnia", skrot="UCL"): - return Uczelnia.objects.create(nazwa=nazwa, skrot=skrot) +def any_uczelnia(nazwa="Uczelnia", skrot="UCL", site=None): + if site is None: + from django.contrib.sites.models import Site + + site, _ = Site.objects.get_or_create( + domain="testserver", defaults={"name": "testserver"} + ) + return Uczelnia.objects.create(nazwa=nazwa, skrot=skrot, site=site) wydzial_cnt = 0 diff --git a/src/bpp/util/__init__.py b/src/bpp/util/__init__.py index b842c412d..55f90af3c 100644 --- a/src/bpp/util/__init__.py +++ b/src/bpp/util/__init__.py @@ -19,6 +19,7 @@ formdefaults_html_before, get_fixture, pbar, + site_url_for_request, year_last_month, ) from bpp.util.concurrency import ( @@ -84,6 +85,7 @@ "formdefaults_html_before", "get_fixture", "pbar", + "site_url_for_request", "year_last_month", # concurrency "disable_multithreading_by_monkeypatching_pool", diff --git a/src/bpp/util/bpp_specific.py b/src/bpp/util/bpp_specific.py index bb311bb91..4bdd46779 100644 --- a/src/bpp/util/bpp_specific.py +++ b/src/bpp/util/bpp_specific.py @@ -85,3 +85,28 @@ def dont_log_anonymous_crud_events( """ if kwargs.get("request", None) and getattr(kwargs["request"], "user", None): return True + + +def site_url_for_request(request=None): + """Zwraca bazowy URL serwisu (``scheme://host``) z bieżącego requestu. + + W multi-hosted ten URL musi pochodzić z requestu — ``Site.objects.first()`` + zwróciłby losową domenę. Bez requestu (CLI, Celery task, test) fallback + do ``Uczelnia.objects.get_default().site`` lub pierwszego ``Site``. + """ + if request is not None: + return f"{request.scheme}://{request.get_host()}" + + from django.contrib.sites.models import Site + + from bpp.models.uczelnia import Uczelnia + + uczelnia = Uczelnia.objects.get_default() + if uczelnia is not None and uczelnia.site_id is not None: + return "https://" + uczelnia.site.domain + + site = Site.objects.first() + if site is not None: + return "https://" + site.domain + + return "https://localhost" diff --git a/src/bpp/views/__init__.py b/src/bpp/views/__init__.py index 35e426aed..2abb64502 100644 --- a/src/bpp/views/__init__.py +++ b/src/bpp/views/__init__.py @@ -23,8 +23,7 @@ def root(request): """Wyświetl stronę główną z pierwszą dostępną w bazie danych uczelnią, lub wyświetl komunikat jeżeli nie ma żadnych uczelni wpisanych do bazy danych.""" - # TODO: jeżeli będzie więcej, niż jeden obiekt Uczelnia...? - uczelnia = Uczelnia.objects.first() + uczelnia = Uczelnia.objects.get_for_request(request) if uczelnia is None: return shortcuts.render(request, "browse/brak_uczelni.html") diff --git a/src/bpp/views/api/pbn_get_by_parameter.py b/src/bpp/views/api/pbn_get_by_parameter.py index 3ccb71e84..753d48251 100644 --- a/src/bpp/views/api/pbn_get_by_parameter.py +++ b/src/bpp/views/api/pbn_get_by_parameter.py @@ -53,7 +53,7 @@ def post(self, request, *args, **kw): if not ni: return JsonResponse({"error": API_BRAK_PARAMETRU}) - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(request) if not uczelnia: return JsonResponse({"error": "W systemie brak obiektu Uczelnia"}) diff --git a/src/bpp/views/autocomplete/authors.py b/src/bpp/views/autocomplete/authors.py index 0373b49a6..362137cd7 100644 --- a/src/bpp/views/autocomplete/authors.py +++ b/src/bpp/views/autocomplete/authors.py @@ -1,6 +1,7 @@ """Author-related autocomplete views.""" import json +from collections import OrderedDict from braces.views import GroupRequiredMixin from dal import autocomplete @@ -13,7 +14,7 @@ from bpp.const import GR_WPROWADZANIE_DANYCH from bpp.jezyk_polski import warianty_zapisanego_nazwiska from bpp.models import Autor_Dyscyplina -from bpp.models.autor import Autor +from bpp.models.autor import Autor, Autor_Jednostka 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 @@ -28,32 +29,94 @@ class AutorAutocompleteBase( ): """Base autocomplete for authors with PBN indicators.""" + GROUP_NASZA_UCZELNIA = 1 + GROUP_HISTORYCZNIE = 2 + GROUP_ZEWNETRZNI = 3 + + GROUP_LABELS = { + GROUP_NASZA_UCZELNIA: "✅ Autorzy z naszej uczelni", + GROUP_HISTORYCZNIE: "🏛️ Autorzy powiązani historycznie z naszą uczelnią", + GROUP_ZEWNETRZNI: "🌐 Autorzy zewnętrzni", + } + def get_queryset(self): - from django.db.models import Exists, OuterRef + from django.db.models import ( + Case, + Exists, + IntegerField, + OuterRef, + Value, + When, + ) - qs = Autor.objects.select_related("tytul", "pbn_uid") + if self.q: + qs = Autor.objects.fulltext_filter(self.q) + else: + qs = Autor.objects.all() - # Annotate with information if person is from institution (OsobaZInstytucji) - qs = qs.annotate( + qs = qs.select_related("tytul", "pbn_uid").annotate( ma_osobe_z_instytucji=Exists( OsobaZInstytucji.objects.filter(personId_id=OuterRef("pbn_uid_id")) ) ) - if self.q: - return ( - Autor.objects.fulltext_filter(self.q) - .select_related("tytul", "pbn_uid") - .annotate( - ma_osobe_z_instytucji=Exists( - OsobaZInstytucji.objects.filter( - personId_id=OuterRef("pbn_uid_id") - ) - ) + uczelnia = getattr(getattr(self, "request", None), "_uczelnia", None) + if uczelnia: + ma_jednostke_w_naszej = Exists( + Autor_Jednostka.objects.filter( + autor=OuterRef("pk"), + jednostka__uczelnia=uczelnia, ) ) + qs = qs.annotate( + ma_jednostke_w_naszej=ma_jednostke_w_naszej, + grupa_uczelnia=Case( + When( + aktualna_jednostka__uczelnia=uczelnia, + then=Value(self.GROUP_NASZA_UCZELNIA), + ), + When( + ma_jednostke_w_naszej=True, + then=Value(self.GROUP_HISTORYCZNIE), + ), + default=Value(self.GROUP_ZEWNETRZNI), + output_field=IntegerField(), + ), + ).order_by("grupa_uczelnia", "nazwisko", "imiona") + return qs + def get_results(self, context): + """Group authors into optgroups by their relation to the current uczelnia.""" + uczelnia = getattr(getattr(self, "request", None), "_uczelnia", None) + if uczelnia is None: + return super().get_results(context) + + groups = OrderedDict((grp_no, []) for grp_no in self.GROUP_LABELS) + for result in context["object_list"]: + grp_no = getattr(result, "grupa_uczelnia", self.GROUP_ZEWNETRZNI) + groups.setdefault(grp_no, []).append(result) + + output = [] + for grp_no, items in groups.items(): + if not items: + continue + output.append( + { + "id": None, + "text": self.GROUP_LABELS.get(grp_no, ""), + "children": [ + { + "id": self.get_result_value(r), + "text": self.get_result_label(r), + "selected_text": self.get_selected_result_label(r), + } + for r in items + ], + } + ) + return output + def get_result_label(self, result): # Handle error objects or non-Autor instances if not isinstance(result, Autor): diff --git a/src/bpp/views/browse.py b/src/bpp/views/browse.py index 690c8cc12..f655947c7 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -23,6 +23,7 @@ from multiseek.logic import AND, OR from multiseek.util import make_field from multiseek.views import MULTISEEK_SESSION_KEY, MULTISEEK_SESSION_KEY_REMOVED +from siteblog.models import Article from bpp.models import ( Autor, @@ -42,7 +43,6 @@ ZakresLatQueryObject, ZrodloQueryObject, ) -from siteblog.models import Article logger = logging.getLogger(__name__) @@ -68,23 +68,40 @@ def get_uczelnia_context_data(uczelnia, article_slug=None): """Shared function to get context data for uczelnia view.""" context = {"object": uczelnia, "uczelnia": uczelnia} + # Multi-host: filtruj artykuły po Site bieżącej uczelni. siteblog.Article + # ma M2M `sites`; pusty M2M = artykuł widoczny wszędzie (zgodnie z + # help_textem w siteblog). on_site (CurrentSiteManager) jest strict — + # wymusza Site_id i wyklucza puste M2M — więc używamy własnego Q. + site_id = uczelnia.site_id + visible_articles = Article.objects.filter( + Q(sites=site_id) | Q(sites__isnull=True) + ).distinct() + if article_slug: - context["article"] = get_object_or_404(Article, slug=article_slug) + context["article"] = get_object_or_404(visible_articles, slug=article_slug) else: - context["news"] = Article.objects.filter(status=Article.STATUS.published)[ - :5 - ] - # Add 5 most recently updated records - context["recently_updated"] = Rekord.objects.order_by("-ostatnio_zmieniony")[ - :12 - ] - # Add 5 recent records with abstracts + context["news"] = visible_articles.filter(status=Article.STATUS.published)[:5] + + # Multi-host: rekordy z autorami z jednostek tej uczelni + jednostki_uczelni = uczelnia.jednostka_set.all() + context["recently_updated"] = ( + Rekord.objects.filter(autorzy__jednostka__in=jednostki_uczelni) + .order_by("-ostatnio_zmieniony") + .distinct()[:12] + ) + context["recent_abstracts"] = ( Wydawnictwo_Ciagle_Streszczenie.objects.exclude(streszczenie__isnull=True) .exclude(streszczenie__exact="") - .order_by("-rekord__ostatnio_zmieniony")[:5] + .filter(rekord__autorzy_set__jednostka__in=jednostki_uczelni) + .order_by("-rekord__ostatnio_zmieniony") + .distinct()[:5] + ) + context["total_rekord_count"] = ( + Rekord.objects.filter(autorzy__jednostka__in=jednostki_uczelni) + .distinct() + .count() ) - context["total_rekord_count"] = Rekord.objects.count() context["current_year"] = timezone.now().date().year return context @@ -402,17 +419,9 @@ class JednostkiView(Browser): paginate_by = 150 def get_paginate_by(self, queryset): - uczelnia = None - - if hasattr(self, "request") and self.request is not None: - uczelnia = Uczelnia.objects.get_for_request(self.request) - - if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() - + uczelnia = Uczelnia.objects.get_for_request(getattr(self, "request", None)) if uczelnia is None: return self.paginate_by - return uczelnia.ilosc_jednostek_na_strone def get_queryset(self): diff --git a/src/bpp/views/oai.py b/src/bpp/views/oai.py index 1a5772af9..2ca19b93e 100644 --- a/src/bpp/views/oai.py +++ b/src/bpp/views/oai.py @@ -87,8 +87,9 @@ def get_dc_ident(model, obj_pk): class BPPOAIDatabase: - def __init__(self, original): + def __init__(self, original, request=None): self.original = original + self.request = request def get_set(self, oai_id): if oai_id == 1: @@ -183,7 +184,7 @@ def oai_query( if from_date is not None: query = query.filter(ostatnio_zmieniony__gte=from_date) - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) if uczelnia: ukryte_statusy = uczelnia.ukryte_statusy("api") if ukryte_statusy: @@ -240,7 +241,8 @@ def get(self, request, *args, **kwargs): url = "/".join(urlparts) db = BPPOAIDatabase( - Rekord.objects.all().exclude(charakter_formalny__nazwa_w_primo="") + Rekord.objects.all().exclude(charakter_formalny__nazwa_w_primo=""), + request=request, ) oai_server = OAIServerFactory(db, FeedConfig("bpp", base_url)) return HttpResponse( diff --git a/src/bpp/views/zapytanie.py b/src/bpp/views/zapytanie.py index 70ff47042..1dfae4068 100644 --- a/src/bpp/views/zapytanie.py +++ b/src/bpp/views/zapytanie.py @@ -175,8 +175,7 @@ 'charakter_formalny.skrot = "AOR") and punkty_kbn >= 100', ), ( - "Negacja: wszystko poza artykulami z czasopism, " - "IF>=3, od 2023", + "Negacja: wszystko poza artykulami z czasopism, IF>=3, od 2023", 'charakter_formalny.skrot != "AC" and rok >= 2023 ' "and impact_factor > 3", ), @@ -194,8 +193,7 @@ ), ( "Hot & trending: cytowane >50 razy w okresie, IF>5", - "liczba_cytowan > 50 and rok >= 2020 and " - "impact_factor > 5", + "liczba_cytowan > 50 and rok >= 2020 and impact_factor > 5", ), ( "Audyt jakosci danych — artykuly 2024+ bez DOI/WWW", @@ -213,8 +211,7 @@ 'ostatnio_zmieniony >= "2025-01-01" and adnotacje != ""', ), ( - "Wielowarunkowa granica IF + zakres punktow + " - "minimum cytowan", + "Wielowarunkowa granica IF + zakres punktow + minimum cytowan", "impact_factor >= 5 and impact_factor <= 10 and " "punkty_kbn >= 100 and liczba_cytowan >= 5", ), diff --git a/src/bpp_setup_wizard/forms.py b/src/bpp_setup_wizard/forms.py index e5bf93dba..1e76d44a0 100644 --- a/src/bpp_setup_wizard/forms.py +++ b/src/bpp_setup_wizard/forms.py @@ -110,7 +110,7 @@ def clean(self): return cleaned_data - def save(self, commit=True): + def save(self, commit=True, request=None): uczelnia = super().save(commit=False) uczelnia.pbn_api_kasuj_przed_wysylka = True @@ -120,6 +120,11 @@ def save(self, commit=True): uczelnia.pbn_integracja = True uczelnia.pbn_aktualizuj_na_biezaco = True + if uczelnia.site_id is None and request is not None: + from django.contrib.sites.shortcuts import get_current_site + + uczelnia.site = get_current_site(request) + if commit: uczelnia.save() diff --git a/src/bpp_setup_wizard/steps.py b/src/bpp_setup_wizard/steps.py index 4c03e43d1..e65c724df 100644 --- a/src/bpp_setup_wizard/steps.py +++ b/src/bpp_setup_wizard/steps.py @@ -27,3 +27,10 @@ def is_complete(self) -> bool: def get_context(self, request) -> dict: return {"subtitle": "Podstawowe dane instytucji"} + + def on_complete(self, form, request): + # Multi-hosted: form needs request to auto-link uczelnia.site to + # the current Site via get_current_site(request). Without it the + # uczelnia would land without a Site FK and fail the NOT NULL + # constraint added in migration 0417. + return form.save(request=request) diff --git a/src/conftest.py b/src/conftest.py index 519575714..f411226a4 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -587,8 +587,8 @@ def constance_cache_warmed_up(db): Fixture that pre-creates constance values in the database and warms the cache to prevent constance queries during test execution. - This ensures all constance values exist in the DB before the test runs, - avoiding INSERT/UPDATE queries during the test's query assertion block. + Note: Most constance settings have been migrated to Uczelnia model fields. + This fixture now only handles remaining constance entries (if any). """ import json @@ -602,15 +602,4 @@ def constance_cache_warmed_up(db): value_json = json.dumps({"__type__": "default", "__value__": default}) Constance.objects.get_or_create(key=key, defaults={"value": value_json}) - # Warm the cache by accessing all values - _ = ( - config.UZYWAJ_PUNKTACJI_WEWNETRZNEJ, - config.POKAZUJ_INDEX_COPERNICUS, - config.POKAZUJ_PUNKTACJA_SNIP, - config.POKAZUJ_OSWIADCZENIE_KEN, - config.SKROT_WYDZIALU_W_NAZWIE_JEDNOSTKI, - config.UCZELNIA_UZYWA_WYDZIALOW, - config.GOOGLE_ANALYTICS_PROPERTY_ID, - config.GOOGLE_VERIFICATION_CODE, - ) return config diff --git a/src/crossref_bpp/views.py b/src/crossref_bpp/views.py index ba03c9d15..b5e527eaf 100644 --- a/src/crossref_bpp/views.py +++ b/src/crossref_bpp/views.py @@ -111,7 +111,7 @@ def _pobierz_dane_z_pbn(request, doi): pbn_error = None try: - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(request) if not uczelnia or not uczelnia.pbn_integracja: return None, "Integracja z PBN nieaktywna" diff --git a/src/deduplikator_autorow/admin.py b/src/deduplikator_autorow/admin.py index 407f2e8a2..b59b13949 100644 --- a/src/deduplikator_autorow/admin.py +++ b/src/deduplikator_autorow/admin.py @@ -18,6 +18,9 @@ @admin.register(NotADuplicate) class NotADuplicateAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): + def has_module_permission(self, request): + return request.user.is_superuser + list_display = [ "autor", "created_by", @@ -79,6 +82,9 @@ def get_author_last_name(self, obj): @admin.register(IgnoredScientist) class IgnoredScientistAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): + def has_module_permission(self, request): + return request.user.is_superuser + list_display = [ "get_scientist_display", "get_autor_display", @@ -136,6 +142,9 @@ def save_model(self, request, obj, form, change): @admin.register(IgnoredAuthor) class IgnoredAuthorAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): + def has_module_permission(self, request): + return request.user.is_superuser + list_display = [ "get_autor_display", "reason", @@ -172,6 +181,9 @@ def save_model(self, request, obj, form, change): @admin.register(LogScalania) class LogScalaniaAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): + def has_module_permission(self, request): + return request.user.is_superuser + list_display = [ "get_operation_icon", "get_merge_description", @@ -376,6 +388,9 @@ def has_change_permission(self, request, obj=None): @admin.register(DuplicateScanRun) class DuplicateScanRunAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): + def has_module_permission(self, request): + return request.user.is_superuser + list_display = [ "id", "status", @@ -489,6 +504,9 @@ def has_change_permission(self, request, obj=None): @admin.register(DuplicateCandidate) class DuplicateCandidateAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): + def has_module_permission(self, request): + return request.user.is_superuser + list_display = [ "id", "get_main_autor_link", diff --git a/src/deduplikator_autorow/tests/test_xlsx_orcid_and_pbn_url.py b/src/deduplikator_autorow/tests/test_xlsx_orcid_and_pbn_url.py index 33ee44e50..ed7619bb2 100644 --- a/src/deduplikator_autorow/tests/test_xlsx_orcid_and_pbn_url.py +++ b/src/deduplikator_autorow/tests/test_xlsx_orcid_and_pbn_url.py @@ -14,14 +14,20 @@ @pytest.fixture def candidate_with_orcid_and_pbn(db): """Para autorów z ORCID i PBN UID, oraz Uczelnia z pbn_api_root.""" + from django.contrib.sites.models import Site + from bpp.models import Uczelnia + site, _ = Site.objects.get_or_create( + domain="testserver", defaults={"name": "testserver"} + ) uczelnia, _ = Uczelnia.objects.get_or_create( nazwa="Test U", defaults={ "skrot": "TU", "slug": "test-u", "pbn_api_root": "https://pbn-micro-alpha.opi.org.pl", + "site": site, }, ) if not uczelnia.pbn_api_root: diff --git a/src/deduplikator_autorow/utils/export.py b/src/deduplikator_autorow/utils/export.py index 67a378117..9c2116ea7 100644 --- a/src/deduplikator_autorow/utils/export.py +++ b/src/deduplikator_autorow/utils/export.py @@ -5,21 +5,20 @@ from collections import Counter from io import BytesIO -from django.contrib.sites.models import Site from openpyxl.styles import Font from openpyxl.workbook import Workbook -from bpp.util import worksheet_columns_autosize, worksheet_create_table +from bpp.util import ( + site_url_for_request, + worksheet_columns_autosize, + worksheet_create_table, +) from deduplikator_autorow.models import DuplicateCandidate -def _get_site_domain(): - """Pobierz domenę serwisu do konstrukcji pełnych URLi.""" - try: - current_site = Site.objects.get_current() - return f"https://{current_site.domain}" - except BaseException: - return "https://bpp.iplweb.pl" +def _get_site_domain(request=None): + """Pobierz bazowy URL serwisu do konstrukcji pełnych URLi.""" + return site_url_for_request(request) def _create_pbn_url(autor): @@ -87,7 +86,7 @@ def _format_url_hyperlinks(ws, data_rows_count): cell.font = Font(color="0000FF", underline="single") -def export_duplicates_to_xlsx(): +def export_duplicates_to_xlsx(request=None): """ Eksportuje kandydatów na duplikaty do formatu XLSX. @@ -114,7 +113,7 @@ def export_duplicates_to_xlsx(): Returns: bytes: Zawartość pliku XLSX """ - site_domain = _get_site_domain() + site_domain = _get_site_domain(request) # JEDNO zapytanie zamiast tysięcy! Materializujemy raz, żeby Counter # i list-comprehension nie wykonywały dwóch iteracji po queryset diff --git a/src/deduplikator_autorow/views/export.py b/src/deduplikator_autorow/views/export.py index fcb78ec28..8f543e0c3 100644 --- a/src/deduplikator_autorow/views/export.py +++ b/src/deduplikator_autorow/views/export.py @@ -28,7 +28,7 @@ def download_duplicates_xlsx(request): """ try: # Generuj plik XLSX - xlsx_content = export_duplicates_to_xlsx() + xlsx_content = export_duplicates_to_xlsx(request) # Stwórz odpowiedź HTTP z plikiem response = HttpResponse( diff --git a/src/deduplikator_publikacji/admin.py b/src/deduplikator_publikacji/admin.py index 3f45296c8..17ecfd41f 100644 --- a/src/deduplikator_publikacji/admin.py +++ b/src/deduplikator_publikacji/admin.py @@ -5,6 +5,9 @@ @admin.register(PublicationDuplicateScanRun) class PublicationDuplicateScanRunAdmin(admin.ModelAdmin): + def has_module_permission(self, request): + return request.user.is_superuser + list_display = [ "pk", "status", @@ -31,6 +34,9 @@ class PublicationDuplicateScanRunAdmin(admin.ModelAdmin): @admin.register(PublicationDuplicateCandidate) class PublicationDuplicateCandidateAdmin(admin.ModelAdmin): + def has_module_permission(self, request): + return request.user.is_superuser + list_display = [ "pk", "original_title_short", diff --git a/src/deduplikator_zrodel/utils.py b/src/deduplikator_zrodel/utils.py index 0b99e6426..d796d5b1c 100644 --- a/src/deduplikator_zrodel/utils.py +++ b/src/deduplikator_zrodel/utils.py @@ -328,15 +328,11 @@ def policz_zrodla_z_duplikatami(): return count -def _get_site_domain(): - """Helper function to get site domain for XLSX export.""" - from django.contrib.sites.models import Site +def _get_site_domain(request=None): + """Helper function to get site URL for XLSX export.""" + from bpp.util import site_url_for_request - try: - current_site = Site.objects.get_current() - return f"https://{current_site.domain}" - except BaseException: - return "https://bpp.iplweb.pl" + return site_url_for_request(request) def _create_pbn_journal_url(pbn_uid): @@ -440,7 +436,7 @@ def _format_worksheet_urls(ws, data_rows): cell.font = Font(color="0000FF", underline="single") -def export_duplicates_to_xlsx(): +def export_duplicates_to_xlsx(request=None): """ Eksportuje wszystkie źródła z duplikatami do formatu XLSX. @@ -471,7 +467,7 @@ def export_duplicates_to_xlsx(): from bpp.util import worksheet_columns_autosize, worksheet_create_table - site_domain = _get_site_domain() + site_domain = _get_site_domain(request) # Pobierz źródła ignorowane ignored_ids = set(IgnoredSource.objects.values_list("zrodlo_id", flat=True)) diff --git a/src/deduplikator_zrodel/views.py b/src/deduplikator_zrodel/views.py index bc9412a3a..cd8a04252 100644 --- a/src/deduplikator_zrodel/views.py +++ b/src/deduplikator_zrodel/views.py @@ -302,7 +302,7 @@ def download_duplicates_xlsx(request): try: # Generuj plik XLSX - xlsx_content = export_duplicates_to_xlsx() + xlsx_content = export_duplicates_to_xlsx(request) # Stwórz odpowiedź HTTP z plikiem response = HttpResponse( diff --git a/src/django_bpp/asgi.py b/src/django_bpp/asgi.py index 77c577b50..80b90b007 100644 --- a/src/django_bpp/asgi.py +++ b/src/django_bpp/asgi.py @@ -8,12 +8,15 @@ # is populated before importing code that may import ORM models. django_asgi_app = get_asgi_application() -from channels.auth import AuthMiddlewareStack -from channels.routing import ProtocolTypeRouter, URLRouter -from channels.security.websocket import AllowedHostsOriginValidator +# Te importy muszą biec po get_asgi_application(), żeby AppRegistry +# było zainicjalizowane — channels_broadcast.routing i pbn_import.routing +# importują ORM modele. +import channels_broadcast.routing # noqa: E402 +from channels.auth import AuthMiddlewareStack # noqa: E402 +from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 +from channels.security.websocket import AllowedHostsOriginValidator # noqa: E402 -import channels_broadcast.routing -import pbn_import.routing +import pbn_import.routing # noqa: E402 websocket_urlpatterns = ( channels_broadcast.routing.websocket_urlpatterns diff --git a/src/django_bpp/menu.py b/src/django_bpp/menu.py index c03999e02..087c23935 100644 --- a/src/django_bpp/menu.py +++ b/src/django_bpp/menu.py @@ -228,7 +228,7 @@ def flt(n1, n2, v, icon_class=None): from bpp.models import Uczelnia - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(context["request"]) uzywaj_wydzialow = True if uczelnia is not None: uzywaj_wydzialow = uczelnia.uzywaj_wydzialow diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index f6b111ab5..13266f30d 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -92,6 +92,9 @@ def int_or_none(v): # Konfiguracja Django # DJANGO_BPP_HOSTNAME=(str, "localhost"), + # Multi-hosted: comma-separated lista nazw hostów (np. "u1.example,u2.example"). + # Pusta wartość = używaj DJANGO_BPP_HOSTNAME (single-host, backward compat). + DJANGO_BPP_HOSTNAMES=(str, ""), DJANGO_BPP_DB_NAME=(str, "bpp"), DJANGO_BPP_DB_USER=(str, "bpp"), DJANGO_BPP_DB_PASSWORD=(str, "password"), @@ -204,7 +207,10 @@ def int_or_none(v): os.path.join(BASE_DIR, "locale"), ] -SITE_ID = 1 # dla static-sitemaps +# SITE_ID służy jako fallback dla SiteResolutionMiddleware gdy hostname +# nie pasuje do żadnego obiektu Site. W multi-hosted ustawia się na ID +# domyślnego Site. static-sitemaps również wymaga tej wartości. +SITE_ID = env("DJANGO_BPP_SITE_ID", default=1, cast=int) USE_I18N = True USE_TZ = True @@ -292,6 +298,7 @@ def int_or_none(v): "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "bpp.middleware.SiteResolutionMiddleware", # After auth - resolves Site/Uczelnia from hostname "django_countdown.middleware.CountdownBlockingMiddleware", # After auth - needs request.user "first_run_wizard.middleware.FirstRunWizardMiddleware", # After auth middleware to have request.user "django.contrib.messages.middleware.MessageMiddleware", @@ -589,17 +596,60 @@ def autoslug_gen(): }, ] -DJANGO_BPP_HOSTNAME = env("DJANGO_BPP_HOSTNAME") +# Lista hostów obsługiwanych przez deployment. +# Konfiguracja jawnie XOR: ustaw ALBO DJANGO_BPP_HOSTNAME (single, bez +# przecinka), ALBO DJANGO_BPP_HOSTNAMES (multi-host, CSV z minimum dwoma +# hostami). Ustawienie obu = ImproperlyConfigured (intencja niejasna). +_hostname = os.environ.get("DJANGO_BPP_HOSTNAME", "").strip() +_hostnames_csv = os.environ.get("DJANGO_BPP_HOSTNAMES", "").strip() + +if _hostname and _hostnames_csv: + raise ImproperlyConfigured( + "Ustaw albo DJANGO_BPP_HOSTNAME (single host, bez przecinka), albo " + "DJANGO_BPP_HOSTNAMES (multi-host, comma-separated, minimum dwa). " + "Oba naraz są niejednoznaczne — wybierz jedno." + ) + +if _hostnames_csv: + if "," not in _hostnames_csv: + raise ImproperlyConfigured( + f"DJANGO_BPP_HOSTNAMES musi zawierać minimum dwa hosty " + f"oddzielone przecinkiem (otrzymano: {_hostnames_csv!r}). " + f"Dla single-host użyj DJANGO_BPP_HOSTNAME." + ) + DJANGO_BPP_HOSTNAMES = [h.strip() for h in _hostnames_csv.split(",") if h.strip()] + if len(DJANGO_BPP_HOSTNAMES) < 2: + raise ImproperlyConfigured( + f"DJANGO_BPP_HOSTNAMES po sparsowaniu daje mniej niż dwa hosty " + f"(otrzymano: {DJANGO_BPP_HOSTNAMES!r}). " + f"Dla single-host użyj DJANGO_BPP_HOSTNAME." + ) +elif _hostname: + if "," in _hostname: + raise ImproperlyConfigured( + f"DJANGO_BPP_HOSTNAME nie może zawierać przecinka " + f"(otrzymano: {_hostname!r}). Dla multi-host użyj " + f"DJANGO_BPP_HOSTNAMES." + ) + DJANGO_BPP_HOSTNAMES = [_hostname] +else: + # Żadne nie ustawione — fallback do default ("localhost") z env declaration + DJANGO_BPP_HOSTNAMES = [env("DJANGO_BPP_HOSTNAME")] + +# Canonical/primary hostname — pierwszy z listy. Używany m.in. przez +# Rollbar jako identyfikacja deployment'u (oznaczenie instalacji); per-request +# vhost gdzie padło zgłoszenie ma osobny klucz w extra_data middleware'u. +DJANGO_BPP_HOSTNAME = DJANGO_BPP_HOSTNAMES[0] ALLOWED_HOSTS = [ "127.0.0.1", "appserver", "appserver:8000", "test.unexistenttld", - DJANGO_BPP_HOSTNAME, + *DJANGO_BPP_HOSTNAMES, ] -CSRF_TRUSTED_ORIGINS = ["https://" + DJANGO_BPP_HOSTNAME] +CSRF_TRUSTED_ORIGINS = ["https://" + h for h in DJANGO_BPP_HOSTNAMES] # Optional extra CSRF origins for dev with non-standard ports # (comma-separated, e.g. "https://bpp.localnet:10443,https://localhost:10443") @@ -1261,13 +1311,20 @@ def iter_namespace(ns_pkg): # # django-static-sitemaps +# W trybie multi-hosted można wyłączyć sitemaps ustawiając +# DJANGO_BPP_ENABLE_SITEMAPS=False, ponieważ static-sitemaps +# generuje sitemapę tylko dla jednej domeny (SITE_ID). # -STATICSITEMAPS_ROOT_SITEMAP = "django_bpp.sitemaps.django_bpp_sitemaps" - -STATICSITEMAPS_REFRESH_AFTER = 24 * 60 +ENABLE_SITEMAPS = env("DJANGO_BPP_ENABLE_SITEMAPS", default=True, cast=bool) -STATICSITEMAPS_ROOT_DIR = os.path.relpath(STATIC_ROOT, os.getcwd()) +if ENABLE_SITEMAPS: + STATICSITEMAPS_ROOT_SITEMAP = "django_bpp.sitemaps.django_bpp_sitemaps" + STATICSITEMAPS_REFRESH_AFTER = 24 * 60 + STATICSITEMAPS_ROOT_DIR = os.path.relpath(STATIC_ROOT, os.getcwd()) +else: + if "static_sitemaps" in INSTALLED_APPS: + INSTALLED_APPS.remove("static_sitemaps") # # "Audyt" bezpieczeństwa @@ -1445,93 +1502,11 @@ def iter_namespace(ns_pkg): CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend" CONSTANCE_DATABASE_CACHE_BACKEND = "constance_cache" -CONSTANCE_CONFIG = { - # Punktacja - "UZYWAJ_PUNKTACJI_WEWNETRZNEJ": ( - env("DJANGO_BPP_UZYWAJ_PUNKTACJI_WEWNETRZNEJ"), - "Używaj punktacji wewnętrznej w systemie", - bool, - ), - "POKAZUJ_INDEX_COPERNICUS": ( - True, - "Pokazuj pole Index Copernicus w formularzach", - bool, - ), - "POKAZUJ_PUNKTACJA_SNIP": ( - True, - "Pokazuj pole punktacji SNIP w formularzach", - bool, - ), - # Funkcjonalność - "POKAZUJ_OSWIADCZENIE_KEN": ( - env("DJANGO_BPP_POKAZUJ_OSWIADCZENIE_KEN"), - "Pokazuj opcję oświadczenia KEN", - bool, - ), - # Struktura uczelni - "SKROT_WYDZIALU_W_NAZWIE_JEDNOSTKI": ( - env("DJANGO_BPP_SKROT_WYDZIALU_W_NAZWIE_JEDNOSTKI"), - "Wyświetlaj skrót wydziału w nazwie jednostki", - bool, - ), - "UCZELNIA_UZYWA_WYDZIALOW": ( - env("DJANGO_BPP_UCZELNIA_UZYWA_WYDZIALOW"), - "Uczelnia używa struktury wydziałowej", - bool, - ), - # Integracje Google - "GOOGLE_ANALYTICS_PROPERTY_ID": ( - env("DJANGO_BPP_GOOGLE_ANALYTICS_PROPERTY_ID"), - "Google Analytics Property ID (np. UA-XXXXXXXX-X lub G-XXXXXXXXXX)", - str, - ), - "GOOGLE_VERIFICATION_CODE": ( - env("DJANGO_BPP_GOOGLE_VERIFICATION_CODE"), - "Kod weryfikacyjny Google Search Console", - str, - ), - # Wydruk - marginesy - "WYDRUK_MARGINES_GORA": ( - "2cm", - "Margines górny wydruku (np. 2cm, 20mm, 0.8in)", - str, - ), - "WYDRUK_MARGINES_DOL": ( - "2cm", - "Margines dolny wydruku (np. 2cm, 20mm, 0.8in)", - str, - ), - "WYDRUK_MARGINES_LEWO": ( - "2cm", - "Margines lewy wydruku (np. 2cm, 20mm, 0.8in)", - str, - ), - "WYDRUK_MARGINES_PRAWO": ( - "2cm", - "Margines prawy wydruku (np. 2cm, 20mm, 0.8in)", - str, - ), -} - -CONSTANCE_CONFIG_FIELDSETS = { - "Punktacja": ( - "UZYWAJ_PUNKTACJI_WEWNETRZNEJ", - "POKAZUJ_INDEX_COPERNICUS", - "POKAZUJ_PUNKTACJA_SNIP", - ), - "Funkcjonalność": ("POKAZUJ_OSWIADCZENIE_KEN",), - "Struktura uczelni": ( - "SKROT_WYDZIALU_W_NAZWIE_JEDNOSTKI", - "UCZELNIA_UZYWA_WYDZIALOW", - ), - "Integracje Google": ( - "GOOGLE_ANALYTICS_PROPERTY_ID", - "GOOGLE_VERIFICATION_CODE", - ), - "Wydruk": ( - "WYDRUK_MARGINES_GORA", - "WYDRUK_MARGINES_DOL", - "WYDRUK_MARGINES_LEWO", - "WYDRUK_MARGINES_PRAWO", - ), -} +# Ustawienia per-uczelnia przeniesione do modelu Uczelnia: +# UZYWAJ_PUNKTACJI_WEWNETRZNEJ, POKAZUJ_INDEX_COPERNICUS, POKAZUJ_PUNKTACJA_SNIP, +# POKAZUJ_OSWIADCZENIE_KEN, SKROT_WYDZIALU_W_NAZWIE_JEDNOSTKI, +# UCZELNIA_UZYWA_WYDZIALOW, GOOGLE_ANALYTICS_PROPERTY_ID, +# GOOGLE_VERIFICATION_CODE, WYDRUK_MARGINES_* +# Puste CONSTANCE_CONFIG zachowane dla backward compat z django-constance. +CONSTANCE_CONFIG = {} +CONSTANCE_CONFIG_FIELDSETS = {} diff --git a/src/django_bpp/settings/local.py b/src/django_bpp/settings/local.py index 51718ae7a..358b8c3c6 100644 --- a/src/django_bpp/settings/local.py +++ b/src/django_bpp/settings/local.py @@ -15,6 +15,7 @@ def setenv_default(varname, default_value): from .base import * # noqa from .base import ( # noqa DATABASES, + DJANGO_BPP_HOSTNAMES, INSTALLED_APPS, MIDDLEWARE, REDIS_HOST, @@ -46,7 +47,7 @@ def setenv_default(varname, default_value): "mac-mini", "publikacje-test", "test.unexistenttld", - env("DJANGO_BPP_HOSTNAME"), # noqa + *DJANGO_BPP_HOSTNAMES, ] CELERY_ALWAYS_EAGER = False diff --git a/src/django_bpp/settings/production.py b/src/django_bpp/settings/production.py index 4647ad07e..b5fd22bcc 100644 --- a/src/django_bpp/settings/production.py +++ b/src/django_bpp/settings/production.py @@ -3,6 +3,7 @@ from .base import * # noqa from .base import ( # noqa DJANGO_BPP_ENABLE_TEST_CONFIGURATION, + DJANGO_BPP_HOSTNAMES, INSTALLED_APPS, MIDDLEWARE, REDIS_HOST, @@ -116,7 +117,7 @@ def should_minify(self, request, response): "127.0.0.1", "appserver", "appserver:8000", - env("DJANGO_BPP_HOSTNAME"), # noqa + *DJANGO_BPP_HOSTNAMES, ] SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/src/django_bpp/urls.py b/src/django_bpp/urls.py index 06bb742f8..ca84555ca 100644 --- a/src/django_bpp/urls.py +++ b/src/django_bpp/urls.py @@ -352,7 +352,11 @@ def protected_media_serve(request, path, document_root=None): # cache_page(7*24*3600)(sitemaps_views.sitemap), {'sitemaps': django_bpp_sitemaps}, # name='sitemaps'), # url(r'^sitemap\.xml', include('static_sitemaps.urls')), - path("", include("static_sitemaps.urls")), + *( + [path("", include("static_sitemaps.urls"))] + if getattr(settings, "ENABLE_SITEMAPS", True) + else [] + ), url(r"", include("webmaster_verification.urls")), url( r"^global-nav-redir/(?P.+)/$", diff --git a/src/ewaluacja2021/management/commands/przelicz_liczbe_n_dla_uczelni.py b/src/ewaluacja2021/management/commands/przelicz_liczbe_n_dla_uczelni.py index 20055edb5..7bd4853a1 100644 --- a/src/ewaluacja2021/management/commands/przelicz_liczbe_n_dla_uczelni.py +++ b/src/ewaluacja2021/management/commands/przelicz_liczbe_n_dla_uczelni.py @@ -7,5 +7,20 @@ class Command(BaseCommand): """Wymusza przeliczenie liczby N dla uczelni""" + def add_arguments(self, parser): + super().add_arguments(parser) + parser.add_argument( + "--uczelnia-id", + type=int, + default=None, + help="ID uczelni (domyślnie: pierwsza uczelnia w bazie)", + ) + def handle(self, *args, **options): - oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=Uczelnia.objects.get_default()) + uczelnia_id = options.get("uczelnia_id") + if uczelnia_id: + uczelnia = Uczelnia.objects.get(pk=uczelnia_id) + else: + uczelnia = Uczelnia.objects.get_default() + + oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) diff --git a/src/ewaluacja2021/util.py b/src/ewaluacja2021/util.py index 59e201f5d..0ac8f88c7 100644 --- a/src/ewaluacja2021/util.py +++ b/src/ewaluacja2021/util.py @@ -5,7 +5,6 @@ from typing import Any import openpyxl.worksheet.worksheet -from django.contrib.sites.models import Site from django.utils.functional import cached_property from openpyxl.utils import get_column_letter from openpyxl.worksheet.table import TableColumn @@ -30,7 +29,7 @@ class SHUFFLE_TYPE(Enum): RANDOM = 4 -import random +import random # noqa: E402 def shuffle_array( @@ -45,19 +44,19 @@ def shuffle_array( i = random.randint(1, 3) if i == SHUFFLE_TYPE.BEGIN: - for a in range(no_shuffles): + for _ in range(no_shuffles): random.shuffle(first) elif i == SHUFFLE_TYPE.MIDDLE: - for a in range(no_shuffles): + for _ in range(no_shuffles): random.shuffle(second) elif i == SHUFFLE_TYPE.END: - for a in range(no_shuffles): + for _ in range(no_shuffles): random.shuffle(third) return first + second + third -def output_table_to_xlsx( +def output_table_to_xlsx( # noqa: C901 # builder funkcja: opcjonalne kolumny i formatowanie scalone w jednej procedurze ws: openpyxl.worksheet.worksheet.Worksheet, title: str, headers: list[str], @@ -97,7 +96,12 @@ def output_table_to_xlsx( first_table_row = ws.max_row - site_name = Site.objects.first().domain + # CLI/Celery context — brak requestu. Helper fallbackuje do + # Uczelnia.objects.get_default().site lub Site.objects.first(). + from bpp.util import site_url_for_request + + site_url = site_url_for_request() + site_name = site_url.removeprefix("https://").removeprefix("http://") url = first_column_url.format(site_name=site_name) autor_url = f"https://{site_name}/bpp/autor/" for row in dataset: @@ -148,7 +152,7 @@ def output_table_to_xlsx( ws.column_dimensions[letter].bestFit = True dont_resize_those_columns = [] - for ncol, col in enumerate(ws.columns): + for ncol, _ in enumerate(ws.columns): if headers[ncol] in totals: dont_resize_those_columns.append(ncol) @@ -250,13 +254,13 @@ def float_or_string_or_int_or_none_to_decimal(i, decimal_places=4): raise NotImplementedError(f"Type {type(i)} not supported.") -import os -import zipfile +import os # noqa: E402 +import zipfile # noqa: E402 def zipdir(path, ziph): # https://stackoverflow.com/a/1855118/401516 - for root, dirs, files in os.walk(path): + for root, _, files in os.walk(path): for file in files: ziph.write( os.path.join(root, file), diff --git a/src/ewaluacja2021/views.py b/src/ewaluacja2021/views.py index dd24329f1..26dcd7588 100644 --- a/src/ewaluacja2021/views.py +++ b/src/ewaluacja2021/views.py @@ -76,7 +76,7 @@ class ListaRaporto3N(GroupRequiredMixin, generic.ListView): def get(self, request, *args, **kwargs): if request.GET.get("przelicz") == "1" and request.user.is_staff: oblicz_liczby_n_dla_ewaluacji_2022_2025( - uczelnia=Uczelnia.objects.get_default() + uczelnia=Uczelnia.objects.get_for_request(request) ) messages.info( request, diff --git a/src/ewaluacja_liczba_n/excel_export.py b/src/ewaluacja_liczba_n/excel_export.py index 89a85c187..529d6e0f0 100644 --- a/src/ewaluacja_liczba_n/excel_export.py +++ b/src/ewaluacja_liczba_n/excel_export.py @@ -139,7 +139,7 @@ def get_filename(self) -> str: def export(self, request) -> HttpResponse: """Generate and return the Excel export response.""" - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(request) wb = Workbook() # Sheet 1: Summary of Liczba N for institution diff --git a/src/ewaluacja_liczba_n/management/commands/przelicz_n.py b/src/ewaluacja_liczba_n/management/commands/przelicz_n.py index d9db4467b..7f8788f56 100644 --- a/src/ewaluacja_liczba_n/management/commands/przelicz_n.py +++ b/src/ewaluacja_liczba_n/management/commands/przelicz_n.py @@ -7,7 +7,22 @@ class Command(BaseCommand): """Wymusza przeliczenie liczby N dla uczelni z użyciem nowej aplikacji ewaluacja_liczba_n""" + def add_arguments(self, parser): + super().add_arguments(parser) + parser.add_argument( + "--uczelnia-id", + type=int, + default=None, + help="ID uczelni (domyślnie: pierwsza uczelnia w bazie)", + ) + def handle(self, *args, **options): + uczelnia_id = options.get("uczelnia_id") + if uczelnia_id: + uczelnia = Uczelnia.objects.get(pk=uczelnia_id) + else: + uczelnia = Uczelnia.objects.get_default() + self.stdout.write("Przeliczam liczby N dla uczelni...") - oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=Uczelnia.objects.get_default()) + oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) self.stdout.write(self.style.SUCCESS("Przeliczono liczby N pomyślnie!")) diff --git a/src/ewaluacja_liczba_n/views/index.py b/src/ewaluacja_liczba_n/views/index.py index 8bfa9ac35..f0b019e09 100644 --- a/src/ewaluacja_liczba_n/views/index.py +++ b/src/ewaluacja_liczba_n/views/index.py @@ -28,7 +28,7 @@ class LiczbaNIndexView(GroupRequiredMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) # Pobierz wszystkie dane liczby N dla uczelni (średnia z 2022-2025) wszystkie_liczby_n = ( @@ -96,7 +96,7 @@ class ObliczLiczbeNView(GroupRequiredMixin, View): group_required = GR_WPROWADZANIE_DANYCH def post(self, request, *args, **kwargs): - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(request) try: oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia) diff --git a/src/ewaluacja_metryki/management/commands/oblicz_metryki.py b/src/ewaluacja_metryki/management/commands/oblicz_metryki.py index 6f890e590..4cd0b00cf 100644 --- a/src/ewaluacja_metryki/management/commands/oblicz_metryki.py +++ b/src/ewaluacja_metryki/management/commands/oblicz_metryki.py @@ -53,11 +53,18 @@ def add_arguments(self, parser): choices=["N", "D", "B", "Z", " "], default=["N", "D", "B", "Z", " "], help=( - "Rodzaje autorów do przetworzenia (N=pracownik, B=pracownik badawczy, D=doktorant, " - "Z=inny zatrudniony, ' '=brak danych). " - "Domyślnie: wszystkie" + "Rodzaje autorów do przetworzenia " + "(N=pracownik, B=pracownik badawczy, " + "D=doktorant, Z=inny zatrudniony, " + "' '=brak danych). Domyślnie: wszystkie" ), ) + parser.add_argument( + "--uczelnia-id", + type=int, + default=None, + help="ID uczelni (domyślnie: pierwsza uczelnia w bazie)", + ) def handle(self, *args, **options): rok_min = options["rok_min"] @@ -67,13 +74,18 @@ def handle(self, *args, **options): bez_liczby_n = options["bez_liczby_n"] rodzaje_autora = options.get("rodzaje_autora", ["N", "D", "B", "Z", " "]) + uczelnia_id = options.get("uczelnia_id") + if uczelnia_id: + uczelnia = Uczelnia.objects.get(pk=uczelnia_id) + else: + uczelnia = Uczelnia.objects.get_default() + # Krok 1: Przelicz liczby N, chyba że pominięto if not bez_liczby_n: self.stdout.write( self.style.WARNING("Krok 1/2: Przeliczanie liczby N dla uczelni...") ) try: - uczelnia = Uczelnia.objects.get_default() oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) self.stdout.write( self.style.SUCCESS("✓ Przeliczono liczby N pomyślnie") diff --git a/src/ewaluacja_metryki/tasks.py b/src/ewaluacja_metryki/tasks.py index 8bc32d62c..d86c1d1b5 100644 --- a/src/ewaluacja_metryki/tasks.py +++ b/src/ewaluacja_metryki/tasks.py @@ -183,6 +183,7 @@ def generuj_metryki_task_parallel( nadpisz=True, przelicz_liczbe_n=True, rodzaje_autora=None, + uczelnia_id=None, ): """ Celery task do równoległego generowania metryk ewaluacyjnych. @@ -212,7 +213,11 @@ def generuj_metryki_task_parallel( status.ostatni_komunikat = "Przeliczanie liczby N..." status.save() - uczelnia = Uczelnia.objects.get_default() + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) + if uczelnia_id + else Uczelnia.objects.get_default() + ) oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) logger.info("Przeliczono liczby N pomyślnie") @@ -306,6 +311,7 @@ def generuj_metryki_task( nadpisz=True, przelicz_liczbe_n=True, rodzaje_autora=None, + uczelnia_id=None, ): """ Celery task do generowania metryk ewaluacyjnych. @@ -334,7 +340,11 @@ def generuj_metryki_task( status.ostatni_komunikat = "Przeliczanie liczby N..." status.save() - uczelnia = Uczelnia.objects.get_default() + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) + if uczelnia_id + else Uczelnia.objects.get_default() + ) oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) logger.info("Przeliczono liczby N pomyślnie") diff --git a/src/ewaluacja_metryki/views/export.py b/src/ewaluacja_metryki/views/export.py index 569c1817c..3e37fccd3 100644 --- a/src/ewaluacja_metryki/views/export.py +++ b/src/ewaluacja_metryki/views/export.py @@ -242,11 +242,11 @@ def _apply_sorting_to_queryset(self, queryset, request): return queryset.order_by(*sort_mapping[sort]) return queryset.order_by(sort) - def _determine_visible_columns(self): + def _determine_visible_columns(self, request): """Determine which columns should be visible in export.""" from bpp.models import Dyscyplina_Naukowa - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(request) uzywa_wydzialow = uczelnia.uzywaj_wydzialow if uczelnia else False wszystkie_dyscypliny = Dyscyplina_Naukowa.objects.filter( @@ -614,7 +614,7 @@ def get(self, request): queryset = self._apply_sorting_to_queryset(queryset, request) # Determine visible columns - visible_columns = self._determine_visible_columns() + visible_columns = self._determine_visible_columns(request) # Create and write headers headers = self._create_headers(visible_columns) diff --git a/src/ewaluacja_metryki/views/list.py b/src/ewaluacja_metryki/views/list.py index 1f899bd5a..a9f30e054 100644 --- a/src/ewaluacja_metryki/views/list.py +++ b/src/ewaluacja_metryki/views/list.py @@ -165,7 +165,7 @@ def _get_jednostki_wydzialy_context(self): context = {} # Sprawdź czy uczelnia używa wydziałów - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) context["uzywa_wydzialow"] = uczelnia.uzywaj_wydzialow if uczelnia else False # Jeśli wydzial jest wybrany, filtruj jednostki tylko z tego wydziału diff --git a/src/fixtures/conftest_browser.py b/src/fixtures/conftest_browser.py index 9487a8cb6..f05e98d82 100644 --- a/src/fixtures/conftest_browser.py +++ b/src/fixtures/conftest_browser.py @@ -2,7 +2,6 @@ import pytest from django.apps import apps -from django.core.exceptions import ImproperlyConfigured from django_webtest import DjangoTestApp try: diff --git a/src/fixtures/conftest_models.py b/src/fixtures/conftest_models.py index 9321c07e3..b54457444 100644 --- a/src/fixtures/conftest_models.py +++ b/src/fixtures/conftest_models.py @@ -23,9 +23,14 @@ def rok(): @pytest.fixture(scope="function") def uczelnia(db): + from django.contrib.sites.models import Site + + site, _ = Site.objects.get_or_create( + domain="testserver", defaults={"name": "testserver"} + ) return Uczelnia.objects.get_or_create( skrot="TE", - nazwa="Testowa uczelnia", + defaults={"nazwa": "Testowa uczelnia", "site": site}, )[0] diff --git a/src/fixtures/conftest_multisite.py b/src/fixtures/conftest_multisite.py new file mode 100644 index 000000000..788e216d4 --- /dev/null +++ b/src/fixtures/conftest_multisite.py @@ -0,0 +1,182 @@ +"""Fixtures for multi-site (multi-hosted) testing. + +Provides two universities (uczelnie) with linked Sites, staff users +with per-site access, and helper utilities for simulating requests +to different domains. +""" + +import pytest +from django.contrib.sites.models import Site +from django.test import RequestFactory +from model_bakery import baker + +from bpp.models import BppUser, Jednostka, Uczelnia, Wydzial + + +@pytest.fixture +def site1(db): + """Site for the first university.""" + site, _ = Site.objects.update_or_create( + pk=1, + defaults={"domain": "uczelnia1.localhost", "name": "Uczelnia 1"}, + ) + return site + + +@pytest.fixture +def site2(db): + """Site for the second university.""" + return Site.objects.create(domain="uczelnia2.localhost", name="Uczelnia 2") + + +@pytest.fixture +def uczelnia1(site1): + """First university linked to site1.""" + uczelnia, _ = Uczelnia.objects.get_or_create( + skrot="U1", + defaults={"nazwa": "Uczelnia Pierwsza", "site": site1}, + ) + if uczelnia.site != site1: + uczelnia.site = site1 + uczelnia.save(update_fields=["site"]) + return uczelnia + + +@pytest.fixture +def uczelnia2(site2): + """Second university linked to site2.""" + return Uczelnia.objects.create(skrot="U2", nazwa="Uczelnia Druga", site=site2) + + +@pytest.fixture +def wydzial_uczelnia1(uczelnia1): + """Faculty belonging to uczelnia1.""" + return Wydzial.objects.create( + uczelnia=uczelnia1, skrot="W1-U1", nazwa="Wydział Pierwszy U1" + ) + + +@pytest.fixture +def wydzial_uczelnia2(uczelnia2): + """Faculty belonging to uczelnia2.""" + return Wydzial.objects.create( + uczelnia=uczelnia2, skrot="W1-U2", nazwa="Wydział Pierwszy U2" + ) + + +@pytest.fixture +def jednostka_uczelnia1(wydzial_uczelnia1): + """Unit belonging to uczelnia1.""" + return Jednostka.objects.create( + uczelnia=wydzial_uczelnia1.uczelnia, + wydzial=wydzial_uczelnia1, + skrot="J1-U1", + nazwa="Jednostka Pierwsza U1", + ) + + +@pytest.fixture +def jednostka_uczelnia2(wydzial_uczelnia2): + """Unit belonging to uczelnia2.""" + return Jednostka.objects.create( + uczelnia=wydzial_uczelnia2.uczelnia, + wydzial=wydzial_uczelnia2, + skrot="J1-U2", + nazwa="Jednostka Pierwsza U2", + ) + + +@pytest.fixture +def autor_uczelnia1(jednostka_uczelnia1, tytuly): + """Author affiliated with uczelnia1.""" + autor = baker.make( + "bpp.Autor", + imiona="Jan", + nazwisko="Testowy1", + aktualna_jednostka=jednostka_uczelnia1, + ) + baker.make( + "bpp.Autor_Jednostka", + autor=autor, + jednostka=jednostka_uczelnia1, + ) + return autor + + +@pytest.fixture +def autor_uczelnia2(jednostka_uczelnia2, tytuly): + """Author affiliated with uczelnia2.""" + autor = baker.make( + "bpp.Autor", + imiona="Anna", + nazwisko="Testowa2", + aktualna_jednostka=jednostka_uczelnia2, + ) + baker.make( + "bpp.Autor_Jednostka", + autor=autor, + jednostka=jednostka_uczelnia2, + ) + return autor + + +@pytest.fixture +def staff_user_uczelnia1(uczelnia1, db): + """Staff user with access only to uczelnia1.""" + user = BppUser.objects.create_user( + username="staff_u1", + password="test12345", + is_staff=True, + ) + user.accessible_uczelnie.add(uczelnia1) + return user + + +@pytest.fixture +def staff_user_uczelnia2(uczelnia2, db): + """Staff user with access only to uczelnia2.""" + user = BppUser.objects.create_user( + username="staff_u2", + password="test12345", + is_staff=True, + ) + user.accessible_uczelnie.add(uczelnia2) + return user + + +@pytest.fixture +def superuser_multisite(db): + """Superuser — has access to all sites implicitly.""" + return BppUser.objects.create_superuser( + username="super_multi", + password="test12345", + ) + + +def make_request_for_site(site, path="/", user=None): + """Create a request with HTTP_HOST set to the site's domain. + + Args: + site: Site object whose domain to use as hostname. + path: URL path for the request. + user: Optional user to attach to request. + + Returns: + HttpRequest with site resolution attributes set. + """ + from bpp.middleware import SiteResolutionMiddleware + + factory = RequestFactory() + request = factory.get(path, HTTP_HOST=site.domain) + + if user is not None: + request.user = user + else: + from django.contrib.auth.models import AnonymousUser + + request.user = AnonymousUser() + + # Run middleware to set request.site and request._uczelnia + mw = SiteResolutionMiddleware(lambda r: None) + mw.process_request(request) + return request diff --git a/src/fixtures/playwright_fixtures.py b/src/fixtures/playwright_fixtures.py index 05e7efdcd..4620d4001 100644 --- a/src/fixtures/playwright_fixtures.py +++ b/src/fixtures/playwright_fixtures.py @@ -73,6 +73,7 @@ def preauth_asgi_page(preauth_page: Page, channels_live_server, transactional_db import time from channels_broadcast.core import get_channel_name_for_user + from django_bpp.playwright_util import ( wait_for_channel_subscription, wait_for_page_load, @@ -141,6 +142,7 @@ def preauth_asgi_page_per_test( import time from channels_broadcast.core import get_channel_name_for_user + from django_bpp.playwright_util import ( wait_for_channel_subscription, wait_for_page_load, diff --git a/src/import_dyscyplin/tasks.py b/src/import_dyscyplin/tasks.py index 8f7a58f9e..72171bd55 100644 --- a/src/import_dyscyplin/tasks.py +++ b/src/import_dyscyplin/tasks.py @@ -1,12 +1,12 @@ import traceback from celery.utils.log import get_task_logger +from channels_broadcast.models import Notification from django.db import transaction from django.urls import reverse from django_bpp.celery_tasks import app from import_dyscyplin.models import Import_Dyscyplin -from channels_broadcast.models import Notification logger = get_task_logger("django") diff --git a/src/import_dyscyplin/tests/test_tasks.py b/src/import_dyscyplin/tests/test_tasks.py index 8e1625d5a..470f3eb3b 100644 --- a/src/import_dyscyplin/tests/test_tasks.py +++ b/src/import_dyscyplin/tests/test_tasks.py @@ -1,12 +1,12 @@ +import channels_broadcast.core as notifications_core import pytest +from channels_broadcast.models import Notification from django.core.files.base import ContentFile from django.db import connection, transaction from django.db.models import Max from django.test.utils import CaptureQueriesContext -import channels_broadcast.core as notifications_core from bpp.models import Autor_Dyscyplina -from channels_broadcast.models import Notification from import_dyscyplin.models import Import_Dyscyplin from import_dyscyplin.tasks import ( integruj_import_dyscyplin, diff --git a/src/import_dyscyplin/views.py b/src/import_dyscyplin/views.py index dd9fc1422..f72bc64d0 100644 --- a/src/import_dyscyplin/views.py +++ b/src/import_dyscyplin/views.py @@ -1,6 +1,7 @@ from braces.views import GroupRequiredMixin, JSONResponseMixin from celery import uuid from celery.result import AsyncResult +from channels_broadcast.mixins import ChannelSubscriberSingleObjectMixin from django.contrib import messages from django.db import transaction from django.db.models import Q @@ -21,7 +22,6 @@ przeanalizuj_import_dyscyplin, stworz_kolumny, ) -from channels_broadcast.mixins import ChannelSubscriberSingleObjectMixin from .forms import Import_DyscyplinForm from .models import Import_Dyscyplin diff --git a/src/importer_publikacji/providers/pbn.py b/src/importer_publikacji/providers/pbn.py index 5031f4a76..ee1a7dbcd 100644 --- a/src/importer_publikacji/providers/pbn.py +++ b/src/importer_publikacji/providers/pbn.py @@ -33,12 +33,13 @@ } -def _get_pbn_client(): +def _get_pbn_client(uczelnia=None): from bpp.models import Uczelnia from pbn_api.client import PBNClient from pbn_api.client.transport import RequestsTransport - uczelnia = Uczelnia.objects.get_default() + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() if not uczelnia or not all( [ uczelnia.pbn_app_name, diff --git a/src/importer_publikacji/views/publikacja.py b/src/importer_publikacji/views/publikacja.py index bacb26d92..5d4875bab 100644 --- a/src/importer_publikacji/views/publikacja.py +++ b/src/importer_publikacji/views/publikacja.py @@ -107,7 +107,7 @@ def _create_wydawnictwo_zwarte(session, common_fields, normalized_data): return Wydawnictwo_Zwarte.objects.create(**common_fields) -def _add_authors_to_record(session, record): +def _add_authors_to_record(session, record, uczelnia=None): """Dodaj dopasowanych autorów do rekordu.""" authors = ( session.authors.exclude(match_status=(ImportedAuthor.MatchStatus.UNMATCHED)) @@ -121,7 +121,8 @@ def _add_authors_to_record(session, record): typ_aut = Typ_Odpowiedzialnosci.objects.get(skrot="aut.") - uczelnia = Uczelnia.objects.get_default() + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() obca = uczelnia.obca_jednostka if uczelnia else None for imported_author in authors: diff --git a/src/komparator_pbn/views.py b/src/komparator_pbn/views.py index 058e965f8..540f04e2a 100644 --- a/src/komparator_pbn/views.py +++ b/src/komparator_pbn/views.py @@ -35,7 +35,7 @@ def get_context_data(self, **kwargs): from bpp.models.system import Charakter_Formalny from bpp.models.uczelnia import Uczelnia - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) # Get charaktery formalne that should be exported to PBN charaktery_wysylane_do_pbn = list( @@ -271,7 +271,7 @@ def get_queryset(self): # Apply the same filtering logic as in main view for "not sent" records from bpp.models.uczelnia import Uczelnia - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) # Exclude PK=0 records if university setting is enabled if uczelnia and uczelnia.pbn_api_nie_wysylaj_prac_bez_pk: @@ -305,7 +305,7 @@ def get_queryset(self): # Apply the same filtering logic as in main view for "not sent" records from bpp.models.uczelnia import Uczelnia - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) # Exclude PK=0 records if university setting is enabled if uczelnia and uczelnia.pbn_api_nie_wysylaj_prac_bez_pk: diff --git a/src/long_running/notification_mixins.py b/src/long_running/notification_mixins.py index 9c2cf07d8..6decb14bc 100644 --- a/src/long_running/notification_mixins.py +++ b/src/long_running/notification_mixins.py @@ -1,8 +1,7 @@ -from django.contrib.messages import constants -from django.utils.functional import cached_property - from channels_broadcast import core as notifications_core from channels_broadcast.models import Notification +from django.contrib.messages import constants +from django.utils.functional import cached_property class NullNotificationMixin: diff --git a/src/miniblog/migrations/0004_migrate_to_siteblog_and_delete.py b/src/miniblog/migrations/0004_migrate_to_siteblog_and_delete.py index 2cb8ef152..a20a25202 100644 --- a/src/miniblog/migrations/0004_migrate_to_siteblog_and_delete.py +++ b/src/miniblog/migrations/0004_migrate_to_siteblog_and_delete.py @@ -17,7 +17,6 @@ """ from django.db import migrations - SQL_COPY_ROWS = """ INSERT INTO siteblog_article ( id, created, modified, status, status_changed, diff --git a/src/orcid_integration/backends.py b/src/orcid_integration/backends.py index 185519b92..3308e8164 100644 --- a/src/orcid_integration/backends.py +++ b/src/orcid_integration/backends.py @@ -45,7 +45,7 @@ def authenticate(self, request, orcid_id=None, **kwargs): ) return None - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(request) if uczelnia and uczelnia.orcid_tylko_dla_pracownikow: if not (user.is_staff or user.is_superuser): logger.info( diff --git a/src/oswiadczenia/tasks.py b/src/oswiadczenia/tasks.py index 2ada5b56e..b08e4c216 100644 --- a/src/oswiadczenia/tasks.py +++ b/src/oswiadczenia/tasks.py @@ -536,11 +536,12 @@ def _generate_zip_output(task, declarations, uczelnia): @shared_task(bind=True) -def generate_oswiadczenia_zip(self, task_id: int): +def generate_oswiadczenia_zip(self, task_id: int, uczelnia_id=None): """Generate ZIP or single file with declarations. Args: task_id: ID of OswiadczeniaExportTask record. + uczelnia_id: ID of Uczelnia (defaults to get_default()). Returns: dict with status and task_id. @@ -555,7 +556,11 @@ def generate_oswiadczenia_zip(self, task_id: int): try: queryset = build_queryset_for_task(task) - uczelnia = Uczelnia.objects.get_default() + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) + if uczelnia_id + else Uczelnia.objects.get_default() + ) declarations = build_declarations_list(queryset, uczelnia) task.total_items = len(declarations) diff --git a/src/pbn_api/admin/osoba_z_instytycji.py b/src/pbn_api/admin/osoba_z_instytycji.py index 6f65be14d..031637c36 100644 --- a/src/pbn_api/admin/osoba_z_instytycji.py +++ b/src/pbn_api/admin/osoba_z_instytycji.py @@ -1,11 +1,15 @@ from django.contrib import admin +from bpp.admin.helpers.site_filtered import SiteFilteredAdminMixin from pbn_api.admin.mixins import ReadOnlyListChangeFormAdminMixin from pbn_api.models import OsobaZInstytucji @admin.register(OsobaZInstytucji) -class OsobaZInstytucjiAdmin(ReadOnlyListChangeFormAdminMixin, admin.ModelAdmin): +class OsobaZInstytucjiAdmin( + SiteFilteredAdminMixin, ReadOnlyListChangeFormAdminMixin, admin.ModelAdmin +): + uczelnia_field_path = "uczelnia" show_full_result_count = False autocomplete_fields = ["institutionId", "personId"] list_display = [ diff --git a/src/pbn_api/admin/oswiadczenieinstytucji.py b/src/pbn_api/admin/oswiadczenieinstytucji.py index 6738cd3b8..218e4d37c 100644 --- a/src/pbn_api/admin/oswiadczenieinstytucji.py +++ b/src/pbn_api/admin/oswiadczenieinstytucji.py @@ -1,5 +1,6 @@ from django.contrib import admin +from bpp.admin.helpers.site_filtered import SiteFilteredAdminMixin from bpp.models import Rekord from pbn_api.admin.base import BasePBNAPIAdmin from pbn_api.admin.filters import ( @@ -11,7 +12,8 @@ @admin.register(OswiadczenieInstytucji) -class OswiadczeniaInstytucjiAdmin(BasePBNAPIAdmin): +class OswiadczeniaInstytucjiAdmin(SiteFilteredAdminMixin, BasePBNAPIAdmin): + uczelnia_field_path = "uczelnia" autocomplete_fields = ["institutionId", "personId", "publicationId"] list_select_related = ["publicationId", "personId", "institutionId"] diff --git a/src/pbn_api/admin/publikacjainstytucji_v1.py b/src/pbn_api/admin/publikacjainstytucji_v1.py index a426307b3..37cbf0160 100644 --- a/src/pbn_api/admin/publikacjainstytucji_v1.py +++ b/src/pbn_api/admin/publikacjainstytucji_v1.py @@ -1,5 +1,6 @@ from django.contrib import admin +from bpp.admin.helpers.site_filtered import SiteFilteredAdminMixin from bpp.models import Rekord from pbn_api.admin.base import BasePBNAPIAdmin from pbn_api.admin.filters import ( @@ -10,7 +11,8 @@ @admin.register(PublikacjaInstytucji) -class PublikacjaInstytucjiAdmin(BasePBNAPIAdmin): +class PublikacjaInstytucjiAdmin(SiteFilteredAdminMixin, BasePBNAPIAdmin): + uczelnia_field_path = "uczelnia" list_per_page = 25 actions = None autocomplete_fields = [ diff --git a/src/pbn_api/admin/publikacjainstytucji_v2.py b/src/pbn_api/admin/publikacjainstytucji_v2.py index 57715ceed..1f357cfaa 100644 --- a/src/pbn_api/admin/publikacjainstytucji_v2.py +++ b/src/pbn_api/admin/publikacjainstytucji_v2.py @@ -1,12 +1,14 @@ from django.contrib import admin from django.db import models +from bpp.admin.helpers.site_filtered import SiteFilteredAdminMixin from pbn_api.admin import BasePBNAPIAdmin, PrettyJSONWidgetReadonly from pbn_api.models import PublikacjaInstytucji_V2 @admin.register(PublikacjaInstytucji_V2) -class PublikacjaInstytucjiAdmin(BasePBNAPIAdmin): +class PublikacjaInstytucjiAdmin(SiteFilteredAdminMixin, BasePBNAPIAdmin): + uczelnia_field_path = "uczelnia" list_per_page = 25 actions = None diff --git a/src/pbn_api/admin/sentdata.py b/src/pbn_api/admin/sentdata.py index a2c86fbe9..c101de966 100644 --- a/src/pbn_api/admin/sentdata.py +++ b/src/pbn_api/admin/sentdata.py @@ -1,13 +1,15 @@ from django.contrib import admin from bpp.admin.helpers.pbn_api.gui import sprobuj_wyslac_do_pbn_gui +from bpp.admin.helpers.site_filtered import SiteFilteredAdminMixin from pbn_api.admin.base import BasePBNAPIAdminNoReadonly from pbn_api.admin.widgets import JSONWithActionsWidget from pbn_api.models import SentData @admin.register(SentData) -class SentDataAdmin(BasePBNAPIAdminNoReadonly): +class SentDataAdmin(SiteFilteredAdminMixin, BasePBNAPIAdminNoReadonly): + uczelnia_field_path = "uczelnia" list_display = [ "object", "last_updated_on", diff --git a/src/pbn_api/management/commands/fix_from_institution_api_for_scientist.py b/src/pbn_api/management/commands/fix_from_institution_api_for_scientist.py index 26410d2ba..bda6a065f 100644 --- a/src/pbn_api/management/commands/fix_from_institution_api_for_scientist.py +++ b/src/pbn_api/management/commands/fix_from_institution_api_for_scientist.py @@ -18,7 +18,11 @@ def handle(self, app_id, app_token, base_url, user_token, *args, **options): # 2) pobierze naukowców za pomocą funkcji "pobierz_ludzi_z_uczelni" client = self.get_client(app_id, app_token, base_url, user_token) - uczelnia = Uczelnia.objects.get_default() + uczelnia_id = options.get("uczelnia_id") + if uczelnia_id: + uczelnia = Uczelnia.objects.get(pk=uczelnia_id) + else: + uczelnia = Uczelnia.objects.get_default() if uczelnia.pbn_uid_id is None: raise Exception("Uczelnia nie ma ustawionego pbn_uid_id") diff --git a/src/pbn_api/management/commands/util.py b/src/pbn_api/management/commands/util.py index 0efe0e65c..f930fc385 100644 --- a/src/pbn_api/management/commands/util.py +++ b/src/pbn_api/management/commands/util.py @@ -14,6 +14,13 @@ def add_arguments(self, parser): base_url = settings.PBN_CLIENT_BASE_URL user_token = None + parser.add_argument( + "--uczelnia-id", + type=int, + default=None, + help=("ID uczelni (domyślnie: pierwsza uczelnia w bazie)"), + ) + uczelnia = Uczelnia.objects.get_default() if uczelnia is not None: if uczelnia.pbn_app_name: diff --git a/src/pbn_api/migrations/0069_add_uczelnia_fk.py b/src/pbn_api/migrations/0069_add_uczelnia_fk.py new file mode 100644 index 000000000..827b3386f --- /dev/null +++ b/src/pbn_api/migrations/0069_add_uczelnia_fk.py @@ -0,0 +1,70 @@ +# Generated by Django 4.2.25 on 2026-04-08 20:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0414_copy_constance_to_uczelnia"), + ("pbn_api", "0068_add_cache_models"), + ] + + operations = [ + migrations.AddField( + model_name="osobazinstytucji", + name="uczelnia", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="osoby_z_instytucji", + to="bpp.uczelnia", + ), + ), + migrations.AddField( + model_name="oswiadczenieinstytucji", + name="uczelnia", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="oswiadczenia_instytucji", + to="bpp.uczelnia", + ), + ), + migrations.AddField( + model_name="publikacjainstytucji", + name="uczelnia", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="publikacje_instytucji", + to="bpp.uczelnia", + ), + ), + migrations.AddField( + model_name="publikacjainstytucji_v2", + name="uczelnia", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="publikacje_instytucji_v2", + to="bpp.uczelnia", + ), + ), + migrations.AddField( + model_name="sentdata", + name="uczelnia", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sent_data", + to="bpp.uczelnia", + ), + ), + ] diff --git a/src/pbn_api/migrations/0070_link_pbn_to_uczelnia.py b/src/pbn_api/migrations/0070_link_pbn_to_uczelnia.py new file mode 100644 index 000000000..d4dc9028d --- /dev/null +++ b/src/pbn_api/migrations/0070_link_pbn_to_uczelnia.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.25 on 2026-04-08 20:08 + +from django.db import migrations + + +def link_pbn_records_to_uczelnia(apps, schema_editor): + """Set uczelnia FK on all existing PBN per-institution records.""" + Uczelnia = apps.get_model("bpp", "Uczelnia") + uczelnia = Uczelnia.objects.first() + if uczelnia is None: + return + + for model_name in [ + "OsobaZInstytucji", + "OswiadczenieInstytucji", + "PublikacjaInstytucji", + "PublikacjaInstytucji_V2", + "SentData", + ]: + Model = apps.get_model("pbn_api", model_name) + Model.objects.filter(uczelnia__isnull=True).update(uczelnia=uczelnia) + + +class Migration(migrations.Migration): + dependencies = [ + ("pbn_api", "0069_add_uczelnia_fk"), + ("bpp", "0414_copy_constance_to_uczelnia"), + ] + + operations = [ + migrations.RunPython( + link_pbn_records_to_uczelnia, migrations.RunPython.noop + ), + ] diff --git a/src/pbn_api/models/osoba_z_instytucji.py b/src/pbn_api/models/osoba_z_instytucji.py index 49285ad59..13d3b6475 100644 --- a/src/pbn_api/models/osoba_z_instytucji.py +++ b/src/pbn_api/models/osoba_z_instytucji.py @@ -8,6 +8,13 @@ class OsobaZInstytucji(models.Model): firstName = models.TextField() lastName = models.TextField() institutionId = models.ForeignKey("pbn_api.Institution", on_delete=models.PROTECT) + uczelnia = models.ForeignKey( + "bpp.Uczelnia", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="osoby_z_instytucji", + ) institutionName = models.TextField() title = models.TextField(blank=True, default="") polonUuid = models.UUIDField(unique=True) diff --git a/src/pbn_api/models/oswiadczenie_instytucji.py b/src/pbn_api/models/oswiadczenie_instytucji.py index eb831f347..3f66196e9 100644 --- a/src/pbn_api/models/oswiadczenie_instytucji.py +++ b/src/pbn_api/models/oswiadczenie_instytucji.py @@ -28,6 +28,13 @@ class OswiadczenieInstytucji(LinkDoPBNMixin, models.Model): institutionId = models.ForeignKey("pbn_api.Institution", on_delete=models.CASCADE) personId = models.ForeignKey("pbn_api.Scientist", on_delete=models.CASCADE) publicationId = models.ForeignKey("pbn_api.Publication", on_delete=models.CASCADE) + uczelnia = models.ForeignKey( + "bpp.Uczelnia", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="oswiadczenia_instytucji", + ) type = models.CharField(max_length=50) disciplines = models.JSONField(blank=True, null=True) diff --git a/src/pbn_api/models/publikacja_instytucji.py b/src/pbn_api/models/publikacja_instytucji.py index 26704f164..440e3d581 100644 --- a/src/pbn_api/models/publikacja_instytucji.py +++ b/src/pbn_api/models/publikacja_instytucji.py @@ -6,8 +6,15 @@ class PublikacjaInstytucji(models.Model): insPersonId = models.ForeignKey("pbn_api.Scientist", on_delete=models.CASCADE) institutionId = models.ForeignKey("pbn_api.Institution", on_delete=models.CASCADE) publicationId = models.ForeignKey("pbn_api.Publication", on_delete=models.CASCADE) - publicationType = models.CharField(max_length=50, null=True, blank=True) - userType = models.CharField(max_length=50, null=True, blank=True) + uczelnia = models.ForeignKey( + "bpp.Uczelnia", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="publikacje_instytucji", + ) + publicationType = models.CharField(max_length=50, null=True, blank=True) # noqa: DJ001 + userType = models.CharField(max_length=50, null=True, blank=True) # noqa: DJ001 publicationVersion = models.UUIDField(null=True, blank=True) publicationYear = models.PositiveSmallIntegerField(null=True, blank=True) snapshot = JSONField(null=True, blank=True) @@ -23,6 +30,14 @@ class PublikacjaInstytucji_V2(models.Model): o oświadczeniach instytucji. """ + uczelnia = models.ForeignKey( + "bpp.Uczelnia", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="publikacje_instytucji_v2", + ) + class Meta: verbose_name = "Publikacja instytucji V2" verbose_name_plural = "Publikacje instytucji V2" @@ -31,7 +46,7 @@ class Meta: def __str__(self): return self.json_data.get("title") - uuid = models.UUIDField(primary_key=True) + uuid = models.UUIDField(primary_key=True) # noqa: DJ012 # objectId powinno być realnie OneToOne, ale ja za cholerę nie wiem, czy PBN ma realnie to unikalne, # potem będzie się mój system wykrzaczał jeżeli oni mają zdublowane, więc: objectId = models.ForeignKey("pbn_api.Publication", on_delete=models.CASCADE) @@ -50,7 +65,7 @@ def link_do_pi(self): from bpp import const from bpp.models import Uczelnia - uczelnia = Uczelnia.objects.get_default() + uczelnia = self.uczelnia or Uczelnia.objects.get_default() if uczelnia is not None: return const.LINK_PI_ADD_STATEMENTS.format( pbn_api_root=uczelnia.pbn_api_root, pbn_uid_id=pbn_uid_id, uuid=uuid diff --git a/src/pbn_api/models/sentdata.py b/src/pbn_api/models/sentdata.py index 3b82543ed..13cfa107f 100644 --- a/src/pbn_api/models/sentdata.py +++ b/src/pbn_api/models/sentdata.py @@ -132,13 +132,21 @@ class SentData(LinkDoPBNMixin, models.Model): object = GenericForeignKey() + uczelnia = models.ForeignKey( + "bpp.Uczelnia", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="sent_data", + ) + data_sent = JSONField("Wysłane dane") last_updated_on = models.DateTimeField("Data operacji", auto_now=True) uploaded_okay = models.BooleanField( "Wysłano poprawnie", default=True, db_index=True ) - exception = models.TextField("Kod błędu", max_length=65535, blank=True, null=True) + exception = models.TextField("Kod błędu", max_length=65535, blank=True, null=True) # noqa: DJ001 # New fields for success tracking submitted_successfully = models.BooleanField( @@ -153,7 +161,7 @@ class SentData(LinkDoPBNMixin, models.Model): blank=True, help_text="Kiedy dane zostały wysłane do PBN", ) - api_response_status = models.TextField( + api_response_status = models.TextField( # noqa: DJ001 "Status odpowiedzi API", null=True, blank=True, help_text="Odpowiedź z PBN API" ) @@ -165,7 +173,7 @@ class SentData(LinkDoPBNMixin, models.Model): on_delete=models.SET_NULL, ) - typ_rekordu = models.CharField(max_length=50, blank=True, null=True) + typ_rekordu = models.CharField(max_length=50, blank=True, null=True) # noqa: DJ001 objects = SentDataManager() @@ -190,7 +198,7 @@ def rekord_w_bpp(self): except ObjectDoesNotExist: pass - def save( + def save( # noqa: DJ012 self, force_insert=False, force_update=False, using=None, update_fields=None ): if update_fields and "data_sent" in update_fields: diff --git a/src/pbn_api/views.py b/src/pbn_api/views.py index 38fea2402..6ff043c6d 100644 --- a/src/pbn_api/views.py +++ b/src/pbn_api/views.py @@ -23,7 +23,7 @@ def get_redirect_url(self, *args, **kwargs): from django.utils import timezone - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) # Get the original page from 'next' parameter or HTTP referer next_url = self.request.GET.get("next") @@ -53,7 +53,7 @@ def get_redirect_url(self, *args, **kwargs): if not ott: raise HttpResponseBadRequest("Brak parametru OTT lub pusty") - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) # Default redirect URL redirect_url = "/" diff --git a/src/pbn_downloader_app/tasks.py b/src/pbn_downloader_app/tasks.py index b0a7ea91b..ba29b3753 100644 --- a/src/pbn_downloader_app/tasks.py +++ b/src/pbn_downloader_app/tasks.py @@ -327,13 +327,14 @@ def update_publications_progress(task, tqdm_self, desc): @app.task -def download_institution_people(user_id): +def download_institution_people(user_id, uczelnia_id=None): """ Download institution people using PBN API integrator function. Uses database-based locking to ensure only one instance runs at a time. Args: user_id: ID of the user initiating the download (must have valid PBN token) + uczelnia_id: ID of Uczelnia (defaults to get_default()). """ from bpp.models import Uczelnia from pbn_downloader_app.models import PbnInstitutionPeopleTask @@ -351,7 +352,11 @@ def download_institution_people(user_id): user, pbn_user = validate_pbn_user(user_id) # Get institution ID - uczelnia = Uczelnia.objects.get_default() + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) + if uczelnia_id + else Uczelnia.objects.get_default() + ) if not uczelnia.pbn_uid_id: raise ValueError( "Default institution does not have PBN UID. " @@ -397,10 +402,14 @@ def update_people_progress(task, tqdm_self, desc): raise -def get_pbn_client(pbn_user): +def get_pbn_client(pbn_user, uczelnia_id=None): """ Create a PBN client with proper configuration. + Args: + pbn_user: PBN user object with pbn_token. + uczelnia_id: ID of Uczelnia (defaults to get_default()). + Returns: tuple: (client, uczelnia) if successful @@ -410,7 +419,11 @@ def get_pbn_client(pbn_user): from bpp.models import Uczelnia from pbn_api.client import PBNClient, RequestsTransport - uczelnia = Uczelnia.objects.get_default() + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) + if uczelnia_id + else Uczelnia.objects.get_default() + ) if not uczelnia: raise ValueError("No default institution configured") diff --git a/src/pbn_export_queue/admin.py b/src/pbn_export_queue/admin.py index a86e06586..a5ac51395 100644 --- a/src/pbn_export_queue/admin.py +++ b/src/pbn_export_queue/admin.py @@ -6,6 +6,7 @@ from django.utils.safestring import mark_safe from bpp.admin.core import DynamicAdminFilterMixin +from bpp.admin.helpers.site_filtered import SiteFilteredAdminMixin from .models import PBN_Export_Queue @@ -42,7 +43,10 @@ def render(self, name, value, renderer, attrs=None): @admin.register(PBN_Export_Queue) -class PBN_Export_QueueAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): +class PBN_Export_QueueAdmin( + SiteFilteredAdminMixin, DynamicAdminFilterMixin, admin.ModelAdmin +): + uczelnia_field_path = "uczelnia" list_per_page = 10 list_display = [ "rekord_do_wysylki", diff --git a/src/pbn_export_queue/migrations/0008_add_uczelnia_fk.py b/src/pbn_export_queue/migrations/0008_add_uczelnia_fk.py new file mode 100644 index 000000000..22dad0f76 --- /dev/null +++ b/src/pbn_export_queue/migrations/0008_add_uczelnia_fk.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.25 on 2026-04-09 19:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0414_copy_constance_to_uczelnia"), + ("pbn_export_queue", "0007_reclassify_doiorwwwmissing_errors"), + ] + + operations = [ + migrations.AddField( + model_name="pbn_export_queue", + name="uczelnia", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="pbn_export_queue", + to="bpp.uczelnia", + ), + ), + ] diff --git a/src/pbn_export_queue/migrations/0009_link_queue_to_uczelnia.py b/src/pbn_export_queue/migrations/0009_link_queue_to_uczelnia.py new file mode 100644 index 000000000..bcc2e1bdc --- /dev/null +++ b/src/pbn_export_queue/migrations/0009_link_queue_to_uczelnia.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.25 on 2026-04-09 19:05 + +from django.db import migrations + + +def link_queue_to_uczelnia(apps, schema_editor): + Uczelnia = apps.get_model("bpp", "Uczelnia") + PBN_Export_Queue = apps.get_model("pbn_export_queue", "PBN_Export_Queue") + uczelnia = Uczelnia.objects.first() + if uczelnia: + PBN_Export_Queue.objects.filter(uczelnia__isnull=True).update(uczelnia=uczelnia) + + +class Migration(migrations.Migration): + dependencies = [ + ("pbn_export_queue", "0008_add_uczelnia_fk"), + ("bpp", "0414_copy_constance_to_uczelnia"), + ] + + operations = [ + migrations.RunPython( + link_queue_to_uczelnia, + migrations.RunPython.noop, + ), + ] diff --git a/src/pbn_export_queue/models.py b/src/pbn_export_queue/models.py index 725e4ddc5..f14413587 100644 --- a/src/pbn_export_queue/models.py +++ b/src/pbn_export_queue/models.py @@ -77,6 +77,14 @@ class PBN_Export_Queue(models.Model): zamowil = models.ForeignKey(AUTH_USER_MODEL, on_delete=models.CASCADE) + uczelnia = models.ForeignKey( + "bpp.Uczelnia", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="pbn_export_queue", + ) + zamowiono = models.DateTimeField(auto_now_add=True, db_index=True) wysylke_podjeto = models.DateTimeField(null=True, blank=True) diff --git a/src/pbn_export_queue/tests/test_admin.py b/src/pbn_export_queue/tests/test_admin.py index 9258e26e7..ba6483965 100644 --- a/src/pbn_export_queue/tests/test_admin.py +++ b/src/pbn_export_queue/tests/test_admin.py @@ -10,6 +10,7 @@ from pbn_export_queue.admin import PBN_Export_QueueAdmin from pbn_export_queue.models import PBN_Export_Queue +from django.contrib import admin from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.utils import timezone @@ -182,7 +183,7 @@ def test_pbn_export_queue_admin_response_change_normal( "pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn.delay" ) as mock_task: with patch.object( - admin_instance.__class__.__bases__[1], "response_change" + admin.ModelAdmin, "response_change" ) as mock_super: mock_super.return_value = "super_response" diff --git a/src/pbn_import/tasks.py b/src/pbn_import/tasks.py index b32a516ac..5e3d9d3c2 100644 --- a/src/pbn_import/tasks.py +++ b/src/pbn_import/tasks.py @@ -58,7 +58,7 @@ def update_progress(session, step_name, progress, message=None): @shared_task(bind=True) -def run_pbn_import(self, session_id): +def run_pbn_import(self, session_id, uczelnia_id=None): """Main PBN import task""" logger.info(f"Uruchamianie zadania Celery dla sesji importu #{session_id}") try: @@ -73,7 +73,11 @@ def run_pbn_import(self, session_id): # Get configuration config = session.config - uczelnia = Uczelnia.objects.get_default() + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) + if uczelnia_id + else Uczelnia.objects.get_default() + ) if not uczelnia: raise Exception("Brak konfiguracji uczelni") diff --git a/src/pbn_import/templatetags/pbn_import_tags.py b/src/pbn_import/templatetags/pbn_import_tags.py index 58f810265..f76da6a31 100644 --- a/src/pbn_import/templatetags/pbn_import_tags.py +++ b/src/pbn_import/templatetags/pbn_import_tags.py @@ -11,12 +11,16 @@ register = template.Library() -@register.simple_tag -def pbn_publication_url(pbn_publication_id): +@register.simple_tag(takes_context=True) +def pbn_publication_url(context, pbn_publication_id): """Generate URL to publication in PBN system.""" if not pbn_publication_id: return "" - uczelnia = Uczelnia.objects.get_default() + request = context.get("request") + if request is not None: + uczelnia = Uczelnia.objects.get_for_request(request) + else: + uczelnia = Uczelnia.objects.get_default() pbn_root = uczelnia.pbn_api_root if uczelnia else "https://pbn.nauka.gov.pl" # Remove trailing slash if present pbn_root = pbn_root.rstrip("/") diff --git a/src/pbn_import/utils/author_import.py b/src/pbn_import/utils/author_import.py index 61b97e9d5..400ce29a7 100644 --- a/src/pbn_import/utils/author_import.py +++ b/src/pbn_import/utils/author_import.py @@ -12,9 +12,10 @@ class AuthorImporter(ImportStepBase): step_name = "author_import" step_description = "Import autorów" - def run(self): + def run(self, uczelnia=None): """Import authors""" - uczelnia = Uczelnia.objects.get_default() + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() if not uczelnia or not uczelnia.pbn_uid_id: self.log( diff --git a/src/pbn_import/utils/import_manager.py b/src/pbn_import/utils/import_manager.py index c7f0f9018..ed7fc0fdc 100644 --- a/src/pbn_import/utils/import_manager.py +++ b/src/pbn_import/utils/import_manager.py @@ -92,7 +92,7 @@ def _has_error_logs(self) -> bool: session=self.session, level__in=["error", "critical"] ).exists() - def _refresh_pbn_client_after_setup(self): + def _refresh_pbn_client_after_setup(self, uczelnia=None): """Refresh PBN client after initial setup changes configuration. On a clean database, pbn_uid_id may be None when the import starts. @@ -102,8 +102,10 @@ def _refresh_pbn_client_after_setup(self): """ from bpp.models import Uczelnia - # Refresh uczelnia from database to get changes made by InitialSetup - uczelnia = Uczelnia.objects.get_default() + # Refresh uczelnia from database to get changes made by + # InitialSetup + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() if uczelnia is None: logger.warning("Nie znaleziono uczelni po InitialSetup") diff --git a/src/pbn_import/utils/initial_setup.py b/src/pbn_import/utils/initial_setup.py index b4302d4e8..061d4d8d9 100644 --- a/src/pbn_import/utils/initial_setup.py +++ b/src/pbn_import/utils/initial_setup.py @@ -17,13 +17,15 @@ class InitialSetup(ImportStepBase): step_name = "initial_setup" step_description = "Konfiguracja początkowa" - def run(self): + def run(self, uczelnia=None): """Execute initial setup""" + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() + # Check if we have a PBN client if self.client is None: self.log("warning", "Brak klienta PBN - próba utworzenia") # Try to get or create PBN client - uczelnia = Uczelnia.objects.get_default() if uczelnia: try: self.client = uczelnia.pbn_client() @@ -53,7 +55,7 @@ def run(self): # For other errors, we can try minimal setup self.log("warning", f"Nie można zintegrować języków z PBN: {error_msg}") self.log("info", "Próba uruchomienia minimalnej konfiguracji") - return self._run_minimal_setup(Uczelnia.objects.get_default()) + return self._run_minimal_setup(uczelnia) # Step 2: Countries self.update_progress(1, 4, "Importowanie krajów") @@ -87,7 +89,6 @@ def run(self): self.clear_subtask_progress() # Auto-match Uczelnia and enable PBN integration - uczelnia = Uczelnia.objects.get_default() self._finalize_uczelnia_setup(uczelnia) self.update_progress(4, 4, "Zakończono konfigurację początkową") diff --git a/src/pbn_import/utils/institution_import.py b/src/pbn_import/utils/institution_import.py index a6b685477..30d5b9901 100644 --- a/src/pbn_import/utils/institution_import.py +++ b/src/pbn_import/utils/institution_import.py @@ -95,9 +95,10 @@ def __init__( wydzial_domyslny ) - def run(self): + def run(self, uczelnia=None): """Setup default institutions""" - uczelnia = Uczelnia.objects.get_default() + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() if not uczelnia: raise ValueError( diff --git a/src/pbn_import/utils/publication_import.py b/src/pbn_import/utils/publication_import.py index 66fad93e0..bf45a13c1 100644 --- a/src/pbn_import/utils/publication_import.py +++ b/src/pbn_import/utils/publication_import.py @@ -73,9 +73,10 @@ def run(self): "error_count": len(self.errors), } - def _setup_uczelnia_and_jednostka(self): + def _setup_uczelnia_and_jednostka(self, uczelnia=None): """Setup uczelnia and default jednostka for import.""" - uczelnia = Uczelnia.objects.get_default() + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() if not uczelnia or not uczelnia.pbn_uid_id: self.log( diff --git a/src/pbn_import/views.py b/src/pbn_import/views.py index 2a090d5a2..39c0e68b2 100644 --- a/src/pbn_import/views.py +++ b/src/pbn_import/views.py @@ -83,7 +83,7 @@ def get_context_data(self, **kwargs): context["motivational_message"] = self.get_motivational_message() # Check if PBN is configured - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) context["pbn_configured"] = uczelnia and uczelnia.pbn_integracja context["uczelnia"] = uczelnia context["uzywaj_wydzialow"] = uczelnia.uzywaj_wydzialow if uczelnia else False diff --git a/src/pbn_integrator/management/commands/pbn_integrator.py b/src/pbn_integrator/management/commands/pbn_integrator.py index 156f2fdc3..9bb5b0bab 100644 --- a/src/pbn_integrator/management/commands/pbn_integrator.py +++ b/src/pbn_integrator/management/commands/pbn_integrator.py @@ -9,10 +9,11 @@ django.setup() -from pbn_api.exceptions import IntegracjaWylaczonaException -from pbn_api.management.commands.util import PBNBaseCommand -from pbn_integrator import utils as integrator -from pbn_integrator.utils import ( +from bpp.models import Uczelnia # noqa: E402 +from pbn_api.exceptions import IntegracjaWylaczonaException # noqa: E402 +from pbn_api.management.commands.util import PBNBaseCommand # noqa: E402 +from pbn_integrator import utils as integrator # noqa: E402 +from pbn_integrator.utils import ( # noqa: E402 integruj_autorow_z_uczelni, integruj_instytucje, integruj_jezyki, @@ -42,8 +43,6 @@ wyswietl_niezmatchowane_ze_zblizonymi_tytulami, ) -from bpp.models import Uczelnia - def check_end_before(stage, end_before_stage): if end_before_stage == stage: @@ -54,13 +53,15 @@ class Command(PBNBaseCommand): def add_arguments(self, parser): super().add_arguments(parser) - parser.add_argument( - "--disable-multiprocessing", action="store_true", default=False - ), + ( + parser.add_argument( + "--disable-multiprocessing", action="store_true", default=False + ), + ) parser.add_argument("--start-from-stage", type=int, default=0) parser.add_argument("--end-before-stage", type=int, default=None) - parser.add_argument("--just-one-stage", action="store_true"), + (parser.add_argument("--just-one-stage", action="store_true"),) parser.add_argument("--clear-all", action="store_true", default=False) parser.add_argument("--clear-publications", action="store_true", default=False) @@ -148,6 +149,249 @@ def add_arguments(self, parser): "--disable-progress-bar", action="store_true", default=False ) + def _run_stage(self, flag, enable_all, start, end, stage, func): + """Uruchom etap jeśli odpowiednia flaga jest włączona.""" + check_end_before(stage, end) + if (flag or enable_all) and start <= stage: + func() + + def _handle_clears(self, clear_all, clear_match, clear_pubs): + if clear_all: + integrator.clear_all() + sys.exit(0) + if clear_match: + integrator.clear_match_publications() + sys.exit(0) + if clear_pubs: + integrator.clear_publications() + sys.exit(0) + + def _handle_system_and_sources(self, opts, client, s, e): + """Etapy 0-3: system data, źródła, instytucje.""" + ea = opts["enable_all"] + dpb = opts["disable_progress_bar"] + + self._run_stage( + opts["enable_system_data"], + ea, + s, + e, + 0, + lambda: ( + integruj_jezyki(client), + integruj_kraje(client), + client.download_disciplines(), + client.sync_disciplines(), + ), + ) + self._run_stage( + opts["enable_pobierz_zrodla"], + ea, + s, + e, + 1, + lambda: pobierz_zrodla(client), + ) + self._run_stage( + opts["enable_integruj_zrodla"], + ea, + s, + e, + 2, + lambda: integruj_zrodla(dpb), + ) + self._run_stage( + opts["enable_institutions"], + ea, + s, + e, + 3, + lambda: ( + pobierz_instytucje(client), + integruj_uczelnie(), + integruj_instytucje(), + ), + ) + + def _handle_people(self, opts, client, s, e): + """Etapy 6-9: pobieranie i integracja ludzi.""" + ea = opts["enable_all"] + pbn_uid_id = Uczelnia.objects.default.pbn_uid_id + + self._run_stage( + opts["enable_download_people_institution"], + ea, + s, + e, + 6, + lambda: pobierz_ludzi_z_uczelni(client, pbn_uid_id), + ) + self._run_stage( + opts["enable_integrate_people_institution"], + ea, + s, + e, + 7, + lambda: integruj_autorow_z_uczelni(client, pbn_uid_id), + ) + self._run_stage( + opts["enable_integrate_people_all"], + ea, + s, + e, + 8, + integruj_wszystkich_niezintegrowanych_autorow, + ) + self._run_stage( + opts["enable_check_orcid_people"], + ea, + s, + e, + 9, + lambda: weryfikuj_orcidy(client, pbn_uid_id), + ) + + def _handle_publishers_and_conferences(self, opts, client, s, e): + """Etapy 10-11: wydawcy i konferencje.""" + ea = opts["enable_all"] + + self._run_stage( + opts["enable_publishers"], + ea, + s, + e, + 10, + lambda: ( + pobierz_wydawcow_wszystkich(client), + pobierz_wydawcow_mnisw(client), + integruj_wydawcow(), + call_command("pbn_importuj_wydawcow"), + ), + ) + self._run_stage( + opts["enable_conferences"], + ea, + s, + e, + 11, + lambda: pobierz_konferencje(client), + ) + + def _handle_publications(self, opts, client, s, e): + """Etapy 12-21: pobieranie i integracja publikacji.""" + ea = opts["enable_all"] + skip_pages = opts["skip_pages"] + dm = opts["disable_multiprocessing"] + + self._run_stage( + opts["enable_pobierz_rekordy_publikacji_instytucji"], + ea, + s, + e, + 12, + lambda: pobierz_rekordy_publikacji_instytucji(client), + ) + self._run_stage( + opts["enable_pobierz_publikacje_instytucji"], + ea, + s, + e, + 13, + lambda: pobierz_publikacje_z_instytucji(client), + ) + self._run_stage( + opts["enable_pobierz_oswiadczenia_instytucji"], + ea, + s, + e, + 14, + lambda: pobierz_oswiadczenia_z_instytucji(client), + ) + self._run_stage( + opts["enable_odswiez_tabele_publikacji"], + ea, + s, + e, + 15, + lambda: pobierz_skasowane_prace(client), + ) + self._run_stage( + opts["enable_odswiez_tabele_publikacji"], + ea, + s, + e, + 16, + lambda: odswiez_tabele_publikacji(client), + ) + self._run_stage( + opts["enable_integruj_publikacje_instytucji"], + ea, + s, + e, + 17, + lambda: integruj_publikacje_instytucji(dm, skip_pages=skip_pages), + ) + self._run_stage( + opts["enable_pobierz_oswiadczenia_instytucji"], + ea, + s, + e, + 18, + integruj_oswiadczenia_z_instytucji, + ) + self._run_stage( + opts["enable_pobierz_po_doi"], + ea, + s, + e, + 19, + lambda: pobierz_prace_po_doi(client), + ) + self._run_stage( + opts["enable_pobierz_po_isbn"], + ea, + s, + e, + 20, + lambda: pobierz_prace_po_isbn(client), + ) + self._run_stage( + opts["enable_integruj_wszystkie_publikacje"], + ea, + s, + e, + 21, + lambda: ( + wyswietl_niezmatchowane_ze_zblizonymi_tytulami(), + sprawdz_ilosc_autorow_przy_zmatchowaniu(), + ), + ) + + def _handle_sync(self, opts, uczelnia, client): + """Etap końcowy: synchronizacja publikacji z PBN.""" + if opts["enable_delete_all"]: + usun_wszystkie_oswiadczenia(client) + if opts["enable_delete_zeros"]: + usun_zerowe_oswiadczenia(client) + + if opts["enable_sync"]: + export_pk_zero = opts["export_pk_zero"] + delete_before = opts["delete_statements_before_upload"] + + if export_pk_zero is None: + export_pk_zero = not uczelnia.pbn_api_nie_wysylaj_prac_bez_pk + if delete_before is None: + delete_before = uczelnia.pbn_api_kasuj_przed_wysylka + + synchronizuj_publikacje( + client=client, + force_upload=opts["force_upload"], + only_bad=opts["only_bad"], + only_new=opts["only_new"], + delete_statements_before_upload=delete_before, + export_pk_zero=export_pk_zero, + ) + def handle( self, app_id, @@ -193,237 +437,47 @@ def handle( delete_statements_before_upload, export_pk_zero, *args, - **options + **options, ): if disable_multiprocessing: integrator.CPU_COUNT = "single" - uczelnia = Uczelnia.objects.get_default() + uczelnia_id = options.get("uczelnia_id") + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) + if uczelnia_id + else Uczelnia.objects.get_default() + ) if uczelnia is not None: if not uczelnia.pbn_integracja: raise IntegracjaWylaczonaException() client = self.get_client(app_id, app_token, base_url, user_token) - if clear_all: - integrator.clear_all() - sys.exit(0) - - if clear_match_publications: - integrator.clear_match_publications() - sys.exit(0) - - if clear_publications: - integrator.clear_publications() - sys.exit(0) + self._handle_clears(clear_all, clear_match_publications, clear_publications) if just_one_stage: end_before_stage = start_from_stage + 1 - stage = 0 - if (enable_system_data or enable_all) and start_from_stage <= stage: - integruj_jezyki(client) - integruj_kraje(client) - client.download_disciplines() - client.sync_disciplines() - - stage = 1 - check_end_before(stage, end_before_stage) - if (enable_pobierz_zrodla or enable_all) and start_from_stage <= stage: - pobierz_zrodla(client) - - stage = 2 - check_end_before(stage, end_before_stage) - if (enable_integruj_zrodla or enable_all) and start_from_stage <= stage: - integruj_zrodla(disable_progress_bar) - - stage = 3 - check_end_before(stage, end_before_stage) - if (enable_institutions or enable_all) and start_from_stage <= stage: - # Pobieranie instytucji musi odbywac się przed pobieraniem ludzi - pobierz_instytucje(client) - integruj_uczelnie() - integruj_instytucje() - - # stage = 4 - # check_end_before(stage, end_before_stage) - # if (enable_download_people_all or enable_all) and start_from_stage <= stage: - # os.makedirs("pbn_json_data", exist_ok=True) - # pobierz_ludzi_offline(client) - # - # stage = 5 - # check_end_before(stage, end_before_stage) - # if (enable_download_people_all or enable_all) and start_from_stage <= stage: - # wgraj_ludzi_z_offline_do_bazy() - - stage = 6 - check_end_before(stage, end_before_stage) - - if ( - enable_download_people_institution or enable_all - ) and start_from_stage <= stage: - pobierz_ludzi_z_uczelni(client, Uczelnia.objects.default.pbn_uid_id) - stage = 7 - check_end_before(stage, end_before_stage) - - if ( - enable_integrate_people_institution or enable_all - ) and start_from_stage <= stage: - integruj_autorow_z_uczelni(client, Uczelnia.objects.default.pbn_uid_id) - stage = 8 - - if (enable_integrate_people_all or enable_all) and start_from_stage <= stage: - integruj_wszystkich_niezintegrowanych_autorow() - stage = 9 - - if (enable_check_orcid_people or enable_all) and start_from_stage <= stage: - weryfikuj_orcidy(client, Uczelnia.objects.default.pbn_uid_id) - stage = 10 - check_end_before(stage, end_before_stage) - - if (enable_publishers or enable_all) and start_from_stage <= stage: - pobierz_wydawcow_wszystkich(client) - pobierz_wydawcow_mnisw(client) - integruj_wydawcow() - call_command("pbn_importuj_wydawcow") - # zamapuj_wydawcow nie trzeba, bo zostanie wywołany przez pbn_importuj_wydawców gdyby coś - # call_command("zamapuj_wydawcow") - - stage = 11 - check_end_before(stage, end_before_stage) - - if (enable_conferences or enable_all) and start_from_stage <= stage: - pobierz_konferencje(client) - - stage = 12 - check_end_before(stage, end_before_stage) - - # - # Pobieranie wszystkich publikacji z całego PBNu - bez wiekszego sensu - # do obecnych zastosowań - # - # if ( - # enable_pobierz_wszystkie_publikacje - # ) and start_from_stage <= stage: - # os.makedirs("pbn_json_data", exist_ok=True) - # pobierz_prace_offline(client) - # - # stage = 13 - # check_end_before(stage, end_before_stage) - # - # Wgrywanie wszystkich prac z offline do bazy - # - # if ( - # enable_pobierz_wszystkie_publikacje - # ) and start_from_stage <= stage: - # wgraj_prace_z_offline_do_bazy() - # - - # - # Pobieranie oswiadczen i publikacji z insytucji - # - - if ( - enable_pobierz_rekordy_publikacji_instytucji or enable_all - ) and start_from_stage <= stage: - pobierz_rekordy_publikacji_instytucji(client) - - stage = 13 - check_end_before(stage, end_before_stage) - if ( - enable_pobierz_publikacje_instytucji or enable_all - ) and start_from_stage <= stage: - pobierz_publikacje_z_instytucji(client) - - stage = 14 - check_end_before(stage, end_before_stage) - - if ( - enable_pobierz_oswiadczenia_instytucji or enable_all - ) and start_from_stage <= stage: - pobierz_oswiadczenia_z_instytucji(client) - - stage = 15 - - if ( - enable_odswiez_tabele_publikacji or enable_all - ) and start_from_stage <= stage: - pobierz_skasowane_prace(client) - - stage = 16 - check_end_before(stage, end_before_stage) - - if ( - enable_odswiez_tabele_publikacji or enable_all - ) and start_from_stage <= stage: - odswiez_tabele_publikacji(client) - - stage = 17 - check_end_before(stage, end_before_stage) - - # if (enable_integruj_wszystkie_publikacje) and start_from_stage <= stage: - # integruj_wszystkie_publikacje( - # disable_multiprocessing, skip_pages=skip_pages - # ) - - if ( - enable_integruj_publikacje_instytucji or enable_all - ) and start_from_stage <= stage: - integruj_publikacje_instytucji( - disable_multiprocessing, skip_pages=skip_pages - ) - - stage = 18 - check_end_before(stage, end_before_stage) - - if ( - enable_pobierz_oswiadczenia_instytucji or enable_all - ) and start_from_stage <= stage: - integruj_oswiadczenia_z_instytucji() - - stage = 19 - check_end_before(stage, end_before_stage) - - if (enable_pobierz_po_doi or enable_all) and start_from_stage <= stage: - pobierz_prace_po_doi(client) - - stage = 20 - check_end_before(stage, end_before_stage) - - if (enable_pobierz_po_isbn or enable_all) and start_from_stage <= stage: - pobierz_prace_po_isbn(client) - - stage = 21 - check_end_before(stage, end_before_stage) - - if ( - enable_integruj_wszystkie_publikacje or enable_all - ) and start_from_stage <= stage: - wyswietl_niezmatchowane_ze_zblizonymi_tytulami() - sprawdz_ilosc_autorow_przy_zmatchowaniu() - - stage = 22 - check_end_before(stage, end_before_stage) - - if enable_delete_all: - usun_wszystkie_oswiadczenia(client) - - if enable_delete_zeros: - usun_zerowe_oswiadczenia(client) - - if enable_sync: - uczelnia = Uczelnia.objects.get_default() - - if export_pk_zero is None: - export_pk_zero = not uczelnia.pbn_api_nie_wysylaj_prac_bez_pk - - if delete_statements_before_upload is None: - delete_statements_before_upload = uczelnia.pbn_api_kasuj_przed_wysylka + s = start_from_stage + e = end_before_stage + + # Zbierz wszystkie opcje do słownika + opts = {k: v for k, v in locals().items() if k.startswith("enable_")} + opts.update( + { + "disable_progress_bar": disable_progress_bar, + "disable_multiprocessing": disable_multiprocessing, + "skip_pages": skip_pages, + "force_upload": force_upload, + "only_bad": only_bad, + "only_new": only_new, + "delete_statements_before_upload": delete_statements_before_upload, + "export_pk_zero": export_pk_zero, + } + ) - synchronizuj_publikacje( - client=client, - force_upload=force_upload, - only_bad=only_bad, - only_new=only_new, - delete_statements_before_upload=delete_statements_before_upload, - export_pk_zero=export_pk_zero, - ) + self._handle_system_and_sources(opts, client, s, e) + self._handle_people(opts, client, s, e) + self._handle_publishers_and_conferences(opts, client, s, e) + self._handle_publications(opts, client, s, e) + self._handle_sync(opts, uczelnia, client) diff --git a/src/pbn_integrator/utils/institutions.py b/src/pbn_integrator/utils/institutions.py index fb876024e..d53ae8384 100644 --- a/src/pbn_integrator/utils/institutions.py +++ b/src/pbn_integrator/utils/institutions.py @@ -58,9 +58,10 @@ def pobierz_instytucje_polon(client: PBNClient, callback=None): ) -def integruj_uczelnie(): +def integruj_uczelnie(uczelnia=None): """Integrate the default university with PBN.""" - uczelnia = Uczelnia.objects.get_default() + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() if uczelnia.pbn_uid_id is not None: return @@ -79,9 +80,10 @@ def integruj_uczelnie(): uczelnia.save() -def integruj_instytucje(): +def integruj_instytucje(uczelnia=None): """Integrate university units with PBN institutions.""" - uczelnia = Uczelnia.objects.get_default() + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() assert uczelnia.pbn_uid_id for j in Jednostka.objects.filter(skupia_pracownikow=True): diff --git a/src/pbn_integrator/utils/scientists.py b/src/pbn_integrator/utils/scientists.py index 535a662c8..48ed41ad8 100644 --- a/src/pbn_integrator/utils/scientists.py +++ b/src/pbn_integrator/utils/scientists.py @@ -41,7 +41,7 @@ def pbn_json_wez_pbn_id_stare(person): def pobierz_i_zapisz_dane_jednej_osoby( - client_or_token, personId, from_institution_api + client_or_token, personId, from_institution_api, uczelnia=None ) -> Scientist: """Fetch and save data for a single person. @@ -49,6 +49,7 @@ def pobierz_i_zapisz_dane_jednej_osoby( client_or_token: PBN client or token string. personId: Person ID. from_institution_api: Whether data is from institution API. + uczelnia: Optional Uczelnia instance for PBN client creation. Returns: The Scientist object. @@ -56,7 +57,9 @@ def pobierz_i_zapisz_dane_jednej_osoby( client = client_or_token if isinstance(client_or_token, str): # Create PBN client - client = Uczelnia.objects.get_default().pbn_client(client_or_token) + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() + client = uczelnia.pbn_client(client_or_token) scientist = client.get_person_by_id(personId) return zapisz_mongodb( @@ -117,7 +120,22 @@ def _zapisz_osobe_z_instytucji(person): raise # Inne błędy IntegrityError propaguj -def pobierz_ludzi_z_uczelni(client_or_token: PBNClient, instutition_id, callback=None): +def _get_max_workers(): + """Determine number of threads for parallel downloads.""" + if CPU_COUNT == "auto": + max_workers = os.cpu_count() * 3 // 4 + return max(max_workers, 1) + elif CPU_COUNT == "single": + return 1 + return 4 # Default fallback + + +def pobierz_ludzi_z_uczelni( + client_or_token: PBNClient, + instutition_id, + callback=None, + uczelnia=None, +): """Fetch all people from a university. This procedure fetches data for all people from the university, @@ -127,25 +145,20 @@ def pobierz_ludzi_z_uczelni(client_or_token: PBNClient, instutition_id, callback client_or_token: PBN client or token string. instutition_id: Institution ID. callback: Optional progress callback. + uczelnia: Optional Uczelnia instance for PBN client creation. """ assert instutition_id is not None client = client_or_token if isinstance(client_or_token, str): # Create PBN client - client = Uczelnia.objects.get_default().pbn_client(client_or_token) + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() + client = uczelnia.pbn_client(client_or_token) elementy = client.get_people_by_institution_id(instutition_id) - # Determine number of threads (similar to initialize_pool logic) - if CPU_COUNT == "auto": - max_workers = os.cpu_count() * 3 // 4 - if max_workers < 1: - max_workers = 1 - elif CPU_COUNT == "single": - max_workers = 1 - else: - max_workers = 4 # Default fallback + max_workers = _get_max_workers() # Use ThreadPoolExecutor instead of multiprocessing with ThreadPoolExecutor(max_workers=max_workers) as executor: diff --git a/src/pbn_wysylka_oswiadczen/tasks.py b/src/pbn_wysylka_oswiadczen/tasks.py index 1eaba31d1..250a6ab57 100644 --- a/src/pbn_wysylka_oswiadczen/tasks.py +++ b/src/pbn_wysylka_oswiadczen/tasks.py @@ -19,12 +19,13 @@ from pbn_wysylka_oswiadczen.queries import get_publications_queryset -def get_pbn_client(user): +def get_pbn_client(user, uczelnia=None): """ Create a PBN client for the given user. Args: user: Django user with PBN token + uczelnia: Uczelnia instance (optional, falls back to default) Returns: PBNClient: Configured PBN API client @@ -40,7 +41,8 @@ def get_pbn_client(user): if not pbn_user.pbn_token_possibly_valid(): raise ValueError("Token PBN wygasl. Zaloguj sie ponownie do PBN.") - uczelnia = Uczelnia.objects.get_default() + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() if not uczelnia: raise ValueError("Brak domyslnej uczelni w systemie.") diff --git a/src/przemapuj_prace_autora/test_integration.py b/src/przemapuj_prace_autora/test_integration.py index c22952ab9..c666f134d 100644 --- a/src/przemapuj_prace_autora/test_integration.py +++ b/src/przemapuj_prace_autora/test_integration.py @@ -1,4 +1,5 @@ import pytest +from cacheops import invalidate_all from django.contrib.auth import get_user_model from django.core.cache import cache from django.test import Client @@ -32,9 +33,13 @@ def uczelnia(db): # Clear any existing universities to ensure get_default() returns our test university Uczelnia.objects.all().delete() u = baker.make(Uczelnia, nazwa="Test University", skrot="TU") - # Delete specific cache key for uczelnia instead of invalidating all caches - # (much faster - invalidate_all() is very expensive) + # Reset wszystkich warstw cache po podmianie Uczelni: Django cache klucze + # (per-site namespace z Phase 5) + cacheops query cache, żeby context + # processor i ORM zwróciły nowo utworzoną instancję, a nie poprzednią. cache.delete(b"bpp_uczelnia") + cache.delete("bpp_uczelnia_0") + cache.delete("bpp_uczelnia_1") + invalidate_all() return u diff --git a/src/ranking_autorow/forms.py b/src/ranking_autorow/forms.py index 7939963ab..81e4f8281 100644 --- a/src/ranking_autorow/forms.py +++ b/src/ranking_autorow/forms.py @@ -88,7 +88,6 @@ class RankingAutorowForm(forms.Form): rozbij_na_jednostki = forms.BooleanField( label="Rozbij punktację na jednostki i wydziały", required=False, - initial=lambda: Uczelnia.objects.first().ranking_autorow_rozbij_domyslnie, ) tylko_afiliowane = forms.BooleanField( @@ -137,9 +136,14 @@ class RankingAutorowForm(forms.Form): ), ) - def __init__(self, lata, *args, **kwargs): + def __init__(self, lata, *args, request=None, **kwargs): super().__init__(*args, **kwargs) + uczelnia = Uczelnia.objects.get_for_request(request) + self.fields["rozbij_na_jednostki"].initial = ( + uczelnia.ranking_autorow_rozbij_domyslnie if uczelnia else False + ) + # Import models here to avoid circular imports from bpp.models import ( Patent_Autor, @@ -182,7 +186,6 @@ def __init__(self, lata, *args, **kwargs): self.helper.form_method = "post" # Check if uczelnia uses wydzialy - uczelnia = Uczelnia.objects.first() uzywaj_wydzialow = uczelnia.uzywaj_wydzialow if uczelnia else True # Build layout fields based on uzywaj_wydzialow diff --git a/src/ranking_autorow/views.py b/src/ranking_autorow/views.py index 109439cf5..d8f91d91e 100644 --- a/src/ranking_autorow/views.py +++ b/src/ranking_autorow/views.py @@ -49,6 +49,7 @@ def get_lata(self): def get_form_kwargs(self, **kw): data = FormView.get_form_kwargs(self, **kw) data["lata"] = self.get_lata() + data["request"] = self.request return data def get_raport_arguments(self, form): @@ -222,7 +223,7 @@ def _apply_location_filters(self, qset): if jednostki: qset = qset.filter(jednostka__in=jednostki) - uczelnia = Uczelnia.objects.first() + uczelnia = Uczelnia.objects.get_for_request(self.request) if uczelnia and uczelnia.uzywaj_wydzialow and not jednostki: wydzialy = self.get_wydzialy() if wydzialy: @@ -253,7 +254,7 @@ def _apply_exclusions(self, qset): if self.bez_nieaktualnych: qset = qset.exclude(autor__aktualna_jednostka=None) - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) if uczelnia is not None: ukryte_statusy = uczelnia.ukryte_statusy("rankingi") if ukryte_statusy: @@ -370,7 +371,7 @@ def get_context_data(self, **kwargs): subtitle_parts.append(", ".join([x.nazwa for x in jednostki])) # Check if uczelnia uses wydzialy and handle them - uczelnia = Uczelnia.objects.first() + uczelnia = Uczelnia.objects.get_for_request(self.request) if uczelnia and uczelnia.uzywaj_wydzialow: wydzialy = self.get_wydzialy() context["wydzialy"] = wydzialy if wydzialy else [] @@ -403,7 +404,7 @@ def get_context_data(self, **kwargs): return context def get_table_kwargs(self): - uczelnia = Uczelnia.objects.all().first() + uczelnia = Uczelnia.objects.get_for_request(self.request) pokazuj = uczelnia.pokazuj_liczbe_cytowan_w_rankingu if pokazuj == OpcjaWyswietlaniaField.POKAZUJ_NIGDY or ( diff --git a/src/raport_slotow/tests/test_ewaluacja.py b/src/raport_slotow/tests/test_ewaluacja.py index f0ed6d61a..fc7ee6cb0 100644 --- a/src/raport_slotow/tests/test_ewaluacja.py +++ b/src/raport_slotow/tests/test_ewaluacja.py @@ -40,7 +40,9 @@ def test_raport_ewaluacja_no_queries( # UWAGA UWAGA UWAGA # Jeżeli nagle z jakichś powodów ten raport zacznie generować więcej zapytań, to proszę # się nad tym tematem POCHYLIC i nie zwiekszać tej wartosci max_num_queries... - with django_assert_max_num_queries(13): + # Wyjątek: +3 z SiteResolutionMiddleware (Site.get + site.uczelnia + cache lookup) + # — koszt strukturalny multi-hosting'u, nie logiki raportu. + with django_assert_max_num_queries(16): admin_client.get( url, data={ diff --git a/src/rozbieznosci_dyscyplin/admin.py b/src/rozbieznosci_dyscyplin/admin.py index c31c8a952..78de6bc20 100644 --- a/src/rozbieznosci_dyscyplin/admin.py +++ b/src/rozbieznosci_dyscyplin/admin.py @@ -1,10 +1,8 @@ -# Register your models here. import json from collections.abc import Iterable from json import JSONDecodeError from django.contrib import admin, messages -from django.contrib.sites.models import Site from django.http import HttpResponseRedirect from django.urls import path, reverse from djangoql.admin import DjangoQLSearchMixin @@ -14,6 +12,7 @@ from bpp.admin.core import DynamicAdminFilterMixin from bpp.admin.helpers import link_do_obiektu from bpp.admin.xlsx_export.mixins import EksportDanychMixin +from bpp.util import site_url_for_request from rozbieznosci_dyscyplin.admin_utils import ( CachingPaginator, DyscyplinaAutoraUstawionaFilter, @@ -193,9 +192,13 @@ class Meta: "bpp_strona_url", ) + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.request = kwargs.get("request") + def get_site_url(self): - """Get the base site URL.""" - return "https://" + Site.objects.all().first().domain + """Get the base site URL (per-request w multi-hosted).""" + return site_url_for_request(self.request) def dehydrate_bpp_strona_url(self, obj): """Generate BPP work page URL.""" @@ -254,13 +257,17 @@ def dehydrate_dyscypliny_zrodla(self, obj): return "; ".join(disciplines.values_list("dyscyplina__nazwa", flat=True)) return "" + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.request = kwargs.get("request") + def dehydrate_zrodlo_strona_url(self, obj): """Generate BPP source page URL.""" return self.get_site_url() + reverse("bpp:browse_zrodlo", args=[obj.zrodlo.pk]) def get_site_url(self): - """Get the base site URL.""" - return "https://" + Site.objects.all().first().domain + """Get the base site URL (per-request w multi-hosted).""" + return site_url_for_request(self.request) def dehydrate_bpp_strona_url(self, obj): """Generate BPP work page URL.""" @@ -311,6 +318,15 @@ class RozbieznosciViewAdmin( list_per_page = 25 search_fields = ["rekord__tytul_oryginalny", "autor__nazwisko", "autor__imiona"] + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia: + return qs.filter(autor__aktualna_jednostka__uczelnia=uczelnia) + return qs + def get_object(self, request, object_id, from_field=None): parse_incoming_id = parse_object_id(object_id) return RozbieznosciView.objects.get(pk=tuple(parse_incoming_id)) @@ -412,6 +428,15 @@ class RozbieznosciZrodelViewAdmin( "id", ] + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia: + return qs.filter(autor__aktualna_jednostka__uczelnia=uczelnia) + return qs + def get_object(self, request, object_id, from_field=None): parse_incoming_id = parse_object_id(object_id, max_len=4) return RozbieznosciZrodelView.objects.get(pk=tuple(parse_incoming_id)) diff --git a/src/rozbieznosci_if/admin.py b/src/rozbieznosci_if/admin.py index 0dc0821fc..5aead8f11 100644 --- a/src/rozbieznosci_if/admin.py +++ b/src/rozbieznosci_if/admin.py @@ -1,4 +1,3 @@ -# Register your models here. from django.contrib import admin from rozbieznosci_if.models import IgnorujRozbieznoscIf, RozbieznosciIfLog @@ -8,6 +7,9 @@ class IgnorujRozbieznoscIfAdmin(admin.ModelAdmin): list_display = ["object", "created_on"] + def has_module_permission(self, request): + return request.user.is_superuser + @admin.register(RozbieznosciIfLog) class RozbieznosciIfLogAdmin(admin.ModelAdmin): @@ -24,6 +26,17 @@ class RozbieznosciIfLogAdmin(admin.ModelAdmin): ] date_hierarchy = "created_on" + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia: + return qs.filter( + rekord__autorzy_set__jednostka__uczelnia=uczelnia + ).distinct() + return qs + def has_add_permission(self, request): return False diff --git a/src/rozbieznosci_pk/admin.py b/src/rozbieznosci_pk/admin.py index 41f504851..857be3c93 100644 --- a/src/rozbieznosci_pk/admin.py +++ b/src/rozbieznosci_pk/admin.py @@ -7,6 +7,9 @@ class IgnorujRozbieznoscPkAdmin(admin.ModelAdmin): list_display = ["object", "created_on"] + def has_module_permission(self, request): + return request.user.is_superuser + @admin.register(RozbieznosciPkLog) class RozbieznosciPkLogAdmin(admin.ModelAdmin): @@ -23,6 +26,17 @@ class RozbieznosciPkLogAdmin(admin.ModelAdmin): ] date_hierarchy = "created_on" + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia: + return qs.filter( + rekord__autorzy_set__jednostka__uczelnia=uczelnia + ).distinct() + return qs + def has_add_permission(self, request): return False diff --git a/src/zglos_publikacje/forms.py b/src/zglos_publikacje/forms.py index 671bea204..a0bb7ad5c 100644 --- a/src/zglos_publikacje/forms.py +++ b/src/zglos_publikacje/forms.py @@ -303,6 +303,7 @@ def _zbuduj_layout(self): def __init__(self, *args, rodzaj=None, forma_dostepu=None, **kw): self.rodzaj = rodzaj self.forma_dostepu = forma_dostepu + uczelnia = kw.pop("uczelnia", None) self.helper = FormHelper() self.helper.form_tag = False @@ -311,8 +312,13 @@ def __init__(self, *args, rodzaj=None, forma_dostepu=None, **kw): super().__init__(*args, **kw) - uczelnia = Uczelnia.objects.get_default() - if not uczelnia.pytaj_o_zgode_na_publikacje_pelnego_tekstu: + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() + + if ( + uczelnia is not None + and not uczelnia.pytaj_o_zgode_na_publikacje_pelnego_tekstu + ): self.fields.pop("zgoda_na_publikacje_pelnego_tekstu", None) self._usun_pola_wg_formy_dostepu(forma_dostepu) diff --git a/src/zglos_publikacje/migrations/0023_nowy_formularz_zgloszenia.py b/src/zglos_publikacje/migrations/0023_nowy_formularz_zgloszenia.py index 0defdec40..cba951cf4 100644 --- a/src/zglos_publikacje/migrations/0023_nowy_formularz_zgloszenia.py +++ b/src/zglos_publikacje/migrations/0023_nowy_formularz_zgloszenia.py @@ -1,7 +1,8 @@ # Generated by Django 4.2.25 on 2026-04-02 08:57 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models + import zglos_publikacje.models diff --git a/src/zglos_publikacje/models.py b/src/zglos_publikacje/models.py index 3307af5dc..278b6e083 100644 --- a/src/zglos_publikacje/models.py +++ b/src/zglos_publikacje/models.py @@ -243,7 +243,17 @@ def clean(self): or (self.opl_pub_amount is not None and self.opl_pub_amount != 0) ) - uczelnia = Uczelnia.objects.get_default() + # Informacja o opłatach może być opcjonalna, w zależności od ustawień obiektu Uczelnia. + # Informacja o opłatach może być opcjonalna jeżeli rodzaj zgłaszanej publikacji to "pozostałe" + + # W obydwu przypadkach nie walidujemy (nie uruchamiamy ModelZOplataZaPublikacje.clean)... ale pod jednym + # warunkiem: pod takim warunkiem, ze NIC nie zostało wpisane jeżeli chodzi o informację o opłatach + # -- czyli, że zmienna zupelny_brak_informacji_o_oplatach jest False. + + if not hasattr(self, "_uczelnia") or self._uczelnia is None: + uczelnia = Uczelnia.objects.get_default() + else: + uczelnia = self._uczelnia wymaga_oplatach = self._uczelnia_wymaga_oplatach_dla_rodzaju(uczelnia) diff --git a/src/zglos_publikacje/tests/test_forms.py b/src/zglos_publikacje/tests/test_forms.py index ca8645fbf..c9dd7a074 100644 --- a/src/zglos_publikacje/tests/test_forms.py +++ b/src/zglos_publikacje/tests/test_forms.py @@ -171,8 +171,6 @@ def test_walidacja_plikow_dla_dostepu_ograniczonego_z_dict(): W wizardie, gdy formularz jest walidowany ponownie w render_done(), self.files może być zwykłym dict, a nie QueryDict. """ - from django.core.files.uploadedfile import SimpleUploadedFile - baker.make(Uczelnia) # Najpierw sprawdźmy czy formularz bez błędów w polach dochodzi do clean()