Skip to content

Feature/multi hosted config#189

Open
mpasternak wants to merge 243 commits into
devfrom
feature/multi-hosted-config
Open

Feature/multi hosted config#189
mpasternak wants to merge 243 commits into
devfrom
feature/multi-hosted-config

Conversation

@mpasternak

Copy link
Copy Markdown
Member

No description provided.

mpasternak and others added 30 commits April 28, 2026 18:00
Add infrastructure for multi-hosted BPP configuration where one server
can serve multiple universities on different domains.

- Uczelnia: add OneToOne to django.contrib.sites.Site + theme_name field
- BppUser: add M2M accessible_sites for per-user university access control
- SiteResolutionMiddleware: resolves hostname → Site → Uczelnia on every request
- Context processors: site-aware cache keys, per-uczelnia theme selection
- Admin: site/theme fields in UczelniaAdmin, accessible_sites in BppUserAdmin
- Data migration: links existing Uczelnia to Site(pk=1), grants staff access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move all per-uczelnia settings from django-constance to fields on the
Uczelnia model, enabling per-university configuration in multi-hosted mode.

- Add 8 new fields to Uczelnia: google_analytics_property_id,
  google_verification_code, pokazuj_oswiadczenie_ken,
  skrot_wydzialu_w_nazwie_jednostki, wydruk_margines_* (4 fields)
- Context processor reads from Uczelnia instead of constance.config
- Admin mixin reads scoring settings from Uczelnia instead of constance
- Empty CONSTANCE_CONFIG (all settings migrated to Uczelnia)
- Data migration copies existing Constance values to Uczelnia
- UczelniaAdmin: add new fieldsets for Google, structure, margins

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add SiteFilteredAdminMixin to filter admin querysets by current uczelnia.
Regular admins see only their university's data, superusers see all.

- New SiteFilteredAdminMixin in src/bpp/admin/helpers/site_filtered.py
- Applied to JednostkaAdmin (uczelnia), WydzialAdmin (uczelnia),
  AutorAdmin (aktualna_jednostka__uczelnia), UczelniaAdmin (pk filter)
- FK dropdowns for wydzial/jednostka filtered per-uczelnia
- Middleware blocks admin access for staff with accessible_sites
  configured but missing current site (backward compat: no sites = allow)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add uczelnia ForeignKey to PBN models that store per-institution data,
enabling multi-tenant PBN operations.

- Add uczelnia FK to: OsobaZInstytucji, PublikacjaInstytucji,
  PublikacjaInstytucji_V2, OswiadczenieInstytucji, SentData
- Data migration links all existing records to first Uczelnia
- Apply SiteFilteredAdminMixin to all 5 PBN admin classes
- Fix PublikacjaInstytucji_V2.link_do_pi() to use self.uczelnia

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add site_cache_key utility and update admin filter count cache to include
site_id, preventing cross-tenant cache pollution.

- New src/bpp/cache_utils.py with site_cache_key() utility
- Admin filter_count_view cache key includes site.pk
- Fix test cache invalidation for per-site keys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace Uczelnia.objects.get_default() with proper multi-site patterns:
- Views: use get_for_request(request) or self.request
- Celery tasks: add uczelnia_id parameter with fallback to get_default()
- Management commands: add --uczelnia-id argument
- Refactor pbn_integrator handle() to reduce complexity (C901 33→<10)
- Fix UP031 percent format → f-strings in ranking_autorow
- Refactor ranking get_queryset() to reduce complexity (C901 13→<10)

28 files updated across tasks, commands, views, admin helpers, and menu.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace Uczelnia.objects.get_default() in model/utility code that has
no request context, by adding uczelnia parameter with fallback.

bpp models:
- jednostka.py: use self.uczelnia in get_default_ordering
- abstract/pbn.py: add uczelnia param to link_do_pbn, _format_link_pi
- abstract/disciplines.py: add uczelnia param to przelicz_punkty_dyscyplin
- multiseek_registry/fields: add uczelnia param to option_enabled
- admin/helpers/pbn_api/cli.py: add uczelnia param, fix B904 raise from

PBN import/integrator utilities:
- pbn_import/utils: add uczelnia param to all importer classes
- pbn_integrator/utils: add uczelnia param to scientists and institutions
- pbn_import/templatetags: use request from template context

Other:
- zglos_publikacje: forms accept uczelnia kwarg, model uses _uczelnia attr
- importer_publikacji: add uczelnia param to helpers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SITE_ID configurable via DJANGO_BPP_SITE_ID env var (default=1)
- Add DJANGO_BPP_ENABLE_SITEMAPS env var to disable static-sitemaps
  in multi-hosted mode (static-sitemaps generates for one domain only)
- django_countdown already multi-site friendly (uses get_current_site)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New test fixtures (conftest_multisite.py):
- site1/site2, uczelnia1/uczelnia2, staff users per-site
- wydzial/jednostka/autor per-uczelnia
- make_request_for_site() helper for simulating domain requests

Middleware tests (test_site_resolution.py, 9 tests):
- Hostname→Site→Uczelnia resolution
- Fallback to SITE_ID for unknown hosts
- Staff blocked from wrong site's admin (403)
- Superuser allowed everywhere
- Anonymous allowed on public pages
- Backward compat: staff with no sites configured

Admin filtering tests (test_site_filtered.py, 5 tests):
- Jednostka/Wydzial filtered per-uczelnia for staff
- UczelniaAdmin shows only own uczelnia for non-superuser
- Superuser sees all data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Article model: add M2M uczelnie field (default: all universities)
- ArticleAdmin: filter_horizontal for uczelnie, auto-assign all on create
- Browse view: filter articles, recently_updated, abstracts, total count
  by authors from current uczelnia's units
- Root view: use get_for_request instead of .first()
- Data migration: assign existing articles to all uczelnie
- Fix get_absolute_url to use self.uczelnie.first()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- PBN_Export_Queue: add uczelnia FK + SiteFilteredAdminMixin in admin
  Data migration links existing records to first Uczelnia
- Deduplikator autorów/publikacji: has_module_permission = superuser only
  (deduplikacja jest operacją globalną, nie per-uczelnia)
- Rozbieżności IF/PK/dyscyplin: TODO markers for per-uczelnia filtering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rozbieżności dyscyplin:
- RozbieznosciViewAdmin/RozbieznosciZrodelViewAdmin: filter by
  autor__aktualna_jednostka__uczelnia for non-superusers

Rozbieżności IF/PK:
- RozbieznosciIfLogAdmin/RozbieznosciPkLogAdmin: filter by
  rekord__autorzy_set__jednostka__uczelnia with distinct()
- IgnorujRozbieznoscIf/PkAdmin: superuser-only (GenericFK)

Autocomplete:
- AutorAutocompleteBase: filter by aktualna_jednostka__uczelnia
  when request._uczelnia is set (admin context)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
5 integration tests verifying multi-site data isolation:
- Article visible only on assigned uczelnia
- Article on both uczelnie when both assigned
- Staff cannot see other uczelnia's jednostki in admin
- Staff gets 403 on wrong uczelnia's admin
- Browse record count scoped per uczelnia

Fix: browse view queryset used invalid `original__autorzy_set` path
(original is a cached_property, not a DB field). Changed to
`autorzy__jednostka__in` which is the correct ORM path for Rekord
materialized view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per Ultraplan review: change BppUser M2M from Site to Uczelnia for
clearer semantics — user has access to universities, not domains.

- BppUser.accessible_sites (M2M→Site) → accessible_uczelnie (M2M→Uczelnia)
- Migration: add new field, copy data (Site→Uczelnia via OneToOne), remove old
- Middleware: check access by uczelnia instead of site
- Admin: update fieldset reference
- Fixtures + tests: updated to use accessible_uczelnie

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Łączy leafy grafu migracji powstałe po rebase feature/multi-hosted-config:
- bpp: 0413_bppuser_autor_onetoone (dev) + 0415_rename_accessible_sites_to_uczelnie (feature)
- miniblog: 0003_alter_article_article_body (dev) + 0004_assign_articles_to_all_uczelnie (feature)
Dwie pozostałości po Phase 4 — Uczelnia.objects.first() i .all().first()
w widoku rankingu autorów. W multi-site zwracały losową uczelnię zamiast
tej z bieżącego requestu, przez co podgląd "uzywaj_wydzialow" i
"pokazuj_liczbe_cytowan_w_rankingu" nie respektował ustawień uczelni
hostującej daną stronę.
Code:
- autocomplete/authors.py: getattr(getattr(self, "request", None), ...)
  zamiast self.request — view'y są instancjonowane bezpośrednio w testach
  bez routingu HTTP.
- browse.py:JednostkiView.get_paginate_by: użyj None-safe
  Uczelnia.objects.get_for_request, zamiast hasattr-ochrony zwracającej
  static fallback.

Testy zaktualizowane do nowego API:
- test_handlers.test_handler403_permission_denied: @pytest.mark.django_db
  (SiteResolutionMiddleware sięga do DB jak handler403/404/500).
- pbn_export_queue test_admin: patch admin.ModelAdmin.response_change
  zamiast __bases__[1] (po dodaniu SiteFilteredAdminMixin baza
  ModelAdmin przesunęła się na index 2).
- test_browse: a.uczelnie.set([uczelnia]) — Article jest M2M-przypisany
  do uczelni od Phase: Miniblog M2M.
- test_oai, test_ewaluacja_no_queries: bump query budgetu o +3 (Site.get
  + site.uczelnia + cache lookup z SiteResolutionMiddleware).

ImportError w django_pg_baseline/tests/test_rebuild.py jest pre-existing
na dev (eb1a124), nie regresja tej gałęzi.
Bugfixy (request był dostępny, ale używano get_default()/first()):
- bpp/context_processors/orcid.py — orcid_login_enabled flag.
- orcid_integration/backends.py — auth backend's authenticate(request)
  ignorował request. Realny problem bezpieczeństwa: w multi-site
  uczelnia.orcid_tylko_dla_pracownikow rozstrzygane było po losowej
  uczelni, nie tej z hosta.
- bpp/admin/jednostka.py — get_changeform_initial_data(self, request).
- ranking_autorow: refactor RankingAutorowForm — sygnatura
  __init__(self, lata, *args, request=None, **kwargs), klasowa lambda
  w polu rozbij_na_jednostki przeniesiona do __init__. View przekazuje
  request przez get_form_kwargs.

Site OneToOne obowiązkowe:
- Model: usunięto null=True, blank=True z Uczelnia.site.
- Migracja 0417_ensure_uczelnia_site_not_null: data migration
  fail-loudly dla niejednoznacznych przypadków, AlterField NOT NULL.
- Setup wizard (UczelniaSetupForm.save) — auto-link do
  get_current_site(request).
- Admin (UczelniaAdmin.save_model) — auto-link przy tworzeniu nowej
  Uczelni.
- Test util any_uczelnia + fixture uczelnia w conftest_models —
  get_or_create Site(domain="testserver") jeśli nie podano.
- test_views_browse: zamiana Uczelnia.objects.create(...) na
  any_uczelnia(...).

Pełny suite: 3682 passed, 0 failed.
Zmiana semantyki przypisania artykułu do uczelni:
- Niepusty M2M ``Article.uczelnie`` = artykuł widoczny tylko na wybranych
  uczelniach (bez zmian).
- Pusty M2M = artykuł widoczny na WSZYSTKICH uczelniach (lazy resolution
  zamiast eager-assignment z ArticleAdmin.save_model).

Zalety vs. poprzednia implementacja (admin save_model assign all):
- Nowo utworzona Uczelnia automatycznie widzi artykuły z pustym M2M (przed
  zmianą trzeba było ręcznie edytować artykuły dodane przed nową uczelnią).
- Edycja artykułu z czyszczeniem M2M = "pokazuj wszędzie" (przed: artykuł
  znikał wszędzie, bo save_model sprawdzał `not change`).

Implementacja:
- ``Article.objects.visible_on(uczelnia)`` manager method z
  ``Q(uczelnie=uczelnia) | Q(uczelnie__isnull=True)``.
- ``bpp.views.browse.get_uczelnia_context_data`` używa ``visible_on``
  zarówno dla listy ostatnich artykułów, jak i dla pojedynczego
  artykułu (``get_object_or_404``).
- Usunięto ``ArticleAdmin.save_model`` (eager-assignment do wszystkich).

Tests:
- ``test_article_with_empty_m2m_visible_on_all_uczelnie`` — nowy test
  weryfikujący lazy resolution.
- Istniejące testy isolation/explicit-assignment zostają zielone.

Brak migracji — zgodnie z decyzją, brak istniejących instalacji do
zaktualizowania.
Plik .docker-build juz nie istnieje (skasowany w poprzednim commicie),
wiec elif sprawdzajacy `[ -f ".docker-build" ]` byl dormantnym kodem.
Zastapione: push na non-master (czyli feature/fix/hotfix przez
restrykcje triggera) → buduj zawsze. Realizuje user-intent "auto-build
na feature branches" — bez tego push na feature spadalby na else
(skip), a `.docker-build` flag nie istnieje.

Komentarze i opisy aktualizowane — bez wzmianek o pliku flagi.
Pozostale `docker-build` w workflow to label PR-a (mechanizm zostaje).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wycofuje gating labelem .docker-build/docker-build na rzecz prostszej
zasady: master/main push i workflow_dispatch buduja zawsze (release
flow + manual override), pozostale (PR sync, feature/fix/hotfix push
bez PR) — tylko gdy actor=mpasternak. Inni contributorzy nie pala
Docker Cloud minutek; jesli trzeba zbudowac obraz dla cudzego PR-a:
`gh workflow run build-docker-images.yml --ref <branch>`.

Dev branch dopisany jawnie do komentarza w pushu jako "intentionally
excluded" — push do dev nie odpala buildu (intermediate state nie
zasluguje na obraz, release leci przez master).

Dodany main do triggerow obok master (gdyby kiedys repo zmienilo
default branch — single source of truth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-hosted deployment (jedna instalacja BPP, wiele uczelni/domen)
nie mieścił się w pojedynczym DJANGO_BPP_HOSTNAME. Wprowadzona zmienna
DJANGO_BPP_HOSTNAMES (CSV) rozwiązuje to bez breaking change:

- Jeśli ustawisz DJANGO_BPP_HOSTNAMES, jest source-of-truth dla
  ALLOWED_HOSTS i CSRF_TRUSTED_ORIGINS. Pierwszy element listy staje się
  canonical hostname (settings.DJANGO_BPP_HOSTNAME) — wykorzystywany przez
  Rollbar do identyfikacji deployment'u w raportach błędów.
- Jeśli HOSTNAMES jest puste, używamy single DJANGO_BPP_HOSTNAME jak
  wcześniej. Existujące deployments nie wymagają zmian konfiguracji.

Zmienione pliki:
- settings/base.py: parsing CSV w DJANGO_BPP_HOSTNAMES, derive HOSTNAME
  z pierwszego elementu listy.
- settings/local.py, production.py: ALLOWED_HOSTS rozszerzony o pełną
  listę zamiast pojedynczego env('DJANGO_BPP_HOSTNAME').
- .env.docker, .env.example: udokumentowano obie zmienne i ich relację.

Tests: 3683 passed, 0 failed (full suite).
Walidacja konfiguracji (base.py):
- DJANGO_BPP_HOSTNAME i DJANGO_BPP_HOSTNAMES ustawione naraz
  → ImproperlyConfigured (intencja niejasna).
- DJANGO_BPP_HOSTNAME zawiera przecinek
  → ImproperlyConfigured (na multi-host używaj HOSTNAMES).
- DJANGO_BPP_HOSTNAMES bez przecinka lub tylko jeden host po sparsowaniu
  → ImproperlyConfigured (na single-host używaj HOSTNAME).

Custom Rollbar middleware (bpp/middleware.py):
- Dotychczasowy DJANGO_BPP_HOSTNAME (canonical/installation identity) zostaje.
- Dodatkowo per-request: request_host (vhost gdzie padło zgłoszenie) +
  uczelnia_skrot/uczelnia_pk z request._uczelnia (ustawiane przez
  SiteResolutionMiddleware).
- DisallowedHost przy request.get_host() łapany ostrożnie i raportowany
  jako sentinel "<DisallowedHost>" — Rollbar handler nie powinien
  failować przy raportowaniu błędu, który sam jest DisallowedHost.

Tests: 3683 passed, 0 failed.
Pięć miejsc używało Site.objects.first()/get_current() do budowy URL-i
w eksportach XLSX/BibTeX. W multi-hosted to losowy host — eksport
wygenerowany na uczelnia1 mógł zawierać linki na uczelnia2.

Wspólny helper bpp.util.site_url_for_request(request=None):
- z requestem: f"{scheme}://{host}".
- bez requestu (CLI/Celery): fallback do Uczelnia.objects.get_default()
  .site, dalej Site.objects.first(), ostatecznie "https://localhost".

Naprawione miejsca:
- bpp/admin/xlsx_export/resources.py: Wydawnictwo_ResourceBase trzyma
  request z kwargs (przekazane przez ImportExportModelAdmin).
- rozbieznosci_dyscyplin/admin.py: RozbieznosciViewResource +
  RozbieznosciZrodelViewResource analogicznie.
- deduplikator_autorow/utils/export.py + views.py: export_duplicates_to_xlsx
  bierze request opcjonalnie, propagacja z download_duplicates_xlsx.
- deduplikator_zrodel/utils.py + views.py: analogicznie.
- ewaluacja2021/util.py: output_table_to_xlsx (CLI/Celery context),
  helper fallbackuje do default Uczelnia.site.

Drobne pre-existing fixy w ewaluacja2021/util.py (wymagane przez
ruff hook): rename `a`/`col`/`dirs` na `_`, # noqa: E402 dla
intencjonalnych mid-file imports, # noqa: C901 dla output_table_to_xlsx.

Plus IDE fix w bpp/admin/uczelnia.py:save_model: try/except
ImproperlyConfigured przy obj.pbn_client() (gdy admin ustawi
pbn_integracja=True ale nie wypełni pbn_app_name/token).

Tests: 3683 passed, 0 failed.
Dodaje przycisk „Importuj" w admin/bpp/jednostka/. Plik XLSX (kolumny:
Uczelnia, Wydział, Katedra/Zakład/Klinika) jest parsowany przez nowy
JednostkaImportResource:

- Uczelnie muszą istnieć (lookup po nazwa) — błąd per-wiersz w GUI.
- Brakujące Wydziały tworzone get_or_create przez WydzialGetOrCreateWidget
  z auto-generowanym skrot (max 10) i skrot_nazwy (max 250).
- Puste komórki Wydział/Katedra dostają domyślne nazwy
  („Wydział <skrót uczelni>", „Jednostka Wydziału <X>").
- import_id_fields=("nazwa",) + skip_unchanged → idempotentny re-import.
- before_save_instance auto-generuje Jednostka.skrot i ustawia
  aktualna=True na nowych wierszach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wcześniej autocomplete twardo filtrował autorów po
aktualna_jednostka.uczelnia == request._uczelnia, przez co nie dało się
wybrać:
- wieloetatowca z aktualną jednostką w innej uczelni federacji
- byłego pracownika (brak aktualnej jednostki, ale Autor_Jednostka u nas)
- autora bez żadnego przypisania (np. świeżo zaimportowanego z PBN)

Zamiast filtrować, autocomplete annotuje każdy wynik etykietą grupy
(Case/When + Exists na Autor_Jednostka) i sortuje po niej. Override
get_results renderuje 3 optgroupy w odpowiedzi Select2 — JS po stronie
klienta nie wymaga zmian (Select2 obsługuje optgroup natywnie):

  ✅ Autorzy z naszej uczelni
  🏛️ Autorzy powiązani historycznie z naszą uczelnią
  🌐 Autorzy zewnętrzni

get_result_label zostaje bez zmian — emoji per-option (📚 PBN,
🏛️ MNISW, [❌ USUNIĘTY]) działa jak wcześniej.

Naprawia 5 testów Playwright padających pre-merge na multi-hosted-config:
test_podpowiedzi_dyscyplin_autor_ma_jedna_uczelnia_podpowiada
(ciagle/zwarte) oraz test_procent_odpowiedzialnosci AutorFormset
jeden_autor (ciagle/zwarte) i dobrze_potem_zle_dwoch_autorow (patent).
Wszystkie 5 używały autorów bez aktualna_jednostka, których stary filtr
odsiewał z autocomplete.
mpasternak and others added 18 commits June 5, 2026 10:06
…tury journal_id

Po merge dev: _process_journal_thread_safe dostaje journal_id (nie obiekt
Journal) i ładuje Journal w wątku — optymalizacja szczytu pamięci z dev
(#329). Test catch-all/Rollbar z brancha multi-hosted-config wołał starą
sygnaturę (pbn_journal). Aktualizacja: przekazujemy journal_id i pacujemy
Journal.objects.get; intencja (logger.exception + rollbar + status błędu
bez rzucania) bez zmian.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hanged files)

Cztery pliki dotknięte przez branch były zacommitowane bez ruff-format i
CI „Lint changed files" (pre-commit ruff-format na diffie vs origin/dev) je
łapał. Tylko formatowanie — zero zmian logiki. Odtworzone 1:1 komendą CI
(uvx pre-commit run ruff-format --from-ref origin/dev --to-ref HEAD):
4 files reformatted, 269 left unchanged. Migracje pominięte (exclude hooka).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ta/rok per uczelnia

test_lata/rok_..._per_uczelnia (dodane w 75de645, R3a) wołają dodaj_autora,
które domyślnie szuka Typ_Odpowiedzialnosci(skrot="aut."). Testy nie żądały
fixture'a typy_odpowiedzialnosci, więc padały na DoesNotExist w setupie —
pre-existing regres brancha, maskowany wcześniej przez awarie get_default w
tych samych shardach. Fix jak w sąsiednich testach per-uczelnia
(test_lata_autocomplete_per_uczelnia.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dev przywrócił UczelniaManager.get_default/_get_default + cached_property
default z DeprecationWarning (8c6eb3e). W tej gałęzi „uczelnia domyślna"
NIE ISTNIEJE — guard test test_get_default_usuniete_na_trwale zabrania
get_default ORAZ .default. Konflikt w src/bpp/models/uczelnia.py rozwiązany
na rzecz multi-hosted:

- odrzucono dev-owe get_default/_get_default/default,
- zachowano get_single_uczelnia_or_none/_or_fail + get_for_pbn_background,
- usunięto osierocony import warnings.

Skan runtime src/: 0 wołań Uczelnia.objects.get_default()/.default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ring sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
GET /__external_auth/login/ (redirect z Grafany/Dozzle dla niezalogowanych)
zwracał 500. Django przy inicjalizacji silnika szablonów ładuje ZACHŁANNIE
wszystkie moduły templatetags/*.py każdej apki z INSTALLED_APPS — więc
bpp.templatetags.bpp_formdefaults był importowany, a jego top-levelowy
`from formdefaults.models import FormRepresentation` wywalał się na
RuntimeError ("Model class ... doesn't declare an explicit app_label"),
bo formdefaults nie było w INSTALLED_APPS lekkiego authservera.

Fix: dodaj "formdefaults" do INSTALLED_APPS w auth_server.py.

Regresja: dwa testy w test_auth_server.py — jeden pilnuje obecności
formdefaults w INSTALLED_APPS, drugi renderuje auth_server/login.html
(dokładnie ścieżka, która padała na produkcji).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…osted

Import publikacji startujący od późniejszego kroku (np. od źródeł, z
pominięciem kroku institution_setup) wywalał się na:

    ValueError: Nie znaleziono domyślnej jednostki dla importu publikacji

Przyczyna — niespójność kluczy w ImportSession.config: formularz nowego
importu zapisywał wybór jako config["jednostka_domyslna_id"], a kroki
publication_import / statement_import czytały WYŁĄCZNIE
config["default_jednostka_id"] — klucz zapisywany tylko przez krok
institution_setup. Bez tego kroku klucz nie istniał, a ślepy fallback
filter(nazwa="Jednostka Domyślna") (dokładne dopasowanie nazwy, bez filtra
uczelni) zwracał None → wyjątek.

Zmiany:

1. Ujednolicenie wyznaczania domyślnej jednostki — nowy resolver
   resolve_default_jednostka(session, uczelnia): default_jednostka_id →
   jednostka_domyslna_id → uczelnia-aware znajdz_lub_utworz_jednostke_domyslna.
   Używany przez publication_import; statement_import reużywa wyniku
   (miał ten sam bug przez drugi odczyt config).

2. Formularz (StartImportView.post) zapisuje też kanoniczne klucze
   default_jednostka_id / wydzial_id już na etapie startu, więc każdy krok
   zna wybór niezależnie od tego, który krok ruszy pierwszy.

3. Gate-check multi-hosted: domyślna jednostka i wydział MUSZĄ należeć do
   uczelni z requestu. GET zawęża listy jednostek/wydziałów do tej uczelni;
   POST odrzuca start importu z encją obcej uczelni (ochrona przed cichym
   przypisaniem prac/autorów do tenanta B z kontekstu tenanta A).

Testy: nowy test_default_jednostka_resolution.py (resolver) + testy gate-check,
zapisu kanonicznego klucza i filtrowania listy w GET w test_views_dashboard.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Domknięcie luki, przez którą bug wszedł na produkcję: formularz (producent)
i kroki importu (konsument) były testowane OSOBNO — nikt nie przepuścił
configu zapisanego przez formularz przez
PublicationImporter._setup_uczelnia_and_jednostka (metoda rzucająca
ValueError miała 0 testów).

Nowy test end-to-end reprodukuje dokładnie produkcyjny traceback: sesja z
configiem zawierającym WYŁĄCZNIE jednostka_domyslna_id (institution_setup
pominięty, import od źródeł) → _setup musi rozwiązać jednostkę bez wyjątku.
Zweryfikowane red→green: przeciw staremu kodzie test pada ValueError
"Nie znaleziono domyślnej jednostki" (publication_import.py), z poprawką
przechodzi.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Test mierzył wybór plannera zależny od statystyk — przechodził w izolacji
(świeża, nie-ANALYZE-owana tabela → heurystyka ~10 stron → Index Scan),
padał w pełnej suite (autoanalyze ustawiał relpages=1 → Seq Scan na
1-stronicowej tabeli). Teraz test zasiewa ~600 wierszy (klon jednym
INSERT...SELECT × generate_series) + ANALYZE, więc planner naturalnie
wybiera Index Scan po PK; order-independent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ImportManager — plik spinający wszystkie kroki importu — miał ~21% pokrycia,
mimo że to on decyduje o autoryzacji, pomijaniu kroków, obsłudze błędów i
anulowaniu. To najgęstsza logika decyzyjna w pbn_import, a była praktycznie
nietestowana (najbardziej prawdopodobne miejsce następnego buga typu
"contract drift").

Nowy test_import_manager.py (18 testów):
- _check_pbn_authorization: brak klienta / klient OK / klient rzuca,
- _extract_error_message: JSON description → message → raw → str(exc),
- _validate_pbn_authorization: rzuca gdy krok wymaga PBN bez autoryzacji,
  przechodzi dla samych kroków konfiguracyjnych,
- _should_skip_step: tabela (setup vs nie-setup × auth),
- _has_error_logs,
- run() end-to-end na atrapach kroków: happy path → completed,
  anulowanie w trakcie startu → cancelled (krok nie biegnie),
  błąd kroku required → failed,
- pause/resume/cancel ustawiają status.

Pokrycie pbn_import jako całość: 33.7% → 38.5%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Po zakończonym imporcie (status=completed) strona szczegółów sesji dostaje
nową zakładkę „Log": podgląd symulowanego raw logu w <pre> + przycisk
„Pobierz log (.txt)".

Raw logu nie mamy fizycznie — odtwarzamy go z wpisów ImportLog. Zawartość to
te same dane co zakładka „Błędy i ostrzeżenia" (poziomy error/critical/
warning): czas, klasa błędu (details.exception), moduł (step), pełna
wiadomość, kontekst oraz — gdy są — pełne tracebacki. Wpisy chronologicznie
(najstarsze u góry), jak w prawdziwym pliku logu.

Komponenty:
- utils/log_export.py: render_session_log_text(session) -> str (czysta,
  testowalna funkcja, bez HTTP),
- ImportLogDownloadView (GET session/<pk>/log.txt/): text/plain + attachment,
  dostępne tylko dla completed (inaczej 404), tylko właściciel/superuser,
- ImportSessionDetailView: raw_log_text w kontekście dla completed,
- session_detail.html: zakładka + panel #log-panel pod {% if completed %}.

Bez zmian modelu/migracji — log jest pochodną istniejącego ImportLog.

Testy: test_log_export.py (format, filtr poziomów, kolejność, traceback,
brak błędów, brak details) + testy widoku pobierania i widoczności zakładki.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Korekta po feedbacku: zakładka „Log" + pobieranie były zawężone do
status=completed, więc na nieudanym imporcie (failed) — czyli tam, gdzie
log jest najpotrzebniejszy — nie było ich widać.

Teraz zakładka i pobranie działają dla KAŻDEGO statusu (completed/failed/
cancelled/running/pending). Dla biegnącego importu to migawka z chwili
renderu/żądania. Usunięto gate completed-only w widoku szczegółów, w
ImportLogDownloadView (już bez Http404) i w szablonie.

Testy zaktualizowane: zamiast „running ukrywa zakładkę" — parametryzowane
testy, że zakładka jest dla każdego statusu, a pobranie działa też dla
failed/cancelled/running/pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ugim)

Wyświetlanie: zakładka „Log" wrzucała CAŁY log do <pre> przy każdym wejściu
w szczegóły sesji. Przy tysiącach błędów z tracebackami to MB HTML-a w jednym
<pre> → zatkana przeglądarka + spuchnięty payload strony (a te same dane są
już w zakładce „Błędy i ostrzeżenia").

Podgląd inline ograniczony do PREVIEW_LIMIT (100) pierwszych wpisów + baner
„podgląd przycięty: pokazano X z Y — pobierz pełny log". Pełny log bez zmian
przez pobranie .txt (render_session_log_text bez limitu) — zgodnie z uwagą,
że pobieranie jest OK, nie ruszam go (streaming można dodać później).

- log_export.render_session_log_text(session, limit=None): slice qs[:limit] +
  stopka o przycięciu; count_log_entries() do flagi truncated,
- widok szczegółów: raw_log_text (limit=PREVIEW_LIMIT) + raw_log_total/
  raw_log_shown/raw_log_truncated,
- szablon: baner callout gdy przycięte.

Testy: limit przycina i odnotowuje, brak stopki bez limitu/w granicy, count
liczy tylko error+warning; widok ustawia flagi i pokazuje baner (patch limitu).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…długich tagów (#331)

Import publikacji z PBN wywalał się na dwóch klasach danych:

1. Słowa kluczowe (taggit Tag.name/slug = varchar(100)) — PBN bywa sklejony
   w jeden bardzo długi ciąg bez separatorów, przekraczał limit i wywracał
   import na DataError. Teraz: za długie tagi są pomijane, logowane i
   zapisywane do `adnotacje` pod znacznikiem `tagsTooLong` (do ręcznej
   korekty), a rekord się importuje.

2. Tytuły w językach innych niż eng/pol — importer brał tylko eng/pol do
   `tytul` i asertował pusty słownik `titles`, więc deu/rus/lit wywalały
   AssertionError, a brak eng/pol — KeyError. Teraz tytuły w pozostałych
   językach trafiają do nowych wierszy `Wydawnictwo_{Ciagle,Zwarte}_Tytul`
   (analogicznie do streszczeń), z dowiązaniem do słownika `Jezyk` jeśli się
   da (`pbn_uid_id` → fallback `skrot`), a surowy kod PBN zachowany w
   `kod_jezyka_pbn` nawet gdy języka nie ma w słowniku. `tytul` nadal dostaje
   eng→pol (bez regresji wyświetlania/wyszukiwania).

- nowy abstrakt BazaModeluTytulow + 2 modele konkretne + migracja 0430
- inline w adminie obu wydawnictw (edytowalne ręcznie)
- przetworz_tytuly(pbn_json, ret, klasa_tytulu) woła się po ret.save()
- testy: tagi (pomijanie/adnotacje) + tytuły (eng/pol, obce języki,
  brak eng/pol, dowiązanie Jezyk, nieznany język, unikalność)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…334)

* docs(oidc): spec naprawy logowania OIDC + menu instytucjonalne

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(oidc): napraw logowanie OIDC (claimy mail->email, easyaudit) + menu instytucjonalne per-uczelnia

Naprawa logowania OIDC (Keycloak, realm KA):

* Claimy: realm wystawia adres pod kluczem \`mail\`, a mozilla-django-oidc
  oczekuje \`email\` (verify_claims/filter_users_by_claims/create_user) -
  normalizujemy w jednym chokepoincie get_userinfo() (mail->email).
  Username z preferred_username, imie/nazwisko z given/family_name.
* KeyError: 'username' z easyaudit: jego handler user_login_failed robi
  twardo credentials[USERNAME_FIELD], a callback OIDC wola authenticate()
  bez username -> 500 przy PROPAGATE_EXCEPTIONS=True. Guard (apps.ready,
  gdy easyaudit zainstalowany) podmienia receiver na odporny wariant,
  delegujacy do oryginalnej logiki easyaudit.
* Banner [OIDC] z claimami: stderr -> logger.debug.

Menu "logowanie instytucjonalne" jak Microsoft, ale PER-UCZELNIA:

* oidc_integration/access.py: oidc_enabled_for_request() - wspolne zrodlo
  prawdy dla menu i routingu. OIDC to jeden realm na proces, wiec gateujemy
  po skrocie uczelni (request._uczelnia.skrot == OIDC_LOGIN_SKROT); bez
  skrotu = instalacja jedno-uczelniana -> globalnie. Precedencja:
  OIDC (per-uczelnia) > Microsoft (globalny) > formularz BPP.
* InstitutionalLoginView jako login_form; local_login_form = formularz BPP.
* top_bar.html: instytucjonalne (OIDC/Microsoft) + logowanie BPP w menu.
* registration/login.html: usuniety przycisk OIDC spod formularza.

Testy: backendy (normalizacja, debug log), easyaudit guard, per-uczelnia
gating, dyspozytor logowania.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…microsoft_auth

W trybie mieszanym (microsoft_auth + oidc_integration) /logout/ obsługuje
MicrosoftLogoutView dla wszystkich backendów. Nie był on świadomy OIDC, więc
sesja zalogowana przez Keycloaka szła na logout Microsoftu i zostawała żywa
sesja SSO w Keycloaku (cicha re-autoryzacja przy kolejnym logowaniu).

MicrosoftLogoutView rozpoznaje teraz sesję OIDC po BACKEND_SESSION_KEY i kieruje
ją na RP-Initiated Logout Keycloaka (URL budowany przed logout(), bo czyta
oidc_id_token z sesji). Sesje Microsoft/lokalne/ORCID bez zmian.

Dodatkowo: jawny TODO „GATE PRZED PRODUKCJĄ" w BppOIDCBackend — provisioning
fazy 2a (konto każdemu userowi realmu) nie może wejść na produkcję bez gate'u.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/oidc_integration/tests/test_logout.py Fixed
mpasternak and others added 11 commits June 6, 2026 07:43
…ring sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Naprawia 4 klasy błędów wywalających import PBN (z logu produkcyjnego):

- AssertionError na rozdziałach: sub-słownik chapters niesie redundantne
  `year`/`originalLanguage`, których nic nie konsumowało — popujemy je
  (tripwire assert_dictionary_empty zostaje ścisły dla realnie nowych kluczy).
- Jezyk.DoesNotExist (książki): surowy Jezyk.objects.get bez fallbacku →
  przejście na odporny pobierz_jezyk.
- KeyError 'mainLanguage' (artykuły): pop bez default → pop(..., None).
- KeyError 'publisher' (książki/rozdziały): brak wydawcy w PBN → wydawca=None
  (FK jest nullable).

Domyślny język gdy PBN nie poda języka albo poda kod spoza słownika:
- get_jezyk_polski() + pobierz_jezyk(..., domyslny_jezyk) — deterministycznie
  polski zamiast "pierwszego rekordu w tabeli".
- Parametr domyslny_jezyk przewleczony przez importuj_publikacje_po_pbn_uid_id
  do artykułów/książek/rozdziałów.
- Wybór języka na formularzu nowego importu: pole "Domyślny język publikacji"
  → config["default_jezyk_id"] → resolve_default_jezyk(session) →
  PublicationImporter.default_jezyk (analogicznie do default_jednostka).

TDD: 6 testów resolvera języka, 3 resolve_default_jezyk, 1 wiring + aktualizacja
asercji przekazywania kwargs. Zielono: pbn_integrator (121), pbn_import (325).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…picker aktualnie zatrudnionych

Powielanie nagłówka "✅ Autorzy z naszej uczelni" brało się z optgroupów
emitowanych per strona AJAX Select2 (przy przewijaniu nagłówek się dublował).

- PublicAutorAutocomplete (raporty, ranking, multiseek, raport slotów):
  płaska lista bez optgroup, WYŁĄCZNIE autorzy z aktualnej uczelni (bieżąca
  lub historyczna afiliacja) — także w single-install, gdzie mixin scope'ujący
  jest no-op. Staffowy AutorAutocomplete (admin) zostaje z grupami i autorami
  zewnętrznymi (potrzebne przy dodawaniu współautorów).

- AutorZUczelniAutocopmlete → AutorAktualnieZatrudnionyNaUczelni: zawęża
  WYŁĄCZNIE po aktualna_jednostka__uczelnia (aktualnie zatrudnieni), płasko,
  bez żadnych innych warunków. Nazwa klasy oddaje semantykę; URL
  (autor-z-uczelni-autocomplete) bez zmian, więc admin prac doktorskich/
  habilitacyjnych działa bez modyfikacji.

TDD: 3 nowe testy (publiczny płaski, single-install tylko-uczelnia, aktualnie
zatrudnieni). Zielono: 143 testy autocomplete, manage.py check, ruff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e publikacji

Usuwa Wydawnictwo_Ciagle i/lub Wydawnictwo_Zwarte z pod-rekordami, zostawiając
słowniki (Zrodlo/Wydawca/Jezyk). Flagi --ciagle / --zwarte (domyślnie oba),
--dry-run (podgląd planu), oraz bezpiecznik nieinteraktywny --yes-i-am-sure
--confirm-db <nazwa_bazy>. Sprząta też generyczne referencje (GFK) i loguje
kaskady Django. Zastępuje dawną komendę wyczysc_baze.

TDD: 3 testy (kasowanie publikacji + dzieci, zachowanie słowników, dry-run).
Zielono, ruff czysty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gejt "Lint changed files" padał na 2 błędach ruff, przez co krok nie dochodził
do ruff-format (sekwencyjny run:), maskując latentne błędy formatowania.

- jednostka.py: usunięty nieużywany import DjangoQLSearchMixin (admin używa
  BppDjangoQLSearchMixin) — F401.
- models/abstract/__init__.py: posortowany blok importów (.titles na właściwe
  miejsce alfabetyczne) — I001.
- 8 plików testowych pbn_import: ruff-format (zawijanie do 88 znaków), czysto
  kosmetyczne, bez zmian logiki.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… CI)

test_consumers.py (SYNC testy) woła korutyny konsumera przez async_to_sync.
asgiref odmawia, gdy wątek wołającego ma już DZIAŁAJĄCY event loop. Na sharded
CI wcześniejszy test async w tym samym shardzie zostawia działający loop, więc
shardy 2 i 3 deterministycznie wywalały test_connect_* / test_*has_permission
("You cannot use AsyncToSync in the same thread as an async event loop").
Lokalnie, w izolacji, nie reprodukowało się.

Fix: module-local shim async_to_sync uruchamia wywołanie w świeżym wątku (bez
działającego loopa), drop-in dla istniejących miejsc wywołań. Zweryfikowane
symulacją (wywołanie z wnętrza działającego loopa: raw rzuca, shim działa) +
8/8 testów konsumera lokalnie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dwa błędy z importu PBN (sesja #10) zatrzymywały całą sesję importu:

1. KeyError 'mode' w importuj_openaccess — blok openAccess z PBN bywa
   niekompletny (potrafi nie zawierać license/mode/releaseDateMode/
   textVersion). Pola docelowe to nullable FK, więc brak klucza zostawia
   pole puste zamiast wywalać import. Wartość OBECNA, lecz nieznana w
   słowniku BPP, dalej zgłasza błąd (realna luka konfiguracji).

2. AssertionError {'originalLanguage': 'pol'} w importuj_artykul —
   adapter eksportu zapisuje originalLanguage z jezyk_orig, ale importer
   nigdy tego klucza nie odczytywał → leftover wywalał assert_dictionary_empty.
   Nowy helper ustaw_jezyk_oryginalny mapuje originalLanguage → jezyk_orig
   (round-trip eksportu), wpięty w import artykułów i książek. Nieznany kod
   języka → None (jezyk_orig jest nullable, dotyczy tłumaczeń).

Refactor: blok dat OA wydzielony do _importuj_openaccess_daty (złożoność
cyklomatyczna importuj_openaccess wracała ponad limit ruff C901).

Testy: test_openaccess_i_jezyk_orig.py — 10 testów (komplet pól, brak
mode, pusty blok, brak openAccess, nieznana licencja → ValueError, daty
ISO i rok+miesiąc, mapowanie/nieznany kod/brak originalLanguage).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…acja site (#336)

* fix(multihosted): create_from_string czyta widoczność z uczelni z requestu

AutorManager.create_from_string przyjmuje teraz jawną uczelnię i czyta z
niej nowy_autor_z_formularza_pokazuj; AutorAutocomplete.create_object
przekazuje uczelnię z requestu (get_for_request). Bez uczelni fallback na
get_single_uczelnia_or_none zamiast first() (pierwsza-z-brzegu).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(multihosted): admin AutorForm afiliuje-default bez first()

Inline-owy formularz autora (generuj_formularz_dla_autorow) nie ma requestu,
więc domyślne 'afiliuje' bierze z get_single_uczelnia_or_none (single → jej
ustawienie; 0/>1 → neutralny default True) zamiast Uczelnia.objects.first().

Sprząta też nieaktualne wpisy bpp/admin/core.py i bpp/models/autor.py z
APPROVED_FIRST w guard-teście (nie ma tam już first()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(multihosted): multiseek IndexCopernicus respektuje uczelnię z requestu

Ujednolicono sygnaturę option_enabled(request=None) w hierarchii
BppMultiseekVisibilityMixin; enabled() przekazuje request niżej.
IndexCopernicusQueryObject ustala uczelnię przez get_for_request(request)
(sam degraduje do jedyna-albo-None), więc pole 'Index Copernicus' honoruje
pokazuj_index_copernicus oglądanej uczelni zamiast zawsze być widoczne.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(multihosted): single GUI path kolejki PBN zapisuje uczelnię z requestu

sprobuj_utworzyc_zlecenie_eksportu_do_PBN_gui przekazuje teraz
uczelnia=get_for_request(request) do sprobuj_utowrzyc_wpis (jak batch path),
żeby wysyłka w tle użyła właściwej konfiguracji PBN zamiast zgadywać.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(multihosted): link 'Otwórz w PBN' używa uczelni z requestu

sprobuj_wyslac_do_pbn przekazuje uczelnia=uczelnia do obu wywołań
link_do_pbn() (common.py:146,168). Wcześniej bez argumentu przy >1 uczelni
link degradował do None (get_single_uczelnia_or_none → None).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(multihosted): PublikacjaInstytucji_V2.link_do_pi nie wybucha przy >1 uczelni

Fallback self.uczelnia or Uczelnia.objects.get() → get_single_uczelnia_or_none.
Martwy guard 'if uczelnia is not None' ożywiony: przy 0/>1 uczelni link się
nie renderuje zamiast rzucać MultipleObjectsReturned / DoesNotExist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(multihosted): admin UczelniaForm wymaga site z przyjaznym komunikatem

site jest NOT NULL od migracji 0417; forma dostarcza teraz komunikat
dziedzinowy (domena/Site wiąże uczelnię z adresem) zamiast generycznego
'To pole jest wymagane.'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants