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 50ca5a8cc..e3a64effd 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 }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fd88603d9..43017cb3f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -43,6 +43,8 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.gitignore b/.gitignore index 4d505d70a..500a86cf5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ htmlcov/ nosetests.xml coverage.xml coverage.json +coverage-*.json cov_html/ *,cover diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a4e2617d7..a987721e2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,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' @@ -39,6 +55,7 @@ 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. + # # mkdocs.yml uses `!!python/object/apply:pymdownx.slugs.slugify` # (Python-object tag) — SafeLoader odmawia jego budowy, choć # `mkdocs build --strict` parsuje plik bez problemu. diff --git a/CLAUDE.md b/CLAUDE.md index 68b977143..5a6fa6d4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -398,6 +398,27 @@ usuwajacy tagi starsze niz N dni. - Fixtures in `src/conftest.py` and subdirectories - Full suite timeout: at least 600000ms (10 minutes) +### Uruchamiaj testy LOKALNIE — nie spychaj wszystkiego na CI + +**Domyślnie odpalaj testy na swojej maszynie.** „Środowiskowo-ciężkie", +„zostawmy to CI", „Playwright wymaga setupu" to NIE są powody, żeby pominąć +lokalny przebieg — to najwyżej powód, żeby najpierw zrobić warunki wstępne +(`make assets`, `make playwright-install`). Brak warunku wstępnego = wykonaj go, +nie pomijaj testu. + +- **Praca nad zdalnym branchem / w PR:** odpalenie lokalnego `make tests` + **równolegle** z czekaniem na CI jest OK i **zalecane** — szybszy feedback, + łapiesz regresje zanim CI je zwróci, nie marnujesz rundy CI. Te dwa kanały się + nie wykluczają; rób oba. +- Pełne `make tests` przerywa się na pierwszym błędnym kroku (`make` zwraca na + Error 1), więc gdy `tests-without-playwright` padnie, kroki + `tests-only-playwright` i `js-tests` się NIE wykonają. Po naprawie Pythona + **dokończ** pozostałe kroki (albo ponów całe `make tests`), zamiast uznać je + za „pominięte". +- Jedyny akceptowalny powód, by czegoś nie odpalić lokalnie: fizyczny brak + możliwości (np. brak działającego Dockera dla testcontainers) — wtedy powiedz + to wprost, a nie „jest ciężkie". + ### Testy Playwright (`src/integration_tests/`) lokalnie Testy przeglądarkowe (np. `test_global_search.py`) **da się** odpalić diff --git a/Makefile b/Makefile index 8fc212ae3..09a13ded6 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ BRANCH=`git branch | sed -n '/\* /s///p'` -.PHONY: help clean distclean tests test-durations release tests-without-playwright tests-only-playwright docker destroy-test-databases cache-delete buildx-cache-stats buildx-cache-prune buildx-cache-prune-aggressive buildx-cache-prune-registry buildx-cache-export buildx-cache-import buildx-cache-list bump-dev bump-release bump-and-start-dev migrate new-worktree clean-worktree generate-500-page build build-force build-base build-app-services build-appserver-base build-appserver build-workerserver build-beatserver build-authserver build-denorm-queue build-servers check-clean-tree prepare-claude prepare-developer-machine prepare-developer-machine-linux prepare-developer-machine-macos playwright-install +.PHONY: help clean distclean tests test-durations release tests-without-playwright tests-only-playwright docker destroy-test-databases cache-delete buildx-cache-stats buildx-cache-prune buildx-cache-prune-aggressive buildx-cache-prune-registry buildx-cache-export buildx-cache-import buildx-cache-list bump-dev bump-release bump-and-start-dev migrate new-worktree clean-worktree generate-500-page build build-force build-base build-app-services build-appserver-base build-appserver build-workerserver build-beatserver build-authserver build-denorm-queue build-servers docker-images-on-ci check-clean-tree prepare-claude prepare-developer-machine prepare-developer-machine-linux prepare-developer-machine-macos playwright-install .DEFAULT_GOAL := help @@ -530,6 +530,34 @@ gh-run-watch-docker-images: ## Obserwuj najnowszy run workflow "Docker - oficjal gh-run-watch-docker-images-alt: ## Alternatywna wersja gh-run-watch-docker-images (pipe) gh run list --workflow="Docker - oficjalne obrazy" --limit=1 --json databaseId --jq '.[0].databaseId' | xargs gh run watch +docker-images-on-ci: ## Wyślij build "Docker - oficjalne obrazy" na CI i obserwuj go + @BRANCH=$$(git rev-parse --abbrev-ref HEAD); \ + UPSTREAM=$$(git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true); \ + if [ -z "$$UPSTREAM" ]; then \ + echo "BŁĄD: Gałąź $$BRANCH nie ma upstreamu. Wypchnij ją najpierw na remote."; \ + exit 1; \ + fi; \ + if [ -n "$$(git status --porcelain)" ]; then \ + echo "BŁĄD: Working tree zawiera niezcommitowane zmiany. Zcommituj lub stashuj je przed wysłaniem buildu na CI."; \ + exit 1; \ + fi; \ + if [ "$$(git rev-parse HEAD)" != "$$(git rev-parse "$$UPSTREAM")" ]; then \ + echo "BŁĄD: Lokalna gałąź $$BRANCH nie jest zsynchronizowana z $$UPSTREAM."; \ + echo " Wypchnij zmiany przed wysłaniem buildu na CI."; \ + exit 1; \ + fi; \ + echo "Wysyłam build Docker - oficjalne obrazy na CI dla gałęzi: $$BRANCH"; \ + gh workflow run build-docker-images.yml --ref "$$BRANCH"; \ + echo "Czekam na pojawienie się runu..."; \ + sleep 3; \ + RUN_ID=$$(gh run list --workflow="Docker - oficjalne obrazy" --branch="$$BRANCH" --limit=1 --json databaseId --jq '.[0].databaseId'); \ + if [ -z "$$RUN_ID" ]; then \ + echo "BŁĄD: Nie udało się znaleźć nowego runu workflow."; \ + exit 1; \ + fi; \ + echo "Obserwuję run ID: $$RUN_ID"; \ + gh run watch "$$RUN_ID" + ##@ Wersjonowanie i release sleep-3: ## `sleep 3` (helper używany w pipeline release) diff --git a/conftest.py b/conftest.py index deb444790..aceca2a68 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 7dec4e534..38ca99386 100644 --- a/docker/bpp_base/Dockerfile +++ b/docker/bpp_base/Dockerfile @@ -94,6 +94,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/ +# 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). # JS for esbuild bundle eksploratora powiązań (cytoscape-entry + moduły ES) COPY src/powiazania_autorow/static/powiazania_autorow/js/ \ src/powiazania_autorow/static/powiazania_autorow/js/ diff --git a/docs/administrator/konfiguracja-pbn.md b/docs/administrator/konfiguracja-pbn.md index 28cc8f5c9..0b8a00bb6 100644 --- a/docs/administrator/konfiguracja-pbn.md +++ b/docs/administrator/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/administrator/ogolna.md b/docs/administrator/ogolna.md index 0d54adf2b..58644fae9 100644 --- a/docs/administrator/ogolna.md +++ b/docs/administrator/ogolna.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/docs/deweloper/audyt-multihosted-pbn.md b/docs/deweloper/audyt-multihosted-pbn.md new file mode 100644 index 000000000..654c00ccd --- /dev/null +++ b/docs/deweloper/audyt-multihosted-pbn.md @@ -0,0 +1,201 @@ +# Audyt multi-hosted: PBN i `Uczelnia.get_default` + +Data: 2026-06-02. Gałąź: `feature/multi-hosted-config`. + +Cel: w instalacji **wielouczelnianej** (jedna instancja BPP obsługuje wiele +obiektów `Uczelnia`, każda z własną konfiguracją PBN: `pbn_app_name`, +`pbn_app_token`, `pbn_api_root`, token użytkownika) żadna ścieżka runtime nie +może „zgadywać" uczelni. Audyt wynajduje miejsca, które: + +- **(A)** wołają `Uczelnia.pbn_client(...)` / `get_pbn_client(...)`, +- **(B)** budują połączenie do PBN „poza" obiektem `Uczelnia` (ręczna + instancja `PBNClient(RequestsTransport(...))`), +- **(C)** „zgadują" uczelnię przez `get_default()` / `objects.default` / + `.first()`. + +## Kontekst API + +- `Uczelnia.pbn_client(pbn_user_token=None)` — metoda **instancji**: buduje + klienta PBN z konfiguracji **tej** uczelni. Wywołanie na złej uczelni = + połączenie ze złym kontem PBN. +- `UczelniaManager.get_default()` → `self.all().first()` — **pierwsza + z brzegu**. W multi-hosted to losowy/błędny strzał. +- Poprawne resolvery (już istnieją): `get_for_request(request)`, + `get_for_pbn_background(uczelnia_id)` (rzuca `ValueError` przy `None`), + `get_for_site(site)`. + +## Ustalenie kluczowe + +`PBNClient` **nie zna swojej `Uczelnia`** (`client/__init__.py` trzyma tylko +`self.transport`). Dlatego nawet gdy klient zbudowano z właściwej uczelni, kod +*wewnątrz* klienta (`publication_sync.py`, adapter) **ponownie zgaduje** +uczelnię przez `get_default()`. To źródło kilku WYSOKICH ryzyk i główny motyw +rozbicia `PBNClient` na dwie warstwy (osobny spec: +`docs/superpowers/specs/2026-06-02-pbn-client-split-design.md`). + +--- + +## Tier 🔴 WYSOKIE — runtime buduje ZŁEGO klienta PBN/OAuth lub wpis kolejki bez uczelni + +| Miejsce | Wzorzec | Problem | +|---|---|---| +| `pbn_api/adapters/wydawnictwo.py:94` ← `pbn_api/client/publication_sync.py:191, 622` | C | Adapter wysyłki instancjonowany w środku klienta **bez** uczelni → `get_default()` czyta flagi payloadu (`pbn_api_nie_wysylaj_prac_bez_pk`, `pbn_wysylaj_bez_oswiadczen`) z losowej uczelni | +| `pbn_import/utils/import_manager.py:108→125` + `initial_setup.py:23→31` | A+C | `tasks.py` poprawnie wybiera uczelnię, ale `ImportManager` **nie propaguje** jej do kroków → `get_default()` **nadpisuje** `self.client` klientem złej uczelni (regresja na już-poprawionej ścieżce) | +| `importer_publikacji/providers/pbn.py:42, 214` + `views/pbn_check.py:131` | A+B+C | `_get_pbn_client()` buduje klienta **ręcznie** (`PBNClient(RequestsTransport(...))`) z `get_default()`, mimo że oba wywołania siedzą w widokach z `request`. Jedyny produkcyjny wzorzec (B) | +| `importer_publikacji/tasks.py` → `bpp/admin/helpers/pbn_api/gui.py:87` → `cli.py:43` | C | `create_publication_task` tworzy `_PbnRequestStub` bez `_uczelnia`; wpis `PBN_Export_Queue` powstaje z `uczelnia=None`; wysyłka z kolejki znów spada do `get_default()`. Docstring wprost zakłada „fallback OK" — błędne w multi-hosted | +| `orcid_integration/views.py:29` (`_get_orcid_client`) | B+C | Buduje `OrcidClient` z credentiali uczelni przez `get_default()`, mimo dostępnego `request` → logowanie do złego konta ORCID | +| `pbn_integrator/utils/scientists.py:61/156` | A+C | Buduje klienta z `get_default()` gdy `uczelnia=None` (w praktyce łagodzone — zwykle przekazuje się gotowy klient) | + +## Tier 🟠 ŚREDNIE — runtime zgaduje uczelnię dla DANYCH, nie dla klienta + +Skutkuje złym `pbn_uid`, błędnymi filtrami/flagami, ale **nie** łączy się ze +złym kontem PBN. + +| Miejsce | Co zgaduje | +|---|---| +| `pbn_api/client/publication_sync.py:287, 1046` | flaga `pbn_kasuj_dyscypliny_selektywnie` (strategia DELETE oświadczeń) | +| `importer_autorow_pbn/views.py:69` | `objects.default` do filtra listy naukowców po `pbn_uid_id` | +| `pbn_import/utils/{author_import.py:18, publication_import.py:79, institution_import.py:101}` | `pbn_uid_id`, `obca_jednostka` w ścieżce Celery | +| `pbn_integrator/utils/scientists.py:435`, `institutions.py:64/86`, `importer/authors.py:89+` | `pbn_uid_id`, `obca_jednostka` przy imporcie | +| `pbn_integrator/management/commands/pbn_integrator.py:217` | `pbn_uid_id` mimo dostępnej `uczelnia` w `handle()` | +| `zglos_publikacje/forms.py:316`, `models.py:254` | flagi formularza zgłoszeń (wizard nie przekazuje uczelni) | +| `importer_publikacji/views/{steps.py:336, publikacja.py:125}` | flagi `pbn_integracja`/`pbn_aktualizuj_na_biezaco`, `obca_jednostka` | +| `bpp/models/sloty/core.py:34`, `abstract/disciplines.py:18`, `jednostka.py:46`, `multiseek_registry/fields/numeric_fields.py:71`, `abstract/pbn.py:23/89` | per-uczelnia ustawienia: ukryte statusy, sortowanie, index copernicus, liczenie slotów, linki PBN | + +## Tier 🟢 OK / NISKIE — jawny resolver albo świadomy fallback + +- Jawny `get_for_request`/`pbn_client` tej uczelni: `crossref_bpp/views.py:124`, + `bpp/views/api/pbn_get_by_parameter.py:56/62`, + `bpp/views/autocomplete/{pbn_api.py:82, wydawnictwo_nadrzedne_w_pbn.py:172}`, + `bpp/admin/helpers/pbn_api/gui.py:137`, `bpp/admin/uczelnia.py:307`. +- Już naprawione ścieżki Celery (ostatnie commity) przez + `get_for_pbn_background(uczelnia_id)`: `pbn_downloader_app/tasks.py`, + `pbn_wysylka_oswiadczen/tasks.py`, `pbn_export_queue` (FK na wpisie), + `pbn_import/tasks.py:78/82`. +- Wzorcowa warstwa management commands: + `pbn_api/management/commands/util.py:_resolve_uczelnia` — `get_default()` + TYLKO gdy `count==1`, inaczej `CommandError`. +- Świadome, udokumentowane fallbacki: `bpp/middleware.py:295` (Site bez + Uczelni), `bpp/util/bpp_specific.py:104` (CLI/Celery bez requestu), + `do_roku_default`. Migracje backfill i testy. + +--- + +## Audyt wewnętrzny `PBNClient` — gdzie potrzebna jest `Uczelnia` + +Pełny audyt linia-po-linii w `src/pbn_api/client/` + `src/pbn_api/adapters/`. + +### Kontrakt: pola `Uczelnia` faktycznie używane przez warstwę klienta + +Tylko **trzy** flagi `bool` przepływają do logiki klienta: + +| Flaga | Gdzie | Cel | Typ do W1 | +|---|---|---|---| +| `pbn_kasuj_dyscypliny_selektywnie` | `publication_sync.py:289, 1048` | strategia DELETE oświadczeń: per-osoba vs batch | `bool` | +| `pbn_wysylaj_bez_oswiadczen` | `adapters/wydawnictwo.py:100` | praca bez statements → inny endpoint + pre-clear | `bool` (przez obecność `statements` w JSON) | +| `pbn_api_nie_wysylaj_prac_bez_pk` | `adapters/wydawnictwo.py:97` | blokuje eksport prac z `punkty_kbn==0` | `bool` (już jako `export_pk_zero`) | + +Cała reszta sprzężenia to rekord BPP (do adaptera) i modele persystencji +(`SentData`, `Rekord`, `Publication`, `OswiadczenieInstytucji`, +`PBNOdpowiedziNiepozadane`, `PublikacjaInstytucji_V2`, `Dyscyplina_Naukowa`, +`TlumaczDyscyplin`). + +**Kontrakt W2→W1 dla sync publikacji:** +`(pbn_publication_json, statements_intended, pbn_uid, kasuj_selektywnie: bool, +bez_oswiadczen: bool)`. + +### Czyste (Warstwa 1) vs BPP-aware (Warstwa 2) + +- **Czyste PBN (zostają w `pbn_client`):** `transport`, `auth` (OAuth), + `pagination`, `utils`, wszystkie 8 mixinów słownikowo-CRUD (`conferences`, + `dictionaries`, `institutions`, `journals`, `person`, `publications`, + `publishers`, `search`) — **zero importów `bpp`**. Plus z `publication_sync`: + silnik oświadczeń (`_diff_statements`, `_delete_statements_*`, + `_get_pbn_statements_with_retry`, `post_publication*`, `get_publication_fee*`, + `convert_json_with_statements_to_no_statements`, `_convert_stmt_for_api`). +- **BPP-orchestracja (→ `pbn_client_bpp`):** `sync_publication`, + `upload_publication`, `_prepare_publication_json`, `_check_upload_needed`, + `_pre_upload_clear_pbn_statements_if_any`, `download_publication`, + `download_statements_of_publication`, `pobierz_publikacje_instytucji_v2`, + `_build_post_statements_payload`, `_handle_uid_change/_handle_uid_conflict`, + `eventually_coerce_to_publication`, `upload_publication_fee`, oraz **cały** + `DisciplinesMixin` (`sync_disciplines`). Plus wszystkie `adapters/*`. +- **Mieszane (rozcięcie):** `_post_statements_with_retry`, + `_sync_statements_with_pbn` — W2 dostarcza gotowy payload / „intencję" + (listy dict z adaptera) + flagi, W1 robi czyste HTTP/diff. + +### Skala migracji call-site'ów + +- Metody orchestracji woła się **tylko w 6 miejscach** (poza klientem/testami): + `bpp/admin/helpers/pbn_api/common.py:155`, + `pbn_import/utils/initial_setup.py:73`, + `pbn_integrator/management/commands/pbn_integrator.py:182`, + `pbn_integrator/utils/synchronization.py:91, 277, 321`. +- `from pbn_api.client import PBNClient`: 35 importów (utrzymać re-eksportem). +- Budowa klienta przez `Uczelnia.pbn_client(...)`: ~20 call-site'ów — jedna + fabryka. + +--- + +## Decyzja w sprawie `get_default` + +(Patrz dyskusja z 2026-06-02.) + +- **Produkcja (runtime: widoki, zadania, sygnały)** — nigdy nie zgaduje; + uczelnia przychodzi jawnie (`get_for_request`, argument, `self.uczelnia` w W2). +- **Legalny przypadek „jest jedna uczelnia" (testy + single-install CLI)** — + `Uczelnia.objects.get()` (Django rzuca `MultipleObjectsReturned` przy >1, + `DoesNotExist` przy 0). Bez nowej metody typu `get_single_fail_if_more`. +- **`get_default`** — nie wołać w nowym kodzie; legalnych callerów migrować na + `.get()`; docelowo zostawić tylko świadomy „dowolna" (ewentualny + `get_arbitrary()` dla `middleware` Site-bez-uczelni) albo wycofać. + +### Status: ZREALIZOWANE (2026-06-02, Fazy 1–9) + +Cleanup wykonany. Pozostały po nim **tylko świadome użycia** `get_default`/ +`objects.default` (15 plików), pilnowane przez sentinel-test +`src/bpp/tests/test_multihosted_get_default_guard.py` (nowy `get_default` +w runtime → fail CI). Kategorie pozostałych: + +- **Świadome fallbacki bez requestu:** `middleware.py` (Site bez Uczelni), + `util/bpp_specific.py` (CLI/Celery bez requestu), `pbn_import_tags.py` + (template tag, request-first), `command_helpers.py` (CLI + `CommandError`). +- **None-tolerant warstwa modelu/wyświetlanie:** `jednostka.py` (sortowanie), + `multiseek .../numeric_fields.py` (index copernicus), `abstract/pbn.py` + (root linków). +- **GUARDED:** `pbn_api/.../util.py` (`count==1` else `CommandError`). +- **Test-only:** `adapters/wydawnictwo.py` (runtime przekazuje jawną uczelnię). +- **PARKED z TODO (deeper redesign per-uczelnia):** `sloty/core.py`, + `abstract/disciplines.py` (sloty/punktacja per-uczelnia — cache per + rekord×uczelnia), oraz integrator (`scientists.py`, `importer/authors.py`, + `pbn_integrator.py` — threading uczelni docelowej przez pipeline). + +Reszta runtime została przepięta na jawną uczelnię: `get_for_request` (widoki, +ORCID, importer steps/detail), `session.uczelnia` (importer_publikacji), +`self.uczelnia` w `BppPBNClient`/`ImportManager` (PBN sync, import), oraz +`Uczelnia.objects.get()` w fallbackach single-install (komendy CLI, taski +z `uczelnia_id`). + +**Następny, osobny wątek (brainstorm):** per-uczelnia liczenie slotów/punktacji +(parked TODO) — `Cache_Punktacja_*` z `uczelnia_id`, liczenie+zapis per +uczelnia autora, odczyty filtrowane po uczelni oglądającego. + +## Backfill per-uczelnia cache (write-side) + +Migracje dodają nullable `uczelnia` na `Cache_Punktacja_Dyscypliny` +(`0424_cache_punktacja_dyscypliny_uczelnia_and_more`) i naprawiają widok +`bpp_cache_punktacja_autora_view` tak, by joinował po uczelni +(`0425_per_uczelnia_cache_view`). + +Po deployu należy **przeliczyć cache** pełnym przeliczeniem punktów dyscyplin — +nowy kod (`IPunktacjaCacher.rebuildEntries`) zapisze wiersze osobno per uczelnia. +W instalacji jednouczelnianej liczby pozostają identyczne (fast-track: jedna +uczelnia w systemie ⇒ liczenie jak dotychczas, tylko z otagowaniem uczelnią). + +Wyzwolenie pełnego przeliczenia: rebuild pól `@denormalized cached_punkty_dyscyplin` +(np. denorm rebuild używany w projekcie lub `denorms.flush()` w shellu), który +woła `przelicz_punkty_dyscyplin()` per rekord. Konkretną komendę denorm rebuild +potwierdzić w środowisku docelowym. + +Opcjonalnie, po przeliczeniu, można dodać migrację zacieśniającą +`Cache_Punktacja_Dyscypliny.uczelnia` do `null=False`. diff --git a/docs/deweloper/przeglad-pbn-import-2026-06-12.md b/docs/deweloper/przeglad-pbn-import-2026-06-12.md new file mode 100644 index 000000000..4e51dbb40 --- /dev/null +++ b/docs/deweloper/przeglad-pbn-import-2026-06-12.md @@ -0,0 +1,155 @@ +# Przegląd kodu importu PBN — 2026-06-12 + +> Read-only review przeprowadzony na branchu `feature/multi-hosted-config`. +> Implementacja poprawek: branch `feature/pbn-import-cleanup` → PR do +> `feature/multi-hosted-config`. Plan wdrożenia: +> [`docs/superpowers/plans/2026-06-12-pbn-import-cleanup.md`](../superpowers/plans/2026-06-12-pbn-import-cleanup.md). + +## Zakres + +Przegląd `src/pbn_import/` oraz powiązanego kodu importu PBN +(`pbn_integrator`, `pbn_api`, `import_common`). ~22k linii kodu produkcyjnego +(bez testów/migracji) w czterech głównych aplikacjach. + +## Architektura — obraz ogólny + +System jest poprawnie zbudowany **warstwowo**, nie jako konkurujące +reimplementacje: + +``` +pbn_import (UI WebSocket/HTMX + ImportManager + pipeline kroków) ← nowsza prezentacja + └─deleguje→ pbn_integrator (właściwy silnik importu encji) ← kanoniczny + └─używa→ pbn_api (klient + adaptery + modele) +``` + +Zależność jest jednokierunkowa: `pbn_integrator` ma **zero** realnych +zależności kodowych od `pbn_import` (dwa „odwrotne" trafienia to nazwa +loggera `"pbn_import"` w `pbn_integrator/importer/publishers.py:14` oraz +`call_command` do komendy z `pbn_api`). 8 z 10 klas-kroków w +`pbn_import/utils/*_import.py` to cienkie wrappery (30–90 linii) +delegujące do `pbn_integrator`. **Nie ma duplikacji importerów encji do +usunięcia** — konsolidacja przeniosłaby kod, nie usunęła. + +## Ustalenia i rekomendacje (rankingowane) + +### 1. Martwa warstwa WebSocket — usunąć w całości ✅ DO ZROBIENIA + +`pbn_import` dostarcza dwa mechanizmy postępu: Django Channels (WebSocket) +**oraz** polling HTMX. Działa tylko HTMX. Ścieżka WS jest martwa na trzy +niezależnie zabójcze sposoby (zweryfikowane bezpośrednio): + +- **Niezgodność koperty.** Każde `send_websocket_update` pakuje payload jako + `{"type": "import_update", ...}` (`tasks.py:27`, `views.py:307`), więc + dispatchowany jest tylko handler `import_update` konsumenta. Bogatsze + handlery `progress_update`/`log_entry`/`status_change`/`completion_notification` + (`consumers.py:74–134`) są **nieosiągalne**. +- **Klient porzuca wszystko.** `dashboard.html:459` robi `switch(data.type)` + na zewnętrznej kopercie (zawsze `"import_update"`), a `case` to + `'progress_update'`/`'log_entry'`/`'completion'` — żaden nie pasuje, każda + wiadomość jest odrzucana. +- **Nawet pasujący handler nic nie robi.** `updateProgressDisplay` ustawia + tylko `document.title`; `addLogEntry` (`dashboard.html:464`) jest + **wywoływany, ale nigdy nie zdefiniowany** (ReferenceError). + +Polling HTMX (`every 5s` na postępie/logach) jest w pełni podłączony i to +on realizuje cały realtime. + +**Akcja:** usunąć `consumers.py`, `routing.py`, helpery i wywołania +`send_websocket_update` (`tasks.py`, `views.py`), blok ` +""" class Command(BaseCommand): - help = "Generuje statyczny plik 500.html dla nginx" + help = "Generuje statyczne pliki 500.html dla nginx (generyczny + per-domena)" def handle(self, *args, **options): - # Create fake request with anonymous user - factory = RequestFactory() - request = factory.get("/") - request.user = AnonymousUser() - request.session = {} + static_root = Path(settings.STATIC_ROOT) + + # 1. Generyczny fallback — pojedyncza uczelnia (single-site) albo + # neutralna „niezdefiniowana uczelnia" (multi-site bez dopasowania + # domeny). nginx serwuje go przez `try_files ... /static/500.html`, + # gdy brak strony per-domena. Host z ALLOWED_HOSTS, by jakikolwiek + # procesor/template wołający get_host() nie wywalił DisallowedHost. + generic_html = self._render_500( + Uczelnia.objects.get_single_uczelnia_or_none(), self._valid_host() + ) + # Source-dir (gitignored) — zbierany przez collectstatic na buildzie, + # zachowuje wsteczny kontrakt z obrazami pre-multi-hosted. + bpp_app_dir = Path(__file__).parent.parent.parent + self._write(bpp_app_dir / "static" / "500.html", generic_html) + # $STATIC_ROOT — autorytatywne miejsce serwowane przez nginx w runtime. + self._write(static_root / "500.html", generic_html) + + # 2. Per-domena — każdy Site dostaje stronę z brandingiem swojej + # uczelni w `$STATIC_ROOT/500/.html`. + count = 0 + for site in Site.objects.all(): + try: + uczelnia = Uczelnia.objects.get_for_site(site) + html = self._render_500(uczelnia, site.domain) + self._write(static_root / "500" / f"{site.domain}.html", html) + count += 1 + except Exception: + # Best-effort artefakt: jedna wadliwa domena nie może wywalić + # generacji pozostałych. Loguj pełny traceback i kontynuuj. + self.stderr.write( + self.style.ERROR( + f"Nie udało się wygenerować strony 500 dla domeny " + f"{site.domain!r}:" + ) + ) + traceback.print_exc() + continue - # Create fake Uczelnia object for 500 error page - class FakeUczelnia: - skrot = "Strona główna" - nazwa = "Błąd 500" + self.stdout.write( + self.style.SUCCESS( + f"Wygenerowano stronę 500: generyczną + {count} per-domena " + f"w {static_root}" + ) + ) - def sprawdz_uprawnienie(self, attr, request, ignoruj_grupe=None): - # For 500 error page, don't show any permission-restricted content - return False + def _render_500(self, uczelnia, host): + """Wyrenderuj finalny HTML strony 500 dla danej uczelni i hosta. - uczelnia_context = {"uczelnia": FakeUczelnia()} + ``uczelnia`` może być ``None`` (→ neutralna „niezdefiniowana uczelnia"). + ``host`` trafia do ``SERVER_NAME`` fałszywego requestu — musi być w + ALLOWED_HOSTS, bo procesory/template mogą wołać ``get_host()``. + """ + factory = RequestFactory() + request = factory.get("/", SERVER_NAME=host) + request.user = AnonymousUser() + request.session = {} + # KLUCZ: ustaw uczelnię z góry. ``Uczelnia.objects.get_for_request`` + # zwraca ``request._uczelnia`` ZANIM sięgnie po ``get_host()`` w + # ``_site_dla_requestu`` — to jednocześnie wymusza właściwy branding + # per-domena i uodparnia command na DisallowedHost (testserver). + request._uczelnia = uczelnia - # Build context from all context processors context = {} - context.update(uczelnia_context) context.update(bpp_configuration(request)) context.update(global_nav_user(request)) context.update(google_analytics(request)) context.update(microsoft_auth_status(request)) context.update(testing(request)) - - # Add any additional required context context["messages"] = [] context["password_change_required"] = False - - # Set cookielaw to accepted to avoid showing cookie banner on 500 page + # Ustaw cookielaw na zaakceptowane, by nie pokazywać bannera cookies. context["cookielaw"] = {"notset": False, "accepted": True, "rejected": False} - # Render the template + # Context processor ``uczelnia`` trzyma globalny cache ``b"bpp_uczelnia"`` + # NIE rozróżniający domen — przy seryjnym renderowaniu per-domena + # pierwsza uczelnia „zatrułaby" kolejne strony. Czyść przed każdym + # renderem, by processor policzył uczelnię z ``request._uczelnia``. + cache.delete(b"bpp_uczelnia") + template = loader.get_template("50x.html") rendered_html = template.render(context, request) + rendered_html = rendered_html.replace("", CLEANUP_SCRIPT + "") - # Add JavaScript to remove login menu from the rendered page - cleanup_script = """ - -""" - # Insert the script before tag - rendered_html = rendered_html.replace("", cleanup_script + "") - - # Add warning comment at the top warning = f""" - {% with box=praca.autorzy_dla_opisu_skrocony %} + {% autorzy_skrocony praca uczelnia as box %}
{% if box.skrocony %} @@ -57,7 +57,6 @@

{% endif %}

- {% endwith %} @@ -292,21 +291,21 @@

{% endif %} - {% if not request.user.is_anonymous and praca.link_do_pi %} + {% if not request.user.is_anonymous and praca|link_do_pi:uczelnia %} {% endblock %} diff --git a/src/django_bpp/templates/top_bar.html b/src/django_bpp/templates/top_bar.html index ac0bf0bf0..b7d075dd8 100644 --- a/src/django_bpp/templates/top_bar.html +++ b/src/django_bpp/templates/top_bar.html @@ -287,7 +287,7 @@ mój profil - {% if not microsoft_login_enabled %} + {% if not logged_in_via_external_auth %}
  • zmiana hasła
  • {% endif %} {% if request.user.is_superuser %} @@ -461,13 +461,18 @@ {% endif %} {% if request.user.is_anonymous %} - {% if microsoft_login_enabled %} + {% if microsoft_login_enabled or oidc_login_enabled %}
  • zaloguj