From cbd489af21c5d815b6a2a1b6c01588e66cb648bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 8 Apr 2026 20:41:02 +0200 Subject: [PATCH 001/247] =?UTF-8?q?Phase=200:=20Multi-hosted=20foundation?= =?UTF-8?q?=20=E2=80=94=20Site-Uczelnia=20linkage=20and=20middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bpp/admin/__init__.py | 8 +++ src/bpp/admin/uczelnia.py | 2 + src/bpp/context_processors/config.py | 10 ++- src/bpp/context_processors/uczelnia.py | 18 ++++- src/bpp/middleware.py | 44 ++++++++++++ .../0411_uczelnia_site_theme_user_sites.py | 69 +++++++++++++++++++ .../migrations/0412_link_uczelnia_to_site.py | 52 ++++++++++++++ src/bpp/models/profile.py | 9 +++ src/bpp/models/uczelnia.py | 30 ++++++++ src/django_bpp/settings/base.py | 1 + 10 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 src/bpp/migrations/0411_uczelnia_site_theme_user_sites.py create mode 100644 src/bpp/migrations/0412_link_uczelnia_to_site.py diff --git a/src/bpp/admin/__init__.py b/src/bpp/admin/__init__.py index 55a814360..e7c365303 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_sites",), + "description": "Superużytkownicy mają automatycznie dostęp " + "do wszystkich uczelni.", + }, + ), ) autocomplete_fields = ["autor"] diff --git a/src/bpp/admin/uczelnia.py b/src/bpp/admin/uczelnia.py index e9c0367cc..3f2d54934 100644 --- a/src/bpp/admin/uczelnia.py +++ b/src/bpp/admin/uczelnia.py @@ -77,6 +77,8 @@ class UczelniaAdmin( "nazwa", "nazwa_dopelniacz_field", "skrot", + "site", + "theme_name", "pbn_uid", "pbn_id", "favicon_ico", 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/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/middleware.py b/src/bpp/middleware.py index 369200473..ad258463b 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,6 +253,49 @@ 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 + + class CustomRollbarNotifierMiddleware(RollbarNotifierMiddleware): def get_extra_data(self, request, exc): from django.conf import settings 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/models/profile.py b/src/bpp/models/profile.py index 888d68173..7cdd3480e 100644 --- a/src/bpp/models/profile.py +++ b/src/bpp/models/profile.py @@ -44,6 +44,15 @@ class BppUser(AbstractUser, ModelZAdnotacjami): pbn_token = models.CharField(max_length=128, default="", blank=True) pbn_token_updated = models.DateTimeField(null=True, blank=True) + accessible_sites = models.ManyToManyField( + "sites.Site", + verbose_name="Dostępne strony (uczelnie)", + blank=True, + related_name="bpp_users", + help_text="Uczelnie (strony), do których użytkownik ma dostęp. " + "Superużytkownicy mają dostęp do wszystkich.", + ) + 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 597381140..1220ee8d5 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,31 @@ 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, + null=True, + blank=True, + 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", diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index 243e4314b..ffa7c83d8 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -292,6 +292,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 "bpp_setup_wizard.middleware.SetupWizardMiddleware", # After auth middleware to have request.user "django.contrib.messages.middleware.MessageMiddleware", From 5026ab51069713c7aaa3f33975caa11fb09eea13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 8 Apr 2026 20:48:09 +0200 Subject: [PATCH 002/247] Phase 1: Migrate Constance settings to Uczelnia model fields 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) --- .../admin/helpers/constance_field_mixin.py | 58 ++++++----- src/bpp/admin/uczelnia.py | 24 +++++ .../context_processors/constance_config.py | 83 +++++++--------- .../0413_uczelnia_constance_fields.py | 77 +++++++++++++++ .../0414_copy_constance_to_uczelnia.py | 57 +++++++++++ src/bpp/models/uczelnia.py | 43 ++++++++ src/conftest.py | 15 +-- src/django_bpp/settings/base.py | 98 ++----------------- 8 files changed, 276 insertions(+), 179 deletions(-) create mode 100644 src/bpp/migrations/0413_uczelnia_constance_fields.py create mode 100644 src/bpp/migrations/0414_copy_constance_to_uczelnia.py diff --git a/src/bpp/admin/helpers/constance_field_mixin.py b/src/bpp/admin/helpers/constance_field_mixin.py index 1fcd83914..ca4acee69 100644 --- a/src/bpp/admin/helpers/constance_field_mixin.py +++ b/src/bpp/admin/helpers/constance_field_mixin.py @@ -1,38 +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 - + if uczelnia is not None: return { - "POKAZUJ_INDEX_COPERNICUS": config.POKAZUJ_INDEX_COPERNICUS, - "POKAZUJ_PUNKTACJA_SNIP": config.POKAZUJ_PUNKTACJA_SNIP, - "UZYWAJ_PUNKTACJI_WEWNETRZNEJ": 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"), @@ -77,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(): @@ -102,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/uczelnia.py b/src/bpp/admin/uczelnia.py index 3f2d54934..ab3bbee68 100644 --- a/src/bpp/admin/uczelnia.py +++ b/src/bpp/admin/uczelnia.py @@ -167,6 +167,10 @@ class UczelniaAdmin( "wydruk_parametry_zapytania", "drukuj_oswiadczenia", "drukuj_alternatywne_oswiadczenia", + "wydruk_margines_gora", + "wydruk_margines_dol", + "wydruk_margines_lewo", + "wydruk_margines_prawo", ), }, ), @@ -194,6 +198,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", 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/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/models/uczelnia.py b/src/bpp/models/uczelnia.py index 1220ee8d5..67995b3d6 100644 --- a/src/bpp/models/uczelnia.py +++ b/src/bpp/models/uczelnia.py @@ -476,6 +476,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/conftest.py b/src/conftest.py index 259b98af9..bfdeea33b 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -586,8 +586,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 @@ -601,15 +601,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/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index ffa7c83d8..a0b924f50 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -1371,93 +1371,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 = {} From 456b1ca00c3f2d3ef32fe89e4b4f4f6b733b5ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 8 Apr 2026 22:06:36 +0200 Subject: [PATCH 003/247] Phase 2: Admin panel multi-site filtering 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) --- src/bpp/admin/autor.py | 3 ++ src/bpp/admin/helpers/site_filtered.py | 41 ++++++++++++++++++++++++++ src/bpp/admin/jednostka.py | 3 ++ src/bpp/admin/uczelnia.py | 12 ++++++++ src/bpp/admin/wydzial.py | 3 ++ src/bpp/middleware.py | 31 +++++++++++++++++++ 6 files changed, 93 insertions(+) create mode 100644 src/bpp/admin/helpers/site_filtered.py diff --git a/src/bpp/admin/autor.py b/src/bpp/admin/autor.py index 2fe2c67e4..b9a5e939e 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/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..217fb805f 100644 --- a/src/bpp/admin/jednostka.py +++ b/src/bpp/admin/jednostka.py @@ -14,6 +14,7 @@ from .helpers import LimitingFormset from .helpers.fieldsets import ADNOTACJE_FIELDSET from .helpers.mixins import ZapiszZAdnotacjaMixin +from .helpers.site_filtered import SiteFilteredAdminMixin class Jednostka_WydzialInline(admin.TabularInline): @@ -37,12 +38,14 @@ class Autor_JednostkaInline(admin.TabularInline): class JednostkaAdmin( + SiteFilteredAdminMixin, DjangoQLSearchMixin, RestrictDeletionToAdministracjaGroupMixin, ZapiszZAdnotacjaMixin, BaseBppAdminMixin, DraggableMPTTAdmin, ): + uczelnia_field_path = "uczelnia" djangoql_completion_enabled_by_default = False djangoql_completion = True diff --git a/src/bpp/admin/uczelnia.py b/src/bpp/admin/uczelnia.py index ab3bbee68..a5ddb527e 100644 --- a/src/bpp/admin/uczelnia.py +++ b/src/bpp/admin/uczelnia.py @@ -12,6 +12,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 +62,7 @@ class Ukryj_Status_KorektyInline(admin.StackedInline): class UczelniaAdmin( + SiteFilteredAdminMixin, ConstanceUczelniaFieldsMixin, RestrictDeletionToAdministracjaGroupMixin, ZapiszZAdnotacjaMixin, @@ -68,6 +70,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 = ( ( 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/middleware.py b/src/bpp/middleware.py index ad258463b..528c67c05 100644 --- a/src/bpp/middleware.py +++ b/src/bpp/middleware.py @@ -295,6 +295,37 @@ def process_request(self, request): 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 + + site = getattr(request, "site", None) + if site is None: + return None + + # If user has any accessible_sites configured, enforce the check. + # If user has none (backward compat / not yet configured), allow access. + if ( + user.accessible_sites.exists() + and not user.accessible_sites.filter(pk=site.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 cc6b84ec2c1cc44e80a21b6777d4e6052cb4dfdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 8 Apr 2026 22:28:57 +0200 Subject: [PATCH 004/247] Phase 3: PBN per-institution models get Uczelnia FK 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) --- src/pbn_api/admin/osoba_z_instytycji.py | 6 +- src/pbn_api/admin/oswiadczenieinstytucji.py | 4 +- src/pbn_api/admin/publikacjainstytucji_v1.py | 4 +- src/pbn_api/admin/publikacjainstytucji_v2.py | 4 +- src/pbn_api/admin/sentdata.py | 4 +- .../migrations/0069_add_uczelnia_fk.py | 70 +++++++++++++++++++ .../migrations/0070_link_pbn_to_uczelnia.py | 34 +++++++++ src/pbn_api/models/osoba_z_instytucji.py | 7 ++ src/pbn_api/models/oswiadczenie_instytucji.py | 7 ++ src/pbn_api/models/publikacja_instytucji.py | 23 ++++-- src/pbn_api/models/sentdata.py | 16 +++-- 11 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 src/pbn_api/migrations/0069_add_uczelnia_fk.py create mode 100644 src/pbn_api/migrations/0070_link_pbn_to_uczelnia.py 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/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: From e36fd4078477e48d07e9ee0d2c8d070639f95f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Apr 2026 08:42:49 +0200 Subject: [PATCH 005/247] Phase 5: Cache key namespacing for multi-site 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) --- src/bpp/admin/core.py | 3 ++- src/bpp/cache_utils.py | 16 ++++++++++++++++ src/przemapuj_prace_autora/test_integration.py | 2 ++ 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 src/bpp/cache_utils.py 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/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/przemapuj_prace_autora/test_integration.py b/src/przemapuj_prace_autora/test_integration.py index 1c3f285cf..119ae20fa 100644 --- a/src/przemapuj_prace_autora/test_integration.py +++ b/src/przemapuj_prace_autora/test_integration.py @@ -35,6 +35,8 @@ def uczelnia(db): u = baker.make(Uczelnia, nazwa="Test University", skrot="TU") # Invalidate all caches so context processor returns the new uczelnia cache.delete(b"bpp_uczelnia") + cache.delete("bpp_uczelnia_0") + cache.delete("bpp_uczelnia_1") invalidate_all() return u From df6cca9b0317c995c455eda0b2683c492cc9339a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Apr 2026 09:10:02 +0200 Subject: [PATCH 006/247] Phase 4 + 6.1: Replace get_default() in tasks, commands, and views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bpp/admin/helpers/mixins.py | 2 +- .../commands/import_jednostki_ipis.py | 15 +- src/bpp/management/commands/wyczysc_baze.py | 34 +- src/bpp/views/api/pbn_get_by_parameter.py | 2 +- src/bpp/views/browse.py | 3 - src/bpp/views/oai.py | 8 +- src/crossref_bpp/views.py | 2 +- src/django_bpp/menu.py | 2 +- .../commands/przelicz_liczbe_n_dla_uczelni.py | 17 +- src/ewaluacja2021/views.py | 2 +- src/ewaluacja_liczba_n/excel_export.py | 2 +- .../management/commands/przelicz_n.py | 17 +- src/ewaluacja_liczba_n/views/index.py | 4 +- .../management/commands/oblicz_metryki.py | 20 +- src/ewaluacja_metryki/tasks.py | 14 +- src/ewaluacja_metryki/views/export.py | 6 +- src/ewaluacja_metryki/views/list.py | 2 +- src/komparator_pbn/views.py | 6 +- src/oswiadczenia/tasks.py | 9 +- .../fix_from_institution_api_for_scientist.py | 6 +- src/pbn_api/management/commands/util.py | 7 + src/pbn_api/views.py | 4 +- src/pbn_downloader_app/tasks.py | 21 +- src/pbn_import/tasks.py | 8 +- src/pbn_import/views.py | 2 +- .../management/commands/pbn_integrator.py | 514 ++++++++++-------- src/pbn_wysylka_oswiadczen/tasks.py | 6 +- src/ranking_autorow/views.py | 4 +- 28 files changed, 454 insertions(+), 285 deletions(-) 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/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/views/api/pbn_get_by_parameter.py b/src/bpp/views/api/pbn_get_by_parameter.py index a1c59b8bb..cafefb1f5 100644 --- a/src/bpp/views/api/pbn_get_by_parameter.py +++ b/src/bpp/views/api/pbn_get_by_parameter.py @@ -52,7 +52,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/browse.py b/src/bpp/views/browse.py index 8721d6aa1..01c32eab6 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -387,9 +387,6 @@ def get_paginate_by(self, queryset): 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() - if uczelnia is None: return self.paginate_by 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/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/django_bpp/menu.py b/src/django_bpp/menu.py index 3a1823545..214e92fd5 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/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/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 9938b1813..51352cdfb 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( @@ -623,7 +623,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/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/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/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/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 458e4b10d..a2a25c7e4 100644 --- a/src/pbn_downloader_app/tasks.py +++ b/src/pbn_downloader_app/tasks.py @@ -316,13 +316,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 @@ -340,7 +341,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. " @@ -386,10 +391,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 @@ -399,7 +408,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_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/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_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/ranking_autorow/views.py b/src/ranking_autorow/views.py index 109439cf5..e4a3c8aab 100644 --- a/src/ranking_autorow/views.py +++ b/src/ranking_autorow/views.py @@ -222,7 +222,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 +253,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: From 252a40457e9971cd17130b55f63571978b651b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Apr 2026 09:19:31 +0200 Subject: [PATCH 007/247] Phase 6.2-6.4: Add uczelnia parameter to models and PBN utilities 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) --- src/bpp/admin/helpers/pbn_api/cli.py | 10 +++-- src/bpp/models/abstract/disciplines.py | 10 +++-- src/bpp/models/abstract/pbn.py | 14 ++++--- src/bpp/models/jednostka.py | 5 ++- .../fields/numeric_fields.py | 4 +- src/importer_publikacji/providers/pbn.py | 5 ++- src/importer_publikacji/views.py | 5 ++- .../templatetags/pbn_import_tags.py | 10 +++-- src/pbn_import/utils/author_import.py | 5 ++- src/pbn_import/utils/import_manager.py | 8 ++-- src/pbn_import/utils/initial_setup.py | 9 +++-- src/pbn_import/utils/institution_import.py | 5 ++- src/pbn_import/utils/publication_import.py | 5 ++- src/pbn_integrator/utils/institutions.py | 10 +++-- src/pbn_integrator/utils/scientists.py | 39 ++++++++++++------- src/zglos_publikacje/forms.py | 11 ++++-- src/zglos_publikacje/models.py | 5 ++- 17 files changed, 102 insertions(+), 58 deletions(-) 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/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/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/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.py b/src/importer_publikacji/views.py index 80187290d..d9a694242 100644 --- a/src/importer_publikacji/views.py +++ b/src/importer_publikacji/views.py @@ -1577,7 +1577,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)) @@ -1591,7 +1591,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/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_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 245026368..40e76c74e 100644 --- a/src/pbn_integrator/utils/scientists.py +++ b/src/pbn_integrator/utils/scientists.py @@ -38,7 +38,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. @@ -46,6 +46,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. @@ -53,7 +54,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( @@ -114,7 +117,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, @@ -124,25 +142,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/zglos_publikacje/forms.py b/src/zglos_publikacje/forms.py index 66e041759..51144f4a5 100644 --- a/src/zglos_publikacje/forms.py +++ b/src/zglos_publikacje/forms.py @@ -6,11 +6,10 @@ from django.forms import inlineformset_factory from django.forms.widgets import HiddenInput +from bpp.models import Autor, Dyscyplina_Naukowa, Jednostka, Uczelnia from zglos_publikacje.models import Zgloszenie_Publikacji, Zgloszenie_Publikacji_Autor from zglos_publikacje.validators import validate_file_extension_pdf -from bpp.models import Autor, Dyscyplina_Naukowa, Jednostka, Uczelnia - class Zgloszenie_Publikacji_DaneOgolneForm(forms.ModelForm): rok = forms.IntegerField( @@ -57,6 +56,8 @@ class Meta: ] def __init__(self, *args, **kw): + uczelnia = kw.pop("uczelnia", None) + self.helper = FormHelper() self.helper.form_tag = False self.helper.form_class = "custom" @@ -72,8 +73,12 @@ def __init__(self, *args, **kw): ) super().__init__(*args, **kw) + if uczelnia is None: + uczelnia = Uczelnia.objects.get_default() + if ( - not Uczelnia.objects.get_default().pytaj_o_zgode_na_publikacje_pelnego_tekstu + uczelnia is not None + and not uczelnia.pytaj_o_zgode_na_publikacje_pelnego_tekstu ): self.fields.pop("zgoda_na_publikacje_pelnego_tekstu", None) diff --git a/src/zglos_publikacje/models.py b/src/zglos_publikacje/models.py index 0597e79c9..c6c7e2f97 100644 --- a/src/zglos_publikacje/models.py +++ b/src/zglos_publikacje/models.py @@ -154,7 +154,10 @@ def clean(self): # 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. - uczelnia = Uczelnia.objects.get_default() + if not hasattr(self, "_uczelnia") or self._uczelnia is None: + uczelnia = Uczelnia.objects.get_default() + else: + uczelnia = self._uczelnia # Dla rozdziałów w monografii NIE zbieramy informacji o opłatach if ( From 317f3ce80851273d981870921c168314d5ecf180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Apr 2026 16:32:24 +0200 Subject: [PATCH 008/247] Phase 7: Make SITE_ID configurable, optional static-sitemaps - 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) --- src/django_bpp/settings/base.py | 20 +++++++++++++++----- src/django_bpp/urls.py | 6 +++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index a0b924f50..6aeeba421 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -204,7 +204,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 @@ -1224,13 +1227,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" +ENABLE_SITEMAPS = env("DJANGO_BPP_ENABLE_SITEMAPS", default=True, cast=bool) -STATICSITEMAPS_REFRESH_AFTER = 24 * 60 - -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 diff --git a/src/django_bpp/urls.py b/src/django_bpp/urls.py index f94d27aa1..db2d9651e 100644 --- a/src/django_bpp/urls.py +++ b/src/django_bpp/urls.py @@ -350,7 +350,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.+)/$", From b6b6132245ecb31f079fea1ba7c39e3a7d8915d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Apr 2026 21:01:52 +0200 Subject: [PATCH 009/247] Add multi-site test infrastructure and isolation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- conftest.py | 1 + .../tests/test_admin/test_site_filtered.py | 103 ++++++++++ .../test_middleware/test_site_resolution.py | 113 +++++++++++ src/fixtures/conftest_multisite.py | 182 ++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100644 src/bpp/tests/test_admin/test_site_filtered.py create mode 100644 src/bpp/tests/test_middleware/test_site_resolution.py create mode 100644 src/fixtures/conftest_multisite.py diff --git a/conftest.py b/conftest.py index f590f7768..8e063fbe8 100644 --- a/conftest.py +++ b/conftest.py @@ -52,6 +52,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/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..894360625 --- /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_sites 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_sites 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/fixtures/conftest_multisite.py b/src/fixtures/conftest_multisite.py new file mode 100644 index 000000000..b704055bc --- /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(site1, db): + """Staff user with access only to uczelnia1.""" + user = BppUser.objects.create_user( + username="staff_u1", + password="test12345", + is_staff=True, + ) + user.accessible_sites.add(site1) + return user + + +@pytest.fixture +def staff_user_uczelnia2(site2, db): + """Staff user with access only to uczelnia2.""" + user = BppUser.objects.create_user( + username="staff_u2", + password="test12345", + is_staff=True, + ) + user.accessible_sites.add(site2) + 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 From 33dabf2d5896f2ad2927bd4e81298dad26b05fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Apr 2026 21:04:08 +0200 Subject: [PATCH 010/247] =?UTF-8?q?Miniblog:=20M2M=20Article=E2=86=94Uczel?= =?UTF-8?q?nia=20+=20per-uczelnia=20browse=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/bpp/views/__init__.py | 3 +- src/bpp/views/browse.py | 39 +++++++++++++------ src/miniblog/admin.py | 19 ++++++++- .../migrations/0003_article_uczelnie_m2m.py | 25 ++++++++++++ .../0004_assign_articles_to_all_uczelnie.py | 29 ++++++++++++++ src/miniblog/models.py | 20 ++++++++-- 6 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 src/miniblog/migrations/0003_article_uczelnie_m2m.py create mode 100644 src/miniblog/migrations/0004_assign_articles_to_all_uczelnie.py 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/browse.py b/src/bpp/views/browse.py index 01c32eab6..c74198d67 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -54,22 +54,39 @@ def get_uczelnia_context_data(uczelnia, article_slug=None): context = {"object": uczelnia, "uczelnia": uczelnia} if article_slug: - context["article"] = get_object_or_404(Article, slug=article_slug) + context["article"] = get_object_or_404( + Article, slug=article_slug, uczelnie=uczelnia + ) else: - context["miniblog"] = 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 + # Artykuły przypisane do tej uczelni + context["miniblog"] = Article.objects.filter( + status=Article.STATUS.published, uczelnie=uczelnia + )[:5] + + # Rekordy z autorami z jednostek tej uczelni + jednostki_uczelni = uczelnia.jednostka_set.all() + context["recently_updated"] = ( + Rekord.objects.filter( + original__autorzy_set__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( + original__autorzy_set__jednostka__in=jednostki_uczelni + ) + .distinct() + .count() ) - context["total_rekord_count"] = Rekord.objects.count() context["current_year"] = timezone.now().date().year return context diff --git a/src/miniblog/admin.py b/src/miniblog/admin.py index 1eb14850b..e6b23f9ed 100644 --- a/src/miniblog/admin.py +++ b/src/miniblog/admin.py @@ -2,6 +2,8 @@ from django.contrib import admin from django.forms.widgets import Textarea +from bpp.models import Uczelnia + from .models import Article SmallerTextarea = Textarea(attrs={"cols": 75, "rows": 2}) @@ -10,7 +12,14 @@ class ArticleForm(forms.ModelForm): class Meta: - fields = ["title", "article_body", "status", "published_on", "slug"] + fields = [ + "title", + "article_body", + "status", + "published_on", + "slug", + "uczelnie", + ] model = Article widgets = {"title": SmallerTextarea, "article_body": BiggerTextarea} @@ -19,5 +28,13 @@ class Meta: class ArticleAdmin(admin.ModelAdmin): search_fields = ["title", "article_body"] list_display = ["title", "status", "created", "published_on"] + list_filter = ["status", "uczelnie"] form = ArticleForm + filter_horizontal = ["uczelnie"] prepopulated_fields = {"slug": ("title",)} + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + # New articles with no uczelnie selected → assign to all + if not change and not obj.uczelnie.exists(): + obj.uczelnie.set(Uczelnia.objects.all()) diff --git a/src/miniblog/migrations/0003_article_uczelnie_m2m.py b/src/miniblog/migrations/0003_article_uczelnie_m2m.py new file mode 100644 index 000000000..4a9339d86 --- /dev/null +++ b/src/miniblog/migrations/0003_article_uczelnie_m2m.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.25 on 2026-04-09 19:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0414_copy_constance_to_uczelnia"), + ("miniblog", "0002_auto_20180101_2017"), + ] + + operations = [ + migrations.AddField( + model_name="article", + name="uczelnie", + field=models.ManyToManyField( + blank=True, + help_text="Universities where this article is displayed. Leave empty for all universities.", + related_name="articles", + to="bpp.uczelnia", + verbose_name="Universities", + ), + ), + ] diff --git a/src/miniblog/migrations/0004_assign_articles_to_all_uczelnie.py b/src/miniblog/migrations/0004_assign_articles_to_all_uczelnie.py new file mode 100644 index 000000000..a6c0db589 --- /dev/null +++ b/src/miniblog/migrations/0004_assign_articles_to_all_uczelnie.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.25 on 2026-04-09 19:03 + +from django.db import migrations + + +def assign_articles_to_all_uczelnie(apps, schema_editor): + """Assign all existing articles to all existing universities.""" + Article = apps.get_model("miniblog", "Article") + Uczelnia = apps.get_model("bpp", "Uczelnia") + + uczelnie = list(Uczelnia.objects.all()) + if not uczelnie: + return + + for article in Article.objects.all(): + article.uczelnie.set(uczelnie) + + +class Migration(migrations.Migration): + dependencies = [ + ("miniblog", "0003_article_uczelnie_m2m"), + ("bpp", "0414_copy_constance_to_uczelnia"), + ] + + operations = [ + migrations.RunPython( + assign_articles_to_all_uczelnie, migrations.RunPython.noop + ), + ] diff --git a/src/miniblog/models.py b/src/miniblog/models.py index e2fd2081b..c82b71f0b 100644 --- a/src/miniblog/models.py +++ b/src/miniblog/models.py @@ -32,6 +32,16 @@ class Article(TimeStampedModel, StatusModel): verbose_name=_("Published on"), default=timezone.now ) slug = models.SlugField(unique=True) + uczelnie = models.ManyToManyField( + "bpp.Uczelnia", + verbose_name=_("Universities"), + blank=True, + related_name="articles", + help_text=_( + "Universities where this article is displayed. " + "Leave empty for all universities." + ), + ) class Meta: verbose_name_plural = _("Articles") @@ -41,10 +51,14 @@ class Meta: def get_absolute_url(self): if self.status != self.STATUS.published: return reverse("admin:miniblog_article_change", args=(self.pk,)) - # TODO: co gdy będzie wiele uczelni w systemie? - uczelnia = Uczelnia.objects.all().first() + uczelnia = self.uczelnie.first() or Uczelnia.objects.first() + if uczelnia is None: + return "#" if self.article_body.has_more: - return reverse("bpp:browse_artykul", args=(uczelnia.slug, self.slug)) + return reverse( + "bpp:browse_artykul", + args=(uczelnia.slug, self.slug), + ) return reverse("bpp:browse_uczelnia", args=(uczelnia.slug,)) def __str__(self): From ec7087c7a266c7f6472380043a4ab8d04428ff55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Apr 2026 21:06:24 +0200 Subject: [PATCH 011/247] =?UTF-8?q?PBN=20Queue=20uczelnia=20FK,=20deduplik?= =?UTF-8?q?ator=20superuser-only,=20rozbie=C5=BCno=C5=9Bci=20TODO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/deduplikator_autorow/admin.py | 15 +++++++++++ src/deduplikator_publikacji/admin.py | 6 +++++ src/pbn_export_queue/admin.py | 6 ++++- .../migrations/0008_add_uczelnia_fk.py | 25 +++++++++++++++++++ .../migrations/0009_link_queue_to_uczelnia.py | 25 +++++++++++++++++++ src/pbn_export_queue/models.py | 8 ++++++ src/rozbieznosci_dyscyplin/admin.py | 1 + src/rozbieznosci_if/admin.py | 1 + src/rozbieznosci_pk/admin.py | 1 + 9 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/pbn_export_queue/migrations/0008_add_uczelnia_fk.py create mode 100644 src/pbn_export_queue/migrations/0009_link_queue_to_uczelnia.py diff --git a/src/deduplikator_autorow/admin.py b/src/deduplikator_autorow/admin.py index 8f4fe5e8d..69be9e5dc 100644 --- a/src/deduplikator_autorow/admin.py +++ b/src/deduplikator_autorow/admin.py @@ -17,6 +17,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", @@ -78,6 +81,9 @@ def get_author_last_name(self, obj): @admin.register(IgnoredAuthor) class IgnoredAuthorAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): + def has_module_permission(self, request): + return request.user.is_superuser + list_display = [ "get_scientist_display", "get_autor_display", @@ -135,6 +141,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", @@ -339,6 +348,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", @@ -452,6 +464,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_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/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/rozbieznosci_dyscyplin/admin.py b/src/rozbieznosci_dyscyplin/admin.py index c31c8a952..7b61535f4 100644 --- a/src/rozbieznosci_dyscyplin/admin.py +++ b/src/rozbieznosci_dyscyplin/admin.py @@ -1,3 +1,4 @@ +# TODO: Multi-site — filtruj po autorach z aktualnej uczelni # Register your models here. import json from collections.abc import Iterable diff --git a/src/rozbieznosci_if/admin.py b/src/rozbieznosci_if/admin.py index 0dc0821fc..3822d51eb 100644 --- a/src/rozbieznosci_if/admin.py +++ b/src/rozbieznosci_if/admin.py @@ -1,3 +1,4 @@ +# TODO: Multi-site — filtruj po autorach z aktualnej uczelni # Register your models here. from django.contrib import admin diff --git a/src/rozbieznosci_pk/admin.py b/src/rozbieznosci_pk/admin.py index 41f504851..0b523186f 100644 --- a/src/rozbieznosci_pk/admin.py +++ b/src/rozbieznosci_pk/admin.py @@ -1,3 +1,4 @@ +# TODO: Multi-site — filtruj po autorach z aktualnej uczelni from django.contrib import admin from rozbieznosci_pk.models import IgnorujRozbieznoscPk, RozbieznosciPkLog From b0d314a787e802cfd3f786409d246575504eabc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Apr 2026 21:12:25 +0200 Subject: [PATCH 012/247] =?UTF-8?q?Rozbie=C5=BCno=C5=9Bci=20per-uczelnia?= =?UTF-8?q?=20filtering=20+=20autocomplete=20per-uczelnia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bpp/views/autocomplete/authors.py | 7 ++++++- src/rozbieznosci_dyscyplin/admin.py | 20 ++++++++++++++++++-- src/rozbieznosci_if/admin.py | 16 ++++++++++++++-- src/rozbieznosci_pk/admin.py | 15 ++++++++++++++- 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/bpp/views/autocomplete/authors.py b/src/bpp/views/autocomplete/authors.py index 1cea8c001..0fedd5007 100644 --- a/src/bpp/views/autocomplete/authors.py +++ b/src/bpp/views/autocomplete/authors.py @@ -41,7 +41,7 @@ def get_queryset(self): ) if self.q: - return ( + qs = ( Autor.objects.fulltext_filter(self.q) .select_related("tytul", "pbn_uid") .annotate( @@ -52,6 +52,11 @@ def get_queryset(self): ) ) ) + + uczelnia = getattr(self.request, "_uczelnia", None) + if uczelnia: + qs = qs.filter(aktualna_jednostka__uczelnia=uczelnia) + return qs def get_result_label(self, result): diff --git a/src/rozbieznosci_dyscyplin/admin.py b/src/rozbieznosci_dyscyplin/admin.py index 7b61535f4..d5d331447 100644 --- a/src/rozbieznosci_dyscyplin/admin.py +++ b/src/rozbieznosci_dyscyplin/admin.py @@ -1,5 +1,3 @@ -# TODO: Multi-site — filtruj po autorach z aktualnej uczelni -# Register your models here. import json from collections.abc import Iterable from json import JSONDecodeError @@ -312,6 +310,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)) @@ -413,6 +420,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 3822d51eb..5aead8f11 100644 --- a/src/rozbieznosci_if/admin.py +++ b/src/rozbieznosci_if/admin.py @@ -1,5 +1,3 @@ -# TODO: Multi-site — filtruj po autorach z aktualnej uczelni -# Register your models here. from django.contrib import admin from rozbieznosci_if.models import IgnorujRozbieznoscIf, RozbieznosciIfLog @@ -9,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): @@ -25,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 0b523186f..857be3c93 100644 --- a/src/rozbieznosci_pk/admin.py +++ b/src/rozbieznosci_pk/admin.py @@ -1,4 +1,3 @@ -# TODO: Multi-site — filtruj po autorach z aktualnej uczelni from django.contrib import admin from rozbieznosci_pk.models import IgnorujRozbieznoscPk, RozbieznosciPkLog @@ -8,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): @@ -24,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 From dd038819b3425493598c0e356782330e90fd3147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 9 Apr 2026 21:16:04 +0200 Subject: [PATCH 013/247] E2E multi-site isolation tests + fix browse queryset paths 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) --- src/bpp/tests/test_multisite/__init__.py | 0 .../tests/test_multisite/test_isolation.py | 147 ++++++++++++++++++ src/bpp/views/browse.py | 8 +- 3 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 src/bpp/tests/test_multisite/__init__.py create mode 100644 src/bpp/tests/test_multisite/test_isolation.py 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..dc3052d3d --- /dev/null +++ b/src/bpp/tests/test_multisite/test_isolation.py @@ -0,0 +1,147 @@ +"""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_uczelnia( + uczelnia1, uczelnia2, site1, site2, settings +): + """Article assigned to uczelnia1 is not visible on uczelnia2's page.""" + from miniblog.models import Article + + settings.ALLOWED_HOSTS = ["*"] + + article = baker.make( + Article, + title="Test Article U1", + article_body="Body text", + status=Article.STATUS.published, + ) + article.uczelnie.set([uczelnia1]) + + from bpp.views.browse import get_uczelnia_context_data + + # Clear cache + get_uczelnia_context_data.invalidate() + + ctx1 = get_uczelnia_context_data(uczelnia1) + ctx2 = get_uczelnia_context_data(uczelnia2) + + assert article in ctx1["miniblog"] + assert article not in ctx2["miniblog"] + + +@pytest.mark.django_db +def test_article_on_all_uczelnie_when_both_assigned(uczelnia1, uczelnia2): + """Article assigned to both uczelnie appears on both.""" + from bpp.views.browse import get_uczelnia_context_data + from miniblog.models import Article + + article = baker.make( + Article, + title="Global Article", + article_body="Body text", + status=Article.STATUS.published, + ) + article.uczelnie.set([uczelnia1, uczelnia2]) + + get_uczelnia_context_data.invalidate() + + ctx1 = get_uczelnia_context_data(uczelnia1) + ctx2 = get_uczelnia_context_data(uczelnia2) + + assert article in ctx1["miniblog"] + assert article in ctx2["miniblog"] + + +@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/views/browse.py b/src/bpp/views/browse.py index c74198d67..d2c8a90b1 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -66,9 +66,7 @@ def get_uczelnia_context_data(uczelnia, article_slug=None): # Rekordy z autorami z jednostek tej uczelni jednostki_uczelni = uczelnia.jednostka_set.all() context["recently_updated"] = ( - Rekord.objects.filter( - original__autorzy_set__jednostka__in=jednostki_uczelni - ) + Rekord.objects.filter(autorzy__jednostka__in=jednostki_uczelni) .order_by("-ostatnio_zmieniony") .distinct()[:12] ) @@ -81,9 +79,7 @@ def get_uczelnia_context_data(uczelnia, article_slug=None): .distinct()[:5] ) context["total_rekord_count"] = ( - Rekord.objects.filter( - original__autorzy_set__jednostka__in=jednostki_uczelni - ) + Rekord.objects.filter(autorzy__jednostka__in=jednostki_uczelni) .distinct() .count() ) From a8e14a1e0083775c37a7edfb58fbef88391ea659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 12 Apr 2026 17:20:25 +0200 Subject: [PATCH 014/247] =?UTF-8?q?Refactor=20accessible=5Fsites=20?= =?UTF-8?q?=E2=86=92=20accessible=5Fuczelnie=20(M2M=20to=20Uczelnia)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bpp/admin/__init__.py | 2 +- src/bpp/middleware.py | 12 ++-- ...415_rename_accessible_sites_to_uczelnie.py | 56 +++++++++++++++++++ src/bpp/models/profile.py | 13 +++-- .../test_middleware/test_site_resolution.py | 4 +- src/fixtures/conftest_multisite.py | 8 +-- 6 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 src/bpp/migrations/0415_rename_accessible_sites_to_uczelnie.py diff --git a/src/bpp/admin/__init__.py b/src/bpp/admin/__init__.py index e7c365303..6f7279490 100644 --- a/src/bpp/admin/__init__.py +++ b/src/bpp/admin/__init__.py @@ -257,7 +257,7 @@ class BppUserAdmin(UserAdmin): ( "Dostęp do uczelni", { - "fields": ("accessible_sites",), + "fields": ("accessible_uczelnie",), "description": "Superużytkownicy mają automatycznie dostęp " "do wszystkich uczelni.", }, diff --git a/src/bpp/middleware.py b/src/bpp/middleware.py index 528c67c05..d375f8843 100644 --- a/src/bpp/middleware.py +++ b/src/bpp/middleware.py @@ -308,15 +308,15 @@ def process_view(self, request, view_func, view_args, view_kwargs): if user is None or not user.is_authenticated or user.is_superuser: return None - site = getattr(request, "site", None) - if site is None: + uczelnia = getattr(request, "_uczelnia", None) + if uczelnia is None: return None - # If user has any accessible_sites configured, enforce the check. - # If user has none (backward compat / not yet configured), allow access. + # If user has any accessible_uczelnie configured, enforce the check. + # If user has none (backward compat / not yet configured), allow. if ( - user.accessible_sites.exists() - and not user.accessible_sites.filter(pk=site.pk).exists() + user.accessible_uczelnie.exists() + and not user.accessible_uczelnie.filter(pk=uczelnia.pk).exists() ): from django.http import HttpResponseForbidden 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/models/profile.py b/src/bpp/models/profile.py index 7cdd3480e..d652c85cf 100644 --- a/src/bpp/models/profile.py +++ b/src/bpp/models/profile.py @@ -44,13 +44,14 @@ class BppUser(AbstractUser, ModelZAdnotacjami): pbn_token = models.CharField(max_length=128, default="", blank=True) pbn_token_updated = models.DateTimeField(null=True, blank=True) - accessible_sites = models.ManyToManyField( - "sites.Site", - verbose_name="Dostępne strony (uczelnie)", + accessible_uczelnie = models.ManyToManyField( + "bpp.Uczelnia", + verbose_name="Dostępne uczelnie", blank=True, - related_name="bpp_users", - help_text="Uczelnie (strony), do których użytkownik ma dostęp. " - "Superużytkownicy mają dostęp do wszystkich.", + 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( diff --git a/src/bpp/tests/test_middleware/test_site_resolution.py b/src/bpp/tests/test_middleware/test_site_resolution.py index 894360625..d7fa271b5 100644 --- a/src/bpp/tests/test_middleware/test_site_resolution.py +++ b/src/bpp/tests/test_middleware/test_site_resolution.py @@ -100,13 +100,13 @@ def test_middleware_allows_anonymous_public_pages(site1, uczelnia1): @pytest.mark.django_db def test_middleware_allows_staff_with_no_sites_configured(site1, uczelnia1, db): - """Staff with empty accessible_sites is allowed (backward compat).""" + """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_sites is empty + # 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, [], {}) diff --git a/src/fixtures/conftest_multisite.py b/src/fixtures/conftest_multisite.py index b704055bc..788e216d4 100644 --- a/src/fixtures/conftest_multisite.py +++ b/src/fixtures/conftest_multisite.py @@ -121,26 +121,26 @@ def autor_uczelnia2(jednostka_uczelnia2, tytuly): @pytest.fixture -def staff_user_uczelnia1(site1, db): +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_sites.add(site1) + user.accessible_uczelnie.add(uczelnia1) return user @pytest.fixture -def staff_user_uczelnia2(site2, db): +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_sites.add(site2) + user.accessible_uczelnie.add(uczelnia2) return user From 68e9cf7662a1ff2e78b7df974861999210dd3c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 28 Apr 2026 18:07:08 +0200 Subject: [PATCH 015/247] Merge migrations po rebase na dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Łą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) --- src/bpp/migrations/0416_merge_20260428_1806.py | 13 +++++++++++++ src/miniblog/migrations/0005_merge_20260428_1806.py | 13 +++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/bpp/migrations/0416_merge_20260428_1806.py create mode 100644 src/miniblog/migrations/0005_merge_20260428_1806.py 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/miniblog/migrations/0005_merge_20260428_1806.py b/src/miniblog/migrations/0005_merge_20260428_1806.py new file mode 100644 index 000000000..16cfa5efe --- /dev/null +++ b/src/miniblog/migrations/0005_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 = [ + ("miniblog", "0003_alter_article_article_body"), + ("miniblog", "0004_assign_articles_to_all_uczelnie"), + ] + + operations = [] From 3cf02102c00ad256825af3e0283519583f169e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 28 Apr 2026 18:17:52 +0200 Subject: [PATCH 016/247] ranking_autorow: get_for_request w get_context_data i get_table_kwargs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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ę. --- src/ranking_autorow/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ranking_autorow/views.py b/src/ranking_autorow/views.py index e4a3c8aab..de98671d0 100644 --- a/src/ranking_autorow/views.py +++ b/src/ranking_autorow/views.py @@ -370,7 +370,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 +403,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 ( From 5cc32d4db56e69e6510b19ac272549c35d228e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 28 Apr 2026 18:43:09 +0200 Subject: [PATCH 017/247] Fix testowe regresje po rebase'ie na dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (eb1a124e3), nie regresja tej gałęzi. --- src/bpp/tests/test_views/test_browse/test_browse.py | 2 ++ src/bpp/tests/test_views/test_handlers.py | 1 + src/bpp/tests/test_views/test_oai.py | 4 ++-- src/bpp/views/autocomplete/authors.py | 2 +- src/bpp/views/browse.py | 7 +------ src/pbn_export_queue/tests/test_admin.py | 3 ++- src/raport_slotow/tests/test_ewaluacja.py | 4 +++- 7 files changed, 12 insertions(+), 11 deletions(-) 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 9df9a7fc6..db554ca97 100644 --- a/src/bpp/tests/test_views/test_browse/test_browse.py +++ b/src/bpp/tests/test_views/test_browse/test_browse.py @@ -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.uczelnie.set([uczelnia]) 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.uczelnie.set([uczelnia]) # 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/views/autocomplete/authors.py b/src/bpp/views/autocomplete/authors.py index 0fedd5007..de7e1a6ab 100644 --- a/src/bpp/views/autocomplete/authors.py +++ b/src/bpp/views/autocomplete/authors.py @@ -53,7 +53,7 @@ def get_queryset(self): ) ) - uczelnia = getattr(self.request, "_uczelnia", None) + uczelnia = getattr(getattr(self, "request", None), "_uczelnia", None) if uczelnia: qs = qs.filter(aktualna_jednostka__uczelnia=uczelnia) diff --git a/src/bpp/views/browse.py b/src/bpp/views/browse.py index d2c8a90b1..953ef5084 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -395,14 +395,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) - + 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/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/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={ From 67ee7772bafe2083f4b625e6190ccfd9b2f560c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 28 Apr 2026 19:23:43 +0200 Subject: [PATCH 018/247] =?UTF-8?q?Uczelnia.site=20obowi=C4=85zkowe=20+=20?= =?UTF-8?q?4=20bugfixy=20lookup-em=20uczelni?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/bpp/admin/jednostka.py | 2 +- src/bpp/admin/uczelnia.py | 5 ++ src/bpp/context_processors/orcid.py | 2 +- .../0417_ensure_uczelnia_site_not_null.py | 80 +++++++++++++++++++ src/bpp/models/uczelnia.py | 2 - src/bpp/tests/test_views/test_views_browse.py | 21 ++--- src/bpp/tests/util.py | 10 ++- src/bpp_setup_wizard/forms.py | 7 +- src/bpp_setup_wizard/views.py | 2 +- src/fixtures/conftest_models.py | 7 +- src/orcid_integration/backends.py | 2 +- src/ranking_autorow/forms.py | 9 ++- src/ranking_autorow/views.py | 1 + 13 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 src/bpp/migrations/0417_ensure_uczelnia_site_not_null.py diff --git a/src/bpp/admin/jednostka.py b/src/bpp/admin/jednostka.py index 217fb805f..3e3f7c829 100644 --- a/src/bpp/admin/jednostka.py +++ b/src/bpp/admin/jednostka.py @@ -121,7 +121,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/uczelnia.py b/src/bpp/admin/uczelnia.py index a5ddb527e..42e78bb02 100644 --- a/src/bpp/admin/uczelnia.py +++ b/src/bpp/admin/uczelnia.py @@ -274,6 +274,11 @@ def get_queryset(self, request): ] 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: 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/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/models/uczelnia.py b/src/bpp/models/uczelnia.py index 67995b3d6..136df6993 100644 --- a/src/bpp/models/uczelnia.py +++ b/src/bpp/models/uczelnia.py @@ -83,8 +83,6 @@ class Uczelnia(ModelZAdnotacjami, ModelZPBN_ID, NazwaISkrot, NazwaWDopelniaczu): "sites.Site", verbose_name="Strona (domena)", on_delete=models.PROTECT, - null=True, - blank=True, related_name="uczelnia", help_text="Powiązanie z obiektem Site (domena internetowa tej uczelni).", ) diff --git a/src/bpp/tests/test_views/test_views_browse.py b/src/bpp/tests/test_views/test_views_browse.py index 68f79e4ea..47ac84b22 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 @@ -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" 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_setup_wizard/forms.py b/src/bpp_setup_wizard/forms.py index 8f9a5c190..47d33d25d 100644 --- a/src/bpp_setup_wizard/forms.py +++ b/src/bpp_setup_wizard/forms.py @@ -179,7 +179,7 @@ def clean(self): return cleaned_data - def save(self, commit=True): + def save(self, commit=True, request=None): uczelnia = super().save(commit=False) # Set the fields that should always be True @@ -198,6 +198,11 @@ def save(self, commit=True): True # Włącz opcjonalną aktualizację przy edycji ) + 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/views.py b/src/bpp_setup_wizard/views.py index 7df5e747b..012cc2062 100644 --- a/src/bpp_setup_wizard/views.py +++ b/src/bpp_setup_wizard/views.py @@ -89,7 +89,7 @@ def dispatch(self, request, *args, **kwargs): def form_valid(self, form): # Create the Uczelnia - uczelnia = form.save() + uczelnia = form.save(request=self.request) messages.success( self.request, 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/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/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 de98671d0..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): From 2f376972de142d78f6894e31bf63bf8c69903b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 28 Apr 2026 20:30:19 +0200 Subject: [PATCH 019/247] =?UTF-8?q?miniblog:=20lazy=20resolution=20?= =?UTF-8?q?=E2=80=94=20pusty=20M2M=20=3D=20wsz=C4=99dzie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../tests/test_multisite/test_isolation.py | 23 +++++++++++++++++++ src/bpp/views/browse.py | 8 +++---- src/miniblog/admin.py | 8 ------- src/miniblog/models.py | 13 +++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/bpp/tests/test_multisite/test_isolation.py b/src/bpp/tests/test_multisite/test_isolation.py index dc3052d3d..7506b89ee 100644 --- a/src/bpp/tests/test_multisite/test_isolation.py +++ b/src/bpp/tests/test_multisite/test_isolation.py @@ -58,6 +58,29 @@ def test_article_on_all_uczelnie_when_both_assigned(uczelnia1, uczelnia2): assert article in ctx2["miniblog"] +@pytest.mark.django_db +def test_article_with_empty_m2m_visible_on_all_uczelnie(uczelnia1, uczelnia2): + """Pusty M2M ``uczelnie`` = artykuł widoczny wszędzie (lazy resolution).""" + from bpp.views.browse import get_uczelnia_context_data + from miniblog.models import Article + + article = baker.make( + Article, + title="Universal Article", + article_body="Body text", + status=Article.STATUS.published, + ) + # Celowo brak article.uczelnie.set(...) — pusty M2M. + + get_uczelnia_context_data.invalidate() + + ctx1 = get_uczelnia_context_data(uczelnia1) + ctx2 = get_uczelnia_context_data(uczelnia2) + + assert article in ctx1["miniblog"] + assert article in ctx2["miniblog"] + + @pytest.mark.django_db def test_staff_cannot_see_other_uczelnia_jednostki_in_admin( site1, diff --git a/src/bpp/views/browse.py b/src/bpp/views/browse.py index 953ef5084..7e6dc4d78 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -55,12 +55,12 @@ def get_uczelnia_context_data(uczelnia, article_slug=None): if article_slug: context["article"] = get_object_or_404( - Article, slug=article_slug, uczelnie=uczelnia + Article.objects.visible_on(uczelnia), slug=article_slug ) else: - # Artykuły przypisane do tej uczelni - context["miniblog"] = Article.objects.filter( - status=Article.STATUS.published, uczelnie=uczelnia + # Artykuły przypisane do tej uczelni (pusty M2M = wszędzie) + context["miniblog"] = Article.objects.visible_on(uczelnia).filter( + status=Article.STATUS.published )[:5] # Rekordy z autorami z jednostek tej uczelni diff --git a/src/miniblog/admin.py b/src/miniblog/admin.py index e6b23f9ed..145054b1a 100644 --- a/src/miniblog/admin.py +++ b/src/miniblog/admin.py @@ -2,8 +2,6 @@ from django.contrib import admin from django.forms.widgets import Textarea -from bpp.models import Uczelnia - from .models import Article SmallerTextarea = Textarea(attrs={"cols": 75, "rows": 2}) @@ -32,9 +30,3 @@ class ArticleAdmin(admin.ModelAdmin): form = ArticleForm filter_horizontal = ["uczelnie"] prepopulated_fields = {"slug": ("title",)} - - def save_model(self, request, obj, form, change): - super().save_model(request, obj, form, change) - # New articles with no uczelnie selected → assign to all - if not change and not obj.uczelnie.exists(): - obj.uczelnie.set(Uczelnia.objects.all()) diff --git a/src/miniblog/models.py b/src/miniblog/models.py index c82b71f0b..3d18a5421 100644 --- a/src/miniblog/models.py +++ b/src/miniblog/models.py @@ -1,6 +1,7 @@ # Create your models here. from django.conf import settings from django.db import models +from django.db.models import Q from django.db.models.signals import post_save from django.dispatch import receiver from django.urls.base import reverse @@ -16,6 +17,16 @@ SPLIT_MARKER = getattr(settings, "SPLIT_MARKER", "WTF") +class ArticleManager(models.Manager): + def visible_on(self, uczelnia): + """Artykuły widoczne na danej uczelni. + + Pusty M2M ``uczelnie`` = artykuł widoczny na wszystkich uczelniach + (lazy resolution). Niepusty = tylko na wybranych. + """ + return self.filter(Q(uczelnie=uczelnia) | Q(uczelnie__isnull=True)).distinct() + + class Article(TimeStampedModel, StatusModel): STATUS = Choices(("draft", _("draft")), ("published", _("published"))) @@ -43,6 +54,8 @@ class Article(TimeStampedModel, StatusModel): ), ) + objects = ArticleManager() + class Meta: verbose_name_plural = _("Articles") verbose_name = _("Article") From 829616fe3b1f62f4c6364b89e838910b01711838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Mon, 27 Apr 2026 18:43:50 +0200 Subject: [PATCH 020/247] ci(docker): wywal mechanizm .docker-build z workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/build-docker-images.yml | 32 +++++++++++------------ 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-docker-images.yml b/.github/workflows/build-docker-images.yml index c7327401a..5a09740de 100644 --- a/.github/workflows/build-docker-images.yml +++ b/.github/workflows/build-docker-images.yml @@ -75,7 +75,7 @@ on: workflow_dispatch: # Ręczne wywołanie z GUI GitHub lub przez # `gh workflow run build-docker-images.yml --ref `. - # Zawsze buduje, niezależnie od obecności .docker-build / labela. + # Zawsze buduje, niezależnie od labela PR-a. concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -84,22 +84,20 @@ concurrency: jobs: check-flag: # Guard job oszczędzający Docker Cloud Build minuty. - # - master : buduj zawsze (release flow) - # - workflow_dispatch: buduj zawsze (świadomy trigger ręczny) - # - pull_request z labelem `docker-build`: buduj przy każdym push - # do PR-a (label żyje w metadanych PR-a, - # nie trafia do kodu → bezpieczne przy mergu) - # - feature/fix/hotfix: buduj tylko gdy w root repo jest plik - # `.docker-build` (pusty, commitowany do - # gałęzi wymagającej obrazów pre-prod). + # - master push: buduj zawsze (release flow) + # - feature/fix/hotfix push: buduj zawsze (auto-build, dedupe poniżej + # jeśli branch ma otwarty PR) + # - workflow_dispatch: buduj zawsze (trigger ręczny) + # - pull_request z labelem `docker-build`: buduj na każdy push do PR-a + # (label żyje w metadanych PR-a, nie + # trafia do kodu → bezpieczne przy mergu) + # - pull_request bez labela: skip # - # Aby włączyć budowanie obrazów na branchu przez label PR-a: + # Aby włączyć budowanie obrazów dla otwartego PR-a: # gh pr edit --add-label docker-build # Aby wyłączyć: # gh pr edit --remove-label docker-build - # Alternatywnie, przez flagę w drzewie (uwaga: ryzyko merge'u do mastera): - # touch .docker-build && git add .docker-build && git commit - # Budowanie ad-hoc bez commitowania flagi / ustawiania labela: + # Budowanie ad-hoc bez ustawiania labela: # gh workflow run build-docker-images.yml --ref runs-on: ubuntu-latest permissions: @@ -139,15 +137,15 @@ jobs: elif [ "$EVENT_NAME" = "pull_request" ] && [ "$HAS_DOCKER_BUILD_LABEL" = "true" ]; then echo "should_build=true" >> "$GITHUB_OUTPUT" echo "::notice::Docker build — PR ma label 'docker-build'" - elif [ "$REF_NAME" = "master" ]; then + elif [ "$EVENT_NAME" = "push" ] && [ "$REF_NAME" = "master" ]; then echo "should_build=true" >> "$GITHUB_OUTPUT" echo "::notice::Docker build — push na master (release)" - elif [ -f ".docker-build" ]; then + elif [ "$EVENT_NAME" = "push" ]; then echo "should_build=true" >> "$GITHUB_OUTPUT" - echo "::notice::Docker build włączone przez obecność .docker-build w root" + echo "::notice::Docker build — push na ${REF_NAME} (auto-build feature/fix/hotfix)" else echo "should_build=false" >> "$GITHUB_OUTPUT" - echo "::notice::Pomijam Docker build — brak .docker-build w root repo oraz labela 'docker-build' na PR" + echo "::notice::Pomijam Docker build — PR bez labela 'docker-build'" fi docker: From 316e9333883a728d48f1ea67a9a91aef6e3dd8a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Mon, 27 Apr 2026 19:09:17 +0200 Subject: [PATCH 021/247] ci(docker): buduj PR/feature push tylko gdy actor=mpasternak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `. 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) --- .github/workflows/build-docker-images.yml | 64 ++++++++++++----------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/.github/workflows/build-docker-images.yml b/.github/workflows/build-docker-images.yml index 5a09740de..4967bf521 100644 --- a/.github/workflows/build-docker-images.yml +++ b/.github/workflows/build-docker-images.yml @@ -64,18 +64,18 @@ on: push: branches: - master + - main - 'feature/**' - 'fix/**' - 'hotfix/**' + # dev intentionally excluded — merge dev->master jest release flow, + # nie chcemy palic Docker Cloud minutek na intermediate state dev. pull_request: - # Buduje, gdy PR ma label `docker-build` (patrz check-flag). - # Typ `labeled` obsługuje moment dodania labela na już-pushniętym - # commicie — build odpala się od razu, bez konieczności nowego pushu. - types: [opened, synchronize, reopened, labeled] + # Buduje na każdy push do PR-a (oraz na otwarcie/reopen). + types: [opened, synchronize, reopened] workflow_dispatch: # Ręczne wywołanie z GUI GitHub lub przez # `gh workflow run build-docker-images.yml --ref `. - # Zawsze buduje, niezależnie od labela PR-a. concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -83,21 +83,16 @@ concurrency: jobs: check-flag: - # Guard job oszczędzający Docker Cloud Build minuty. - # - master push: buduj zawsze (release flow) - # - feature/fix/hotfix push: buduj zawsze (auto-build, dedupe poniżej - # jeśli branch ma otwarty PR) - # - workflow_dispatch: buduj zawsze (trigger ręczny) - # - pull_request z labelem `docker-build`: buduj na każdy push do PR-a - # (label żyje w metadanych PR-a, nie - # trafia do kodu → bezpieczne przy mergu) - # - pull_request bez labela: skip + # Guard job decyduje czy budowac obraz Docker. + # - master/main push: buduj zawsze (release flow, dowolny actor) + # - workflow_dispatch: buduj zawsze (manual override, dowolny actor) + # - PR push / feature push: buduj tylko gdy actor=mpasternak + # (aby nie palic Docker Cloud minutek na PR-y + # contributorow — manualnie odpalisz przez + # `gh workflow run` jesli trzeba) + # Plus dedupe: push do branchu z otwartym PR-em → skip (PR run obsluzy). # - # Aby włączyć budowanie obrazów dla otwartego PR-a: - # gh pr edit --add-label docker-build - # Aby wyłączyć: - # gh pr edit --remove-label docker-build - # Budowanie ad-hoc bez ustawiania labela: + # Budowanie ad-hoc dowolnego branchu jako inny user: # gh workflow run build-docker-images.yml --ref runs-on: ubuntu-latest permissions: @@ -113,14 +108,16 @@ jobs: REF_NAME: ${{ github.ref_name }} EVENT_NAME: ${{ github.event_name }} REPO: ${{ github.repository }} - HAS_DOCKER_BUILD_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'docker-build') }} + ACTOR: ${{ github.actor }} run: | # Dedupe: push event na branchu z otwartym PR-em jest duplikatem # pull_request eventu dla tego samego commita. Pomijamy push run, # zeby nie budowac i nie pushowac tego samego SHA dwa razy do # Docker Cloud (~7 min build + transfer do rejestru kazdorazowo). # pull_request event bedzie tagowal obraz -merge. - if [ "$EVENT_NAME" = "push" ] && [ "$REF_NAME" != "master" ]; then + if [ "$EVENT_NAME" = "push" ] \ + && [ "$REF_NAME" != "master" ] \ + && [ "$REF_NAME" != "main" ]; then PR=$(gh pr list --head "$REF_NAME" --state open \ --repo "$REPO" \ --json number --jq '.[0].number // empty') @@ -131,21 +128,26 @@ jobs: fi fi - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - echo "should_build=true" >> "$GITHUB_OUTPUT" - echo "::notice::Docker build — trigger ręczny (workflow_dispatch)" - elif [ "$EVENT_NAME" = "pull_request" ] && [ "$HAS_DOCKER_BUILD_LABEL" = "true" ]; then + # Zawsze: master/main push (release) i workflow_dispatch (manual) + if [ "$EVENT_NAME" = "push" ] \ + && { [ "$REF_NAME" = "master" ] || [ "$REF_NAME" = "main" ]; }; then echo "should_build=true" >> "$GITHUB_OUTPUT" - echo "::notice::Docker build — PR ma label 'docker-build'" - elif [ "$EVENT_NAME" = "push" ] && [ "$REF_NAME" = "master" ]; then + echo "::notice::Docker build — push na ${REF_NAME} (release flow)" + exit 0 + fi + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then echo "should_build=true" >> "$GITHUB_OUTPUT" - echo "::notice::Docker build — push na master (release)" - elif [ "$EVENT_NAME" = "push" ]; then + echo "::notice::Docker build — workflow_dispatch (manual override)" + exit 0 + fi + + # Pozostale (PR event, feature push bez PR) — tylko mpasternak. + if [ "$ACTOR" = "mpasternak" ]; then echo "should_build=true" >> "$GITHUB_OUTPUT" - echo "::notice::Docker build — push na ${REF_NAME} (auto-build feature/fix/hotfix)" + echo "::notice::Docker build — actor=mpasternak, event=${EVENT_NAME}" else echo "should_build=false" >> "$GITHUB_OUTPUT" - echo "::notice::Pomijam Docker build — PR bez labela 'docker-build'" + echo "::notice::Pomijam Docker build — actor=${ACTOR} != mpasternak" fi docker: From c2c7a05121ce6b1430da99dd7ae47058f6f9a287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 28 Apr 2026 21:08:15 +0200 Subject: [PATCH 022/247] =?UTF-8?q?DJANGO=5FBPP=5FHOSTNAMES=20(csv)=20?= =?UTF-8?q?=E2=80=94=20multi-host=20ALLOWED=5FHOSTS=20/=20CSRF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .env.docker | 10 ++++++++++ .env.example | 13 +++++++++++++ src/django_bpp/settings/base.py | 21 ++++++++++++++++++--- src/django_bpp/settings/local.py | 3 ++- src/django_bpp/settings/production.py | 3 ++- 5 files changed, 45 insertions(+), 5 deletions(-) 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 3c48c15fd..f380a0e1a 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/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index 6aeeba421..22424dd68 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"), @@ -575,17 +578,29 @@ def autoslug_gen(): }, ] -DJANGO_BPP_HOSTNAME = env("DJANGO_BPP_HOSTNAME") +# Lista hostów obsługiwanych przez deployment. +# Preferowany sposób (multi-hosted): DJANGO_BPP_HOSTNAMES jako CSV. +# Backward-compat (single-host): DJANGO_BPP_HOSTNAME (jedna nazwa). +# Jeśli oba są ustawione, HOSTNAMES wygrywa; HOSTNAME staje się ignorowany. +_hostnames_csv = env("DJANGO_BPP_HOSTNAMES") +if _hostnames_csv: + DJANGO_BPP_HOSTNAMES = [h.strip() for h in _hostnames_csv.split(",") if h.strip()] +else: + DJANGO_BPP_HOSTNAMES = [env("DJANGO_BPP_HOSTNAME")] + +# Canonical/primary hostname — pierwszy z listy. Używany m.in. przez +# Rollbar (identyfikacja deployment'u w raportach błędów). +DJANGO_BPP_HOSTNAME = DJANGO_BPP_HOSTNAMES[0] if DJANGO_BPP_HOSTNAMES else "localhost" 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") diff --git a/src/django_bpp/settings/local.py b/src/django_bpp/settings/local.py index 59c2a6466..246a07d0f 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.iplweb", "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 738492a00..7246a9868 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") From 258144fc8093c30b2e2c3f1b16c2725492035d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 28 Apr 2026 21:23:47 +0200 Subject: [PATCH 023/247] HOSTNAME/HOSTNAMES: walidacja XOR + per-vhost info w Rollbarze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "" — Rollbar handler nie powinien failować przy raportowaniu błędu, który sam jest DisallowedHost. Tests: 3683 passed, 0 failed. --- src/bpp/middleware.py | 21 +++++++++++++++- src/django_bpp/settings/base.py | 43 ++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/bpp/middleware.py b/src/bpp/middleware.py index d375f8843..78db693de 100644 --- a/src/bpp/middleware.py +++ b/src/bpp/middleware.py @@ -330,11 +330,30 @@ def process_view(self, request, view_func, view_args, view_kwargs): 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/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index 22424dd68..1684f4502 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -579,18 +579,49 @@ def autoslug_gen(): ] # Lista hostów obsługiwanych przez deployment. -# Preferowany sposób (multi-hosted): DJANGO_BPP_HOSTNAMES jako CSV. -# Backward-compat (single-host): DJANGO_BPP_HOSTNAME (jedna nazwa). -# Jeśli oba są ustawione, HOSTNAMES wygrywa; HOSTNAME staje się ignorowany. -_hostnames_csv = env("DJANGO_BPP_HOSTNAMES") +# 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 (identyfikacja deployment'u w raportach błędów). -DJANGO_BPP_HOSTNAME = DJANGO_BPP_HOSTNAMES[0] if DJANGO_BPP_HOSTNAMES else "localhost" +# 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", From 9d853c8550b12e51eddf7d3ef29c2492ff12e786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 28 Apr 2026 22:06:52 +0200 Subject: [PATCH 024/247] =?UTF-8?q?Site=20URL=20per-request=20=E2=80=94=20?= =?UTF-8?q?fix=20multi-host=20site=20URLs=20w=205=20ekspoertach=20XLSX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/bpp/admin/uczelnia.py | 13 +++++++++++- src/bpp/admin/xlsx_export/resources.py | 10 +++++++-- src/bpp/util.py | 25 +++++++++++++++++++++++ src/deduplikator_autorow/utils/export.py | 21 +++++++++---------- src/deduplikator_autorow/views.py | 2 +- src/deduplikator_zrodel/utils.py | 16 ++++++--------- src/deduplikator_zrodel/views.py | 2 +- src/ewaluacja2021/util.py | 26 ++++++++++++++---------- src/rozbieznosci_dyscyplin/admin.py | 18 +++++++++++----- 9 files changed, 91 insertions(+), 42 deletions(-) diff --git a/src/bpp/admin/uczelnia.py b/src/bpp/admin/uczelnia.py index 42e78bb02..5594d3aec 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 @@ -282,8 +283,18 @@ def save_model(self, request, obj, form, change): 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/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/util.py b/src/bpp/util.py index 434e7d4e1..5f83cf823 100644 --- a/src/bpp/util.py +++ b/src/bpp/util.py @@ -741,3 +741,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/deduplikator_autorow/utils/export.py b/src/deduplikator_autorow/utils/export.py index 68747532b..8fe65100d 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(pbn_uid): @@ -74,7 +73,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. @@ -98,7 +97,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! # Pobierz wszystkich kandydatów ze statusem PENDING diff --git a/src/deduplikator_autorow/views.py b/src/deduplikator_autorow/views.py index 8d6be892f..8c758dd52 100644 --- a/src/deduplikator_autorow/views.py +++ b/src/deduplikator_autorow/views.py @@ -618,7 +618,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_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/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/rozbieznosci_dyscyplin/admin.py b/src/rozbieznosci_dyscyplin/admin.py index d5d331447..78de6bc20 100644 --- a/src/rozbieznosci_dyscyplin/admin.py +++ b/src/rozbieznosci_dyscyplin/admin.py @@ -3,7 +3,6 @@ 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 @@ -13,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, @@ -192,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.""" @@ -253,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.""" From f4c74f27843636ed7c6e7ad3a3d5ed4512fd5e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 28 Apr 2026 23:03:42 +0200 Subject: [PATCH 025/247] =?UTF-8?q?Import=20wydzia=C5=82=C3=B3w/jednostek?= =?UTF-8?q?=20z=20XLSX=20przez=20admin=20(django-import-export)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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ł ", „Jednostka Wydziału "). - 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) --- src/bpp/admin/jednostka.py | 5 + src/bpp/admin/jednostka_import.py | 197 ++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 src/bpp/admin/jednostka_import.py diff --git a/src/bpp/admin/jednostka.py b/src/bpp/admin/jednostka.py index 3e3f7c829..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 @@ -15,6 +16,7 @@ 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): @@ -38,6 +40,7 @@ class Autor_JednostkaInline(admin.TabularInline): class JednostkaAdmin( + ImportMixin, SiteFilteredAdminMixin, DjangoQLSearchMixin, RestrictDeletionToAdministracjaGroupMixin, @@ -49,6 +52,8 @@ class JednostkaAdmin( 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"] 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 From fc05b8ddf22fdb129ae98e53ac2af9ebdf3f5812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 2 May 2026 07:10:16 +0200 Subject: [PATCH 026/247] AutorAutocomplete: 3 optgroupy zamiast filtra per-uczelnia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../+autocomplete-autorow-grupy.feature.rst | 15 +++ src/bpp/views/autocomplete/authors.py | 96 +++++++++++++++---- 2 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 src/bpp/newsfragments/+autocomplete-autorow-grupy.feature.rst 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/views/autocomplete/authors.py b/src/bpp/views/autocomplete/authors.py index de7e1a6ab..78a924ee3 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,37 +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: - qs = ( - 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: - qs = qs.filter(aktualna_jednostka__uczelnia=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): From 60b781bc6c88aaef1b9440ac068794b0166d2f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 21 May 2026 13:10:56 +0200 Subject: [PATCH 027/247] fix(tests): napraw 5+3 regresji po merge'u dev (siteblog API + Uczelnia.site NOT NULL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Po mergu origin/dev w branch feature/multi-hosted-config zostało 5 failures i 3 errors w testach. Tylko wzorce wymagające adaptacji do nowego stanu po mergu — same testy są poprawne, ale używały API z przed mergea. - src/bpp/tests/test_views/test_browse/test_browse.py: test_artykuly i test_artykul_ze_skrotem używały `a.uczelnie.set([uczelnia])` (M2M na starym miniblog.Article). Po mergu Article to siteblog.Article z M2M `sites` (do django.contrib.sites.Site). Zamiana na `a.sites.set([uczelnia.site])` — fixture uczelnia ma `.site` (OneToOne do Site, mandatory po 0417). - src/bpp/tests/test_views/test_views_browse.py: 3 testy używały `Uczelnia.objects.create(nazwa="X", skrot="X")` — to lata przed 0417 migracją wymuszającą Uczelnia.site NOT NULL. Zamiana na helper `any_uczelnia()` (już użyty wcześniej w tym pliku), który auto-tworzy Site i przypina go. - src/deduplikator_autorow/tests/test_xlsx_orcid_and_pbn_url.py: fixture `candidate_with_orcid_and_pbn` używała `Uczelnia.objects.get_or_create` bez `site=` w defaults. Dodane `site` (get_or_create na testserver). Wszystkie 468 testów w merge-targeted suite passuje (test_multisite, test_middleware, test_views, test_admin/test_site_filtered, bpp_setup_wizard, zglos_publikacje, deduplikator_autorow, miniblog, przemapuj_prace_autora). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bpp/tests/test_views/test_browse/test_browse.py | 4 ++-- src/bpp/tests/test_views/test_views_browse.py | 6 +++--- .../tests/test_xlsx_orcid_and_pbn_url.py | 6 ++++++ 3 files changed, 11 insertions(+), 5 deletions(-) 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 144d4f6c2..145ee8aa8 100644 --- a/src/bpp/tests/test_views/test_browse/test_browse.py +++ b/src/bpp/tests/test_views/test_browse/test_browse.py @@ -168,7 +168,7 @@ def test_artykuly(uczelnia, client): a = Article.objects.create( title=TYTUL, article_body="456", status=Article.STATUS.draft, slug="1" ) - a.uczelnie.set([uczelnia]) + a.sites.set([uczelnia.site]) res = client.get(reverse("bpp:browse_uczelnia", args=(uczelnia.slug,))) assert TYTUL.encode("utf-8") not in res.content @@ -190,7 +190,7 @@ def test_artykul_ze_skrotem(uczelnia, client): status=Article.STATUS.published, slug="1", ) - a.uczelnie.set([uczelnia]) + a.sites.set([uczelnia.site]) # Invalidate cacheops cache for get_uczelnia_context_data invalidate_all() diff --git a/src/bpp/tests/test_views/test_views_browse.py b/src/bpp/tests/test_views/test_views_browse.py index ddb014ff2..cc5a4beb8 100644 --- a/src/bpp/tests/test_views/test_views_browse.py +++ b/src/bpp/tests/test_views/test_views_browse.py @@ -361,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) @@ -379,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) @@ -393,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/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: From e97e7b237667a9404c44ea6f00da866d7101b3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 21 May 2026 13:23:50 +0200 Subject: [PATCH 028/247] fix(demo_data): ensure_uczelnia ustawia site na pierwszy Site (Uczelnia.site NOT NULL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demo data generator tworzył Uczelnię bez site, co po migracji 0417 (Uczelnia.site mandatory) wywalało NotNullViolation we wszystkich testach test_demo_data (28 testów: 7 failures + 21 errors w jednej fixturze jednostki_fixture która tworzy uczelnię przez ensure_uczelnia). W kontekście CLI/demo nie ma requestu więc get_current_site nie zadziała — bierzemy pierwszy Site (zwykle django.contrib.sites fixture 'example.com'), albo tworzymy 'demo.local' jeśli baza pusta. Tests: 74 passed (test_demo_data full suite + 2 flaky które przy okazji się przeszły). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bpp/demo_data/generators/uczelnia.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 From 9add55c83349016763490abbc0981581ddc0518e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 21 May 2026 10:55:36 +0200 Subject: [PATCH 029/247] feat(university-themes): nowe motywy uczelni MWSL/UAFM/VIZJA + brand palety (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: dodano trzy nowe zestawy kolorystyczne frontend dla uczelni Dodano trzy nowe frontend themes dla BPP, nawiązujące kolorystyką do stron uczelni: 1. Uniwersytet VIZJA (vizja.pl): - Szary (#3a3a3a) z żółtymi akcentami (#fbb800) - Tło: #f8f8f8 - Buttons: żółte z czarnym text - Links: żółte akcenty (#fbb800) 2. MWSLiT Wrocław (mwsl.eu): - Granat (#003688) z pomarańczowymi akcentami (#ff6b35) - Tło: #f5f8ff - Buttons: pomarańczowe z białym text - Links: granatowe z pomarańczem na hover 3. UFAM (ufam.edu.pl): - Niebieski (#0056b8, #003688) - Tło: #f5f8ff - Buttons: niebieskie z białym text - Links: niebieskie akcenty Nowe pliki: - src/bpp/static/scss/_settings_vizja.scss - ustawienia Foundation dla Vizja - src/bpp/static/scss/_settings_mwsl.scss - ustawienia Foundation dla MWSL - src/bpp/static/scss/_settings_ufam.scss - ustawienia Foundation dla UFAM - src/bpp/static/scss/app-vizja.scss - theme Vizja - src/bpp/static/scss/app-mwsl.scss - theme MWSL - src/bpp/static/scss/app-ufam.scss - theme UFAM Każdy theme importuje odpowiedni _settings_*.scss z kolorami, a resztę ustawień pobiera z domyślnego settings.scss. Aby użyć nowego theme, w settings/base.py zmień DJANGO_BPP_THEME_NAME na odpowiedni plik CSS (scss/app-vizja, scss/app-mwsl, scss/app-ufam). Co-Authored-By: Claude Sonnet 4.6 * feat: run_site buduje assets + nowe themes w COMPRESS_OFFLINE_CONTEXT 1. run_site automatycznie buduje frontend assets (make assets) - Nowa metoda _build_assets() wywołuje make assets na początku - Opcja --skip-assets dla devs którzy mają aktualny CSS - Graceful degradation: błędy assets są tylko warningi 2. Dodano nowe frontend themes do COMPRESS_OFFLINE_CONTEXT: - scss/app-vizja.css (Uniwersytet VIZJA - szary z żółtymi akcentami) - scss/app-mwsl.css (MWSLiT Wrocław - granat z pomarańczem) - scss/app-ufam.css (UFAM - niebieski) Nowe themes są dostępne dla django-compress do offline kompresji i cachowania. Aby użyć nowego theme, zmień DJANGO_BPP_THEME_NAME w settings na odpowiedni plik CSS (scss/app-vizja, scss/app-mwsl, scss/app-ufam). Co-Authored-By: Claude Sonnet 4.6 * feat: dodano nowe uniwersyteckie themes do Gruntfile.js Dodano trzy nowe frontend themes do konfiguracji Grunt: - vizja: scss/app-vizja.scss → scss/app-vizja.css - mwsl: scss/app-mwsl.scss → scss/app-mwsl.css - ufam: scss/app-ufam.scss → scss/app-ufam.css Te taski są teraz budowane równolegle z resztą themes przez grunt concurrent:themes. Co-Authored-By: Claude Sonnet 4.6 * fix(university-themes): poprawki kolorów i ikon kalendarza - App-vizja: przyciemnienie złotego koloru z #fbb800 na #d4a000 dla lepszej czytelności na szarym tle #f8f8f8 - Ikona kalendarza: dodanie override dla .uczelnia__tile aby używała koloru z klasy .uczelnia__tile-icon zamiast $primary-color (kafe na głównej stronie mają teraz własne kolory) - Ptaszki dropdown: zmiana hardcoded koloru rgba(44, 62, 80, 0.6) na rgba($anchor-color, 0.6) dla spójności ze theme'ami Co-Authored-By: Claude Sonnet 4.6 * refactor(university-themes): MWSL i UFAM jako samodzielne theme'y Foundation Rozszerzono _settings_mwsl.scss i _settings_ufam.scss z minimalnej formy (@import 'settings') do pełnego, samodzielnego setu zmiennych Foundation. Każdy theme zawiera teraz wszystkie 56 sekcji konfiguracji Foundation z dostosowanymi kolorami uczelni — dzięki temu zmiany w bazowym _settings.scss nie wpływają na wygląd theme'ów uczelnianych. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(university-themes): palety zgodne z brandem + rename UFAM→UAFM - MWSL: primary #ff6b35→#e35b00, secondary #003688→#002b53 (1:1 z mwsl.eu) - VIZJA: primary #d4a000→#EFA402, secondary #3a3a3a→#01608C (federacjavizja.pl) - UAFM (poprzednio UFAM): primary #0056b8→#b41906, secondary #003688→#045595, alert #cc4b37→#df1a17 (uafm.edu.pl); zmiana nazwy plików, taska Grunta i THEME_NAME w base.py - Usunięto globalną regułę .fi-calendar { color: $primary-color; } z app-vizja, app-uafm, app-mwsl, app-green, app-orange — kolor kalendarza wyciekał na cały serwis; teraz kolor pochodzi wyłącznie z modyfikatora uczelnia__tile-icon--* na kafelku homepage. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- Gruntfile.js | 21 + src/bpp/static/scss/_settings_mwsl.scss | 880 +++++++++++++++++++++++ src/bpp/static/scss/_settings_uafm.scss | 880 +++++++++++++++++++++++ src/bpp/static/scss/_settings_vizja.scss | 879 ++++++++++++++++++++++ src/bpp/static/scss/app-green.scss | 4 - src/bpp/static/scss/app-mwsl.scss | 129 ++++ src/bpp/static/scss/app-orange.scss | 4 - src/bpp/static/scss/app-uafm.scss | 129 ++++ src/bpp/static/scss/app-vizja.scss | 129 ++++ src/bpp/static/scss/top_bar.scss | 2 +- src/django_bpp/settings/base.py | 15 + 11 files changed, 3063 insertions(+), 9 deletions(-) create mode 100644 src/bpp/static/scss/_settings_mwsl.scss create mode 100644 src/bpp/static/scss/_settings_uafm.scss create mode 100644 src/bpp/static/scss/_settings_vizja.scss create mode 100644 src/bpp/static/scss/app-mwsl.scss create mode 100644 src/bpp/static/scss/app-uafm.scss create mode 100644 src/bpp/static/scss/app-vizja.scss diff --git a/Gruntfile.js b/Gruntfile.js index be0c455af..c3307020c 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -41,6 +41,24 @@ module.exports = function (grunt) { 'src/bpp/static/scss/app-orange.scss' } }, + vizja: { + files: { + 'src/bpp/static/scss/app-vizja.css': + 'src/bpp/static/scss/app-vizja.scss' + } + }, + mwsl: { + files: { + 'src/bpp/static/scss/app-mwsl.css': + 'src/bpp/static/scss/app-mwsl.scss' + } + }, + uafm: { + files: { + 'src/bpp/static/scss/app-uafm.css': + 'src/bpp/static/scss/app-uafm.scss' + } + }, adminthemes: { files: { 'src/bpp/static/bpp/css/admin-themes.css': @@ -145,6 +163,9 @@ module.exports = function (grunt) { 'sass:blue', 'sass:green', 'sass:orange', + 'sass:vizja', + 'sass:mwsl', + 'sass:uafm', 'sass:adminthemes', 'sass:adminfilterpanel', 'sass:przemapuj_zrodla', diff --git a/src/bpp/static/scss/_settings_mwsl.scss b/src/bpp/static/scss/_settings_mwsl.scss new file mode 100644 index 000000000..16b1a8da2 --- /dev/null +++ b/src/bpp/static/scss/_settings_mwsl.scss @@ -0,0 +1,880 @@ +// Paleta dopasowana do mwsl.eu (template.css): +// primary #e35b00 — dominujący pomarańcz brand +// secondary #002b53 — dominujący granat brand +// hover dla primary: #bb4b00 (już na ich stronie) +// +// linki: #e35b00 +// kolor tła: #f5f8ff + +@use "sass:color"; +@use "sass:math"; + +// Foundation for Sites Settings +// ----------------------------- +// +// Table of Contents: +// +// 1. Global +// 2. Breakpoints +// 3. The Grid +// 4. Base Typography +// 5. Typography Helpers +// 6. Abide +// 7. Accordion +// 8. Accordion Menu +// 9. Badge +// 10. Breadcrumbs +// 11. Button +// 12. Button Group +// 13. Callout +// 14. Card +// 15. Close Button +// 16. Drilldown +// 17. Dropdown +// 18. Dropdown Menu +// 19. Flexbox Utilities +// 20. Forms +// 21. Label +// 22. Media Object +// 23. Menu +// 24. Meter +// 25. Off-canvas +// 26. Orbit +// 27. Pagination +// 28. Progress Bar +// 29. Prototype Arrow +// 30. Prototype Border-Box +// 31. Prototype Border-None +// 32. Prototype Bordered +// 33. Prototype Display +// 34. Prototype Font-Styling +// 35. Prototype List-Style-Type +// 36. Prototype Overflow +// 37. Prototype Position +// 38. Prototype Rounded +// 39. Prototype Separator +// 40. Prototype Shadow +// 41. Prototype Sizing +// 42. Prototype Spacing +// 43. Prototype Text-Decoration +// 44. Prototype Text-Transformation +// 45. Prototype Text-Utilities +// 46. Responsive Embed +// 47. Reveal +// 48. Slider +// 49. Switch +// 50. Table +// 51. Tabs +// 52. Thumbnail +// 53. Title Bar +// 54. Tooltip +// 55. Top Bar +// 56. Xy Grid + +@import 'util/util'; + +// 1. Global +// --------- + +$global-font-size: 92%; +$global-width: rem-calc(1200); +$global-lineheight: 1.5; +$foundation-palette: ( + primary: #e35b00, + secondary: #002b53, + success: #0044cc, + warning: #ffae15, + alert: #bb4b00, +); +$light-gray: #e6e6e6; +$medium-gray: #cacaca; +$dark-gray: #8a8a8a; +$black: #0a0a0a; +$white: #fefefe; +$body-background: #f5f8ff; +$body-font-color: $black; +$body-font-family: 'Helvetica Neue', Helvetica, Roboto, Arial, sans-serif; +$body-antialiased: true; +$global-margin: 1rem; +$global-padding: 1rem; +$global-position: 1rem; +$global-weight-normal: normal; +$global-weight-bold: bold; +$global-radius: 0; +$global-menu-padding: 0.7rem 1rem; +$global-menu-nested-margin: 1rem; +$global-text-direction: ltr; +$global-flexbox: true; +$global-prototype-breakpoints: false; +$global-button-cursor: auto; +$global-color-pick-contrast-tolerance: 0; +$print-transparent-backgrounds: true; + +@include add-foundation-colors; + +// 2. Breakpoints +// -------------- + +$breakpoints: ( + small: 0, + medium: 640px, + large: 1024px, + xlarge: 1200px, + xxlarge: 1440px, +); +$print-breakpoint: large; +$breakpoint-classes: (small medium large); + +// 3. The Grid +// ----------- + +$grid-row-width: $global-width; +$grid-column-count: 12; +$grid-column-gutter: ( + small: 20px, + medium: 30px, +); +$grid-column-align-edge: true; +$grid-column-alias: 'columns'; +$block-grid-max: 8; + +// 4. Base Typography +// ------------------ + +$header-font-family: $body-font-family; +$header-font-weight: $global-weight-normal; +$header-font-style: normal; +$font-family-monospace: Consolas, 'Liberation Mono', Courier, monospace; +$header-color: inherit; +$header-lineheight: 1.4; +$header-margin-bottom: 0.5rem; +$header-styles: ( + small: ( + 'h1': ('font-size': 24), + 'h2': ('font-size': 20), + 'h3': ('font-size': 19), + 'h4': ('font-size': 18), + 'h5': ('font-size': 17), + 'h6': ('font-size': 16), + ), + medium: ( + 'h1': ('font-size': 32), + 'h2': ('font-size': 28), + 'h3': ('font-size': 24), + 'h4': ('font-size': 20), + 'h5': ('font-size': 16), + 'h6': ('font-size': 14), + ), +); +$header-text-rendering: optimizeLegibility; +$small-font-size: 80%; +$header-small-font-color: $medium-gray; +$paragraph-lineheight: 1.6; +$paragraph-margin-bottom: 1rem; +$paragraph-text-rendering: optimizeLegibility; +$code-color: $black; +$code-font-family: $font-family-monospace; +$code-font-weight: $global-weight-normal; +$code-background: $light-gray; +$code-border: 1px solid $medium-gray; +$code-padding: rem-calc(2 5 1); +$anchor-color: $primary-color; +$anchor-color-hover: scale-color($anchor-color, $lightness: -14%); +$anchor-text-decoration: none; +$anchor-text-decoration-hover: none; +$hr-width: $global-width; +$hr-border: 1px solid $medium-gray; +$hr-margin: rem-calc(20) auto; +$list-lineheight: $paragraph-lineheight; +$list-margin-bottom: $paragraph-margin-bottom; +$list-style-type: disc; +$list-style-position: outside; +$list-side-margin: 1.25rem; +$list-nested-side-margin: 1.25rem; +$defnlist-margin-bottom: 1rem; +$defnlist-term-weight: $global-weight-bold; +$defnlist-term-margin-bottom: 0.3rem; +$blockquote-color: $dark-gray; +$blockquote-padding: rem-calc(9 20 0 19); +$blockquote-border: 1px solid $medium-gray; +$cite-font-size: rem-calc(13); +$cite-color: $dark-gray; +$cite-pseudo-content: '\2014 \0020'; +$keystroke-font: $font-family-monospace; +$keystroke-color: $black; +$keystroke-background: $light-gray; +$keystroke-padding: rem-calc(2 4 0); +$keystroke-radius: $global-radius; +$abbr-underline: 1px dotted $black; + +// 5. Typography Helpers +// --------------------- + +$lead-font-size: $global-font-size * 1.25; +$lead-lineheight: 1.6; +$subheader-lineheight: 1.4; +$subheader-color: $dark-gray; +$subheader-font-weight: $global-weight-normal; +$subheader-margin-top: 0.2rem; +$subheader-margin-bottom: 0.5rem; +$stat-font-size: 2.5rem; + +// 6. Abide +// -------- + +$abide-inputs: true; +$abide-labels: true; +$input-background-invalid: get-color(alert); +$form-label-color-invalid: get-color(alert); +$input-error-color: get-color(alert); +$input-error-font-size: rem-calc(12); +$input-error-font-weight: $global-weight-bold; + +// 7. Accordion +// ------------ + +$accordion-background: $white; +$accordion-plusminus: true; +$accordion-title-font-size: rem-calc(12); +$accordion-item-color: $primary-color; +$accordion-item-background-hover: $light-gray; +$accordion-item-padding: 1.25rem 1rem; +$accordion-content-background: $white; +$accordion-content-border: 1px solid $light-gray; +$accordion-content-color: $body-font-color; +$accordion-content-padding: 1rem; + +// 8. Accordion Menu +// ----------------- + +$accordionmenu-padding: $global-menu-padding; +$accordionmenu-nested-margin: $global-menu-nested-margin; +$accordionmenu-submenu-padding: $accordionmenu-padding; +$accordionmenu-arrows: true; +$accordionmenu-arrow-color: $primary-color; +$accordionmenu-item-background: null; +$accordionmenu-border: null; +$accordionmenu-submenu-toggle-background: null; +$accordion-submenu-toggle-border: $accordionmenu-border; +$accordionmenu-submenu-toggle-width: 40px; +$accordionmenu-submenu-toggle-height: $accordionmenu-submenu-toggle-width; +$accordionmenu-arrow-size: 6px; + +// 9. Badge +// -------- + +$badge-background: $primary-color; +$badge-color: $white; +$badge-color-alt: $black; +$badge-palette: $foundation-palette; +$badge-padding: 0.3em; +$badge-minwidth: 2.1em; +$badge-font-size: 0.6rem; + +// 10. Breadcrumbs +// --------------- + +$breadcrumbs-margin: 0 0 $global-margin 0; +$breadcrumbs-item-font-size: rem-calc(11); +$breadcrumbs-item-color: $primary-color; +$breadcrumbs-item-color-current: $black; +$breadcrumbs-item-color-disabled: $medium-gray; +$breadcrumbs-item-margin: 0.75rem; +$breadcrumbs-item-uppercase: true; +$breadcrumbs-item-separator: true; +$breadcrumbs-item-separator-item: '/'; +$breadcrumbs-item-separator-item-rtl: '\\'; +$breadcrumbs-item-separator-color: $medium-gray; + +// 11. Button +// ---------- + +$button-font-family: inherit; +$button-padding: 0.85em 1em; +$button-margin: 0 0 $global-margin 0; +$button-fill: solid; +$button-background: $primary-color; +$button-background-hover: scale-color($button-background, $lightness: -15%); +$button-color: $white; +$button-color-alt: $black; +$button-radius: $global-radius; +$button-hollow-border-width: 1px; +$button-sizes: ( + tiny: 0.6rem, + small: 0.75rem, + default: 0.9rem, + large: 1.25rem, +); +$button-palette: $foundation-palette; +$button-opacity-disabled: 0.25; +$button-background-hover-lightness: -20%; +$button-hollow-hover-lightness: -50%; +$button-transition: background-color 0.25s ease-out, color 0.25s ease-out; +$button-responsive-expanded: false; + +// 12. Button Group +// ---------------- + +$buttongroup-margin: 1rem; +$buttongroup-spacing: 1px; +$buttongroup-child-selector: '.button'; +$buttongroup-expand-max: 6; +$buttongroup-radius-on-each: true; + +// 13. Callout +// ----------- + +$callout-background: $white; +$callout-background-fade: 85%; +$callout-border: 1px solid rgba($black, 0.25); +$callout-margin: 0 0 1rem 0; +$callout-padding: 1rem; +$callout-font-color: $body-font-color; +$callout-font-color-alt: $body-background; +$callout-radius: $global-radius; +$callout-link-tint: 30%; + +// 14. Card +// -------- + +$card-background: $white; +$card-font-color: $body-font-color; +$card-divider-background: $light-gray; +$card-border: 1px solid $light-gray; +$card-shadow: none; +$card-border-radius: $global-radius; +$card-padding: $global-padding; +$card-margin-bottom: $global-margin; + +// 15. Close Button +// ---------------- + +$closebutton-position: right top; +$closebutton-offset-horizontal: ( + small: 0.66rem, + medium: 1rem, +); +$closebutton-offset-vertical: ( + small: 0.33em, + medium: 0.5rem, +); +$closebutton-size: ( + small: 1.5em, + medium: 2em, +); +$closebutton-lineheight: 1; +$closebutton-color: $dark-gray; +$closebutton-color-hover: $black; + +// 16. Drilldown +// ------------- + +$drilldown-transition: transform 0.15s linear; +$drilldown-arrows: true; +$drilldown-padding: $global-menu-padding; +$drilldown-nested-margin: 0; +$drilldown-background: $white; +$drilldown-submenu-padding: $drilldown-padding; +$drilldown-submenu-background: $white; +$drilldown-arrow-color: $primary-color; +$drilldown-arrow-size: 6px; + +// 17. Dropdown +// ------------ + +$dropdown-padding: 1rem; +$dropdown-background: $body-background; +$dropdown-border: 1px solid $medium-gray; +$dropdown-font-size: 1rem; +$dropdown-width: 300px; +$dropdown-radius: $global-radius; +$dropdown-sizes: ( + tiny: 100px, + small: 200px, + large: 400px, +); + +// 18. Dropdown Menu +// ----------------- + +$dropdownmenu-arrows: true; +$dropdownmenu-arrow-color: $anchor-color; +$dropdownmenu-arrow-size: 6px; +$dropdownmenu-arrow-padding: 1.5rem; +$dropdownmenu-min-width: 200px; +$dropdownmenu-background: $white; +$dropdownmenu-submenu-background: $dropdownmenu-background; +$dropdownmenu-padding: $global-menu-padding; +$dropdownmenu-nested-margin: 0; +$dropdownmenu-submenu-padding: $dropdownmenu-padding; +$dropdownmenu-border: 1px solid $medium-gray; +$dropdown-menu-item-color-active: get-color(primary); +$dropdown-menu-item-background-active: transparent; + +// 19. Flexbox Utilities +// --------------------- + +$flex-source-ordering-count: 6; +$flexbox-responsive-breakpoints: true; + +// 20. Forms +// --------- + +$fieldset-border: 1px solid $medium-gray; +$fieldset-padding: rem-calc(20); +$fieldset-margin: rem-calc(18 0); +$legend-padding: rem-calc(0 3); +$form-spacing: rem-calc(16); +$helptext-color: $black; +$helptext-font-size: rem-calc(13); +$helptext-font-style: italic; +$input-prefix-color: $black; +$input-prefix-background: $light-gray; +$input-prefix-border: 1px solid $medium-gray; +$input-prefix-padding: 1rem; +$form-label-color: $black; +$form-label-font-size: rem-calc(14); +$form-label-font-weight: $global-weight-normal; +$form-label-line-height: 1.8; +$select-background: $white; +$select-triangle-color: $dark-gray; +$select-radius: $global-radius; +$input-color: $black; +$input-placeholder-color: $medium-gray; +$input-font-family: inherit; +$input-font-size: rem-calc(16); +$input-font-weight: $global-weight-normal; +$input-line-height: $global-lineheight; +$input-background: $white; +$input-background-focus: $white; +$input-background-disabled: $light-gray; +$input-border: 1px solid $medium-gray; +$input-border-focus: 1px solid $dark-gray; +$input-padding: calc($form-spacing / 2); +$input-shadow: inset 0 1px 2px rgba($black, 0.1); +$input-shadow-focus: 0 0 5px $medium-gray; +$input-cursor-disabled: not-allowed; +$input-transition: box-shadow 0.5s, border-color 0.25s ease-in-out; +$input-number-spinners: true; +$input-radius: $global-radius; +$form-button-radius: $global-radius; + +// 21. Label +// --------- + +$label-background: $primary-color; +$label-color: $white; +$label-color-alt: $black; +$label-palette: $foundation-palette; +$label-font-size: 0.8rem; +$label-padding: 0.33333rem 0.5rem; +$label-radius: $global-radius; + +// 22. Media Object +// ---------------- + +$mediaobject-margin-bottom: $global-margin; +$mediaobject-section-padding: $global-padding; +$mediaobject-image-width-stacked: 100%; + +// 23. Menu +// -------- + +$menu-margin: 0; +$menu-nested-margin: $global-menu-nested-margin; +$menu-items-padding: $global-menu-padding; +$menu-simple-margin: 1rem; +$menu-item-color-active: $white; +$menu-item-background-active: get-color(primary); +$menu-icon-spacing: 0.25rem; +$menu-item-background-hover: $light-gray; +$menu-state-back-compat: true; +$menu-centered-back-compat: true; +$menu-icons-back-compat: true; + +// 24. Meter +// --------- + +$meter-height: 1rem; +$meter-radius: $global-radius; +$meter-background: $medium-gray; +$meter-fill-good: $success-color; +$meter-fill-medium: $warning-color; +$meter-fill-bad: $alert-color; + +// 25. Off-canvas +// -------------- + +$offcanvas-sizes: ( + small: 250px, +); +$offcanvas-vertical-sizes: ( + small: 250px, +); +$offcanvas-background: $light-gray; +$offcanvas-shadow: 0 0 10px rgba($black, 0.7); +$offcanvas-inner-shadow-size: 20px; +$offcanvas-inner-shadow-color: rgba($black, 0.25); +$offcanvas-overlay-zindex: 11; +$offcanvas-push-zindex: 12; +$offcanvas-overlap-zindex: 13; +$offcanvas-reveal-zindex: 12; +$offcanvas-transition-length: 0.5s; +$offcanvas-transition-timing: ease; +$offcanvas-fixed-reveal: true; +$offcanvas-exit-background: rgba($white, 0.25); +$maincontent-class: 'off-canvas-content'; + +// 26. Orbit +// --------- + +$orbit-bullet-background: $medium-gray; +$orbit-bullet-background-active: $dark-gray; +$orbit-bullet-diameter: 1.2rem; +$orbit-bullet-margin: 0.1rem; +$orbit-bullet-margin-top: 0.8rem; +$orbit-bullet-margin-bottom: 0.8rem; +$orbit-caption-background: rgba($black, 0.5); +$orbit-caption-padding: 1rem; +$orbit-control-background-hover: rgba($black, 0.5); +$orbit-control-padding: 1rem; +$orbit-control-zindex: 10; + +// 27. Pagination +// -------------- + +$pagination-font-size: rem-calc(14); +$pagination-margin-bottom: $global-margin; +$pagination-item-color: $black; +$pagination-item-padding: rem-calc(3 10); +$pagination-item-spacing: rem-calc(1); +$pagination-radius: $global-radius; +$pagination-item-background-hover: $light-gray; +$pagination-item-background-current: $primary-color; +$pagination-item-color-current: $white; +$pagination-item-color-disabled: $medium-gray; +$pagination-ellipsis-color: $black; +$pagination-mobile-items: false; +$pagination-mobile-current-item: false; +$pagination-arrows: true; + +// 28. Progress Bar +// ---------------- + +$progress-height: 1rem; +$progress-background: $medium-gray; +$progress-margin-bottom: $global-margin; +$progress-meter-background: $primary-color; +$progress-radius: $global-radius; + +// 29. Prototype Arrow +// ------------------- + +$prototype-arrow-directions: ( + down, + up, + right, + left +); +$prototype-arrow-size: 0.4375rem; +$prototype-arrow-color: $black; + +// 30. Prototype Border-Box +// ------------------------ + +$prototype-border-box-breakpoints: $global-prototype-breakpoints; + +// 31. Prototype Border-None +// ------------------------- + +$prototype-border-none-breakpoints: $global-prototype-breakpoints; + +// 32. Prototype Bordered +// ---------------------- + +$prototype-bordered-breakpoints: $global-prototype-breakpoints; +$prototype-border-width: rem-calc(1); +$prototype-border-type: solid; +$prototype-border-color: $medium-gray; + +// 33. Prototype Display +// --------------------- + +$prototype-display-breakpoints: $global-prototype-breakpoints; +$prototype-display: ( + inline, + inline-block, + block, + table, + table-cell +); + +// 34. Prototype Font-Styling +// -------------------------- + +$prototype-font-breakpoints: $global-prototype-breakpoints; +$prototype-wide-letter-spacing: rem-calc(4); +$prototype-font-normal: $global-weight-normal; +$prototype-font-bold: $global-weight-bold; + +// 35. Prototype List-Style-Type +// ----------------------------- + +$prototype-list-breakpoints: $global-prototype-breakpoints; +$prototype-style-type-unordered: ( + disc, + circle, + square +); +$prototype-style-type-ordered: ( + decimal, + lower-alpha, + lower-latin, + lower-roman, + upper-alpha, + upper-latin, + upper-roman +); + +// 36. Prototype Overflow +// ---------------------- + +$prototype-overflow-breakpoints: $global-prototype-breakpoints; +$prototype-overflow: ( + visible, + hidden, + scroll +); + +// 37. Prototype Position +// ---------------------- + +$prototype-position-breakpoints: $global-prototype-breakpoints; +$prototype-position: ( + static, + relative, + absolute, + fixed +); +$prototype-position-z-index: 975; + +// 38. Prototype Rounded +// --------------------- + +$prototype-rounded-breakpoints: $global-prototype-breakpoints; +$prototype-border-radius: rem-calc(3); + +// 39. Prototype Separator +// ----------------------- + +$prototype-separator-breakpoints: $global-prototype-breakpoints; +$prototype-separator-align: center; +$prototype-separator-height: rem-calc(2); +$prototype-separator-width: 3rem; +$prototype-separator-background: $primary-color; +$prototype-separator-margin-top: $global-margin; + +// 40. Prototype Shadow +// -------------------- + +$prototype-shadow-breakpoints: $global-prototype-breakpoints; +$prototype-box-shadow: 0 2px 5px 0 rgba(0,0,0,.16), + 0 2px 10px 0 rgba(0,0,0,.12); + +// 41. Prototype Sizing +// -------------------- + +$prototype-sizing-breakpoints: $global-prototype-breakpoints; +$prototype-sizing: ( + width, + height +); +$prototype-sizes: ( + 25: 25%, + 50: 50%, + 75: 75%, + 100: 100% +); + +// 42. Prototype Spacing +// --------------------- + +$prototype-spacing-breakpoints: $global-prototype-breakpoints; +$prototype-spacers-count: 3; + +// 43. Prototype Text-Decoration +// ----------------------------- + +$prototype-decoration-breakpoints: $global-prototype-breakpoints; +$prototype-text-decoration: ( + overline, + underline, + line-through, +); + +// 44. Prototype Text-Transformation +// --------------------------------- + +$prototype-transformation-breakpoints: $global-prototype-breakpoints; +$prototype-text-transformation: ( + lowercase, + uppercase, + capitalize +); + +// 45. Prototype Text-Utilities +// ---------------------------- + +$prototype-utilities-breakpoints: $global-prototype-breakpoints; +$prototype-text-overflow: ellipsis; + +// 46. Responsive Embed +// -------------------- + +$responsive-embed-margin-bottom: rem-calc(16); +$responsive-embed-ratios: ( + default: 4 by 3, + widescreen: 16 by 9, +); + +// 47. Reveal +// ---------- + +$reveal-background: $white; +$reveal-width: 600px; +$reveal-max-width: $global-width; +$reveal-padding: $global-padding; +$reveal-border: 1px solid $medium-gray; +$reveal-radius: $global-radius; +$reveal-zindex: 1005; +$reveal-overlay-background: rgba($black, 0.45); + +// 48. Slider +// ---------- + +$slider-width-vertical: 0.5rem; +$slider-transition: all 0.2s ease-in-out; +$slider-height: 0.5rem; +$slider-background: $light-gray; +$slider-fill-background: $medium-gray; +$slider-handle-height: 1.4rem; +$slider-handle-width: 1.4rem; +$slider-handle-background: $primary-color; +$slider-opacity-disabled: 0.25; +$slider-radius: $global-radius; + +// 49. Switch +// ---------- + +$switch-background: $medium-gray; +$switch-background-active: $primary-color; +$switch-height: 2rem; +$switch-height-tiny: 1.5rem; +$switch-height-small: 1.75rem; +$switch-height-large: 2.5rem; +$switch-radius: $global-radius; +$switch-margin: $global-margin; +$switch-paddle-background: $white; +$switch-paddle-offset: 0.25rem; +$switch-paddle-radius: $global-radius; +$switch-paddle-transition: all 0.25s ease-out; + +// 50. Table +// --------- + +$table-background: $white; +$table-color-scale: 5%; +$table-border: 1px solid smart-scale($table-background, $table-color-scale); +$table-padding: rem-calc(8 10 10); +$table-hover-scale: 2%; +$table-row-hover: color.adjust($table-background, $lightness: -$table-hover-scale); +$table-row-stripe-hover: color.adjust($table-background, $lightness: -($table-color-scale + $table-hover-scale)); +$table-is-striped: true; +$table-striped-background: smart-scale($table-background, $table-color-scale); +$table-stripe: even; +$table-head-background: smart-scale($table-background, calc($table-color-scale / 2)); +$table-head-row-hover: color.adjust($table-head-background, $lightness: -$table-hover-scale); +$table-foot-background: smart-scale($table-background, $table-color-scale); +$table-foot-row-hover: color.adjust($table-foot-background, $lightness: -$table-hover-scale); +$table-head-font-color: $body-font-color; +$table-foot-font-color: $body-font-color; +$show-header-for-stacked: false; +$table-stack-breakpoint: medium; + +// 51. Tabs +// -------- + +$tab-margin: 0; +$tab-background: $white; +$tab-color: $primary-color; +$tab-background-active: $light-gray; +$tab-active-color: $primary-color; +$tab-item-font-size: rem-calc(12); +$tab-item-background-hover: $white; +$tab-item-padding: 1.25rem 1.5rem; +$tab-expand-max: 6; +$tab-content-background: $white; +$tab-content-border: $light-gray; +$tab-content-color: $body-font-color; +$tab-content-padding: 1rem; + +// 52. Thumbnail +// ------------- + +$thumbnail-border: solid 4px $white; +$thumbnail-margin-bottom: $global-margin; +$thumbnail-shadow: 0 0 0 1px rgba($black, 0.2); +$thumbnail-shadow-hover: 0 0 6px 1px rgba($primary-color, 0.5); +$thumbnail-transition: box-shadow 200ms ease-out; +$thumbnail-radius: $global-radius; + +// 53. Title Bar +// ------------- + +$titlebar-background: $black; +$titlebar-color: $white; +$titlebar-padding: 0.5rem; +$titlebar-text-font-weight: bold; +$titlebar-icon-color: $white; +$titlebar-icon-color-hover: $medium-gray; +$titlebar-icon-spacing: 0.25rem; + +// 54. Tooltip +// ----------- + +$has-tip-cursor: help; +$has-tip-font-weight: $global-weight-bold; +$has-tip-border-bottom: dotted 1px $dark-gray; +$tooltip-background-color: $black; +$tooltip-color: $white; +$tooltip-padding: 0.75rem; +$tooltip-max-width: 10rem; +$tooltip-font-size: $small-font-size; +$tooltip-pip-width: 0.75rem; +$tooltip-pip-height: $tooltip-pip-width * 0.866; +$tooltip-radius: $global-radius; + +// 55. Top Bar +// ----------- + +$topbar-padding: 0.5rem; +$topbar-background: $light-gray; +$topbar-submenu-background: $topbar-background; +$topbar-title-spacing: 0.5rem 1rem 0.5rem 0; +$topbar-input-width: 200px; +$topbar-unstack-breakpoint: medium; + +// 56. Xy Grid +// ----------- + +$xy-grid: true; +$grid-container: $global-width; +$grid-columns: 12; +$grid-margin-gutters: ( + small: 20px, + medium: 30px +); +$grid-padding-gutters: $grid-margin-gutters; +$grid-container-padding: $grid-padding-gutters; +$grid-container-max: $global-width; +$xy-block-grid-max: 8; diff --git a/src/bpp/static/scss/_settings_uafm.scss b/src/bpp/static/scss/_settings_uafm.scss new file mode 100644 index 000000000..535eb9297 --- /dev/null +++ b/src/bpp/static/scss/_settings_uafm.scss @@ -0,0 +1,880 @@ +// Paleta dopasowana do uafm.edu.pl (generateblocks/style-42.css + WP --accent): +// primary #b41906 — brand red (ciemniejszy, lepszy kontrast linków) +// secondary #045595 — brand blue +// alert #df1a17 — vivid brand red (WP theme --accent) +// +// linki: #b41906 +// kolor tła: #f5f9fc + +@use "sass:color"; +@use "sass:math"; + +// Foundation for Sites Settings +// ----------------------------- +// +// Table of Contents: +// +// 1. Global +// 2. Breakpoints +// 3. The Grid +// 4. Base Typography +// 5. Typography Helpers +// 6. Abide +// 7. Accordion +// 8. Accordion Menu +// 9. Badge +// 10. Breadcrumbs +// 11. Button +// 12. Button Group +// 13. Callout +// 14. Card +// 15. Close Button +// 16. Drilldown +// 17. Dropdown +// 18. Dropdown Menu +// 19. Flexbox Utilities +// 20. Forms +// 21. Label +// 22. Media Object +// 23. Menu +// 24. Meter +// 25. Off-canvas +// 26. Orbit +// 27. Pagination +// 28. Progress Bar +// 29. Prototype Arrow +// 30. Prototype Border-Box +// 31. Prototype Border-None +// 32. Prototype Bordered +// 33. Prototype Display +// 34. Prototype Font-Styling +// 35. Prototype List-Style-Type +// 36. Prototype Overflow +// 37. Prototype Position +// 38. Prototype Rounded +// 39. Prototype Separator +// 40. Prototype Shadow +// 41. Prototype Sizing +// 42. Prototype Spacing +// 43. Prototype Text-Decoration +// 44. Prototype Text-Transformation +// 45. Prototype Text-Utilities +// 46. Responsive Embed +// 47. Reveal +// 48. Slider +// 49. Switch +// 50. Table +// 51. Tabs +// 52. Thumbnail +// 53. Title Bar +// 54. Tooltip +// 55. Top Bar +// 56. Xy Grid + +@import 'util/util'; + +// 1. Global +// --------- + +$global-font-size: 92%; +$global-width: rem-calc(1200); +$global-lineheight: 1.5; +$foundation-palette: ( + primary: #b41906, + secondary: #045595, + success: #3adb76, + warning: #ffae00, + alert: #df1a17, +); +$light-gray: #e6e6e6; +$medium-gray: #cacaca; +$dark-gray: #8a8a8a; +$black: #0a0a0a; +$white: #fefefe; +$body-background: #f5f8ff; +$body-font-color: $black; +$body-font-family: 'Helvetica Neue', Helvetica, Roboto, Arial, sans-serif; +$body-antialiased: true; +$global-margin: 1rem; +$global-padding: 1rem; +$global-position: 1rem; +$global-weight-normal: normal; +$global-weight-bold: bold; +$global-radius: 0; +$global-menu-padding: 0.7rem 1rem; +$global-menu-nested-margin: 1rem; +$global-text-direction: ltr; +$global-flexbox: true; +$global-prototype-breakpoints: false; +$global-button-cursor: auto; +$global-color-pick-contrast-tolerance: 0; +$print-transparent-backgrounds: true; + +@include add-foundation-colors; + +// 2. Breakpoints +// -------------- + +$breakpoints: ( + small: 0, + medium: 640px, + large: 1024px, + xlarge: 1200px, + xxlarge: 1440px, +); +$print-breakpoint: large; +$breakpoint-classes: (small medium large); + +// 3. The Grid +// ----------- + +$grid-row-width: $global-width; +$grid-column-count: 12; +$grid-column-gutter: ( + small: 20px, + medium: 30px, +); +$grid-column-align-edge: true; +$grid-column-alias: 'columns'; +$block-grid-max: 8; + +// 4. Base Typography +// ------------------ + +$header-font-family: $body-font-family; +$header-font-weight: $global-weight-normal; +$header-font-style: normal; +$font-family-monospace: Consolas, 'Liberation Mono', Courier, monospace; +$header-color: inherit; +$header-lineheight: 1.4; +$header-margin-bottom: 0.5rem; +$header-styles: ( + small: ( + 'h1': ('font-size': 24), + 'h2': ('font-size': 20), + 'h3': ('font-size': 19), + 'h4': ('font-size': 18), + 'h5': ('font-size': 17), + 'h6': ('font-size': 16), + ), + medium: ( + 'h1': ('font-size': 32), + 'h2': ('font-size': 28), + 'h3': ('font-size': 24), + 'h4': ('font-size': 20), + 'h5': ('font-size': 16), + 'h6': ('font-size': 14), + ), +); +$header-text-rendering: optimizeLegibility; +$small-font-size: 80%; +$header-small-font-color: $medium-gray; +$paragraph-lineheight: 1.6; +$paragraph-margin-bottom: 1rem; +$paragraph-text-rendering: optimizeLegibility; +$code-color: $black; +$code-font-family: $font-family-monospace; +$code-font-weight: $global-weight-normal; +$code-background: $light-gray; +$code-border: 1px solid $medium-gray; +$code-padding: rem-calc(2 5 1); +$anchor-color: $primary-color; +$anchor-color-hover: scale-color($anchor-color, $lightness: -14%); +$anchor-text-decoration: none; +$anchor-text-decoration-hover: none; +$hr-width: $global-width; +$hr-border: 1px solid $medium-gray; +$hr-margin: rem-calc(20) auto; +$list-lineheight: $paragraph-lineheight; +$list-margin-bottom: $paragraph-margin-bottom; +$list-style-type: disc; +$list-style-position: outside; +$list-side-margin: 1.25rem; +$list-nested-side-margin: 1.25rem; +$defnlist-margin-bottom: 1rem; +$defnlist-term-weight: $global-weight-bold; +$defnlist-term-margin-bottom: 0.3rem; +$blockquote-color: $dark-gray; +$blockquote-padding: rem-calc(9 20 0 19); +$blockquote-border: 1px solid $medium-gray; +$cite-font-size: rem-calc(13); +$cite-color: $dark-gray; +$cite-pseudo-content: '\2014 \0020'; +$keystroke-font: $font-family-monospace; +$keystroke-color: $black; +$keystroke-background: $light-gray; +$keystroke-padding: rem-calc(2 4 0); +$keystroke-radius: $global-radius; +$abbr-underline: 1px dotted $black; + +// 5. Typography Helpers +// --------------------- + +$lead-font-size: $global-font-size * 1.25; +$lead-lineheight: 1.6; +$subheader-lineheight: 1.4; +$subheader-color: $dark-gray; +$subheader-font-weight: $global-weight-normal; +$subheader-margin-top: 0.2rem; +$subheader-margin-bottom: 0.5rem; +$stat-font-size: 2.5rem; + +// 6. Abide +// -------- + +$abide-inputs: true; +$abide-labels: true; +$input-background-invalid: get-color(alert); +$form-label-color-invalid: get-color(alert); +$input-error-color: get-color(alert); +$input-error-font-size: rem-calc(12); +$input-error-font-weight: $global-weight-bold; + +// 7. Accordion +// ------------ + +$accordion-background: $white; +$accordion-plusminus: true; +$accordion-title-font-size: rem-calc(12); +$accordion-item-color: $primary-color; +$accordion-item-background-hover: $light-gray; +$accordion-item-padding: 1.25rem 1rem; +$accordion-content-background: $white; +$accordion-content-border: 1px solid $light-gray; +$accordion-content-color: $body-font-color; +$accordion-content-padding: 1rem; + +// 8. Accordion Menu +// ----------------- + +$accordionmenu-padding: $global-menu-padding; +$accordionmenu-nested-margin: $global-menu-nested-margin; +$accordionmenu-submenu-padding: $accordionmenu-padding; +$accordionmenu-arrows: true; +$accordionmenu-arrow-color: $primary-color; +$accordionmenu-item-background: null; +$accordionmenu-border: null; +$accordionmenu-submenu-toggle-background: null; +$accordion-submenu-toggle-border: $accordionmenu-border; +$accordionmenu-submenu-toggle-width: 40px; +$accordionmenu-submenu-toggle-height: $accordionmenu-submenu-toggle-width; +$accordionmenu-arrow-size: 6px; + +// 9. Badge +// -------- + +$badge-background: $primary-color; +$badge-color: $white; +$badge-color-alt: $black; +$badge-palette: $foundation-palette; +$badge-padding: 0.3em; +$badge-minwidth: 2.1em; +$badge-font-size: 0.6rem; + +// 10. Breadcrumbs +// --------------- + +$breadcrumbs-margin: 0 0 $global-margin 0; +$breadcrumbs-item-font-size: rem-calc(11); +$breadcrumbs-item-color: $primary-color; +$breadcrumbs-item-color-current: $black; +$breadcrumbs-item-color-disabled: $medium-gray; +$breadcrumbs-item-margin: 0.75rem; +$breadcrumbs-item-uppercase: true; +$breadcrumbs-item-separator: true; +$breadcrumbs-item-separator-item: '/'; +$breadcrumbs-item-separator-item-rtl: '\\'; +$breadcrumbs-item-separator-color: $medium-gray; + +// 11. Button +// ---------- + +$button-font-family: inherit; +$button-padding: 0.85em 1em; +$button-margin: 0 0 $global-margin 0; +$button-fill: solid; +$button-background: $primary-color; +$button-background-hover: scale-color($button-background, $lightness: -15%); +$button-color: $white; +$button-color-alt: $black; +$button-radius: $global-radius; +$button-hollow-border-width: 1px; +$button-sizes: ( + tiny: 0.6rem, + small: 0.75rem, + default: 0.9rem, + large: 1.25rem, +); +$button-palette: $foundation-palette; +$button-opacity-disabled: 0.25; +$button-background-hover-lightness: -20%; +$button-hollow-hover-lightness: -50%; +$button-transition: background-color 0.25s ease-out, color 0.25s ease-out; +$button-responsive-expanded: false; + +// 12. Button Group +// ---------------- + +$buttongroup-margin: 1rem; +$buttongroup-spacing: 1px; +$buttongroup-child-selector: '.button'; +$buttongroup-expand-max: 6; +$buttongroup-radius-on-each: true; + +// 13. Callout +// ----------- + +$callout-background: $white; +$callout-background-fade: 85%; +$callout-border: 1px solid rgba($black, 0.25); +$callout-margin: 0 0 1rem 0; +$callout-padding: 1rem; +$callout-font-color: $body-font-color; +$callout-font-color-alt: $body-background; +$callout-radius: $global-radius; +$callout-link-tint: 30%; + +// 14. Card +// -------- + +$card-background: $white; +$card-font-color: $body-font-color; +$card-divider-background: $light-gray; +$card-border: 1px solid $light-gray; +$card-shadow: none; +$card-border-radius: $global-radius; +$card-padding: $global-padding; +$card-margin-bottom: $global-margin; + +// 15. Close Button +// ---------------- + +$closebutton-position: right top; +$closebutton-offset-horizontal: ( + small: 0.66rem, + medium: 1rem, +); +$closebutton-offset-vertical: ( + small: 0.33em, + medium: 0.5rem, +); +$closebutton-size: ( + small: 1.5em, + medium: 2em, +); +$closebutton-lineheight: 1; +$closebutton-color: $dark-gray; +$closebutton-color-hover: $black; + +// 16. Drilldown +// ------------- + +$drilldown-transition: transform 0.15s linear; +$drilldown-arrows: true; +$drilldown-padding: $global-menu-padding; +$drilldown-nested-margin: 0; +$drilldown-background: $white; +$drilldown-submenu-padding: $drilldown-padding; +$drilldown-submenu-background: $white; +$drilldown-arrow-color: $primary-color; +$drilldown-arrow-size: 6px; + +// 17. Dropdown +// ------------ + +$dropdown-padding: 1rem; +$dropdown-background: $body-background; +$dropdown-border: 1px solid $medium-gray; +$dropdown-font-size: 1rem; +$dropdown-width: 300px; +$dropdown-radius: $global-radius; +$dropdown-sizes: ( + tiny: 100px, + small: 200px, + large: 400px, +); + +// 18. Dropdown Menu +// ----------------- + +$dropdownmenu-arrows: true; +$dropdownmenu-arrow-color: $anchor-color; +$dropdownmenu-arrow-size: 6px; +$dropdownmenu-arrow-padding: 1.5rem; +$dropdownmenu-min-width: 200px; +$dropdownmenu-background: $white; +$dropdownmenu-submenu-background: $dropdownmenu-background; +$dropdownmenu-padding: $global-menu-padding; +$dropdownmenu-nested-margin: 0; +$dropdownmenu-submenu-padding: $dropdownmenu-padding; +$dropdownmenu-border: 1px solid $medium-gray; +$dropdown-menu-item-color-active: get-color(primary); +$dropdown-menu-item-background-active: transparent; + +// 19. Flexbox Utilities +// --------------------- + +$flex-source-ordering-count: 6; +$flexbox-responsive-breakpoints: true; + +// 20. Forms +// --------- + +$fieldset-border: 1px solid $medium-gray; +$fieldset-padding: rem-calc(20); +$fieldset-margin: rem-calc(18 0); +$legend-padding: rem-calc(0 3); +$form-spacing: rem-calc(16); +$helptext-color: $black; +$helptext-font-size: rem-calc(13); +$helptext-font-style: italic; +$input-prefix-color: $black; +$input-prefix-background: $light-gray; +$input-prefix-border: 1px solid $medium-gray; +$input-prefix-padding: 1rem; +$form-label-color: $black; +$form-label-font-size: rem-calc(14); +$form-label-font-weight: $global-weight-normal; +$form-label-line-height: 1.8; +$select-background: $white; +$select-triangle-color: $dark-gray; +$select-radius: $global-radius; +$input-color: $black; +$input-placeholder-color: $medium-gray; +$input-font-family: inherit; +$input-font-size: rem-calc(16); +$input-font-weight: $global-weight-normal; +$input-line-height: $global-lineheight; +$input-background: $white; +$input-background-focus: $white; +$input-background-disabled: $light-gray; +$input-border: 1px solid $medium-gray; +$input-border-focus: 1px solid $dark-gray; +$input-padding: calc($form-spacing / 2); +$input-shadow: inset 0 1px 2px rgba($black, 0.1); +$input-shadow-focus: 0 0 5px $medium-gray; +$input-cursor-disabled: not-allowed; +$input-transition: box-shadow 0.5s, border-color 0.25s ease-in-out; +$input-number-spinners: true; +$input-radius: $global-radius; +$form-button-radius: $global-radius; + +// 21. Label +// --------- + +$label-background: $primary-color; +$label-color: $white; +$label-color-alt: $black; +$label-palette: $foundation-palette; +$label-font-size: 0.8rem; +$label-padding: 0.33333rem 0.5rem; +$label-radius: $global-radius; + +// 22. Media Object +// ---------------- + +$mediaobject-margin-bottom: $global-margin; +$mediaobject-section-padding: $global-padding; +$mediaobject-image-width-stacked: 100%; + +// 23. Menu +// -------- + +$menu-margin: 0; +$menu-nested-margin: $global-menu-nested-margin; +$menu-items-padding: $global-menu-padding; +$menu-simple-margin: 1rem; +$menu-item-color-active: $white; +$menu-item-background-active: get-color(primary); +$menu-icon-spacing: 0.25rem; +$menu-item-background-hover: $light-gray; +$menu-state-back-compat: true; +$menu-centered-back-compat: true; +$menu-icons-back-compat: true; + +// 24. Meter +// --------- + +$meter-height: 1rem; +$meter-radius: $global-radius; +$meter-background: $medium-gray; +$meter-fill-good: $success-color; +$meter-fill-medium: $warning-color; +$meter-fill-bad: $alert-color; + +// 25. Off-canvas +// -------------- + +$offcanvas-sizes: ( + small: 250px, +); +$offcanvas-vertical-sizes: ( + small: 250px, +); +$offcanvas-background: $light-gray; +$offcanvas-shadow: 0 0 10px rgba($black, 0.7); +$offcanvas-inner-shadow-size: 20px; +$offcanvas-inner-shadow-color: rgba($black, 0.25); +$offcanvas-overlay-zindex: 11; +$offcanvas-push-zindex: 12; +$offcanvas-overlap-zindex: 13; +$offcanvas-reveal-zindex: 12; +$offcanvas-transition-length: 0.5s; +$offcanvas-transition-timing: ease; +$offcanvas-fixed-reveal: true; +$offcanvas-exit-background: rgba($white, 0.25); +$maincontent-class: 'off-canvas-content'; + +// 26. Orbit +// --------- + +$orbit-bullet-background: $medium-gray; +$orbit-bullet-background-active: $dark-gray; +$orbit-bullet-diameter: 1.2rem; +$orbit-bullet-margin: 0.1rem; +$orbit-bullet-margin-top: 0.8rem; +$orbit-bullet-margin-bottom: 0.8rem; +$orbit-caption-background: rgba($black, 0.5); +$orbit-caption-padding: 1rem; +$orbit-control-background-hover: rgba($black, 0.5); +$orbit-control-padding: 1rem; +$orbit-control-zindex: 10; + +// 27. Pagination +// -------------- + +$pagination-font-size: rem-calc(14); +$pagination-margin-bottom: $global-margin; +$pagination-item-color: $black; +$pagination-item-padding: rem-calc(3 10); +$pagination-item-spacing: rem-calc(1); +$pagination-radius: $global-radius; +$pagination-item-background-hover: $light-gray; +$pagination-item-background-current: $primary-color; +$pagination-item-color-current: $white; +$pagination-item-color-disabled: $medium-gray; +$pagination-ellipsis-color: $black; +$pagination-mobile-items: false; +$pagination-mobile-current-item: false; +$pagination-arrows: true; + +// 28. Progress Bar +// ---------------- + +$progress-height: 1rem; +$progress-background: $medium-gray; +$progress-margin-bottom: $global-margin; +$progress-meter-background: $primary-color; +$progress-radius: $global-radius; + +// 29. Prototype Arrow +// ------------------- + +$prototype-arrow-directions: ( + down, + up, + right, + left +); +$prototype-arrow-size: 0.4375rem; +$prototype-arrow-color: $black; + +// 30. Prototype Border-Box +// ------------------------ + +$prototype-border-box-breakpoints: $global-prototype-breakpoints; + +// 31. Prototype Border-None +// ------------------------- + +$prototype-border-none-breakpoints: $global-prototype-breakpoints; + +// 32. Prototype Bordered +// ---------------------- + +$prototype-bordered-breakpoints: $global-prototype-breakpoints; +$prototype-border-width: rem-calc(1); +$prototype-border-type: solid; +$prototype-border-color: $medium-gray; + +// 33. Prototype Display +// --------------------- + +$prototype-display-breakpoints: $global-prototype-breakpoints; +$prototype-display: ( + inline, + inline-block, + block, + table, + table-cell +); + +// 34. Prototype Font-Styling +// -------------------------- + +$prototype-font-breakpoints: $global-prototype-breakpoints; +$prototype-wide-letter-spacing: rem-calc(4); +$prototype-font-normal: $global-weight-normal; +$prototype-font-bold: $global-weight-bold; + +// 35. Prototype List-Style-Type +// ----------------------------- + +$prototype-list-breakpoints: $global-prototype-breakpoints; +$prototype-style-type-unordered: ( + disc, + circle, + square +); +$prototype-style-type-ordered: ( + decimal, + lower-alpha, + lower-latin, + lower-roman, + upper-alpha, + upper-latin, + upper-roman +); + +// 36. Prototype Overflow +// ---------------------- + +$prototype-overflow-breakpoints: $global-prototype-breakpoints; +$prototype-overflow: ( + visible, + hidden, + scroll +); + +// 37. Prototype Position +// ---------------------- + +$prototype-position-breakpoints: $global-prototype-breakpoints; +$prototype-position: ( + static, + relative, + absolute, + fixed +); +$prototype-position-z-index: 975; + +// 38. Prototype Rounded +// --------------------- + +$prototype-rounded-breakpoints: $global-prototype-breakpoints; +$prototype-border-radius: rem-calc(3); + +// 39. Prototype Separator +// ----------------------- + +$prototype-separator-breakpoints: $global-prototype-breakpoints; +$prototype-separator-align: center; +$prototype-separator-height: rem-calc(2); +$prototype-separator-width: 3rem; +$prototype-separator-background: $primary-color; +$prototype-separator-margin-top: $global-margin; + +// 40. Prototype Shadow +// -------------------- + +$prototype-shadow-breakpoints: $global-prototype-breakpoints; +$prototype-box-shadow: 0 2px 5px 0 rgba(0,0,0,.16), + 0 2px 10px 0 rgba(0,0,0,.12); + +// 41. Prototype Sizing +// -------------------- + +$prototype-sizing-breakpoints: $global-prototype-breakpoints; +$prototype-sizing: ( + width, + height +); +$prototype-sizes: ( + 25: 25%, + 50: 50%, + 75: 75%, + 100: 100% +); + +// 42. Prototype Spacing +// --------------------- + +$prototype-spacing-breakpoints: $global-prototype-breakpoints; +$prototype-spacers-count: 3; + +// 43. Prototype Text-Decoration +// ----------------------------- + +$prototype-decoration-breakpoints: $global-prototype-breakpoints; +$prototype-text-decoration: ( + overline, + underline, + line-through, +); + +// 44. Prototype Text-Transformation +// --------------------------------- + +$prototype-transformation-breakpoints: $global-prototype-breakpoints; +$prototype-text-transformation: ( + lowercase, + uppercase, + capitalize +); + +// 45. Prototype Text-Utilities +// ---------------------------- + +$prototype-utilities-breakpoints: $global-prototype-breakpoints; +$prototype-text-overflow: ellipsis; + +// 46. Responsive Embed +// -------------------- + +$responsive-embed-margin-bottom: rem-calc(16); +$responsive-embed-ratios: ( + default: 4 by 3, + widescreen: 16 by 9, +); + +// 47. Reveal +// ---------- + +$reveal-background: $white; +$reveal-width: 600px; +$reveal-max-width: $global-width; +$reveal-padding: $global-padding; +$reveal-border: 1px solid $medium-gray; +$reveal-radius: $global-radius; +$reveal-zindex: 1005; +$reveal-overlay-background: rgba($black, 0.45); + +// 48. Slider +// ---------- + +$slider-width-vertical: 0.5rem; +$slider-transition: all 0.2s ease-in-out; +$slider-height: 0.5rem; +$slider-background: $light-gray; +$slider-fill-background: $medium-gray; +$slider-handle-height: 1.4rem; +$slider-handle-width: 1.4rem; +$slider-handle-background: $primary-color; +$slider-opacity-disabled: 0.25; +$slider-radius: $global-radius; + +// 49. Switch +// ---------- + +$switch-background: $medium-gray; +$switch-background-active: $primary-color; +$switch-height: 2rem; +$switch-height-tiny: 1.5rem; +$switch-height-small: 1.75rem; +$switch-height-large: 2.5rem; +$switch-radius: $global-radius; +$switch-margin: $global-margin; +$switch-paddle-background: $white; +$switch-paddle-offset: 0.25rem; +$switch-paddle-radius: $global-radius; +$switch-paddle-transition: all 0.25s ease-out; + +// 50. Table +// --------- + +$table-background: $white; +$table-color-scale: 5%; +$table-border: 1px solid smart-scale($table-background, $table-color-scale); +$table-padding: rem-calc(8 10 10); +$table-hover-scale: 2%; +$table-row-hover: color.adjust($table-background, $lightness: -$table-hover-scale); +$table-row-stripe-hover: color.adjust($table-background, $lightness: -($table-color-scale + $table-hover-scale)); +$table-is-striped: true; +$table-striped-background: smart-scale($table-background, $table-color-scale); +$table-stripe: even; +$table-head-background: smart-scale($table-background, calc($table-color-scale / 2)); +$table-head-row-hover: color.adjust($table-head-background, $lightness: -$table-hover-scale); +$table-foot-background: smart-scale($table-background, $table-color-scale); +$table-foot-row-hover: color.adjust($table-foot-background, $lightness: -$table-hover-scale); +$table-head-font-color: $body-font-color; +$table-foot-font-color: $body-font-color; +$show-header-for-stacked: false; +$table-stack-breakpoint: medium; + +// 51. Tabs +// -------- + +$tab-margin: 0; +$tab-background: $white; +$tab-color: $primary-color; +$tab-background-active: $light-gray; +$tab-active-color: $primary-color; +$tab-item-font-size: rem-calc(12); +$tab-item-background-hover: $white; +$tab-item-padding: 1.25rem 1.5rem; +$tab-expand-max: 6; +$tab-content-background: $white; +$tab-content-border: $light-gray; +$tab-content-color: $body-font-color; +$tab-content-padding: 1rem; + +// 52. Thumbnail +// ------------- + +$thumbnail-border: solid 4px $white; +$thumbnail-margin-bottom: $global-margin; +$thumbnail-shadow: 0 0 0 1px rgba($black, 0.2); +$thumbnail-shadow-hover: 0 0 6px 1px rgba($primary-color, 0.5); +$thumbnail-transition: box-shadow 200ms ease-out; +$thumbnail-radius: $global-radius; + +// 53. Title Bar +// ------------- + +$titlebar-background: $black; +$titlebar-color: $white; +$titlebar-padding: 0.5rem; +$titlebar-text-font-weight: bold; +$titlebar-icon-color: $white; +$titlebar-icon-color-hover: $medium-gray; +$titlebar-icon-spacing: 0.25rem; + +// 54. Tooltip +// ----------- + +$has-tip-cursor: help; +$has-tip-font-weight: $global-weight-bold; +$has-tip-border-bottom: dotted 1px $dark-gray; +$tooltip-background-color: $black; +$tooltip-color: $white; +$tooltip-padding: 0.75rem; +$tooltip-max-width: 10rem; +$tooltip-font-size: $small-font-size; +$tooltip-pip-width: 0.75rem; +$tooltip-pip-height: $tooltip-pip-width * 0.866; +$tooltip-radius: $global-radius; + +// 55. Top Bar +// ----------- + +$topbar-padding: 0.5rem; +$topbar-background: $light-gray; +$topbar-submenu-background: $topbar-background; +$topbar-title-spacing: 0.5rem 1rem 0.5rem 0; +$topbar-input-width: 200px; +$topbar-unstack-breakpoint: medium; + +// 56. Xy Grid +// ----------- + +$xy-grid: true; +$grid-container: $global-width; +$grid-columns: 12; +$grid-margin-gutters: ( + small: 20px, + medium: 30px +); +$grid-padding-gutters: $grid-margin-gutters; +$grid-container-padding: $grid-padding-gutters; +$grid-container-max: $global-width; +$xy-block-grid-max: 8; diff --git a/src/bpp/static/scss/_settings_vizja.scss b/src/bpp/static/scss/_settings_vizja.scss new file mode 100644 index 000000000..d2e9583af --- /dev/null +++ b/src/bpp/static/scss/_settings_vizja.scss @@ -0,0 +1,879 @@ +// Paleta dopasowana do federacjavizja.pl (theme-style.css / main.css): +// primary #EFA402 — brand amber (.bgyellow) +// secondary #01608C — brand navy (.bgnavy) +// +// linki: #EFA402 (uwaga: niski kontrast WCAG na białym ~3.8:1) +// kolor tła: #f8f8f8 + +@use "sass:color"; +@use "sass:math"; + +// Foundation for Sites Settings +// ----------------------------- +// +// Table of Contents: +// +// 1. Global +// 2. Breakpoints +// 3. The Grid +// 4. Base Typography +// 5. Typography Helpers +// 6. Abide +// 7. Accordion +// 8. Accordion Menu +// 9. Badge +// 10. Breadcrumbs +// 11. Button +// 12. Button Group +// 13. Callout +// 14. Card +// 15. Close Button +// 16. Drilldown +// 17. Dropdown +// 18. Dropdown Menu +// 19. Flexbox Utilities +// 20. Forms +// 21. Label +// 22. Media Object +// 23. Menu +// 24. Meter +// 25. Off-canvas +// 26. Orbit +// 27. Pagination +// 28. Progress Bar +// 29. Prototype Arrow +// 30. Prototype Border-Box +// 31. Prototype Border-None +// 32. Prototype Bordered +// 33. Prototype Display +// 34. Prototype Font-Styling +// 35. Prototype List-Style-Type +// 36. Prototype Overflow +// 37. Prototype Position +// 38. Prototype Rounded +// 39. Prototype Separator +// 40. Prototype Shadow +// 41. Prototype Sizing +// 42. Prototype Spacing +// 43. Prototype Text-Decoration +// 44. Prototype Text-Transformation +// 45. Prototype Text-Utilities +// 46. Responsive Embed +// 47. Reveal +// 48. Slider +// 49. Switch +// 50. Table +// 51. Tabs +// 52. Thumbnail +// 53. Title Bar +// 54. Tooltip +// 55. Top Bar +// 56. Xy Grid + +@import 'util/util'; + +// 1. Global +// --------- + +$global-font-size: 92%; +$global-width: rem-calc(1200); +$global-lineheight: 1.5; +$foundation-palette: ( + primary: #EFA402, + secondary: #01608C, + success: #3adb76, + warning: #ffae00, + alert: #cc4b37, +); +$light-gray: #e6e6e6; +$medium-gray: #cacaca; +$dark-gray: #8a8a8a; +$black: #0a0a0a; +$white: #fefefe; +$body-background: #f8f8f8; +$body-font-color: $black; +$body-font-family: 'Helvetica Neue', Helvetica, Roboto, Arial, sans-serif; +$body-antialiased: true; +$global-margin: 1rem; +$global-padding: 1rem; +$global-position: 1rem; +$global-weight-normal: normal; +$global-weight-bold: bold; +$global-radius: 0; +$global-menu-padding: 0.7rem 1rem; +$global-menu-nested-margin: 1rem; +$global-text-direction: ltr; +$global-flexbox: true; +$global-prototype-breakpoints: false; +$global-button-cursor: auto; +$global-color-pick-contrast-tolerance: 0; +$print-transparent-backgrounds: true; + +@include add-foundation-colors; + +// 2. Breakpoints +// -------------- + +$breakpoints: ( + small: 0, + medium: 640px, + large: 1024px, + xlarge: 1200px, + xxlarge: 1440px, +); +$print-breakpoint: large; +$breakpoint-classes: (small medium large); + +// 3. The Grid +// ----------- + +$grid-row-width: $global-width; +$grid-column-count: 12; +$grid-column-gutter: ( + small: 20px, + medium: 30px, +); +$grid-column-align-edge: true; +$grid-column-alias: 'columns'; +$block-grid-max: 8; + +// 4. Base Typography +// ------------------ + +$header-font-family: $body-font-family; +$header-font-weight: $global-weight-normal; +$header-font-style: normal; +$font-family-monospace: Consolas, 'Liberation Mono', Courier, monospace; +$header-color: inherit; +$header-lineheight: 1.4; +$header-margin-bottom: 0.5rem; +$header-styles: ( + small: ( + 'h1': ('font-size': 24), + 'h2': ('font-size': 20), + 'h3': ('font-size': 19), + 'h4': ('font-size': 18), + 'h5': ('font-size': 17), + 'h6': ('font-size': 16), + ), + medium: ( + 'h1': ('font-size': 32), + 'h2': ('font-size': 28), + 'h3': ('font-size': 24), + 'h4': ('font-size': 20), + 'h5': ('font-size': 16), + 'h6': ('font-size': 14), + ), +); +$header-text-rendering: optimizeLegibility; +$small-font-size: 80%; +$header-small-font-color: $medium-gray; +$paragraph-lineheight: 1.6; +$paragraph-margin-bottom: 1rem; +$paragraph-text-rendering: optimizeLegibility; +$code-color: $black; +$code-font-family: $font-family-monospace; +$code-font-weight: $global-weight-normal; +$code-background: $light-gray; +$code-border: 1px solid $medium-gray; +$code-padding: rem-calc(2 5 1); +$anchor-color: $primary-color; +$anchor-color-hover: scale-color($anchor-color, $lightness: -14%); +$anchor-text-decoration: none; +$anchor-text-decoration-hover: none; +$hr-width: $global-width; +$hr-border: 1px solid $medium-gray; +$hr-margin: rem-calc(20) auto; +$list-lineheight: $paragraph-lineheight; +$list-margin-bottom: $paragraph-margin-bottom; +$list-style-type: disc; +$list-style-position: outside; +$list-side-margin: 1.25rem; +$list-nested-side-margin: 1.25rem; +$defnlist-margin-bottom: 1rem; +$defnlist-term-weight: $global-weight-bold; +$defnlist-term-margin-bottom: 0.3rem; +$blockquote-color: $dark-gray; +$blockquote-padding: rem-calc(9 20 0 19); +$blockquote-border: 1px solid $medium-gray; +$cite-font-size: rem-calc(13); +$cite-color: $dark-gray; +$cite-pseudo-content: '\2014 \0020'; +$keystroke-font: $font-family-monospace; +$keystroke-color: $black; +$keystroke-background: $light-gray; +$keystroke-padding: rem-calc(2 4 0); +$keystroke-radius: $global-radius; +$abbr-underline: 1px dotted $black; + +// 5. Typography Helpers +// --------------------- + +$lead-font-size: $global-font-size * 1.25; +$lead-lineheight: 1.6; +$subheader-lineheight: 1.4; +$subheader-color: $dark-gray; +$subheader-font-weight: $global-weight-normal; +$subheader-margin-top: 0.2rem; +$subheader-margin-bottom: 0.5rem; +$stat-font-size: 2.5rem; + +// 6. Abide +// -------- + +$abide-inputs: true; +$abide-labels: true; +$input-background-invalid: get-color(alert); +$form-label-color-invalid: get-color(alert); +$input-error-color: get-color(alert); +$input-error-font-size: rem-calc(12); +$input-error-font-weight: $global-weight-bold; + +// 7. Accordion +// ------------ + +$accordion-background: $white; +$accordion-plusminus: true; +$accordion-title-font-size: rem-calc(12); +$accordion-item-color: $primary-color; +$accordion-item-background-hover: $light-gray; +$accordion-item-padding: 1.25rem 1rem; +$accordion-content-background: $white; +$accordion-content-border: 1px solid $light-gray; +$accordion-content-color: $body-font-color; +$accordion-content-padding: 1rem; + +// 8. Accordion Menu +// ----------------- + +$accordionmenu-padding: $global-menu-padding; +$accordionmenu-nested-margin: $global-menu-nested-margin; +$accordionmenu-submenu-padding: $accordionmenu-padding; +$accordionmenu-arrows: true; +$accordionmenu-arrow-color: $primary-color; +$accordionmenu-item-background: null; +$accordionmenu-border: null; +$accordionmenu-submenu-toggle-background: null; +$accordion-submenu-toggle-border: $accordionmenu-border; +$accordionmenu-submenu-toggle-width: 40px; +$accordionmenu-submenu-toggle-height: $accordionmenu-submenu-toggle-width; +$accordionmenu-arrow-size: 6px; + +// 9. Badge +// -------- + +$badge-background: $primary-color; +$badge-color: $white; +$badge-color-alt: $black; +$badge-palette: $foundation-palette; +$badge-padding: 0.3em; +$badge-minwidth: 2.1em; +$badge-font-size: 0.6rem; + +// 10. Breadcrumbs +// --------------- + +$breadcrumbs-margin: 0 0 $global-margin 0; +$breadcrumbs-item-font-size: rem-calc(11); +$breadcrumbs-item-color: $primary-color; +$breadcrumbs-item-color-current: $black; +$breadcrumbs-item-color-disabled: $medium-gray; +$breadcrumbs-item-margin: 0.75rem; +$breadcrumbs-item-uppercase: true; +$breadcrumbs-item-separator: true; +$breadcrumbs-item-separator-item: '/'; +$breadcrumbs-item-separator-item-rtl: '\\'; +$breadcrumbs-item-separator-color: $medium-gray; + +// 11. Button +// ---------- + +$button-font-family: inherit; +$button-padding: 0.85em 1em; +$button-margin: 0 0 $global-margin 0; +$button-fill: solid; +$button-background: $primary-color; +$button-background-hover: scale-color($button-background, $lightness: -15%); +$button-color: $white; +$button-color-alt: $black; +$button-radius: $global-radius; +$button-hollow-border-width: 1px; +$button-sizes: ( + tiny: 0.6rem, + small: 0.75rem, + default: 0.9rem, + large: 1.25rem, +); +$button-palette: $foundation-palette; +$button-opacity-disabled: 0.25; +$button-background-hover-lightness: -20%; +$button-hollow-hover-lightness: -50%; +$button-transition: background-color 0.25s ease-out, color 0.25s ease-out; +$button-responsive-expanded: false; + +// 12. Button Group +// ---------------- + +$buttongroup-margin: 1rem; +$buttongroup-spacing: 1px; +$buttongroup-child-selector: '.button'; +$buttongroup-expand-max: 6; +$buttongroup-radius-on-each: true; + +// 13. Callout +// ----------- + +$callout-background: $white; +$callout-background-fade: 85%; +$callout-border: 1px solid rgba($black, 0.25); +$callout-margin: 0 0 1rem 0; +$callout-padding: 1rem; +$callout-font-color: $body-font-color; +$callout-font-color-alt: $body-background; +$callout-radius: $global-radius; +$callout-link-tint: 30%; + +// 14. Card +// -------- + +$card-background: $white; +$card-font-color: $body-font-color; +$card-divider-background: $light-gray; +$card-border: 1px solid $light-gray; +$card-shadow: none; +$card-border-radius: $global-radius; +$card-padding: $global-padding; +$card-margin-bottom: $global-margin; + +// 15. Close Button +// ---------------- + +$closebutton-position: right top; +$closebutton-offset-horizontal: ( + small: 0.66rem, + medium: 1rem, +); +$closebutton-offset-vertical: ( + small: 0.33em, + medium: 0.5rem, +); +$closebutton-size: ( + small: 1.5em, + medium: 2em, +); +$closebutton-lineheight: 1; +$closebutton-color: $dark-gray; +$closebutton-color-hover: $black; + +// 16. Drilldown +// ------------- + +$drilldown-transition: transform 0.15s linear; +$drilldown-arrows: true; +$drilldown-padding: $global-menu-padding; +$drilldown-nested-margin: 0; +$drilldown-background: $white; +$drilldown-submenu-padding: $drilldown-padding; +$drilldown-submenu-background: $white; +$drilldown-arrow-color: $primary-color; +$drilldown-arrow-size: 6px; + +// 17. Dropdown +// ------------ + +$dropdown-padding: 1rem; +$dropdown-background: $body-background; +$dropdown-border: 1px solid $medium-gray; +$dropdown-font-size: 1rem; +$dropdown-width: 300px; +$dropdown-radius: $global-radius; +$dropdown-sizes: ( + tiny: 100px, + small: 200px, + large: 400px, +); + +// 18. Dropdown Menu +// ----------------- + +$dropdownmenu-arrows: true; +$dropdownmenu-arrow-color: $anchor-color; +$dropdownmenu-arrow-size: 6px; +$dropdownmenu-arrow-padding: 1.5rem; +$dropdownmenu-min-width: 200px; +$dropdownmenu-background: $white; +$dropdownmenu-submenu-background: $dropdownmenu-background; +$dropdownmenu-padding: $global-menu-padding; +$dropdownmenu-nested-margin: 0; +$dropdownmenu-submenu-padding: $dropdownmenu-padding; +$dropdownmenu-border: 1px solid $medium-gray; +$dropdown-menu-item-color-active: get-color(primary); +$dropdown-menu-item-background-active: transparent; + +// 19. Flexbox Utilities +// --------------------- + +$flex-source-ordering-count: 6; +$flexbox-responsive-breakpoints: true; + +// 20. Forms +// --------- + +$fieldset-border: 1px solid $medium-gray; +$fieldset-padding: rem-calc(20); +$fieldset-margin: rem-calc(18 0); +$legend-padding: rem-calc(0 3); +$form-spacing: rem-calc(16); +$helptext-color: $black; +$helptext-font-size: rem-calc(13); +$helptext-font-style: italic; +$input-prefix-color: $black; +$input-prefix-background: $light-gray; +$input-prefix-border: 1px solid $medium-gray; +$input-prefix-padding: 1rem; +$form-label-color: $black; +$form-label-font-size: rem-calc(14); +$form-label-font-weight: $global-weight-normal; +$form-label-line-height: 1.8; +$select-background: $white; +$select-triangle-color: $dark-gray; +$select-radius: $global-radius; +$input-color: $black; +$input-placeholder-color: $medium-gray; +$input-font-family: inherit; +$input-font-size: rem-calc(16); +$input-font-weight: $global-weight-normal; +$input-line-height: $global-lineheight; +$input-background: $white; +$input-background-focus: $white; +$input-background-disabled: $light-gray; +$input-border: 1px solid $medium-gray; +$input-border-focus: 1px solid $dark-gray; +$input-padding: calc($form-spacing / 2); +$input-shadow: inset 0 1px 2px rgba($black, 0.1); +$input-shadow-focus: 0 0 5px $medium-gray; +$input-cursor-disabled: not-allowed; +$input-transition: box-shadow 0.5s, border-color 0.25s ease-in-out; +$input-number-spinners: true; +$input-radius: $global-radius; +$form-button-radius: $global-radius; + +// 21. Label +// --------- + +$label-background: $primary-color; +$label-color: $white; +$label-color-alt: $black; +$label-palette: $foundation-palette; +$label-font-size: 0.8rem; +$label-padding: 0.33333rem 0.5rem; +$label-radius: $global-radius; + +// 22. Media Object +// ---------------- + +$mediaobject-margin-bottom: $global-margin; +$mediaobject-section-padding: $global-padding; +$mediaobject-image-width-stacked: 100%; + +// 23. Menu +// -------- + +$menu-margin: 0; +$menu-nested-margin: $global-menu-nested-margin; +$menu-items-padding: $global-menu-padding; +$menu-simple-margin: 1rem; +$menu-item-color-active: $white; +$menu-item-background-active: get-color(primary); +$menu-icon-spacing: 0.25rem; +$menu-item-background-hover: $light-gray; +$menu-state-back-compat: true; +$menu-centered-back-compat: true; +$menu-icons-back-compat: true; + +// 24. Meter +// --------- + +$meter-height: 1rem; +$meter-radius: $global-radius; +$meter-background: $medium-gray; +$meter-fill-good: $success-color; +$meter-fill-medium: $warning-color; +$meter-fill-bad: $alert-color; + +// 25. Off-canvas +// -------------- + +$offcanvas-sizes: ( + small: 250px, +); +$offcanvas-vertical-sizes: ( + small: 250px, +); +$offcanvas-background: $light-gray; +$offcanvas-shadow: 0 0 10px rgba($black, 0.7); +$offcanvas-inner-shadow-size: 20px; +$offcanvas-inner-shadow-color: rgba($black, 0.25); +$offcanvas-overlay-zindex: 11; +$offcanvas-push-zindex: 12; +$offcanvas-overlap-zindex: 13; +$offcanvas-reveal-zindex: 12; +$offcanvas-transition-length: 0.5s; +$offcanvas-transition-timing: ease; +$offcanvas-fixed-reveal: true; +$offcanvas-exit-background: rgba($white, 0.25); +$maincontent-class: 'off-canvas-content'; + +// 26. Orbit +// --------- + +$orbit-bullet-background: $medium-gray; +$orbit-bullet-background-active: $dark-gray; +$orbit-bullet-diameter: 1.2rem; +$orbit-bullet-margin: 0.1rem; +$orbit-bullet-margin-top: 0.8rem; +$orbit-bullet-margin-bottom: 0.8rem; +$orbit-caption-background: rgba($black, 0.5); +$orbit-caption-padding: 1rem; +$orbit-control-background-hover: rgba($black, 0.5); +$orbit-control-padding: 1rem; +$orbit-control-zindex: 10; + +// 27. Pagination +// -------------- + +$pagination-font-size: rem-calc(14); +$pagination-margin-bottom: $global-margin; +$pagination-item-color: $black; +$pagination-item-padding: rem-calc(3 10); +$pagination-item-spacing: rem-calc(1); +$pagination-radius: $global-radius; +$pagination-item-background-hover: $light-gray; +$pagination-item-background-current: $primary-color; +$pagination-item-color-current: $white; +$pagination-item-color-disabled: $medium-gray; +$pagination-ellipsis-color: $black; +$pagination-mobile-items: false; +$pagination-mobile-current-item: false; +$pagination-arrows: true; + +// 28. Progress Bar +// ---------------- + +$progress-height: 1rem; +$progress-background: $medium-gray; +$progress-margin-bottom: $global-margin; +$progress-meter-background: $primary-color; +$progress-radius: $global-radius; + +// 29. Prototype Arrow +// ------------------- + +$prototype-arrow-directions: ( + down, + up, + right, + left +); +$prototype-arrow-size: 0.4375rem; +$prototype-arrow-color: $black; + +// 30. Prototype Border-Box +// ------------------------ + +$prototype-border-box-breakpoints: $global-prototype-breakpoints; + +// 31. Prototype Border-None +// ------------------------- + +$prototype-border-none-breakpoints: $global-prototype-breakpoints; + +// 32. Prototype Bordered +// ---------------------- + +$prototype-bordered-breakpoints: $global-prototype-breakpoints; +$prototype-border-width: rem-calc(1); +$prototype-border-type: solid; +$prototype-border-color: $medium-gray; + +// 33. Prototype Display +// --------------------- + +$prototype-display-breakpoints: $global-prototype-breakpoints; +$prototype-display: ( + inline, + inline-block, + block, + table, + table-cell +); + +// 34. Prototype Font-Styling +// -------------------------- + +$prototype-font-breakpoints: $global-prototype-breakpoints; +$prototype-wide-letter-spacing: rem-calc(4); +$prototype-font-normal: $global-weight-normal; +$prototype-font-bold: $global-weight-bold; + +// 35. Prototype List-Style-Type +// ----------------------------- + +$prototype-list-breakpoints: $global-prototype-breakpoints; +$prototype-style-type-unordered: ( + disc, + circle, + square +); +$prototype-style-type-ordered: ( + decimal, + lower-alpha, + lower-latin, + lower-roman, + upper-alpha, + upper-latin, + upper-roman +); + +// 36. Prototype Overflow +// ---------------------- + +$prototype-overflow-breakpoints: $global-prototype-breakpoints; +$prototype-overflow: ( + visible, + hidden, + scroll +); + +// 37. Prototype Position +// ---------------------- + +$prototype-position-breakpoints: $global-prototype-breakpoints; +$prototype-position: ( + static, + relative, + absolute, + fixed +); +$prototype-position-z-index: 975; + +// 38. Prototype Rounded +// --------------------- + +$prototype-rounded-breakpoints: $global-prototype-breakpoints; +$prototype-border-radius: rem-calc(3); + +// 39. Prototype Separator +// ----------------------- + +$prototype-separator-breakpoints: $global-prototype-breakpoints; +$prototype-separator-align: center; +$prototype-separator-height: rem-calc(2); +$prototype-separator-width: 3rem; +$prototype-separator-background: $primary-color; +$prototype-separator-margin-top: $global-margin; + +// 40. Prototype Shadow +// -------------------- + +$prototype-shadow-breakpoints: $global-prototype-breakpoints; +$prototype-box-shadow: 0 2px 5px 0 rgba(0,0,0,.16), + 0 2px 10px 0 rgba(0,0,0,.12); + +// 41. Prototype Sizing +// -------------------- + +$prototype-sizing-breakpoints: $global-prototype-breakpoints; +$prototype-sizing: ( + width, + height +); +$prototype-sizes: ( + 25: 25%, + 50: 50%, + 75: 75%, + 100: 100% +); + +// 42. Prototype Spacing +// --------------------- + +$prototype-spacing-breakpoints: $global-prototype-breakpoints; +$prototype-spacers-count: 3; + +// 43. Prototype Text-Decoration +// ----------------------------- + +$prototype-decoration-breakpoints: $global-prototype-breakpoints; +$prototype-text-decoration: ( + overline, + underline, + line-through, +); + +// 44. Prototype Text-Transformation +// --------------------------------- + +$prototype-transformation-breakpoints: $global-prototype-breakpoints; +$prototype-text-transformation: ( + lowercase, + uppercase, + capitalize +); + +// 45. Prototype Text-Utilities +// ---------------------------- + +$prototype-utilities-breakpoints: $global-prototype-breakpoints; +$prototype-text-overflow: ellipsis; + +// 46. Responsive Embed +// -------------------- + +$responsive-embed-margin-bottom: rem-calc(16); +$responsive-embed-ratios: ( + default: 4 by 3, + widescreen: 16 by 9, +); + +// 47. Reveal +// ---------- + +$reveal-background: $white; +$reveal-width: 600px; +$reveal-max-width: $global-width; +$reveal-padding: $global-padding; +$reveal-border: 1px solid $medium-gray; +$reveal-radius: $global-radius; +$reveal-zindex: 1005; +$reveal-overlay-background: rgba($black, 0.45); + +// 48. Slider +// ---------- + +$slider-width-vertical: 0.5rem; +$slider-transition: all 0.2s ease-in-out; +$slider-height: 0.5rem; +$slider-background: $light-gray; +$slider-fill-background: $medium-gray; +$slider-handle-height: 1.4rem; +$slider-handle-width: 1.4rem; +$slider-handle-background: $primary-color; +$slider-opacity-disabled: 0.25; +$slider-radius: $global-radius; + +// 49. Switch +// ---------- + +$switch-background: $medium-gray; +$switch-background-active: $primary-color; +$switch-height: 2rem; +$switch-height-tiny: 1.5rem; +$switch-height-small: 1.75rem; +$switch-height-large: 2.5rem; +$switch-radius: $global-radius; +$switch-margin: $global-margin; +$switch-paddle-background: $white; +$switch-paddle-offset: 0.25rem; +$switch-paddle-radius: $global-radius; +$switch-paddle-transition: all 0.25s ease-out; + +// 50. Table +// --------- + +$table-background: $white; +$table-color-scale: 5%; +$table-border: 1px solid smart-scale($table-background, $table-color-scale); +$table-padding: rem-calc(8 10 10); +$table-hover-scale: 2%; +$table-row-hover: color.adjust($table-background, $lightness: -$table-hover-scale); +$table-row-stripe-hover: color.adjust($table-background, $lightness: -($table-color-scale + $table-hover-scale)); +$table-is-striped: true; +$table-striped-background: smart-scale($table-background, $table-color-scale); +$table-stripe: even; +$table-head-background: smart-scale($table-background, calc($table-color-scale / 2)); +$table-head-row-hover: color.adjust($table-head-background, $lightness: -$table-hover-scale); +$table-foot-background: smart-scale($table-background, $table-color-scale); +$table-foot-row-hover: color.adjust($table-foot-background, $lightness: -$table-hover-scale); +$table-head-font-color: $body-font-color; +$table-foot-font-color: $body-font-color; +$show-header-for-stacked: false; +$table-stack-breakpoint: medium; + +// 51. Tabs +// -------- + +$tab-margin: 0; +$tab-background: $white; +$tab-color: $primary-color; +$tab-background-active: $light-gray; +$tab-active-color: $primary-color; +$tab-item-font-size: rem-calc(12); +$tab-item-background-hover: $white; +$tab-item-padding: 1.25rem 1.5rem; +$tab-expand-max: 6; +$tab-content-background: $white; +$tab-content-border: $light-gray; +$tab-content-color: $body-font-color; +$tab-content-padding: 1rem; + +// 52. Thumbnail +// ------------- + +$thumbnail-border: solid 4px $white; +$thumbnail-margin-bottom: $global-margin; +$thumbnail-shadow: 0 0 0 1px rgba($black, 0.2); +$thumbnail-shadow-hover: 0 0 6px 1px rgba($primary-color, 0.5); +$thumbnail-transition: box-shadow 200ms ease-out; +$thumbnail-radius: $global-radius; + +// 53. Title Bar +// ------------- + +$titlebar-background: $black; +$titlebar-color: $white; +$titlebar-padding: 0.5rem; +$titlebar-text-font-weight: bold; +$titlebar-icon-color: $white; +$titlebar-icon-color-hover: $medium-gray; +$titlebar-icon-spacing: 0.25rem; + +// 54. Tooltip +// ----------- + +$has-tip-cursor: help; +$has-tip-font-weight: $global-weight-bold; +$has-tip-border-bottom: dotted 1px $dark-gray; +$tooltip-background-color: $black; +$tooltip-color: $white; +$tooltip-padding: 0.75rem; +$tooltip-max-width: 10rem; +$tooltip-font-size: $small-font-size; +$tooltip-pip-width: 0.75rem; +$tooltip-pip-height: $tooltip-pip-width * 0.866; +$tooltip-radius: $global-radius; + +// 55. Top Bar +// ----------- + +$topbar-padding: 0.5rem; +$topbar-background: $light-gray; +$topbar-submenu-background: $topbar-background; +$topbar-title-spacing: 0.5rem 1rem 0.5rem 0; +$topbar-input-width: 200px; +$topbar-unstack-breakpoint: medium; + +// 56. Xy Grid +// ----------- + +$xy-grid: true; +$grid-container: $global-width; +$grid-columns: 12; +$grid-margin-gutters: ( + small: 20px, + medium: 30px +); +$grid-padding-gutters: $grid-margin-gutters; +$grid-container-padding: $grid-padding-gutters; +$grid-container-max: $global-width; +$xy-block-grid-max: 8; diff --git a/src/bpp/static/scss/app-green.scss b/src/bpp/static/scss/app-green.scss index 7e8d455e5..955d29540 100644 --- a/src/bpp/static/scss/app-green.scss +++ b/src/bpp/static/scss/app-green.scss @@ -116,7 +116,3 @@ input[type=radio] { background: $primary-color !important; color: white; } - -.fi-calendar { - color: $primary-color; -} diff --git a/src/bpp/static/scss/app-mwsl.scss b/src/bpp/static/scss/app-mwsl.scss new file mode 100644 index 000000000..2266118fe --- /dev/null +++ b/src/bpp/static/scss/app-mwsl.scss @@ -0,0 +1,129 @@ +@use "sass:color"; +@import "settings_mwsl"; + +@import "common"; +@import "checkbox"; +@import "main_page_buttons"; +@import "browse"; +@import "browse_autorzy"; +@import "browse_jednostki"; +@import "external_links"; +@import "ranking_autorow"; +@import "pagination"; +@import "wizard_forms"; + +@import 'foundation'; + +@include foundation-global-styles; +@include foundation-xy-grid-classes; +@include foundation-grid; +@include foundation-flex-grid; +@include foundation-flex-classes; +@include foundation-typography; +@include foundation-forms; +@include foundation-button; +@include foundation-accordion; +@include foundation-accordion-menu; +@include foundation-badge; +@include foundation-breadcrumbs; +@include foundation-button-group; +@include foundation-callout; +@include foundation-card; +@include foundation-close-button; +@include foundation-menu; +@include foundation-menu-icon; +@include foundation-drilldown-menu; +@include foundation-dropdown; +@include foundation-dropdown-menu; +@include foundation-responsive-embed; +@include foundation-label; +@include foundation-media-object; +@include foundation-off-canvas; +@include foundation-orbit; +@include foundation-pagination; +@include foundation-progress-bar; +@include foundation-slider; +@include foundation-sticky; +@include foundation-reveal; +@include foundation-switch; +@include foundation-table; +@include foundation-tabs; +@include foundation-thumbnail; +@include foundation-title-bar; +@include foundation-tooltip; +@include foundation-top-bar; +@include foundation-visibility-classes; +@include foundation-float-classes; +@include foundation-print-styles; + +$info-color: #fff0e8; + +/* MWSL University theme checkbox and radio button accent color */ +/* Using Foundation's primary color (#e35b00) for the orange theme */ +input[type=checkbox], +input[type=radio] { + accent-color: #e35b00; +} + +// Theme-specific overrides for browse.scss +.modern-header { + h1, h2 { + .fi-foundation { + color: $primary-color; + } + } +} + +.section-header { + h2 { + .fi-home { + color: $primary-color; + } + + .fi-torsos-all { + color: $secondary-color; + } + } +} + +.jednostka-name a { + &:hover { + &.link-aktualne { + color: $primary-color; + } + + &.link-kola { + color: $secondary-color; + } + } +} + +.button-search { + background: $primary-color; + + &:hover { + background-color: color.adjust($primary-color, $lightness: -10%); + } +} + +.button-show-all { + background: $secondary-color; + + &:hover { + background-color: color.adjust($secondary-color, $lightness: -10%); + } +} + +.author-profile-link { + color: $primary-color; +} + +.ui-state-focus { + background: $primary-color !important; + border: 1px white !important; +} + +.select2-results__option--highlighted { + background: $primary-color !important; + color: white; +} diff --git a/src/bpp/static/scss/app-orange.scss b/src/bpp/static/scss/app-orange.scss index e802b7d5c..8385eb5d5 100644 --- a/src/bpp/static/scss/app-orange.scss +++ b/src/bpp/static/scss/app-orange.scss @@ -131,7 +131,3 @@ input[type=radio] { background: $primary-color !important; color: white; } - -.fi-calendar { - color: $primary-color; -} diff --git a/src/bpp/static/scss/app-uafm.scss b/src/bpp/static/scss/app-uafm.scss new file mode 100644 index 000000000..25fe6d152 --- /dev/null +++ b/src/bpp/static/scss/app-uafm.scss @@ -0,0 +1,129 @@ +@use "sass:color"; +@import "settings_uafm"; + +@import "common"; +@import "checkbox"; +@import "main_page_buttons"; +@import "browse"; +@import "browse_autorzy"; +@import "browse_jednostki"; +@import "external_links"; +@import "ranking_autorow"; +@import "pagination"; +@import "wizard_forms"; + +@import 'foundation'; + +@include foundation-global-styles; +@include foundation-xy-grid-classes; +@include foundation-grid; +@include foundation-flex-grid; +@include foundation-flex-classes; +@include foundation-typography; +@include foundation-forms; +@include foundation-button; +@include foundation-accordion; +@include foundation-accordion-menu; +@include foundation-badge; +@include foundation-breadcrumbs; +@include foundation-button-group; +@include foundation-callout; +@include foundation-card; +@include foundation-close-button; +@include foundation-menu; +@include foundation-menu-icon; +@include foundation-drilldown-menu; +@include foundation-dropdown; +@include foundation-dropdown-menu; +@include foundation-responsive-embed; +@include foundation-label; +@include foundation-media-object; +@include foundation-off-canvas; +@include foundation-orbit; +@include foundation-pagination; +@include foundation-progress-bar; +@include foundation-slider; +@include foundation-sticky; +@include foundation-reveal; +@include foundation-switch; +@include foundation-table; +@include foundation-tabs; +@include foundation-thumbnail; +@include foundation-title-bar; +@include foundation-tooltip; +@include foundation-top-bar; +@include foundation-visibility-classes; +@include foundation-float-classes; +@include foundation-print-styles; + +$info-color: #f0f8ff; + +/* UFAM University theme checkbox and radio button accent color */ +/* Using Foundation's primary color (#b41906) for the red theme */ +input[type=checkbox], +input[type=radio] { + accent-color: #b41906; +} + +// Theme-specific overrides for browse.scss +.modern-header { + h1, h2 { + .fi-foundation { + color: $primary-color; + } + } +} + +.section-header { + h2 { + .fi-home { + color: $primary-color; + } + + .fi-torsos-all { + color: $secondary-color; + } + } +} + +.jednostka-name a { + &:hover { + &.link-aktualne { + color: $primary-color; + } + + &.link-kola { + color: $secondary-color; + } + } +} + +.button-search { + background: $primary-color; + + &:hover { + background-color: color.adjust($primary-color, $lightness: -10%); + } +} + +.button-show-all { + background: $secondary-color; + + &:hover { + background-color: color.adjust($secondary-color, $lightness: -10%); + } +} + +.author-profile-link { + color: $primary-color; +} + +.ui-state-focus { + background: $primary-color !important; + border: 1px white !important; +} + +.select2-results__option--highlighted { + background: $primary-color !important; + color: white; +} diff --git a/src/bpp/static/scss/app-vizja.scss b/src/bpp/static/scss/app-vizja.scss new file mode 100644 index 000000000..7ffb4505d --- /dev/null +++ b/src/bpp/static/scss/app-vizja.scss @@ -0,0 +1,129 @@ +@use "sass:color"; +@import "settings_vizja"; + +@import "common"; +@import "checkbox"; +@import "main_page_buttons"; +@import "browse"; +@import "browse_autorzy"; +@import "browse_jednostki"; +@import "external_links"; +@import "ranking_autorow"; +@import "pagination"; +@import "wizard_forms"; + +@import 'foundation'; + +@include foundation-global-styles; +@include foundation-xy-grid-classes; +@include foundation-grid; +@include foundation-flex-grid; +@include foundation-flex-classes; +@include foundation-typography; +@include foundation-forms; +@include foundation-button; +@include foundation-accordion; +@include foundation-accordion-menu; +@include foundation-badge; +@include foundation-breadcrumbs; +@include foundation-button-group; +@include foundation-callout; +@include foundation-card; +@include foundation-close-button; +@include foundation-menu; +@include foundation-menu-icon; +@include foundation-drilldown-menu; +@include foundation-dropdown; +@include foundation-dropdown-menu; +@include foundation-responsive-embed; +@include foundation-label; +@include foundation-media-object; +@include foundation-off-canvas; +@include foundation-orbit; +@include foundation-pagination; +@include foundation-progress-bar; +@include foundation-slider; +@include foundation-sticky; +@include foundation-reveal; +@include foundation-switch; +@include foundation-table; +@include foundation-tabs; +@include foundation-thumbnail; +@include foundation-title-bar; +@include foundation-tooltip; +@include foundation-top-bar; +@include foundation-visibility-classes; +@include foundation-float-classes; +@include foundation-print-styles; + +$info-color: #fff8e0; + +/* Vizja University theme checkbox and radio button accent color */ +/* Using Foundation's primary color (#EFA402) for the amber theme */ +input[type=checkbox], +input[type=radio] { + accent-color: #EFA402; +} + +// Theme-specific overrides for browse.scss +.modern-header { + h1, h2 { + .fi-foundation { + color: $primary-color; + } + } +} + +.section-header { + h2 { + .fi-home { + color: $primary-color; + } + + .fi-torsos-all { + color: $secondary-color; + } + } +} + +.jednostka-name a { + &:hover { + &.link-aktualne { + color: $primary-color; + } + + &.link-kola { + color: $secondary-color; + } + } +} + +.button-search { + background: $primary-color; + + &:hover { + background-color: color.adjust($primary-color, $lightness: -10%); + } +} + +.button-show-all { + background: $secondary-color; + + &:hover { + background-color: color.adjust($secondary-color, $lightness: -10%); + } +} + +.author-profile-link { + color: $primary-color; +} + +.ui-state-focus { + background: $primary-color !important; + border: 1px white !important; +} + +.select2-results__option--highlighted { + background: $primary-color !important; + color: $black; +} diff --git a/src/bpp/static/scss/top_bar.scss b/src/bpp/static/scss/top_bar.scss index 8436bd420..ae179ec35 100644 --- a/src/bpp/static/scss/top_bar.scss +++ b/src/bpp/static/scss/top_bar.scss @@ -284,7 +284,7 @@ nav.sticky-header { width: 0; height: 0; border: inset 6px; - border-color: rgba(44, 62, 80, 0.6) transparent transparent transparent; + border-color: rgba($anchor-color, 0.6) transparent transparent transparent; border-top-style: solid; margin-left: 5px; vertical-align: middle; diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index 8a374e200..13266f30d 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -579,6 +579,21 @@ def autoslug_gen(): "STATIC_URL": STATIC_URL, "LANGUAGE_CODE": "pl", }, + { + "THEME_NAME": "scss/app-vizja.css", + "STATIC_URL": STATIC_URL, + "LANGUAGE_CODE": "pl", + }, + { + "THEME_NAME": "scss/app-mwsl.css", + "STATIC_URL": STATIC_URL, + "LANGUAGE_CODE": "pl", + }, + { + "THEME_NAME": "scss/app-uafm.css", + "STATIC_URL": STATIC_URL, + "LANGUAGE_CODE": "pl", + }, ] # Lista hostów obsługiwanych przez deployment. From dadca2cce7842d896125f427514e8edaeefcf8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 21 May 2026 10:55:28 +0200 Subject: [PATCH 030/247] docs: usun pozostalosci po Sphinxie po migracji na MkDocs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migracja docsow z Sphinxa na MkDocs Material (87a76da43) pozostawila kilka rozsianych odwolan do Sphinxa — sprzatamy je. - Makefile: live-docs -> mkdocs serve (zamiast sphinx-autobuild) - docs/SECURITY_PRACTICES.md: wyjatek dla live-docs opisany przez docs/requirements.txt (mkdocs-material) zamiast sphinx-autobuild - SECURITY.md + docs/SECURITY.md: HISTORY.rst -> HISTORY.md (plik HISTORY.md istnieje od dawna, RST byl martwym odsylaczem) - bin/scan-deps.sh: przyklad dev-only paczki w komentarzu sphinx -> mkdocs - AUTHORS.rst -> AUTHORS.md (jedyny pozostaly .rst w repo, niczego nie referowal, tresc juz w docs/authors.md ale plik w roocie zostawiamy dla widocznosci GitHuba) Co-Authored-By: Claude Opus 4.7 (1M context) --- AUTHORS.md | 11 +++++++++++ AUTHORS.rst | 15 --------------- Makefile | 12 ++++++------ SECURITY.md | 4 ++-- bin/scan-deps.sh | 2 +- docs/SECURITY.md | 4 ++-- docs/SECURITY_PRACTICES.md | 6 ++++-- 7 files changed, 26 insertions(+), 28 deletions(-) create mode 100644 AUTHORS.md delete mode 100644 AUTHORS.rst diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 000000000..e7840f4de --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,11 @@ +# Autorzy + +## Programiści + +- Michał Pasternak + +## Bibliotekarze + +- Elżbieta Drożdż +- Renata Birska +- Małgorzata Zając diff --git a/AUTHORS.rst b/AUTHORS.rst deleted file mode 100644 index 4e9122ae6..000000000 --- a/AUTHORS.rst +++ /dev/null @@ -1,15 +0,0 @@ -======= -Autorzy -======= - -Programiści ------------ - -* Michał Pasternak - -Bibliotekarze -------------- - -* Elżbieta Drożdż -* Renata Birska -* Małgorzata Zając diff --git a/Makefile b/Makefile index 0092ad0b2..abf57620f 100644 --- a/Makefile +++ b/Makefile @@ -299,12 +299,12 @@ js-tests: assets ## Uruchom testy JS (QUnit via Puppeteer) ##@ Dokumentacja # cel: live-docs -# Uruchom sphinx-autobuild -live-docs: ## Uruchom sphinx-autobuild na porcie 8080 (live-reload docs) - # Nie wrzucam instalacji sphinx-autobuild do requirements_dev.in - # celowo i z premedytacją: - uv pip install --upgrade sphinx-autobuild - uv run sphinx-autobuild --port 8080 -D language=pl docs/ docs/_build +# Uruchom mkdocs serve (live-reload docs) +live-docs: ## Uruchom mkdocs serve na porcie 8080 (live-reload docs) + # Zaleznosci docsow trzymamy poza glownym dev-extras (uzywane tylko + # lokalnie i na Read the Docs) — instalujemy ad-hoc: + uv pip install -r docs/requirements.txt + uv run mkdocs serve --dev-addr 127.0.0.1:8080 ##@ Microsoft Auth diff --git a/SECURITY.md b/SECURITY.md index 617ec144f..672d25c9b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,7 +44,7 @@ W zgłoszeniu opisz proszę: | Łata: wysokie | 30 dni | | Łata: średnie/niskie | następne planowane wydanie | -Zgłaszający otrzyma podziękowanie w wpisie do `HISTORY.rst` i (na życzenie) w +Zgłaszający otrzyma podziękowanie w wpisie do `HISTORY.md` i (na życzenie) w opublikowanym Security Advisory, po wydaniu łaty. ## Poza zakresem @@ -101,5 +101,5 @@ Please include reproduction steps, impact, BPP version, and any PoC/logs. - Triage: within 7 business days. - Patch: critical 14d, high 30d, medium/low next scheduled release. -Reporters are credited in `HISTORY.rst` and (on request) in the published +Reporters are credited in `HISTORY.md` and (on request) in the published Security Advisory. diff --git a/bin/scan-deps.sh b/bin/scan-deps.sh index fc946e0a5..26bb71fd6 100755 --- a/bin/scan-deps.sh +++ b/bin/scan-deps.sh @@ -10,7 +10,7 @@ set -euo pipefail # trafia do obrazu Dockera. # # --full: skan bieżącego venva ze wszystkimi extras (dev/test/docs). -# Pokazuje też CVE w ipython, pytest, sphinx itd. - przydatne tylko +# Pokazuje też CVE w ipython, pytest, mkdocs itd. - przydatne tylko # jeśli chcesz wiedzieć co dotyka developerów lokalnie. Te paczki # nigdy nie idą do obrazu produkcyjnego. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 617ec144f..672d25c9b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -44,7 +44,7 @@ W zgłoszeniu opisz proszę: | Łata: wysokie | 30 dni | | Łata: średnie/niskie | następne planowane wydanie | -Zgłaszający otrzyma podziękowanie w wpisie do `HISTORY.rst` i (na życzenie) w +Zgłaszający otrzyma podziękowanie w wpisie do `HISTORY.md` i (na życzenie) w opublikowanym Security Advisory, po wydaniu łaty. ## Poza zakresem @@ -101,5 +101,5 @@ Please include reproduction steps, impact, BPP version, and any PoC/logs. - Triage: within 7 business days. - Patch: critical 14d, high 30d, medium/low next scheduled release. -Reporters are credited in `HISTORY.rst` and (on request) in the published +Reporters are credited in `HISTORY.md` and (on request) in the published Security Advisory. diff --git a/docs/SECURITY_PRACTICES.md b/docs/SECURITY_PRACTICES.md index 2dec8f197..7ac6601c1 100644 --- a/docs/SECURITY_PRACTICES.md +++ b/docs/SECURITY_PRACTICES.md @@ -40,8 +40,10 @@ zapisane w `uv.lock` — z hashami SHA-256, które wykrywają tampering. - `make uv-sync` — luźny, bez `--frozen`, dla aktywnego dewelopmentu gdy dev modyfikuje `pyproject.toml` i potrzebuje refresh lockfile w jednym kroku. Workflow: `vim pyproject.toml; make uv-lock; make uv-sync`. -- `make live-docs` — `uv pip install --upgrade sphinx-autobuild` poza - lockfile (świadomie, sphinx-autobuild jest dev-only narzędziem). +- `make live-docs` — `uv pip install -r docs/requirements.txt` poza + głównym lockfile (świadomie, mkdocs-material i jego zależności to + dev-only narzędzia używane lokalnie i przez Read the Docs; + `docs/requirements.txt` jest osobnym manifestem dla RTD). - `make enable-microsoft-auth` — `uv pip install django_microsoft_auth` (alternatywnie można `uv sync --extra office365` jeśli pakiet jest w lockfile). From 822db2e72bb0da33f117cf0855242b987e38b444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 21 May 2026 13:42:45 +0200 Subject: [PATCH 031/247] fix(ci+docker): napraw hint check-flag dla PR + usun stale COPY notifications 1. check-flag hint: na pull_request evencie github.ref_name to "/merge" (np. 189/merge) ktorego workflow_dispatch nie akceptuje ("HTTP 422: No ref found for: 189/merge"). Dodane HEAD_REF z github.head_ref (nazwa branchu zrodlowego PR-a) + fallback do ref_name dla nie-PR eventow. Przed: gh workflow run build-docker-images.yml --ref 189/merge (fail) Po: gh workflow run build-docker-images.yml --ref feature/multi-hosted-config 2. docker/bpp_base/Dockerfile: usuniety martwy COPY z src/notifications/static/notifications/js/. src/notifications/ apka zostala usunieta na dev w commicie 048c2cfa2 (notifications JS jest teraz dostarczane przez pakiet django-channels-broadcast, ktory wyladuje pliki z venv w runtime collectstatic). Powodowalo to fail docker builda na "failed to compute cache key: ... /src/notifications/ static/notifications/js: not found". Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-docker-images.yml | 8 +++++++- docker/bpp_base/Dockerfile | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) 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/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/ From e99f8ccce8cc2c73f49e3afb0732129c132e7218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Mon, 1 Jun 2026 09:53:12 +0200 Subject: [PATCH 032/247] =?UTF-8?q?fix(migrations):=20scal=20li=C5=9Bcie?= =?UTF-8?q?=20grafu=20bpp=20(0417+0418=20=E2=86=92=200419)=20po=20merge=20?= =?UTF-8?q?dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge dev wniósł 0417_remove_uczelnia_pokazuj_raport_autorow_and_more, co utworzyło drugą liść obok istniejącej 0418_merge_20260521_1015. Pusta migracja scalająca unifikuje graf — bez zmian schematu. --- src/bpp/migrations/0419_merge_20260601_0952.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/bpp/migrations/0419_merge_20260601_0952.py diff --git a/src/bpp/migrations/0419_merge_20260601_0952.py b/src/bpp/migrations/0419_merge_20260601_0952.py new file mode 100644 index 000000000..92197872f --- /dev/null +++ b/src/bpp/migrations/0419_merge_20260601_0952.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.14 on 2026-06-01 07:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0417_remove_uczelnia_pokazuj_raport_autorow_and_more'), + ('bpp', '0418_merge_20260521_1015'), + ] + + operations = [ + ] From 077084b68b40fcf95c9556bb3cb717c67aaf0ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Mon, 1 Jun 2026 11:54:06 +0200 Subject: [PATCH 033/247] fix(tests): zarejestruj conftest_multisite po django.setup() (#189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fikstury `site1`/`uczelnia1`/`site2`/`uczelnia2` (i pochodne) były rejestrowane przez `pytest_plugins` WYŁĄCZNIE w rootdir-owym `conftest.py`. `conftest_multisite` importuje modele Django na top-levelu, a rootdir-owe `pytest_plugins` ładuje się w preloadzie `pytest-testcontainers-django` ZANIM `django.setup()` zapełni rejestr aplikacji → import wybucha `AppRegistryNotReady`, fikstury cicho się nie rejestrują, a każdy test ich żądający pada `ERROR at setup: fixture 'site1' not found`. To wywalało 7 z 8 shardów w CI (testy multisite/middleware/site-filtered). Pozostałe model-bearing moduły fikstur (conftest_models, _publications, _system, _disciplines, pbn_api, wydawnictwa) są już w `src/conftest.py` (ładowanym PO `django.setup()`) — `conftest_multisite` był jedynym, który złamał ten wzorzec. Dopisuję go tam, obok sąsiadów. Domykam też lukę w guardzie `test_conftest_preload_safety.py`: dotąd sprawdzał tylko eager-chain `from fixtures import *` (przez `import conftest`), który NIE ładuje `pytest_plugins`. Nowy test importuje każdy moduł z rootdir-owej `pytest_plugins` przed `django.setup()` i wymaga, by każdy preload-niebezpieczny (model-bearing) był też w `src/conftest.py`. Bez fixu nowy test pada wskazując dokładnie `fixtures.conftest_multisite`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/tests/test_conftest_preload_safety.py | 104 ++++++++++++++++++ src/conftest.py | 9 ++ 2 files changed, 113 insertions(+) diff --git a/src/bpp/tests/test_conftest_preload_safety.py b/src/bpp/tests/test_conftest_preload_safety.py index a51746941..c9c143e74 100644 --- a/src/bpp/tests/test_conftest_preload_safety.py +++ b/src/bpp/tests/test_conftest_preload_safety.py @@ -18,6 +18,7 @@ and asserts the rootdir conftest imports without touching the app registry. """ +import ast import os import subprocess import sys @@ -68,3 +69,106 @@ def test_rootdir_conftest_imports_before_django_setup(): "preload chain. Move it into a pytest_plugins module instead.\n\n" f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}" ) + + +def _pytest_plugins_of(conftest_path: Path) -> list[str]: + """Statically extract the ``pytest_plugins`` list literal from a conftest. + + Uses ``ast`` instead of importing the module: the rootdir conftest runs a + ``make assets`` check and ``from fixtures import *`` at import time, and + ``src/conftest.py`` installs monkey-patches — none of which we want to + trigger just to read a list of strings. + """ + tree = ast.parse(conftest_path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "pytest_plugins" for t in node.targets + ): + return [el.value for el in node.value.elts if isinstance(el, ast.Constant)] + return [] + + +# Probe run in a clean subprocess that mirrors the plugin preload bootstrap +# (settings configured, ``django.setup()`` NOT called). For each plugin module +# named on argv it prints ``\tOK`` if it imports cleanly or +# ``\tUNSAFE: `` if importing it touches the unpopulated app +# registry. A module that is UNSAFE here cannot be reliably registered from the +# rootdir conftest's ``pytest_plugins`` (whose loading races ``django.setup()``) +# and therefore MUST also be registered in ``src/conftest.py`` (loaded AFTER +# ``django.setup()``), or its fixtures silently vanish — exactly the +# ``fixture 'site1' not found`` class of failure. +_PLUGIN_IMPORT_PROBE = """ +import importlib +import os +import sys + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_bpp.settings.local") + +from django.conf import settings + +settings.INSTALLED_APPS # force lazy settings load; does NOT populate apps + +import django.apps + +assert django.apps.apps.ready is False, ( + "precondition failed: app registry is already populated" +) + +for module in sys.argv[1:]: + try: + importlib.import_module(module) + except Exception as exc: # noqa: BLE001 -- any failure means preload-unsafe + print(f"{module}\\tUNSAFE: {type(exc).__name__}: {exc}") + else: + print(f"{module}\\tOK") +""" + + +def test_model_bearing_plugins_are_registered_post_django_setup(): + """Every preload-unsafe fixture plugin in the rootdir conftest must also be + registered in ``src/conftest.py`` (the post-``django.setup()`` list). + + The rootdir conftest's ``pytest_plugins`` is loaded during the + ``pytest-testcontainers-django`` preload, which races ``django.setup()``. + A model-bearing module registered ONLY there fails to import (its fixtures + never register) and every test requesting them errors at setup with + ``fixture '...' not found``. ``src/conftest.py`` is the safety net that + registers such modules once the app registry is ready. + """ + root_plugins = _pytest_plugins_of(REPO_ROOT / "conftest.py") + src_plugins = set(_pytest_plugins_of(REPO_ROOT / "src" / "conftest.py")) + assert root_plugins, "could not parse pytest_plugins from rootdir conftest.py" + + env = { + **os.environ, + "PYTHONPATH": os.pathsep.join([str(REPO_ROOT), str(REPO_ROOT / "src")]), + "DJANGO_BPP_TESTING": "1", + } + result = subprocess.run( + [sys.executable, "-c", _PLUGIN_IMPORT_PROBE, *root_plugins], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + "plugin import probe crashed:\n" + f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}" + ) + + unsafe = { + line.split("\t", 1)[0] + for line in result.stdout.splitlines() + if "\tUNSAFE:" in line + } + offenders = sorted(unsafe - src_plugins) + assert not offenders, ( + "These fixture plugin modules import Django models at top level and are " + "registered ONLY in the rootdir conftest.py's pytest_plugins, so their " + "fixtures silently fail to register under the testcontainers preload " + "(tests requesting them error with `fixture '...' not found`). Add them " + "to src/conftest.py's pytest_plugins (loaded after django.setup()), " + "alongside fixtures.pbn_api / fixtures.wydawnictwa:\n " + + "\n ".join(offenders) + + f"\n\n--- probe output ---\n{result.stdout}" + ) diff --git a/src/conftest.py b/src/conftest.py index 2f6bf9381..33a3b25d6 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -50,6 +50,15 @@ "fixtures.conftest_system", "fixtures.conftest_browser", "fixtures.conftest_disciplines", + # ``conftest_multisite`` importuje modele Django na top-levelu (``Site``, + # ``Uczelnia``, ``BppUser``...), więc — jak moduły niżej — MUSI być tutaj. + # Rejestracja wyłącznie w rootdir-owym ``conftest.py`` nie wystarcza: jego + # ``pytest_plugins`` ładuje się w preloadzie ``pytest-testcontainers-django`` + # ZANIM ``django.setup()`` zapełni rejestr aplikacji, import wybucha + # ``AppRegistryNotReady`` i fikstury (``site1``, ``uczelnia1``...) cicho się + # nie rejestrują → ``fixture 'site1' not found``. Guard: + # bpp/tests/test_conftest_preload_safety.py. + "fixtures.conftest_multisite", # ``pbn_api`` i ``wydawnictwa`` importują modele Django na top-levelu, # więc MUSZĄ być rejestrowane tutaj (plugin ładowany po ``django.setup()``), # a NIE przez ``from fixtures import *`` w rootdir-owym ``conftest.py`` — From a23966ae7bfcd70add9cb0b8c1e62aa0de9e5409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 12:26:31 +0200 Subject: [PATCH 034/247] =?UTF-8?q?feat(uczelnia):=20udost=C4=99pnij=20mot?= =?UTF-8?q?ywy=20vizja/mwsl/uafm=20w=20wyborze=20w=20adminie=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motywy app-vizja, app-mwsl i app-uafm były już zbudowane przez Grunt i zarejestrowane w COMPRESS_OFFLINE_CONTEXT, a context processor czytał uczelnia.theme_name — brakowało jedynie wpisów w THEME_CHOICES, więc nie dało się ich wybrać w menu w adminie uczelni. - dopisz trzy nowe motywy do THEME_CHOICES (z etykietami nazywającymi uczelnię + paletę) - migracja 0421 (AlterField choices, no-op na poziomie schematu DB) - per-motyw tinty --admin-hover-bg w admin/base_site.html dla nowych motywów (wcześniej spadały na neutralną szarość) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0421_alter_uczelnia_theme_name.py | 29 +++++++++++++++++++ src/bpp/models/uczelnia.py | 3 ++ src/django_bpp/templates/admin/base_site.html | 6 ++++ 3 files changed, 38 insertions(+) create mode 100644 src/bpp/migrations/0421_alter_uczelnia_theme_name.py diff --git a/src/bpp/migrations/0421_alter_uczelnia_theme_name.py b/src/bpp/migrations/0421_alter_uczelnia_theme_name.py new file mode 100644 index 000000000..b0c482c69 --- /dev/null +++ b/src/bpp/migrations/0421_alter_uczelnia_theme_name.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.14 on 2026-06-02 10:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0420_merge_20260601_1246"), + ] + + operations = [ + migrations.AlterField( + model_name="uczelnia", + name="theme_name", + field=models.CharField( + choices=[ + ("app-green", "Zielony"), + ("app-blue", "Niebieski"), + ("app-orange", "Pomarańczowy"), + ("app-vizja", "Vizja (federacjavizja.pl — amber/granat)"), + ("app-mwsl", "MWSL (mwsl.eu — pomarańcz/granat)"), + ("app-uafm", "UAFM (uafm.edu.pl — czerwień/błękit)"), + ], + default="app-green", + max_length=50, + verbose_name="Motyw kolorystyczny", + ), + ), + ] diff --git a/src/bpp/models/uczelnia.py b/src/bpp/models/uczelnia.py index b4b0a277e..91884440b 100644 --- a/src/bpp/models/uczelnia.py +++ b/src/bpp/models/uczelnia.py @@ -75,6 +75,9 @@ def do_roku_default(self=None, request=None): ("app-green", "Zielony"), ("app-blue", "Niebieski"), ("app-orange", "Pomarańczowy"), + ("app-vizja", "Vizja (federacjavizja.pl — amber/granat)"), + ("app-mwsl", "MWSL (mwsl.eu — pomarańcz/granat)"), + ("app-uafm", "UAFM (uafm.edu.pl — czerwień/błękit)"), ] diff --git a/src/django_bpp/templates/admin/base_site.html b/src/django_bpp/templates/admin/base_site.html index 657ed2150..3faa1fbc3 100644 --- a/src/django_bpp/templates/admin/base_site.html +++ b/src/django_bpp/templates/admin/base_site.html @@ -222,6 +222,12 @@

{% trans 'Django administration' %}

--admin-hover-bg: #f0f8f2; {% elif THEME_NAME_RAW == 'app-orange' %} --admin-hover-bg: #fef5f0; + {% elif THEME_NAME_RAW == 'app-vizja' %} + --admin-hover-bg: #fdf8ec; + {% elif THEME_NAME_RAW == 'app-mwsl' %} + --admin-hover-bg: #fdf3ec; + {% elif THEME_NAME_RAW == 'app-uafm' %} + --admin-hover-bg: #fbeeec; {% else %} --admin-hover-bg: #f5f5f5; {% endif %} From 1ca4ec3d0637baa20315fc59d90e64b6cb9831d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 13:03:03 +0200 Subject: [PATCH 035/247] docs(spec): motywy front-endu z jednej listy w settings (#189) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-02-motywy-z-settings-design.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md diff --git a/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md b/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md new file mode 100644 index 000000000..be1f09f76 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md @@ -0,0 +1,99 @@ +# Motywy front-endu: jedno źródło prawdy w `settings`, admin czyta z niego + +Data: 2026-06-02 +Gałąź: `feature/multi-hosted-config` (PR #189) + +## Problem + +Lista dostępnych motywów kolorystycznych front-endu jest dziś powtórzona +ręcznie w kilku źródłach prawdy i musi być trzymana w synchronie palcami: + +1. `Gruntfile.js` — targety `sass:*` + `concurrent.themes.tasks` (build CSS), +2. `settings/base.py` → `COMPRESS_OFFLINE_CONTEXT` — lista ścieżek CSS, +3. `models/uczelnia.py` → `THEME_CHOICES` — `choices` pola `theme_name` + (gatuje dropdown w adminie; każda zmiana = nowa migracja). + +Skutek: dziś istniały zbudowane motywy (`app-vizja`, `app-mwsl`, `app-uafm`), +których nie dało się wybrać w adminie, bo brakowało ich w `THEME_CHOICES`. + +## Zakres tego kroku ("na początek") + +Zredukować punkty 2 i 3 do **jednej** listy w `settings`, z której korzysta +admin. **Gruntfile zostaje ręczny** (świadoma decyzja użytkownika). Auto-detekcja +motywów z dysku (glob po `.scss`) to ewentualny osobny krok 2, NIE tutaj. + +## Projekt + +### 1. Kanoniczna lista w `settings/base.py` + +```python +BPP_THEMES = [ + ("app-green", "Zielony"), + ("app-blue", "Niebieski"), + ("app-orange", "Pomarańczowy"), + ("app-vizja", "Vizja (federacjavizja.pl — amber/granat)"), + ("app-mwsl", "MWSL (mwsl.eu — pomarańcz/granat)"), + ("app-uafm", "UAFM (uafm.edu.pl — czerwień/błękit)"), +] +``` + +Jedyne miejsce, gdzie żyje lista motywów (wartość + etykieta). + +### 2. `COMPRESS_OFFLINE_CONTEXT` wyliczany z `BPP_THEMES` + +```python +COMPRESS_OFFLINE_CONTEXT = [ + {"THEME_NAME": f"scss/{value}.css", "STATIC_URL": STATIC_URL, "LANGUAGE_CODE": "pl"} + for value, _label in BPP_THEMES +] +``` + +Likwiduje drugą, równoległą listę. + +### 3. Model: zdjęcie `choices=` + +`Uczelnia.theme_name` → zwykły `CharField` (zostaje `default="app-green"`, +`max_length=50`, `verbose_name`). Usunięcie martwej stałej `THEME_CHOICES`. +Walidacja wartości przenosi się z modelu do formularza admina, dzięki czemu +zmiana listy motywów **nie wymaga już migracji**. + +Migracja `0422_*` (`AlterField` zdejmujący `choices`) — ostatnia migracja +motywów. No-op na poziomie kolumny DB. Append do historii (reguła: nie ruszamy +istniejącej `0421`, która jest już wypchnięta na remote). + +### 4. Admin: `theme_name` jako `ChoiceField` z settings + +Nowy `UczelniaAdminForm(forms.ModelForm)`: +- pole `theme_name = forms.ChoiceField(...)` (bo model nie daje już `choices`, + domyślny widget byłby `TextInput`), +- `choices` ustawiane w `__init__` z `settings.BPP_THEMES`. + +`UczelniaAdmin.form = UczelniaAdminForm`. + +## Komponenty i granice + +- `settings.BPP_THEMES` — dane (jedno źródło prawdy). +- `COMPRESS_OFFLINE_CONTEXT` — pochodna danych (czysta transformacja). +- `UczelniaAdminForm` — adapter danych → widżet UI + walidacja wyboru. +- Model — przechowuje surowy string, nie zna już dozwolonej listy. + +## Obsługa błędów / brzegi + +- Wartość spoza listy zapisana w bazie (np. po usunięciu motywu z settings): + context processor i tak skleja ścieżkę `scss/.css`; jeśli plik nie + istnieje, `{% static %}` zwróci 404 na arkuszu — degradacja kosmetyczna, nie + wywrotka. Admin przy edycji takiej uczelni pokaże wybór z aktualnej listy + (stara wartość nie będzie zaznaczona) — akceptowalne dla tego kroku. + +## Test (pytest) + +1. `Uczelnia._meta.get_field("theme_name").choices is None` — model bez choices. +2. `UczelniaAdminForm().fields["theme_name"].choices == settings.BPP_THEMES`. +3. `len(COMPRESS_OFFLINE_CONTEXT) == len(BPP_THEMES)` i zbiór `THEME_NAME` + pokrywa się z `scss/.css` dla każdego motywu. + +## Poza zakresem + +- Auto-detekcja motywów z dysku (glob `.scss`). +- Zmiany w `Gruntfile.js`. +- `--admin-hover-bg` (zostaje jak jest). From 2479cb44d36e5b067552645b3a87de8675c73a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 13:08:56 +0200 Subject: [PATCH 036/247] =?UTF-8?q?feat(uczelnia):=20lista=20motyw=C3=B3w?= =?UTF-8?q?=20z=20jednego=20=C5=BAr=C3=B3d=C5=82a=20w=20settings,=20admin?= =?UTF-8?q?=20czyta=20z=20niej=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lista motywów była zduplikowana ręcznie w COMPRESS_OFFLINE_CONTEXT (settings) i THEME_CHOICES (model), a każda zmiana choices na modelu wymuszała migrację. Konsoliduję do jednej stałej settings.BPP_THEMES. - settings.BPP_THEMES — jedyne źródło prawdy (wartość = nazwa pliku SCSS, etykieta kolorystyczna bez nazw własnych uczelni) - COMPRESS_OFFLINE_CONTEXT wyliczany z BPP_THEMES (koniec drugiej listy) - Uczelnia.theme_name traci choices= (zwykły CharField); usunięto martwą THEME_CHOICES → migracja 0422 (ostatnia migracja motywów, no-op na DB) - UczelniaAdminForm: theme_name jako ChoiceField z choices z settings, walidacja wyboru w formularzu zamiast w modelu - dodanie motywu = wpis w BPP_THEMES (+ ręcznie target w Gruntfile), ZERO migracji Spec: docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md Testy: model bez choices, form.choices == settings.BPP_THEMES, COMPRESS_OFFLINE_CONTEXT pochodny BPP_THEMES. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-02-motywy-z-settings-design.md | 10 ++-- src/bpp/admin/uczelnia.py | 21 +++++++++ .../0422_alter_uczelnia_theme_name.py | 19 ++++++++ src/bpp/models/uczelnia.py | 14 ++---- .../tests/test_admin/test_uczelnia_theme.py | 47 +++++++++++++++++++ src/django_bpp/settings/base.py | 46 ++++++++---------- 6 files changed, 115 insertions(+), 42 deletions(-) create mode 100644 src/bpp/migrations/0422_alter_uczelnia_theme_name.py create mode 100644 src/bpp/tests/test_admin/test_uczelnia_theme.py diff --git a/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md b/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md index be1f09f76..bd6fba92d 100644 --- a/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md +++ b/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md @@ -31,13 +31,15 @@ BPP_THEMES = [ ("app-green", "Zielony"), ("app-blue", "Niebieski"), ("app-orange", "Pomarańczowy"), - ("app-vizja", "Vizja (federacjavizja.pl — amber/granat)"), - ("app-mwsl", "MWSL (mwsl.eu — pomarańcz/granat)"), - ("app-uafm", "UAFM (uafm.edu.pl — czerwień/błękit)"), + ("app-vizja", "Bursztynowo-granatowy"), + ("app-mwsl", "Pomarańczowo-granatowy"), + ("app-uafm", "Czerwono-błękitny"), ] ``` -Jedyne miejsce, gdzie żyje lista motywów (wartość + etykieta). +Jedyne miejsce, gdzie żyje lista motywów (wartość = nazwa pliku SCSS bez +rozszerzenia + etykieta kolorystyczna; etykiety celowo bez nazw własnych +uczelni). ### 2. `COMPRESS_OFFLINE_CONTEXT` wyliczany z `BPP_THEMES` diff --git a/src/bpp/admin/uczelnia.py b/src/bpp/admin/uczelnia.py index 76a6adc96..1e79ecd71 100644 --- a/src/bpp/admin/uczelnia.py +++ b/src/bpp/admin/uczelnia.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib import admin, messages from django.core.exceptions import ImproperlyConfigured from reversion.admin import VersionAdmin @@ -62,6 +63,25 @@ class Ukryj_Status_KorektyInline(admin.StackedInline): extra = 0 +class UczelniaAdminForm(forms.ModelForm): + # `theme_name` to na poziomie modelu zwykły CharField (bez `choices`), + # więc deklarujemy je tu jako ChoiceField, a listę motywów pobieramy z + # settings.BPP_THEMES — jedynego źródła prawdy. Dzięki temu dorzucenie + # motywu w settings od razu pojawia się w dropdownie, bez migracji. + theme_name = forms.ChoiceField(label="Motyw kolorystyczny") + + class Meta: + model = Uczelnia + # Tylko pole, które tu nadpisujemy — admin i tak regeneruje pełną + # listę pól z fieldsets przez modelform_factory, ten Meta.fields jest + # wtedy przesłaniany. Wystarcza do samodzielnego instancjonowania formy. + fields = ["theme_name"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["theme_name"].choices = list(settings.BPP_THEMES) + + class UczelniaAdmin( SiteFilteredAdminMixin, ConstanceUczelniaFieldsMixin, @@ -70,6 +90,7 @@ class UczelniaAdmin( BaseBppAdminMixin, VersionAdmin, ): + form = UczelniaAdminForm list_display = ["nazwa", "nazwa_dopelniacz_field", "skrot", "pbn_uid"] def get_queryset(self, request): diff --git a/src/bpp/migrations/0422_alter_uczelnia_theme_name.py b/src/bpp/migrations/0422_alter_uczelnia_theme_name.py new file mode 100644 index 000000000..14289ae98 --- /dev/null +++ b/src/bpp/migrations/0422_alter_uczelnia_theme_name.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.14 on 2026-06-02 11:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0421_alter_uczelnia_theme_name"), + ] + + operations = [ + migrations.AlterField( + model_name="uczelnia", + name="theme_name", + field=models.CharField( + default="app-green", max_length=50, verbose_name="Motyw kolorystyczny" + ), + ), + ] diff --git a/src/bpp/models/uczelnia.py b/src/bpp/models/uczelnia.py index 91884440b..089efaebf 100644 --- a/src/bpp/models/uczelnia.py +++ b/src/bpp/models/uczelnia.py @@ -71,16 +71,6 @@ def do_roku_default(self=None, request=None): raise NotImplementedError -THEME_CHOICES = [ - ("app-green", "Zielony"), - ("app-blue", "Niebieski"), - ("app-orange", "Pomarańczowy"), - ("app-vizja", "Vizja (federacjavizja.pl — amber/granat)"), - ("app-mwsl", "MWSL (mwsl.eu — pomarańcz/granat)"), - ("app-uafm", "UAFM (uafm.edu.pl — czerwień/błękit)"), -] - - class Uczelnia(ModelZAdnotacjami, ModelZPBN_ID, NazwaISkrot, NazwaWDopelniaczu): site = models.OneToOneField( "sites.Site", @@ -94,7 +84,9 @@ class Uczelnia(ModelZAdnotacjami, ModelZPBN_ID, NazwaISkrot, NazwaWDopelniaczu): "Motyw kolorystyczny", max_length=50, default="app-green", - choices=THEME_CHOICES, + # Dozwolone wartości pochodzą z settings.BPP_THEMES, walidowane w + # UczelniaAdminForm — celowo BEZ `choices=` na poziomie modelu, żeby + # zmiana listy motywów nie generowała migracji. ) slug = AutoSlugField(populate_from="skrot", unique=True) diff --git a/src/bpp/tests/test_admin/test_uczelnia_theme.py b/src/bpp/tests/test_admin/test_uczelnia_theme.py new file mode 100644 index 000000000..88e9033b0 --- /dev/null +++ b/src/bpp/tests/test_admin/test_uczelnia_theme.py @@ -0,0 +1,47 @@ +"""Motywy front-endu pochodzą z jednej listy w settings (BPP_THEMES). + +Patrz docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md +""" + +import pytest +from django.conf import settings + +from bpp.models import Uczelnia + + +def test_theme_name_field_ma_choices_z_settings(): + """Model nie zamraża listy motywów (zero migracji przy zmianie listy). + + Pole `theme_name` to zwykły CharField — dozwolone wartości żyją w + settings.BPP_THEMES, a nie w `choices` pola modelu. + """ + field = Uczelnia._meta.get_field("theme_name") + assert not field.choices, ( + "theme_name nie powinno mieć statycznych choices na poziomie modelu " + "— inaczej każda zmiana listy motywów wymusza migrację." + ) + + +def test_bpp_themes_jest_lista_wartosc_etykieta(): + """settings.BPP_THEMES to lista krotek (wartość, etykieta).""" + assert settings.BPP_THEMES + for entry in settings.BPP_THEMES: + value, label = entry # rozpakowanie wymusza kształt 2-krotki + assert value and label + + +def test_compress_offline_context_wyliczany_z_bpp_themes(): + """COMPRESS_OFFLINE_CONTEXT pochodzi z BPP_THEMES (bez drugiej listy).""" + ctx = settings.COMPRESS_OFFLINE_CONTEXT + assert len(ctx) == len(settings.BPP_THEMES) + expected = {f"scss/{value}.css" for value, _ in settings.BPP_THEMES} + assert {c["THEME_NAME"] for c in ctx} == expected + + +@pytest.mark.django_db +def test_admin_form_oferuje_motywy_z_settings(): + """Dropdown w adminie uczelni odbija aktualną listę z settings.""" + from bpp.admin.uczelnia import UczelniaAdminForm + + form = UczelniaAdminForm() + assert list(form.fields["theme_name"].choices) == list(settings.BPP_THEMES) diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index b908ad994..d59795d26 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -587,37 +587,29 @@ def autoslug_gen(): COMPRESS_JS_FILTERS = ["compressor.filters.jsmin.rJSMinFilter"] COMPRESS_ROOT = STATIC_ROOT + +# Jedyne źródło prawdy dla listy motywów kolorystycznych front-endu: +# (wartość pliku SCSS bez rozszerzenia, etykieta widoczna w adminie uczelni). +# Admin (UczelniaAdminForm) czyta tę listę, a COMPRESS_OFFLINE_CONTEXT poniżej +# jest z niej wyliczany — dodanie motywu = jeden wpis tutaj (+ ręcznie target +# w Gruntfile.js). Walidacja wyboru żyje w formularzu, NIE w `choices` pola +# modelu, dzięki czemu zmiana listy nie wymaga migracji. +BPP_THEMES = [ + ("app-green", "Zielony"), + ("app-blue", "Niebieski"), + ("app-orange", "Pomarańczowy"), + ("app-vizja", "Bursztynowo-granatowy"), + ("app-mwsl", "Pomarańczowo-granatowy"), + ("app-uafm", "Czerwono-błękitny"), +] + COMPRESS_OFFLINE_CONTEXT = [ { - "THEME_NAME": "scss/app-blue.css", - "STATIC_URL": STATIC_URL, - "LANGUAGE_CODE": "pl", - }, - { - "THEME_NAME": "scss/app-green.css", - "STATIC_URL": STATIC_URL, - "LANGUAGE_CODE": "pl", - }, - { - "THEME_NAME": "scss/app-orange.css", - "STATIC_URL": STATIC_URL, - "LANGUAGE_CODE": "pl", - }, - { - "THEME_NAME": "scss/app-vizja.css", - "STATIC_URL": STATIC_URL, - "LANGUAGE_CODE": "pl", - }, - { - "THEME_NAME": "scss/app-mwsl.css", - "STATIC_URL": STATIC_URL, - "LANGUAGE_CODE": "pl", - }, - { - "THEME_NAME": "scss/app-uafm.css", + "THEME_NAME": f"scss/{value}.css", "STATIC_URL": STATIC_URL, "LANGUAGE_CODE": "pl", - }, + } + for value, _label in BPP_THEMES ] # Lista hostów obsługiwanych przez deployment. From e8161faa9c0a77b1ba806e33d13f1badada5895e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 14:11:09 +0200 Subject: [PATCH 037/247] =?UTF-8?q?feat(uczelnia):=20dopisz=20nazw=C4=99?= =?UTF-8?q?=20uczelni=20w=20nawiasie=20do=20etykiety=20motywu=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-02-motywy-z-settings-design.md | 10 +++++----- src/django_bpp/settings/base.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md b/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md index bd6fba92d..09cf3345c 100644 --- a/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md +++ b/docs/superpowers/specs/2026-06-02-motywy-z-settings-design.md @@ -31,15 +31,15 @@ BPP_THEMES = [ ("app-green", "Zielony"), ("app-blue", "Niebieski"), ("app-orange", "Pomarańczowy"), - ("app-vizja", "Bursztynowo-granatowy"), - ("app-mwsl", "Pomarańczowo-granatowy"), - ("app-uafm", "Czerwono-błękitny"), + ("app-vizja", "Bursztynowo-granatowy (VIZJA)"), + ("app-mwsl", "Pomarańczowo-granatowy (MWSL)"), + ("app-uafm", "Czerwono-błękitny (UAFM)"), ] ``` Jedyne miejsce, gdzie żyje lista motywów (wartość = nazwa pliku SCSS bez -rozszerzenia + etykieta kolorystyczna; etykiety celowo bez nazw własnych -uczelni). +rozszerzenia + etykieta = schemat kolorystyczny, a po nim nazwa własna +uczelni w nawiasie). ### 2. `COMPRESS_OFFLINE_CONTEXT` wyliczany z `BPP_THEMES` diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index d59795d26..a224aa60c 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -598,9 +598,9 @@ def autoslug_gen(): ("app-green", "Zielony"), ("app-blue", "Niebieski"), ("app-orange", "Pomarańczowy"), - ("app-vizja", "Bursztynowo-granatowy"), - ("app-mwsl", "Pomarańczowo-granatowy"), - ("app-uafm", "Czerwono-błękitny"), + ("app-vizja", "Bursztynowo-granatowy (VIZJA)"), + ("app-mwsl", "Pomarańczowo-granatowy (MWSL)"), + ("app-uafm", "Czerwono-błękitny (UAFM)"), ] COMPRESS_OFFLINE_CONTEXT = [ From e6f4012c19012428140e1349153ddd719ad1ce8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 14:31:34 +0200 Subject: [PATCH 038/247] fix(migrations): merge bpp i pbn_api po scaleniu dev Po wmergowaniu origin/dev (themes #283 + pbn_api interaktywny #164) powstaly rozwidlenia grafu migracji: - pbn_api: 0069_sentdata_api_url (dev) vs 0069_add_uczelnia_fk -> 0070_link_pbn_to_uczelnia (feature) -> merge 0071 - bpp: 0419_merge (dev) vs 0422_alter_uczelnia_theme_name (feature) -> merge 0423 Obie galezie dotykaja roznych pol, wiec merge migracje sa puste (tylko scalenie lisci). makemigrations --check: brak driftu. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/migrations/0423_merge_20260602_1430.py | 12 ++++++++++++ ...069_sentdata_api_url_0070_link_pbn_to_uczelnia.py | 12 ++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 src/bpp/migrations/0423_merge_20260602_1430.py create mode 100644 src/pbn_api/migrations/0071_merge_0069_sentdata_api_url_0070_link_pbn_to_uczelnia.py diff --git a/src/bpp/migrations/0423_merge_20260602_1430.py b/src/bpp/migrations/0423_merge_20260602_1430.py new file mode 100644 index 000000000..8e0950fcf --- /dev/null +++ b/src/bpp/migrations/0423_merge_20260602_1430.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2.14 on 2026-06-02 12:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0419_merge_20260601_1319"), + ("bpp", "0422_alter_uczelnia_theme_name"), + ] + + operations = [] diff --git a/src/pbn_api/migrations/0071_merge_0069_sentdata_api_url_0070_link_pbn_to_uczelnia.py b/src/pbn_api/migrations/0071_merge_0069_sentdata_api_url_0070_link_pbn_to_uczelnia.py new file mode 100644 index 000000000..8e89dbb99 --- /dev/null +++ b/src/pbn_api/migrations/0071_merge_0069_sentdata_api_url_0070_link_pbn_to_uczelnia.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2.14 on 2026-06-02 12:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("pbn_api", "0069_sentdata_api_url"), + ("pbn_api", "0070_link_pbn_to_uczelnia"), + ] + + operations = [] From db5171e88d971e18a211b8ad6a2bf8605d4a329b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 15:50:00 +0200 Subject: [PATCH 039/247] =?UTF-8?q?fix(pbn):=20wymu=C5=9B=20jawn=C4=85=20u?= =?UTF-8?q?czelni=C4=99=20w=20PBN-owych=20zadaniach=20w=20tle=20(multi-hos?= =?UTF-8?q?ted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W instalacji z wieloma uczelniami PBN-owe zadania Celery (pobieranie publikacji/osób/źródeł, import) brały konfigurację PBN z Uczelnia.objects.get_default() (pierwsza-z-brzegu), a nie z uczelni wybranej w requeście. Gdy PBN był skonfigurowany w innej niż pierwsza uczelni, zadania dostawały pusty app_token → 403 "token aplikacji null" oraz "Brak konfiguracji klienta PBN" przy imporcie. Zmiany: - UczelniaManager.get_for_pbn_background(uczelnia_id): ścisły resolwer dla torów w tle — wymaga jawnego id, ZERO fallbacku do get_default(). - Entrypointy (widoki pbn_downloader_app, pbn_import) przekazują id uczelni z requestu (get_for_request) do zadań. - Zadania pbn_downloader_app (publikacje/osoby/źródła) i pbn_import używają get_for_pbn_background; publikacje przekazują --uczelnia-id do management-commandów. - get_pbn_client (pbn_downloader_app) buduje klienta przez kanoniczną Uczelnia.pbn_client() — koniec z ręcznym sklejaniem transportu. - PBNBaseCommand: flaga --uczelnia-id realnie działa (wcześniej martwa). get_default() dozwolone TYLKO gdy w systemie jest dokładnie jedna uczelnia; przy wielu — CommandError zamiast cichego wyboru pierwszej. - Dedup: pbn_api.tasks.download_institution_publications to teraz re-eksport kanonicznej implementacji z pbn_downloader_app.tasks (były dwie rozjeżdżające się kopie). - Komunikat błędu "Default institution does not have PBN UID" zastąpiony wskazaniem konkretnej uczelni. Testy: nowe (resolwer, entrypointy, kontrakt komendy) pisane TDD — najpierw RED. Istniejące zaktualizowane do wymaganego uczelnia_id. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/models/uczelnia.py | 21 ++ .../test_models/test_uczelnia_pbn_resolver.py | 47 +++ src/pbn_api/management/commands/util.py | 97 ++++-- src/pbn_api/tasks.py | 263 +-------------- src/pbn_api/tests/test_pbn_base_command.py | 84 +++++ src/pbn_api/tests/test_tasks.py | 303 ++++-------------- src/pbn_downloader_app/tasks.py | 78 ++--- src/pbn_downloader_app/tests.py | 71 ++++ src/pbn_downloader_app/tests_tasks.py | 73 +++-- src/pbn_downloader_app/views.py | 37 ++- src/pbn_import/tasks.py | 11 +- src/pbn_import/tests/test_tasks.py | 92 +++++- src/pbn_import/tests/test_views_dashboard.py | 27 ++ src/pbn_import/views.py | 7 +- 14 files changed, 603 insertions(+), 608 deletions(-) create mode 100644 src/bpp/tests/test_models/test_uczelnia_pbn_resolver.py create mode 100644 src/pbn_api/tests/test_pbn_base_command.py diff --git a/src/bpp/models/uczelnia.py b/src/bpp/models/uczelnia.py index 8b47a8a40..5029d9920 100644 --- a/src/bpp/models/uczelnia.py +++ b/src/bpp/models/uczelnia.py @@ -43,6 +43,27 @@ def get_for_request(self, request): return self.get_default() + def get_for_pbn_background(self, uczelnia_id) -> "Uczelnia": + """Resolwer uczelni dla PBN-owych zadań w tle (Celery, kolejki). + + W instalacji multi-hosted KAŻDY entrypoint (widok) zna konkretną + uczelnię z requestu (``get_for_request``) i MUSI przekazać jej + ``pk`` do zadania. Brak ``uczelnia_id`` to błąd programistyczny — + świadomie NIE robimy fallbacku do ``get_default()``, bo wybrałby + pierwszą-z-brzegu uczelnię, która może nie mieć skonfigurowanego + PBN (to było źródłem błędu ``403 token aplikacji null``). + + :raises ValueError: gdy ``uczelnia_id`` jest ``None``. + :raises Uczelnia.DoesNotExist: gdy uczelnia o danym id nie istnieje. + """ + if uczelnia_id is None: + raise ValueError( + "Operacja PBN w tle wymaga jawnego uczelnia_id — w trybie " + "multi-hosted nie ma fallbacku do uczelni domyślnej. " + "Entrypoint (widok) musi przekazać id uczelni z requestu." + ) + return self.get(pk=uczelnia_id) + def get_for_site(self, site) -> Union["Uczelnia", None]: """Zwraca Uczelnię powiązaną z danym obiektem Site.""" if site is None: diff --git a/src/bpp/tests/test_models/test_uczelnia_pbn_resolver.py b/src/bpp/tests/test_models/test_uczelnia_pbn_resolver.py new file mode 100644 index 000000000..b642e9f26 --- /dev/null +++ b/src/bpp/tests/test_models/test_uczelnia_pbn_resolver.py @@ -0,0 +1,47 @@ +"""Testy dla ścisłego resolwera uczelni dla zadań PBN w tle. + +W trybie multi-hosted KAŻDY entrypoint (widok) zna konkretną uczelnię +z requestu i MUSI przekazać jej ``pk`` do zadania Celery. Resolwer +``get_for_pbn_background`` świadomie NIE robi fallbacku do +``get_default()`` — wybór "pierwszej z brzegu" uczelni to właśnie ten +bug, przez który zadania pobierały konfigurację PBN złej uczelni. +""" + +import pytest + +from bpp.models import Uczelnia + + +@pytest.mark.django_db +def test_get_for_pbn_background_raises_without_id(): + """Brak uczelnia_id => ValueError, ZERO fallbacku do get_default().""" + with pytest.raises(ValueError): + Uczelnia.objects.get_for_pbn_background(None) + + +@pytest.mark.django_db +def test_get_for_pbn_background_returns_requested_uczelnia(uczelnia): + """Z poprawnym id zwraca dokładnie tę uczelnię.""" + assert Uczelnia.objects.get_for_pbn_background(uczelnia.pk) == uczelnia + + +@pytest.mark.django_db +def test_get_for_pbn_background_does_not_fall_back_to_first(uczelnia): + """Gdy istnieją dwie uczelnie, a id jest None — resolwer MUSI rzucić, + a nie po cichu zwrócić pierwszą (to było źródłem błędu 403).""" + from django.contrib.sites.models import Site + + site2, _ = Site.objects.get_or_create( + domain="druga.example.com", defaults={"name": "druga"} + ) + Uczelnia.objects.create(skrot="DR", nazwa="Druga uczelnia", site=site2) + + with pytest.raises(ValueError): + Uczelnia.objects.get_for_pbn_background(None) + + +@pytest.mark.django_db +def test_get_for_pbn_background_raises_for_unknown_id(uczelnia): + """Nieistniejące id => Uczelnia.DoesNotExist (nie cichy fallback).""" + with pytest.raises(Uczelnia.DoesNotExist): + Uczelnia.objects.get_for_pbn_background(uczelnia.pk + 9999) diff --git a/src/pbn_api/management/commands/util.py b/src/pbn_api/management/commands/util.py index f930fc385..3300fbaf2 100644 --- a/src/pbn_api/management/commands/util.py +++ b/src/pbn_api/management/commands/util.py @@ -1,6 +1,6 @@ import warnings -from django.core.management import BaseCommand +from django.core.management import BaseCommand, CommandError from bpp.models import BppUser, Uczelnia from pbn_api.client import PBNClient, RequestsTransport @@ -9,37 +9,86 @@ class PBNBaseCommand(BaseCommand): def add_arguments(self, parser): - app_id = settings.PBN_CLIENT_APP_ID - app_token = settings.PBN_CLIENT_APP_TOKEN - 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)"), + help=( + "ID uczelni. Wymagane gdy w systemie jest więcej niż jedna " + "uczelnia; przy dokładnie jednej uczelni jest ona używana " + "automatycznie." + ), + ) + # Domyślne wartości celowo None — credentiale rozwiązujemy dopiero + # w execute() (po sparsowaniu --uczelnia-id), a nie na etapie + # budowania parsera. Dzięki temu --uczelnia-id realnie wpływa na + # wybór konfiguracji PBN (wcześniej flaga była martwa). + parser.add_argument("--app-id", default=None) + parser.add_argument("--app-token", default=None) + parser.add_argument("--base-url", default=None) + parser.add_argument("--user-token", default=None) + + def execute(self, *args, **options): + self._fill_pbn_credentials(options) + return super().execute(*args, **options) + + def _resolve_uczelnia(self, uczelnia_id): + """Uczelnia dla komendy CLI. + + - ``--uczelnia-id`` zawsze honorowane (i walidowane), + - przy dokładnie jednej uczelni używamy jej (get_default() jest OK + TYLKO w tym jednym przypadku), + - przy wielu uczelniach brak ``--uczelnia-id`` to ``CommandError`` — + bez cichego wyboru pierwszej-z-brzegu. + """ + if uczelnia_id is not None: + try: + return Uczelnia.objects.get(pk=uczelnia_id) + except Uczelnia.DoesNotExist as e: + raise CommandError(f"Brak uczelni o id={uczelnia_id}.") from e + + count = Uczelnia.objects.count() + if count == 0: + return None + if count == 1: + return Uczelnia.objects.get_default() + raise CommandError( + "W systemie jest więcej niż jedna uczelnia — podaj --uczelnia-id, " + "żeby wskazać której konfiguracji PBN użyć." ) - uczelnia = Uczelnia.objects.get_default() - if uczelnia is not None: - if uczelnia.pbn_app_name: - app_id = uczelnia.pbn_app_name - if uczelnia.pbn_app_token: - app_token = uczelnia.pbn_app_token - if uczelnia.pbn_api_root: - base_url = uczelnia.pbn_api_root - if uczelnia.pbn_api_user_id is not None: - user_token = uczelnia.pbn_api_user.pbn_token + def _fill_pbn_credentials(self, options): + """Uzupełnij app_id/app_token/base_url/user_token w ``options``. + + Wartości jawnie podane na CLI mają priorytet; resztę bierzemy z + konfiguracji wskazanej uczelni, a w ostateczności z ``settings``. + """ + uczelnia = self._resolve_uczelnia(options.get("uczelnia_id")) + + if options.get("app_id") is None: + options["app_id"] = ( + uczelnia.pbn_app_name + if uczelnia and uczelnia.pbn_app_name + else settings.PBN_CLIENT_APP_ID + ) + if options.get("app_token") is None: + options["app_token"] = ( + uczelnia.pbn_app_token + if uczelnia and uczelnia.pbn_app_token + else settings.PBN_CLIENT_APP_TOKEN + ) + if options.get("base_url") is None: + options["base_url"] = ( + uczelnia.pbn_api_root + if uczelnia and uczelnia.pbn_api_root + else settings.PBN_CLIENT_BASE_URL + ) + if options.get("user_token") is None: + if uczelnia is not None and uczelnia.pbn_api_user_id is not None: + options["user_token"] = uczelnia.pbn_api_user.pbn_token else: user = BppUser.objects.first() - if user is not None: - user_token = user.pbn_token - - parser.add_argument("--app-id", default=app_id) - parser.add_argument("--app-token", default=app_token) - parser.add_argument("--base-url", default=base_url) - parser.add_argument("--user-token", default=user_token) + options["user_token"] = user.pbn_token if user is not None else None def get_client(self, app_id, app_token, base_url, user_token, verbose=False): if user_token is None: diff --git a/src/pbn_api/tasks.py b/src/pbn_api/tasks.py index 6bd4df965..7e6b35ed0 100644 --- a/src/pbn_api/tasks.py +++ b/src/pbn_api/tasks.py @@ -1,247 +1,16 @@ -import logging -import sys -from contextlib import contextmanager -from threading import Lock - -import rollbar -from django.core.management import call_command - -from django_bpp.celery_tasks import app - -logger = logging.getLogger(__name__) - - -def _validate_user_pbn_access(user_id): - """ - Sprawdza czy użytkownik ma dostęp do PBN. - - Returns: - tuple: (user, pbn_user) jeśli walidacja przeszła - - Raises: - ValueError: jeśli użytkownik nie ma dostępu - """ - from bpp.models.profile import BppUser - - user = BppUser.objects.get(pk=user_id) - pbn_user = user.get_pbn_user() - - if not pbn_user.pbn_token: - raise ValueError( - f"User {user.username} is not authorized in PBN (no pbn_token)" - ) - - if not pbn_user.pbn_token_possibly_valid(): - raise ValueError(f"User {user.username} has an invalid or expired PBN token") - - return user, pbn_user - - -def _create_task_record(user, task_model): - """ - Tworzy rekord zadania z blokadą transakcji. - - Returns: - PbnDownloadTask: nowy rekord zadania - - Raises: - ValueError: jeśli inne zadanie już działa - """ - from django.db import transaction - from django.utils import timezone - - with transaction.atomic(): - if task_model.objects.filter(status="running").exists(): - raise ValueError( - "Another download task is already running. " - "Please wait for it to complete." - ) - - return task_model.objects.create( - user=user, - status="running", - started_at=timezone.now(), - current_step="Inicjalizacja pobierania...", - progress_percentage=0, - ) - - -def _mark_task_completed(task_record): - """Oznacza zadanie jako zakończone.""" - from django.utils import timezone - - task_record.status = "completed" - task_record.current_step = "Pobieranie zakończone pomyślnie" - task_record.progress_percentage = 100 - task_record.completed_at = timezone.now() - task_record.save() - - -def _mark_task_failed(task_record, error): - """Oznacza zadanie jako nieudane.""" - from django.utils import timezone - - if task_record: - task_record.status = "failed" - task_record.error_message = str(error) - task_record.completed_at = timezone.now() - task_record.save() - - -@contextmanager -def _tqdm_progress_patcher(task_record): - """ - Context manager do patchowania tqdm dla raportowania postępu. - """ - import tqdm - - original_init = tqdm.tqdm.__init__ - original_update = tqdm.tqdm.update - original_close = tqdm.tqdm.close - progress_lock = Lock() - - def patched_init(self, iterable=None, desc=None, total=None, *args, **kwargs): - self._task_record = task_record - self._desc = desc or "" - # Determine phase offset based on current step - if task_record.current_step and "Faza 1" in task_record.current_step: - self._phase_offset = 10 - elif task_record.current_step and "Faza 2" in task_record.current_step: - self._phase_offset = 50 - else: - self._phase_offset = 10 - original_init(self, iterable, desc, total, *args, **kwargs) - - def patched_update(self, n=1): - result = original_update(self, n) - if hasattr(self, "_task_record") and self._task_record: - _update_progress_from_tqdm(self, progress_lock) - return result - - def patched_close(self): - result = original_close(self) - if hasattr(self, "_task_record") and self._task_record: - _finalize_progress_from_tqdm(self, progress_lock) - return result - - # Apply patches - tqdm.tqdm.__init__ = patched_init - tqdm.tqdm.update = patched_update - tqdm.tqdm.close = patched_close - - try: - yield - finally: - # Restore original methods - tqdm.tqdm.__init__ = original_init - tqdm.tqdm.update = original_update - tqdm.tqdm.close = original_close - - -def _update_progress_from_tqdm(tqdm_instance, lock): - """Aktualizuje postęp zadania na podstawie stanu tqdm.""" - with lock: - try: - task = tqdm_instance._task_record - if not tqdm_instance.total or tqdm_instance.total <= 0: - return - - # Calculate progress - command_progress = (tqdm_instance.n / tqdm_instance.total) * 35 - total_progress = tqdm_instance._phase_offset + command_progress - task.progress_percentage = min(90, max(10, int(total_progress))) - - # Update publication/statement counts - if tqdm_instance._desc: - _update_task_counters(task, tqdm_instance) - - task.save() - except Exception: - rollbar.report_exc_info(sys.exc_info()) - logger.debug("Błąd aktualizacji postępu zadania", exc_info=True) - - -def _update_task_counters(task, tqdm_instance): - """Aktualizuje liczniki publikacji/oświadczeń w zadaniu.""" - desc = tqdm_instance._desc.lower() - - if "publikacj" in desc: - task.publications_processed = tqdm_instance.n - if tqdm_instance.total: - task.total_publications = tqdm_instance.total - elif "oświadczen" in desc or "statement" in desc: - task.statements_processed = tqdm_instance.n - if tqdm_instance.total: - task.total_statements = tqdm_instance.total - - # Update step description - progress_text = f"{tqdm_instance._desc} ({tqdm_instance.n}" - if tqdm_instance.total: - progress_text += f"/{tqdm_instance.total}" - progress_text += ")" - task.current_step = progress_text - - -def _finalize_progress_from_tqdm(tqdm_instance, lock): - """Finalizuje postęp po zamknięciu tqdm.""" - with lock: - try: - task = tqdm_instance._task_record - if tqdm_instance.total and tqdm_instance.n >= tqdm_instance.total: - total_progress = tqdm_instance._phase_offset + 35 - task.progress_percentage = min(90, int(total_progress)) - task.save() - except Exception: - rollbar.report_exc_info(sys.exc_info()) - logger.debug("Błąd aktualizacji postępu zadania (close)", exc_info=True) - - -def _run_pbn_download_commands(task_record, pbn_token): - """Uruchamia komendy pobierania z PBN.""" - task_record.current_step = "Pobieranie publikacji instytucji (Faza 1/2)" - task_record.progress_percentage = 10 - task_record.save() - - call_command("pbn_pobierz_publikacje_z_instytucji_v2", user_token=pbn_token) - - task_record.current_step = "Pobieranie oświadczeń i publikacji (Faza 2/2)" - task_record.progress_percentage = 50 - task_record.save() - - call_command("pbn_pobierz_oswiadczenia_i_publikacje_v1", user_token=pbn_token) - - task_record.current_step = "Finalizowanie pobierania..." - task_record.progress_percentage = 90 - task_record.save() - - -@app.task -def download_institution_publications(user_id): - """ - Download institution publications using PBN API management commands. - 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) - """ - from pbn_downloader_app.models import PbnDownloadTask - - # Check for running tasks - if PbnDownloadTask.objects.filter(status="running").exists(): - raise ValueError( - "Another download task is already running. Please wait for it to complete." - ) - - task_record = None - try: - user, pbn_user = _validate_user_pbn_access(user_id) - task_record = _create_task_record(user, PbnDownloadTask) - - with _tqdm_progress_patcher(task_record): - _run_pbn_download_commands(task_record, pbn_user.pbn_token) - - _mark_task_completed(task_record) - - except Exception as e: - _mark_task_failed(task_record, e) - raise +"""Wsteczna kompatybilność — re-eksport kanonicznego zadania. + +Historycznie ``pbn_api.tasks`` zawierało WŁASNĄ implementację +``download_institution_publications`` (osobny patcher tqdm, osobne +helpery), rozjeżdżającą się z bliźniaczą implementacją w +``pbn_downloader_app.tasks``. To była duplikacja dwóch torów tego samego +pobierania. Kanoniczna, jedyna implementacja żyje teraz w +``pbn_downloader_app.tasks`` — tutaj tylko re-eksport, żeby nie zepsuć +ewentualnych importów ``from pbn_api.tasks import ...``. +""" + +from pbn_downloader_app.tasks import ( # noqa: F401 + download_institution_publications, +) + +__all__ = ["download_institution_publications"] diff --git a/src/pbn_api/tests/test_pbn_base_command.py b/src/pbn_api/tests/test_pbn_base_command.py new file mode 100644 index 000000000..79cb6ba36 --- /dev/null +++ b/src/pbn_api/tests/test_pbn_base_command.py @@ -0,0 +1,84 @@ +"""Testy rozwiązywania uczelni/credentiali PBN w PBNBaseCommand (CLI). + +Reguła multi-hosted dla komend CLI: +- ``--uczelnia-id`` zawsze honorowane, +- przy DOKŁADNIE jednej uczelni w systemie używamy jej automatycznie + (get_default() jest OK tylko w tym jednym przypadku), +- przy wielu uczelniach brak ``--uczelnia-id`` to błąd (CommandError) — + bez cichego wyboru pierwszej-z-brzegu. +""" + +import pytest +from django.core.management import CommandError + +from bpp.models import Uczelnia +from pbn_api.management.commands.util import PBNBaseCommand + + +def _second_uczelnia(**kwargs): + from django.contrib.sites.models import Site + + site, _ = Site.objects.get_or_create( + domain="druga.example.com", defaults={"name": "druga"} + ) + return Uczelnia.objects.create(skrot="DR", nazwa="Druga", site=site, **kwargs) + + +def _blank_options(**over): + opts = { + "uczelnia_id": None, + "app_id": None, + "app_token": None, + "base_url": None, + "user_token": None, + } + opts.update(over) + return opts + + +@pytest.mark.django_db +def test_single_uczelnia_used_without_flag(uczelnia): + uczelnia.pbn_app_name = "APP-A" + uczelnia.pbn_app_token = "TOK-A" + uczelnia.pbn_api_root = "https://a.example/" + uczelnia.save() + + options = _blank_options() + PBNBaseCommand()._fill_pbn_credentials(options) + + assert options["app_id"] == "APP-A" + assert options["app_token"] == "TOK-A" + assert options["base_url"] == "https://a.example/" + + +@pytest.mark.django_db +def test_multiple_uczelnie_without_flag_raises(uczelnia): + _second_uczelnia() + + with pytest.raises(CommandError): + PBNBaseCommand()._fill_pbn_credentials(_blank_options()) + + +@pytest.mark.django_db +def test_explicit_uczelnia_id_selects_it(uczelnia): + uczelnia.pbn_app_name = "APP-A" + uczelnia.pbn_app_token = "TOK-A" + uczelnia.save() + u2 = _second_uczelnia( + pbn_app_name="APP-B", + pbn_app_token="TOK-B", + pbn_api_root="https://b.example/", + ) + + options = _blank_options(uczelnia_id=u2.pk) + PBNBaseCommand()._fill_pbn_credentials(options) + + assert options["app_id"] == "APP-B" + assert options["app_token"] == "TOK-B" + assert options["base_url"] == "https://b.example/" + + +@pytest.mark.django_db +def test_unknown_uczelnia_id_raises(uczelnia): + with pytest.raises(CommandError): + PBNBaseCommand()._fill_pbn_credentials(_blank_options(uczelnia_id=999999)) diff --git a/src/pbn_api/tests/test_tasks.py b/src/pbn_api/tests/test_tasks.py index 4fa75c578..314f6691e 100644 --- a/src/pbn_api/tests/test_tasks.py +++ b/src/pbn_api/tests/test_tasks.py @@ -1,288 +1,119 @@ -import pytest -from django.utils import timezone -from model_bakery import baker -from unittest.mock import patch, MagicMock, call - -from pbn_api.tasks import download_institution_publications -from bpp.models.profile import BppUser - +"""Testy re-eksportu kanonicznego zadania pobierania publikacji. -@pytest.mark.django_db -def test_download_institution_publications_no_user(): - """Test task raises error when user not found""" - with pytest.raises(BppUser.DoesNotExist): - download_institution_publications(999999) - - -@pytest.mark.django_db -def test_download_institution_publications_no_pbn_token(): - """Test task raises error when user has no PBN token""" - user = baker.make(BppUser) +``pbn_api.tasks.download_institution_publications`` to teraz re-eksport +kanonicznej implementacji z ``pbn_downloader_app.tasks`` (koniec z +duplikacją). Tu pilnujemy, że re-eksport działa i że kontrakt +multi-hosted (wymagane uczelnia_id) jest zachowany niezależnie od +ścieżki importu. +""" - with pytest.raises(ValueError, match="not authorized"): - download_institution_publications(user.pk) - - -@pytest.mark.django_db -def test_download_institution_publications_invalid_token(): - """Test task raises error when user has invalid/expired token""" - user = baker.make(BppUser) - user.pbn_token = "expired_token" - user.pbn_token_updated = timezone.now() - timezone.timedelta(days=100) - user.save() - - # Mock pbn_token_possibly_valid to return False - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "expired_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = False - mock_get_pbn.return_value = mock_pbn_user - - with pytest.raises(ValueError, match="invalid or expired"): - download_institution_publications(user.pk) - - -@pytest.mark.django_db -def test_download_institution_publications_concurrent_task_running(): - """Test task raises error when another task is already running""" - from pbn_downloader_app.models import PbnDownloadTask - - user = baker.make(BppUser) - user.pbn_token = "valid_token" - user.save() - - # Create a running task - baker.make(PbnDownloadTask, status="running") - - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "valid_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = True - mock_get_pbn.return_value = mock_pbn_user - - with pytest.raises(ValueError, match="already running"): - download_institution_publications(user.pk) - - -@pytest.mark.django_db -@patch("pbn_api.tasks.call_command") -def test_download_institution_publications_creates_task_record(mock_call_command): - """Test task creates PbnDownloadTask record""" - from pbn_downloader_app.models import PbnDownloadTask - - user = baker.make(BppUser) - user.pbn_token = "valid_token" - user.save() - - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "valid_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = True - mock_get_pbn.return_value = mock_pbn_user - - download_institution_publications(user.pk) - - # Verify task record was created and marked as completed - task = PbnDownloadTask.objects.first() - assert task is not None - assert task.user == user - assert task.status == "completed" - - -@pytest.mark.django_db -@patch("pbn_api.tasks.call_command") -def test_download_institution_publications_runs_both_commands(mock_call_command): - """Test task runs both download management commands""" - from pbn_downloader_app.models import PbnDownloadTask +from unittest.mock import MagicMock, patch - user = baker.make(BppUser) - user.pbn_token = "valid_token" - user.save() +import pytest +from model_bakery import baker - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "valid_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = True - mock_get_pbn.return_value = mock_pbn_user +from bpp.models.profile import BppUser +from pbn_api.tasks import download_institution_publications - download_institution_publications(user.pk) - # Verify both commands were called - assert mock_call_command.call_count == 2 +def _mock_valid_pbn_user(user): + mock_pbn_user = MagicMock() + mock_pbn_user.pbn_token = "valid_token" + mock_pbn_user.pbn_token_possibly_valid.return_value = True + return patch.object(type(user), "get_pbn_user", return_value=mock_pbn_user) @pytest.mark.django_db -@patch("pbn_api.tasks.call_command") -def test_download_institution_publications_first_command_correct(mock_call_command): - """Test task calls first command with correct arguments""" - user = baker.make(BppUser) - user.pbn_token = "valid_token" - user.save() - - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "valid_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = True - mock_get_pbn.return_value = mock_pbn_user +def test_reexport_is_canonical(): + """pbn_api.tasks re-eksportuje DOKŁADNIE kanoniczną funkcję.""" + from pbn_downloader_app.tasks import ( + download_institution_publications as canonical, + ) - download_institution_publications(user.pk) - - # Check first command call - first_call = mock_call_command.call_args_list[0] - assert first_call[0][0] == "pbn_pobierz_publikacje_z_instytucji_v2" - assert first_call[1]["user_token"] == "valid_token" + assert download_institution_publications is canonical @pytest.mark.django_db -@patch("pbn_api.tasks.call_command") -def test_download_institution_publications_second_command_correct(mock_call_command): - """Test task calls second command with correct arguments""" +def test_requires_uczelnia_id(uczelnia): + """Wywołanie bez uczelnia_id => błąd (kontrakt multi-hosted).""" user = baker.make(BppUser) - user.pbn_token = "valid_token" - user.save() - - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "valid_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = True - mock_get_pbn.return_value = mock_pbn_user + with pytest.raises(TypeError): download_institution_publications(user.pk) - # Check second command call - second_call = mock_call_command.call_args_list[1] - assert second_call[0][0] == "pbn_pobierz_oswiadczenia_i_publikacje_v1" - assert second_call[1]["user_token"] == "valid_token" - @pytest.mark.django_db -@patch("pbn_api.tasks.call_command") -def test_download_institution_publications_sets_progress(mock_call_command): - """Test task updates progress in database""" - from pbn_downloader_app.models import PbnDownloadTask - - user = baker.make(BppUser) - user.pbn_token = "valid_token" - user.save() - - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "valid_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = True - mock_get_pbn.return_value = mock_pbn_user - - download_institution_publications(user.pk) - - task = PbnDownloadTask.objects.first() - - # Check that progress was set to 100 - assert task.progress_percentage == 100 +def test_no_user(uczelnia): + """Brak użytkownika => BppUser.DoesNotExist.""" + with pytest.raises(BppUser.DoesNotExist): + download_institution_publications(999999, uczelnia.pk) @pytest.mark.django_db -@patch("pbn_api.tasks.call_command") -def test_download_institution_publications_sets_completion_time(mock_call_command): - """Test task sets completion time""" - from pbn_downloader_app.models import PbnDownloadTask - +def test_no_pbn_token(uczelnia): + """Użytkownik bez tokenu PBN => ValueError.""" user = baker.make(BppUser) - user.pbn_token = "valid_token" - user.save() - - before = timezone.now() - - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "valid_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = True - mock_get_pbn.return_value = mock_pbn_user - download_institution_publications(user.pk) - - after = timezone.now() - task = PbnDownloadTask.objects.first() - - assert task.completed_at is not None - assert before <= task.completed_at <= after + with pytest.raises(ValueError, match="not authorized"): + download_institution_publications(user.pk, uczelnia.pk) @pytest.mark.django_db -@patch("pbn_api.tasks.call_command") -def test_download_institution_publications_error_marks_as_failed(mock_call_command): - """Test task marks task as failed on error""" +def test_concurrent_task_running(uczelnia): + """Inne działające zadanie => ValueError 'already running'.""" from pbn_downloader_app.models import PbnDownloadTask user = baker.make(BppUser) - user.pbn_token = "valid_token" - user.save() - - mock_call_command.side_effect = Exception("Test error") - - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "valid_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = True - mock_get_pbn.return_value = mock_pbn_user - - with pytest.raises(Exception): - download_institution_publications(user.pk) + baker.make(PbnDownloadTask, status="running") - task = PbnDownloadTask.objects.first() - assert task.status == "failed" - assert task.error_message == "Test error" + with _mock_valid_pbn_user(user): + with pytest.raises(ValueError, match="already running"): + download_institution_publications(user.pk, uczelnia.pk) @pytest.mark.django_db -@patch("pbn_api.tasks.call_command") -def test_download_institution_publications_error_saves_completion_time( - mock_call_command, -): - """Test task saves completion time on error""" +def test_runs_both_commands_with_uczelnia_id(uczelnia): + """Sukces: obie komendy odpalone, z poprawnym uczelnia_id i tokenem.""" from pbn_downloader_app.models import PbnDownloadTask user = baker.make(BppUser) - user.pbn_token = "valid_token" - user.save() - mock_call_command.side_effect = Exception("Test error") + with _mock_valid_pbn_user(user): + with patch("django.core.management.call_command") as mock_call: + with patch("pbn_downloader_app.tasks.tqdm_progress_context"): + download_institution_publications(user.pk, uczelnia.pk) - before = timezone.now() + assert mock_call.call_count == 2 - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "valid_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = True - mock_get_pbn.return_value = mock_pbn_user + first = mock_call.call_args_list[0] + assert first[0][0] == "pbn_pobierz_publikacje_z_instytucji_v2" + assert first[1]["user_token"] == "valid_token" + assert first[1]["uczelnia_id"] == uczelnia.pk - with pytest.raises(Exception): - download_institution_publications(user.pk) + second = mock_call.call_args_list[1] + assert second[0][0] == "pbn_pobierz_oswiadczenia_i_publikacje_v1" + assert second[1]["uczelnia_id"] == uczelnia.pk - after = timezone.now() task = PbnDownloadTask.objects.first() - - assert task.completed_at is not None - assert before <= task.completed_at <= after + assert task.status == "completed" @pytest.mark.django_db -@patch("pbn_api.tasks.call_command") -def test_download_institution_publications_sets_initial_status(mock_call_command): - """Test task starts with running status""" +def test_error_marks_as_failed(uczelnia): + """Błąd komendy => zadanie oznaczone jako failed.""" from pbn_downloader_app.models import PbnDownloadTask user = baker.make(BppUser) - user.pbn_token = "valid_token" - user.save() - with patch.object(type(user), "get_pbn_user") as mock_get_pbn: - mock_pbn_user = MagicMock() - mock_pbn_user.pbn_token = "valid_token" - mock_pbn_user.pbn_token_possibly_valid.return_value = True - mock_get_pbn.return_value = mock_pbn_user - - download_institution_publications(user.pk) + with _mock_valid_pbn_user(user): + with patch( + "django.core.management.call_command", + side_effect=Exception("Test error"), + ): + with patch("pbn_downloader_app.tasks.tqdm_progress_context"): + with pytest.raises(Exception, match="Test error"): + download_institution_publications(user.pk, uczelnia.pk) task = PbnDownloadTask.objects.first() - - # Should be completed after successful run - assert task.status == "completed" + assert task.status == "failed" + assert "Test error" in task.error_message diff --git a/src/pbn_downloader_app/tasks.py b/src/pbn_downloader_app/tasks.py index ba29b3753..e8f5586de 100644 --- a/src/pbn_downloader_app/tasks.py +++ b/src/pbn_downloader_app/tasks.py @@ -244,18 +244,24 @@ def mark_task_failed(task_record, error): @app.task -def download_institution_publications(user_id): +def download_institution_publications(user_id, uczelnia_id): """ Download institution publications using PBN API management commands. 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 konkretnej uczelni (z entrypointu). Wymagane — + multi-hosted nie robi get_default() po stronie zadania. """ from django.core.management import call_command + from bpp.models import Uczelnia from pbn_downloader_app.models import PbnDownloadTask + # Fail fast: jawna, istniejąca uczelnia (bez fallbacku do domyślnej). + Uczelnia.objects.get_for_pbn_background(uczelnia_id) + # Sprawdzenie obecności running-taska wykonuje atomowo # `create_task_with_lock` (wewnątrz transaction.atomic + filter().exists()). # Wcześniejszy "wstępny" check w tym miejscu otwierał race window: dwa @@ -295,7 +301,9 @@ def update_publications_progress(task, tqdm_self, desc): phase_range=35, ): call_command( - "pbn_pobierz_publikacje_z_instytucji_v2", user_token=pbn_user.pbn_token + "pbn_pobierz_publikacje_z_instytucji_v2", + uczelnia_id=uczelnia_id, + user_token=pbn_user.pbn_token, ) # Phase 2: Download statements and publications @@ -312,6 +320,7 @@ def update_publications_progress(task, tqdm_self, desc): ): call_command( "pbn_pobierz_oswiadczenia_i_publikacje_v1", + uczelnia_id=uczelnia_id, user_token=pbn_user.pbn_token, ) @@ -327,14 +336,15 @@ def update_publications_progress(task, tqdm_self, desc): @app.task -def download_institution_people(user_id, uczelnia_id=None): +def download_institution_people(user_id, uczelnia_id): """ 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()). + uczelnia_id: ID konkretnej uczelni (z entrypointu). Wymagane — + multi-hosted nie robi get_default() po stronie zadania. """ from bpp.models import Uczelnia from pbn_downloader_app.models import PbnInstitutionPeopleTask @@ -351,16 +361,12 @@ def download_institution_people(user_id, uczelnia_id=None): try: user, pbn_user = validate_pbn_user(user_id) - # Get institution ID - uczelnia = ( - Uczelnia.objects.get(pk=uczelnia_id) - if uczelnia_id - else Uczelnia.objects.get_default() - ) + # Konkretna uczelnia z entrypointu — BEZ fallbacku do get_default(). + uczelnia = Uczelnia.objects.get_for_pbn_background(uczelnia_id) if not uczelnia.pbn_uid_id: raise ValueError( - "Default institution does not have PBN UID. " - "Please run PBN integration first." + f"Uczelnia (id={uczelnia.pk}, {uczelnia}) nie ma ustawionego " + "PBN UID. Najpierw uruchom integrację PBN dla tej uczelni." ) task_record = create_task_with_lock( @@ -402,45 +408,25 @@ def update_people_progress(task, tqdm_self, desc): raise -def get_pbn_client(pbn_user, uczelnia_id=None): +def get_pbn_client(pbn_user, uczelnia_id): """ - Create a PBN client with proper configuration. + Zbuduj klienta PBN dla konkretnej uczelni. + + Klient powstaje przez kanoniczną metodę ``Uczelnia.pbn_client()`` — + bez ręcznego sklejania transportu (koniec z duplikacją). Uczelnia jest + rozwiązywana ściśle po id (bez get_default()). Args: - pbn_user: PBN user object with pbn_token. - uczelnia_id: ID of Uczelnia (defaults to get_default()). + pbn_user: obiekt użytkownika PBN z ``pbn_token``. + uczelnia_id: ID konkretnej uczelni (wymagane). Returns: - tuple: (client, uczelnia) if successful - - Raises: - ValueError: If configuration is invalid + tuple: (client, uczelnia) """ from bpp.models import Uczelnia - from pbn_api.client import PBNClient, RequestsTransport - - uczelnia = ( - Uczelnia.objects.get(pk=uczelnia_id) - if uczelnia_id - else Uczelnia.objects.get_default() - ) - if not uczelnia: - raise ValueError("No default institution configured") - - app_id = uczelnia.pbn_app_name - app_token = uczelnia.pbn_app_token - base_url = uczelnia.pbn_api_root - - if not all([app_id, app_token, base_url]): - raise ValueError( - "Institution PBN settings not properly configured " - "(app_id, app_token, or base_url missing)" - ) - - transport = RequestsTransport(app_id, app_token, base_url, pbn_user.pbn_token) - client = PBNClient(transport) - return client, uczelnia + uczelnia = Uczelnia.objects.get_for_pbn_background(uczelnia_id) + return uczelnia.pbn_client(pbn_user.pbn_token), uczelnia class JournalsProgressCallback: @@ -465,13 +451,15 @@ def clear(self): @app.task -def download_journals(user_id): +def download_journals(user_id, uczelnia_id): """ Download journals from PBN API and integrate with BPP Zrodlo records. 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 konkretnej uczelni (z entrypointu). Wymagane — + multi-hosted nie robi get_default() po stronie zadania. """ from bpp.models import Zrodlo from pbn_downloader_app.models import PbnJournalsDownloadTask @@ -487,7 +475,7 @@ def download_journals(user_id): task_record = None try: user, pbn_user = validate_pbn_user(user_id) - client, _uczelnia = get_pbn_client(pbn_user) + client, _uczelnia = get_pbn_client(pbn_user, uczelnia_id) task_record = create_task_with_lock( PbnJournalsDownloadTask, user, "Inicjalizacja pobierania zrodel..." diff --git a/src/pbn_downloader_app/tests.py b/src/pbn_downloader_app/tests.py index 03ba73034..650a09e65 100644 --- a/src/pbn_downloader_app/tests.py +++ b/src/pbn_downloader_app/tests.py @@ -23,6 +23,77 @@ User = get_user_model() +def _authorized_request(path): + """Zbuduj request z użytkownikiem mającym ważny token PBN i uprawnienia.""" + from unittest.mock import MagicMock + + factory = RequestFactory() + request = factory.post(path) + + user = User.objects.create_user("testuser", password="testpass") + group, _ = Group.objects.get_or_create(name=GR_WPROWADZANIE_DANYCH) + user.groups.add(group) + request.user = user + request.session = {} + + pbn_user = MagicMock() + pbn_user.pbn_token = "valid-token" + pbn_user.pbn_token_possibly_valid.return_value = True + user.get_pbn_user = lambda: pbn_user + return request, user + + +def _delay_uczelnia_id(mock_task): + """Wyciągnij uczelnia_id przekazane do .delay() (kwarg lub 2. pozycyjny).""" + mock_task.delay.assert_called_once() + call = mock_task.delay.call_args + uczelnia_id = call.kwargs.get("uczelnia_id") + if uczelnia_id is None and len(call.args) > 1: + uczelnia_id = call.args[1] + return uczelnia_id + + +@pytest.mark.django_db +def test_start_pbn_download_passes_uczelnia_id(uczelnia): + """Entrypoint pobierania publikacji MUSI przekazać id uczelni do zadania.""" + from unittest.mock import patch + + request, user = _authorized_request("/api/start-download/") + + with patch( + "pbn_downloader_app.views.download_institution_publications" + ) as mock_task: + StartPbnDownloadView().post(request) + + assert _delay_uczelnia_id(mock_task) == uczelnia.pk + + +@pytest.mark.django_db +def test_start_people_download_passes_uczelnia_id(uczelnia): + """Entrypoint pobierania osób MUSI przekazać id uczelni do zadania.""" + from unittest.mock import patch + + request, user = _authorized_request("/api/start-people-download/") + + with patch("pbn_downloader_app.views.download_institution_people") as mock_task: + StartPbnPeopleDownloadView().post(request) + + assert _delay_uczelnia_id(mock_task) == uczelnia.pk + + +@pytest.mark.django_db +def test_start_journals_download_passes_uczelnia_id(uczelnia): + """Entrypoint pobierania źródeł MUSI przekazać id uczelni do zadania.""" + from unittest.mock import patch + + request, user = _authorized_request("/api/start-journals-download/") + + with patch("pbn_downloader_app.views.download_journals") as mock_task: + StartJournalsDownloadView().post(request) + + assert _delay_uczelnia_id(mock_task) == uczelnia.pk + + @pytest.mark.django_db def test_pbn_download_task_model(): """Test PbnDownloadTask model functionality.""" diff --git a/src/pbn_downloader_app/tests_tasks.py b/src/pbn_downloader_app/tests_tasks.py index 4d24e7683..c4c830a4f 100644 --- a/src/pbn_downloader_app/tests_tasks.py +++ b/src/pbn_downloader_app/tests_tasks.py @@ -145,7 +145,8 @@ def test_mark_task_failed_handles_none(): @pytest.mark.django_db def test_get_pbn_client_success(uczelnia): - """Test get_pbn_client creates client with valid config.""" + """get_pbn_client buduje klienta przez kanoniczną Uczelnia.pbn_client() + dla konkretnej (przekazanej po id) uczelni.""" class MockPbnUser: pbn_token = "valid-token" @@ -155,35 +156,30 @@ class MockPbnUser: uczelnia.pbn_api_root = "https://pbn-api.test/" uczelnia.save() - with patch("pbn_api.client.PBNClient"): - with patch("pbn_api.client.RequestsTransport") as mock_transport: - client, returned_uczelnia = get_pbn_client(MockPbnUser()) + client, returned_uczelnia = get_pbn_client(MockPbnUser(), uczelnia.pk) - mock_transport.assert_called_once_with( - "test-app", "test-app-token", "https://pbn-api.test/", "valid-token" - ) + assert client is not None assert returned_uczelnia == uczelnia @pytest.mark.django_db -def test_get_pbn_client_no_uczelnia(): - """Test get_pbn_client raises ValueError when no default uczelnia.""" - from bpp.models import Uczelnia - - Uczelnia.objects.all().delete() +def test_get_pbn_client_requires_uczelnia_id(): + """Bez uczelnia_id => ValueError (brak fallbacku do get_default()).""" class MockPbnUser: pbn_token = "valid-token" with pytest.raises(ValueError) as exc_info: - get_pbn_client(MockPbnUser()) + get_pbn_client(MockPbnUser(), None) - assert "No default institution" in str(exc_info.value) + assert "uczelnia_id" in str(exc_info.value) @pytest.mark.django_db def test_get_pbn_client_incomplete_config(uczelnia): - """Test get_pbn_client raises ValueError when config incomplete.""" + """Niekompletna konfiguracja PBN uczelni => ImproperlyConfigured + (rzucane przez kanoniczną Uczelnia.pbn_client()).""" + from django.core.exceptions import ImproperlyConfigured class MockPbnUser: pbn_token = "valid-token" @@ -194,10 +190,10 @@ class MockPbnUser: uczelnia.pbn_api_root = "https://pbn-api.test/" uczelnia.save() - with pytest.raises(ValueError) as exc_info: - get_pbn_client(MockPbnUser()) + with pytest.raises(ImproperlyConfigured) as exc_info: + get_pbn_client(MockPbnUser(), uczelnia.pk) - assert "not properly configured" in str(exc_info.value) + assert "Brak nazwy aplikacji" in str(exc_info.value) # ============================================================================ @@ -206,19 +202,30 @@ class MockPbnUser: @pytest.mark.django_db -def test_download_institution_publications_already_running(): +def test_download_institution_publications_already_running(uczelnia): """Test download_institution_publications fails when task already running.""" user = User.objects.create_user("testuser", password="testpass") PbnDownloadTask.objects.create(user=user, status="running") - with pytest.raises(ValueError) as exc_info: - download_institution_publications(user.pk) + class MockPbnUser: + pbn_token = "valid-token" + + def pbn_token_possibly_valid(self): + return True + + # Strażnik współbieżności (create_task_with_lock) działa PO walidacji + # użytkownika, więc mockujemy walidację, by dojść do testowanego guardu. + with patch("pbn_downloader_app.tasks.validate_pbn_user") as mock_validate: + mock_validate.return_value = (user, MockPbnUser()) + + with pytest.raises(ValueError) as exc_info: + download_institution_publications(user.pk, uczelnia.pk) assert "already running" in str(exc_info.value) @pytest.mark.django_db -def test_download_institution_publications_success(): +def test_download_institution_publications_success(uczelnia): """Test download_institution_publications succeeds with mocked dependencies.""" user = User.objects.create_user("testuser", password="testpass") @@ -234,7 +241,7 @@ def pbn_token_possibly_valid(self): # call_command is imported locally, so mock at source with patch("django.core.management.call_command") as mock_call: with patch("pbn_downloader_app.tasks.tqdm_progress_context"): - download_institution_publications(user.pk) + download_institution_publications(user.pk, uczelnia.pk) assert mock_call.call_count == 2 @@ -244,7 +251,7 @@ def pbn_token_possibly_valid(self): @pytest.mark.django_db -def test_download_institution_publications_handles_error(): +def test_download_institution_publications_handles_error(uczelnia): """Test download_institution_publications marks task failed on error.""" user = User.objects.create_user("testuser", password="testpass") @@ -263,7 +270,7 @@ def pbn_token_possibly_valid(self): ): with patch("pbn_downloader_app.tasks.tqdm_progress_context"): with pytest.raises(Exception, match="API Error"): - download_institution_publications(user.pk) + download_institution_publications(user.pk, uczelnia.pk) task = PbnDownloadTask.objects.filter(user=user).first() assert task is not None @@ -277,13 +284,13 @@ def pbn_token_possibly_valid(self): @pytest.mark.django_db -def test_download_institution_people_already_running(): +def test_download_institution_people_already_running(uczelnia): """Test download_institution_people fails when task already running.""" user = User.objects.create_user("testuser", password="testpass") PbnInstitutionPeopleTask.objects.create(user=user, status="running") with pytest.raises(ValueError) as exc_info: - download_institution_people(user.pk) + download_institution_people(user.pk, uczelnia.pk) assert "already running" in str(exc_info.value) @@ -306,7 +313,7 @@ def pbn_token_possibly_valid(self): mock_validate.return_value = (user, MockPbnUser()) with pytest.raises(ValueError) as exc_info: - download_institution_people(user.pk) + download_institution_people(user.pk, uczelnia.pk) assert "PBN UID" in str(exc_info.value) @@ -332,7 +339,7 @@ def pbn_token_possibly_valid(self): with patch("pbn_downloader_app.tasks.tqdm_progress_context"): # pobierz_ludzi_z_uczelni is imported locally from pbn_integrator.utils with patch("pbn_integrator.utils.pobierz_ludzi_z_uczelni") as mock_pobierz: - download_institution_people(user.pk) + download_institution_people(user.pk, uczelnia.pk) mock_pobierz.assert_called_once() @@ -347,13 +354,13 @@ def pbn_token_possibly_valid(self): @pytest.mark.django_db -def test_download_journals_already_running(): +def test_download_journals_already_running(uczelnia): """Test download_journals fails when task already running.""" user = User.objects.create_user("testuser", password="testpass") PbnJournalsDownloadTask.objects.create(user=user, status="running") with pytest.raises(ValueError) as exc_info: - download_journals(user.pk) + download_journals(user.pk, uczelnia.pk) assert "already running" in str(exc_info.value) @@ -387,7 +394,7 @@ def pbn_token_possibly_valid(self): with patch( "pbn_komparator_zrodel.utils.aktualizuj_brakujace_dyscypliny_pbn" ): - download_journals(user.pk) + download_journals(user.pk, uczelnia.pk) mock_pobierz.assert_called_once() mock_integruj.assert_called_once() @@ -425,7 +432,7 @@ def pbn_token_possibly_valid(self): side_effect=Exception("API Error"), ): with pytest.raises(Exception, match="API Error"): - download_journals(user.pk) + download_journals(user.pk, uczelnia.pk) task = PbnJournalsDownloadTask.objects.filter(user=user).first() assert task is not None diff --git a/src/pbn_downloader_app/views.py b/src/pbn_downloader_app/views.py index 70417a5e8..261ba750b 100644 --- a/src/pbn_downloader_app/views.py +++ b/src/pbn_downloader_app/views.py @@ -6,6 +6,7 @@ from django.views.generic.base import TemplateView from bpp.const import GR_WPROWADZANIE_DANYCH +from bpp.models import Uczelnia from pbn_downloader_app.models import ( PbnDownloadTask, PbnInstitutionPeopleTask, @@ -19,6 +20,18 @@ logger = logging.getLogger(__name__) + +def _request_uczelnia_id(request): + """Id uczelni z requestu dla PBN-owych zadań w tle. + + Multi-hosted: entrypoint zna konkretną uczelnię z hosta i przekazuje + jej id do zadania. Zadanie NIE robi get_default() — patrz + UczelniaManager.get_for_pbn_background. + """ + uczelnia = Uczelnia.objects.get_for_request(request) + return uczelnia.pk if uczelnia else None + + # Custom group required decorator @@ -105,7 +118,9 @@ def post(self, request): # Start the task try: - download_institution_publications.delay(user.pk) + download_institution_publications.delay( + user.pk, uczelnia_id=_request_uczelnia_id(request) + ) return JsonResponse( {"success": True, "message": "Zadanie pobierania rozpoczęte."} ) @@ -199,7 +214,9 @@ def post(self, request): # Start the task try: - download_institution_publications.delay(user.pk) + download_institution_publications.delay( + user.pk, uczelnia_id=_request_uczelnia_id(request) + ) return JsonResponse( {"success": True, "message": "Zadanie pobierania uruchomione ponownie."} ) @@ -254,7 +271,9 @@ def post(self, request): # Start the task try: - download_institution_people.delay(user.pk) + download_institution_people.delay( + user.pk, uczelnia_id=_request_uczelnia_id(request) + ) return JsonResponse( {"success": True, "message": "Zadanie pobierania osób rozpoczęte."} ) @@ -348,7 +367,9 @@ def post(self, request): # Start the task try: - download_institution_people.delay(user.pk) + download_institution_people.delay( + user.pk, uczelnia_id=_request_uczelnia_id(request) + ) return JsonResponse( { "success": True, @@ -409,7 +430,9 @@ def post(self, request): # Start the task try: - download_journals.delay(user.pk) + download_journals.delay( + user.pk, uczelnia_id=_request_uczelnia_id(request) + ) return JsonResponse( {"success": True, "message": "Zadanie pobierania źródeł rozpoczęte."} ) @@ -505,7 +528,9 @@ def post(self, request): # Start the task try: - download_journals.delay(user.pk) + download_journals.delay( + user.pk, uczelnia_id=_request_uczelnia_id(request) + ) return JsonResponse( { "success": True, diff --git a/src/pbn_import/tasks.py b/src/pbn_import/tasks.py index 5e3d9d3c2..67d7f706a 100644 --- a/src/pbn_import/tasks.py +++ b/src/pbn_import/tasks.py @@ -73,14 +73,9 @@ def run_pbn_import(self, session_id, uczelnia_id=None): # Get configuration config = session.config - uczelnia = ( - Uczelnia.objects.get(pk=uczelnia_id) - if uczelnia_id - else Uczelnia.objects.get_default() - ) - - if not uczelnia: - raise Exception("Brak konfiguracji uczelni") + # Multi-hosted: konkretna uczelnia z entrypointu, BEZ fallbacku + # do get_default() (patrz UczelniaManager.get_for_pbn_background). + uczelnia = Uczelnia.objects.get_for_pbn_background(uczelnia_id) # Create PBN client try: diff --git a/src/pbn_import/tests/test_tasks.py b/src/pbn_import/tests/test_tasks.py index 7c98c2dbc..785440d7f 100644 --- a/src/pbn_import/tests/test_tasks.py +++ b/src/pbn_import/tests/test_tasks.py @@ -118,7 +118,7 @@ def test_run_pbn_import_success(uczelnia, admin_user): with patch("pbn_import.tasks.send_websocket_update"): with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk,)) + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.status == "completed" @@ -139,7 +139,7 @@ def test_run_pbn_import_marks_running(uczelnia, admin_user): with patch("pbn_import.tasks.send_websocket_update"): with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk,)) + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.task_id is not None @@ -158,7 +158,7 @@ def test_run_pbn_import_handles_no_pbn_client(uczelnia, admin_user): with patch.object( uczelnia, "pbn_client", side_effect=Exception("No PBN config") ): - run_pbn_import.apply(args=(session.pk,)) + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.status == "completed" @@ -181,7 +181,7 @@ def test_run_pbn_import_failure(uczelnia, admin_user): with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): with patch("pbn_import.tasks.rollbar"): - run_pbn_import.apply(args=(session.pk,)) + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.status == "failed" @@ -209,7 +209,7 @@ def simulate_cancellation(sess, pbn_client, config): with patch("pbn_import.tasks.send_websocket_update"): with patch("pbn_import.tasks.ImportManager", side_effect=simulate_cancellation): with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk,)) + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.status == "cancelled" @@ -226,7 +226,7 @@ def test_run_pbn_import_failed_result(uczelnia, admin_user): with patch("pbn_import.tasks.send_websocket_update"): with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk,)) + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.status == "failed" @@ -248,7 +248,7 @@ def track_websocket(sess, data): with patch("pbn_import.tasks.send_websocket_update", side_effect=track_websocket): with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk,)) + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) completion_call = next( (c for c in websocket_calls if c.get("type") == "completion"), None @@ -257,6 +257,82 @@ def track_websocket(sess, data): assert completion_call["success"] is True +@pytest.mark.django_db +def test_run_pbn_import_uses_passed_uczelnia_not_default(admin_user): + """run_pbn_import MUSI budować klienta z uczelni przekazanej przez + entrypoint, a NIE z pierwszej-z-brzegu (get_default()). + + Regresja błędu multi-hosted: PBN skonfigurowany w drugiej uczelni, + a zadanie czytało konfigurację pierwszej -> 403 'token aplikacji null'. + """ + from django.contrib.sites.models import Site + + from bpp.models import Uczelnia + + # Pierwsza uczelnia (get_default()) — BEZ konfiguracji PBN. + site1, _ = Site.objects.get_or_create( + domain="pierwsza.example.com", defaults={"name": "pierwsza"} + ) + Uczelnia.objects.create(skrot="P1", nazwa="Pierwsza", site=site1) + + # Druga uczelnia — to ją wybiera użytkownik (ma PBN). + site2, _ = Site.objects.get_or_create( + domain="druga.example.com", defaults={"name": "druga"} + ) + uczelnia2 = Uczelnia.objects.create(skrot="P2", nazwa="Druga", site=site2) + + session = ImportSession.objects.create( + user=admin_user, status="pending", config={} + ) + + recorded_pk = [] + + def fake_pbn_client(self, token): + recorded_pk.append(self.pk) + return MagicMock() + + mock_import_manager = MagicMock() + mock_import_manager.run.return_value = {"success": True} + + with patch("pbn_import.tasks.send_websocket_update"): + with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): + with patch.object(Uczelnia, "pbn_client", fake_pbn_client): + run_pbn_import.apply(args=(session.pk, uczelnia2.pk)) + + assert recorded_pk == [uczelnia2.pk] + + +@pytest.mark.django_db +def test_run_pbn_import_without_uczelnia_id_does_not_fall_back(admin_user): + """Bez uczelnia_id zadanie MUSI zakończyć się błędem, a nie po cichu + użyć pierwszej uczelni (get_default()). To eliminuje wzorzec + 'default institution' z toru w tle.""" + from django.contrib.sites.models import Site + + from bpp.models import Uczelnia + + site1, _ = Site.objects.get_or_create( + domain="pierwsza.example.com", defaults={"name": "pierwsza"} + ) + Uczelnia.objects.create(skrot="P1", nazwa="Pierwsza", site=site1) + + session = ImportSession.objects.create( + user=admin_user, status="pending", config={} + ) + + mock_import_manager = MagicMock() + mock_import_manager.run.return_value = {"success": True} + + with patch("pbn_import.tasks.send_websocket_update"): + with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): + with patch("pbn_import.tasks.rollbar"): + # brak uczelnia_id — entrypoint go nie podał + run_pbn_import.apply(args=(session.pk,)) + + session.refresh_from_db() + assert session.status == "failed" + + @pytest.mark.django_db def test_run_pbn_import_creates_start_log(uczelnia, admin_user): """Test run_pbn_import creates start log entry.""" @@ -268,7 +344,7 @@ def test_run_pbn_import_creates_start_log(uczelnia, admin_user): with patch("pbn_import.tasks.send_websocket_update"): with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk,)) + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) start_log = ImportLog.objects.filter( session=session, step="Start", level="info" diff --git a/src/pbn_import/tests/test_views_dashboard.py b/src/pbn_import/tests/test_views_dashboard.py index 11747f612..f2855dd83 100644 --- a/src/pbn_import/tests/test_views_dashboard.py +++ b/src/pbn_import/tests/test_views_dashboard.py @@ -218,6 +218,33 @@ def test_start_import_stores_config(self, django_user_model): assert session.config["wydzial_domyslny"] == "IT Department" assert session.config["wydzial_domyslny_id"] == wydzial.pk + def test_start_import_passes_uczelnia_id_from_request( + self, uczelnia, django_user_model + ): + """Entrypoint MUSI przekazać id uczelni z requestu do zadania w tle.""" + uczelnia.pbn_integracja = True + uczelnia.save() + + client = Client() + user = baker.make(django_user_model, is_superuser=True) + client.force_login(user) + + with ( + patch("pbn_import.tasks.run_pbn_import") as mock_task, + patch("pbn_import.views.get_channel_layer"), + patch("pbn_import.views.async_to_sync"), + ): + mock_task.delay.return_value = MagicMock(id="task-123") + + client.post(reverse("pbn_import:start"), {"initial": "on"}) + + mock_task.delay.assert_called_once() + call = mock_task.delay.call_args + passed = call.kwargs.get("uczelnia_id") + if passed is None and len(call.args) > 1: + passed = call.args[1] + assert passed == uczelnia.pk + def test_start_import_redirects_to_dashboard(self, django_user_model): """Test start import redirects to dashboard after creation""" client = Client() diff --git a/src/pbn_import/views.py b/src/pbn_import/views.py index 39c0e68b2..f5b2d3f92 100644 --- a/src/pbn_import/views.py +++ b/src/pbn_import/views.py @@ -181,7 +181,12 @@ def post(self, request): # Launch Celery task from .tasks import run_pbn_import - result = run_pbn_import.delay(session.id) + # Multi-hosted: entrypoint zna konkretną uczelnię z requestu i MUSI + # przekazać jej id do zadania w tle (zadanie NIE robi get_default()). + uczelnia = Uczelnia.objects.get_for_request(request) + result = run_pbn_import.delay( + session.id, uczelnia_id=uczelnia.pk if uczelnia else None + ) session.task_id = result.id session.status = "pending" # Keep as pending until task starts session.save() From e2498e3c9442649c39bf6742c0811049fc7069c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 15:55:41 +0200 Subject: [PATCH 040/247] =?UTF-8?q?fix(pbn):=20wysy=C5=82ka=20o=C5=9Bwiadc?= =?UTF-8?q?ze=C5=84=20bierze=20jawn=C4=85=20uczelni=C4=99=20z=20requestu?= =?UTF-8?q?=20(multi-hosted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pbn_wysylka_oswiadczen.get_pbn_client(user) budował klienta z Uczelnia.objects.get_default() — w multi-hosted brał konfigurację PBN złej uczelni. Teraz: - get_pbn_client(user, uczelnia_id) buduje klienta przez kanoniczną Uczelnia.pbn_client() i rozwiązuje uczelnię ściśle po id (get_for_pbn_background) — bez fallbacku do get_default(). - wysylka_oswiadczen_task przyjmuje uczelnia_id i podaje go do get_pbn_client. - StartTaskView przekazuje id uczelni z requestu do zadania. Testy: nowe (entrypoint, kontrakt get_pbn_client) pisane TDD (RED→GREEN), istniejące zaktualizowane do wymaganego uczelnia_id. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pbn_wysylka_oswiadczen/tasks.py | 41 +++----- .../tests/test_tasks.py | 95 +++++++++++++++++-- src/pbn_wysylka_oswiadczen/views.py | 9 +- 3 files changed, 110 insertions(+), 35 deletions(-) diff --git a/src/pbn_wysylka_oswiadczen/tasks.py b/src/pbn_wysylka_oswiadczen/tasks.py index 250a6ab57..f053b8c0a 100644 --- a/src/pbn_wysylka_oswiadczen/tasks.py +++ b/src/pbn_wysylka_oswiadczen/tasks.py @@ -9,7 +9,6 @@ from bpp.models import Uczelnia from pbn_api.adapters.wydawnictwo import WydawnictwoPBNAdapter -from pbn_api.client import PBNClient, RequestsTransport from pbn_api.exceptions import ( CannotDeleteStatementsException, DaneLokalneWymagajaAktualizacjiException, @@ -19,19 +18,23 @@ from pbn_wysylka_oswiadczen.queries import get_publications_queryset -def get_pbn_client(user, uczelnia=None): +def get_pbn_client(user, uczelnia_id): """ - Create a PBN client for the given user. + Zbuduj klienta PBN dla użytkownika i KONKRETNEJ uczelni. + + Klient powstaje przez kanoniczną metodę ``Uczelnia.pbn_client()`` (bez + ręcznego sklejania transportu). Uczelnia rozwiązywana ściśle po id — + multi-hosted nie robi get_default() po stronie zadania w tle. Args: - user: Django user with PBN token - uczelnia: Uczelnia instance (optional, falls back to default) + user: Django user z tokenem PBN. + uczelnia_id: ID konkretnej uczelni (wymagane). Returns: - PBNClient: Configured PBN API client + PBNClient: skonfigurowany klient PBN API. Raises: - ValueError: If configuration is invalid + ValueError: brak/nieważny token lub brak uczelnia_id. """ pbn_user = user.get_pbn_user() @@ -41,22 +44,8 @@ def get_pbn_client(user, uczelnia=None): if not pbn_user.pbn_token_possibly_valid(): raise ValueError("Token PBN wygasl. Zaloguj sie ponownie do PBN.") - if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() - if not uczelnia: - raise ValueError("Brak domyslnej uczelni w systemie.") - - app_id = uczelnia.pbn_app_name - app_token = uczelnia.pbn_app_token - base_url = uczelnia.pbn_api_root - - if not all([app_id, app_token, base_url]): - raise ValueError( - "Ustawienia PBN uczelni niekompletne (brak app_id, app_token lub base_url)" - ) - - transport = RequestsTransport(app_id, app_token, base_url, pbn_user.pbn_token) - return PBNClient(transport) + uczelnia = Uczelnia.objects.get_for_pbn_background(uczelnia_id) + return uczelnia.pbn_client(pbn_user.pbn_token) def _delete_existing_statements(publication, pbn_client, log_entry): @@ -285,7 +274,7 @@ def _process_publications_loop( @shared_task(bind=True) -def wysylka_oswiadczen_task(self, task_id: int): +def wysylka_oswiadczen_task(self, task_id: int, uczelnia_id: int = None): """ Main Celery task for sending statements to PBN. @@ -310,8 +299,8 @@ def wysylka_oswiadczen_task(self, task_id: int): task.save() try: - # Get PBN client - pbn_client = get_pbn_client(task.user) + # Get PBN client — konkretna uczelnia z entrypointu (bez get_default) + pbn_client = get_pbn_client(task.user, uczelnia_id) # Build publication list ciagle_qs, zwarte_qs = get_publications_queryset( diff --git a/src/pbn_wysylka_oswiadczen/tests/test_tasks.py b/src/pbn_wysylka_oswiadczen/tests/test_tasks.py index ce76ab090..c8d3d97f3 100644 --- a/src/pbn_wysylka_oswiadczen/tests/test_tasks.py +++ b/src/pbn_wysylka_oswiadczen/tests/test_tasks.py @@ -39,7 +39,7 @@ class MockPbnUser: user.get_pbn_user = lambda: MockPbnUser() with pytest.raises(ValueError) as exc_info: - get_pbn_client(user) + get_pbn_client(user, 1) assert "tokenu" in str(exc_info.value).lower() @@ -58,14 +58,14 @@ def pbn_token_possibly_valid(self): user.get_pbn_user = lambda: MockPbnUser() with pytest.raises(ValueError) as exc_info: - get_pbn_client(user) + get_pbn_client(user, 1) assert "wygasl" in str(exc_info.value).lower() @pytest.mark.django_db -def test_get_pbn_client_no_uczelnia(): - """Test get_pbn_client raises error when no uczelnia exists.""" +def test_get_pbn_client_unknown_uczelnia_id(): + """Nieistniejące uczelnia_id => Uczelnia.DoesNotExist (bez fallbacku).""" user = User.objects.create_user("testuser", password="testpass") class MockPbnUser: @@ -76,15 +76,96 @@ def pbn_token_possibly_valid(self): user.get_pbn_user = lambda: MockPbnUser() - # Make sure no Uczelnia exists - Uczelnia.objects.all().delete() + with pytest.raises(Uczelnia.DoesNotExist): + get_pbn_client(user, 999999) + + +@pytest.mark.django_db +def test_get_pbn_client_requires_uczelnia_id(uczelnia): + """Bez uczelnia_id get_pbn_client rzuca (brak fallbacku do get_default).""" + user = User.objects.create_user("testuser", password="testpass") + + class MockPbnUser: + pbn_token = "valid-token" + + def pbn_token_possibly_valid(self): + return True + + user.get_pbn_user = lambda: MockPbnUser() with pytest.raises(ValueError) as exc_info: - get_pbn_client(user) + get_pbn_client(user, None) assert "uczelni" in str(exc_info.value).lower() +@pytest.mark.django_db +def test_get_pbn_client_uses_passed_uczelnia(uczelnia): + """get_pbn_client buduje klienta z PRZEKAZANEJ uczelni (po id), + przez kanoniczną Uczelnia.pbn_client().""" + user = User.objects.create_user("testuser", password="testpass") + + class MockPbnUser: + pbn_token = "valid-token" + + def pbn_token_possibly_valid(self): + return True + + user.get_pbn_user = lambda: MockPbnUser() + + uczelnia.pbn_app_name = "APP" + uczelnia.pbn_app_token = "TOK" + uczelnia.pbn_api_root = "https://x.example/" + uczelnia.save() + + recorded = [] + + def fake_pbn_client(self, token): + recorded.append(self.pk) + return MagicMock() + + with patch.object(Uczelnia, "pbn_client", fake_pbn_client): + client = get_pbn_client(user, uczelnia.pk) + + assert client is not None + assert recorded == [uczelnia.pk] + + +@pytest.mark.django_db +def test_start_task_view_passes_uczelnia_id(uczelnia): + """Entrypoint wysyłki oświadczeń MUSI przekazać id uczelni do zadania.""" + from django.contrib.auth.models import Group + from django.test import RequestFactory + + from bpp.const import GR_WPROWADZANIE_DANYCH + from pbn_wysylka_oswiadczen.views import StartTaskView + + request = RequestFactory().post("/start/", {"rok_od": "2022", "rok_do": "2025"}) + user = User.objects.create_user("testuser", password="testpass") + group, _ = Group.objects.get_or_create(name=GR_WPROWADZANIE_DANYCH) + user.groups.add(group) + request.user = user + request.session = {} + + pbn_user = MagicMock() + pbn_user.pbn_token = "valid-token" + pbn_user.pbn_token_possibly_valid.return_value = True + user.get_pbn_user = lambda: pbn_user + + with patch( + "pbn_wysylka_oswiadczen.views.wysylka_oswiadczen_task" + ) as mock_task: + mock_task.delay.return_value = MagicMock(id="task-123") + StartTaskView().post(request) + + mock_task.delay.assert_called_once() + call = mock_task.delay.call_args + passed = call.kwargs.get("uczelnia_id") + if passed is None and len(call.args) > 1: + passed = call.args[1] + assert passed == uczelnia.pk + + @pytest.mark.django_db def test_delete_existing_statements_cannot_delete(): """Test _delete_existing_statements handles CannotDeleteStatementsException.""" diff --git a/src/pbn_wysylka_oswiadczen/views.py b/src/pbn_wysylka_oswiadczen/views.py index db7f7de64..3c35a8a6a 100644 --- a/src/pbn_wysylka_oswiadczen/views.py +++ b/src/pbn_wysylka_oswiadczen/views.py @@ -12,6 +12,7 @@ from queryset_sequence import QuerySetSequence from bpp.const import GR_WPROWADZANIE_DANYCH +from bpp.models import Uczelnia from bpp.util import sanitize_xlsx_row from pbn_wysylka_oswiadczen.models import PbnWysylkaLog, PbnWysylkaOswiadczenTask from pbn_wysylka_oswiadczen.queries import get_publications_queryset @@ -251,9 +252,13 @@ def post(self, request): resume_mode=resume_mode, ) - # Start Celery task + # Start Celery task — entrypoint zna uczelnię z requestu i przekazuje + # jej id do zadania (zadanie NIE robi get_default()). + uczelnia = Uczelnia.objects.get_for_request(request) try: - celery_result = wysylka_oswiadczen_task.delay(task.pk) + celery_result = wysylka_oswiadczen_task.delay( + task.pk, uczelnia_id=uczelnia.pk if uczelnia else None + ) task.celery_task_id = celery_result.id task.save() From 7dc65b9031075d9bc50b587f62ac4c5e3ac0d9df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 16:05:29 +0200 Subject: [PATCH 041/247] =?UTF-8?q?fix(pbn):=20kolejka=20eksportu=20u?= =?UTF-8?q?=C5=BCywa=20uczelni=20z=20wpisu,=20nie=20get=5Fdefault=20(multi?= =?UTF-8?q?-hosted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PBN_Export_Queue ma pole `uczelnia`, ale nigdy nie było ustawiane ani używane — wysyłka szła przez sprobuj_wyslac_do_pbn_celery bez uczelni, więc warstwa niżej brała Uczelnia.objects.get_default() (pierwsza-z-brzegu). Zmiany: - PBN_Export_Queue.send_to_pbn przekazuje self.uczelnia do sprobuj_wyslac_do_pbn_celery (zamiast pozwalać na get_default()). - Manager.sprobuj_utowrzyc_wpis(user, rekord, uczelnia=None) zapisuje uczelnię na wpisie. - queue_pbn_export_batch przyjmuje uczelnia_id i ustawia je na wpisach. - Akcja admina wyslij_do_pbn_w_tle bierze uczelnię z requestu (get_for_request) i przekazuje jej id do batcha. Uwaga: pole `uczelnia` było już w modelu (brak migracji). Wpisy bez uczelni (np. ze starych batchy / deduplikatora) nadal działają — cli ma fallback get_default() bezpieczny dla instalacji jedno-uczelnianej. Testy: nowe (send forwarduje uczelnię, manager zapisuje, batch ustawia) pisane TDD (RED→GREEN). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/admin/actions.py | 7 +++- src/pbn_export_queue/models.py | 4 ++- src/pbn_export_queue/tasks.py | 13 +++++-- .../tests/test_pbn_queue_manager.py | 10 ++++++ .../tests/test_pbn_queue_send.py | 27 ++++++++++++++ .../tests/test_uczelnia_multihosted.py | 35 +++++++++++++++++++ 6 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 src/pbn_export_queue/tests/test_uczelnia_multihosted.py diff --git a/src/bpp/admin/actions.py b/src/bpp/admin/actions.py index ca3704858..a35c30aae 100644 --- a/src/bpp/admin/actions.py +++ b/src/bpp/admin/actions.py @@ -4,7 +4,7 @@ from bpp.admin.helpers.pbn_api.gui import ( sprobuj_wyslac_do_pbn_gui, ) -from bpp.models import Status_Korekty +from bpp.models import Status_Korekty, Uczelnia from pbn_export_queue.tasks import queue_pbn_export_batch @@ -83,12 +83,17 @@ def wyslij_do_pbn_w_tle(modeladmin, request, queryset): # Collect record IDs record_ids = list(queryset.values_list("id", flat=True)) + # Multi-hosted: zapamiętaj uczelnię z requestu, żeby wysyłka w tle + # użyła właściwej konfiguracji PBN (a nie pierwszej-z-brzegu). + uczelnia = Uczelnia.objects.get_for_request(request) + # Queue the batch export in background queue_pbn_export_batch.delay( app_label=app_label, model_name=model_name, record_ids=record_ids, user_id=request.user.id, + uczelnia_id=uczelnia.pk if uczelnia else None, ) modeladmin.message_user( diff --git a/src/pbn_export_queue/models.py b/src/pbn_export_queue/models.py index 6a533edb5..73e84a87d 100644 --- a/src/pbn_export_queue/models.py +++ b/src/pbn_export_queue/models.py @@ -35,13 +35,14 @@ def filter_rekord_do_wysylki(self, rekord): wysylke_zakonczono=None, ) - def sprobuj_utowrzyc_wpis(self, user, rekord): + def sprobuj_utowrzyc_wpis(self, user, rekord, uczelnia=None): if self.filter_rekord_do_wysylki(rekord).exists(): raise AlreadyEnqueuedError("ten rekord jest już w kolejce do wysyłki") return self.create( rekord_do_wysylki=rekord, zamowil=user, + uczelnia=uczelnia, ) @@ -381,6 +382,7 @@ def send_to_pbn(self): user=self.zamowil.get_pbn_user(), obj=self.rekord_do_wysylki, force_upload=True, + uczelnia=self.uczelnia, ) except Exception as exc: return self._handle_pbn_exception(exc) diff --git a/src/pbn_export_queue/tasks.py b/src/pbn_export_queue/tasks.py index a3c1aa201..79ef461d5 100644 --- a/src/pbn_export_queue/tasks.py +++ b/src/pbn_export_queue/tasks.py @@ -126,7 +126,7 @@ def kolejka_ponow_wysylke_prac_po_zalogowaniu(pk): @app.task -def queue_pbn_export_batch(app_label, model_name, record_ids, user_id): +def queue_pbn_export_batch(app_label, model_name, record_ids, user_id, uczelnia_id=None): """ Queue multiple records for PBN export in batch. @@ -135,9 +135,12 @@ def queue_pbn_export_batch(app_label, model_name, record_ids, user_id): model_name: Model name (e.g. 'wydawnictwo_ciagle') record_ids: List of record IDs to queue user_id: User ID who initiated the export + uczelnia_id: ID uczelni z requestu (multi-hosted) — zapisywane na + wpisie kolejki, żeby wysyłka użyła właściwej konfiguracji PBN. """ from django.contrib.auth import get_user_model + from bpp.models import Uczelnia from pbn_api.exceptions import AlreadyEnqueuedError User = get_user_model() @@ -147,13 +150,19 @@ def queue_pbn_export_batch(app_label, model_name, record_ids, user_id): except User.DoesNotExist: return + uczelnia = ( + Uczelnia.objects.filter(pk=uczelnia_id).first() if uczelnia_id else None + ) + model = apps.get_model(app_label, model_name) for record_id in record_ids: try: record = model.objects.get(pk=record_id) try: - PBN_Export_Queue.objects.sprobuj_utowrzyc_wpis(user, record) + PBN_Export_Queue.objects.sprobuj_utowrzyc_wpis( + user, record, uczelnia=uczelnia + ) # Send to PBN in background queue_entry = PBN_Export_Queue.objects.filter_rekord_do_wysylki( record diff --git a/src/pbn_export_queue/tests/test_pbn_queue_manager.py b/src/pbn_export_queue/tests/test_pbn_queue_manager.py index 6085c9365..461bf80e9 100644 --- a/src/pbn_export_queue/tests/test_pbn_queue_manager.py +++ b/src/pbn_export_queue/tests/test_pbn_queue_manager.py @@ -30,6 +30,16 @@ def test_sprobuj_utowrzyc_wpis_success(self, wydawnictwo_ciagle, admin_user): assert result.zamowil == admin_user assert result.rekord_do_wysylki == wydawnictwo_ciagle + def test_sprobuj_utowrzyc_wpis_stores_uczelnia( + self, wydawnictwo_ciagle, admin_user, uczelnia + ): + """Wpis kolejki zapamiętuje konkretną uczelnię (multi-hosted).""" + result = PBN_Export_Queue.objects.sprobuj_utowrzyc_wpis( + admin_user, wydawnictwo_ciagle, uczelnia=uczelnia + ) + + assert result.uczelnia == uczelnia + def test_sprobuj_utowrzyc_wpis_already_enqueued( self, wydawnictwo_ciagle, admin_user ): diff --git a/src/pbn_export_queue/tests/test_pbn_queue_send.py b/src/pbn_export_queue/tests/test_pbn_queue_send.py index 05a1c0751..4b36c2cdd 100644 --- a/src/pbn_export_queue/tests/test_pbn_queue_send.py +++ b/src/pbn_export_queue/tests/test_pbn_queue_send.py @@ -65,6 +65,33 @@ def test_send_to_pbn_record_deleted_but_already_finished( result = queue_item.send_to_pbn() assert result == SendStatus.FINISHED_OKAY + def test_send_to_pbn_forwards_entry_uczelnia( + self, wydawnictwo_ciagle, admin_user, uczelnia + ): + """send_to_pbn MUSI użyć uczelni zapisanej na wpisie kolejki, a nie + pozwolić warstwie niżej zgadywać przez get_default() (multi-hosted).""" + queue_item = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + uczelnia=uczelnia, + ) + + with ( + patch( + "bpp.admin.helpers.pbn_api.cli.sprobuj_wyslac_do_pbn_celery" + ) as mock_send, + patch.object( + PBN_Export_Queue, + "_handle_successful_send", + return_value=SendStatus.FINISHED_OKAY, + ), + ): + mock_send.return_value = (MagicMock(), []) + queue_item.send_to_pbn() + + assert mock_send.call_args.kwargs.get("uczelnia") == uczelnia + def test_send_to_pbn_statements_resend_failed_exception( self, wydawnictwo_ciagle, admin_user ): diff --git a/src/pbn_export_queue/tests/test_uczelnia_multihosted.py b/src/pbn_export_queue/tests/test_uczelnia_multihosted.py new file mode 100644 index 000000000..f15a04e16 --- /dev/null +++ b/src/pbn_export_queue/tests/test_uczelnia_multihosted.py @@ -0,0 +1,35 @@ +"""Testy multi-hosted dla kolejki eksportu PBN. + +Wpis kolejki niesie konkretną uczelnię (z entrypointu), żeby wysyłka w +tle użyła właściwej konfiguracji PBN zamiast zgadywać przez get_default(). +""" + +import pytest +from model_bakery import baker + +from bpp.models import Wydawnictwo_Ciagle +from pbn_export_queue.models import PBN_Export_Queue +from pbn_export_queue.tasks import queue_pbn_export_batch + + +@pytest.mark.django_db(transaction=True) +def test_queue_pbn_export_batch_stores_uczelnia(mocker, uczelnia): + """Batch zapisuje uczelnię (z entrypointu) na wpisach kolejki.""" + from django.contrib.auth import get_user_model + + User = get_user_model() + user = User.objects.create_user(username="batch_user", password="testpass") + record = baker.make(Wydawnictwo_Ciagle, tytul_oryginalny="Rec") + + mocker.patch("pbn_export_queue.tasks.task_sprobuj_wyslac_do_pbn.delay") + + queue_pbn_export_batch( + app_label="bpp", + model_name="wydawnictwo_ciagle", + record_ids=[record.id], + user_id=user.id, + uczelnia_id=uczelnia.pk, + ) + + entry = PBN_Export_Queue.objects.get(zamowil=user) + assert entry.uczelnia == uczelnia From a58524262d8db11a2e3751b79841527d8cd9f19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 16:10:09 +0200 Subject: [PATCH 042/247] =?UTF-8?q?fix(pbn):=20pobieranie=20os=C3=B3b=20bu?= =?UTF-8?q?duje=20klienta=20z=20w=C5=82a=C5=9Bciwej=20uczelni=20(multi-hos?= =?UTF-8?q?ted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit download_institution_people przekazywało token (string) do pobierz_ludzi_z_uczelni, a ten — dla argumentu-stringa — budował klienta PBN przez Uczelnia.objects.get_default() (zarówno przy pobieraniu listy osób, jak i w pobieraniu danych pojedynczej osoby). To ten sam bug multi-hosted, tylko warstwę niżej: klient powstawał z konfiguracji PBN pierwszej-z-brzegu uczelni. Teraz zadanie buduje klienta przez kanoniczną uczelnia.pbn_client() z KONKRETNEJ uczelni i podaje go jako obiekt (oraz uczelnia=uczelnia), dzięki czemu integrator nie sięga już po get_default(). Test pisany TDD (RED→GREEN): sprawdza, że klient powstaje z przekazanej uczelni i trafia do integratora jako obiekt, nie token. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pbn_downloader_app/tasks.py | 9 ++++- src/pbn_downloader_app/tests_tasks.py | 48 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/pbn_downloader_app/tasks.py b/src/pbn_downloader_app/tasks.py index e8f5586de..2342d8bd9 100644 --- a/src/pbn_downloader_app/tasks.py +++ b/src/pbn_downloader_app/tasks.py @@ -395,7 +395,14 @@ def update_people_progress(task, tqdm_self, desc): ): from pbn_integrator.utils import pobierz_ludzi_z_uczelni - pobierz_ludzi_z_uczelni(pbn_user.pbn_token, uczelnia.pbn_uid_id) + # Budujemy klienta z KONKRETNEJ uczelni i podajemy go jako obiekt + # (nie token) — inaczej integrator wewnętrznie sięgnąłby po + # get_default() przy tworzeniu klienta (multi-hosted bug). + pobierz_ludzi_z_uczelni( + uczelnia.pbn_client(pbn_user.pbn_token), + uczelnia.pbn_uid_id, + uczelnia=uczelnia, + ) task_record.current_step = "Finalizowanie pobierania osób..." task_record.progress_percentage = 95 diff --git a/src/pbn_downloader_app/tests_tasks.py b/src/pbn_downloader_app/tests_tasks.py index c4c830a4f..7cb54ed1b 100644 --- a/src/pbn_downloader_app/tests_tasks.py +++ b/src/pbn_downloader_app/tests_tasks.py @@ -318,6 +318,51 @@ def pbn_token_possibly_valid(self): assert "PBN UID" in str(exc_info.value) +@pytest.mark.django_db +def test_download_institution_people_passes_client_from_correct_uczelnia(uczelnia): + """download_institution_people MUSI zbudować klienta z PRZEKAZANEJ + uczelni i podać go (obiekt, nie token) do pobierz_ludzi_z_uczelni — + inaczej integrator wewnętrznie zbuduje klienta przez get_default() + (ten sam bug multi-hosted, warstwę niżej).""" + from bpp.models import Uczelnia + + user = User.objects.create_user("testuser", password="testpass") + + pbn_institution = baker.make("pbn_api.Institution") + uczelnia.pbn_uid = pbn_institution + uczelnia.pbn_app_name = "APP" + uczelnia.pbn_app_token = "TOK" + uczelnia.pbn_api_root = "https://x.example/" + uczelnia.save() + + class MockPbnUser: + pbn_token = "valid-token" + + def pbn_token_possibly_valid(self): + return True + + sentinel_client = MagicMock(name="pbn_client") + recorded_pk = [] + + def fake_pbn_client(self, token): + recorded_pk.append(self.pk) + return sentinel_client + + with patch("pbn_downloader_app.tasks.validate_pbn_user") as mock_validate: + mock_validate.return_value = (user, MockPbnUser()) + with patch("pbn_downloader_app.tasks.tqdm_progress_context"): + with patch.object(Uczelnia, "pbn_client", fake_pbn_client): + with patch( + "pbn_integrator.utils.pobierz_ludzi_z_uczelni" + ) as mock_pobierz: + download_institution_people(user.pk, uczelnia.pk) + + # klient zbudowany z właściwej uczelni i przekazany jako obiekt + assert recorded_pk == [uczelnia.pk] + first_arg = mock_pobierz.call_args.args[0] + assert first_arg is sentinel_client + + @pytest.mark.django_db def test_download_institution_people_success(uczelnia): """Test download_institution_people succeeds with mocked dependencies.""" @@ -325,6 +370,9 @@ def test_download_institution_people_success(uczelnia): pbn_institution = baker.make("pbn_api.Institution") uczelnia.pbn_uid = pbn_institution + uczelnia.pbn_app_name = "APP" + uczelnia.pbn_app_token = "TOK" + uczelnia.pbn_api_root = "https://x.example/" uczelnia.save() class MockPbnUser: From ececfd2d0a67a43f2511037c275d8ac007e7ec42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 19:16:17 +0200 Subject: [PATCH 043/247] docs(pbn): audyt multi-hosted i spec rozbicia PBNClient na warstwy Audyt call-site'ow PBN/get_default (3 tiery ryzyka) + audyt wewnetrzny PBNClient (kontrakt 3 flag, podzial czyste/BPP). Spec: split na pbn_client (Warstwa 1, reusable) + pbn_client_bpp (Warstwa 2, BPP-aware), Poziom 1 (klient + adaptery, modele zostaja w pbn_api). Co-Authored-By: Claude Opus 4.8 --- docs/deweloper/audyt-multihosted-pbn.md | 154 +++++++++++ .../2026-06-02-pbn-client-split-design.md | 256 ++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 docs/deweloper/audyt-multihosted-pbn.md create mode 100644 docs/superpowers/specs/2026-06-02-pbn-client-split-design.md diff --git a/docs/deweloper/audyt-multihosted-pbn.md b/docs/deweloper/audyt-multihosted-pbn.md new file mode 100644 index 000000000..7c1a92799 --- /dev/null +++ b/docs/deweloper/audyt-multihosted-pbn.md @@ -0,0 +1,154 @@ +# 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ć. + +To osobny, stopniowy wątek — nie wchodzi w zakres splitu `pbn_client`, poza tym +że split sam z siebie usuwa `get_default` z `publication_sync` i adaptera. diff --git a/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md b/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md new file mode 100644 index 000000000..89fbda094 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md @@ -0,0 +1,256 @@ +# Spec: rozbicie `PBNClient` na warstwę reusable + warstwę BPP + +Data: 2026-06-02. Gałąź bazowa: `feature/multi-hosted-config`. +Powiązany audyt: `docs/deweloper/audyt-multihosted-pbn.md`. + +## Problem + +`pbn_api.client.PBNClient` jest dziś **mieszanką dwóch odpowiedzialności**: + +1. cienka warstwa HTTP nad REST API PBN (transport, auth, 8 mixinów + słownikowo-CRUD, silnik diff/DELETE/POST oświadczeń) — **w 100% czyste PBN, + zero importów `bpp`**; +2. orchestracja synchronizacji BPP↔PBN (`publication_sync.py`, 44 KB + + `DisciplinesMixin`), która importuje `bpp.models.Uczelnia`/`Rekord`, czyta + flagi uczelni, woła adaptery i — co gorsza — **`Uczelnia.objects.get_default()` + wewnątrz klienta** (`publication_sync.py:287, 1046`; `adapters/wydawnictwo.py:94`). + +Konsekwencja dla multi-hosted: `PBNClient` nie zna swojej `Uczelnia`, więc +logika w środku zgaduje ją przez `get_default()` (pierwsza z brzegu) — czyta +flagi/sterowanie payloadem z **niewłaściwej** uczelni. + +## Cel + +Rozciąć klienta dokładnie po granicy odpowiedzialności na dwa pakiety: + +- **`src/pbn_client/`** — Warstwa 1, reusable, **kandydat do ekstrakcji jako + osobny pakiet PyPI w przyszłości**. Wie tylko o pojęciach PBN: tokeny, URL-e, + PBN UID instytucji (goła wartość), JSON-y, flagi `bool`. **Nie wolno jej + importować `bpp.models`** (ani niczego z projektu poza `pbn_api.const`-owym + odpowiednikiem, który też się przenosi). +- **`src/pbn_client_bpp/`** — Warstwa 2, „nasza", BPP-aware. Klasa + `BppPBNClient(uczelnia)` budowana **z** obiektu `Uczelnia`; trzyma + orchestrację, adaptery (most rekord BPP → PBN JSON) i odczyt flag uczelni. + **Tu — i tylko tu — żyje wiedza o `Uczelnia`.** `get_default()` znika + z podsystemu. + +Po splicie pytanie „czy `PBNClient` potrzebuje uczelni" znika: czysty klient +nigdy jej nie zna, `BppPBNClient` zawsze ma ją z konstrukcji. + +## Trójpodział odpowiedzialności (cel) + +Dziś `pbn_api` to worek z trzema rolami. Split rozdziela je po naturalnych +szwach: + +- **`pbn_client`** — *protokół* PBN (reusable, do ekstrakcji). Bez Django-modeli. + Najczystszy, ekstrahowalny pierwszy. +- **`pbn_api`** — *dane domenowe PBN*: lustro encji PBN (Publication, + Institution, Journal, Scientist, Publisher, Conference, Discipline, + Language, Country) + słowniki + admin do przeglądania. **Własny + reusable-kandydat** — ekstrahowalny **po** odseparowaniu resztkowego + sklejenia z BPP (osobny przyszły tor). +- **`pbn_client_bpp`** — *logika integracji* BPP↔PBN: `BppPBNClient` + + orchestracja + **adaptery** (rekord BPP → PBN JSON). Wie o `Uczelnia`. + Z natury projektowy (klej), nie-reusable. + +Zakres tego speca to **Poziom 1**: split klienta + przeniesienie `adapters/` +do `pbn_client_bpp`. **Modele zostają w `pbn_api`** — to ich właściwy dom +(dane PBN), nie materiał dla warstwy kleju. Nie ma „Poziomu 2 = przenieś +modele do `pbn_client_bpp`" — to byłby zły kierunek. + +### Nalecialości BPP w `pbn_api` (osobny przyszły tor, nie ten spec) + +Sklejenie modeli `pbn_api` z BPP jest realne, ale skoncentrowane: + +- **Twarde FK** (na modelach „instytucji/wysyłki"): `uczelnia` FK + (`oswiadczenie_instytucji`, `osoba_z_instytucji`, `publikacja_instytucji`, + `sentdata`), `content_type`→`Rekord` GenericFK (`sentdata`, + `pbn_odpowiedzi_niepozadane`), `dyscyplina_w_bpp` OneToOne + (`tlumacz_dyscyplin`). +- **Miękkie**: behawioralny `LinkDoPBNMixin` + metody `matchuj_*` z leniwymi + importami `from bpp.models...`. + +Izolacja tego (np. przeniesienie modeli „wysyłki" jak `SentData` do warstwy +projektowej, wstrzykiwanie matchera) to warunek ekstrakcji `pbn_api` — ale +osobny, późniejszy wątek, poza tym specem. + +## Architektura docelowa + +### `src/pbn_client/` (Warstwa 1, reusable) + +``` +src/pbn_client/ + __init__.py # eksport PBNClient + publiczne nazwy + client.py # PBNClient = kompozycja CZYSTYCH mixinów + transport.py # RequestsTransport, PBNClientTransport + auth.py # OAuthMixin + pagination.py + utils.py + const.py # URL-e, komunikaty PBN (z pbn_api/const.py — część PBN-owa) + exceptions.py # wyjątki PBN (z pbn_api/exceptions.py) + conf.py # ustawienia PBN (PBN_CLIENT_*), bez sięgania do bpp + statements.py # CZYSTY StatementsMixin wyjęty z publication_sync + mixins/ # mixiny słownikowo-CRUD (8 plików, 9 klas) +``` + +`PBNClient` przyjmuje tokeny / PBN UID-y (string) / JSON-y / flagi `bool`, +zwraca JSON. Testowalny **bez bazy** (mock transport). + +`statements.py` to **mixin** (`StatementsMixin`), nie luźne funkcje — woła +`self.transport` i `self.delete_publication_statement`/`get_institution_statements` +(z `InstitutionsProfileMixin`), więc musi być wmieszany w `PBNClient` przez MRO +(zależy od `InstitutionsProfileMixin`, też w W1). Z `publication_sync` +przenoszone tu: `_diff_statements`, `_statement_key_*`, `_convert_stmt_for_api`, +`_delete_statements_{with_retry,selective,batch}`, `_get_pbn_statements_with_retry`, +`convert_json_with_statements_to_no_statements`, `_post_publication_data`, +`post_publication{,_no_statements}`, `post_publication_fee`, `get_publication_fee{,s_batch}`. + +### `src/pbn_client_bpp/` (Warstwa 2, BPP-aware) + +``` +src/pbn_client_bpp/ + __init__.py + client.py # BppPBNClient(PBNClient) — patrz niżej + publication_sync.py# orchestracja: sync_publication, upload_publication, ... + disciplines.py # sync_disciplines (BPP-aware) + adapters/ # PRZENIESIONE z pbn_api: rekord BPP → PBN JSON + # modele persystencji (Publication, SentData, ...) zostają w pbn_api, + # importowane lokalnie w metodach (mniejszy churn, brak migracji) +``` + +Przeniesienie `adapters/` (Poziom 1): ~5 call-site'ów do aktualizacji importu +(`pbn_wysylka_oswiadczen/tasks.py`, `pbn_api/management/commands/` +`{pbn_show_json, pbn_test_wysylka_interaktywna, pbn_wyslij_oswiadczenia_instytucji}.py`, +oraz wewnętrzny `publication_sync`). Czysty kod — **bez migracji**. Domyka fix +`adapters/wydawnictwo.py:94` (`get_default` → `uczelnia` podane przez +`BppPBNClient`). Dla kompatybilności wstecznej zostaje cienki re-eksport w +`pbn_api/adapters/__init__.py`. + +`BppPBNClient` **dziedziczy** po `PBNClient` (a nie kompozycja), bo call-site'y +wołają na tym samym obiekcie i metody czyste (`get_journals`), i orchestrację +(`sync_publication`). Dziedziczenie = zero delegacji ~50 metod. + +```python +class BppPBNClient( + PBNClient, # czyste metody HTTP (W1) + PublicationSyncOrchestrationMixin, + DisciplinesBppMixin, +): + def __init__(self, transport, uczelnia): + super().__init__(transport) + self.uczelnia = uczelnia # JEDYNE źródło prawdy o uczelni +``` + +Orchestracja czyta flagi z `self.uczelnia` (nie `get_default()`) i przekazuje +je do czystych metod W1 jako gołe boole. + +## Kontrakt W2 → W1 + +Audyt potwierdził, że granica jest wąska. Czyste metody W1 dla sync publikacji +przyjmują: + +``` +(pbn_publication_json: dict, + statements_intended: list[dict], + pbn_uid: str, + kasuj_selektywnie: bool, + bez_oswiadczen: bool) +``` + +W1 **nigdy** nie widzi `Uczelnia`, `rec` (rekordu BPP) ani `get_default`. +Trzy flagi uczelni (`pbn_kasuj_dyscypliny_selektywnie`, `pbn_wysylaj_bez_oswiadczen`, +`pbn_api_nie_wysylaj_prac_bez_pk`) odczytuje W2 i podaje jako parametry. + +## Fabryka i kompatybilność wsteczna + +- `Uczelnia.pbn_client(token)` buduje transport i zwraca + `BppPBNClient(transport, uczelnia=self)`. To usuwa `get_default()` + z `publication_sync.py:287/1046` i `adapters/wydawnictwo.py:94` — + uczelnia jest jawna. **Import `BppPBNClient` musi być lokalny w metodzie** + (cykl `bpp.models.uczelnia → pbn_client_bpp → adapters → bpp.models`). +- `pbn_api.client` zostaje **shimem re-eksportującym CAŁY dotychczasowy + publiczny zestaw** (`__all__`): `PBNClient` (z `pbn_client`), `BppPBNClient` + (z `pbn_client_bpp`), `OAuthMixin`, wszystkie 9 klas mixinów, + `RequestsTransport`, `PBNClientTransport`, `PageableResource`, `smart_content` + oraz stałe `PBN_*`/`DEFAULT_BASE_URL`/`NEEDS_PBN_AUTH_MSG`. Inaczej pękną + importy typu `from pbn_api.client import RequestsTransport`. 35 importów + `PBNClient` nie pęka; adnotacje `client: PBNClient = uczelnia.pbn_client()` + pozostają poprawne (`BppPBNClient` *is-a* `PBNClient`). +- `pbn_api/.../util.py` `PBNBaseCommand.get_client(...)` zwraca `BppPBNClient` + zbudowany z uczelni rozwiązanej przez `_resolve_uczelnia` (guard `count==1`). + Pokrywa CLI-owe `sync_disciplines` (`pbn_integrator.py:182`). + +### Call-site'y do migracji (z audytu: 6 orchestracji + fabryki) + +`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` — wszystkie dostają +klienta z fabryki (`Uczelnia.pbn_client` / `get_client`), więc po zmianie +fabryki działają bez modyfikacji, otrzymując poprawną uczelnię „za darmo". + +## Przepływ danych (upload publikacji, po splicie) + +``` +Widok/zadanie ──get_for_request/uczelnia_id──▶ Uczelnia + Uczelnia.pbn_client(token) ──▶ BppPBNClient(transport, uczelnia) + BppPBNClient.upload_publication(rec) [W2] + ├─ WydawnictwoPBNAdapter(rec, uczelnia=self.uczelnia).pbn_get_json() + ├─ flagi = (self.uczelnia.pbn_kasuj_dyscypliny_selektywnie, ...) + └─ self.post_publication(json, pbn_uid, *flagi) [W1, czyste HTTP] +``` + +## INSTALLED_APPS / pakiet Django + +- `pbn_client` to **czysta biblioteka** (bez modeli/migracji) — nie musi być + appką Django ani trafiać do `INSTALLED_APPS`. Konfiguracja PBN przez własny + `conf.py` (czyta Django `settings`, ale nie modele). +- `pbn_client_bpp` **dodajemy do `INSTALLED_APPS`** z własnym `apps.py` + (`AppConfig`) — może mieć szablony/adminy/testy; modele na razie zostają + w `pbn_api`. +- **Testy:** istniejące `pbn_api/tests/test_client*.py` zostają, importując + przez shim `pbn_api.client` — dzięki temu „zielone" przez całą migrację. + Relokacja testów czystych do `pbn_client/tests/` jest opcjonalna i późniejsza. +- **Baseline:** przed i po KAŻDEJ fazie odpalać celowany podzbiór + (`uv run pytest src/pbn_api/tests/ src/pbn_integrator/tests/ -p no:cacheprovider`) + jako bramkę regresji, niezależnie od pełnego suite'u. + +## Ryzyka i mitygacje + +| Ryzyko | Mitygacja | +|---|---| +| Cykl importów `pbn_client_bpp` ↔ `pbn_api` (modele/adaptery) | Importy lokalne w metodach (jak dziś w `publication_sync.py`) | +| Pęknięcie 35 importów `pbn_api.client.PBNClient` | Shim re-eksportujący w `pbn_api/client/__init__.py` | +| CLI `get_client` → orchestracja na czystym `PBNClient` (brak metod) | `get_client` zwraca `BppPBNClient` z `_resolve_uczelnia` | +| Regresja w cięciu `statements.py` | Najpierw wydzielić W1 z re-eksportem i **zielonymi testami**, dopiero potem wyciąć orchestrację | +| Brak pokrycia multi-hosted | Dodać fixture `dwie_uczelnie` + test: właściwy `pbn_app_token` w transporcie i flagi z właściwej uczelni | + +## Plan etapowy (kolejność krytyczna) + +1. **W1 bez ruszania zachowania:** utwórz `src/pbn_client/`, przenieś czyste + moduły (transport, auth, pagination, utils, const/exceptions/conf — część + PBN, 8 mixinów). `pbn_api.client` re-eksportuje. Zielone testy. +2. **Wytnij `statements.py`** (czysty silnik) z `publication_sync.py` do W1. + Zielone testy. +3. **W2:** utwórz `src/pbn_client_bpp/` z `BppPBNClient(PBNClient)` + + orchestracją (BPP-owe części `publication_sync` + `DisciplinesMixin`). + `get_default()` w orchestracji zastąpiony przez `self.uczelnia`. +4. **Przenieś `adapters/`** z `pbn_api` do `pbn_client_bpp`; zaktualizuj ~5 + importów; re-eksport w `pbn_api/adapters/__init__.py`. Adapter dostaje + `uczelnia=self.uczelnia` z `BppPBNClient`; `get_default()` z adaptera + usunięty. Zielone testy. +5. **Fabryki:** `Uczelnia.pbn_client()` i `get_client()` zwracają + `BppPBNClient`. Shim re-eksportuje `BppPBNClient`. +6. **Fixture + testy multi-hosted.** Weryfikacja, że właściwa uczelnia steruje + payloadem (token w transporcie + 3 flagi + adapter). +7. **(poza tym specem)** pozostałe znaleziska audytu Tier 🔴/🟠 nie-PBN + (ORCID, `importer_publikacji/providers/pbn.py`, `importer_autorow_pbn`) oraz + wątek `get_default` jako follow-up. + +## Poza zakresem + +- Szeroki refaktor `get_default` (osobny, **następny** wątek; patrz audyt). +- Izolacja nalecialości BPP w `pbn_api` (FK `uczelnia`/`Rekord`, `matchuj_*`, + `LinkDoPBNMixin`) — warunek ekstrakcji `pbn_api`, osobny przyszły tor. + Modele **nie** są przenoszone do `pbn_client_bpp`. +- Fizyczna ekstrakcja `pbn_client` / `pbn_api` do osobnych repo/PyPI (dopiero + gdy warstwy są stabilne i odseparowane). From cf27921cb97c8200643340a78d1ecca35bb4b6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 19:16:17 +0200 Subject: [PATCH 044/247] refactor(pbn): Faza 1 - wydziel czysta warstwe pbn_client (Warstwa 1) Przenosi czyste moduly protokolu PBN (transport, auth, pagination, utils, const, exceptions, conf, mixiny) z pbn_api do nowego pakietu src/pbn_client, bez zaleznosci od bpp ani pbn_api. Transparentne shimy w pbn_api (const, exceptions, client/transport) zachowuja kompatybilnosc wsteczna. PBNClient na razie zostaje w pbn_api/client (trzyma jeszcze orchestracje). Co-Authored-By: Claude Opus 4.8 --- src/pbn_api/client/__init__.py | 17 +- src/pbn_api/client/transport.py | 316 +----------------- src/pbn_api/const.py | 35 +- src/pbn_api/exceptions.py | 191 +---------- src/pbn_api/management/commands/util.py | 2 +- src/pbn_client/__init__.py | 41 +++ src/{pbn_api/client => pbn_client}/auth.py | 6 +- src/{pbn_api => pbn_client}/conf/__init__.py | 0 src/{pbn_api => pbn_client}/conf/settings.py | 0 src/pbn_client/const.py | 33 ++ src/pbn_client/exceptions.py | 189 +++++++++++ .../client => pbn_client}/mixins/__init__.py | 0 .../mixins/conferences.py | 0 .../mixins/dictionaries.py | 2 +- .../mixins/institutions.py | 4 +- .../client => pbn_client}/mixins/journals.py | 2 +- .../client => pbn_client}/mixins/person.py | 0 .../mixins/publications.py | 2 +- .../mixins/publishers.py | 0 .../client => pbn_client}/mixins/search.py | 2 +- .../client => pbn_client}/pagination.py | 0 src/pbn_client/transport.py | 314 +++++++++++++++++ src/{pbn_api/client => pbn_client}/utils.py | 0 23 files changed, 608 insertions(+), 548 deletions(-) create mode 100644 src/pbn_client/__init__.py rename src/{pbn_api/client => pbn_client}/auth.py (94%) rename src/{pbn_api => pbn_client}/conf/__init__.py (100%) rename src/{pbn_api => pbn_client}/conf/settings.py (100%) create mode 100644 src/pbn_client/const.py create mode 100644 src/pbn_client/exceptions.py rename src/{pbn_api/client => pbn_client}/mixins/__init__.py (100%) rename src/{pbn_api/client => pbn_client}/mixins/conferences.py (100%) rename src/{pbn_api/client => pbn_client}/mixins/dictionaries.py (83%) rename src/{pbn_api/client => pbn_client}/mixins/institutions.py (98%) rename src/{pbn_api/client => pbn_client}/mixins/journals.py (94%) rename src/{pbn_api/client => pbn_client}/mixins/person.py (100%) rename src/{pbn_api/client => pbn_client}/mixins/publications.py (94%) rename src/{pbn_api/client => pbn_client}/mixins/publishers.py (100%) rename src/{pbn_api/client => pbn_client}/mixins/search.py (80%) rename src/{pbn_api/client => pbn_client}/pagination.py (100%) create mode 100644 src/pbn_client/transport.py rename src/{pbn_api/client => pbn_client}/utils.py (100%) diff --git a/src/pbn_api/client/__init__.py b/src/pbn_api/client/__init__.py index 13fa89cb4..1c52bb2cb 100644 --- a/src/pbn_api/client/__init__.py +++ b/src/pbn_api/client/__init__.py @@ -9,9 +9,11 @@ from collections.abc import Iterable from pprint import pprint +from pbn_client.auth import OAuthMixin + # Re-export constants for backwards compatibility # (previously these were importable from pbn_api.client) -from pbn_api.const import ( +from pbn_client.const import ( DEFAULT_BASE_URL, NEEDS_PBN_AUTH_MSG, PBN_DELETE_PUBLICATION_STATEMENT, @@ -27,10 +29,7 @@ PBN_POST_PUBLICATIONS_URL, PBN_SEARCH_PUBLICATIONS_URL, ) - -from .auth import OAuthMixin -from .disciplines import DisciplinesMixin -from .mixins import ( +from pbn_client.mixins import ( ConferencesMixin, DictionariesMixin, InstitutionsMixin, @@ -41,10 +40,12 @@ PublishersMixin, SearchMixin, ) -from .pagination import PageableResource +from pbn_client.pagination import PageableResource +from pbn_client.transport import PBNClientTransport, RequestsTransport +from pbn_client.utils import smart_content + +from .disciplines import DisciplinesMixin from .publication_sync import PublicationSyncMixin -from .transport import PBNClientTransport, RequestsTransport -from .utils import smart_content __all__ = [ # Client classes diff --git a/src/pbn_api/client/transport.py b/src/pbn_api/client/transport.py index f1ac5b6a2..05f18b2ad 100644 --- a/src/pbn_api/client/transport.py +++ b/src/pbn_api/client/transport.py @@ -1,314 +1,6 @@ -"""HTTP transport layer for PBN API client.""" +"""Shim kompatybilnościowy — transport przeniesiony do ``pbn_client.transport``. -import logging -import random -import time -import warnings -from urllib.parse import quote +Patrz: docs/superpowers/specs/2026-06-02-pbn-client-split-design.md +""" -import requests -import rollbar -from requests import ConnectionError -from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError -from requests.exceptions import SSLError -from simplejson.errors import JSONDecodeError - -from pbn_api.const import DEFAULT_BASE_URL -from pbn_api.exceptions import ( - AccessDeniedException, - HttpException, - NeedsPBNAuthorisationException, - PraceSerwisoweException, - ResourceLockedException, -) - -from .auth import OAuthMixin -from .pagination import PageableResource -from .utils import smart_content - -logger = logging.getLogger(__name__) - - -class PBNClientTransport: - """Base transport class for PBN API communication.""" - - def __init__(self, app_id, app_token, base_url, user_token=None): - self.app_id = app_id - self.app_token = app_token - - self.base_url = base_url - if self.base_url is None: - self.base_url = DEFAULT_BASE_URL - - self.access_token = user_token - - -class RequestsTransport(OAuthMixin, PBNClientTransport): - """HTTP transport implementation using requests library.""" - - def _build_headers(self, headers=None): - """Build headers for API request.""" - sent_headers = {"X-App-Id": self.app_id, "X-App-Token": self.app_token} - if self.access_token: - sent_headers["X-User-Token"] = self.access_token - if headers is not None: - sent_headers.update(headers) - return sent_headers - - def _make_get_request_with_retry(self, url, headers, max_retries=15): - """Make GET request with retry on SSL/Connection errors.""" - from pbn_api.conf import settings as pbn_settings - - retries = 0 - while retries < max_retries: - try: - return requests.get( - self.base_url + url, - headers=headers, - timeout=pbn_settings.PBN_CLIENT_HTTP_TIMEOUT, - ) - except (SSLError, ConnectionError) as e: - retries += 1 - time.sleep(random.randint(1, 5)) - if retries >= max_retries: - raise e - - def _handle_403_response(self, ret, url, headers, fail_on_auth_missing): - """Handle 403 response, attempting reauthorization if needed.""" - if fail_on_auth_missing: - raise AccessDeniedException(url, smart_content(ret.content)) - - if ret.json()["message"] in ["Access Denied", "Forbidden"]: - raise AccessDeniedException(url, smart_content(ret.content)) - - if hasattr(self, "authorize"): - auth_result = self.authorize(self.base_url, self.app_id, self.app_token) - if not auth_result: - return None - return self.get(url, headers, fail_on_auth_missing=True) - - return ret - - def _parse_json_response(self, ret, url): - """Parse JSON response with special handling for service maintenance.""" - try: - return ret.json() - except (RequestsJSONDecodeError, JSONDecodeError) as e: - if ret.status_code == 200 and b"prace serwisowe" in ret.content: - raise PraceSerwisoweException() from e - raise e - - def get(self, url, headers=None, fail_on_auth_missing=False): - sent_headers = self._build_headers(headers) - ret = self._make_get_request_with_retry(url, sent_headers) - - if ret.status_code == 403: - result = self._handle_403_response(ret, url, headers, fail_on_auth_missing) - if result is None: - return - if result != ret: - return result - - if ret.status_code >= 400: - raise HttpException(ret.status_code, url, smart_content(ret.content)) - - return self._parse_json_response(ret, url) - - def _ensure_access_token(self): - """Ensure access token is available.""" - if not hasattr(self, "access_token"): - return self.authorize(self.base_url, self.app_id, self.app_token) - return True - - def _build_post_headers(self, headers=None): - """Build headers for POST request.""" - sent_headers = { - "X-App-Id": self.app_id, - "X-App-Token": self.app_token, - "X-User-Token": self.access_token, - } - if headers is not None: - sent_headers.update(headers) - return sent_headers - - def _get_request_method(self, delete): - """Get appropriate HTTP method.""" - return requests.delete if delete else requests.post - - def _parse_403_response(self, ret, url): - """Parse 403 response JSON.""" - try: - return ret.json() - except BaseException as e: - raise HttpException( - ret.status_code, - url, - "Blad podczas odkodowywania JSON podczas odpowiedzi 403: " - + smart_content(ret.content), - ) from e - - def _handle_403_access_denied(self, ret_json, ret, url): - """Handle 403 Access Denied responses.""" - from pbn_api.const import NEEDS_PBN_AUTH_MSG - - if ret_json.get("message") == "Access Denied": - raise AccessDeniedException(url, smart_content(ret.content)) - - if ret_json.get("message") == "Forbidden" and ret_json.get( - "description", "" - ).startswith(NEEDS_PBN_AUTH_MSG): - raise NeedsPBNAuthorisationException( - ret.status_code, url, smart_content(ret.content) - ) - - if hasattr(self, "authorize"): - self.authorize(self.base_url, self.app_id, self.app_token) - - def _check_error_response(self, ret, url): - """Check and handle error responses.""" - if ret.status_code >= 400: - if ret.status_code == 423 and smart_content(ret.content) == "Locked": - raise ResourceLockedException( - ret.status_code, url, smart_content(ret.content) - ) - # Diagnostyka: logger.error dla widoczności w konsoli/plikach - # logów, rollbar.report_message dla zdalnego trackingu (oba przy - # każdym 4xx/5xx — szczegóły body i headers przydają się przy - # debugowaniu enigmatycznych odpowiedzi typu „400 Bad Request" - # bez body). - logger.error( - "PBN %s on %s: headers=%r body_len=%d body=%r", - ret.status_code, - url, - dict(ret.headers), - len(ret.content), - ret.content[:4000], - ) - rollbar.report_message( - f"PBN {ret.status_code} on {url}", - level="error" if ret.status_code >= 500 else "warning", - extra_data={ - "status_code": ret.status_code, - "url": url, - "headers": dict(ret.headers), - "body_len": len(ret.content), - "body": smart_content(ret.content[:4000]), - }, - ) - raise HttpException(ret.status_code, url, smart_content(ret.content)) - - def post(self, url, headers=None, body=None, delete=False): - if not self._ensure_access_token(): - return - if not hasattr(self, "access_token"): - return self.post(url, headers=headers, body=body, delete=delete) - - from pbn_api.conf import settings as pbn_settings - - sent_headers = self._build_post_headers(headers) - method = self._get_request_method(delete) - ret = method( - self.base_url + url, - headers=sent_headers, - json=body, - timeout=pbn_settings.PBN_CLIENT_HTTP_TIMEOUT, - ) - - if ret.status_code == 403: - ret_json = self._parse_403_response(ret, url) - self._handle_403_access_denied(ret_json, ret, url) - - self._check_error_response(ret, url) - - try: - return ret.json() - except (RequestsJSONDecodeError, JSONDecodeError) as e: - if ret.status_code == 200: - if ret.content == b"": - return - - if b"prace serwisowe" in ret.content: - raise PraceSerwisoweException() from e - - raise e - - def delete( - self, - url, - headers=None, - body=None, - ): - return self.post(url, headers, body, delete=True) - - def _pages(self, method, url, headers=None, body=None, page_size=10, *args, **kw): - # Stronicowanie zwraca rezultaty w taki sposób: - # {'content': [{'mongoId': '5e709189878c28a04737dc6f', - # 'status': 'ACTIVE', - # ... - # 'versionHash': '---'}]}], - # 'first': True, - # 'last': False, - # 'number': 0, - # 'numberOfElements': 10, - # 'pageable': {'offset': 0, - # 'pageNumber': 0, - # 'pageSize': 10, - # 'paged': True, - # 'sort': {'sorted': False, 'unsorted': True}, - # 'unpaged': False}, - # 'size': 10, - # 'sort': {'sorted': False, 'unsorted': True}, - # 'totalElements': 68577, - # 'totalPages': 6858} - - chr = "?" - if url.find("?") >= 0: - chr = "&" - - url = url + f"{chr}size={page_size}" - chr = "&" - - for elem in kw: - url += chr + elem + "=" + quote(kw[elem]) - - method_function = getattr(self, method) - - if method == "get": - res = method_function(url, headers) - elif method == "post": - res = method_function(url, headers, body=body) - else: - raise NotImplementedError - - if "pageable" not in res: - warnings.warn( - f"PBNClient.{method}_page request for {url} with headers {headers} " - f"did not return a paged resource, " - f"maybe use PBNClient.{method} (without 'page') instead", - RuntimeWarning, - stacklevel=2, - ) - return res - return PageableResource( - self, res, url=url, headers=headers, body=body, method=method - ) - - def get_pages(self, url, headers=None, page_size=10, *args, **kw): - return self._pages( - "get", *args, url=url, headers=headers, page_size=page_size, **kw - ) - - def post_pages(self, url, headers=None, body=None, page_size=10, *args, **kw): - # Jak get_pages, ale methoda to post - if body is None: - body = kw - - return self._pages( - "post", - *args, - url=url, - headers=headers, - body=body, - page_size=page_size, - **kw, - ) +from pbn_client.transport import * # noqa: F401,F403 diff --git a/src/pbn_api/const.py b/src/pbn_api/const.py index d5a206e62..0fc1849cc 100644 --- a/src/pbn_api/const.py +++ b/src/pbn_api/const.py @@ -1,33 +1,6 @@ -DELETED = "DELETED" -ACTIVE = "ACTIVE" +"""Shim kompatybilnościowy — stałe przeniesione do ``pbn_client.const``. -PBN_POST_PUBLICATIONS_URL = "/api/v1/publications" +Patrz: docs/superpowers/specs/2026-06-02-pbn-client-split-design.md +""" -PBN_POST_PUBLICATION_NO_STATEMENTS_URL = "/api/v1/repositorium/publications" - -PBN_POST_PUBLICATION_FEE_URL = "/api/v1/institutionProfile/publications/fees/{id}" - -PBN_GET_LANGUAGES_URL = "/api/v1/dictionary/languages" -PBN_SEARCH_PUBLICATIONS_URL = "/api/v1/search/publications" - -PBN_GET_JOURNAL_BY_ID = "/api/v1/journals/{id}" - -DEFAULT_BASE_URL = "https://pbn-micro-alpha.opi.org.pl" - -NEEDS_PBN_AUTH_MSG = ( - "W celu poprawnej autentykacji należy podać poprawny token użytkownika aplikacji." -) -PBN_DELETE_PUBLICATION_STATEMENT = ( - "/api/v1/institutionProfile/publications/{publicationId}" -) -PBN_GET_PUBLICATION_BY_ID_URL = "/api/v1/publications/id/{id}" - -PBN_GET_INSTITUTION_STATEMENTS = ( - "/api/v1/institutionProfile/publications/page/statements" -) - -PBN_GET_DISCIPLINES_URL = "/api/v2/dictionary/disciplines" - -PBN_POST_INSTITUTION_STATEMENTS_URL = "/api/v2/institution-profile/statements" - -PBN_GET_INSTITUTION_PUBLICATIONS_V2 = "/api/v2/institution-profile/publications" +from pbn_client.const import * # noqa: F401,F403 diff --git a/src/pbn_api/exceptions.py b/src/pbn_api/exceptions.py index 074f7d39b..1ae6f13f4 100644 --- a/src/pbn_api/exceptions.py +++ b/src/pbn_api/exceptions.py @@ -1,189 +1,6 @@ -import json +"""Shim kompatybilnościowy — wyjątki przeniesione do ``pbn_client.exceptions``. +Patrz: docs/superpowers/specs/2026-06-02-pbn-client-split-design.md +""" -class AlreadyEnqueuedError(Exception): - pass - - -class CharakterFormalnyNieobslugiwanyError(Exception): - pass - - -class TlumaczDyscyplinException(ValueError): - pass - - -class BrakZdefiniowanegoObiektuUczelniaWSystemieError(Exception): - pass - - -class PraceSerwisoweException(Exception): - def __str__(self): - return "Po stronie PBN trwają prace serwisowe. Prosimy spróbować później. " - - -class CannotDeleteStatementsException(Exception): - pass - - -class HttpException(Exception): - def __init__(self, status_code, url, content): - self.status_code = status_code - self.url = url - self.content = content - try: - self.json = json.loads(content[:4096]) - except (json.JSONDecodeError, ValueError, TypeError): - self.json = None - - -class ResourceLockedException(HttpException): - pass - - -class AccessDeniedException(Exception): - def __init__(self, url, content): - self.url = url - self.content = content - - -class BrakIDPracyPoStroniePBN(HttpException): - pass - - -class SciencistDoesNotExist(Exception): - pass - - -class AuthenticationConfigurationError(Exception): - pass - - -class AuthenticationResponseError(Exception): - pass - - -class IntegracjaWylaczonaException(Exception): - pass - - -class SameDataUploadedRecently(Exception): - pass - - -class WillNotExportError(Exception): - pass - - -class DOIorWWWMissing(WillNotExportError): - pass - - -class LanguageMissingPBNUID(WillNotExportError): - pass - - -class StatementsMissing(WillNotExportError): - pass - - -class PKZeroExportDisabled(WillNotExportError): - pass - - -class CharakterFormalnyMissingPBNUID(WillNotExportError): - pass - - -class StatementDeletionError(Exception): - def __init__(self, status_code, url, content): - self.status_code = status_code - self.url = url - self.content = content - - -class NeedsPBNAuthorisationException(HttpException): - pass - - -class NoFeeDataException(ValueError): - pass - - -class NoPBNUIDException(ValueError): - pass - - -class CannotUploadPublicationFee(ValueError): - """Raised when PBN server indicates that publication is not subject to fee requirements.""" - - pass - - -class PublicationDoesNotExistInInstitutionProfile(ValueError): - """Raised when publication does not exist or is not in the institution profile.""" - - pass - - -class PBNUIDChangedException(ValueError): - """Podnoszony w sytuacji gdy wysłanej pracy która już posiada PBN UID należałoby zmienić PBN UID na inny - na skutek odpowiedzi serwera. Technicznie nie jest to błąd i ten PBN UID jest ustawiany. Ten Exception - jest używany przez Sentry do zgłoszenia (wysłania) sytuacji.""" - - -class PBNUIDSetToExistentException(ValueError): - """Podnoszony gdy wg serwera PBN pracy nowo wysyłanej nalezałoby ustawić PBN UID - istniejącego rekordu. Używany do wysłania przez Sentry zgłoszenia o sytuacji.""" - - -class DaneLokalneWymagajaAktualizacjiException(Exception): - """Podnoszony, gdy lokalne dane powinny zostać zaktualizowane, aby odzwierciedlać - zmiany po stronie PBN.""" - - -class PublikacjaInstytucjiV2NieZnalezionaException(Exception): - """Publikacja instytucji nie znaleziona po ID w api V2""" - - -class ZnalezionoWielePublikacjiInstytucjiV2Exception(Exception): - pass - - -class BPPPublicationNotFound(Exception): - """Publikacja z PBN nie ma odpowiednika w BPP.""" - - pass - - -class BPPAutorNotFound(Exception): - """Naukowiec z PBN nie ma odpowiednika w BPP.""" - - pass - - -class BPPAutorPublicationLinkNotFound(Exception): - """Autor istnieje w BPP, publikacja istnieje w BPP, - ale autor nie jest powiązany z tą publikacją.""" - - pass - - -class StatementsResendFailedException(Exception): - """Podnoszony gdy synchronizacja oświadczeń z PBN nie powiodła się - po wyczerpaniu prób retry w ``sync_publication`` (GET/DELETE/POST). - - Publikacja została już wysłana do PBN (POST do endpointu repo OK), - ale kolejne kroki synchronizacji oświadczeń zawiodły. Klasyfikowany - w ``pbn_export_queue`` jako RETRY_LATER + TECHNICZNY. - """ - - def __init__(self, publication_pk, pbn_uid, last_error): - self.publication_pk = publication_pk - self.pbn_uid = pbn_uid - self.last_error = last_error - super().__init__( - f"Synchronizacja oświadczeń dla pracy pk={publication_pk} " - f"(PBN UID={pbn_uid}) nie powiodła się po wyczerpaniu prób: " - f"{last_error}" - ) +from pbn_client.exceptions import * # noqa: F401,F403 diff --git a/src/pbn_api/management/commands/util.py b/src/pbn_api/management/commands/util.py index 3300fbaf2..a07e49695 100644 --- a/src/pbn_api/management/commands/util.py +++ b/src/pbn_api/management/commands/util.py @@ -4,7 +4,7 @@ from bpp.models import BppUser, Uczelnia from pbn_api.client import PBNClient, RequestsTransport -from pbn_api.conf import settings +from pbn_client.conf import settings class PBNBaseCommand(BaseCommand): diff --git a/src/pbn_client/__init__.py b/src/pbn_client/__init__.py new file mode 100644 index 000000000..1ae372158 --- /dev/null +++ b/src/pbn_client/__init__.py @@ -0,0 +1,41 @@ +"""PBN API client — czysta warstwa protokołu (Warstwa 1, reusable). + +Pakiet świadomie NIE zależy od ``bpp`` ani od ``pbn_api`` — to kandydat do +ekstrakcji jako osobny pakiet. Wie wyłącznie o pojęciach PBN: tokeny, URL-e, +PBN UID, JSON-y, flagi bool. + +Patrz: docs/superpowers/specs/2026-06-02-pbn-client-split-design.md +""" + +from .auth import OAuthMixin +from .mixins import ( + ConferencesMixin, + DictionariesMixin, + InstitutionsMixin, + InstitutionsProfileMixin, + JournalsMixin, + PersonMixin, + PublicationsMixin, + PublishersMixin, + SearchMixin, +) +from .pagination import PageableResource +from .transport import PBNClientTransport, RequestsTransport +from .utils import smart_content + +__all__ = [ + "OAuthMixin", + "ConferencesMixin", + "DictionariesMixin", + "InstitutionsMixin", + "InstitutionsProfileMixin", + "JournalsMixin", + "PersonMixin", + "PublicationsMixin", + "PublishersMixin", + "SearchMixin", + "PageableResource", + "PBNClientTransport", + "RequestsTransport", + "smart_content", +] diff --git a/src/pbn_api/client/auth.py b/src/pbn_client/auth.py similarity index 94% rename from src/pbn_api/client/auth.py rename to src/pbn_client/auth.py index 56485c236..e00a8c6b7 100644 --- a/src/pbn_api/client/auth.py +++ b/src/pbn_client/auth.py @@ -4,7 +4,7 @@ import requests -from pbn_api.exceptions import ( +from pbn_client.exceptions import ( AuthenticationConfigurationError, AuthenticationResponseError, ) @@ -24,7 +24,7 @@ def get_auth_url(klass, base_url, app_id, state=None): @classmethod def get_user_token(klass, base_url, app_id, app_token, one_time_token): - from pbn_api.conf import settings as pbn_settings + from pbn_client.conf import settings as pbn_settings headers = { "X-App-Id": app_id, @@ -53,7 +53,7 @@ def get_user_token(klass, base_url, app_id, app_token, one_time_token): return response.json().get("X-User-Token") def authorize(self, base_url, app_id, app_token): - from pbn_api.conf import settings + from pbn_client.conf import settings if self.access_token: return True diff --git a/src/pbn_api/conf/__init__.py b/src/pbn_client/conf/__init__.py similarity index 100% rename from src/pbn_api/conf/__init__.py rename to src/pbn_client/conf/__init__.py diff --git a/src/pbn_api/conf/settings.py b/src/pbn_client/conf/settings.py similarity index 100% rename from src/pbn_api/conf/settings.py rename to src/pbn_client/conf/settings.py diff --git a/src/pbn_client/const.py b/src/pbn_client/const.py new file mode 100644 index 000000000..d5a206e62 --- /dev/null +++ b/src/pbn_client/const.py @@ -0,0 +1,33 @@ +DELETED = "DELETED" +ACTIVE = "ACTIVE" + +PBN_POST_PUBLICATIONS_URL = "/api/v1/publications" + +PBN_POST_PUBLICATION_NO_STATEMENTS_URL = "/api/v1/repositorium/publications" + +PBN_POST_PUBLICATION_FEE_URL = "/api/v1/institutionProfile/publications/fees/{id}" + +PBN_GET_LANGUAGES_URL = "/api/v1/dictionary/languages" +PBN_SEARCH_PUBLICATIONS_URL = "/api/v1/search/publications" + +PBN_GET_JOURNAL_BY_ID = "/api/v1/journals/{id}" + +DEFAULT_BASE_URL = "https://pbn-micro-alpha.opi.org.pl" + +NEEDS_PBN_AUTH_MSG = ( + "W celu poprawnej autentykacji należy podać poprawny token użytkownika aplikacji." +) +PBN_DELETE_PUBLICATION_STATEMENT = ( + "/api/v1/institutionProfile/publications/{publicationId}" +) +PBN_GET_PUBLICATION_BY_ID_URL = "/api/v1/publications/id/{id}" + +PBN_GET_INSTITUTION_STATEMENTS = ( + "/api/v1/institutionProfile/publications/page/statements" +) + +PBN_GET_DISCIPLINES_URL = "/api/v2/dictionary/disciplines" + +PBN_POST_INSTITUTION_STATEMENTS_URL = "/api/v2/institution-profile/statements" + +PBN_GET_INSTITUTION_PUBLICATIONS_V2 = "/api/v2/institution-profile/publications" diff --git a/src/pbn_client/exceptions.py b/src/pbn_client/exceptions.py new file mode 100644 index 000000000..074f7d39b --- /dev/null +++ b/src/pbn_client/exceptions.py @@ -0,0 +1,189 @@ +import json + + +class AlreadyEnqueuedError(Exception): + pass + + +class CharakterFormalnyNieobslugiwanyError(Exception): + pass + + +class TlumaczDyscyplinException(ValueError): + pass + + +class BrakZdefiniowanegoObiektuUczelniaWSystemieError(Exception): + pass + + +class PraceSerwisoweException(Exception): + def __str__(self): + return "Po stronie PBN trwają prace serwisowe. Prosimy spróbować później. " + + +class CannotDeleteStatementsException(Exception): + pass + + +class HttpException(Exception): + def __init__(self, status_code, url, content): + self.status_code = status_code + self.url = url + self.content = content + try: + self.json = json.loads(content[:4096]) + except (json.JSONDecodeError, ValueError, TypeError): + self.json = None + + +class ResourceLockedException(HttpException): + pass + + +class AccessDeniedException(Exception): + def __init__(self, url, content): + self.url = url + self.content = content + + +class BrakIDPracyPoStroniePBN(HttpException): + pass + + +class SciencistDoesNotExist(Exception): + pass + + +class AuthenticationConfigurationError(Exception): + pass + + +class AuthenticationResponseError(Exception): + pass + + +class IntegracjaWylaczonaException(Exception): + pass + + +class SameDataUploadedRecently(Exception): + pass + + +class WillNotExportError(Exception): + pass + + +class DOIorWWWMissing(WillNotExportError): + pass + + +class LanguageMissingPBNUID(WillNotExportError): + pass + + +class StatementsMissing(WillNotExportError): + pass + + +class PKZeroExportDisabled(WillNotExportError): + pass + + +class CharakterFormalnyMissingPBNUID(WillNotExportError): + pass + + +class StatementDeletionError(Exception): + def __init__(self, status_code, url, content): + self.status_code = status_code + self.url = url + self.content = content + + +class NeedsPBNAuthorisationException(HttpException): + pass + + +class NoFeeDataException(ValueError): + pass + + +class NoPBNUIDException(ValueError): + pass + + +class CannotUploadPublicationFee(ValueError): + """Raised when PBN server indicates that publication is not subject to fee requirements.""" + + pass + + +class PublicationDoesNotExistInInstitutionProfile(ValueError): + """Raised when publication does not exist or is not in the institution profile.""" + + pass + + +class PBNUIDChangedException(ValueError): + """Podnoszony w sytuacji gdy wysłanej pracy która już posiada PBN UID należałoby zmienić PBN UID na inny + na skutek odpowiedzi serwera. Technicznie nie jest to błąd i ten PBN UID jest ustawiany. Ten Exception + jest używany przez Sentry do zgłoszenia (wysłania) sytuacji.""" + + +class PBNUIDSetToExistentException(ValueError): + """Podnoszony gdy wg serwera PBN pracy nowo wysyłanej nalezałoby ustawić PBN UID + istniejącego rekordu. Używany do wysłania przez Sentry zgłoszenia o sytuacji.""" + + +class DaneLokalneWymagajaAktualizacjiException(Exception): + """Podnoszony, gdy lokalne dane powinny zostać zaktualizowane, aby odzwierciedlać + zmiany po stronie PBN.""" + + +class PublikacjaInstytucjiV2NieZnalezionaException(Exception): + """Publikacja instytucji nie znaleziona po ID w api V2""" + + +class ZnalezionoWielePublikacjiInstytucjiV2Exception(Exception): + pass + + +class BPPPublicationNotFound(Exception): + """Publikacja z PBN nie ma odpowiednika w BPP.""" + + pass + + +class BPPAutorNotFound(Exception): + """Naukowiec z PBN nie ma odpowiednika w BPP.""" + + pass + + +class BPPAutorPublicationLinkNotFound(Exception): + """Autor istnieje w BPP, publikacja istnieje w BPP, + ale autor nie jest powiązany z tą publikacją.""" + + pass + + +class StatementsResendFailedException(Exception): + """Podnoszony gdy synchronizacja oświadczeń z PBN nie powiodła się + po wyczerpaniu prób retry w ``sync_publication`` (GET/DELETE/POST). + + Publikacja została już wysłana do PBN (POST do endpointu repo OK), + ale kolejne kroki synchronizacji oświadczeń zawiodły. Klasyfikowany + w ``pbn_export_queue`` jako RETRY_LATER + TECHNICZNY. + """ + + def __init__(self, publication_pk, pbn_uid, last_error): + self.publication_pk = publication_pk + self.pbn_uid = pbn_uid + self.last_error = last_error + super().__init__( + f"Synchronizacja oświadczeń dla pracy pk={publication_pk} " + f"(PBN UID={pbn_uid}) nie powiodła się po wyczerpaniu prób: " + f"{last_error}" + ) diff --git a/src/pbn_api/client/mixins/__init__.py b/src/pbn_client/mixins/__init__.py similarity index 100% rename from src/pbn_api/client/mixins/__init__.py rename to src/pbn_client/mixins/__init__.py diff --git a/src/pbn_api/client/mixins/conferences.py b/src/pbn_client/mixins/conferences.py similarity index 100% rename from src/pbn_api/client/mixins/conferences.py rename to src/pbn_client/mixins/conferences.py diff --git a/src/pbn_api/client/mixins/dictionaries.py b/src/pbn_client/mixins/dictionaries.py similarity index 83% rename from src/pbn_api/client/mixins/dictionaries.py rename to src/pbn_client/mixins/dictionaries.py index 90ae6a2b0..e0b2c47d3 100644 --- a/src/pbn_api/client/mixins/dictionaries.py +++ b/src/pbn_client/mixins/dictionaries.py @@ -1,6 +1,6 @@ """Dictionaries API mixin.""" -from pbn_api.const import PBN_GET_DISCIPLINES_URL, PBN_GET_LANGUAGES_URL +from pbn_client.const import PBN_GET_DISCIPLINES_URL, PBN_GET_LANGUAGES_URL class DictionariesMixin: diff --git a/src/pbn_api/client/mixins/institutions.py b/src/pbn_client/mixins/institutions.py similarity index 98% rename from src/pbn_api/client/mixins/institutions.py rename to src/pbn_client/mixins/institutions.py index 0f721d34b..cfe32cdee 100644 --- a/src/pbn_api/client/mixins/institutions.py +++ b/src/pbn_client/mixins/institutions.py @@ -2,13 +2,13 @@ import json -from pbn_api.const import ( +from pbn_client.const import ( PBN_DELETE_PUBLICATION_STATEMENT, PBN_GET_INSTITUTION_PUBLICATIONS_V2, PBN_GET_INSTITUTION_STATEMENTS, PBN_POST_INSTITUTION_STATEMENTS_URL, ) -from pbn_api.exceptions import ( +from pbn_client.exceptions import ( CannotDeleteStatementsException, HttpException, ResourceLockedException, diff --git a/src/pbn_api/client/mixins/journals.py b/src/pbn_client/mixins/journals.py similarity index 94% rename from src/pbn_api/client/mixins/journals.py rename to src/pbn_client/mixins/journals.py index 35e08b4ce..dbf9e200f 100644 --- a/src/pbn_api/client/mixins/journals.py +++ b/src/pbn_client/mixins/journals.py @@ -1,6 +1,6 @@ """Journals API mixin.""" -from pbn_api.const import PBN_GET_JOURNAL_BY_ID +from pbn_client.const import PBN_GET_JOURNAL_BY_ID class JournalsMixin: diff --git a/src/pbn_api/client/mixins/person.py b/src/pbn_client/mixins/person.py similarity index 100% rename from src/pbn_api/client/mixins/person.py rename to src/pbn_client/mixins/person.py diff --git a/src/pbn_api/client/mixins/publications.py b/src/pbn_client/mixins/publications.py similarity index 94% rename from src/pbn_api/client/mixins/publications.py rename to src/pbn_client/mixins/publications.py index 188b94641..4a71b034c 100644 --- a/src/pbn_api/client/mixins/publications.py +++ b/src/pbn_client/mixins/publications.py @@ -2,7 +2,7 @@ from urllib.parse import quote -from pbn_api.const import PBN_GET_PUBLICATION_BY_ID_URL +from pbn_client.const import PBN_GET_PUBLICATION_BY_ID_URL class PublicationsMixin: diff --git a/src/pbn_api/client/mixins/publishers.py b/src/pbn_client/mixins/publishers.py similarity index 100% rename from src/pbn_api/client/mixins/publishers.py rename to src/pbn_client/mixins/publishers.py diff --git a/src/pbn_api/client/mixins/search.py b/src/pbn_client/mixins/search.py similarity index 80% rename from src/pbn_api/client/mixins/search.py rename to src/pbn_client/mixins/search.py index a9665426c..d3024b79c 100644 --- a/src/pbn_api/client/mixins/search.py +++ b/src/pbn_client/mixins/search.py @@ -1,6 +1,6 @@ """Search API mixin.""" -from pbn_api.const import PBN_SEARCH_PUBLICATIONS_URL +from pbn_client.const import PBN_SEARCH_PUBLICATIONS_URL class SearchMixin: diff --git a/src/pbn_api/client/pagination.py b/src/pbn_client/pagination.py similarity index 100% rename from src/pbn_api/client/pagination.py rename to src/pbn_client/pagination.py diff --git a/src/pbn_client/transport.py b/src/pbn_client/transport.py new file mode 100644 index 000000000..833d01715 --- /dev/null +++ b/src/pbn_client/transport.py @@ -0,0 +1,314 @@ +"""HTTP transport layer for PBN API client.""" + +import logging +import random +import time +import warnings +from urllib.parse import quote + +import requests +import rollbar +from requests import ConnectionError +from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError +from requests.exceptions import SSLError +from simplejson.errors import JSONDecodeError + +from pbn_client.const import DEFAULT_BASE_URL +from pbn_client.exceptions import ( + AccessDeniedException, + HttpException, + NeedsPBNAuthorisationException, + PraceSerwisoweException, + ResourceLockedException, +) + +from .auth import OAuthMixin +from .pagination import PageableResource +from .utils import smart_content + +logger = logging.getLogger(__name__) + + +class PBNClientTransport: + """Base transport class for PBN API communication.""" + + def __init__(self, app_id, app_token, base_url, user_token=None): + self.app_id = app_id + self.app_token = app_token + + self.base_url = base_url + if self.base_url is None: + self.base_url = DEFAULT_BASE_URL + + self.access_token = user_token + + +class RequestsTransport(OAuthMixin, PBNClientTransport): + """HTTP transport implementation using requests library.""" + + def _build_headers(self, headers=None): + """Build headers for API request.""" + sent_headers = {"X-App-Id": self.app_id, "X-App-Token": self.app_token} + if self.access_token: + sent_headers["X-User-Token"] = self.access_token + if headers is not None: + sent_headers.update(headers) + return sent_headers + + def _make_get_request_with_retry(self, url, headers, max_retries=15): + """Make GET request with retry on SSL/Connection errors.""" + from pbn_client.conf import settings as pbn_settings + + retries = 0 + while retries < max_retries: + try: + return requests.get( + self.base_url + url, + headers=headers, + timeout=pbn_settings.PBN_CLIENT_HTTP_TIMEOUT, + ) + except (SSLError, ConnectionError) as e: + retries += 1 + time.sleep(random.randint(1, 5)) + if retries >= max_retries: + raise e + + def _handle_403_response(self, ret, url, headers, fail_on_auth_missing): + """Handle 403 response, attempting reauthorization if needed.""" + if fail_on_auth_missing: + raise AccessDeniedException(url, smart_content(ret.content)) + + if ret.json()["message"] in ["Access Denied", "Forbidden"]: + raise AccessDeniedException(url, smart_content(ret.content)) + + if hasattr(self, "authorize"): + auth_result = self.authorize(self.base_url, self.app_id, self.app_token) + if not auth_result: + return None + return self.get(url, headers, fail_on_auth_missing=True) + + return ret + + def _parse_json_response(self, ret, url): + """Parse JSON response with special handling for service maintenance.""" + try: + return ret.json() + except (RequestsJSONDecodeError, JSONDecodeError) as e: + if ret.status_code == 200 and b"prace serwisowe" in ret.content: + raise PraceSerwisoweException() from e + raise e + + def get(self, url, headers=None, fail_on_auth_missing=False): + sent_headers = self._build_headers(headers) + ret = self._make_get_request_with_retry(url, sent_headers) + + if ret.status_code == 403: + result = self._handle_403_response(ret, url, headers, fail_on_auth_missing) + if result is None: + return + if result != ret: + return result + + if ret.status_code >= 400: + raise HttpException(ret.status_code, url, smart_content(ret.content)) + + return self._parse_json_response(ret, url) + + def _ensure_access_token(self): + """Ensure access token is available.""" + if not hasattr(self, "access_token"): + return self.authorize(self.base_url, self.app_id, self.app_token) + return True + + def _build_post_headers(self, headers=None): + """Build headers for POST request.""" + sent_headers = { + "X-App-Id": self.app_id, + "X-App-Token": self.app_token, + "X-User-Token": self.access_token, + } + if headers is not None: + sent_headers.update(headers) + return sent_headers + + def _get_request_method(self, delete): + """Get appropriate HTTP method.""" + return requests.delete if delete else requests.post + + def _parse_403_response(self, ret, url): + """Parse 403 response JSON.""" + try: + return ret.json() + except BaseException as e: + raise HttpException( + ret.status_code, + url, + "Blad podczas odkodowywania JSON podczas odpowiedzi 403: " + + smart_content(ret.content), + ) from e + + def _handle_403_access_denied(self, ret_json, ret, url): + """Handle 403 Access Denied responses.""" + from pbn_client.const import NEEDS_PBN_AUTH_MSG + + if ret_json.get("message") == "Access Denied": + raise AccessDeniedException(url, smart_content(ret.content)) + + if ret_json.get("message") == "Forbidden" and ret_json.get( + "description", "" + ).startswith(NEEDS_PBN_AUTH_MSG): + raise NeedsPBNAuthorisationException( + ret.status_code, url, smart_content(ret.content) + ) + + if hasattr(self, "authorize"): + self.authorize(self.base_url, self.app_id, self.app_token) + + def _check_error_response(self, ret, url): + """Check and handle error responses.""" + if ret.status_code >= 400: + if ret.status_code == 423 and smart_content(ret.content) == "Locked": + raise ResourceLockedException( + ret.status_code, url, smart_content(ret.content) + ) + # Diagnostyka: logger.error dla widoczności w konsoli/plikach + # logów, rollbar.report_message dla zdalnego trackingu (oba przy + # każdym 4xx/5xx — szczegóły body i headers przydają się przy + # debugowaniu enigmatycznych odpowiedzi typu „400 Bad Request" + # bez body). + logger.error( + "PBN %s on %s: headers=%r body_len=%d body=%r", + ret.status_code, + url, + dict(ret.headers), + len(ret.content), + ret.content[:4000], + ) + rollbar.report_message( + f"PBN {ret.status_code} on {url}", + level="error" if ret.status_code >= 500 else "warning", + extra_data={ + "status_code": ret.status_code, + "url": url, + "headers": dict(ret.headers), + "body_len": len(ret.content), + "body": smart_content(ret.content[:4000]), + }, + ) + raise HttpException(ret.status_code, url, smart_content(ret.content)) + + def post(self, url, headers=None, body=None, delete=False): + if not self._ensure_access_token(): + return + if not hasattr(self, "access_token"): + return self.post(url, headers=headers, body=body, delete=delete) + + from pbn_client.conf import settings as pbn_settings + + sent_headers = self._build_post_headers(headers) + method = self._get_request_method(delete) + ret = method( + self.base_url + url, + headers=sent_headers, + json=body, + timeout=pbn_settings.PBN_CLIENT_HTTP_TIMEOUT, + ) + + if ret.status_code == 403: + ret_json = self._parse_403_response(ret, url) + self._handle_403_access_denied(ret_json, ret, url) + + self._check_error_response(ret, url) + + try: + return ret.json() + except (RequestsJSONDecodeError, JSONDecodeError) as e: + if ret.status_code == 200: + if ret.content == b"": + return + + if b"prace serwisowe" in ret.content: + raise PraceSerwisoweException() from e + + raise e + + def delete( + self, + url, + headers=None, + body=None, + ): + return self.post(url, headers, body, delete=True) + + def _pages(self, method, url, headers=None, body=None, page_size=10, *args, **kw): + # Stronicowanie zwraca rezultaty w taki sposób: + # {'content': [{'mongoId': '5e709189878c28a04737dc6f', + # 'status': 'ACTIVE', + # ... + # 'versionHash': '---'}]}], + # 'first': True, + # 'last': False, + # 'number': 0, + # 'numberOfElements': 10, + # 'pageable': {'offset': 0, + # 'pageNumber': 0, + # 'pageSize': 10, + # 'paged': True, + # 'sort': {'sorted': False, 'unsorted': True}, + # 'unpaged': False}, + # 'size': 10, + # 'sort': {'sorted': False, 'unsorted': True}, + # 'totalElements': 68577, + # 'totalPages': 6858} + + chr = "?" + if url.find("?") >= 0: + chr = "&" + + url = url + f"{chr}size={page_size}" + chr = "&" + + for elem in kw: + url += chr + elem + "=" + quote(kw[elem]) + + method_function = getattr(self, method) + + if method == "get": + res = method_function(url, headers) + elif method == "post": + res = method_function(url, headers, body=body) + else: + raise NotImplementedError + + if "pageable" not in res: + warnings.warn( + f"PBNClient.{method}_page request for {url} with headers {headers} " + f"did not return a paged resource, " + f"maybe use PBNClient.{method} (without 'page') instead", + RuntimeWarning, + stacklevel=2, + ) + return res + return PageableResource( + self, res, url=url, headers=headers, body=body, method=method + ) + + def get_pages(self, url, headers=None, page_size=10, *args, **kw): + return self._pages( + "get", *args, url=url, headers=headers, page_size=page_size, **kw + ) + + def post_pages(self, url, headers=None, body=None, page_size=10, *args, **kw): + # Jak get_pages, ale methoda to post + if body is None: + body = kw + + return self._pages( + "post", + *args, + url=url, + headers=headers, + body=body, + page_size=page_size, + **kw, + ) diff --git a/src/pbn_api/client/utils.py b/src/pbn_client/utils.py similarity index 100% rename from src/pbn_api/client/utils.py rename to src/pbn_client/utils.py From e28372c3d85b645a4997120c211ef2a967b6c1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 19:29:12 +0200 Subject: [PATCH 045/247] refactor(pbn): Faza 2 - wytnij StatementsMixin (czysty silnik) do pbn_client Przenosi czyste operacje protokolu (POST/GET publikacji i oplat, konwersje JSON, silnik diff/DELETE/POST oswiadczen + retry) z PublicationSyncMixin do pbn_client/statements.py jako StatementsMixin. PublicationSyncMixin dziedziczy po StatementsMixin, wiec orchestracja wola silnik przez self bez zmian w call-site'ach. rename_dict_key/compare_dicts przeniesione do pbn_client/dict_utils (shim w pbn_api.utils). Docstringi W1 oczyszczone z referencji do klas BPP. 153 testow zielonych; ruff + manage.py check czyste. Co-Authored-By: Claude Opus 4.8 --- src/pbn_api/client/publication_sync.py | 354 +---------------------- src/pbn_api/utils.py | 50 +--- src/pbn_client/dict_utils.py | 48 ++++ src/pbn_client/statements.py | 382 +++++++++++++++++++++++++ 4 files changed, 441 insertions(+), 393 deletions(-) create mode 100644 src/pbn_client/dict_utils.py create mode 100644 src/pbn_client/statements.py diff --git a/src/pbn_api/client/publication_sync.py b/src/pbn_api/client/publication_sync.py index 378479de9..708c49818 100644 --- a/src/pbn_api/client/publication_sync.py +++ b/src/pbn_api/client/publication_sync.py @@ -15,158 +15,35 @@ WydawnictwoPBNAdapter, ) from pbn_api.const import ( - PBN_POST_PUBLICATION_FEE_URL, PBN_POST_PUBLICATION_NO_STATEMENTS_URL, PBN_POST_PUBLICATIONS_URL, ) from pbn_api.exceptions import ( CannotDeleteStatementsException, - CannotUploadPublicationFee, DaneLokalneWymagajaAktualizacjiException, HttpException, NoFeeDataException, NoPBNUIDException, PBNUIDChangedException, PBNUIDSetToExistentException, - PublicationDoesNotExistInInstitutionProfile, PublikacjaInstytucjiV2NieZnalezionaException, SameDataUploadedRecently, - StatementsResendFailedException, ZnalezionoWielePublikacjiInstytucjiV2Exception, ) from pbn_api.models.pbn_odpowiedzi_niepozadane import PBNOdpowiedziNiepozadane from pbn_api.models.sentdata import SentData -from pbn_api.utils import rename_dict_key +from pbn_client.statements import StatementsMixin logger = logging.getLogger(__name__) -class PublicationSyncMixin: - """Mixin providing publication synchronization methods.""" +class PublicationSyncMixin(StatementsMixin): + """Orchestracja synchronizacji publikacji BPP↔PBN (warstwa BPP-aware). - def post_publication(self, json): - """POST publikacji wraz z oświadczeniami do ``/api/v1/publications``. - - Endpoint all-in-one — przyjmuje payload z kluczem ``statements`` - bezpośrednio z ``WydawnictwoPBNAdapter.pbn_get_json()`` (bez - konwersji pól, bez owijania w listę). Zwraca pojedynczy obiekt - z ``objectId`` (a nie listę z ``id`` jak endpoint repo). - """ - return self.transport.post(PBN_POST_PUBLICATIONS_URL, body=json) - - def convert_json_with_statements_to_no_statements(self, json): - # Endpoint repozytoryjny `/api/v1/repositorium/publications` nie - # przyjmuje klucza `statements` w body — oświadczenia synchronizujemy - # osobno przez `/api/v2/institution-profile/statements`. - json.pop("statements", None) - - # PBN zmienił givenNames na firstName - for elem in json.get("authors", []): - elem["firstName"] = elem.pop("givenNames") - - for elem in json.get("editors", []): - elem["firstName"] = elem.pop("givenNames") - - # PBN życzy abstrakty w root - abstracts = json.pop("languageData", {}).get("abstracts", []) - if abstracts: - json["abstracts"] = abstracts - - # PBN nie życzy opłat - json.pop("fee", None) - - # PBN zmienił nazwę mniswId na ministryId - json = rename_dict_key(json, "mniswId", "ministryId") - - # OpenAccess modeArticle -> mode - json = rename_dict_key(json, "modeArticle", "mode") - - # OpenAccess releaseDateYear "2022" -> 2022 (int) - # Jeśli konwersja na int zawiedzie — zachowujemy oryginalną wartość - # (PBN zwróci validation error z jasnym komunikatem, jeśli format - # jest nieprawidłowy). Wcześniejsza implementacja miała NameError: - # zmienna ``i`` była zdefiniowana tylko wewnątrz ``try``, a - # bezwarunkowy assignment poza blokiem rzucał NameError gdy - # ``int()`` failowało. - if json.get("openAccess", False) and isinstance(json["openAccess"], dict): - value = json["openAccess"].get("releaseDateYear") - if value is not None: - try: - json["openAccess"]["releaseDateYear"] = int(value) - except (ValueError, TypeError): - # Nie ruszamy wartości — PBN wskaże problem w walidacji. - pass - return json - - def post_publication_no_statements(self, json): - """ - Ta funkcja służy do wysyłania publikacji BEZ oświadczeń. - - Bierzemy słownik JSON z publikacji-z-oświadczeniami i przetwarzamy go. - - :param json: - :return: - """ - return self.transport.post(PBN_POST_PUBLICATION_NO_STATEMENTS_URL, body=[json]) - - def post_publication_fee(self, publicationId, json): - try: - return self.transport.post( - PBN_POST_PUBLICATION_FEE_URL.format(id=publicationId), body=json - ) - except HttpException as e: - if e.status_code == 400: - if e.content.find("nie jest objęta obowiązkiem") >= 0: - raise CannotUploadPublicationFee( - f"Publikacja {publicationId} nie jest objęta obowiązkiem " - f"wprowadzenia opłat za publikacje." - ) from e - if e.content.find("nie znajduje się w Profilu Instytucji") >= 0: - raise PublicationDoesNotExistInInstitutionProfile( - f"Publikacja {publicationId} nie istnieje lub nie znajduje się " - f"w Profilu Instytucji." - ) from e - raise - - def get_publication_fee(self, publicationId): - res = self.transport.post_pages( - "/api/v1/institutionProfile/publications/search/fees", - body={"publicationIds": [str(publicationId)]}, - ) - if not res.count(): - return - elif res.count() == 1: - return list(res)[0] - else: - raise NotImplementedError("count > 1") - - def get_publication_fees_batch(self, publication_ids): - """Get fees for multiple publications in a single API call. - - Args: - publication_ids: List of publication IDs (PBN UIDs). - - Returns: - Dict mapping publication_id to fee data, or empty dict if no fees. - """ - if not publication_ids: - return {} - - res = self.transport.post_pages( - "/api/v1/institutionProfile/publications/search/fees", - body={"publicationIds": [str(pid) for pid in publication_ids]}, - ) - - # Build a mapping of publication ID -> fee data - # Note: API returns publicationId nested in "publication" object - fees_map = {} - for item in res: - publication = item.get("publication", {}) - pub_id = publication.get("publicationId") if publication else None - if pub_id: - fees_map[pub_id] = item - - return fees_map + Czyste operacje protokołu (POST/GET, diff/DELETE oświadczeń) dziedziczy + z ``pbn_client.statements.StatementsMixin``; tutaj zostaje logika znająca + rekord BPP, ``Uczelnia`` i modele persystencji. + """ def _prepare_publication_json(self, rec, export_pk_zero, always_affiliate_to_uid): """Przygotowuje JSON publikacji do wysyłki. @@ -209,33 +86,6 @@ def _check_upload_needed(self, rec, js, force_upload): SentData.objects.get_for_rec(rec).last_updated_on ) - def _post_publication_data(self, js, bez_oswiadczen): - """POST publikacji do właściwego endpointu i wyciągnięcie ``objectId``. - - - ``bez_oswiadczen=False`` → ``/v1/publications``, - response: ``{"objectId": ...}`` (single dict). - - ``bez_oswiadczen=True`` → ``/v1/repositorium/publications``, - response: ``[{"id": ...}]`` (lista 1 elementu). - """ - if not bez_oswiadczen: - ret = self.post_publication(js) - objectId = ret.get("objectId", None) if isinstance(ret, dict) else None - return ret, objectId - - ret = self.post_publication_no_statements(js) - if len(ret) != 1: - raise Exception( - "Lista zwróconych obiektów przy wysyłce pracy do repozytorium " - "różna od jednego. " - "Sytuacja nieobsługiwana, proszę o kontakt z autorem programu. " - ) - try: - objectId = ret[0].get("id", None) - except (KeyError, IndexError) as e: - raise Exception(f"Serwer zwrócił nieoczekiwaną odpowiedź. {ret=}") from e - - return ret, objectId - def _pre_upload_clear_pbn_statements_if_any(self, rec): """Wycofaj oświadczenia z PBN PRZED wysyłką pracy bez-oświadczeniowej. @@ -408,196 +258,6 @@ def pobierz_publikacje_instytucji_v2(self, objectId): return zapisz_publikacje_instytucji_v2(self, elem[0]) - def _delete_statements_with_retry(self, pbn_uid_id, max_tries=5): - """Delete publication statements with retry on failure. - - Używane przez batch flow (``pbn_wysylka_oswiadczen/tasks.py``) oraz - przez nowy ``_delete_statements_batch`` helper w ``sync_publication``. - """ - no_tries = max_tries - while True: - try: - self.delete_all_publication_statements(pbn_uid_id) - return True - except CannotDeleteStatementsException as e: - # Warunek <= 0 (nie < 0): dla ``max_tries=5`` chcemy dokładnie - # 5 prób (no_tries: 5→4→3→2→1→0), po szóstej iteracji rzucamy. - # Wcześniejsze ``< 0`` pozwalało na 6 prób. - if no_tries <= 0: - raise e - no_tries -= 1 - time.sleep(0.5) - - # ----------- Helpery do nowego split-flow sync_publication ----------- - # Mapowanie kluczy porównania: - # - PBN GET /page/statements zwraca: {personId, area, type, institutionId, ...} - # - Adapter pbn_get_json_statements() zwraca: {personObjectId, disciplineId, - # disciplineUuid, type, ...} - # Klucz porównania: (person mongoId, discipline numerek). Oba na string. - # Selektywny DELETE używa (personId, role) — delete_publication_statement. - - _STATEMENT_RETRY_DELAYS = (2, 4, 8) # exponential backoff przy 3 próbach - - @staticmethod - def _statement_key_pbn(stmt): - """Klucz porównania dla oświadczenia z PBN GET response.""" - return ( - str(stmt.get("personId", "")), - str(stmt.get("area", "")), - ) - - @staticmethod - def _statement_key_intended(stmt): - """Klucz porównania dla oświadczenia z ``pbn_get_json_statements``.""" - return ( - str(stmt.get("personObjectId", "")), - str(stmt.get("disciplineId", "")), - ) - - def _diff_statements(self, pbn_statements, intended_statements): - """Porównuje zestaw oświadczeń PBN z intencją BPP. - - Zwraca (only_in_pbn, only_in_intended) jako sety kluczy - ``(person_mongoId, discipline_numerek)``: - - - ``only_in_pbn`` — do usunięcia z PBN (PBN ma, BPP nie chce) - - ``only_in_intended`` — do dodania do PBN (BPP chce, PBN nie ma) - """ - pbn_keys = {self._statement_key_pbn(s) for s in pbn_statements} - intended_keys = {self._statement_key_intended(s) for s in intended_statements} - return pbn_keys - intended_keys, intended_keys - pbn_keys - - def _report_statements_failure_and_raise( - self, publication_pk, objectId, last_error - ): - """Raportuje do Rollbar (level=warning) i rzuca StatementsResendFailedException.""" - try: - raise StatementsResendFailedException(publication_pk, objectId, last_error) - except StatementsResendFailedException: - rollbar.report_exc_info( - sys.exc_info(), - level="warning", - extra_data={ - "publication_pk": publication_pk, - "pbn_uid": str(objectId), - "last_error": str(last_error), - }, - ) - raise - - def _get_pbn_statements_with_retry(self, objectId, publication_pk, max_tries=3): - """Pobiera oświadczenia publikacji z PBN z retry (exponential backoff). - - Po wyczerpaniu prób: rollbar.report_exc_info(level="warning") oraz - raise ``StatementsResendFailedException``. - """ - last_error = None - for attempt in range(max_tries): - try: - return list( - self.get_institution_statements_of_single_publication( - str(objectId), 5120 - ) - ) - except Exception as e: - last_error = e - logger.warning( - "Błąd pobierania oświadczeń PBN dla %s, próba %d/%d: %s", - objectId, - attempt + 1, - max_tries, - e, - exc_info=True, - ) - if attempt < max_tries - 1: - time.sleep(self._STATEMENT_RETRY_DELAYS[attempt]) - - self._report_statements_failure_and_raise(publication_pk, objectId, last_error) - - def _delete_statements_selective( - self, objectId, pbn_statements_to_delete, publication_pk, max_tries=3 - ): - """Selektywne DELETE oświadczeń per-osoba (delete_publication_statement). - - Iteruje po liście oświadczeń PBN do usunięcia i wywołuje DELETE dla - każdego (klucz: personId + type z PBN GET response). Po wyczerpaniu - prób per oświadczenie: rollbar + raise StatementsResendFailedException. - """ - for stmt in pbn_statements_to_delete: - person_id = stmt.get("personId") - role = stmt.get("type") - last_error = None - success = False - for attempt in range(max_tries): - try: - self.delete_publication_statement(str(objectId), person_id, role) - success = True - break - except Exception as e: - last_error = e - logger.warning( - "Błąd DELETE oświadczenia (%s, %s) dla %s, próba %d/%d: %s", - person_id, - role, - objectId, - attempt + 1, - max_tries, - e, - exc_info=True, - ) - if attempt < max_tries - 1: - time.sleep(self._STATEMENT_RETRY_DELAYS[attempt]) - if not success: - self._report_statements_failure_and_raise( - publication_pk, objectId, last_error - ) - - def _delete_statements_batch(self, objectId, publication_pk, max_tries=3): - """Batch DELETE wszystkich oświadczeń publikacji z retry. - - Rzuca ``CannotDeleteStatementsException`` w górę (caller może - zignorować gdy PBN mówi że nie ma oświadczeń). Po wyczerpaniu prób - dla innych błędów: rollbar + raise StatementsResendFailedException. - """ - last_error = None - for attempt in range(max_tries): - try: - self.delete_all_publication_statements(str(objectId)) - return - except CannotDeleteStatementsException: - raise - except Exception as e: - last_error = e - logger.warning( - "Błąd batch DELETE oświadczeń dla %s, próba %d/%d: %s", - objectId, - attempt + 1, - max_tries, - e, - exc_info=True, - ) - if attempt < max_tries - 1: - time.sleep(self._STATEMENT_RETRY_DELAYS[attempt]) - - self._report_statements_failure_and_raise(publication_pk, objectId, last_error) - - @staticmethod - def _convert_stmt_for_api(stmt): - """Konwersja statement z formatu ``pbn_get_json_statements`` do formatu - akceptowanego przez ``POST /api/v2/institution-profile/statements``. - - Skopiowane z ``WydawnictwoPBNAdapter.pbn_get_api_statements._convert_stmt`` - (``src/pbn_api/adapters/wydawnictwo.py:201-210``). Gdy zmieni się tam - format, zmień też tutaj. - """ - stmt = dict(stmt) # shallow copy — nie modyfikujemy oryginalnego - if "disciplineId" in stmt and "disciplineUuid" in stmt: - del stmt["disciplineId"] - if "type" in stmt: - stmt["personRole"] = stmt.pop("type") - stmt.pop("personNaturalId", None) - return stmt - def _build_post_statements_payload(self, rec, filter_keys=None): """Buduje payload dla ``POST /api/v2/institution-profile/statements``. diff --git a/src/pbn_api/utils.py b/src/pbn_api/utils.py index 12a7c2a3f..e2d815ba0 100644 --- a/src/pbn_api/utils.py +++ b/src/pbn_api/utils.py @@ -1,48 +1,6 @@ -def rename_dict_key(data, old_key, new_key): - """ - Recursively rename a dictionary key in a dictionary and all nested dictionaries. +"""Shim kompatybilnościowy — helpery przeniesione do ``pbn_client.dict_utils``. - Args: - data: Dictionary or any data structure that may contain dictionaries - old_key: The key to be renamed - new_key: The new key name +Patrz: docs/superpowers/specs/2026-06-02-pbn-client-split-design.md +""" - Returns: - The modified data structure with renamed keys - """ - if isinstance(data, dict): - # Create a new dictionary with renamed keys - new_dict = {} - for key, value in data.items(): - # Rename the key if it matches - new_key_name = new_key if key == old_key else key - # Recursively process the value - new_dict[new_key_name] = rename_dict_key(value, old_key, new_key) - return new_dict - elif isinstance(data, list): - # Process each item in the list - return [rename_dict_key(item, old_key, new_key) for item in data] - else: - # Return the value unchanged if it's not a dict or list - return data - - -def compare_dicts(d1, d2, path=""): - diffs = [] - keys = set(d1.keys()) | set(d2.keys()) - - for key in keys: - key_path = f"{path}.{key}" if path else key - - if key not in d1: - diffs.append(f"Key '{key_path}' missing in first dict") - elif key not in d2: - diffs.append(f"Key '{key_path}' missing in second dict") - else: - v1, v2 = d1[key], d2[key] - if isinstance(v1, dict) and isinstance(v2, dict): - diffs.extend(compare_dicts(v1, v2, key_path)) - elif v1 != v2: - diffs.append(f"Value mismatch at '{key_path}': {v1!r} != {v2!r}") - - return diffs +from pbn_client.dict_utils import * # noqa: F401,F403 diff --git a/src/pbn_client/dict_utils.py b/src/pbn_client/dict_utils.py new file mode 100644 index 000000000..12a7c2a3f --- /dev/null +++ b/src/pbn_client/dict_utils.py @@ -0,0 +1,48 @@ +def rename_dict_key(data, old_key, new_key): + """ + Recursively rename a dictionary key in a dictionary and all nested dictionaries. + + Args: + data: Dictionary or any data structure that may contain dictionaries + old_key: The key to be renamed + new_key: The new key name + + Returns: + The modified data structure with renamed keys + """ + if isinstance(data, dict): + # Create a new dictionary with renamed keys + new_dict = {} + for key, value in data.items(): + # Rename the key if it matches + new_key_name = new_key if key == old_key else key + # Recursively process the value + new_dict[new_key_name] = rename_dict_key(value, old_key, new_key) + return new_dict + elif isinstance(data, list): + # Process each item in the list + return [rename_dict_key(item, old_key, new_key) for item in data] + else: + # Return the value unchanged if it's not a dict or list + return data + + +def compare_dicts(d1, d2, path=""): + diffs = [] + keys = set(d1.keys()) | set(d2.keys()) + + for key in keys: + key_path = f"{path}.{key}" if path else key + + if key not in d1: + diffs.append(f"Key '{key_path}' missing in first dict") + elif key not in d2: + diffs.append(f"Key '{key_path}' missing in second dict") + else: + v1, v2 = d1[key], d2[key] + if isinstance(v1, dict) and isinstance(v2, dict): + diffs.extend(compare_dicts(v1, v2, key_path)) + elif v1 != v2: + diffs.append(f"Value mismatch at '{key_path}': {v1!r} != {v2!r}") + + return diffs diff --git a/src/pbn_client/statements.py b/src/pbn_client/statements.py new file mode 100644 index 000000000..d8d27f87d --- /dev/null +++ b/src/pbn_client/statements.py @@ -0,0 +1,382 @@ +"""Silnik oświadczeń i czyste operacje publikacji PBN (Warstwa 1). + +``StatementsMixin`` zawiera wyłącznie czyste operacje protokołu PBN: POST/GET +publikacji i opłat, konwersje JSON oraz silnik diff/DELETE/POST oświadczeń. +Operuje na PBN UID (string), słownikach JSON i flagach bool — nie zna ``bpp`` +ani obiektu ``Uczelnia``. + +Zależy (przez ``self``) od ``InstitutionsProfileMixin`` (metody +``delete_publication_statement``, ``delete_all_publication_statements``, +``get_institution_statements_of_single_publication``), dlatego musi być +komponowany razem z nim w ``PBNClient``. + +Patrz: docs/superpowers/specs/2026-06-02-pbn-client-split-design.md +""" + +import logging +import sys +import time + +import rollbar + +from pbn_client.const import ( + PBN_POST_PUBLICATION_FEE_URL, + PBN_POST_PUBLICATION_NO_STATEMENTS_URL, + PBN_POST_PUBLICATIONS_URL, +) +from pbn_client.dict_utils import rename_dict_key +from pbn_client.exceptions import ( + CannotDeleteStatementsException, + CannotUploadPublicationFee, + HttpException, + PublicationDoesNotExistInInstitutionProfile, + StatementsResendFailedException, +) + +logger = logging.getLogger(__name__) + + +class StatementsMixin: + """Czyste operacje publikacji i oświadczeń PBN (bez zależności od bpp).""" + + def post_publication(self, json): + """POST publikacji wraz z oświadczeniami do ``/api/v1/publications``. + + Endpoint all-in-one — przyjmuje payload z kluczem ``statements`` + (pełny JSON publikacji wraz z oświadczeniami, bez konwersji pól ani + owijania w listę). Zwraca pojedynczy obiekt z ``objectId`` (a nie + listę z ``id`` jak endpoint repo). + """ + return self.transport.post(PBN_POST_PUBLICATIONS_URL, body=json) + + def convert_json_with_statements_to_no_statements(self, json): + # Endpoint repozytoryjny `/api/v1/repositorium/publications` nie + # przyjmuje klucza `statements` w body — oświadczenia synchronizujemy + # osobno przez `/api/v2/institution-profile/statements`. + json.pop("statements", None) + + # PBN zmienił givenNames na firstName + for elem in json.get("authors", []): + elem["firstName"] = elem.pop("givenNames") + + for elem in json.get("editors", []): + elem["firstName"] = elem.pop("givenNames") + + # PBN życzy abstrakty w root + abstracts = json.pop("languageData", {}).get("abstracts", []) + if abstracts: + json["abstracts"] = abstracts + + # PBN nie życzy opłat + json.pop("fee", None) + + # PBN zmienił nazwę mniswId na ministryId + json = rename_dict_key(json, "mniswId", "ministryId") + + # OpenAccess modeArticle -> mode + json = rename_dict_key(json, "modeArticle", "mode") + + # OpenAccess releaseDateYear "2022" -> 2022 (int) + # Jeśli konwersja na int zawiedzie — zachowujemy oryginalną wartość + # (PBN zwróci validation error z jasnym komunikatem, jeśli format + # jest nieprawidłowy). Wcześniejsza implementacja miała NameError: + # zmienna ``i`` była zdefiniowana tylko wewnątrz ``try``, a + # bezwarunkowy assignment poza blokiem rzucał NameError gdy + # ``int()`` failowało. + if json.get("openAccess", False) and isinstance(json["openAccess"], dict): + value = json["openAccess"].get("releaseDateYear") + if value is not None: + try: + json["openAccess"]["releaseDateYear"] = int(value) + except (ValueError, TypeError): + # Nie ruszamy wartości — PBN wskaże problem w walidacji. + pass + return json + + def post_publication_no_statements(self, json): + """ + Ta funkcja służy do wysyłania publikacji BEZ oświadczeń. + + Bierzemy słownik JSON z publikacji-z-oświadczeniami i przetwarzamy go. + + :param json: + :return: + """ + return self.transport.post(PBN_POST_PUBLICATION_NO_STATEMENTS_URL, body=[json]) + + def post_publication_fee(self, publicationId, json): + try: + return self.transport.post( + PBN_POST_PUBLICATION_FEE_URL.format(id=publicationId), body=json + ) + except HttpException as e: + if e.status_code == 400: + if e.content.find("nie jest objęta obowiązkiem") >= 0: + raise CannotUploadPublicationFee( + f"Publikacja {publicationId} nie jest objęta obowiązkiem " + f"wprowadzenia opłat za publikacje." + ) from e + if e.content.find("nie znajduje się w Profilu Instytucji") >= 0: + raise PublicationDoesNotExistInInstitutionProfile( + f"Publikacja {publicationId} nie istnieje lub nie znajduje się " + f"w Profilu Instytucji." + ) from e + raise + + def get_publication_fee(self, publicationId): + res = self.transport.post_pages( + "/api/v1/institutionProfile/publications/search/fees", + body={"publicationIds": [str(publicationId)]}, + ) + if not res.count(): + return + elif res.count() == 1: + return list(res)[0] + else: + raise NotImplementedError("count > 1") + + def get_publication_fees_batch(self, publication_ids): + """Get fees for multiple publications in a single API call. + + Args: + publication_ids: List of publication IDs (PBN UIDs). + + Returns: + Dict mapping publication_id to fee data, or empty dict if no fees. + """ + if not publication_ids: + return {} + + res = self.transport.post_pages( + "/api/v1/institutionProfile/publications/search/fees", + body={"publicationIds": [str(pid) for pid in publication_ids]}, + ) + + # Build a mapping of publication ID -> fee data + # Note: API returns publicationId nested in "publication" object + fees_map = {} + for item in res: + publication = item.get("publication", {}) + pub_id = publication.get("publicationId") if publication else None + if pub_id: + fees_map[pub_id] = item + + return fees_map + + def _post_publication_data(self, js, bez_oswiadczen): + """POST publikacji do właściwego endpointu i wyciągnięcie ``objectId``. + + - ``bez_oswiadczen=False`` → ``/v1/publications``, + response: ``{"objectId": ...}`` (single dict). + - ``bez_oswiadczen=True`` → ``/v1/repositorium/publications``, + response: ``[{"id": ...}]`` (lista 1 elementu). + """ + if not bez_oswiadczen: + ret = self.post_publication(js) + objectId = ret.get("objectId", None) if isinstance(ret, dict) else None + return ret, objectId + + ret = self.post_publication_no_statements(js) + if len(ret) != 1: + raise Exception( + "Lista zwróconych obiektów przy wysyłce pracy do repozytorium " + "różna od jednego. " + "Sytuacja nieobsługiwana, proszę o kontakt z autorem programu. " + ) + try: + objectId = ret[0].get("id", None) + except (KeyError, IndexError) as e: + raise Exception(f"Serwer zwrócił nieoczekiwaną odpowiedź. {ret=}") from e + + return ret, objectId + + def _delete_statements_with_retry(self, pbn_uid_id, max_tries=5): + """Delete publication statements with retry on failure. + + Używane przez batch flow (``pbn_wysylka_oswiadczen/tasks.py``) oraz + przez nowy ``_delete_statements_batch`` helper w ``sync_publication``. + """ + no_tries = max_tries + while True: + try: + self.delete_all_publication_statements(pbn_uid_id) + return True + except CannotDeleteStatementsException as e: + # Warunek <= 0 (nie < 0): dla ``max_tries=5`` chcemy dokładnie + # 5 prób (no_tries: 5→4→3→2→1→0), po szóstej iteracji rzucamy. + # Wcześniejsze ``< 0`` pozwalało na 6 prób. + if no_tries <= 0: + raise e + no_tries -= 1 + time.sleep(0.5) + + # ----------- Helpery do nowego split-flow sync_publication ----------- + # Mapowanie kluczy porównania: + # - PBN GET /page/statements zwraca: {personId, area, type, institutionId, ...} + # - Adapter pbn_get_json_statements() zwraca: {personObjectId, disciplineId, + # disciplineUuid, type, ...} + # Klucz porównania: (person mongoId, discipline numerek). Oba na string. + # Selektywny DELETE używa (personId, role) — delete_publication_statement. + + _STATEMENT_RETRY_DELAYS = (2, 4, 8) # exponential backoff przy 3 próbach + + @staticmethod + def _statement_key_pbn(stmt): + """Klucz porównania dla oświadczenia z PBN GET response.""" + return ( + str(stmt.get("personId", "")), + str(stmt.get("area", "")), + ) + + @staticmethod + def _statement_key_intended(stmt): + """Klucz porównania dla oświadczenia z ``pbn_get_json_statements``.""" + return ( + str(stmt.get("personObjectId", "")), + str(stmt.get("disciplineId", "")), + ) + + def _diff_statements(self, pbn_statements, intended_statements): + """Porównuje zestaw oświadczeń PBN z intencją BPP. + + Zwraca (only_in_pbn, only_in_intended) jako sety kluczy + ``(person_mongoId, discipline_numerek)``: + + - ``only_in_pbn`` — do usunięcia z PBN (PBN ma, BPP nie chce) + - ``only_in_intended`` — do dodania do PBN (BPP chce, PBN nie ma) + """ + pbn_keys = {self._statement_key_pbn(s) for s in pbn_statements} + intended_keys = {self._statement_key_intended(s) for s in intended_statements} + return pbn_keys - intended_keys, intended_keys - pbn_keys + + def _report_statements_failure_and_raise( + self, publication_pk, objectId, last_error + ): + """Raportuje do Rollbar (level=warning) i rzuca StatementsResendFailedException.""" + try: + raise StatementsResendFailedException(publication_pk, objectId, last_error) + except StatementsResendFailedException: + rollbar.report_exc_info( + sys.exc_info(), + level="warning", + extra_data={ + "publication_pk": publication_pk, + "pbn_uid": str(objectId), + "last_error": str(last_error), + }, + ) + raise + + def _get_pbn_statements_with_retry(self, objectId, publication_pk, max_tries=3): + """Pobiera oświadczenia publikacji z PBN z retry (exponential backoff). + + Po wyczerpaniu prób: rollbar.report_exc_info(level="warning") oraz + raise ``StatementsResendFailedException``. + """ + last_error = None + for attempt in range(max_tries): + try: + return list( + self.get_institution_statements_of_single_publication( + str(objectId), 5120 + ) + ) + except Exception as e: + last_error = e + logger.warning( + "Błąd pobierania oświadczeń PBN dla %s, próba %d/%d: %s", + objectId, + attempt + 1, + max_tries, + e, + exc_info=True, + ) + if attempt < max_tries - 1: + time.sleep(self._STATEMENT_RETRY_DELAYS[attempt]) + + self._report_statements_failure_and_raise(publication_pk, objectId, last_error) + + def _delete_statements_selective( + self, objectId, pbn_statements_to_delete, publication_pk, max_tries=3 + ): + """Selektywne DELETE oświadczeń per-osoba (delete_publication_statement). + + Iteruje po liście oświadczeń PBN do usunięcia i wywołuje DELETE dla + każdego (klucz: personId + type z PBN GET response). Po wyczerpaniu + prób per oświadczenie: rollbar + raise StatementsResendFailedException. + """ + for stmt in pbn_statements_to_delete: + person_id = stmt.get("personId") + role = stmt.get("type") + last_error = None + success = False + for attempt in range(max_tries): + try: + self.delete_publication_statement(str(objectId), person_id, role) + success = True + break + except Exception as e: + last_error = e + logger.warning( + "Błąd DELETE oświadczenia (%s, %s) dla %s, próba %d/%d: %s", + person_id, + role, + objectId, + attempt + 1, + max_tries, + e, + exc_info=True, + ) + if attempt < max_tries - 1: + time.sleep(self._STATEMENT_RETRY_DELAYS[attempt]) + if not success: + self._report_statements_failure_and_raise( + publication_pk, objectId, last_error + ) + + def _delete_statements_batch(self, objectId, publication_pk, max_tries=3): + """Batch DELETE wszystkich oświadczeń publikacji z retry. + + Rzuca ``CannotDeleteStatementsException`` w górę (caller może + zignorować gdy PBN mówi że nie ma oświadczeń). Po wyczerpaniu prób + dla innych błędów: rollbar + raise StatementsResendFailedException. + """ + last_error = None + for attempt in range(max_tries): + try: + self.delete_all_publication_statements(str(objectId)) + return + except CannotDeleteStatementsException: + raise + except Exception as e: + last_error = e + logger.warning( + "Błąd batch DELETE oświadczeń dla %s, próba %d/%d: %s", + objectId, + attempt + 1, + max_tries, + e, + exc_info=True, + ) + if attempt < max_tries - 1: + time.sleep(self._STATEMENT_RETRY_DELAYS[attempt]) + + self._report_statements_failure_and_raise(publication_pk, objectId, last_error) + + @staticmethod + def _convert_stmt_for_api(stmt): + """Konwersja pojedynczego oświadczenia do formatu akceptowanego przez + ``POST /api/v2/institution-profile/statements``. + + Wejście: oświadczenie w formacie generowanym przez warstwę wywołującą + (klucze ``personObjectId``/``disciplineId``/``disciplineUuid``/``type``). + Format wejścia musi pozostać zgodny z payloadem oświadczeń budowanym + po stronie wołającego — przy zmianie jednego, zmień drugie. + """ + stmt = dict(stmt) # shallow copy — nie modyfikujemy oryginalnego + if "disciplineId" in stmt and "disciplineUuid" in stmt: + del stmt["disciplineId"] + if "type" in stmt: + stmt["personRole"] = stmt.pop("type") + stmt.pop("personNaturalId", None) + return stmt From 082164251d7af6a1c233782f668878aca16894a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 19:48:58 +0200 Subject: [PATCH 046/247] refactor(pbn): Faza 3A - czysty PBNClient w pbn_client, BppPBNClient w pbn_api Czysty PBNClient (pure mixiny + StatementsMixin + command-runner) laduje w pbn_client/client.py. BppPBNClient(PBNClient, PublicationSyncMixin, DisciplinesMixin) definiowany w pbn_api/client z __init__(transport, uczelnia) - klient zna swoja Uczelnia. Fabryki (Uczelnia.pbn_client, PBNBaseCommand. get_client) i fixtura pbn_client zwracaja BppPBNClient. Wariant B: brak osobnego pakietu pbn_client_bpp, orchestracja zostaje w pbn_api. Bez zmiany zachowania - orchestracja wciaz uzywa get_default wewnetrznie (fix get_default->self.uczelnia w Fazie 3B). 232 testy zielone (138 klient + 94 integrator). Co-Authored-By: Claude Opus 4.8 --- src/bpp/models/uczelnia.py | 9 +- src/fixtures/pbn_api.py | 6 +- src/pbn_api/client/__init__.py | 98 +++----------------- src/pbn_api/management/commands/util.py | 7 +- src/pbn_client/__init__.py | 2 + src/pbn_client/client.py | 114 ++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 94 deletions(-) create mode 100644 src/pbn_client/client.py diff --git a/src/bpp/models/uczelnia.py b/src/bpp/models/uczelnia.py index 5029d9920..e932dd3f1 100644 --- a/src/bpp/models/uczelnia.py +++ b/src/bpp/models/uczelnia.py @@ -643,9 +643,12 @@ def wosclient(self): return WoSClient(self.clarivate_username, self.clarivate_password) - def pbn_client(self, pbn_user_token=None) -> "pbn_api.client.PBNClient": + def pbn_client(self, pbn_user_token=None) -> "pbn_api.client.BppPBNClient": """ - Zwraca klienta PBNu + Zwraca klienta PBNu związanego z TĄ uczelnią (``BppPBNClient``). + + Klient zna ``self`` jako swoją ``uczelnia`` — orchestracja czyta z niej + flagi zamiast zgadywać ``get_default()`` (kluczowe dla multi-hosted). """ from pbn_api import client @@ -668,7 +671,7 @@ def authorize(self, base_url, app_id, token): transport = UczelniaTransport( self.pbn_app_name, self.pbn_app_token, self.pbn_api_root, pbn_user_token ) - return client.PBNClient(transport) + return client.BppPBNClient(transport, uczelnia=self) @property def orcid_base_url(self): diff --git a/src/fixtures/pbn_api.py b/src/fixtures/pbn_api.py index 81faddb70..64fcadfaa 100644 --- a/src/fixtures/pbn_api.py +++ b/src/fixtures/pbn_api.py @@ -7,7 +7,7 @@ from bpp import const from bpp.const import RODZAJ_PBN_KSIAZKA from bpp.models import Charakter_Formalny, Jezyk, Uczelnia, Wydawnictwo_Ciagle -from pbn_api.client import PBN_GET_LANGUAGES_URL, PBNClient +from pbn_api.client import PBN_GET_LANGUAGES_URL, BppPBNClient from pbn_api.models import Institution, Language, Publication, Scientist from pbn_api.tests.utils import MockTransport @@ -73,9 +73,9 @@ @pytest.fixture -def pbn_client() -> PBNClient: +def pbn_client(uczelnia) -> BppPBNClient: transport = MockTransport() - return PBNClient(transport=transport) + return BppPBNClient(transport=transport, uczelnia=uczelnia) @pytest.fixture diff --git a/src/pbn_api/client/__init__.py b/src/pbn_api/client/__init__.py index 1c52bb2cb..6ac15b0a2 100644 --- a/src/pbn_api/client/__init__.py +++ b/src/pbn_api/client/__init__.py @@ -5,10 +5,7 @@ Network (PBN) API. """ -import sys -from collections.abc import Iterable -from pprint import pprint - +from pbn_client import PBNClient from pbn_client.auth import OAuthMixin # Re-export constants for backwards compatibility @@ -50,6 +47,7 @@ __all__ = [ # Client classes "PBNClient", + "BppPBNClient", "PBNClientTransport", "RequestsTransport", "PageableResource", @@ -83,87 +81,15 @@ ] -class PBNClient( - ConferencesMixin, - DictionariesMixin, - InstitutionsMixin, - InstitutionsProfileMixin, - JournalsMixin, - PersonMixin, - PublicationsMixin, - PublishersMixin, - SearchMixin, - PublicationSyncMixin, - DisciplinesMixin, -): - """Main client for interacting with the PBN API.""" - - _interactive = False - - def __init__(self, transport: RequestsTransport): - self.transport = transport - - def _get_command_function(self, cmd): - """Get function to execute from command name.""" - try: - return getattr(self, cmd[0]) - except AttributeError as e: - if self._interactive: - print(f"No such command: {cmd}") - return None - raise e - - def _extract_arguments(self, lst): - """Extract positional and keyword arguments from command list.""" - args = () - kw = {} - for elem in lst: - if elem.find(":") >= 1: - k, n = elem.split(":", 1) - kw[k] = n - else: - args += (elem,) - return args, kw - - def _print_non_interactive_result(self, res): - """Print result in non-interactive mode.""" - import json - - print(json.dumps(res)) - - def _print_interactive_result(self, res): - """Print result in interactive mode.""" - if type(res) is dict: - pprint(res) - elif isinstance(res, Iterable): - if self._interactive and hasattr(res, "total_elements"): - print( - "Incoming data: no_elements=", - res.total_elements, - "no_pages=", - res.total_pages, - ) - input("Press ENTER to continue> ") - for elem in res: - pprint(elem) - - def exec(self, cmd): - fun = self._get_command_function(cmd) - if fun is None: - return - - args, kw = self._extract_arguments(cmd[1:]) - res = fun(*args, **kw) +class BppPBNClient(PBNClient, PublicationSyncMixin, DisciplinesMixin): + """Klient PBN świadomy konkretnej ``Uczelnia`` (Warstwa 2, BPP-aware). - if not sys.stdout.isatty(): - self._print_non_interactive_result(res) - else: - self._print_interactive_result(res) + Dziedziczy czyste operacje protokołu z ``pbn_client.PBNClient`` i dokłada + orchestrację synchronizacji BPP↔PBN (``PublicationSyncMixin`` + + ``DisciplinesMixin``). ``uczelnia`` jest JEDYNYM źródłem prawdy o uczelni — + orchestracja czyta z niej flagi zamiast zgadywać ``get_default()``. + """ - def interactive(self): - self._interactive = True - while True: - cmd = input("cmd> ") - if cmd == "exit": - break - self.exec(cmd.split(" ")) + def __init__(self, transport, uczelnia): + super().__init__(transport) + self.uczelnia = uczelnia diff --git a/src/pbn_api/management/commands/util.py b/src/pbn_api/management/commands/util.py index a07e49695..5b3809dbb 100644 --- a/src/pbn_api/management/commands/util.py +++ b/src/pbn_api/management/commands/util.py @@ -3,7 +3,7 @@ from django.core.management import BaseCommand, CommandError from bpp.models import BppUser, Uczelnia -from pbn_api.client import PBNClient, RequestsTransport +from pbn_api.client import BppPBNClient, RequestsTransport from pbn_client.conf import settings @@ -64,6 +64,9 @@ def _fill_pbn_credentials(self, options): konfiguracji wskazanej uczelni, a w ostateczności z ``settings``. """ uczelnia = self._resolve_uczelnia(options.get("uczelnia_id")) + # Zapamiętujemy rozwiązaną uczelnię, żeby get_client zbudował + # BppPBNClient świadomy uczelni (orchestracja czyta z niej flagi). + self._resolved_uczelnia = uczelnia if options.get("app_id") is None: options["app_id"] = ( @@ -102,4 +105,4 @@ def get_client(self, app_id, app_token, base_url, user_token, verbose=False): print("App token\t", app_token) print("Base URL\t", base_url) print("User token\t", user_token) - return PBNClient(transport) + return BppPBNClient(transport, uczelnia=getattr(self, "_resolved_uczelnia", None)) diff --git a/src/pbn_client/__init__.py b/src/pbn_client/__init__.py index 1ae372158..8d781676f 100644 --- a/src/pbn_client/__init__.py +++ b/src/pbn_client/__init__.py @@ -8,6 +8,7 @@ """ from .auth import OAuthMixin +from .client import PBNClient from .mixins import ( ConferencesMixin, DictionariesMixin, @@ -24,6 +25,7 @@ from .utils import smart_content __all__ = [ + "PBNClient", "OAuthMixin", "ConferencesMixin", "DictionariesMixin", diff --git a/src/pbn_client/client.py b/src/pbn_client/client.py new file mode 100644 index 000000000..b9272a01c --- /dev/null +++ b/src/pbn_client/client.py @@ -0,0 +1,114 @@ +"""Czysty klient protokołu PBN (Warstwa 1). + +``PBNClient`` to kompozycja mixinów protokołu (słownikowo-CRUD + silnik +oświadczeń ``StatementsMixin``). Nie zna ``bpp`` ani obiektu ``Uczelnia`` — +operuje na tokenach, PBN UID-ach, JSON-ach i flagach bool. + +Orchestracja synchronizacji BPP↔PBN (znająca rekord i ``Uczelnia``) żyje +w ``pbn_api/client`` jako ``BppPBNClient`` dziedziczący po tej klasie. + +Patrz: docs/superpowers/specs/2026-06-02-pbn-client-split-design.md +""" + +import sys +from collections.abc import Iterable +from pprint import pprint + +from .mixins import ( + ConferencesMixin, + DictionariesMixin, + InstitutionsMixin, + InstitutionsProfileMixin, + JournalsMixin, + PersonMixin, + PublicationsMixin, + PublishersMixin, + SearchMixin, +) +from .statements import StatementsMixin +from .transport import RequestsTransport + + +class PBNClient( + ConferencesMixin, + DictionariesMixin, + InstitutionsMixin, + InstitutionsProfileMixin, + JournalsMixin, + PersonMixin, + PublicationsMixin, + PublishersMixin, + SearchMixin, + StatementsMixin, +): + """Czysty klient protokołu PBN (bez orchestracji BPP, bez wiedzy o Uczelni).""" + + _interactive = False + + def __init__(self, transport: RequestsTransport): + self.transport = transport + + def _get_command_function(self, cmd): + """Get function to execute from command name.""" + try: + return getattr(self, cmd[0]) + except AttributeError as e: + if self._interactive: + print(f"No such command: {cmd}") + return None + raise e + + def _extract_arguments(self, lst): + """Extract positional and keyword arguments from command list.""" + args = () + kw = {} + for elem in lst: + if elem.find(":") >= 1: + k, n = elem.split(":", 1) + kw[k] = n + else: + args += (elem,) + return args, kw + + def _print_non_interactive_result(self, res): + """Print result in non-interactive mode.""" + import json + + print(json.dumps(res)) + + def _print_interactive_result(self, res): + """Print result in interactive mode.""" + if type(res) is dict: + pprint(res) + elif isinstance(res, Iterable): + if self._interactive and hasattr(res, "total_elements"): + print( + "Incoming data: no_elements=", + res.total_elements, + "no_pages=", + res.total_pages, + ) + input("Press ENTER to continue> ") + for elem in res: + pprint(elem) + + def exec(self, cmd): + fun = self._get_command_function(cmd) + if fun is None: + return + + args, kw = self._extract_arguments(cmd[1:]) + res = fun(*args, **kw) + + if not sys.stdout.isatty(): + self._print_non_interactive_result(res) + else: + self._print_interactive_result(res) + + def interactive(self): + self._interactive = True + while True: + cmd = input("cmd> ") + if cmd == "exit": + break + self.exec(cmd.split(" ")) From 41a94f3858ebb6f62fac9665569eac989338962c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 19:49:18 +0200 Subject: [PATCH 047/247] docs(pbn): zaktualizuj spec do Wariantu B (bez pakietu pbn_client_bpp) BppPBNClient zyje w pbn_api/client (nie osobny pakiet); orchestracja i adaptery zostaja w pbn_api. Konieczne jest tylko odpiecie orchestracji od czystego PBNClient, nie jej fizyczne przeniesienie poza pbn_api. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-02-pbn-client-split-design.md | 177 +++++++++--------- 1 file changed, 86 insertions(+), 91 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md b/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md index 89fbda094..8f2fb924b 100644 --- a/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md +++ b/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md @@ -21,42 +21,39 @@ flagi/sterowanie payloadem z **niewłaściwej** uczelni. ## Cel -Rozciąć klienta dokładnie po granicy odpowiedzialności na dwa pakiety: - -- **`src/pbn_client/`** — Warstwa 1, reusable, **kandydat do ekstrakcji jako - osobny pakiet PyPI w przyszłości**. Wie tylko o pojęciach PBN: tokeny, URL-e, - PBN UID instytucji (goła wartość), JSON-y, flagi `bool`. **Nie wolno jej - importować `bpp.models`** (ani niczego z projektu poza `pbn_api.const`-owym - odpowiednikiem, który też się przenosi). -- **`src/pbn_client_bpp/`** — Warstwa 2, „nasza", BPP-aware. Klasa - `BppPBNClient(uczelnia)` budowana **z** obiektu `Uczelnia`; trzyma - orchestrację, adaptery (most rekord BPP → PBN JSON) i odczyt flag uczelni. - **Tu — i tylko tu — żyje wiedza o `Uczelnia`.** `get_default()` znika - z podsystemu. - -Po splicie pytanie „czy `PBNClient` potrzebuje uczelni" znika: czysty klient -nigdy jej nie zna, `BppPBNClient` zawsze ma ją z konstrukcji. - -## Trójpodział odpowiedzialności (cel) - -Dziś `pbn_api` to worek z trzema rolami. Split rozdziela je po naturalnych -szwach: - -- **`pbn_client`** — *protokół* PBN (reusable, do ekstrakcji). Bez Django-modeli. - Najczystszy, ekstrahowalny pierwszy. -- **`pbn_api`** — *dane domenowe PBN*: lustro encji PBN (Publication, - Institution, Journal, Scientist, Publisher, Conference, Discipline, - Language, Country) + słowniki + admin do przeglądania. **Własny - reusable-kandydat** — ekstrahowalny **po** odseparowaniu resztkowego - sklejenia z BPP (osobny przyszły tor). -- **`pbn_client_bpp`** — *logika integracji* BPP↔PBN: `BppPBNClient` + - orchestracja + **adaptery** (rekord BPP → PBN JSON). Wie o `Uczelnia`. - Z natury projektowy (klej), nie-reusable. - -Zakres tego speca to **Poziom 1**: split klienta + przeniesienie `adapters/` -do `pbn_client_bpp`. **Modele zostają w `pbn_api`** — to ich właściwy dom -(dane PBN), nie materiał dla warstwy kleju. Nie ma „Poziomu 2 = przenieś -modele do `pbn_client_bpp`" — to byłby zły kierunek. +**Wariant B (decyzja 2026-06-02):** rozcinamy na DWIE warstwy; nie tworzymy +osobnego pakietu `pbn_client_bpp`. + +- **`src/pbn_client/`** — Warstwa 1, reusable, kandydat do ekstrakcji jako + osobny pakiet PyPI. Wie tylko o pojęciach PBN: tokeny, URL-e, PBN UID, JSON-y, + flagi `bool`. **Nie importuje `bpp` ani `pbn_api`.** Klasa `PBNClient` = + kompozycja czystych mixinów (+ `StatementsMixin`), bez orchestracji. +- **`BppPBNClient` w `pbn_api/client`** — Warstwa 2, BPP-aware: + `BppPBNClient(PBNClient, PublicationSyncMixin, DisciplinesMixin)` z + `__init__(transport, uczelnia)`. Trzyma orchestrację i odczyt flag uczelni; + **tu — i tylko tu — żyje wiedza o `Uczelnia`.** `get_default()` znika + z podsystemu (zastąpione przez `self.uczelnia`). + +Orchestracja i adaptery **ZOSTAJĄ w `pbn_api`**. Wyniesienie ich do osobnego +pakietu rozwiązywałoby problem, którego jeszcze nie mamy (ekstrakcja +`pbn_api`), a ta jest i tak zablokowana przez sklejenie modeli z BPP (patrz +„Nalecialości"). Konieczne jest tylko **odpięcie orchestracji od czystego +`PBNClient`** — nie jej fizyczne przeniesienie poza `pbn_api`. + +Po splicie pytanie „czy `PBNClient` potrzebuje uczelni" znika: czysty +`pbn_client.PBNClient` nigdy jej nie zna, `BppPBNClient` zawsze ma ją +z konstrukcji. + +## Dwupodział odpowiedzialności (cel) + +- **`pbn_client`** — *protokół* PBN (reusable, do ekstrakcji). Bez Django-modeli, + bez `bpp`, bez `pbn_api`. +- **`pbn_api`** — wszystko BPP↔PBN: *dane domenowe PBN* (lustro encji + Publication/Institution/Journal/Scientist/Publisher/Conference/Discipline/ + Language/Country + słowniki + admin) ORAZ *logika integracji* (`BppPBNClient` + + orchestracja + adaptery, znające `Uczelnia`/`Rekord`). Przyszły + reusable-kandydat — dopiero po odseparowaniu sklejenia z BPP (osobny tor; + orchestracja to tylko część tego sklejenia, modele to druga część). ### Nalecialości BPP w `pbn_api` (osobny przyszły tor, nie ten spec) @@ -105,37 +102,33 @@ przenoszone tu: `_diff_statements`, `_statement_key_*`, `_convert_stmt_for_api`, `convert_json_with_statements_to_no_statements`, `_post_publication_data`, `post_publication{,_no_statements}`, `post_publication_fee`, `get_publication_fee{,s_batch}`. -### `src/pbn_client_bpp/` (Warstwa 2, BPP-aware) +### `BppPBNClient` w `pbn_api/client` (Warstwa 2, BPP-aware) -``` -src/pbn_client_bpp/ - __init__.py - client.py # BppPBNClient(PBNClient) — patrz niżej - publication_sync.py# orchestracja: sync_publication, upload_publication, ... - disciplines.py # sync_disciplines (BPP-aware) - adapters/ # PRZENIESIONE z pbn_api: rekord BPP → PBN JSON - # modele persystencji (Publication, SentData, ...) zostają w pbn_api, - # importowane lokalnie w metodach (mniejszy churn, brak migracji) -``` +Brak osobnego pakietu. Pod Wariantem B: -Przeniesienie `adapters/` (Poziom 1): ~5 call-site'ów do aktualizacji importu -(`pbn_wysylka_oswiadczen/tasks.py`, `pbn_api/management/commands/` -`{pbn_show_json, pbn_test_wysylka_interaktywna, pbn_wyslij_oswiadczenia_instytucji}.py`, -oraz wewnętrzny `publication_sync`). Czysty kod — **bez migracji**. Domyka fix -`adapters/wydawnictwo.py:94` (`get_default` → `uczelnia` podane przez -`BppPBNClient`). Dla kompatybilności wstecznej zostaje cienki re-eksport w -`pbn_api/adapters/__init__.py`. +- `pbn_client.PBNClient` = czysta kompozycja (pure mixiny + `StatementsMixin`), + **bez** `PublicationSyncMixin`/`DisciplinesMixin`. Ląduje w + `pbn_client/client.py`. +- `publication_sync.py` (orchestracja) + `disciplines.py` (`DisciplinesMixin`) + **zostają** w `pbn_api/client`. +- `adapters/` **zostają** w `pbn_api/adapters` (bez przenoszenia). Zmiana tylko + behawioralna: orchestracja przekazuje `uczelnia=self.uczelnia` do adaptera, + a `get_default()` z `adapters/wydawnictwo.py:94` znika. +- `pbn_api/client/__init__.py` definiuje `BppPBNClient` i re-eksportuje całość. -`BppPBNClient` **dziedziczy** po `PBNClient` (a nie kompozycja), bo call-site'y -wołają na tym samym obiekcie i metody czyste (`get_journals`), i orchestrację -(`sync_publication`). Dziedziczenie = zero delegacji ~50 metod. +`BppPBNClient` **dziedziczy** po `PBNClient` (nie kompozycja delegująca), bo +call-site'y wołają na tym samym obiekcie i metody czyste (`get_journals`), i +orchestrację (`sync_publication`). Dziedziczenie = zero delegacji ~50 metod. ```python -class BppPBNClient( - PBNClient, # czyste metody HTTP (W1) - PublicationSyncOrchestrationMixin, - DisciplinesBppMixin, -): +# pbn_api/client/__init__.py +from pbn_client import PBNClient + +from .disciplines import DisciplinesMixin +from .publication_sync import PublicationSyncMixin + + +class BppPBNClient(PBNClient, PublicationSyncMixin, DisciplinesMixin): def __init__(self, transport, uczelnia): super().__init__(transport) self.uczelnia = uczelnia # JEDYNE źródło prawdy o uczelni @@ -167,10 +160,10 @@ Trzy flagi uczelni (`pbn_kasuj_dyscypliny_selektywnie`, `pbn_wysylaj_bez_oswiadc `BppPBNClient(transport, uczelnia=self)`. To usuwa `get_default()` z `publication_sync.py:287/1046` i `adapters/wydawnictwo.py:94` — uczelnia jest jawna. **Import `BppPBNClient` musi być lokalny w metodzie** - (cykl `bpp.models.uczelnia → pbn_client_bpp → adapters → bpp.models`). -- `pbn_api.client` zostaje **shimem re-eksportującym CAŁY dotychczasowy - publiczny zestaw** (`__all__`): `PBNClient` (z `pbn_client`), `BppPBNClient` - (z `pbn_client_bpp`), `OAuthMixin`, wszystkie 9 klas mixinów, + (cykl `bpp.models.uczelnia → pbn_api.client → adapters → bpp.models`). +- `pbn_api.client` re-eksportuje **CAŁY dotychczasowy publiczny zestaw** + (`__all__`): `PBNClient` (z `pbn_client`), `BppPBNClient` (zdefiniowany + tutaj), `OAuthMixin`, wszystkie 9 klas mixinów, `RequestsTransport`, `PBNClientTransport`, `PageableResource`, `smart_content` oraz stałe `PBN_*`/`DEFAULT_BASE_URL`/`NEEDS_PBN_AUTH_MSG`. Inaczej pękną importy typu `from pbn_api.client import RequestsTransport`. 35 importów @@ -204,9 +197,8 @@ Widok/zadanie ──get_for_request/uczelnia_id──▶ Uczelnia - `pbn_client` to **czysta biblioteka** (bez modeli/migracji) — nie musi być appką Django ani trafiać do `INSTALLED_APPS`. Konfiguracja PBN przez własny `conf.py` (czyta Django `settings`, ale nie modele). -- `pbn_client_bpp` **dodajemy do `INSTALLED_APPS`** z własnym `apps.py` - (`AppConfig`) — może mieć szablony/adminy/testy; modele na razie zostają - w `pbn_api`. +- `BppPBNClient` żyje w `pbn_api/client` — **żadnej nowej appki Django**, zero + zmian w `INSTALLED_APPS`, zero migracji. `pbn_api` pozostaje appką jak dziś. - **Testy:** istniejące `pbn_api/tests/test_client*.py` zostają, importując przez shim `pbn_api.client` — dzięki temu „zielone" przez całą migrację. Relokacja testów czystych do `pbn_client/tests/` jest opcjonalna i późniejsza. @@ -218,30 +210,32 @@ Widok/zadanie ──get_for_request/uczelnia_id──▶ Uczelnia | Ryzyko | Mitygacja | |---|---| -| Cykl importów `pbn_client_bpp` ↔ `pbn_api` (modele/adaptery) | Importy lokalne w metodach (jak dziś w `publication_sync.py`) | -| Pęknięcie 35 importów `pbn_api.client.PBNClient` | Shim re-eksportujący w `pbn_api/client/__init__.py` | +| Cykl importów `bpp.models.uczelnia → pbn_api.client → adapters → bpp.models` | `Uczelnia.pbn_client()` importuje `BppPBNClient` **lokalnie w metodzie** | +| Pęknięcie 35 importów `pbn_api.client.PBNClient` | `pbn_api.client` re-eksportuje pełny `__all__` (`PBNClient` z `pbn_client` + `BppPBNClient`) | | CLI `get_client` → orchestracja na czystym `PBNClient` (brak metod) | `get_client` zwraca `BppPBNClient` z `_resolve_uczelnia` | -| Regresja w cięciu `statements.py` | Najpierw wydzielić W1 z re-eksportem i **zielonymi testami**, dopiero potem wyciąć orchestrację | +| Czysty `PBNClient` instancjonowany wprost (`importer_publikacji`) i wołający orchestrację | Sprawdzić: te call-site'y wołają tylko czyste metody; orchestracja idzie przez fabrykę (`BppPBNClient`) | | Brak pokrycia multi-hosted | Dodać fixture `dwie_uczelnie` + test: właściwy `pbn_app_token` w transporcie i flagi z właściwej uczelni | ## Plan etapowy (kolejność krytyczna) -1. **W1 bez ruszania zachowania:** utwórz `src/pbn_client/`, przenieś czyste - moduły (transport, auth, pagination, utils, const/exceptions/conf — część - PBN, 8 mixinów). `pbn_api.client` re-eksportuje. Zielone testy. -2. **Wytnij `statements.py`** (czysty silnik) z `publication_sync.py` do W1. - Zielone testy. -3. **W2:** utwórz `src/pbn_client_bpp/` z `BppPBNClient(PBNClient)` + - orchestracją (BPP-owe części `publication_sync` + `DisciplinesMixin`). - `get_default()` w orchestracji zastąpiony przez `self.uczelnia`. -4. **Przenieś `adapters/`** z `pbn_api` do `pbn_client_bpp`; zaktualizuj ~5 - importów; re-eksport w `pbn_api/adapters/__init__.py`. Adapter dostaje - `uczelnia=self.uczelnia` z `BppPBNClient`; `get_default()` z adaptera - usunięty. Zielone testy. -5. **Fabryki:** `Uczelnia.pbn_client()` i `get_client()` zwracają - `BppPBNClient`. Shim re-eksportuje `BppPBNClient`. -6. **Fixture + testy multi-hosted.** Weryfikacja, że właściwa uczelnia steruje - payloadem (token w transporcie + 3 flagi + adapter). +1. ✅ **W1 bez ruszania zachowania:** `src/pbn_client/`, czyste moduły + przeniesione, `pbn_api.client` re-eksportuje. (Faza 1, zrobione.) +2. ✅ **`StatementsMixin`** wycięty z `publication_sync.py` do + `pbn_client/statements.py`. (Faza 2, zrobione.) +3. **Czysty `PBNClient` → `pbn_client/client.py`:** klasa = pure mixiny + + `StatementsMixin` (bez `PublicationSyncMixin`/`DisciplinesMixin`). + `pbn_client.__init__` eksportuje `PBNClient`. Zielone testy. +4. **`BppPBNClient` w `pbn_api/client/__init__.py`:** + `BppPBNClient(PBNClient, PublicationSyncMixin, DisciplinesMixin)` z + `__init__(transport, uczelnia)`. `pbn_api.client` re-eksportuje pełny `__all__` + (`PBNClient` + `BppPBNClient` + reszta). Zielone testy. +5. **Fix multi-hosted (zmiana zachowania):** w `publication_sync.py` + `get_default()` → `self.uczelnia`; orchestracja przekazuje + `uczelnia=self.uczelnia` do `WydawnictwoPBNAdapter`, `get_default()` z + `adapters/wydawnictwo.py` znika. Fabryki: `Uczelnia.pbn_client()` (lokalny + import) i `PBNBaseCommand.get_client()` zwracają `BppPBNClient`. +6. **Fixture `dwie_uczelnie` + testy multi-hosted.** Weryfikacja, że właściwa + uczelnia steruje payloadem (token w transporcie + 3 flagi + adapter). 7. **(poza tym specem)** pozostałe znaleziska audytu Tier 🔴/🟠 nie-PBN (ORCID, `importer_publikacji/providers/pbn.py`, `importer_autorow_pbn`) oraz wątek `get_default` jako follow-up. @@ -249,8 +243,9 @@ Widok/zadanie ──get_for_request/uczelnia_id──▶ Uczelnia ## Poza zakresem - Szeroki refaktor `get_default` (osobny, **następny** wątek; patrz audyt). -- Izolacja nalecialości BPP w `pbn_api` (FK `uczelnia`/`Rekord`, `matchuj_*`, - `LinkDoPBNMixin`) — warunek ekstrakcji `pbn_api`, osobny przyszły tor. - Modele **nie** są przenoszone do `pbn_client_bpp`. +- Izolacja nalecialości BPP w `pbn_api` (orchestracja + adaptery + FK + `uczelnia`/`Rekord` w modelach + `matchuj_*` + `LinkDoPBNMixin`) — warunek + ekstrakcji `pbn_api`, osobny przyszły tor. W tym specu nic z `pbn_api` nie + jest wynoszone do osobnego pakietu. - Fizyczna ekstrakcja `pbn_client` / `pbn_api` do osobnych repo/PyPI (dopiero gdy warstwy są stabilne i odseparowane). From 1eaf4d18da5526ec7b28aaf725332815d6446dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 20:02:11 +0200 Subject: [PATCH 048/247] fix(pbn): Faza 3B - orchestracja czyta uczelnie z self.uczelnia, nie get_default publication_sync.py: Uczelnia.objects.get_default() -> self.uczelnia (2 miejsca: _pre_upload_clear i sync_publication); WydawnictwoPBNAdapter dostaje uczelnia=self.uczelnia (3 miejsca). To wlasciwy fix multi-hosted: klient czyta flagi (pbn_kasuj_dyscypliny_selektywnie, pbn_wysylaj_bez_oswiadczen, pbn_api_nie_wysylaj_prac_bez_pk) z WLASNEJ uczelni, nie pierwszej-z-brzegu. Fixtura pbn_uczelnia zwraca pbn_client.uczelnia (ten sam obiekt, nie swiezy get_default) - inaczej mutacje flag w tescie nie sa widziane przez klienta. Nowy test_multihosted.py: dwie uczelnie, klient wybiera strategie wg swojej (failowalby przed 3B). Wewnetrzny get_default adaptera zostaje jako fallback dla nie-zmigrowanych callerow (Phase 7). 197+2 testy zielone. Co-Authored-By: Claude Opus 4.8 --- src/fixtures/pbn_api.py | 10 ++-- src/pbn_api/client/publication_sync.py | 15 +++-- src/pbn_api/tests/test_multihosted.py | 79 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 src/pbn_api/tests/test_multihosted.py diff --git a/src/fixtures/pbn_api.py b/src/fixtures/pbn_api.py index 64fcadfaa..68e7bfcf6 100644 --- a/src/fixtures/pbn_api.py +++ b/src/fixtures/pbn_api.py @@ -227,11 +227,11 @@ def pbn_wydawnictwo_zwarte_z_charakterem( @pytest.fixture def pbn_uczelnia(pbn_client) -> Uczelnia: - uczelnia = Uczelnia.objects.get_default() - if uczelnia is None: - uczelnia = baker.make( - Uczelnia, - ) + # MUSI być TYM SAMYM obiektem, który trzyma klient (``self.uczelnia``), + # bo orchestracja czyta flagi z obiektu w pamięci klienta. Świeży + # ``get_default()`` zwróciłby inny obiekt Pythona (ten sam wiersz DB), + # przez co mutacje flag w teście nie byłyby widziane przez klienta. + uczelnia = pbn_client.uczelnia uczelnia.pbn_client = lambda *args, **kw: pbn_client pbn_client.transport.return_values[PBN_GET_LANGUAGES_URL] = {"1": "23"} diff --git a/src/pbn_api/client/publication_sync.py b/src/pbn_api/client/publication_sync.py index 708c49818..f6532d6c2 100644 --- a/src/pbn_api/client/publication_sync.py +++ b/src/pbn_api/client/publication_sync.py @@ -67,6 +67,7 @@ def _prepare_publication_json(self, rec, export_pk_zero, always_affiliate_to_uid """ js = WydawnictwoPBNAdapter( rec, + uczelnia=self.uczelnia, export_pk_zero=export_pk_zero, always_affiliate_to_uid=always_affiliate_to_uid, ).pbn_get_json() @@ -108,8 +109,6 @@ def _pre_upload_clear_pbn_statements_if_any(self, rec): chcemy wysłać publikacji do API które za chwilę odrzuci nas z powodu pozostałych oświadczeń. """ - from bpp.models import Uczelnia - pbn_uid = rec.pbn_uid_id if not pbn_uid: return @@ -134,7 +133,7 @@ def _pre_upload_clear_pbn_statements_if_any(self, rec): if not pbn_statements: return - uczelnia = Uczelnia.objects.get_default() + uczelnia = self.uczelnia kasuj_selektywnie = ( uczelnia.pbn_kasuj_dyscypliny_selektywnie if uczelnia else True ) @@ -279,7 +278,7 @@ def _build_post_statements_payload(self, rec, filter_keys=None): Zwraca ``None`` gdy zestaw po filtrowaniu jest pusty (brak sensu POST-ować pustą listę). """ - adapter = WydawnictwoPBNAdapter(rec) + adapter = WydawnictwoPBNAdapter(rec, uczelnia=self.uczelnia) # Zawsze wywołujemy pbn_get_api_statements — daje publicationUuid # i pełen zestaw dla trybu bez-filtra. Może rzucić @@ -395,7 +394,9 @@ def _sync_statements_with_pbn( publication_pk = rec.pk pbn_statements = self._get_pbn_statements_with_retry(objectId, publication_pk) - intended = WydawnictwoPBNAdapter(rec).pbn_get_json_statements() + intended = WydawnictwoPBNAdapter( + rec, uczelnia=self.uczelnia + ).pbn_get_json_statements() only_in_pbn, only_in_intended = self._diff_statements(pbn_statements, intended) @@ -644,8 +645,6 @@ def sync_publication( # noqa: C901 a tryb kasowania sterowany jest przez ``Uczelnia.pbn_kasuj_dyscypliny_selektywnie``. """ - from bpp.models import Uczelnia - pub = self.eventually_coerce_to_publication(pub) # KROK 1: POST publikacji do endpointu repo (zawsze) @@ -703,7 +702,7 @@ def sync_publication( # noqa: C901 ) # KROK 5: synchronizacja oświadczeń (split flow, po wysyłce publikacji) - uczelnia = Uczelnia.objects.get_default() + uczelnia = self.uczelnia kasuj_selektywnie = ( uczelnia.pbn_kasuj_dyscypliny_selektywnie if uczelnia else True ) diff --git a/src/pbn_api/tests/test_multihosted.py b/src/pbn_api/tests/test_multihosted.py new file mode 100644 index 000000000..096006113 --- /dev/null +++ b/src/pbn_api/tests/test_multihosted.py @@ -0,0 +1,79 @@ +"""Testy poprawności wielouczelnianej (multi-hosted) dla BppPBNClient. + +Dowodzą, że orchestracja czyta konfigurację z ``self.uczelnia`` (uczelni, +z której zbudowano klienta), a NIE z ``Uczelnia.objects.get_default()`` +(pierwszej-z-brzegu). Te testy failowałyby przed Fazą 3B. + +Patrz: docs/superpowers/specs/2026-06-02-pbn-client-split-design.md +""" + +from unittest.mock import MagicMock + +import pytest +from model_bakery import baker + +from bpp.models import Uczelnia +from pbn_api.client import BppPBNClient +from pbn_api.tests.utils import MockTransport + + +@pytest.mark.django_db +def test_pre_upload_clear_uzywa_flagi_z_wlasnej_uczelni_nie_get_default(): + """``_pre_upload_clear`` wybiera batch/selective wg ``self.uczelnia``. + + uczelnia_a (pierwsza, ``get_default()``) ma ``selektywnie=True``; + uczelnia_b (ta, z której zbudowano klienta) ma ``selektywnie=False``. + Klient MUSI wybrać batch DELETE (wg b), nie selective (wg a). + """ + uczelnia_a = baker.make(Uczelnia, pbn_kasuj_dyscypliny_selektywnie=True) + uczelnia_b = baker.make(Uczelnia, pbn_kasuj_dyscypliny_selektywnie=False) + + # Założenie testu: get_default() zwraca uczelnię INNĄ niż ta klienta. + assert Uczelnia.objects.get_default() == uczelnia_a + assert uczelnia_a != uczelnia_b + + client = BppPBNClient(MockTransport(), uczelnia=uczelnia_b) + client.get_institution_statements_of_single_publication = MagicMock( + return_value=[ + {"id": "x", "personId": "p1", "area": "301", "type": "AUTHOR"} + ] + ) + client._delete_statements_selective = MagicMock() + client._delete_statements_batch = MagicMock() + + rec = MagicMock(pbn_uid_id="PBN-UID-123", pk=42) + client._pre_upload_clear_pbn_statements_if_any(rec) + + # uczelnia_b: selektywnie=False -> BATCH. Gdyby kod używał get_default() + # (=uczelnia_a, True) -> SELECTIVE. To jest sedno multi-hosted. + client._delete_statements_batch.assert_called_once() + client._delete_statements_selective.assert_not_called() + + +@pytest.mark.django_db +def test_dwoch_klientow_czyta_wlasne_flagi_niezaleznie(): + """Dwa klienty związane z różnymi uczelniami czytają swoje flagi.""" + uczelnia_selektywna = baker.make( + Uczelnia, pbn_kasuj_dyscypliny_selektywnie=True + ) + uczelnia_batch = baker.make(Uczelnia, pbn_kasuj_dyscypliny_selektywnie=False) + + statements = [{"id": "x", "personId": "p1", "area": "301", "type": "AUTHOR"}] + + for uczelnia, expect_batch in ( + (uczelnia_selektywna, False), + (uczelnia_batch, True), + ): + client = BppPBNClient(MockTransport(), uczelnia=uczelnia) + client.get_institution_statements_of_single_publication = MagicMock( + return_value=list(statements) + ) + client._delete_statements_selective = MagicMock() + client._delete_statements_batch = MagicMock() + + client._pre_upload_clear_pbn_statements_if_any( + MagicMock(pbn_uid_id="PBN-UID-1", pk=1) + ) + + assert client._delete_statements_batch.called is expect_batch + assert client._delete_statements_selective.called is not expect_batch From 79f79bb0d8ea5d2245c13316e36d43c8b645b2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 20:02:11 +0200 Subject: [PATCH 049/247] docs(pbn): spec - zaznacz Faze 3B/test multi-hosted jako zrobione Co-Authored-By: Claude Opus 4.8 --- .../2026-06-02-pbn-client-split-design.md | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md b/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md index 8f2fb924b..9b65f537c 100644 --- a/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md +++ b/docs/superpowers/specs/2026-06-02-pbn-client-split-design.md @@ -112,8 +112,11 @@ Brak osobnego pakietu. Pod Wariantem B: - `publication_sync.py` (orchestracja) + `disciplines.py` (`DisciplinesMixin`) **zostają** w `pbn_api/client`. - `adapters/` **zostają** w `pbn_api/adapters` (bez przenoszenia). Zmiana tylko - behawioralna: orchestracja przekazuje `uczelnia=self.uczelnia` do adaptera, - a `get_default()` z `adapters/wydawnictwo.py:94` znika. + behawioralna: orchestracja przekazuje `uczelnia=self.uczelnia` do adaptera. + Wewnętrzny `get_default()` adaptera (`wydawnictwo.py:94`) **zostaje jako + fallback** — ścieżka `publication_sync` już go nie dotyka (jawna uczelnia), + ale inni callerzy adaptera (`pbn_wysylka_oswiadczen/tasks`, management + commands) wciąż na nim polegają. Ich migracja + usunięcie fallbacku = Phase 7. - `pbn_api/client/__init__.py` definiuje `BppPBNClient` i re-eksportuje całość. `BppPBNClient` **dziedziczy** po `PBNClient` (nie kompozycja delegująca), bo @@ -229,13 +232,14 @@ Widok/zadanie ──get_for_request/uczelnia_id──▶ Uczelnia `BppPBNClient(PBNClient, PublicationSyncMixin, DisciplinesMixin)` z `__init__(transport, uczelnia)`. `pbn_api.client` re-eksportuje pełny `__all__` (`PBNClient` + `BppPBNClient` + reszta). Zielone testy. -5. **Fix multi-hosted (zmiana zachowania):** w `publication_sync.py` - `get_default()` → `self.uczelnia`; orchestracja przekazuje - `uczelnia=self.uczelnia` do `WydawnictwoPBNAdapter`, `get_default()` z - `adapters/wydawnictwo.py` znika. Fabryki: `Uczelnia.pbn_client()` (lokalny - import) i `PBNBaseCommand.get_client()` zwracają `BppPBNClient`. -6. **Fixture `dwie_uczelnie` + testy multi-hosted.** Weryfikacja, że właściwa - uczelnia steruje payloadem (token w transporcie + 3 flagi + adapter). +5. ✅ **Fix multi-hosted (3B, zmiana zachowania):** w `publication_sync.py` + `get_default()` → `self.uczelnia` (2 miejsca); orchestracja przekazuje + `uczelnia=self.uczelnia` do `WydawnictwoPBNAdapter` (3 miejsca). Fabryki + zwracają `BppPBNClient` (zrobione w 3A). Wewnętrzny `get_default()` adaptera + zostaje jako fallback dla nie-zmigrowanych callerów (Phase 7). +6. ✅ **Test multi-hosted** (`test_multihosted.py`): dwie uczelnie z różnymi + flagami; klient związany z drugą wybiera batch/selective wg SWOJEJ uczelni, + nie `get_default()` (pierwszej). Failowałby przed 3B. 7. **(poza tym specem)** pozostałe znaleziska audytu Tier 🔴/🟠 nie-PBN (ORCID, `importer_publikacji/providers/pbn.py`, `importer_autorow_pbn`) oraz wątek `get_default` jako follow-up. From 2e78ad12d0e453f6ee841ca8e19931eb5770fd2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 20:12:39 +0200 Subject: [PATCH 050/247] fix(pbn): multi-hosted - ORCID i filtr autorow uzywaja uczelni z requestu orcid_integration/views._get_orcid_client: get_default() -> get_for_request (credentiale ORCID sa per-uczelnia). importer_autorow_pbn/views: objects.default -> get_for_request przy filtrze listy naukowcow po pbn_uid uczelni. Co-Authored-By: Claude Opus 4.8 --- src/importer_autorow_pbn/views.py | 5 ++++- src/orcid_integration/views.py | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/importer_autorow_pbn/views.py b/src/importer_autorow_pbn/views.py index 8644331cc..4283b797d 100644 --- a/src/importer_autorow_pbn/views.py +++ b/src/importer_autorow_pbn/views.py @@ -66,7 +66,10 @@ def get_queryset(self): # When "pokaz_wszystkich" param is set to "1", show all pokaz_wszystkich = self.request.GET.get("pokaz_wszystkich", "0") if pokaz_wszystkich != "1": - uczelnia = Uczelnia.objects.default + # Uczelnia z requestu (multi-hosted): filtr po pbn_uid „naszej" + # uczelni musi dotyczyć uczelni bieżącego hosta, nie pierwszej + # z brzegu (get_default). + uczelnia = Uczelnia.objects.get_for_request(self.request) if uczelnia and uczelnia.pbn_uid_id: # Filter by institution ID in currentEmployments JSON queryset = queryset.filter( diff --git a/src/orcid_integration/views.py b/src/orcid_integration/views.py index 0dae8e42a..b960c3898 100644 --- a/src/orcid_integration/views.py +++ b/src/orcid_integration/views.py @@ -25,8 +25,13 @@ def _safe_next_url(next_url, request): def _get_orcid_client(request): - """Return ``(uczelnia, OrcidClient)`` or raise Http404.""" - uczelnia = Uczelnia.objects.get_default() + """Return ``(uczelnia, OrcidClient)`` or raise Http404. + + Uczelnia z requestu (multi-hosted): credentiale ORCID są per-uczelnia, + więc NIE wolno zgadywać ``get_default()`` — inaczej w instalacji + wielouczelnianej logowalibyśmy do konta ORCID złej uczelni. + """ + uczelnia = Uczelnia.objects.get_for_request(request) if uczelnia is None or not uczelnia.orcid_enabled: raise Http404 From 2f2d137513ecfe8af15aaa87c77baf7fc6b77c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 20:26:24 +0200 Subject: [PATCH 051/247] fix(pbn): multi-hosted importer_publikacji - ImportSession.uczelnia steruje PBN Dodaje FK ImportSession.uczelnia (migracja 0012, null=True) ustawiane z get_for_request przy tworzeniu sesji (wizard). Uczelnia plynie do: - providers/pbn._get_pbn_client(uczelnia) - wymaga jawnej uczelni, koniec get_default; PbnProvider.fetch uzywa self.uczelnia (ustawiane przez get_provider(name, uczelnia=...) w fetch_session_task); - pbn_check._populate_pbn_result / _check_pbn_by_doi - session.uczelnia; - create_publication_task -> _PbnRequestStub(_uczelnia=session.uczelnia) -> wpis w kolejce dostaje wlasciwa uczelnie (koniec get_default w tym chainie). Nowy test: fetch buduje klienta z uczelni providera, nie get_default. 413+62 testow zielonych, ruff czysty, brak brakujacych migracji. Co-Authored-By: Claude Opus 4.8 --- .../migrations/0012_importsession_uczelnia.py | 20 ++++++++++++++ src/importer_publikacji/models.py | 12 +++++++++ src/importer_publikacji/providers/__init__.py | 11 ++++++-- src/importer_publikacji/providers/pbn.py | 12 +++++---- src/importer_publikacji/tasks.py | 26 +++++++++++-------- .../tests/test_pbn_provider.py | 17 ++++++++++++ src/importer_publikacji/views/pbn_check.py | 5 ++-- src/importer_publikacji/views/wizard.py | 1 + 8 files changed, 83 insertions(+), 21 deletions(-) create mode 100644 src/importer_publikacji/migrations/0012_importsession_uczelnia.py diff --git a/src/importer_publikacji/migrations/0012_importsession_uczelnia.py b/src/importer_publikacji/migrations/0012_importsession_uczelnia.py new file mode 100644 index 000000000..9b764da36 --- /dev/null +++ b/src/importer_publikacji/migrations/0012_importsession_uczelnia.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.14 on 2026-06-02 18:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0423_merge_20260602_1430'), + ('importer_publikacji', '0011_merge_20260601_0632'), + ] + + operations = [ + migrations.AddField( + model_name='importsession', + name='uczelnia', + field=models.ForeignKey(blank=True, help_text='Uczelnia hosta, z którego utworzono sesję (multi-hosted). Steruje konfiguracją PBN użytą do sprawdzenia/eksportu.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_publikacji_sessions', to='bpp.uczelnia', verbose_name='uczelnia'), + ), + ] diff --git a/src/importer_publikacji/models.py b/src/importer_publikacji/models.py index 4b657083b..8acc8233d 100644 --- a/src/importer_publikacji/models.py +++ b/src/importer_publikacji/models.py @@ -36,6 +36,18 @@ class Status(models.TextChoices): related_name="importer_modified_sessions", verbose_name="ostatnio zmodyfikował", ) + uczelnia = models.ForeignKey( + "bpp.Uczelnia", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="importer_publikacji_sessions", + verbose_name="uczelnia", + help_text=( + "Uczelnia hosta, z którego utworzono sesję (multi-hosted). " + "Steruje konfiguracją PBN użytą do sprawdzenia/eksportu." + ), + ) provider_name = models.CharField( "dostawca danych", max_length=50, diff --git a/src/importer_publikacji/providers/__init__.py b/src/importer_publikacji/providers/__init__.py index e69dc7f06..b5905bdb8 100644 --- a/src/importer_publikacji/providers/__init__.py +++ b/src/importer_publikacji/providers/__init__.py @@ -38,6 +38,11 @@ class FetchedPublication: class DataProvider(ABC): """Bazowa klasa abstrakcyjna dla dostawców danych.""" + # Uczelnia kontekstu (multi-hosted) — ustawiana przez ``get_provider``. + # Dostawcy zależni od konfiguracji PBN (``PbnProvider``) czytają z niej + # credentiale; dostawcy niezależni (CrossRef, DSpace) ignorują. + uczelnia = None + @property @abstractmethod def name(self) -> str: @@ -83,8 +88,10 @@ def register_provider( return provider_cls -def get_provider(name: str) -> DataProvider: - return _providers[name]() +def get_provider(name: str, uczelnia=None) -> DataProvider: + provider = _providers[name]() + provider.uczelnia = uczelnia + return provider def get_available_providers() -> list[str]: diff --git a/src/importer_publikacji/providers/pbn.py b/src/importer_publikacji/providers/pbn.py index ee1a7dbcd..8373024aa 100644 --- a/src/importer_publikacji/providers/pbn.py +++ b/src/importer_publikacji/providers/pbn.py @@ -33,13 +33,15 @@ } -def _get_pbn_client(uczelnia=None): - from bpp.models import Uczelnia +def _get_pbn_client(uczelnia): + """Buduje read-only klienta PBN z app-credentiali podanej uczelni. + + Wymaga JAWNEJ uczelni (multi-hosted) — bez zgadywania ``get_default()``. + Caller (provider/widok) ma uczelnię z requestu lub z ``ImportSession``. + """ from pbn_api.client import PBNClient from pbn_api.client.transport import RequestsTransport - if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() if not uczelnia or not all( [ uczelnia.pbn_app_name, @@ -211,7 +213,7 @@ def fetch(self, identifier: str) -> FetchedPublication | None: return None try: - client = _get_pbn_client() + client = _get_pbn_client(self.uczelnia) except ValueError: logger.warning("Brak konfiguracji PBN w Uczelnia") return None diff --git a/src/importer_publikacji/tasks.py b/src/importer_publikacji/tasks.py index d0b53854f..d02546540 100644 --- a/src/importer_publikacji/tasks.py +++ b/src/importer_publikacji/tasks.py @@ -40,7 +40,7 @@ def fetch_session_task(self, session_id, request_user_id): session = ImportSession.objects.get(pk=session_id) try: report_progress(self, "provider_fetch", stages=FETCH_STAGES) - provider = get_provider(session.provider_name) + provider = get_provider(session.provider_name, uczelnia=session.uczelnia) result = provider.fetch(session.identifier) if result is None: # Oczekiwany failure: dostawca nie zna identyfikatora. @@ -174,9 +174,10 @@ def create_publication_task(self, session_id, request_user_id, also_pbn): storage). Helper używa ``request.user`` do utworzenia wpisu w PBN_Export_Queue oraz wywołań ``messages.success/info/error/warning``; no-op ``_messages`` połyka te komunikaty bez błędu (w Celery worker-ze - nie ma sensownego odbiorcy dla flash messages). ``Uczelnia.get_for_request`` - bezpiecznie spada do ``get_default()`` gdy ``hasattr(request, "_uczelnia")`` - jest False, więc nie trzeba podstawiać sesji / cache uczelni. + nie ma sensownego odbiorcy dla flash messages). Uczelnia z + ``session.uczelnia`` jest podstawiana do request-stubu (``_uczelnia``), + więc wpis w kolejce dostaje WŁAŚCIWĄ uczelnię (multi-hosted) — bez + zgadywania ``get_default()``. """ from django.contrib.auth import get_user_model @@ -191,7 +192,7 @@ def create_publication_task(self, session_id, request_user_id, also_pbn): if also_pbn: report_progress(self, "link_pbn", stages=CREATE_STAGES) - _enqueue_pbn_export(request_user, record) + _enqueue_pbn_export(request_user, record, session.uczelnia) session.status = ImportSession.Status.COMPLETED session.created_record_content_type = ContentType.objects.get_for_model(record) @@ -243,26 +244,29 @@ class _PbnRequestStub: ``PBN_Export_Queue.objects.sprobuj_utowrzyc_wpis(request.user, obj)``). - ``_messages``: storage dla flash messages (helper woła ``messages.success/info/error/warning``). - Nieobecność ``_uczelnia`` jest OK — ``Uczelnia.get_for_request`` - fallbackuje do ``get_default()`` gdy ``hasattr(request, "_uczelnia")`` - jest False. + - ``_uczelnia``: uczelnia z ``ImportSession`` (multi-hosted). Dzięki + niej ``Uczelnia.get_for_request(stub)`` zwraca WŁAŚCIWĄ uczelnię, + zamiast zgadywać ``get_default()`` (pierwszą-z-brzegu). """ - def __init__(self, user): + def __init__(self, user, uczelnia=None): self.user = user self._messages = _NoopMessageStorage() + self._uczelnia = uczelnia -def _enqueue_pbn_export(request_user, record): +def _enqueue_pbn_export(request_user, record, uczelnia): """Wywołaj helper PBN export z minimalnym request stubem. Decision: B1 (patrz docstring create_publication_task). Helper wymaga ``request.user`` (do utworzenia wpisu w kolejce) oraz ``request._messages`` (do wywołań flash messages — odrzucane). + ``uczelnia`` (z ``ImportSession``) wędruje do stubu, żeby wpis w + kolejce dostał WŁAŚCIWĄ uczelnię (multi-hosted), a nie ``get_default``. """ from bpp.admin.helpers.pbn_api.gui import ( sprobuj_utworzyc_zlecenie_eksportu_do_PBN_gui, ) - request_stub = _PbnRequestStub(request_user) + request_stub = _PbnRequestStub(request_user, uczelnia=uczelnia) sprobuj_utworzyc_zlecenie_eksportu_do_PBN_gui(request_stub, record) diff --git a/src/importer_publikacji/tests/test_pbn_provider.py b/src/importer_publikacji/tests/test_pbn_provider.py index 2e8862f3a..1310d8357 100644 --- a/src/importer_publikacji/tests/test_pbn_provider.py +++ b/src/importer_publikacji/tests/test_pbn_provider.py @@ -222,6 +222,23 @@ def test_fetch_success_article(mock_get_client, mock_save): mock_client.get_publication_by_id.assert_called_once_with(SAMPLE_PBN_UID) +@patch("importer_publikacji.providers.pbn._save_to_pbn_publication") +@patch("importer_publikacji.providers.pbn._get_pbn_client") +def test_fetch_uzywa_uczelni_providera_nie_get_default(mock_get_client, mock_save): + """Multi-hosted: ``fetch`` buduje klienta z uczelni ustawionej na + providerze (z ``ImportSession.uczelnia``), nie zgaduje ``get_default()``.""" + mock_client = MagicMock() + mock_client.get_publication_by_id.return_value = SAMPLE_PBN_ARTICLE + mock_get_client.return_value = mock_client + + sentinel_uczelnia = object() + provider = PBNProvider() + provider.uczelnia = sentinel_uczelnia + provider.fetch(SAMPLE_PBN_UID) + + mock_get_client.assert_called_once_with(sentinel_uczelnia) + + @patch("importer_publikacji.providers.pbn._save_to_pbn_publication") @patch("importer_publikacji.providers.pbn._get_pbn_client") def test_fetch_saves_to_publication(mock_get_client, mock_save): diff --git a/src/importer_publikacji/views/pbn_check.py b/src/importer_publikacji/views/pbn_check.py index 193dd6705..dc7898d4d 100644 --- a/src/importer_publikacji/views/pbn_check.py +++ b/src/importer_publikacji/views/pbn_check.py @@ -7,7 +7,6 @@ from django.db import IntegrityError -from bpp.models import Uczelnia from import_common.normalization import normalize_doi logger = logging.getLogger(__name__) @@ -91,7 +90,7 @@ def _populate_pbn_result(result, data, session): return result["pbn_mongo_id"] = mongo_id - uczelnia = Uczelnia.objects.get_default() + uczelnia = session.uczelnia if uczelnia and uczelnia.pbn_api_root: from bpp.const import LINK_PBN_DO_PUBLIKACJI @@ -128,7 +127,7 @@ def _check_pbn_by_doi(session): try: from ..providers.pbn import _get_pbn_client - client = _get_pbn_client() + client = _get_pbn_client(session.uczelnia) except Exception as e: logger.warning("Nie można utworzyć klienta PBN: %s", e) return None diff --git a/src/importer_publikacji/views/wizard.py b/src/importer_publikacji/views/wizard.py index b3d7c97d9..0192613ea 100644 --- a/src/importer_publikacji/views/wizard.py +++ b/src/importer_publikacji/views/wizard.py @@ -142,6 +142,7 @@ def post(self, request): session = ImportSession.objects.create( created_by=request.user, + uczelnia=Uczelnia.objects.get_for_request(request), provider_name=provider_name, identifier=normalized, status=ImportSession.Status.FETCHING, From 7af1d56a6b2ea7f18e2380e653cae12552e8e05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 20:34:03 +0200 Subject: [PATCH 052/247] fix(pbn): callerzy adaptera przekazuja jawna uczelnie (multi-hosted) process_single_publication (pbn_wysylka_oswiadczen/tasks): adapter dostaje uczelnia=pbn_client.uczelnia (runtime, wazne). pbn_show_json i pbn_test_wysylka_interaktywna: adapter dostaje self._resolved_uczelnia (PBNBaseCommand). Fallback get_default w adapterze ZOSTAJE jako defensywny - wszystkie sciezki runtime przekazuja juz jawna uczelnie; pelne usuniecie fallbacku + migracja pbn_wyslij (pre-existing C901) odlozone. Co-Authored-By: Claude Opus 4.8 --- src/pbn_api/management/commands/pbn_show_json.py | 2 +- .../commands/pbn_test_wysylka_interaktywna.py | 16 ++++++++++++---- src/pbn_wysylka_oswiadczen/tasks.py | 4 +++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/pbn_api/management/commands/pbn_show_json.py b/src/pbn_api/management/commands/pbn_show_json.py index dcab83f36..be16188b0 100644 --- a/src/pbn_api/management/commands/pbn_show_json.py +++ b/src/pbn_api/management/commands/pbn_show_json.py @@ -18,6 +18,6 @@ def handle(self, model_name, id, *args, **kw): obj = ContentType.objects.get( app_label="bpp", model=model_name ).get_object_for_this_type(id=id) - adapted = WydawnictwoPBNAdapter(obj) + adapted = WydawnictwoPBNAdapter(obj, uczelnia=self._resolved_uczelnia) data = adapted.pbn_get_json() print(json.dumps(data, indent=4, sort_keys=True)) diff --git a/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py b/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py index a4152faa8..48c285421 100644 --- a/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py +++ b/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py @@ -195,7 +195,9 @@ def _step_show_publication(self, publication): # Intencja BPP — live count tego co by adapter wysłał teraz. try: intended_count = len( - WydawnictwoPBNAdapter(publication).pbn_get_json_statements() + WydawnictwoPBNAdapter( + publication, uczelnia=self._resolved_uczelnia + ).pbn_get_json_statements() ) intended_label = str(intended_count) except Exception as e: # noqa: BLE001 @@ -212,7 +214,9 @@ def _step_show_publication(self, publication): def _step_generate_json(self, publication): self._header("KROK 2/8 — Generowanie JSON publikacji") - adapter = WydawnictwoPBNAdapter(publication) + adapter = WydawnictwoPBNAdapter( + publication, uczelnia=self._resolved_uczelnia + ) js = adapter.pbn_get_json() bez_oswiadczen = "statements" not in js n_statements = len(js.get("statements", [])) if not bez_oswiadczen else 0 @@ -367,7 +371,9 @@ def _step_compare_statements(self, publication, pbn_statements): # — oba nam potrzebne do porównania z PBN GET response, gdzie są # ``area`` i ``personId``. try: - intended = WydawnictwoPBNAdapter(publication).pbn_get_json_statements() + intended = WydawnictwoPBNAdapter( + publication, uczelnia=self._resolved_uczelnia + ).pbn_get_json_statements() except Exception as e: # noqa: BLE001 self._warn(f"Nie mogę wygenerować intencji BPP (adapter): {e}") self._info("Zwracam 'różnice' — user zdecyduje co robić.") @@ -461,7 +467,9 @@ def _step_delete_statements(self, pbn_client, object_id): def _step_post_statements(self, pbn_client, publication): self._header("KROK 8/8 — POST nowych oświadczeń") try: - payload = WydawnictwoPBNAdapter(publication).pbn_get_api_statements() + payload = WydawnictwoPBNAdapter( + publication, uczelnia=self._resolved_uczelnia + ).pbn_get_api_statements() except DaneLokalneWymagajaAktualizacjiException as e: self._err( f"Nie mogę przygotować payloadu oświadczeń: {e}. " diff --git a/src/pbn_wysylka_oswiadczen/tasks.py b/src/pbn_wysylka_oswiadczen/tasks.py index f053b8c0a..655e16bc0 100644 --- a/src/pbn_wysylka_oswiadczen/tasks.py +++ b/src/pbn_wysylka_oswiadczen/tasks.py @@ -202,7 +202,9 @@ def process_single_publication(publication, pbn_client, task, log_model): # Step 3: Get statements data try: - json_data = WydawnictwoPBNAdapter(publication).pbn_get_api_statements() + json_data = WydawnictwoPBNAdapter( + publication, uczelnia=pbn_client.uczelnia + ).pbn_get_api_statements() except DaneLokalneWymagajaAktualizacjiException as e: log_entry.error_message = f"Dane lokalne wymagaja aktualizacji: {str(e)}" log_entry.save() From 612ca1529e235a50c03eea6c6c02464bea699226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 21:04:14 +0200 Subject: [PATCH 053/247] fix(multi-hosted): Faza 1 get_default cleanup - widoki/formularze NIE MA PRAWA (request w zasiegu) -> get_for_request: steps._review_context, pbn_export_queue detail_views._resolve_api_endpoint. Akceptowalne (fallback w formularzu/modelu, brak requestu) -> Uczelnia.objects.get(): zglos_publikacje forms/models, nowe_raporty forms, pbn_api publikacja_instytucji. UWAGA: .get() rzuca przy 0 uczelni (zamiast get_default()->None). 3 testy nowe_raporty renderowaly formularz bez uczelni (nierealistyczne) - dociagniety fixture uczelnia. 278 testow zielonych. Co-Authored-By: Claude Opus 4.8 --- src/importer_publikacji/views/steps.py | 2 +- src/nowe_raporty/forms.py | 2 +- src/nowe_raporty/tests/test_generyczne_widoki.py | 6 +++--- src/pbn_api/models/publikacja_instytucji.py | 2 +- src/pbn_export_queue/views/detail_views.py | 2 +- src/zglos_publikacje/forms.py | 2 +- src/zglos_publikacje/models.py | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/importer_publikacji/views/steps.py b/src/importer_publikacji/views/steps.py index 204ca6b77..321484960 100644 --- a/src/importer_publikacji/views/steps.py +++ b/src/importer_publikacji/views/steps.py @@ -333,7 +333,7 @@ def _review_context(request, session): "data": session.normalized_data, } - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(request) if ( uczelnia is not None and uczelnia.pbn_integracja diff --git a/src/nowe_raporty/forms.py b/src/nowe_raporty/forms.py index 46894576a..4928eb10c 100644 --- a/src/nowe_raporty/forms.py +++ b/src/nowe_raporty/forms.py @@ -89,7 +89,7 @@ def clean(self): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get() if not (uczelnia and uczelnia.pokazuj_punktacje_wewnetrzna): self.fields.pop("punktacja_wewnetrzna_od", None) self.fields.pop("punktacja_wewnetrzna_do", None) diff --git a/src/nowe_raporty/tests/test_generyczne_widoki.py b/src/nowe_raporty/tests/test_generyczne_widoki.py index db9010b75..0a87c4271 100644 --- a/src/nowe_raporty/tests/test_generyczne_widoki.py +++ b/src/nowe_raporty/tests/test_generyczne_widoki.py @@ -35,14 +35,14 @@ def test_uczelnia_bez_pola_obiektu(): @pytest.mark.django_db -def test_generyczny_formularz_renderuje_sie(webtest_app): +def test_generyczny_formularz_renderuje_sie(webtest_app, uczelnia): seed_default_reports() # raport-autorow -> WSZYSCY (anonim widzi) res = webtest_app.get(reverse("nowe_raporty:raport_form", args=["raport-autorow"])) assert res.status_code == 200 @pytest.mark.django_db -def test_kilka_raportow_na_tym_samym_poziomie(webtest_app): +def test_kilka_raportow_na_tym_samym_poziomie(webtest_app, uczelnia): seed_default_reports() report = DefinicjaRaportu.objects.get(slug="raport-autorow").report baker.make( @@ -122,7 +122,7 @@ def test_nazwa_pliku_eksportu_opisowa(webtest_app, typy_odpowiedzialnosci): @pytest.mark.django_db -def test_create_entries_rejestruje_formdefaults_per_definicja(): +def test_create_entries_rejestruje_formdefaults_per_definicja(uczelnia): from formdefaults.models import FormRepresentation from nowe_raporty.apps import create_entries diff --git a/src/pbn_api/models/publikacja_instytucji.py b/src/pbn_api/models/publikacja_instytucji.py index 440e3d581..44ccd61ee 100644 --- a/src/pbn_api/models/publikacja_instytucji.py +++ b/src/pbn_api/models/publikacja_instytucji.py @@ -65,7 +65,7 @@ def link_do_pi(self): from bpp import const from bpp.models import Uczelnia - uczelnia = self.uczelnia or Uczelnia.objects.get_default() + uczelnia = self.uczelnia or Uczelnia.objects.get() 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_export_queue/views/detail_views.py b/src/pbn_export_queue/views/detail_views.py index 045c89ea3..80d307467 100644 --- a/src/pbn_export_queue/views/detail_views.py +++ b/src/pbn_export_queue/views/detail_views.py @@ -149,7 +149,7 @@ def _resolve_api_endpoint(self, sent_data): from bpp.models import Uczelnia from pbn_api.const import PBN_POST_PUBLICATION_NO_STATEMENTS_URL - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get_for_request(self.request) if uczelnia and uczelnia.pbn_api_root: return ( uczelnia.pbn_api_root.rstrip("/") diff --git a/src/zglos_publikacje/forms.py b/src/zglos_publikacje/forms.py index a0bb7ad5c..6ea2e08fa 100644 --- a/src/zglos_publikacje/forms.py +++ b/src/zglos_publikacje/forms.py @@ -313,7 +313,7 @@ def __init__(self, *args, rodzaj=None, forma_dostepu=None, **kw): super().__init__(*args, **kw) if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get() if ( uczelnia is not None diff --git a/src/zglos_publikacje/models.py b/src/zglos_publikacje/models.py index 278b6e083..266f2635e 100644 --- a/src/zglos_publikacje/models.py +++ b/src/zglos_publikacje/models.py @@ -251,7 +251,7 @@ def clean(self): # -- 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() + uczelnia = Uczelnia.objects.get() else: uczelnia = self._uczelnia From a9a4cb78f0725a724ea939213e0782c2f71e47a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 21:20:32 +0200 Subject: [PATCH 054/247] fix(multi-hosted): Faza 2+3 - ImportManager propaguje uczelnie, importer uzywa session.uczelnia Faza 2: importer_publikacji _add_authors_to_record uzywa session.uczelnia (obca_jednostka per-uczelnia) zamiast get_default. Faza 3 (audyt WYSOKIE): ImportManager.__init__(uczelnia) przechowuje self.uczelnia i propaguje do krokow (ImportStepBase.__init__ + _execute_step przekazuje uczelnia=self.uczelnia) oraz do _refresh_pbn_client_after_setup. Kroki InitialSetup/AuthorImporter/InstitutionImporter/PublicationImporter czytaja self.uczelnia zamiast get_default(). tasks.run_pbn_import przekazuje uczelnie (z get_for_pbn_background) do managera. Likwiduje regresje, w ktorej get_default() nadpisywal jawnie wybrana uczelnie klientem pierwszej-z-brzegu. Nowy test: ImportManager._refresh uzywa swojej uczelni, nie get_default (dwie uczelnie). Mock simulate_cancellation dostosowany do nowej sygnatury. 523 testy zielone. Co-Authored-By: Claude Opus 4.8 --- src/importer_publikacji/views/publikacja.py | 4 +-- src/pbn_import/tasks.py | 2 +- src/pbn_import/tests/test_tasks.py | 27 ++++++++++++++++++++- src/pbn_import/utils/author_import.py | 3 +-- src/pbn_import/utils/base.py | 5 +++- src/pbn_import/utils/import_manager.py | 18 ++++++++++---- src/pbn_import/utils/initial_setup.py | 3 +-- src/pbn_import/utils/institution_import.py | 7 +++--- src/pbn_import/utils/publication_import.py | 7 +++--- 9 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/importer_publikacji/views/publikacja.py b/src/importer_publikacji/views/publikacja.py index 50d4c8a39..d6003dca0 100644 --- a/src/importer_publikacji/views/publikacja.py +++ b/src/importer_publikacji/views/publikacja.py @@ -11,7 +11,6 @@ from bpp.models import ( Status_Korekty, Typ_Odpowiedzialnosci, - Uczelnia, Wydawnictwo_Ciagle, Wydawnictwo_Zwarte, ) @@ -122,7 +121,8 @@ def _add_authors_to_record(session, record, uczelnia=None): typ_aut = Typ_Odpowiedzialnosci.objects.get(skrot="aut.") if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + # Uczelnia sesji (multi-hosted) — obca_jednostka jest per-uczelnia. + uczelnia = session.uczelnia obca = uczelnia.obca_jednostka if uczelnia else None for imported_author in authors: diff --git a/src/pbn_import/tasks.py b/src/pbn_import/tasks.py index 67d7f706a..ad3f3c771 100644 --- a/src/pbn_import/tasks.py +++ b/src/pbn_import/tasks.py @@ -93,7 +93,7 @@ def run_pbn_import(self, session_id, uczelnia_id=None): pbn_client = None # Create and run ImportManager - import_manager = ImportManager(session, pbn_client, config) + import_manager = ImportManager(session, pbn_client, config, uczelnia=uczelnia) # Run the import result = import_manager.run() diff --git a/src/pbn_import/tests/test_tasks.py b/src/pbn_import/tests/test_tasks.py index 785440d7f..528a11c14 100644 --- a/src/pbn_import/tests/test_tasks.py +++ b/src/pbn_import/tests/test_tasks.py @@ -197,7 +197,7 @@ def test_run_pbn_import_cancelled(uczelnia, admin_user): """Test run_pbn_import respects cancelled status set during import.""" session = ImportSession.objects.create(user=admin_user, status="pending", config={}) - def simulate_cancellation(sess, pbn_client, config): + def simulate_cancellation(sess, pbn_client, config, uczelnia=None): """Simulate ImportManager that detects cancellation during run.""" # During run, session gets cancelled (e.g., user cancels via UI) sess.status = "cancelled" @@ -215,6 +215,31 @@ def simulate_cancellation(sess, pbn_client, config): assert session.status == "cancelled" +@pytest.mark.django_db +def test_import_manager_uzywa_swojej_uczelni_nie_get_default(admin_user): + """Faza 3 (multi-hosted): ImportManager propaguje SWOJĄ uczelnię do + ``_refresh_pbn_client_after_setup``, zamiast zgadywać ``get_default()`` + (pierwszą-z-brzegu). Failowałby przed Fazą 3.""" + from bpp.models import Uczelnia + from pbn_api.models import Institution + from pbn_import.utils.import_manager import ImportManager + + inst1 = baker.make(Institution) + inst2 = baker.make(Institution) + u1 = baker.make(Uczelnia, pbn_uid=inst1) # get_default ją zwróci + u2 = baker.make(Uczelnia, pbn_uid=inst2, pbn_integracja=False) + assert Uczelnia.objects.get_default() == u1 + + session = baker.make(ImportSession, user=admin_user, config={}) + manager = ImportManager(session, client=None, uczelnia=u2) + assert manager.uczelnia == u2 + + manager._refresh_pbn_client_after_setup() + + # Użył pbn_uid u2 (inst2), nie get_default()=u1 (inst1). + assert session.config["uczelnia_pbn_uid"] == inst2.pk + + @pytest.mark.django_db def test_run_pbn_import_failed_result(uczelnia, admin_user): """Test run_pbn_import marks session as failed when result has no success.""" diff --git a/src/pbn_import/utils/author_import.py b/src/pbn_import/utils/author_import.py index 400ce29a7..92709e707 100644 --- a/src/pbn_import/utils/author_import.py +++ b/src/pbn_import/utils/author_import.py @@ -1,6 +1,5 @@ """Author import utilities""" -from bpp.models import Uczelnia from pbn_integrator.utils import integruj_autorow_z_uczelni, pobierz_ludzi_z_uczelni from .base import ImportStepBase @@ -15,7 +14,7 @@ class AuthorImporter(ImportStepBase): def run(self, uczelnia=None): """Import authors""" if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + uczelnia = self.uczelnia if not uczelnia or not uczelnia.pbn_uid_id: self.log( diff --git a/src/pbn_import/utils/base.py b/src/pbn_import/utils/base.py index 66b011a5f..63f26db54 100644 --- a/src/pbn_import/utils/base.py +++ b/src/pbn_import/utils/base.py @@ -88,9 +88,12 @@ class ImportStepBase: step_name: str = "Unknown Step" step_description: str = "Przetwarzanie..." - def __init__(self, session: ImportSession, client=None): + def __init__(self, session: ImportSession, client=None, uczelnia=None): self.session = session self.client = client + # Uczelnia kontekstu importu (multi-hosted) — kroki czytają z niej + # config zamiast zgadywać get_default(). + self.uczelnia = uczelnia self.start_time = None self.processed_count = 0 self.total_count = 0 diff --git a/src/pbn_import/utils/import_manager.py b/src/pbn_import/utils/import_manager.py index ed7fc0fdc..15fdbdfcd 100644 --- a/src/pbn_import/utils/import_manager.py +++ b/src/pbn_import/utils/import_manager.py @@ -21,10 +21,17 @@ class ImportManager: """Orchestrates the entire PBN import process""" def __init__( - self, session: ImportSession, client, config: dict[str, Any] | None = None + self, + session: ImportSession, + client, + config: dict[str, Any] | None = None, + uczelnia=None, ): self.session = session self.client = client + # Uczelnia importu (multi-hosted) — propagowana do kroków, żeby NIE + # zgadywały get_default() i nie przebudowały klienta na złą uczelnię. + self.uczelnia = uczelnia self.config = config or {} self.pbn_authorized = False self.pbn_error_message = None @@ -100,12 +107,10 @@ def _refresh_pbn_client_after_setup(self, uczelnia=None): configuration. This method refreshes the client after InitialSetup to ensure proper authorization for subsequent API calls. """ - from bpp.models import Uczelnia - # Refresh uczelnia from database to get changes made by # InitialSetup if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + uczelnia = self.uczelnia if uczelnia is None: logger.warning("Nie znaleziono uczelni po InitialSetup") @@ -230,7 +235,10 @@ def _execute_step( """Execute a single import step""" step_class = step_config["class"] step = step_class( - session=self.session, client=self.client, **step_config["args"] + session=self.session, + client=self.client, + uczelnia=self.uczelnia, + **step_config["args"], ) logger.info( diff --git a/src/pbn_import/utils/initial_setup.py b/src/pbn_import/utils/initial_setup.py index 061d4d8d9..04b2f1f9c 100644 --- a/src/pbn_import/utils/initial_setup.py +++ b/src/pbn_import/utils/initial_setup.py @@ -1,6 +1,5 @@ """Initial setup for PBN import - languages, countries, disciplines""" -from bpp.models import Uczelnia from import_common.core import matchuj_uczelnie from pbn_integrator.utils import ( integruj_jezyki, @@ -20,7 +19,7 @@ class InitialSetup(ImportStepBase): def run(self, uczelnia=None): """Execute initial setup""" if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + uczelnia = self.uczelnia # Check if we have a PBN client if self.client is None: diff --git a/src/pbn_import/utils/institution_import.py b/src/pbn_import/utils/institution_import.py index 30d5b9901..2786ee3fc 100644 --- a/src/pbn_import/utils/institution_import.py +++ b/src/pbn_import/utils/institution_import.py @@ -1,6 +1,6 @@ """Institution import utilities""" -from bpp.models import Jednostka, Jednostka_Wydzial, Uczelnia, Wydzial +from bpp.models import Jednostka, Jednostka_Wydzial, Wydzial from .base import ImportStepBase @@ -86,10 +86,11 @@ def __init__( self, session, client=None, + uczelnia=None, wydzial_domyslny="Wydział Domyślny", wydzial_domyslny_skrot=None, ): - super().__init__(session, client) + super().__init__(session, client, uczelnia=uczelnia) self.wydzial_domyslny = wydzial_domyslny self.wydzial_domyslny_skrot = wydzial_domyslny_skrot or zrob_skrot( wydzial_domyslny @@ -98,7 +99,7 @@ def __init__( def run(self, uczelnia=None): """Setup default institutions""" if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + uczelnia = self.uczelnia if not uczelnia: raise ValueError( diff --git a/src/pbn_import/utils/publication_import.py b/src/pbn_import/utils/publication_import.py index bf45a13c1..0648c9aaf 100644 --- a/src/pbn_import/utils/publication_import.py +++ b/src/pbn_import/utils/publication_import.py @@ -5,7 +5,6 @@ Jednostka, Rekord, Rodzaj_Zrodla, - Uczelnia, Wersja_Tekstu_OpenAccess, Wydawnictwo_Ciagle, Wydawnictwo_Zwarte, @@ -26,8 +25,8 @@ class PublicationImporter(ImportStepBase): step_name = "publication_import" step_description = "Import publikacji" - def __init__(self, session, client=None, delete_existing=False): - super().__init__(session, client) + def __init__(self, session, client=None, delete_existing=False, uczelnia=None): + super().__init__(session, client, uczelnia=uczelnia) self.delete_existing = delete_existing self.default_jednostka = None @@ -76,7 +75,7 @@ def run(self): def _setup_uczelnia_and_jednostka(self, uczelnia=None): """Setup uczelnia and default jednostka for import.""" if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + uczelnia = self.uczelnia if not uczelnia or not uczelnia.pbn_uid_id: self.log( From 9664762ce69d4393738d62d006aa70fe61285bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 21:58:12 +0200 Subject: [PATCH 055/247] docs(multi-hosted): Faza 4 - oznacz config-reads (sloty per-uczelnia jako TODO) Park #1/#2 (sloty/punktacja): TODO per-uczelnia (autor->jednostka->uczelnia, wynik rozny per uczelnia, osobny redesign warstwy ewaluacji). Zostaje get_default() - NIE .get() (hot-path per-save). #3/#4/#5/#6 (sortowanie jednostek, index copernicus, linki PBN): get_default() akceptowalny (warstwa modelu/rejestru bez requestu, None-tolerant) - komentarze. Zero zmiany logiki - same komentarze. Co-Authored-By: Claude Opus 4.8 --- src/bpp/models/abstract/disciplines.py | 3 +++ src/bpp/models/abstract/pbn.py | 4 ++++ src/bpp/models/jednostka.py | 2 ++ src/bpp/models/sloty/core.py | 5 +++++ src/bpp/multiseek_registry/fields/numeric_fields.py | 2 ++ 5 files changed, 16 insertions(+) diff --git a/src/bpp/models/abstract/disciplines.py b/src/bpp/models/abstract/disciplines.py index 72e3328c1..8c428f747 100644 --- a/src/bpp/models/abstract/disciplines.py +++ b/src/bpp/models/abstract/disciplines.py @@ -15,6 +15,9 @@ def przelicz_punkty_dyscyplin(self, uczelnia=None): if uczelnia is None: from bpp.models.uczelnia import Uczelnia + # TODO(multi-hosted): patrz ISlot — punktacja docelowo per-uczelnia, + # osobny redesign warstwy ewaluacji. Do tego czasu get_default() + # (NIE .get() — hot-path per-save). uczelnia = Uczelnia.objects.get_default() ipc = IPunktacjaCacher(self, uczelnia) diff --git a/src/bpp/models/abstract/pbn.py b/src/bpp/models/abstract/pbn.py index fafb1e154..09d873d57 100644 --- a/src/bpp/models/abstract/pbn.py +++ b/src/bpp/models/abstract/pbn.py @@ -20,6 +20,8 @@ def link_do_pbn(self, uczelnia=None): if uczelnia is None: from bpp.models import Uczelnia + # Root linku PBN (pbn_api_root); metoda modelu bez requestu — + # get_default() akceptowalny (None-tolerant). uczelnia = Uczelnia.objects.get_default() if uczelnia is not None: return self.url_do_pbn.format( @@ -86,6 +88,8 @@ def _format_link_pi(self, pbn_uid_id, uuid=None, versionHash=None, uczelnia=None if uczelnia is None: from bpp.models import Uczelnia + # Root linku PBN (pbn_api_root); metoda modelu bez requestu — + # get_default() akceptowalny (None-tolerant). 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 b4695dc1c..480027283 100644 --- a/src/bpp/models/jednostka.py +++ b/src/bpp/models/jednostka.py @@ -43,6 +43,8 @@ def create(self, *args, **kw): def get_default_ordering(self, uczelnia=None): if uczelnia is None: + # Ustawienie WYŚWIETLANIA (sortowanie jednostek) czytane z warstwy + # modelu (wydzial.py) bez requestu — get_default() akceptowalny. uczelnia = Uczelnia.objects.get_default() ordering = SORTUJ_RECZNIE diff --git a/src/bpp/models/sloty/core.py b/src/bpp/models/sloty/core.py index bc29de254..0fb157ce6 100644 --- a/src/bpp/models/sloty/core.py +++ b/src/bpp/models/sloty/core.py @@ -31,6 +31,11 @@ def ISlot(original, uczelnia=None): # noqa raise CannotAdapt("Sloty dla prac wieloośrodkowych nie są liczone.") if uczelnia is None: + # TODO(multi-hosted): sloty/punktacja docelowo per-uczelnia (autor → + # jednostka → uczelnia; wynik różny per uczelnia, do liczenia i zapisu + # OSOBNO). To osobny redesign warstwy ewaluacji (cache per + # rekord×uczelnia). Do tego czasu get_default() — NIE .get(): to + # hot-path per-save, >1 uczelnia rzuciłaby przy każdym zapisie. uczelnia = Uczelnia.objects.get_default() if ( diff --git a/src/bpp/multiseek_registry/fields/numeric_fields.py b/src/bpp/multiseek_registry/fields/numeric_fields.py index 61636c5ea..e11cc9e6f 100644 --- a/src/bpp/multiseek_registry/fields/numeric_fields.py +++ b/src/bpp/multiseek_registry/fields/numeric_fields.py @@ -68,6 +68,8 @@ class IndexCopernicusQueryObject(BppMultiseekVisibilityMixin, SafeDecimalQueryOb field_name = "index_copernicus" def option_enabled(self, uczelnia=None): + # Toggle per-uczelnia czytany w rejestrze multiseek bez pewnego + # requestu — get_default() akceptowalny (None-tolerant: True gdy brak). u = uczelnia or Uczelnia.objects.get_default() if u is not None: return u.pokazuj_index_copernicus From 83fac7ae78f2e072530ffdf4011eeb014054e6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 22:02:47 +0200 Subject: [PATCH 056/247] fix(multi-hosted): Faza 5 - fallbacki builderow klienta -> .get() (poza adapterem) cli.sprobuj_wyslac_do_pbn_celery i scientists.pobierz_* : fallback gdy caller nie podal uczelni -> Uczelnia.objects.get() (single-or-fail; runtime przekazuje jawnie). Adapter wydawnictwo.py: ZOSTAJE get_default() - to test-only None-tolerant fallback (runtime przekazuje jawna uczelnie; .get() rzucalby w testach adaptera konstruowanych bez uczelni). Co-Authored-By: Claude Opus 4.8 --- src/bpp/admin/helpers/pbn_api/cli.py | 4 +++- src/pbn_api/adapters/wydawnictwo.py | 5 +++++ src/pbn_integrator/utils/scientists.py | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/bpp/admin/helpers/pbn_api/cli.py b/src/bpp/admin/helpers/pbn_api/cli.py index 6cbcc554e..bec2506b5 100644 --- a/src/bpp/admin/helpers/pbn_api/cli.py +++ b/src/bpp/admin/helpers/pbn_api/cli.py @@ -40,7 +40,9 @@ def sprobuj_wyslac_do_pbn_celery( try: uczelnia = sprawdz_wysylke_do_pbn_w_parametrach_uczelni( - uczelnia or Uczelnia.objects.get_default() + # Caller (kolejka) przekazuje uczelnia=self.uczelnia; fallback to + # single-install: .get() (przy >1 rzuca — multi-hosted ma podać). + uczelnia or Uczelnia.objects.get() ) except BrakZdefiniowanegoObiektuUczelniaWSystemieError as e: raise ValueError("W systemie brak obiektu Uczelnia.") from e diff --git a/src/pbn_api/adapters/wydawnictwo.py b/src/pbn_api/adapters/wydawnictwo.py index 8b54128b7..fbc4c85d7 100644 --- a/src/pbn_api/adapters/wydawnictwo.py +++ b/src/pbn_api/adapters/wydawnictwo.py @@ -91,6 +91,11 @@ def __init__( uczelnia = Uczelnia.objects.get_for_request(request) if uczelnia is None: + # Runtime callerzy przekazują JAWNĄ uczelnię (publication_sync, + # pbn_wysylka, komendy) — tu nigdy nie docierają. Ten fallback jest + # test-only: adapter bywa konstruowany w izolacji bez uczelni i ma + # zadziałać None-tolerant (domyślne flagi). Dlatego get_default() + # (None gdy 0), NIE .get() (rzuciłby w testach bez uczelni). uczelnia = Uczelnia.objects.get_default() if uczelnia is not None: diff --git a/src/pbn_integrator/utils/scientists.py b/src/pbn_integrator/utils/scientists.py index 48ed41ad8..c3d6b9df9 100644 --- a/src/pbn_integrator/utils/scientists.py +++ b/src/pbn_integrator/utils/scientists.py @@ -58,7 +58,7 @@ def pobierz_i_zapisz_dane_jednej_osoby( if isinstance(client_or_token, str): # Create PBN client if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get() client = uczelnia.pbn_client(client_or_token) scientist = client.get_person_by_id(personId) @@ -153,7 +153,7 @@ def pobierz_ludzi_z_uczelni( if isinstance(client_or_token, str): # Create PBN client if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get() client = uczelnia.pbn_client(client_or_token) elementy = client.get_people_by_institution_id(instutition_id) From 9a1d31ad0b0cdaf17ed7bde9c64029a2c3ca3839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 22:16:13 +0200 Subject: [PATCH 057/247] fix(multi-hosted): Faza 6 - taski metryk/oswiadczen fallback -> .get() ewaluacja_metryki.generuj_metryki_task_parallel i oswiadczenia: zadania maja uczelnia_id; fallback gdy nie podano -> Uczelnia.objects.get() (single- or-fail; multi-hosted ma podac). Test chord dostal fixture uczelnia (zadanie metryk wymaga uczelni). command_helpers: ZOSTAJE get_default() - util CLI swiadomie None-tolerant z czytelnym CommandError (.get() rzucalby DoesNotExist zamiast komunikatu; test_fails_without_uczelnia tego oczekuje). Co-Authored-By: Claude Opus 4.8 --- src/ewaluacja_metryki/tasks.py | 4 ++-- src/ewaluacja_metryki/tests/test_tasks.py | 4 ++-- src/oswiadczenia/tasks.py | 2 +- src/pbn_import/utils/command_helpers.py | 4 ++++ 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ewaluacja_metryki/tasks.py b/src/ewaluacja_metryki/tasks.py index d86c1d1b5..af60c6cdd 100644 --- a/src/ewaluacja_metryki/tasks.py +++ b/src/ewaluacja_metryki/tasks.py @@ -216,7 +216,7 @@ def generuj_metryki_task_parallel( uczelnia = ( Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id - else Uczelnia.objects.get_default() + else Uczelnia.objects.get() ) oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) logger.info("Przeliczono liczby N pomyślnie") @@ -343,7 +343,7 @@ def generuj_metryki_task( uczelnia = ( Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id - else Uczelnia.objects.get_default() + else Uczelnia.objects.get() ) oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) logger.info("Przeliczono liczby N pomyślnie") diff --git a/src/ewaluacja_metryki/tests/test_tasks.py b/src/ewaluacja_metryki/tests/test_tasks.py index c49cc8fda..162473ea4 100644 --- a/src/ewaluacja_metryki/tests/test_tasks.py +++ b/src/ewaluacja_metryki/tests/test_tasks.py @@ -138,7 +138,7 @@ def test_finalizuj_generowanie_metryk(): # Symuluj atomowe update'y licznika przez poszczególne taski # (w prawdziwym scenariuszu każdy task wywołuje StatusGenerowania.objects.update(...)) - for result in results: + for _result in results: StatusGenerowania.objects.update( liczba_przetworzonych=F("liczba_przetworzonych") + 1 ) @@ -216,7 +216,7 @@ def test_oblicz_metryki_dla_autora_task_inkrementuje_licznik(): @pytest.mark.django_db -def test_generuj_metryki_task_parallel_uruchamia_chord(): +def test_generuj_metryki_task_parallel_uruchamia_chord(uczelnia): """Test że równoległy task tworzy chord z taskami""" from unittest.mock import MagicMock diff --git a/src/oswiadczenia/tasks.py b/src/oswiadczenia/tasks.py index b08e4c216..9e39132ce 100644 --- a/src/oswiadczenia/tasks.py +++ b/src/oswiadczenia/tasks.py @@ -559,7 +559,7 @@ def generate_oswiadczenia_zip(self, task_id: int, uczelnia_id=None): uczelnia = ( Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id - else Uczelnia.objects.get_default() + else Uczelnia.objects.get() ) declarations = build_declarations_list(queryset, uczelnia) diff --git a/src/pbn_import/utils/command_helpers.py b/src/pbn_import/utils/command_helpers.py index 93ca83b13..a0584ae15 100644 --- a/src/pbn_import/utils/command_helpers.py +++ b/src/pbn_import/utils/command_helpers.py @@ -36,6 +36,10 @@ def get_validated_default_jednostka(jednostka_name=None, uczelnia=None): CommandError: Gdy brak domyślnej uczelni lub podana jednostka nie istnieje. """ if uczelnia is None: + # Util CLI: świadomie None-tolerant — get_default() zwraca None gdy + # brak uczelni, a poniżej dajemy CZYTELNY CommandError (NIE .get(), + # które rzuciłoby DoesNotExist zamiast komunikatu). Multi-hosted: CLI + # ma podać uczelnię jawnie. uczelnia = Uczelnia.objects.get_default() if uczelnia is None: From d95e2df500604237cf583c12302200409bd341e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 22:22:05 +0200 Subject: [PATCH 058/247] fix(multi-hosted): Faza 7 - komendy CLI fallback get_default -> .get() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Komendy z --uczelnia-id: else-fallback get_default() -> Uczelnia.objects.get() (single-or-fail; multi-hosted ma podac --uczelnia-id). objects.default -> objects.get() w fix_pbn_import (×2). oblicz_metryki: uczelnia rozwiazywana LENIWIE w galezi liczby-N (--bez-liczby-n nie wymaga uczelni; .get() nie rzuca przy 0/>1 w danych testowych). pbn_import._ensure_pbn_integration: .get(). Co-Authored-By: Claude Opus 4.8 --- .../commands/fix_pbn_import_oswiadczen_ksiazki.py | 2 +- .../management/commands/import_jednostki_ipis.py | 2 +- src/bpp/management/commands/wyczysc_baze.py | 2 +- .../commands/przelicz_liczbe_n_dla_uczelni.py | 2 +- .../management/commands/przelicz_n.py | 2 +- .../management/commands/oblicz_metryki.py | 15 +++++++++------ .../fix_from_institution_api_for_scientist.py | 2 +- .../commands/fix_pbn_import_oswiadczen_ksiazki.py | 2 +- src/pbn_import/management/commands/pbn_import.py | 2 +- .../management/commands/pbn_integrator.py | 2 +- 10 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/bpp/management/commands/fix_pbn_import_oswiadczen_ksiazki.py b/src/bpp/management/commands/fix_pbn_import_oswiadczen_ksiazki.py index d451cf50a..f696ac681 100644 --- a/src/bpp/management/commands/fix_pbn_import_oswiadczen_ksiazki.py +++ b/src/bpp/management/commands/fix_pbn_import_oswiadczen_ksiazki.py @@ -267,7 +267,7 @@ def _integrate_disciplines(self, records_to_integrate, verbose): self.stdout.write("") self.stdout.write("Integracja dyscyplin dla naprawionych rekordów...") - default_jednostka = Uczelnia.objects.default.domyslna_jednostka + default_jednostka = Uczelnia.objects.get().domyslna_jednostka noted_pub = set() noted_aut = set() diff --git a/src/bpp/management/commands/import_jednostki_ipis.py b/src/bpp/management/commands/import_jednostki_ipis.py index 23b2297bd..b4ca216d2 100644 --- a/src/bpp/management/commands/import_jednostki_ipis.py +++ b/src/bpp/management/commands/import_jednostki_ipis.py @@ -29,7 +29,7 @@ def handle(self, *args, **options): if uczelnia_id: uczelnia = Uczelnia.objects.get(pk=uczelnia_id) else: - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get() 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 47b5f7d29..68d3bcd08 100644 --- a/src/bpp/management/commands/wyczysc_baze.py +++ b/src/bpp/management/commands/wyczysc_baze.py @@ -63,7 +63,7 @@ def handle(self, tylko_publikacje, *args, **options): if uczelnia_id: uczelnia = Uczelnia.objects.get(pk=uczelnia_id) else: - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get() challenge = "".join(random.sample("abcdefghijklmnopqrstuvwxzy!@#$^^&", 5)) print("Informacje o systemie") # noqa: T201 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 7bd4853a1..a2f9a7fd2 100644 --- a/src/ewaluacja2021/management/commands/przelicz_liczbe_n_dla_uczelni.py +++ b/src/ewaluacja2021/management/commands/przelicz_liczbe_n_dla_uczelni.py @@ -21,6 +21,6 @@ def handle(self, *args, **options): if uczelnia_id: uczelnia = Uczelnia.objects.get(pk=uczelnia_id) else: - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get() oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) diff --git a/src/ewaluacja_liczba_n/management/commands/przelicz_n.py b/src/ewaluacja_liczba_n/management/commands/przelicz_n.py index 7f8788f56..d788ec6e1 100644 --- a/src/ewaluacja_liczba_n/management/commands/przelicz_n.py +++ b/src/ewaluacja_liczba_n/management/commands/przelicz_n.py @@ -21,7 +21,7 @@ def handle(self, *args, **options): if uczelnia_id: uczelnia = Uczelnia.objects.get(pk=uczelnia_id) else: - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get() self.stdout.write("Przeliczam liczby N dla uczelni...") oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) diff --git a/src/ewaluacja_metryki/management/commands/oblicz_metryki.py b/src/ewaluacja_metryki/management/commands/oblicz_metryki.py index 4cd0b00cf..f83e8bf86 100644 --- a/src/ewaluacja_metryki/management/commands/oblicz_metryki.py +++ b/src/ewaluacja_metryki/management/commands/oblicz_metryki.py @@ -74,14 +74,17 @@ 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: + # Uczelnia potrzebna TYLKO do liczby N — rozwiązujemy leniwie, żeby + # --bez-liczby-n nie wymagało uczelni (i .get() nie rzucało, gdy + # w bazie jest 0 lub >1 uczelni, np. w danych testowych). + uczelnia_id = options.get("uczelnia_id") + if uczelnia_id: + uczelnia = Uczelnia.objects.get(pk=uczelnia_id) + else: + uczelnia = Uczelnia.objects.get() + self.stdout.write( self.style.WARNING("Krok 1/2: Przeliczanie liczby N dla uczelni...") ) 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 bda6a065f..5f4aba1b9 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 @@ -22,7 +22,7 @@ def handle(self, app_id, app_token, base_url, user_token, *args, **options): if uczelnia_id: uczelnia = Uczelnia.objects.get(pk=uczelnia_id) else: - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get() if uczelnia.pbn_uid_id is None: raise Exception("Uczelnia nie ma ustawionego pbn_uid_id") diff --git a/src/pbn_import/management/commands/fix_pbn_import_oswiadczen_ksiazki.py b/src/pbn_import/management/commands/fix_pbn_import_oswiadczen_ksiazki.py index 5fdffd0ee..b441f6cea 100644 --- a/src/pbn_import/management/commands/fix_pbn_import_oswiadczen_ksiazki.py +++ b/src/pbn_import/management/commands/fix_pbn_import_oswiadczen_ksiazki.py @@ -268,7 +268,7 @@ def _integrate_disciplines(self, records_to_integrate, verbose): self.stdout.write("") self.stdout.write("Integracja dyscyplin dla naprawionych rekordów...") - default_jednostka = Uczelnia.objects.default.domyslna_jednostka + default_jednostka = Uczelnia.objects.get().domyslna_jednostka noted_pub = set() noted_aut = set() diff --git a/src/pbn_import/management/commands/pbn_import.py b/src/pbn_import/management/commands/pbn_import.py index fc4595d22..a0b1598ed 100644 --- a/src/pbn_import/management/commands/pbn_import.py +++ b/src/pbn_import/management/commands/pbn_import.py @@ -161,7 +161,7 @@ def _get_import_user(self, options): def _ensure_pbn_integration(self): """Włącz integrację PBN jeśli wyłączona.""" - uczelnia = Uczelnia.objects.get_default() + uczelnia = Uczelnia.objects.get() if uczelnia and not uczelnia.pbn_integracja: uczelnia.pbn_integracja = True uczelnia.save(update_fields=["pbn_integracja"]) diff --git a/src/pbn_integrator/management/commands/pbn_integrator.py b/src/pbn_integrator/management/commands/pbn_integrator.py index 6fbcae719..b922af7e3 100644 --- a/src/pbn_integrator/management/commands/pbn_integrator.py +++ b/src/pbn_integrator/management/commands/pbn_integrator.py @@ -439,7 +439,7 @@ def handle( uczelnia = ( Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id - else Uczelnia.objects.get_default() + else Uczelnia.objects.get() ) if uczelnia is not None: if not uczelnia.pbn_integracja: From 257173e1d63abba28f4c61f274b1400a21218aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 22:25:53 +0200 Subject: [PATCH 059/247] fix(multi-hosted): Faza 8 - integrator data-compares (.get() + park reszty) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit institutions.integruj_uczelnie/integruj_instytucje: fallback get_default() -> .get() (maja param uczelnia; entry-point single-or-fail). Park z TODO: scientists.matchuj_*, importer/authors._przetworz_afiliacje (×5), pbn_integrator._handle_people - porownania z 'nasza' uczelnia (objects.default) docelowo per-uczelnia, wymaga threadingu uczelni docelowej przez pipeline integratora (deeper, jak per-uczelnia sloty). objects.default zostaje swiadomie (cached; .get() bylby perf-regresja w petlach + crash na >1 bez naprawy semantyki). 94 testy integratora zielone. Co-Authored-By: Claude Opus 4.8 --- src/pbn_integrator/importer/authors.py | 4 ++++ src/pbn_integrator/management/commands/pbn_integrator.py | 4 ++++ src/pbn_integrator/utils/institutions.py | 8 ++++++-- src/pbn_integrator/utils/scientists.py | 3 +++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/pbn_integrator/importer/authors.py b/src/pbn_integrator/importer/authors.py index bbc7c1b69..551f8ba6d 100644 --- a/src/pbn_integrator/importer/authors.py +++ b/src/pbn_integrator/importer/authors.py @@ -86,6 +86,10 @@ def _przetworz_afiliacje( Returns: Tuple of (jednostka, afiliuje, typ_odpowiedzialnosci). """ + # TODO(multi-hosted): porównania z „naszą" uczelnią (objects.default tutaj + # i niżej) docelowo per-uczelnia — wymaga przekazania uczelni docelowej + # przez pipeline integratora (deeper, jak per-uczelnia sloty). objects.default + # zostaje świadomie (NIE .get(): wołane wielokrotnie, bez kontekstu uczelni). jednostka = Uczelnia.objects.default.obca_jednostka afiliuje = False # Use provided default or fallback to autor diff --git a/src/pbn_integrator/management/commands/pbn_integrator.py b/src/pbn_integrator/management/commands/pbn_integrator.py index b922af7e3..c010ff8ca 100644 --- a/src/pbn_integrator/management/commands/pbn_integrator.py +++ b/src/pbn_integrator/management/commands/pbn_integrator.py @@ -214,6 +214,10 @@ def _handle_system_and_sources(self, opts, client, s, e): def _handle_people(self, opts, client, s, e): """Etapy 6-9: pobieranie i integracja ludzi.""" ea = opts["enable_all"] + # TODO(multi-hosted): integrator docelowo per-uczelnia — porównanie + # z „naszą" uczelnią wymaga przekazania uczelni DOCELOWEJ przez + # pipeline integratora (deeper, jak per-uczelnia sloty). Do tego czasu + # objects.default (NIE .get(): w pętlach i bez kontekstu uczelni). pbn_uid_id = Uczelnia.objects.default.pbn_uid_id self._run_stage( diff --git a/src/pbn_integrator/utils/institutions.py b/src/pbn_integrator/utils/institutions.py index d53ae8384..9f49c8a54 100644 --- a/src/pbn_integrator/utils/institutions.py +++ b/src/pbn_integrator/utils/institutions.py @@ -61,7 +61,9 @@ def pobierz_instytucje_polon(client: PBNClient, callback=None): def integruj_uczelnie(uczelnia=None): """Integrate the default university with PBN.""" if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + # Entry-point integracji; caller (komenda) docelowo przekazuje uczelnię + # — fallback single-install: .get() (>1 rzuca, multi-hosted ma podać). + uczelnia = Uczelnia.objects.get() if uczelnia.pbn_uid_id is not None: return @@ -83,7 +85,9 @@ def integruj_uczelnie(uczelnia=None): def integruj_instytucje(uczelnia=None): """Integrate university units with PBN institutions.""" if uczelnia is None: - uczelnia = Uczelnia.objects.get_default() + # Entry-point integracji; caller (komenda) docelowo przekazuje uczelnię + # — fallback single-install: .get() (>1 rzuca, multi-hosted ma podać). + uczelnia = Uczelnia.objects.get() 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 c3d6b9df9..dc3001c47 100644 --- a/src/pbn_integrator/utils/scientists.py +++ b/src/pbn_integrator/utils/scientists.py @@ -431,6 +431,9 @@ def matchuj_autora_po_stronie_pbn(imiona, nazwisko, orcid): # noqa: C901 currentEmployments = elem.value_or_none("object", "currentEmployments") if currentEmployments is not None: + # TODO(multi-hosted): matcher porównuje z „naszą" uczelnią — + # docelowo per-uczelnia (deeper integrator-threading, jak sloty). + # objects.default zostaje (cached; matcher bez kontekstu uczelni). for pos in currentEmployments: if pos.get("institutionId") == Uczelnia.objects.default.pbn_uid_id: can_be_set = True From f5f402c169957007590656654e200b42ddf54b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 22:28:59 +0200 Subject: [PATCH 060/247] test(multi-hosted): Faza 9 - guard sentinel na get_default poza whitelista Skanuje produkcyjny kod (bez testow/migracji) i wywala, gdy Uczelnia.objects.get_default()/.default pojawi sie poza zatwierdzona whitelista (15 plikow, kazdy z uzasadnieniem: swiadomy fallback / None-tolerant model-layer / parked TODO per-uczelnia / guarded count==1). Nowy get_default w runtime = potencjalny bug multi-hosted -> fail CI. Szybki (~0.2s, bez DB). Co-Authored-By: Claude Opus 4.8 --- .../test_multihosted_get_default_guard.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/bpp/tests/test_multihosted_get_default_guard.py diff --git a/src/bpp/tests/test_multihosted_get_default_guard.py b/src/bpp/tests/test_multihosted_get_default_guard.py new file mode 100644 index 000000000..91b527d70 --- /dev/null +++ b/src/bpp/tests/test_multihosted_get_default_guard.py @@ -0,0 +1,72 @@ +"""Guard multi-hosted: pilnuje, że ``Uczelnia.objects.get_default()`` / +``Uczelnia.objects.default`` nie pojawia się poza ZATWIERDZONĄ whitelistą. + +Każdy wpis na whiteliście to ŚWIADOMA decyzja (świadomy fallback bez requestu, +None-tolerant warstwa modelu, parked TODO per-uczelnia, albo guarded count==1). +Nowe użycie ``get_default`` w ścieżce runtime to potencjalny bug multi-hosted — +zgaduje uczelnię „pierwszą-z-brzegu" zamiast użyć właściwej. + +Gdy ten test PADA: +- DODAŁEŚ ``get_default``/``objects.default`` → użyj JAWNEJ uczelni: + ``get_for_request(request)`` (widoki), argument przekazany od wołającego, + albo FK na obiekcie (np. ``session.uczelnia``, wpis kolejki). Jeśli miejsce + jest NAPRAWDĘ akceptowalne — dopisz je do ``APPROVED`` z uzasadnieniem. +- USUNĄŁEŚ ``get_default`` (np. naprawiłeś multi-hosted) → zmniejsz licznik + albo usuń wpis z ``APPROVED``. + +Patrz: docs/deweloper/audyt-multihosted-pbn.md +""" + +import re +from pathlib import Path + +# Wzorzec: faktyczne wywołania na managerze (NIE ``self.get_default()`` +# w definicji UczelniaManager, które tego wzorca nie pasują). +PATTERN = re.compile(r"Uczelnia\.objects\.(?:get_default\(\)|default\b)") + +SRC = Path(__file__).resolve().parents[2] # .../src + +# Ścieżka (względem src/) -> dozwolona liczba wystąpień. Komentarz = dlaczego OK. +APPROVED: dict[str, int] = { + "bpp/middleware.py": 1, # świadomy fallback: Site istnieje, brak Uczelni + "bpp/util/bpp_specific.py": 2, # docstring + świadomy fallback (CLI/Celery bez requestu) + "bpp/models/sloty/core.py": 1, # TODO per-uczelnia sloty (parked, hot-path) + "bpp/models/abstract/disciplines.py": 1, # TODO per-uczelnia (parked) + "bpp/models/abstract/pbn.py": 2, # linki PBN, metoda modelu bez requestu + "bpp/models/jednostka.py": 1, # sortowanie (display), warstwa modelu + "bpp/multiseek_registry/fields/numeric_fields.py": 1, # toggle IC, None-tolerant + "ewaluacja2021/util.py": 1, # komentarz (nie kod) + "pbn_api/adapters/wydawnictwo.py": 1, # test-only None-tolerant fallback + "pbn_api/management/commands/util.py": 1, # GUARDED count==1 (wzorzec CLI) + "pbn_import/templatetags/pbn_import_tags.py": 1, # request-first, fallback bez requestu + "pbn_import/utils/command_helpers.py": 1, # CLI None-tolerant + CommandError + "pbn_integrator/importer/authors.py": 5, # TODO integrator per-uczelnia (parked) + "pbn_integrator/utils/scientists.py": 1, # TODO integrator per-uczelnia (parked) + "pbn_integrator/management/commands/pbn_integrator.py": 1, # TODO integrator per-uczelnia +} + + +def _scan() -> dict[str, int]: + found: dict[str, int] = {} + for path in SRC.rglob("*.py"): + rel = path.relative_to(SRC).as_posix() + if "/tests/" in f"/{rel}" or "/migrations/" in f"/{rel}": + continue + if path.name.startswith("test_"): + continue + n = len(PATTERN.findall(path.read_text(encoding="utf-8"))) + if n: + found[rel] = n + return found + + +def test_get_default_poza_whitelista_to_regresja_multihosted(): + found = _scan() + + nowe = {f: n for f, n in found.items() if n > APPROVED.get(f, 0)} + assert not nowe, ( + "Nowe/dodatkowe Uczelnia.objects.get_default()/.default poza whitelistą " + f"(potencjalny bug multi-hosted): {nowe}. Użyj jawnej uczelni " + "(get_for_request / argument / FK obiektu) albo dopisz do APPROVED " + "w tym pliku z uzasadnieniem." + ) From 7abf6e662e392fc0cb3d66dc766bb2fdf7fade84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 22:32:35 +0200 Subject: [PATCH 061/247] docs(multi-hosted): audyt - get_default cleanup ZREALIZOWANE (Fazy 1-9) + guard Co-Authored-By: Claude Opus 4.8 --- docs/deweloper/audyt-multihosted-pbn.md | 31 +++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/deweloper/audyt-multihosted-pbn.md b/docs/deweloper/audyt-multihosted-pbn.md index 7c1a92799..f81ba0575 100644 --- a/docs/deweloper/audyt-multihosted-pbn.md +++ b/docs/deweloper/audyt-multihosted-pbn.md @@ -150,5 +150,32 @@ bez_oswiadczen: bool)`. `.get()`; docelowo zostawić tylko świadomy „dowolna" (ewentualny `get_arbitrary()` dla `middleware` Site-bez-uczelni) albo wycofać. -To osobny, stopniowy wątek — nie wchodzi w zakres splitu `pbn_client`, poza tym -że split sam z siebie usuwa `get_default` z `publication_sync` i adaptera. +### 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. From a834691fd135aae175a12b8cd8382b86ac120af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 22:32:53 +0200 Subject: [PATCH 062/247] docs(multi-hosted): plan implementacji get_default cleanup Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-02-get-default-cleanup.md | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-get-default-cleanup.md diff --git a/docs/superpowers/plans/2026-06-02-get-default-cleanup.md b/docs/superpowers/plans/2026-06-02-get-default-cleanup.md new file mode 100644 index 000000000..7614da4d1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-get-default-cleanup.md @@ -0,0 +1,397 @@ +# Wytępienie `get_default` z runtime (multi-hosted) — plan implementacji + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Usunąć „zgadywanie" uczelni przez `Uczelnia.objects.get_default()` / +`.objects.default` ze wszystkich ścieżek runtime, tak by w instalacji +wielouczelnianej każda operacja używała WŁAŚCIWEJ uczelni (z requestu, z +argumentu, z obiektu nadrzędnego), a `get_default` pozostał wyłącznie tam, gdzie +jest świadomym, udokumentowanym wyborem albo strażowanym single-install. + +**Architecture:** Trzy docelowe wzorce, dobierane per call-site: +1. **`get_for_request(request)`** — gdy w zasięgu jest request (widoki, formularze). +2. **Jawna `uczelnia` jako argument** — threadowana od wołającego, który ją zna + (zadania Celery z `uczelnia_id`, `ImportSession.uczelnia`, FK na obiekcie). +3. **`Uczelnia.objects.get()`** — „jedyna albo wyjątek"; TYLKO dla CLI bez + `--uczelnia-id` i testów single-install. Django samo rzuca + `MultipleObjectsReturned` przy >1 (głośne ujawnienie braku przekazania + uczelni) i `DoesNotExist` przy 0. + +**Tech Stack:** Django 5.2, pytest + model_bakery, ruff (E/F/I/UP/B/W/C90/DJ). + +--- + +## Reguła binarna (ustalona z userem 2026-06-02) + +Każdy call-site trafia do JEDNEGO z dwóch kubełków — bez agonizowania: + +1. **NIE MA PRAWA** mieć `get_default` — miejsca runtime, które budują klienta + PBN / wpis kolejki / linkują dane do konkretnej uczelni, ALBO mają w zasięgu + `request`. Fix: **jawna uczelnia** (threading od wołającego albo + `get_for_request(request)`). +2. **Akceptowalne** — cała reszta (CLI bez `--uczelnia-id`, config-reads bez + sensownego źródła uczelni, fallback adaptera). Cel: **`Uczelnia.objects.get()` + i tyle.** Zwraca jedyną uczelnię; przy >1 rzuca `MultipleObjectsReturned`, + przy 0 `DoesNotExist` — to GŁOŚNE ujawnienie, że (a) instalacja jest + multi-hosted i (b) to miejsce trzeba awansować do kubełka „NIE MA PRAWA". + +To rozstrzyga wszystkie wcześniejsze „DECYZJE" w tym dokumencie: +- **DECYZJA 1 (Faza 4 config-reads):** akceptowalne → `.get()`. Nie threadujemy + na siłę; jeśli `.get()` kiedyś rzuci na multi-hosted, awansujemy to miejsce. +- **DECYZJA 2 (adapter fallback):** → `.get()` (nie usuwamy fallbacku, nie + zostawiamy `get_default` — zamieniamy na `.get()`). Testy adaptera + single-uczelnia dalej działają. +- **DECYZJA 3 (CLI):** → `.get()`. + +--- + +## Zasada przewodnia (uzasadnienie całości) + +`get_default()` = `self.all().first()` — **pierwsza-z-brzegu** uczelnia. W +single-install jest dokładnie jedna, więc to przypadkiem działa. W multi-hosted +to losowy strzał: operacja czyta konfigurację / linkuje dane / buduje klienta +PBN dla NIEWŁAŚCIWEJ uczelni. To nie jest teoretyczne — to było źródłem błędu +„403 token aplikacji null". + +**Dlaczego NIE jeden globalny refactor „zamień get_default na get()":** w +hot-pathach bez requestu (np. sygnał denormalizacji przy KAŻDYM zapisie +publikacji) `Uczelnia.objects.get()` rzuciłby `MultipleObjectsReturned` na +multi-hosted instalacji → **crash przy zapisie**. Te miejsca wymagają +*threadingu* uczelni od wołającego, a nie podmiany akcesora. Dlatego plan jest +podzielony per-kategoria, z różnymi wzorcami i różnym ryzykiem. + +**Kryterium ukończenia każdej fazy:** zielony `ruff check` na zmienionych +plikach + `manage.py check` + celowany podzbiór testów + (gdzie to ma sens) +test regresji multi-hosted w stylu `src/pbn_api/tests/test_multihosted.py` +(dwie uczelnie → właściwa steruje wynikiem; failowałby przed fixem). + +--- + +## Mapa plików (co i dlaczego dotykamy) + +**Zostają nietknięte (świadome/strażowane) — UZASADNIENIE, czemu to NIE bug:** +- `bpp/models/uczelnia.py:31` — *definicja* `get_default` oraz `:44/70/75/81` + (wewnętrzne resolwery `get_for_request`/`get_for_site`/`default`/ + `do_roku_default`). To źródło, nie call-site. +- `pbn_api/management/commands/util.py:54` — strażowane `if count == 1:`, + inaczej `CommandError`. To jest WZORZEC, który chcemy replikować w CLI. +- `bpp/middleware.py:295` — fallback gdy `Site` istnieje, ale nie ma powiązanej + `Uczelni`. Świadomy edge-case przy rozwiązywaniu uczelni Z requestu (to nie + jest „zgadywanie zamiast requestu" — to ostatnia deska gdy mapowanie + Site→Uczelnia jest niekompletne). +- `bpp/util/bpp_specific.py:104` — `site_url_for_request`, request-first, fallback + tylko gdy NIE ma requestu (CLI/Celery budujące absolutny URL). Udokumentowane. + +**Fałszywy traf (NIE dotykać):** `bpp/admin/templates.py:57` — +`Engine.get_default()` to silnik szablonów Django, nie model `Uczelnia`. + +--- + +## Faza 1 — Widoki i formularze z dostępem do requestu (niskie ryzyko) + +**Uzasadnienie:** te call-site'y mają `request` (albo obiekt z `request`) +w bezpośrednim zasięgu. Zamiana na `get_for_request(request)` jest poprawna, +mechaniczna i nie zmienia zachowania single-install (get_for_request spada do +get_default gdy brak `request._uczelnia`). Zysk: multi-hosted czyta config +hosta, nie pierwszej uczelni. + +**Files:** +- Modify: `src/importer_publikacji/views/steps.py:336` +- Modify: `src/zglos_publikacje/forms.py:316` +- Modify: `src/zglos_publikacje/models.py:254` +- Modify: `src/nowe_raporty/forms.py:92` +- Modify: `src/pbn_export_queue/views/detail_views.py:152` +- Modify: `src/pbn_api/models/publikacja_instytucji.py:68` +- Test: odpowiednie `tests/` w każdej z aplikacji + +- [ ] **Step 1: Zlokalizuj źródło requestu w każdym pliku.** + Dla każdego sprawdź, czy w funkcji/metodzie jest `request`/`self.request` + (widoki, `detail_views`), `self.instance`+`request` (formularze), albo czy + obiekt ma własny request. UWAGA per-plik: + - `steps.py:336` (`_review_context`) — to funkcja widoku; potwierdź dostęp do + `request` w sygnaturze i przekaż `get_for_request(request)`. + - `forms.py:316` / `models.py:254` — formularz/`clean()`. Form NIE ma requestu + domyślnie. Sprawdź, czy wizard wstrzykuje `self._uczelnia`/`request` + (w `zglos_publikacje` może być `self.instance._uczelnia`). Jeśli NIE ma + skąd wziąć — to NIE jest Faza 1, przenieś do Fazy 4 (decyzja). + - `detail_views.py:152` — display-only URL do promptu AI; `self.request` + dostępny → `get_for_request(self.request)`. + - `publikacja_instytucji.py:68` — `self.uczelnia or get_default()`. To model + z FK `uczelnia`; jeśli `self.uczelnia` jest, fallback nie odpala. Zostaw + `self.uczelnia` a fallback usuń (gdy None — zwróć link bez api_root albo + `None`), BO to tylko budowa URL-a do wyświetlenia. + +- [ ] **Step 2: Napisz test regresji (przykład dla `detail_views`).** + +```python +@pytest.mark.django_db +def test_detail_view_pbn_url_uzywa_uczelni_z_requestu(rf, admin_user): + u1 = baker.make(Uczelnia, pbn_api_root="https://pbn-1/") # „pierwsza" + u2 = baker.make(Uczelnia, pbn_api_root="https://pbn-2/") + assert Uczelnia.objects.get_default() == u1 + request = rf.get("/") + request.user = admin_user + request._uczelnia = u2 # host = u2 + # ... wywołaj kod budujący URL ... + # assert że użyto pbn-2 (u2), nie pbn-1 (get_default) +``` + +- [ ] **Step 3: Zamień `Uczelnia.objects.get_default()` → + `Uczelnia.objects.get_for_request(request)`** w każdym pliku (z właściwym + źródłem requestu ustalonym w Step 1). + +- [ ] **Step 4: Uruchom testy + ruff.** + Run: `uv run pytest src/zglos_publikacje/ src/nowe_raporty/ src/pbn_export_queue/tests/ -q` + oraz `uv run ruff check `. Expected: PASS / All checks passed. + +- [ ] **Step 5: Commit.** + +```bash +git add src/importer_publikacji/views/steps.py src/zglos_publikacje/ \ + src/nowe_raporty/forms.py src/pbn_export_queue/views/detail_views.py \ + src/pbn_api/models/publikacja_instytucji.py +git commit -m "fix(multi-hosted): widoki/formularze czytaja uczelnie z requestu" +``` + +--- + +## Faza 2 — `importer_publikacji` dokończenie (wykorzystuje `ImportSession.uczelnia`) + +**Uzasadnienie:** w Phase 7/pkt 2 dodaliśmy `ImportSession.uczelnia` (FK, +ustawiane z requestu). Dwa miejsca w `importer_publikacji` jeszcze go nie +wykorzystują, bo są w ścieżce Celery (bez requestu) — ale mają `session` +w zasięgu (bezpośrednio lub przez `_create_publication(session)`). + +**Files:** +- Modify: `src/importer_publikacji/views/publikacja.py:125` (`_add_authors_to_record`) +- Modify: `src/importer_publikacji/views/steps.py:336` (jeśli nie pokryte Fazą 1) + +- [ ] **Step 1:** Prześledź, czy `_add_authors_to_record` dostaje `session` + lub `uczelnia`. `_create_publication(session)` → woła `_add_authors_to_record`. + Przekaż `session.uczelnia` w dół (dodaj parametr `uczelnia` do + `_add_authors_to_record`, przekazany z `_create_publication`). +- [ ] **Step 2:** Test: `create_publication_task` z `session.uczelnia=u2` → + `obca_jednostka` brane z `u2`, nie z `get_default()=u1`. +- [ ] **Step 3:** `uczelnia = Uczelnia.objects.get_default()` → + `uczelnia = session.uczelnia` (z fallbackiem tylko jeśli `session.uczelnia` + może być None dla starych sesji — wtedy `or Uczelnia.objects.get()`? NIE — + zostaw None i niech kod obsłuży brak, jak w innych miejscach). +- [ ] **Step 4:** Run: `uv run pytest src/importer_publikacji/tests/ -q`. PASS. +- [ ] **Step 5:** Commit `fix(multi-hosted): importer_publikacji uzywa session.uczelnia`. + +--- + +## Faza 3 — Runtime PBN: `ImportManager` propaguje uczelnię (audyt WYSOKIE) + +**Uzasadnienie:** to najgroźniejsza regresja z audytu. Zadanie `run_pbn_import` +POPRAWNIE wybiera uczelnię (`get_for_pbn_background(uczelnia_id)`, +`tasks.py:78/82`) i buduje klienta, ALE `ImportManager` nie przechowuje tej +uczelni. W efekcie `_execute_step` woła kroki bez uczelni → +`initial_setup.py:23` robi `get_default()` i **przebudowuje `self.client` na +klienta PIERWSZEJ uczelni**, a `import_manager.py:108` +(`_refresh_pbn_client_after_setup`) analogicznie. To NADPISUJE jawnie wybraną +uczelnię — czyli kasuje fix z `tasks.py`. Dodatkowo `author_import:18`, +`publication_import:79`, `institution_import:101` (dane: `pbn_uid_id`, +`obca_jednostka`) zgadują tą samą drogą. + +**Files:** +- Modify: `src/pbn_import/utils/import_manager.py` (`__init__`, `_execute_step`, + `_refresh_pbn_client_after_setup`, `:108`) +- Modify: `src/pbn_import/utils/initial_setup.py:23,31` +- Modify: `src/pbn_import/utils/author_import.py:18` +- Modify: `src/pbn_import/utils/publication_import.py:79` +- Modify: `src/pbn_import/utils/institution_import.py:101` +- Modify: `src/pbn_import/tasks.py` (przekaż uczelnię do `ImportManager`) +- Test: `src/pbn_import/tests/` + +- [ ] **Step 1: Test (failujący przed fixem).** Dwie uczelnie; `run_pbn_import` + z `uczelnia_id=u2.pk`; po `InitialSetup` `manager.client` ma transport z + tokenem `u2`, nie `u1`. (Asercja na `client.transport`/`client.uczelnia`.) +- [ ] **Step 2:** `ImportManager.__init__(self, ..., uczelnia)` — przechowuje + `self.uczelnia`. `_execute_step` przekazuje `self.uczelnia` do `step()`. + `_refresh_pbn_client_after_setup()` używa `self.uczelnia` zamiast + `get_default()`. +- [ ] **Step 3:** `InitialSetup.run(uczelnia=...)` — gdy `uczelnia` podana, + NIE woła `get_default()`. `author/publication/institution_import` przyjmują + `uczelnia` (już mają parametr `uczelnia=None` + `or get_default()` — usuń + fallback, wymuś przekazanie z managera). +- [ ] **Step 4:** `pbn_import/tasks.py` — `ImportManager(..., uczelnia=uczelnia)` + (uczelnia z `get_for_pbn_background(uczelnia_id)`, już jest na `:78`). +- [ ] **Step 5:** Run: `uv run pytest src/pbn_import/tests/ -q`. PASS. +- [ ] **Step 6:** Commit `fix(multi-hosted): ImportManager propaguje uczelnie do krokow importu`. + +--- + +## Faza 4 — bpp config-reads w hot-pathach ⚠️ DECYZJA WYMAGANA + +**To jest sedno „wolumenu" i NIE jest mechaniczne.** Te funkcje czytają +per-uczelnia config (`ukryte_statusy`, `sortuj_jednostki_alfabetycznie`, +`pokazuj_index_copernicus`, ustawienia liczenia slotów, `pbn_api_root` do +linków) i przyjmują `uczelnia=None` z fallbackiem `get_default()`. Część jest +w hot-pathie BEZ requestu. + +**Files (call-site → dlaczego trudny):** +- `src/bpp/models/abstract/disciplines.py:18` (`przelicz_punkty_dyscyplin`) — + **najtrudniejszy**: wołany z sygnału denormalizacji przy KAŻDYM zapisie + publikacji. Brak requestu. `.get()` → crash na multi-hosted. +- `src/bpp/models/sloty/core.py:34` (`ISlot`) — liczenie slotów, brak requestu. +- `src/bpp/models/jednostka.py:46` (`get_default_ordering`) — sortowanie listy + jednostek; wołane przy renderowaniu. +- `src/bpp/multiseek_registry/fields/numeric_fields.py:71` (`option_enabled`). +- `src/bpp/models/abstract/pbn.py:23,89` (`link_do_pbn`, `_format_link_pi`) — + budują URL z `pbn_api_root`. + +**❓ DECYZJA 1 — czym jest „uczelnia rekordu" w multi-hosted?** +Żeby threadować uczelnię do `przelicz_punkty_dyscyplin(rec)`/`ISlot(rec)`, musi +istnieć deterministyczne `rec → uczelnia`. Opcje: +- (a) **Dane są partycjonowane per-uczelnia** → uczelnia rekordu wynika z + jego struktury (autor→jednostka→wydział→uczelnia). Wtedy threadujemy + `rec.uczelnia` (wymaga zdefiniowania tej własności). +- (b) **Config slotów/dyscyplin jest efektywnie globalny** (jedna polityka + ewaluacji na instalację, niezależnie od hosta) → wtedy `get_default()` jest + semantycznie OK i te miejsca **zostają** (z komentarzem „config globalny"). +- (c) **Hybryda** — część (linki `pbn_api_root`) per-uczelnia, część (sloty) + globalna. + +**Rekomendacja do akceptacji:** dla `abstract/pbn.py` (linki) — per-uczelnia +(opcja a, ale tylko URL, niski koszt). Dla `sloty`/`disciplines`/`jednostka`/ +`multiseek` — **prawdopodobnie opcja b** (config ewaluacyjny/wyświetlania jest +instalacyjny, nie per-host), więc zostają z jawnym komentarzem zamiast cichego +`get_default`. **Potrzebuję Twojego potwierdzenia, czy w docelowym +multi-hosted te ustawienia różnią się per uczelnia.** + +- [ ] **Step 1:** Rozstrzygnij DECYZJĘ 1 (z userem). +- [ ] **Step 2:** Dla miejsc „globalnych" — zostaw `get_default()`, ale dodaj + komentarz `# config instalacyjny, nie per-host — get_default OK` (żeby + następny audyt nie zgłaszał). +- [ ] **Step 3:** Dla miejsc per-uczelnia — zdefiniuj `rec → uczelnia` + i threaduj; test multi-hosted. +- [ ] **Step 4:** Commit per podgrupa. + +--- + +## Faza 5 — Runtime PBN: pozostałe buildery klienta i kolejka + +**Uzasadnienie:** miejsca, które budują klienta PBN lub wpis kolejki z +`get_default()` w ścieżce runtime — bezpośrednie ryzyko złego konta PBN. + +**Files:** +- `src/bpp/admin/helpers/pbn_api/cli.py:43` — `uczelnia or get_default()` + w `sprobuj_wyslac_do_pbn_celery`. Caller `PBN_Export_Queue.send_to_pbn` ma + `self.uczelnia` (FK na wpisie). Fix: przekaż `self.uczelnia` z modelu kolejki; + usuń fallback. +- `src/pbn_integrator/utils/scientists.py:61,156` — buduje klienta z + `get_default()` gdy `uczelnia=None`. Fix: wymuś `uczelnia` (caller integratora + ją rozwiązuje); usuń fallback budujący klienta. +- `src/pbn_api/adapters/wydawnictwo.py:94` — fallback adaptera (patrz osobna + dyskusja). DECYZJA 2: usunąć (wymaga migracji testów adaptera + `pbn_wyslij` + z naprawą C901) czy zostawić defensywnie. Rekomendacja: zostawić defensywnie + TERAZ (wszystkie runtime callerzy przekazują jawną uczelnię), usunąć w + osobnym kroku „test cleanup". + +- [ ] **Step 1:** Test multi-hosted dla `cli.py` (wpis kolejki z `u2` → klient + z tokenem `u2`). +- [ ] **Step 2:** `PBN_Export_Queue.send_to_pbn` → przekaż `self.uczelnia` do + `sprobuj_wyslac_do_pbn_celery`; w `cli.py:43` usuń `or get_default()`. +- [ ] **Step 3:** `scientists.py` — sygnatury wymagają `uczelnia` (bez + fallbacku budującego klienta); zaktualizuj callerów w integratorze. +- [ ] **Step 4:** Run: `uv run pytest src/pbn_export_queue/tests/ src/pbn_integrator/tests/ -q`. PASS. +- [ ] **Step 5:** Commit `fix(multi-hosted): buildery klienta PBN wymagaja jawnej uczelni`. + +--- + +## Faza 6 — Zadania Celery (dane, nie klient) + +**Uzasadnienie:** czytają uczelnię dla danych (pbn_uid, jednostki). Mają +`uczelnia_id`/kontekst zadania albo da się go dodać. + +**Files:** +- `src/ewaluacja_metryki/tasks.py:219,346` — `... else Uczelnia.objects.get_default()`. + Sprawdź, czy zadanie ma `uczelnia_id` w sygnaturze; jeśli nie — dodaj + (jak w `pbn_downloader_app`/`pbn_wysylka` — wzorzec `get_for_pbn_background`). +- `src/oswiadczenia/tasks.py:562` — analogicznie. +- `src/pbn_import/utils/command_helpers.py:39` — util wołany z CLI/manager; + przekaż uczelnię od wołającego. + +- [ ] **Step 1–5:** Per zadanie: dodaj/wykorzystaj `uczelnia_id`, resolwuj przez + `get_for_pbn_background`, threaduj; test multi-hosted; commit. + +--- + +## Faza 7 — CLI management commands (single-install, niskie ryzyko) + +**Uzasadnienie:** uruchamiane ręcznie przez operatora. Akceptowalny wzorzec: +`Uczelnia.objects.get()` (jedyna albo wyjątek) — głośno ujawnia, że w +multi-hosted trzeba podać uczelnię. Docelowo (opcjonalnie) dodać `--uczelnia-id` +jak w `PBNBaseCommand`, ale to osobny scope. + +**❓ DECYZJA 3:** czy CLI ma: +- (a) `Uczelnia.objects.get()` — proste, rzuca przy >1 (rekomendacja), +- (b) `--uczelnia-id` + guard `count==1` (jak `PBNBaseCommand.util.py`) — + więcej kodu, ale działa w multi-hosted bez modyfikacji wywołania. + +**Files:** `ewaluacja2021/.../przelicz_liczbe_n_dla_uczelni.py:24` · +`ewaluacja_liczba_n/.../przelicz_n.py:24` · +`ewaluacja_metryki/.../oblicz_metryki.py:81` · +`pbn_import/management/commands/{pbn_import.py:164, fix_pbn_import_oswiadczen_ksiazki.py:271}` · +`bpp/management/commands/{wyczysc_baze.py:66, import_jednostki_ipis.py:32, fix_pbn_import_oswiadczen_ksiazki.py:270}` · +`pbn_integrator/management/commands/pbn_integrator.py:217,442` · +`pbn_api/management/commands/fix_from_institution_api_for_scientist.py:25` + +- [ ] **Step 1:** Rozstrzygnij DECYZJĘ 3. +- [ ] **Step 2:** Zastosuj wybrany wzorzec we wszystkich; `pbn_integrator.py` + ma już `uczelnia` w `handle()` (`:442`) — użyj jej zamiast `objects.default` + na `:217`. +- [ ] **Step 3:** Run: testy każdej apki + `manage.py check`. Commit. + +--- + +## Faza 8 — Porównania danych w integratorze + +**Uzasadnienie:** `objects.default.pbn_uid_id` używane do porównania +„czy to nasza instytucja" przy imporcie. W multi-hosted porówanie musi +dotyczyć uczelni, do której importujemy. + +**Files:** `pbn_integrator/utils/scientists.py:435` · +`pbn_integrator/utils/institutions.py:64,86` · +`pbn_integrator/importer/authors.py:89,102,111,117,131` + +- [ ] **Step 1:** Ustal, skąd integrator zna „swoją" uczelnię (z polecenia / + kontekstu integracji). Przekaż `uczelnia.pbn_uid_id` jako argument/atrybut + zamiast `Uczelnia.objects.default.pbn_uid_id` w każdym z 8 miejsc. +- [ ] **Step 2:** Test multi-hosted: import do `u2` porównuje z `u2.pbn_uid_id`. +- [ ] **Step 3:** Commit `fix(multi-hosted): integrator porownuje z uczelnia docelowa`. + +--- + +## Faza 9 — Sprzątanie i guard + +- [ ] **Step 1:** Sentinel-test: grep w teście, że w `src/` (poza definicją, + util.py guard, middleware, bpp_specific, migracjami, testami) NIE ma + `Uczelnia.objects.get_default()` / `.objects.default` — żeby regresje nie + wracały. (Wzorzec jak istniejące sentinel-testy w repo.) +- [ ] **Step 2:** Zaktualizuj `docs/deweloper/audyt-multihosted-pbn.md` — + oznacz rozwiązane. + +--- + +## Self-Review (wykonane przy pisaniu) + +- **Pokrycie:** wszystkie ~49 call-site'ów z audytu są przypisane do faz (1–8) + albo do listy „zostają" (z uzasadnieniem). Faza 9 to guard. +- **Decyzje otwarte (do akceptacji usera PRZED implementacją danej fazy):** + DECYZJA 1 (Faza 4 — czy config slotów/dyscyplin jest per-uczelnia czy + globalny), DECYZJA 2 (Faza 5 — usuwać fallback adaptera teraz czy później), + DECYZJA 3 (Faza 7 — `.get()` vs `--uczelnia-id` w CLI). +- **Kolejność ryzyka:** Faza 3 (ImportManager, WYSOKIE) i Faza 5 (buildery + klienta) są najpilniejsze; Faza 7 (CLI) najmniej. Fazy 1–2 to szybkie wygrane. +- **Brak placeholderów w krokach mechanicznych;** Faza 4 świadomie zawiera + DECYZJĘ zamiast kodu — bo bez rozstrzygnięcia „uczelnia rekordu" kod byłby + zgadywaniem. + +## Poza zakresem +- Pełne usunięcie fallbacku adaptera + refactor `pbn_wyslij` C901 (DECYZJA 2). +- Dodanie `--uczelnia-id` do CLI, jeśli wybrana opcja (b) w DECYZJI 3. From 6756b24c5bf9ffcb0f39dc9ab536d33ec2c5feff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 22:39:38 +0200 Subject: [PATCH 063/247] docs(multi-hosted): HANDOFF - stan pracy do wznowienia po reset sesji Co-Authored-By: Claude Opus 4.8 --- docs/superpowers/HANDOFF-multi-hosted.md | 110 +++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/HANDOFF-multi-hosted.md diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md new file mode 100644 index 000000000..c1ffada7c --- /dev/null +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -0,0 +1,110 @@ +# HANDOFF — praca multi-hosted (stan na 2026-06-02) + +Notatka do wznowienia po reset/compact sesji. Gałąź: `feature/multi-hosted-config`. +Wszystko zacommitowane i **wypushowane** (origin na `a834691fd`). + +Kontekst projektu: BPP (Django), instalacja **wielouczelniana** — jedna instancja +obsługuje wiele obiektów `Uczelnia`, każda z własną konfiguracją (PBN tokeny, +flagi wyświetlania itd.). Cel całości: żaden runtime nie „zgaduje" uczelni +(`get_default()` = pierwsza-z-brzegu), tylko używa właściwej. + +--- + +## CO ZROBIONE (2 duże wątki, oba domknięte) + +### Wątek 1: rozbicie `PBNClient` na dwie warstwy (Wariant B) +Spec: `docs/superpowers/specs/2026-06-02-pbn-client-split-design.md` + +- **`src/pbn_client/`** — czysta, reusable warstwa protokołu PBN (transport, + auth, pagination, mixiny słownikowo-CRUD, `StatementsMixin`, `PBNClient`). + **Zero importów `bpp`/`pbn_api`** (zweryfikowane). Kandydat na osobny pakiet. +- **`BppPBNClient`** w `pbn_api/client/__init__.py` — dziedziczy po `PBNClient`, + dokłada orchestrację (`publication_sync`, `disciplines`) i **zna swoją + `Uczelnia`** (`__init__(transport, uczelnia)`). `get_default()` zniknął z + `publication_sync` i z głównej ścieżki adaptera (orchestracja przekazuje + `uczelnia=self.uczelnia`). +- Fabryki: `Uczelnia.pbn_client()`, `PBNBaseCommand.get_client()`, fixtura + `pbn_client` → zwracają `BppPBNClient`. `pbn_api.client` to shim + re-eksportujący pełny `__all__` (kompatybilność wsteczna, 35 importów). +- `pbn_api/const.py`, `exceptions.py`, `client/transport.py`, `utils.py` → + shimy do `pbn_client`. `pbn_api/utils.py` → shim do `pbn_client/dict_utils`. +- Wariant B (NIE było osobnego pakietu `pbn_client_bpp`): orchestracja i + adaptery **zostają w `pbn_api`**. Dlaczego: ekstrakcja `pbn_api` i tak + zablokowana przez sklejenie modeli z `bpp` — patrz audyt. +- Test multi-hosted: `src/pbn_api/tests/test_multihosted.py` (dwie uczelnie → + klient czyta flagi ze SWOJEJ). + +### Wątek 2: cleanup `get_default` (Fazy 1–9) — KOMPLETNY +Plan: `docs/superpowers/plans/2026-06-02-get-default-cleanup.md` +Audyt + status: `docs/deweloper/audyt-multihosted-pbn.md` + +Reguła binarna (ustalona z userem): runtime z dostępną uczelnią → **jawna +uczelnia** (`get_for_request` / argument / FK / `self.uczelnia`); reszta +akceptowalna → **`Uczelnia.objects.get()`** (single-or-fail; NIE nowa metoda). + +- Fazy 1–8 przepięły runtime na jawną uczelnię; fallbacki single-install na + `.get()`. **Rygor per-miejsce** (na życzenie usera) wyłapał 3 miejsca, gdzie + mechaniczne `.get()` było złe → cofnięte do None-tolerant/lazy: + `adapters/wydawnictwo.py` (test-only fallback), `command_helpers.py` + (clean `CommandError`), `oblicz_metryki.py` (lazy uczelnia w gałęzi liczby-N). +- **Faza 9 = guard:** `src/bpp/tests/test_multihosted_get_default_guard.py` — + zamraża whitelistę 15 świadomych miejsc; nowy `get_default` w runtime → + fail CI. (Whitelista + uzasadnienia w tym teście i w audycie.) +- Weryfikacja: **1302 passed, 2 skipped** na dotkniętym obszarze. + +--- + +## CO ZOSTAŁO (PARKED — następne wątki) + +### A) NASTĘPNY: per-uczelnia liczenie slotów/punktacji ← brainstorm tutaj +Parked TODO w: `bpp/models/sloty/core.py` (`ISlot`), +`bpp/models/abstract/disciplines.py` (`przelicz_punkty_dyscyplin`). + +**Ustalenia domenowe (od usera, KLUCZOWE):** +- Rekord NIE ma deterministycznej uczelni — autorzy mogą być z różnych uczelni + (autor → afiliacja na jednostkę → jednostka ma uczelnię). Praca z autorami + z 10 uczelni → sloty trzeba policzyć i zapisać **osobno per uczelnia**. +- Matematyka slotów zależy od **ROKU**, nie uczelni. Z uczelni `ISlot` czyta + TYLKO `ukryte_statusy("sloty")` (rzadki filtr: „nie licz dla statusu X"). +- **Pomysł usera (tani rdzeń):** `Cache_Punktacja_Autora` jest JUŻ per-autor → + dorzucić `uczelnia_id` (= uczelnia jednostki autora) = otagowanie istniejących + wierszy, nie sztuczne mnożenie. + +**Co spec musi rozstrzygnąć (głębia):** +1. `Cache_Punktacja_Dyscypliny` to agregat per (rekord, dyscyplina) → rozbić + per (rekord, uczelnia, dyscyplina); liczenie iteruje uczelnie rekordu. +2. `ukryte_statusy` per uczelnia — `ISlot` biegnie per uczelnia z JEJ statusami + (rekord policzony dla Y, pominięty dla X). +3. Migracja + backfill (`uczelnia_id` z jednostki autora; single-install → + jedna uczelnia, zero zmiany zachowania). +4. ODCZYTY: raporty, rankingi, „liczba N", `ewaluacja_optymalizacja`, metryki, + API — filtrować po uczelni oglądającego (`get_for_request`). Duży, jednorodny + zbiór. +5. Invalidacja przy zmianie afiliacji autora; indeksy/objętość. + +### B) Integrator per-uczelnia (parked TODO) +`pbn_integrator/utils/scientists.py` (matcher), `importer/authors.py` (×5 +porównań afiliacji), `management/commands/pbn_integrator.py` (`_handle_people`). +Porównania z „naszą" uczelnią (`objects.default.pbn_uid_id`) — wymaga przekazania +uczelni docelowej przez pipeline integratora (deeper). `objects.default` zostaje +świadomie (cached; `.get()` byłby perf-regresją w pętlach + crash na >1). + +### C) Drobne +- Pełne usunięcie fallbacku `get_default` z `adapters/wydawnictwo.py` — wymaga + migracji testów adaptera (konstruują bez uczelni) + naprawy `pbn_wyslij` + (pre-existing `C901`). Niski priorytet (runtime przekazuje jawną uczelnię). + +--- + +## STAN GIT +- Gałąź `feature/multi-hosted-config`, origin = `a834691fd` (wypushowane). +- Główne commity (od najnowszych): plan/audyt docs, Faza 9 guard, Fazy 8→1 + get_default, Faza 2+3 ImportManager, importer_publikacji `ImportSession.uczelnia`. + +## KOMENDY (dla agenta) +- Testy: `uv run pytest <ścieżka> -q -p no:cacheprovider` (testcontainers same + stawiają PG/Redis; Docker musi działać). +- Guard: `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q`. +- Lint: `uv run ruff check ` (NIE `--fix`; per CLAUDE.md fix ręcznie). +- `uv run python src/manage.py check` (z `PYTEST_TESTCONTAINERS_DISABLE=1 + DJANGO_BPP_SKIP_DOTENV=1` gdy brak dev-bazy). From 7f793af2cebe9100fce406a92f1d334d6b3033a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 22:51:24 +0200 Subject: [PATCH 064/247] fix(multi-hosted): lap Uczelnia.DoesNotExist w sprobuj_wyslac_do_pbn_celery Regresja z Fazy 5 (83fac7ae7): zamiana get_default() -> .get() zmienila semantyke przy 0 rekordach Uczelnia. get_default() zwracalo None (param-check rzucal BrakZdefiniowanegoObiektuUczelniaWSystemieError -> lapane -> ValueError), ale .get() rzuca Uczelnia.DoesNotExist *przed* param-checkiem, wiec przeciekalo jako surowy DoesNotExist zamiast oczekiwanego ValueError("brak obiektu Uczelnia"). Dodaje Uczelnia.DoesNotExist do lapanych wyjatkow. MultipleObjectsReturned (>1) celowo NIE jest lapane: multi-hosted caller musi podac uczelnia jawnie, a glosny blad to sygnalizuje. Naprawia test_sprobuj_wyslac_do_pbn_celery. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/admin/helpers/pbn_api/cli.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/bpp/admin/helpers/pbn_api/cli.py b/src/bpp/admin/helpers/pbn_api/cli.py index bec2506b5..a3b196726 100644 --- a/src/bpp/admin/helpers/pbn_api/cli.py +++ b/src/bpp/admin/helpers/pbn_api/cli.py @@ -44,7 +44,14 @@ def sprobuj_wyslac_do_pbn_celery( # single-install: .get() (przy >1 rzuca — multi-hosted ma podać). uczelnia or Uczelnia.objects.get() ) - except BrakZdefiniowanegoObiektuUczelniaWSystemieError as e: + except ( + BrakZdefiniowanegoObiektuUczelniaWSystemieError, + Uczelnia.DoesNotExist, + ) as e: + # .get() rzuca DoesNotExist gdy 0 rekordow Uczelnia (dawne + # get_default() zwracalo None -> param-check rzucal Brak...Error). + # MultipleObjectsReturned (>1) celowo NIE jest tu lapane: multi-hosted + # caller musi podac uczelnia jawnie, a glosny blad to sygnalizuje. raise ValueError("W systemie brak obiektu Uczelnia.") from e if uczelnia is False: From 2aebb7ba046d2b81be2e359f19c63dd3fe128da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 23:04:06 +0200 Subject: [PATCH 065/247] docs(multi-hosted): spec - per-uczelnia liczenie slotow (write-side) Design write-side: cache slotow/punktacji liczony per (rekord, uczelnia), kazda uczelnia na subsetcie swoich autorow (true per-university partition, zawezony dzielnik). Cache_Punktacja_Dyscypliny zyskuje uczelnia FK; Cache_Punktacja_Autora trzyma uczelnie wyprowadzana z jednostki. Approach A (parametryzacja SlotMixin/ISlot uczelnia). Naprawa joina widoku SQL, migracja nullable + backfill przez denorm rebuild. Sekcja read-side (raporty/ewaluacja/metryki) jako nastepny, osobny spec. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-02-per-uczelnia-sloty-design.md | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md diff --git a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md new file mode 100644 index 000000000..437c18686 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md @@ -0,0 +1,245 @@ +# Design — per-uczelnia liczenie slotów/punktacji (warstwa write-side) + +Data: 2026-06-02 +Gałąź: `feature/multi-hosted-config` +Kontekst: następny wątek po cleanupie `get_default` (patrz +`docs/superpowers/HANDOFF-multi-hosted.md`, sekcja A). + +## Cel i zakres + +W instalacji wielouczelnianej jeden rekord może mieć autorów z wielu uczelni +(autor → afiliacja na jednostkę → jednostka ma uczelnię). Punktacja i sloty +muszą być liczone i **zapisywane osobno per uczelnia**, za każdym razem dla +**subsetu autorów z danej uczelni**. + +**W zakresie tego spec (write-side foundation):** +- zmiana schematu cache (`Cache_Punktacja_Dyscypliny` zyskuje klucz uczelni), +- parametryzacja kalkulatora slotów uczelnią (filtr zbioru autorów), +- orkiestracja cachera: pętla po uczelniach rekordu, +- naprawa bazowego widoku SQL (join musi uwzględniać uczelnię), +- migracja + backfill przez przeliczenie (denorm rebuild). + +**Poza zakresem (osobny, następny spec — read-side):** +- filtrowanie odczytów po uczelni oglądającego (`get_for_request`): raporty, + rankingi, „liczba N", `ewaluacja_optymalizacja`, API, +- pipeline tabel tymczasowych `Cache_Punktacja_Autora_Sum` / + `_Sum_Group` (raport_slotow), +- integrator per-uczelnia (handoff §B), drobne (handoff §C). + +## Reguła wiodąca (decyzja domenowa usera) + +**True per-university partition.** Dla pracy współautorskiej między uczelniami +slot każdego autora liczony jest z **dzielnikiem zawężonym do autorów danej +uczelni** — zarówno `k` (liczba autorów z dyscypliny), jak i `m` (wszyscy +autorzy rekordu) liczone są na subsetcie autorów z tej uczelni. W rezultacie +`pkdaut`/`slot` różnią się per uczelnia, a `Cache_Punktacja_Dyscypliny` to +agregat per (rekord, uczelnia, dyscyplina). + +> Zastrzeżenie (zanotowane, decyzja usera): to świadome odejście od reguły +> „matematyka slotów zależy od roku, nie uczelni" z wcześniejszego handoffu. +> Per-instytucjonalne przeliczanie dzielnika zmienia liczby względem stanu +> obecnego dla prac współautorskich między uczelniami. User potwierdził to +> dwukrotnie, świadomie, jako regułę docelową. Caveat regulacyjny (udział +> jednostkowy wg MEiN bywa liczony z pełnej współautorskości) odnotowany na +> wypadek audytu liczb. + +## Invariant zgodności + +Przy **dokładnie jednej** uczelni (stan każdej żywej instalacji dziś) nowy kod +musi dawać liczby **identyczne** jak obecnie. Zachowanie wielouczelniane to +nowa zdolność, uśpiona dopóki nie istnieje druga `Uczelnia`. Mechanizm: +`uczelnia=None` w kalkulatorze ⇒ brak filtra autorów ⇒ ścieżka jak dziś. + +## Stan obecny (zmapowany) + +Pliki: +- `src/bpp/models/sloty/core.py` — `ISlot(original, uczelnia=None)` + (dopasowanie kalkulatora), `IPunktacjaCacher` (materializacja cache). +- `src/bpp/models/sloty/common.py` — `SlotMixin`: `wszyscy()`, + `autorzy_z_dyscypliny()`, `dyscypliny`, `liczba_k`, `k_przez_m`, + `pkd_dla_autora` — wszystkie czytają autorów przez `original.autorzy_set`. +- `src/bpp/models/abstract/disciplines.py` — + `ModelZPrzeliczaniemDyscyplin.przelicz_punkty_dyscyplin(uczelnia=None)` + (wejście denorm; dziś `get_default()` fallback — TODO do usunięcia). +- `src/bpp/models/cache/punktacja.py` — modele cache. +- `src/bpp/migrations/0204_cache_punktacja_autora_query_view.sql` — definicja + widoku `bpp_cache_punktacja_autora_view`. + +Wyzwalanie przeliczenia: pole `@denormalized cached_punkty_dyscyplin` na +`Wydawnictwo_Ciagle` / `Wydawnictwo_Zwarte` / `Patent` woła +`self.przelicz_punkty_dyscyplin()` przy zmianie pól autorów (django-denorm). +Backfill ≈ pełny denorm rebuild (`denorms.flush()` / rebuildall). + +Dzielnik (mechanika, `common.py`): +- `liczba_k(d)` = `len(autorzy_z_dyscypliny(d))` — autorzy afiliujący/przypięci + z dyscypliny, +- `wszyscy()` = `autorzy_set.count()` — wszyscy autorzy rekordu (`m`), +- `pkd_dla_autora` = `pkd / liczba_k`, udziały oparte też o `k/m`. + +## Zmiany schematu + +- `Cache_Punktacja_Dyscypliny`: **dodać `uczelnia` FK** + (`ForeignKey(Uczelnia, on_delete=CASCADE, null=True)`) — klucz partycji + (tabela nie ma `jednostka`, więc uczelni nie da się wyprowadzić). `serialize()` + uwzględnia `uczelnia_id`. +- `Cache_Punktacja_Autora`: **bez nowej kolumny** — uczelnia wynika z + `jednostka.uczelnia`. Zmieniają się tylko liczone `slot`/`pkdaut`. Wiersz + pozostaje keyowany `(rekord, autor, jednostka, dyscyplina)`; jeden wiersz + autorstwa mapuje na dokładnie jedną jednostkę → jedną uczelnię. +- Indeks: `Cache_Punktacja_Dyscypliny(rekord_id, uczelnia, dyscyplina)` pod + naprawiony join widoku i przyszłe odczyty per uczelnia. + +Wpływ na `serialize()` / denorm: `Cache_Punktacja_Dyscypliny.serialize()` zyskuje +`uczelnia_id`, a `przelicz_punkty_dyscyplin()` zwraca payload wielouczelniany. +To zmienia łańcuch zapisywany w polu `@denormalized cached_punkty_dyscyplin` +(`TextField`) oraz wszelkie testy asertujące dokładny kształt `serialize()` — +trzeba je zaktualizować. Brak zmiany kontraktu odczytu (dalej lista list). + +Uzasadnienie asymetrii (normalize vs denormalize): Autora ma `jednostka`, więc +uczelnia jest w pełni wyprowadzalna — brak kolumny = zero ryzyka +niespójności; koszt to jeden dodatkowy join (przez `bpp_jednostka`) w widoku +i zapytaniach grupujących per uczelnia. Decyzja usera: trzymać wyprowadzaną. + +## Kalkulator slotów (Approach A — parametryzacja + filtr querysetów) + +- `SlotMixin.__init__(self, original, uczelnia=None)`. Gdy `uczelnia` ustawione, + trzy szwy czytające autorów filtrują po `jednostka__uczelnia`: + - `wszyscy()` → `autorzy_set.filter(jednostka__uczelnia=U).count()` (`m`), + - `autorzy_z_dyscypliny(d)` → dokłada `jednostka__uczelnia=U` (`k` + lista), + - `dyscypliny` (cached_property) → tylko dyscypliny mające autora z `U`. +- `ISlot(original, uczelnia)` przekazuje `uczelnia` do konstruowanego + `SlotKalkulator_*`. Istniejąca bramka `ukryte_statusy("sloty")` zostaje — + teraz naturalnie per uczelnia (rekord może `CannotAdapt` dla jednej uczelni + i policzyć się dla innej). +- `uczelnia=None` ⇒ brak filtra autorów ⇒ zachowanie jak dziś (invariant). + +Ryzyko: pominięty odczyt `autorzy_set` przeciekłby autorów spoza uczelni. +Mitygacja: test, w którym pominięcie zmieniłoby liczbę (asercja na dzielnik). + +## Orkiestracja cachera + +- `IPunktacjaCacher(original, uczelnia)` — w pełni per uczelnia: + - `removeEntries()` zawężone: `Cache_Punktacja_Dyscypliny.filter(uczelnia=U)` + i `Cache_Punktacja_Autora.filter(jednostka__uczelnia=U)`, + - `rebuildEntries()` odpala zawężony kalkulator; nowe wiersze + `Cache_Punktacja_Dyscypliny` tagowane `uczelnia=U`; `Cache_Punktacja_Autora` + bez zmiany kształtu. +- `przelicz_punkty_dyscyplin(self, uczelnia=None)` (wejście denorm) zyskuje + pętlę i **traci `get_default()`** (zamyka parked TODO): + - **skasuj wszystkie** wiersze cache dla rekordu raz (czyści uczelnie, które + wypadły — np. po zmianie afiliacji ostatniego autora z danej uczelni), + - wylicz uczelnie rekordu — `uczelnie_rekordu()` = distinct `uczelnia` wśród + afiliujących/przypiętych autorów (spójnie z filtrami `rebuildEntries`), + - dla każdej zbuduj `IPunktacjaCacher(self, U)` i przebuduj (tylko create — + globalny delete już zrobiony), + - `uczelnia=` jawne ⇒ policz tylko tę jedną (targetowane przebudowy, testy). +- `cached_punkty_dyscyplin` (`@denormalized`) woła bez argumentów ⇒ auto-rebuild + produkuje wszystkie uczelnie. `serialize()` zwraca pełny payload + wielouczelniany. + +`uczelnie_rekordu()`: helper na `ModelZPrzeliczaniemDyscyplin` (lub modelu), +zwraca distinct `Uczelnia` z `autorzy_set` afiliujących/przypiętych. Może być +**luźnym nadzbiorem** — uczelnia bez policzalnych autorów po prostu nie wytworzy +żadnych wierszy (zawężony kalkulator zwróci pusto / `CannotAdapt`), więc +enumeracja nie musi co do joty replikować filtrów `rebuildEntries` +(`skupia_pracownikow`, `rodzaj_autora_uwzgledniany_w_kalkulacjach_slotow`). +Ważne tylko, by nie **pomijała** uczelni, która ma policzalnych autorów. + +## Naprawa widoku SQL (poprawność, nie do odłożenia) + +Nowa migracja DROP+CREATE `bpp_cache_punktacja_autora_view`, by join trafiał +też w uczelnię — inaczej rekord 2-uczelniany daje kartezjański iloczyn wierszy +między uczelniami: + +```sql +CREATE VIEW bpp_cache_punktacja_autora_view AS +SELECT a.id, a.rekord_id, a.pkdaut, a.slot, a.autor_id, a.dyscyplina_id, + a.jednostka_id, + d.autorzy_z_dyscypliny, d.zapisani_autorzy_z_dyscypliny +FROM bpp_cache_punktacja_autora a +JOIN bpp_jednostka j ON j.id = a.jednostka_id +JOIN bpp_cache_punktacja_dyscypliny d + ON a.rekord_id = d.rekord_id + AND a.dyscyplina_id = d.dyscyplina_id + AND d.uczelnia_id = j.uczelnia_id; +``` + +Model `managed=False` widoku (`Cache_Punktacja_Autora_Query_View`) **nie** +dostaje na razie nowego pola — ekspozycja `uczelnia` w odczytach to read-side. + +## Migracja i backfill + +- Migracja 1: dodanie nullable `uczelnia` FK + indeks (szybka, bez ciężkiego + kroku danych blokującego tabelę). +- Migracja 2 (osobny plik): DROP+CREATE widoku z joinem uwzględniającym + uczelnię. +- Backfill = **pełny denorm rebuild po deployu** (udokumentowany krok + deploymentu), który repopuluje per uczelnia nowym kodem. Single-install ⇒ + liczby identyczne. Opcjonalna późniejsza migracja zacieśnia `uczelnia` do + non-null po przeliczeniu. +- Zgodnie z regułą projektu: **żadnych edycji istniejących migracji** — same + nowe pliki. + +## Testy + +- Unit: jeden rekord współautorski, 2 uczelnie → asercja, że `k`, `m`, `slot`, + `pkdaut` każdej uczelni używają tylko jej autorów (różne dzielniki), oraz że + `Cache_Punktacja_Dyscypliny` daje 2 wiersze z poprawną `uczelnia` i + zawężonym `autorzy_z_dyscypliny`. +- Invariant: fixture jednouczelniany → liczby identyczne z obecnymi + oczekiwaniami (ochrona przed regresją). +- `ukryte_statusy` per uczelnia: rekord liczony dla U1, `CannotAdapt` dla U2 → + wiersze tylko dla U1. +- Widok: rekord 2-uczelniany → brak kartezjańskiej duplikacji; wiersz autora + joinuje tylko agregat dyscypliny swojej uczelni. +- `uczelnie_rekordu()`: nie **pomija** żadnej uczelni mającej policzalnego + autora (nadzbiór jest OK — uczelnia bez autorów daje zero wierszy, bez błędu). +- Wypadnięcie uczelni: po przeniesieniu ostatniego autora U2 do U1 i przeliczeniu + — brak osieroconych wierszy U2. + +## Komendy weryfikacji + +- Testy: `uv run pytest src/bpp/tests/test_models/test_sloty/ -q -p no:cacheprovider` +- Lint: `uv run ruff check ` (NIE `--fix`). +- `uv run python src/manage.py makemigrations --check --dry-run` + (z `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1` gdy brak bazy). + +## Następny krok: read-side (osobny spec) + +Po wdrożeniu write-side cache zawiera wiersze per (rekord, uczelnia). Dopóki +odczyty NIE filtrują po uczelni, w instalacji wielouczelnianej liczyłyby +podwójnie/międzyuczelniano. **Single-install jest bezpieczny** (jedna uczelnia += jeden komplet wierszy), więc read-side może iść jako kolejny, oddzielny spec. + +Kontrakt read-side: filtrować po uczelni **oglądającego** (`get_for_request`), +analogicznie jak `Cache_Punktacja_Autora` po `jednostka__uczelnia`, a +`Cache_Punktacja_Dyscypliny` po `uczelnia`. Widok `managed=False` +(`Cache_Punktacja_Autora_Query_View`) trzeba wtedy rozszerzyć o `uczelnia` +(z `bpp_jednostka.uczelnia_id`) i pipeline tabel tymczasowych musi nieść +uczelnię. + +Zinwentaryzowani konsumenci (write-side ich NIE rusza; do read-side spec): + +- **raport_slotow** — `core.py`, `tables.py`, `filters.py`, `views/autor.py`, + `models/uczelnia.py` (główny konsument widoku + tabel `_Sum`/`_Sum_Group`). +- **ewaluacja_optymalizacja** — `core/data_loader.py`, + `core/optimization_phases.py`, `tasks/unpinning/{analysis,capacity_analysis}.py`, + `utils.py`, `views/{author_works,author_works_exports,exports,helpers, + verification}.py`, `views/evaluation_browser/prefetch.py`. +- **ewaluacja_metryki** — `models.py`, `utils.py`, `views/{detail,list}.py`. +- **ewaluacja2021** — `core/{plecakowy,sumator_base,util}.py`, `models.py`, + `reports.py`. +- **ewaluacja_optymalizuj_publikacje** — `views.py` (też wywołuje rebuild — + upewnić się, że po zmianie przypięcia/dyscypliny przelicza per uczelnia). +- **oswiadczenia** — `views.py`. +- **ewaluacja_common** — `utils.py`. +- **bpp** — `core.py`, `management/commands/zbieraj_sloty.py` (raport per autor). + +Tabele tymczasowe pipeline'u raportów (`Cache_Punktacja_Autora_Sum`, +`_Sum_Group`, `bpp_temporary_cpaq*`, `bpp_temporary_cpasg*`) — przebudowa pod +uczelnię należy do read-side. + +## Pozostaje parked (poza ścieżką read-side) + +- Integrator per-uczelnia (handoff §B). +- Drobne (handoff §C). From 576afe70e7c9a3e1fbc0259f2c7f2cc5754be40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 23:07:32 +0200 Subject: [PATCH 066/247] docs(multi-hosted): self-review spec per-uczelnia sloty - doprecyzowano zwracana wartosc przelicz_punkty_dyscyplin() pod petla (zagregowany deterministyczny payload, denorm nie brudny) - subtelnosc mianownika m: autor bez jednostki wypada z m kazdej uczelni - reconcile zakresu read-side (liczba N/rankingi/API konsumuja posrednio) - testy: autor bez jednostki + determinizm zwrotki Co-Authored-By: Claude Opus 4.8 --- .../2026-06-02-per-uczelnia-sloty-design.md | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md index 437c18686..8494dd656 100644 --- a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md +++ b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md @@ -21,7 +21,11 @@ muszą być liczone i **zapisywane osobno per uczelnia**, za każdym razem dla **Poza zakresem (osobny, następny spec — read-side):** - filtrowanie odczytów po uczelni oglądającego (`get_for_request`): raporty, - rankingi, „liczba N", `ewaluacja_optymalizacja`, API, + `ewaluacja_optymalizacja`, `ewaluacja_metryki`, `ewaluacja2021`, oświadczenia + (pełna inwentaryzacja niżej, sekcja „Następny krok: read-side"). „Liczba N", + rankingi i API konsumują cache pośrednio (przez `Rekord`/serializery) — do + zweryfikowania w read-side spec, nie inwentaryzowane tu jako bezpośredni + importerzy, - pipeline tabel tymczasowych `Cache_Punktacja_Autora_Sum` / `_Sum_Group` (raport_slotow), - integrator per-uczelnia (handoff §B), drobne (handoff §C). @@ -116,6 +120,14 @@ i zapytaniach grupujących per uczelnia. Decyzja usera: trzymać wyprowadzaną. Ryzyko: pominięty odczyt `autorzy_set` przeciekłby autorów spoza uczelni. Mitygacja: test, w którym pominięcie zmieniłoby liczbę (asercja na dzielnik). +Subtelność `m` (mianownik) a autorzy bez jednostki: `wszyscy()` dziś liczy +WSZYSTKICH autorów rekordu. Filtr `jednostka__uczelnia=U` wyklucza autorów z +`jednostka IS NULL` z mianownika KAŻDEJ uczelni — to świadoma konsekwencja +„subsetu autorów z danej uczelni" (autor bez jednostki nie należy do żadnej +uczelni), ale jest to zmiana zachowania także względem naiwnego „tagowania". +Decyzja: akceptowalne i spójne z regułą wiodącą; do pokrycia testem +(rekord z autorem bez jednostki → nie wpływa na `m` żadnej uczelni). + ## Orkiestracja cachera - `IPunktacjaCacher(original, uczelnia)` — w pełni per uczelnia: @@ -134,8 +146,17 @@ Mitygacja: test, w którym pominięcie zmieniłoby liczbę (asercja na dzielnik) globalny delete już zrobiony), - `uczelnia=` jawne ⇒ policz tylko tę jedną (targetowane przebudowy, testy). - `cached_punkty_dyscyplin` (`@denormalized`) woła bez argumentów ⇒ auto-rebuild - produkuje wszystkie uczelnie. `serialize()` zwraca pełny payload - wielouczelniany. + produkuje wszystkie uczelnie. + +Zwracana wartość `przelicz_punkty_dyscyplin()`: dziś zwraca `ipc.serialize()` +jednej uczelni (krotka dwóch list), a wynik trafia do pola `@denormalized +cached_punkty_dyscyplin` (TextField, używane jako artefakt denorm/change- +detection, nie parsowane merytorycznie). Po wprowadzeniu pętli musi zwracać +**zagregowany, deterministyczny** payload ze wszystkich uczelni (np. konkatenacja +`serialize()` per uczelnia w stabilnej kolejności po `uczelnia_id`). Źródłem +prawdy są wiersze w tabelach cache; format zwrotki jest elastyczny, byle +deterministyczny (inaczej denorm „migałby" jako wiecznie brudny). Weryfikacja: +brak kodu czytającego `cached_punkty_dyscyplin` jako dane — potwierdzić w planie. `uczelnie_rekordu()`: helper na `ModelZPrzeliczaniemDyscyplin` (lub modelu), zwraca distinct `Uczelnia` z `autorzy_set` afiliujących/przypiętych. Może być @@ -196,6 +217,9 @@ dostaje na razie nowego pola — ekspozycja `uczelnia` w odczytach to read-side. autora (nadzbiór jest OK — uczelnia bez autorów daje zero wierszy, bez błędu). - Wypadnięcie uczelni: po przeniesieniu ostatniego autora U2 do U1 i przeliczeniu — brak osieroconych wierszy U2. +- Autor bez jednostki: nie wpływa na mianownik `m` żadnej uczelni. +- Determinizm zwrotki `przelicz_punkty_dyscyplin()`: dwa przeliczenia tego samego + rekordu dają identyczny string (denorm nie jest wiecznie brudny). ## Komendy weryfikacji From 85420bf6d02c90510560114ad35727c4fefec7ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 23:09:36 +0200 Subject: [PATCH 067/247] docs(multi-hosted): popraw spec - jednostka jest NOT NULL Pole jednostka na wierszu autorstwa (authors.py:23) jest wymagane, wiec przypadek "autor bez jednostki/uczelni" nie istnieje. Zamieniono nadmiarowa uwage z self-review na pozytywny invariant; usunieto zbedny test. Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-06-02-per-uczelnia-sloty-design.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md index 8494dd656..004cebcc4 100644 --- a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md +++ b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md @@ -120,13 +120,13 @@ i zapytaniach grupujących per uczelnia. Decyzja usera: trzymać wyprowadzaną. Ryzyko: pominięty odczyt `autorzy_set` przeciekłby autorów spoza uczelni. Mitygacja: test, w którym pominięcie zmieniłoby liczbę (asercja na dzielnik). -Subtelność `m` (mianownik) a autorzy bez jednostki: `wszyscy()` dziś liczy -WSZYSTKICH autorów rekordu. Filtr `jednostka__uczelnia=U` wyklucza autorów z -`jednostka IS NULL` z mianownika KAŻDEJ uczelni — to świadoma konsekwencja -„subsetu autorów z danej uczelni" (autor bez jednostki nie należy do żadnej -uczelni), ale jest to zmiana zachowania także względem naiwnego „tagowania". -Decyzja: akceptowalne i spójne z regułą wiodącą; do pokrycia testem -(rekord z autorem bez jednostki → nie wpływa na `m` żadnej uczelni). +Invariant `jednostka`: pole `jednostka` na wierszu autorstwa +(`BazaModeluOdpowiedzialnosciAutorow.jednostka`, `src/bpp/models/abstract/ +authors.py:23`) jest **NOT NULL** — każdy autor na rekordzie ma jednostkę, więc +zawsze ma uczelnię. Nie ma więc przypadku „autor bez uczelni", a filtr +`jednostka__uczelnia=U` na `wszyscy()`/`m` zawęża wyłącznie po realnej +przynależności (współautorzy z innej uczelni wypadają z `m` danej uczelni — +to właśnie sedno partycji, nie efekt nullowy). ## Orkiestracja cachera @@ -217,7 +217,6 @@ dostaje na razie nowego pola — ekspozycja `uczelnia` w odczytach to read-side. autora (nadzbiór jest OK — uczelnia bez autorów daje zero wierszy, bez błędu). - Wypadnięcie uczelni: po przeniesieniu ostatniego autora U2 do U1 i przeliczeniu — brak osieroconych wierszy U2. -- Autor bez jednostki: nie wpływa na mianownik `m` żadnej uczelni. - Determinizm zwrotki `przelicz_punkty_dyscyplin()`: dwa przeliczenia tego samego rekordu dają identyczny string (denorm nie jest wiecznie brudny). From de02dc312cb803b0ae2e7ea18819f518df571c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 2 Jun 2026 23:17:22 +0200 Subject: [PATCH 068/247] docs(multi-hosted): spec - federacja, callerzy rebuildu, kompat IPunktacjaCacher - kompatybilnosc: IPunktacjaCacher(original, uczelnia=None) = wszystkie uczelnie rekordu (removeEntries(None) kasuje caly rekord, rebuildEntries(None) petla po uczelniach) -> istniejacy write-path dziala bez zmian, single-install identyczny - inwentaryzacja bezposrednich callerow rebuildu: grupa A (trwaly write-path, poprawny przez kompat, migracja do przelicz_punkty_dyscyplin opcjonalna/DRY), grupa B (symulacja optymalizacji - federacja, odlozone, nietkniete) - pojecie federacji: optymalizacja maksymalizuje wynik calej federacji uczelni, nie pojedynczej -> ewaluacja_optymalizacja i optymalizuj_publikacje odlozone - adnotacje read-side wg usera: raport_slotow istotne; metryki dwustronne (detail/list read, pin_unpin write); ewaluacja2021 status niejasny (web wylaczony, CLI+const zywe); oswiadczenia/ewaluacja_common/bpp.core read-only status do ustalenia - twardy wymog: removeEntries(None) kasuje komplet (brak sierot) Co-Authored-By: Claude Opus 4.8 --- .../2026-06-02-per-uczelnia-sloty-design.md | 132 ++++++++++++++---- 1 file changed, 106 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md index 004cebcc4..b43572dff 100644 --- a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md +++ b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md @@ -130,20 +130,40 @@ to właśnie sedno partycji, nie efekt nullowy). ## Orkiestracja cachera -- `IPunktacjaCacher(original, uczelnia)` — w pełni per uczelnia: +**Kluczowa decyzja kompatybilności:** konstruktor `IPunktacjaCacher(original, +uczelnia=None)` **zostaje wstecznie kompatybilny** (nie wymusza uczelni), żeby +nie złamać kilkunastu istniejących bezpośrednich callerów `rebuildEntries()` +(patrz inwentaryzacja niżej) — w szczególności odkładanego modułu optymalizacji +federacyjnej. `uczelnia` na poziomie cachera ma DWA tryby: + +- **`uczelnia=U` (scoped)** — operacje tylko na danych jednej uczelni: - `removeEntries()` zawężone: `Cache_Punktacja_Dyscypliny.filter(uczelnia=U)` i `Cache_Punktacja_Autora.filter(jednostka__uczelnia=U)`, - - `rebuildEntries()` odpala zawężony kalkulator; nowe wiersze - `Cache_Punktacja_Dyscypliny` tagowane `uczelnia=U`; `Cache_Punktacja_Autora` - bez zmiany kształtu. -- `przelicz_punkty_dyscyplin(self, uczelnia=None)` (wejście denorm) zyskuje - pętlę i **traci `get_default()`** (zamyka parked TODO): + - `rebuildEntries()` odpala zawężony kalkulator (`ISlot(original, U)`); nowe + wiersze `Cache_Punktacja_Dyscypliny` tagowane `uczelnia=U`; + `Cache_Punktacja_Autora` bez zmiany kształtu (uczelnia z jednostki). +- **`uczelnia=None` (all)** — operuje na WSZYSTKICH uczelniach rekordu: + - `removeEntries(None)` — **kasuje cały rekord po `rekord_id`** (unscoped, jak + dziś), NIE pętlą scoped-delete po `uczelnie_rekordu()` — inaczej pominęłoby + uczelnie, które wypadły (sieroty), + - `rebuildEntries(None)` — enumeruje `uczelnie_rekordu()` i wykonuje ścieżkę + scoped (create) dla każdej. + Dzięki temu istniejące wywołania `removeEntries(); rebuildEntries()` dalej + działają i w single-install dają liczby **identyczne jak dziś** (jedna uczelnia + ⇒ filtr `jednostka__uczelnia=U0` obejmuje wszystkich autorów). + +> Uwaga o warstwach: `None` znaczy co innego w kalkulatorze niż w cacherze. +> W `SlotMixin`/`ISlot` `uczelnia=None` = brak filtra autorów (jeden przebieg po +> wszystkich). W `IPunktacjaCacher` `uczelnia=None` = pętla po uczelniach +> rekordu, gdzie każda iteracja woła `ISlot(original, U)` (scoped). Cacher z +> `None` NIE woła `ISlot(None)`. + +- `przelicz_punkty_dyscyplin(self, uczelnia=None)` (wejście denorm) **traci + `get_default()`** (zamyka parked TODO) i deleguje do cachera: - **skasuj wszystkie** wiersze cache dla rekordu raz (czyści uczelnie, które wypadły — np. po zmianie afiliacji ostatniego autora z danej uczelni), - - wylicz uczelnie rekordu — `uczelnie_rekordu()` = distinct `uczelnia` wśród - afiliujących/przypiętych autorów (spójnie z filtrami `rebuildEntries`), - - dla każdej zbuduj `IPunktacjaCacher(self, U)` i przebuduj (tylko create — - globalny delete już zrobiony), + - wylicz `uczelnie_rekordu()`, dla każdej zbuduj `IPunktacjaCacher(self, U)` + i przebuduj (tylko create — globalny delete już zrobiony), - `uczelnia=` jawne ⇒ policz tylko tę jedną (targetowane przebudowy, testy). - `cached_punkty_dyscyplin` (`@denormalized`) woła bez argumentów ⇒ auto-rebuild produkuje wszystkie uczelnie. @@ -166,6 +186,38 @@ enumeracja nie musi co do joty replikować filtrów `rebuildEntries` (`skupia_pracownikow`, `rodzaj_autora_uwzgledniany_w_kalkulacjach_slotow`). Ważne tylko, by nie **pomijała** uczelni, która ma policzalnych autorów. +## Bezpośredni callerzy rebuildu (write-path) — inwentaryzacja i zakres + +Poza polem `@denormalized` istnieje kilkanaście miejsc konstruujących +`IPunktacjaCacher(x)` i wołających `rebuildEntries()` ręcznie. Dzięki decyzji +kompatybilności (`uczelnia=None` = wszystkie uczelnie rekordu) **żadne z nich się +nie wywala**, a w single-install działają identycznie. Podział wg traktowania: + +**A. Trwały write-path (realna zmiana danych):** wszystkie używają wzorca +`cacher.removeEntries(); cacher.rebuildEntries()`. Dzięki semantyce +`uczelnia=None` (`removeEntries(None)` kasuje cały rekord po `rekord_id` jak +dziś; `rebuildEntries(None)` przelicza wszystkie uczelnie) dostają **poprawne +zachowanie wielouczelniane BEZ zmiany kodu**. Ewentualna migracja do +`x.przelicz_punkty_dyscyplin()` jest **kosmetyczna (DRY)**, nie wymagana: +- `src/bpp/admin/core.py:122` — zapis rekordu w adminie, +- `src/ewaluacja_metryki/views/pin_unpin.py:61,134` — pin/odpięcie, +- `src/ewaluacja_optymalizuj_publikacje/views.py:144,173,210` — zmiana + przypięcia/dyscypliny (sama DECYZJA optymalizacyjna jest federacyjna i + odłożona; rebuild po realnej zmianie danych jest poprawny dzięki kompat). + +Twardy wymóg write-side: `removeEntries(None)` MUSI kasować komplet wierszy +rekordu (zachowanie jak dziś) — inaczej zostałyby sieroty po uczelniach, które +wypadły. + +**B. Symulacja optymalizacji (efemeryczne what-if — moduł federacyjny, ODŁOŻONE):** +nietknięte w tym spec; konstruktor zostaje kompatybilny, więc w single-install +działają. Federacyjna poprawność (maks. wynik w obrębie WSZYSTKICH uczelni +federacji, nie pojedynczej) to osobny, późniejszy temat: +- `src/ewaluacja_optymalizacja/utils.py:179`, +- `src/ewaluacja_optymalizacja/tasks/unpinning/simulation.py:116`, +- `src/ewaluacja_optymalizacja/tasks/discipline_swap/simulation.py:48`, +- `src/ewaluacja_optymalizacja/core/__init__.py:344`. + ## Naprawa widoku SQL (poprawność, nie do odłożenia) Nowa migracja DROP+CREATE `bpp_cache_punktacja_autora_view`, by join trafiał @@ -241,22 +293,50 @@ analogicznie jak `Cache_Punktacja_Autora` po `jednostka__uczelnia`, a (z `bpp_jednostka.uczelnia_id`) i pipeline tabel tymczasowych musi nieść uczelnię. -Zinwentaryzowani konsumenci (write-side ich NIE rusza; do read-side spec): - -- **raport_slotow** — `core.py`, `tables.py`, `filters.py`, `views/autor.py`, - `models/uczelnia.py` (główny konsument widoku + tabel `_Sum`/`_Sum_Group`). -- **ewaluacja_optymalizacja** — `core/data_loader.py`, - `core/optimization_phases.py`, `tasks/unpinning/{analysis,capacity_analysis}.py`, - `utils.py`, `views/{author_works,author_works_exports,exports,helpers, - verification}.py`, `views/evaluation_browser/prefetch.py`. -- **ewaluacja_metryki** — `models.py`, `utils.py`, `views/{detail,list}.py`. -- **ewaluacja2021** — `core/{plecakowy,sumator_base,util}.py`, `models.py`, - `reports.py`. -- **ewaluacja_optymalizuj_publikacje** — `views.py` (też wywołuje rebuild — - upewnić się, że po zmianie przypięcia/dyscypliny przelicza per uczelnia). -- **oswiadczenia** — `views.py`. -- **ewaluacja_common** — `utils.py`. -- **bpp** — `core.py`, `management/commands/zbieraj_sloty.py` (raport per autor). +**Pojęcie federacji (kluczowe dla optymalizacji):** instalacja wielouczelniana +to **federacja** uczelni. Optymalizacja (dobór przypięć/dyscyplin maksymalizujący +wynik ewaluacji) musi maksymalizować wynik w obrębie **całej federacji** +(wszystkich uczelni razem), nie pojedynczej uczelni. To NIE jest prosty filtr +per-uczelnia jak w raportach — to inny problem optymalizacyjny ponad +partycjonowanym cache. Cache per (rekord, uczelnia) jest właściwym podłożem +(suma per uczelnia), ale logika decyzyjna jest federacyjna → **odłożona**. + +Zinwentaryzowani konsumenci (write-side ich NIE rusza), z adnotacjami usera: + +- **raport_slotow** (`core.py`, `tables.py`, `filters.py`, `views/autor.py`, + `models/uczelnia.py`) — **ISTOTNE**, główny konsument widoku + tabel + `_Sum`/`_Sum_Group`. Czysty read-side: filtr po uczelni oglądającego. + Priorytet następnego spec. +- **ewaluacja_optymalizacja** (`core/data_loader.py`, + `core/optimization_phases.py`, `tasks/unpinning/*`, `utils.py`, `views/*`, + `views/evaluation_browser/prefetch.py`) — **FEDERACJA, ODŁOŻONE**. Tu trzeba + liczyć na najwyższy wynik nie w obrębie jednej uczelni, lecz wszystkich + (federacji). Osobny, późniejszy temat (też pisze cache w symulacjach — patrz + sekcja „Bezpośredni callerzy rebuildu", grupa B; konstruktor zostaje + kompatybilny, single-install działa). +- **ewaluacja_optymalizuj_publikacje** (`views.py`) — **FEDERACJA, ODŁOŻONE**. + Optymalizacja jednej publikacji musi uwzględniać cele wszystkich uczelni + (federacji). Część write-path (rebuild po zmianie) ujęta w grupie A wyżej; + logika decyzyjna federacyjna odłożona. +- **ewaluacja_metryki** — DWUstronne: `views/{detail,list}.py` to **tylko + odczyt** (zostawiamy / prosty filtr przy odczycie); `views/pin_unpin.py` + to **write-path** (rebuild po pin/unpin) — ujęty w grupie A. (Korekta: moduł + nie jest „tylko odczytem".) +- **ewaluacja2021** (`core/*`, `models.py`, `reports.py`) — **STATUS NIEJASNY**. + Web URL-e **wyłączone** (zakomentowane w `django_bpp/urls.py`: „Disabled + ewaluacja2021 (contains 3N reports)"), ale: jest w `INSTALLED_APPS`, + `ewaluacja_common/utils.py` importuje z niego `const`, ma żywe management + commands (`raport_3n_genetyczny/plecakowy`, `przelicz_liczbe_n_dla_uczelni`, + `odepnij_dyscypliny`). Decyzja „używać/naprawiać?” do podjęcia w read-side + spec — najpierw potwierdzić, czy raporty 3N są jeszcze w użyciu. +- **oswiadczenia** (`views.py`) — read-only (`Cache_Punktacja_Autora.filter`). + Status priorytetu: do ustalenia (user: „nie wiem”). Filtr po uczelni przy + odczycie wystarczy. +- **ewaluacja_common** (`utils.py`) — read-only + (`Cache_Punktacja_Autora_Query.filter`). Status: do ustalenia. +- **bpp** (`core.py` read-only `Cache_Punktacja_Autora_Query.filter`; + `management/commands/zbieraj_sloty.py` raport per autor) — read-only. Status: + do ustalenia. Tabele tymczasowe pipeline'u raportów (`Cache_Punktacja_Autora_Sum`, `_Sum_Group`, `bpp_temporary_cpaq*`, `bpp_temporary_cpasg*`) — przebudowa pod From 48b86087c31d74ff003df8798243e1bf576b517b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 00:01:19 +0200 Subject: [PATCH 069/247] docs(multi-hosted): finalny design - ISlot(pub, uczelnia) opcjonalny + rozstrzyganie Po iteracji z userem: brak parametru uczelni w IPunktacjaCacher i przelicz_punkty_dyscyplin (callerzy bez zmian). ISlot(pub, uczelnia=None) opcjonalny: None => rozstrzygnij (count==1 / praca z 1 uczelni) albo CannotAdapt przy cross-uczelnia (failuj, nie zgaduj). Bramka ukryte_statusy wraca do ISlot. Cacher: removeEntries pelny + rebuildEntries petla po uczelniach (fast-track count==1) z try/except ISlot. Niweluje delte dla 3 callerow display. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-02-per-uczelnia-sloty-design.md | 191 ++++++++++-------- 1 file changed, 111 insertions(+), 80 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md index b43572dff..55a436c55 100644 --- a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md +++ b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md @@ -104,22 +104,51 @@ uczelnia jest w pełni wyprowadzalna — brak kolumny = zero ryzyka niespójności; koszt to jeden dodatkowy join (przez `bpp_jednostka`) w widoku i zapytaniach grupujących per uczelnia. Decyzja usera: trzymać wyprowadzaną. -## Kalkulator slotów (Approach A — parametryzacja + filtr querysetów) - -- `SlotMixin.__init__(self, original, uczelnia=None)`. Gdy `uczelnia` ustawione, - trzy szwy czytające autorów filtrują po `jednostka__uczelnia`: - - `wszyscy()` → `autorzy_set.filter(jednostka__uczelnia=U).count()` (`m`), - - `autorzy_z_dyscypliny(d)` → dokłada `jednostka__uczelnia=U` (`k` + lista), +## Kalkulator slotów (`ISlot(publikacja, uczelnia=None)` — opcjonalna uczelnia) + +Kalkulacje robi się przez `ISlot(publikacja)`; dla wygody przyjmuje opcjonalną +`uczelnia`. Wybór progu/klasy (`_dopasuj_kalkulator`) jest niezależny od uczelni +— od niej zależy tylko liczenie autorów (dzielnik). Split na uczelnie robi cacher +(patrz Orkiestracja), `ISlot` dostarcza scoped kalkulator. + +- `ISlot(original, uczelnia=None)`: + 1. guards: `Patent`/`PW`/`rok is None` → `CannotAdapt`, + 2. jeśli `uczelnia is None` → **rozstrzygnij albo failuj** (`_rozstrzygnij_uczelnie`): + - `Uczelnia.objects.count() == 1` → ta jedna (fast-track single-install), + - praca ma autorów z **dokładnie 1** uczelni → ta uczelnia, + - praca ma autorów z **>1** uczelni → `CannotAdapt` (niejednoznaczne — + podaj uczelnię jawnie), + - 0 afiliujących autorów → `CannotAdapt`, + 3. bramka `ukryte_statusy("sloty")` na (rozstrzygniętej/jawnej) uczelni → + `CannotAdapt`, + 4. `kalk = _dopasuj_kalkulator(original); kalk.uczelnia = uczelnia; return kalk` + (świeża instancja — brak `copy`/zatrucia `cached_property`). +- **Usuwa** wewnętrzny `get_default()` (zamyka TODO). Bramka `ukryte_statusy` + ZOSTAJE w `ISlot` (teraz zawsze jest jakaś uczelnia: jawna lub rozstrzygnięta). +- `SlotMixin.__init__(self, original, uczelnia=None)`; trzy szwy filtrują po + `jednostka__uczelnia` gdy `self.uczelnia` ustawione (helper `_autorzy_qs()`): + - `wszyscy()` → `_autorzy_qs().count()` (`m`), + - `autorzy_z_dyscypliny(d)` → `_autorzy_qs().filter(afiliuje, przypieta, d…)` + (`k` + lista), - `dyscypliny` (cached_property) → tylko dyscypliny mające autora z `U`. -- `ISlot(original, uczelnia)` przekazuje `uczelnia` do konstruowanego - `SlotKalkulator_*`. Istniejąca bramka `ukryte_statusy("sloty")` zostaje — - teraz naturalnie per uczelnia (rekord może `CannotAdapt` dla jednej uczelni - i policzyć się dla innej). -- `uczelnia=None` ⇒ brak filtra autorów ⇒ zachowanie jak dziś (invariant). +- `ModelZPrzeliczaniemDyscyplin.uczelnie_rekordu()` — distinct `Uczelnia` z + `autorzy_set.filter(afiliuje=True, przypieta=True)` po `jednostka__uczelnia_id`. + Używane przez `_rozstrzygnij_uczelnie` i przez cacher (pętla). Luźny nadzbiór + dozwolony. Ryzyko: pominięty odczyt `autorzy_set` przeciekłby autorów spoza uczelni. Mitygacja: test, w którym pominięcie zmieniłoby liczbę (asercja na dzielnik). +**Delta dla 3 callerów `ISlot(obj)` (display)** — `_rozstrzygnij_uczelnie` ją +NIWELUJE w single-install: `ISlot(obj)` rozstrzyga do tej jednej uczelni i stosuje +jej bramkę `ukryte_statusy` — czyli ZACHOWANIE JAK STARY `get_default`. Delta +istnieje tylko w multi-install dla pracy cross-uczelnia, gdzie `ISlot(obj)` +świadomie `CannotAdapt` (zamiast zgadywać uczelnię). Callery +(`ewaluacja_optymalizuj_publikacje/views.py:240`, +`ewaluacja_dwudyscyplinowcy/core.py:77`, `bpp/admin/helpers/pbn_api/gui.py:25`) +już obsługują `CannotAdapt`; właściwe przekazanie uczelni oglądającego to +read-side. + Invariant `jednostka`: pole `jednostka` na wierszu autorstwa (`BazaModeluOdpowiedzialnosciAutorow.jednostka`, `src/bpp/models/abstract/ authors.py:23`) jest **NOT NULL** — każdy autor na rekordzie ma jednostkę, więc @@ -128,86 +157,84 @@ zawsze ma uczelnię. Nie ma więc przypadku „autor bez uczelni", a filtr przynależności (współautorzy z innej uczelni wypadają z `m` danej uczelni — to właśnie sedno partycji, nie efekt nullowy). -## Orkiestracja cachera - -**Kluczowa decyzja kompatybilności:** konstruktor `IPunktacjaCacher(original, -uczelnia=None)` **zostaje wstecznie kompatybilny** (nie wymusza uczelni), żeby -nie złamać kilkunastu istniejących bezpośrednich callerów `rebuildEntries()` -(patrz inwentaryzacja niżej) — w szczególności odkładanego modułu optymalizacji -federacyjnej. `uczelnia` na poziomie cachera ma DWA tryby: - -- **`uczelnia=U` (scoped)** — operacje tylko na danych jednej uczelni: - - `removeEntries()` zawężone: `Cache_Punktacja_Dyscypliny.filter(uczelnia=U)` - i `Cache_Punktacja_Autora.filter(jednostka__uczelnia=U)`, - - `rebuildEntries()` odpala zawężony kalkulator (`ISlot(original, U)`); nowe - wiersze `Cache_Punktacja_Dyscypliny` tagowane `uczelnia=U`; - `Cache_Punktacja_Autora` bez zmiany kształtu (uczelnia z jednostki). -- **`uczelnia=None` (all)** — operuje na WSZYSTKICH uczelniach rekordu: - - `removeEntries(None)` — **kasuje cały rekord po `rekord_id`** (unscoped, jak - dziś), NIE pętlą scoped-delete po `uczelnie_rekordu()` — inaczej pominęłoby - uczelnie, które wypadły (sieroty), - - `rebuildEntries(None)` — enumeruje `uczelnie_rekordu()` i wykonuje ścieżkę - scoped (create) dla każdej. - Dzięki temu istniejące wywołania `removeEntries(); rebuildEntries()` dalej - działają i w single-install dają liczby **identyczne jak dziś** (jedna uczelnia - ⇒ filtr `jednostka__uczelnia=U0` obejmuje wszystkich autorów). - -> Uwaga o warstwach: `None` znaczy co innego w kalkulatorze niż w cacherze. -> W `SlotMixin`/`ISlot` `uczelnia=None` = brak filtra autorów (jeden przebieg po -> wszystkich). W `IPunktacjaCacher` `uczelnia=None` = pętla po uczelniach -> rekordu, gdzie każda iteracja woła `ISlot(original, U)` (scoped). Cacher z -> `None` NIE woła `ISlot(None)`. - -- `przelicz_punkty_dyscyplin(self, uczelnia=None)` (wejście denorm) **traci - `get_default()`** (zamyka parked TODO) i deleguje do cachera: - - **skasuj wszystkie** wiersze cache dla rekordu raz (czyści uczelnie, które - wypadły — np. po zmianie afiliacji ostatniego autora z danej uczelni), - - wylicz `uczelnie_rekordu()`, dla każdej zbuduj `IPunktacjaCacher(self, U)` - i przebuduj (tylko create — globalny delete już zrobiony), - - `uczelnia=` jawne ⇒ policz tylko tę jedną (targetowane przebudowy, testy). +## Orkiestracja cachera (`IPunktacjaCacher` BEZ parametru uczelni) + +`IPunktacjaCacher(original)` — **bez uczelni** (zweryfikowane: żaden caller nigdy +nie podawał drugiego argumentu). Cała logika wieloucz. jest wewnątrz; callerzy +bez zmian. Cacher zawsze podaje uczelnię JAWNIE do `ISlot`, więc nie wpada w +„ambiguous raise" `_rozstrzygnij_uczelnie`. + +- `removeEntries()` — **zawsze kasuje cały rekord** po `rekord_id` (obie tabele). + Zawsze przebudowujemy wszystkie uczelnie, więc pełny delete sprząta też sieroty + po uczelniach, które wypadły (np. po zmianie afiliacji ostatniego autora). +- `rebuildEntries()` — ustala listę uczelni i liczy per uczelnia, gatując + niedopasowane przez `try/except CannotAdapt` (bramka `ukryte_statusy` jest w + `ISlot`, więc rzuca `CannotAdapt` dla uczelni, która ukrywa status): + + ```python + def rebuildEntries(self): + for uczelnia in self._uczelnie_do_przeliczenia(): + try: + kalk = ISlot(self.original, uczelnia=uczelnia) + except CannotAdapt: + continue # nie liczy się (typ/punkty/rok) lub ukryty status + self._zapisz(kalk, uczelnia) + + def _uczelnie_do_przeliczenia(self): + from bpp.models.uczelnia import Uczelnia + if Uczelnia.objects.count() == 1: # FAST-TRACK single-install + return Uczelnia.objects.all() + return self.original.uczelnie_rekordu() # MULTI + ``` + + `_zapisz(kalk, U)`: `Cache_Punktacja_Dyscypliny` tagowane `uczelnia=U`, + `autorzy_z_dyscypliny` z `kalk` (scoped); `Cache_Punktacja_Autora` z + `original.autorzy_set.filter(jednostka__uczelnia=U)`. W single-install filtr + obejmuje wszystkich autorów (no-op) — liczby identyczne jak dziś. + +> Fast-track jest SYSTEMOWY (`count()==1`), nie per-rekord: nieafiliujący +> współautor z innej uczelni nie wchodzi do `uczelnie_rekordu` (filtr +> afiliuje+przypięta), ale `wszyscy()`/`m` policzyłby go bez filtra → zła liczba. +> Tylko „jedna uczelnia w całym systemie" gwarantuje unscoped==scoped. W +> fast-track i tak liczymy przez `ISlot(original, uczelnia=ta_jedna)` (scoped, +> filtr = no-op), więc kod jest jednolity; oszczędzamy tylko zapytanie +> `uczelnie_rekordu`. + +- `przelicz_punkty_dyscyplin(self)` (wejście denorm) — **traci parametr `uczelnia` + i `get_default()`** (zamyka parked TODO). To dokładnie oryginał minus + `get_default`: `ipc = IPunktacjaCacher(self); ipc.removeEntries(); + if ipc.canAdapt(): ipc.rebuildEntries(); return ipc.serialize()`. Logika + wieloucz. siedzi w `rebuildEntries`. - `cached_punkty_dyscyplin` (`@denormalized`) woła bez argumentów ⇒ auto-rebuild produkuje wszystkie uczelnie. -Zwracana wartość `przelicz_punkty_dyscyplin()`: dziś zwraca `ipc.serialize()` -jednej uczelni (krotka dwóch list), a wynik trafia do pola `@denormalized -cached_punkty_dyscyplin` (TextField, używane jako artefakt denorm/change- -detection, nie parsowane merytorycznie). Po wprowadzeniu pętli musi zwracać -**zagregowany, deterministyczny** payload ze wszystkich uczelni (np. konkatenacja -`serialize()` per uczelnia w stabilnej kolejności po `uczelnia_id`). Źródłem -prawdy są wiersze w tabelach cache; format zwrotki jest elastyczny, byle -deterministyczny (inaczej denorm „migałby" jako wiecznie brudny). Weryfikacja: -brak kodu czytającego `cached_punkty_dyscyplin` jako dane — potwierdzić w planie. - -`uczelnie_rekordu()`: helper na `ModelZPrzeliczaniemDyscyplin` (lub modelu), -zwraca distinct `Uczelnia` z `autorzy_set` afiliujących/przypiętych. Może być -**luźnym nadzbiorem** — uczelnia bez policzalnych autorów po prostu nie wytworzy -żadnych wierszy (zawężony kalkulator zwróci pusto / `CannotAdapt`), więc -enumeracja nie musi co do joty replikować filtrów `rebuildEntries` -(`skupia_pracownikow`, `rodzaj_autora_uwzgledniany_w_kalkulacjach_slotow`). -Ważne tylko, by nie **pomijała** uczelni, która ma policzalnych autorów. +Zwracana wartość `przelicz_punkty_dyscyplin()` / `serialize()`: trafia do pola +`@denormalized cached_punkty_dyscyplin` (TextField, artefakt denorm/change- +detection, NIE parsowany merytorycznie — zweryfikowane). Z wieloma uczelniami +`serialize()` zwraca wiersze wielu uczelni; musi być **deterministyczny** +(inaczej denorm wiecznie brudny). Wymuszamy `order_by` z `uczelnia_id`/`pk` jako +ostatecznym tie-breakerem. ## Bezpośredni callerzy rebuildu (write-path) — inwentaryzacja i zakres Poza polem `@denormalized` istnieje kilkanaście miejsc konstruujących -`IPunktacjaCacher(x)` i wołających `rebuildEntries()` ręcznie. Dzięki decyzji -kompatybilności (`uczelnia=None` = wszystkie uczelnie rekordu) **żadne z nich się -nie wywala**, a w single-install działają identycznie. Podział wg traktowania: - -**A. Trwały write-path (realna zmiana danych):** wszystkie używają wzorca -`cacher.removeEntries(); cacher.rebuildEntries()`. Dzięki semantyce -`uczelnia=None` (`removeEntries(None)` kasuje cały rekord po `rekord_id` jak -dziś; `rebuildEntries(None)` przelicza wszystkie uczelnie) dostają **poprawne -zachowanie wielouczelniane BEZ zmiany kodu**. Ewentualna migracja do -`x.przelicz_punkty_dyscyplin()` jest **kosmetyczna (DRY)**, nie wymagana: +`IPunktacjaCacher(x)` i wołających `removeEntries(); rebuildEntries()` ręcznie. +Ponieważ `IPunktacjaCacher` NIE MA parametru uczelni (cała logika wieloucz. jest +wewnątrz), **żaden z tych call-sites się nie zmienia** — i w single-install działa +identycznie. Podział wg traktowania: + +**A. Trwały write-path (realna zmiana danych):** wzorzec +`cacher.removeEntries(); cacher.rebuildEntries()` automatycznie liczy wszystkie +uczelnie rekordu. **Zero zmian kodu.** Ewentualna migracja do +`x.przelicz_punkty_dyscyplin()` jest kosmetyczna (DRY): - `src/bpp/admin/core.py:122` — zapis rekordu w adminie, - `src/ewaluacja_metryki/views/pin_unpin.py:61,134` — pin/odpięcie, - `src/ewaluacja_optymalizuj_publikacje/views.py:144,173,210` — zmiana - przypięcia/dyscypliny (sama DECYZJA optymalizacyjna jest federacyjna i - odłożona; rebuild po realnej zmianie danych jest poprawny dzięki kompat). + przypięcia/dyscypliny (DECYZJA optymalizacyjna jest federacyjna i odłożona; + rebuild po realnej zmianie danych jest poprawny per uczelnia). -Twardy wymóg write-side: `removeEntries(None)` MUSI kasować komplet wierszy -rekordu (zachowanie jak dziś) — inaczej zostałyby sieroty po uczelniach, które -wypadły. +Twardy wymóg write-side: `removeEntries()` MUSI kasować komplet wierszy rekordu +(jak dziś) — inaczej zostałyby sieroty po uczelniach, które wypadły. **B. Symulacja optymalizacji (efemeryczne what-if — moduł federacyjny, ODŁOŻONE):** nietknięte w tym spec; konstruktor zostaje kompatybilny, więc w single-install @@ -271,6 +298,10 @@ dostaje na razie nowego pola — ekspozycja `uczelnia` w odczytach to read-side. — brak osieroconych wierszy U2. - Determinizm zwrotki `przelicz_punkty_dyscyplin()`: dwa przeliczenia tego samego rekordu dają identyczny string (denorm nie jest wiecznie brudny). +- `_rozstrzygnij_uczelnie` (kontrakt `ISlot(obj)` bez uczelni): + - multi-install + praca z 1 uczelni → `ISlot(obj)` liczy (rozstrzyga tę uczelnię), + - multi-install + praca cross-uczelnia → `ISlot(obj)` rzuca `CannotAdapt`, + - single-install → `ISlot(obj)` rozstrzyga tę jedną (jak stary `get_default`). ## Komendy weryfikacji From 58daf3e1c67aea331f3edee8d81f0e0c79a0cb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 00:04:39 +0200 Subject: [PATCH 070/247] docs(multi-hosted): plan implementacji per-uczelnia sloty (write-side) 9 taskow TDD: schema (uczelnia FK), SlotMixin filtr, uczelnie_rekordu, ISlot opcjonalna uczelnia + rozstrzyganie, IPunktacjaCacher petla per uczelnia + fast-track, przelicz bez parametru, widok SQL join po uczelni, testy graniczne, regresja + backfill doc. API bez parametru uczelni u callerow. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-02-per-uczelnia-sloty.md | 1071 +++++++++++++++++ 1 file changed, 1071 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-per-uczelnia-sloty.md diff --git a/docs/superpowers/plans/2026-06-02-per-uczelnia-sloty.md b/docs/superpowers/plans/2026-06-02-per-uczelnia-sloty.md new file mode 100644 index 000000000..bfd59b523 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-per-uczelnia-sloty.md @@ -0,0 +1,1071 @@ +# Per-uczelnia liczenie slotów/punktacji (write-side) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Liczyć i zapisywać cache slotów/punktacji osobno per uczelnia, każda na subsetcie swoich autorów (autor → jednostka → uczelnia), zachowując identyczne liczby w instalacji jednouczelnianej. + +**Architecture:** `ISlot(publikacja, uczelnia=None)` — opcjonalna uczelnia; gdy `None`, ISlot ją rozstrzyga (jedna w systemie / praca z jednej uczelni) albo rzuca `CannotAdapt` przy pracy cross-uczelnia (bez zgadywania). `SlotMixin` filtruje autorów po `jednostka__uczelnia`, gdy uczelnia ustawiona. `IPunktacjaCacher(original)` (bez parametru uczelni) kasuje cały rekord i odbudowuje per uczelnia: fast-track gdy `Uczelnia.objects.count()==1`, inaczej pętla po `uczelnie_rekordu()` z `ISlot(original, uczelnia=U)`. `Cache_Punktacja_Dyscypliny` zyskuje FK `uczelnia` (klucz partycji); `Cache_Punktacja_Autora` trzyma uczelnię wyprowadzaną z `jednostka`. Widok SQL dostaje join po uczelni. Callerzy `IPunktacjaCacher`/`przelicz_punkty_dyscyplin` — bez zmian. + +**Tech Stack:** Django, PostgreSQL, pytest + model_bakery, django-denorm, testcontainers. + +**Spec:** `docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md` + +--- + +## Uwagi wykonawcze (przeczytaj przed startem) + +- Komenda testowa: `uv run pytest <ścieżka> -q -p no:cacheprovider` (testcontainers + same stawiają PG/Redis; Docker musi działać). +- **NIGDY nie edytuj istniejących migracji.** Nowe pliki. +- Lint: `uv run ruff check ` (NIE `--fix` — popraw ręcznie). Max 88 znaków. +- Po każdym Tasku: testy zielone → commit. +- Invariant single-install: istniejący zestaw `test_sloty/` musi pozostać zielony + (ochrona regresji liczb) — sprawdzane w Tasku 9. +- Kolejność ma znaczenie: `uczelnie_rekordu()` (Task 3) jest wymagane przez + `_rozstrzygnij_uczelnie` w `ISlot` (Task 4). + +--- + +## Task 1: FK `uczelnia` na `Cache_Punktacja_Dyscypliny` + serialize + migracja + +**Files:** +- Modify: `src/bpp/models/cache/punktacja.py` +- Test: `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py` (create) +- Migration: wygenerowana przez `makemigrations` + +- [ ] **Step 1: Napisz failing test** + +Utwórz `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py`: + +```python +import pytest + +from bpp.models.cache import Cache_Punktacja_Dyscypliny + + +@pytest.mark.django_db +def test_cache_punktacja_dyscypliny_ma_uczelnia(uczelnia, dyscyplina1): + obj = Cache_Punktacja_Dyscypliny( + rekord_id=[1, 1], + dyscyplina=dyscyplina1, + pkd=10, + slot=1, + uczelnia=uczelnia, + ) + assert obj.uczelnia_id == uczelnia.pk + assert obj.serialize()[-1] == uczelnia.pk +``` + +- [ ] **Step 2: Uruchom test — ma FAILOWAĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: FAIL (`TypeError: unexpected keyword 'uczelnia'` lub brak uczelnia_id). + +- [ ] **Step 3: Dodaj pole + serialize + indeks** + +W `src/bpp/models/cache/punktacja.py`, klasa `Cache_Punktacja_Dyscypliny`, +dodaj pole `uczelnia`, indeks i zaktualizuj `serialize`: + +```python + uczelnia = ForeignKey("bpp.Uczelnia", models.CASCADE, null=True, blank=True) + + class Meta: + ordering = ("dyscyplina__nazwa",) + indexes = [ + models.Index(fields=["uczelnia", "dyscyplina"]), + ] + + def serialize(self): + return [ + self.rekord_id, + self.dyscyplina_id, + str(self.pkd), + str(self.slot), + self.uczelnia_id, + ] +``` + +(Pole wstaw po `zapisani_autorzy_z_dyscypliny`. Indeks tylko na zwykłych FK — +`rekord_id` (TupleField) ma już własny `db_index=True`.) + +- [ ] **Step 4: Wygeneruj migrację** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations bpp` +Expected: plik `src/bpp/migrations/0424_*.py` z `AddField` + `AddIndex`. +**Zapamiętaj nazwę pliku** (potrzebna w Tasku 7). + +- [ ] **Step 5: Uruchom test — ma PRZEJŚĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff check src/bpp/models/cache/punktacja.py src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git add src/bpp/models/cache/punktacja.py src/bpp/migrations/0424_*.py src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git commit -m "feat(multi-hosted): FK uczelnia na Cache_Punktacja_Dyscypliny + serialize" +``` + +--- + +## Task 2: `SlotMixin` + `Zwarte_Baza` — parametr `uczelnia` i filtr autorów + +**Files:** +- Modify: `src/bpp/models/sloty/common.py` +- Modify: `src/bpp/models/sloty/wydawnictwo_zwarte.py:17` +- Test: `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py` + +- [ ] **Step 1: Dodaj fixture'y dwóch uczelni + test scopingu (failing)** + +Dopisz w `test_per_uczelnia.py` (importy + fixture'y + test): + +```python +from bpp.models import ( + Autor_Dyscyplina, + Charakter_Formalny, + Jednostka, + Uczelnia, + Wydzial, +) + + +@pytest.fixture +def druga_uczelnia(db): + from django.contrib.sites.models import Site + + site, _ = Site.objects.get_or_create( + domain="druga.testserver", defaults={"name": "druga"} + ) + return Uczelnia.objects.create(skrot="DR", nazwa="Druga uczelnia", site=site) + + +@pytest.fixture +def jednostka_drugiej_uczelni(druga_uczelnia, db): + wydzial = Wydzial.objects.create( + uczelnia=druga_uczelnia, skrot="W2", nazwa="Wydział II" + ) + return Jednostka.objects.create( + nazwa="Jedn. Drugiej Ucz.", + skrot="JDU", + wydzial=wydzial, + uczelnia=druga_uczelnia, + ) + + +@pytest.fixture +def zwarte_dwie_uczelnie( + wydawnictwo_zwarte, + autor_jan_nowak, + autor_jan_kowalski, + jednostka, + jednostka_drugiej_uczelni, + dyscyplina1, + rodzaj_autora_n, + charaktery_formalne, + wydawca, + typy_odpowiedzialnosci, + rok, +): + # Obaj autorzy w TEJ SAMEJ dyscyplinie, ale w różnych uczelniach. + Autor_Dyscyplina.objects.create( + autor=autor_jan_nowak, dyscyplina_naukowa=dyscyplina1, rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, dyscyplina_naukowa=dyscyplina1, rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_nowak, jednostka, dyscyplina_naukowa=dyscyplina1 + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_kowalski, jednostka_drugiej_uczelni, dyscyplina_naukowa=dyscyplina1 + ) + wydawnictwo_zwarte.punkty_kbn = 20 + wydawnictwo_zwarte.wydawca = wydawca + wydawnictwo_zwarte.charakter_formalny = Charakter_Formalny.objects.get(skrot="KSP") + wydawnictwo_zwarte.save() + return wydawnictwo_zwarte + + +@pytest.mark.django_db +def test_slotmixin_wszyscy_scoped_po_uczelni(zwarte_dwie_uczelnie, jednostka): + from bpp.models.sloty.wydawnictwo_zwarte import ( + SlotKalkulator_Wydawnictwo_Zwarte_Prog3, + ) + + kalk_all = SlotKalkulator_Wydawnictwo_Zwarte_Prog3( + zwarte_dwie_uczelnie, tryb_kalkulacji=None + ) + kalk_u1 = SlotKalkulator_Wydawnictwo_Zwarte_Prog3( + zwarte_dwie_uczelnie, tryb_kalkulacji=None, uczelnia=jednostka.uczelnia + ) + assert kalk_all.wszyscy() == 2 + assert kalk_u1.wszyscy() == 1 +``` + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py::test_slotmixin_wszyscy_scoped_po_uczelni -q -p no:cacheprovider` +Expected: FAIL (`TypeError: unexpected keyword 'uczelnia'`). + +- [ ] **Step 3: Parametryzuj `SlotMixin`** + +W `src/bpp/models/sloty/common.py` zamień `__init__`, dodaj `_autorzy_qs`, +przepnij `wszyscy`, `autorzy_z_dyscypliny`, `dyscypliny`: + +```python + def __init__(self, original, uczelnia=None): + self.original = original + self.uczelnia = uczelnia + + def _autorzy_qs(self): + qs = self.original.autorzy_set.all() + if self.uczelnia is not None: + qs = qs.filter(jednostka__uczelnia=self.uczelnia) + return qs + + def wszyscy(self): + return self._autorzy_qs().count() + + def autorzy_z_dyscypliny(self, dyscyplina_naukowa, typ_ogolny=None): + ret = [] + + elem_kw = {} + if typ_ogolny is not None: + elem_kw = {"typ_odpowiedzialnosci__typ_ogolny": typ_ogolny} + + for elem in self._autorzy_qs().filter( + afiliuje=True, + przypieta=True, + dyscyplina_naukowa=dyscyplina_naukowa, + **elem_kw, + ): + if not elem.rodzaj_autora_uwzgledniany_w_kalkulacjach_slotow(): + continue + ret.append(elem) + return ret +``` + +I `dyscypliny`: + +```python + @cached_property + def dyscypliny(self): + if not self.original.pk: + return set() + + ret = set() + for wa in self._autorzy_qs(): + d = wa.okresl_dyscypline() + if d is None: + continue + ret.add(d) + return ret +``` + +- [ ] **Step 4: Parametryzuj `Zwarte_Baza`** + +W `src/bpp/models/sloty/wydawnictwo_zwarte.py` zamień `__init__`: + +```python + def __init__( + self, original, tryb_kalkulacji, wiele_hst=False, poziom_wydawcy=None, + uczelnia=None, + ): + self.original = original + self.tryb_kalkulacji = tryb_kalkulacji + self.wiele_hst = wiele_hst + self.poziom_wydawcy = poziom_wydawcy + self.uczelnia = uczelnia +``` + +- [ ] **Step 5: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py::test_slotmixin_wszyscy_scoped_po_uczelni -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff check src/bpp/models/sloty/common.py src/bpp/models/sloty/wydawnictwo_zwarte.py +git add src/bpp/models/sloty/common.py src/bpp/models/sloty/wydawnictwo_zwarte.py src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git commit -m "feat(multi-hosted): SlotMixin/Zwarte_Baza filtruja autorow po uczelni" +``` + +--- + +## Task 3: `uczelnie_rekordu()` na modelu + +**Files:** +- Modify: `src/bpp/models/abstract/disciplines.py` +- Test: `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py` + +- [ ] **Step 1: Napisz failing test** + +```python +@pytest.mark.django_db +def test_uczelnie_rekordu_zwraca_obie( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia +): + assert set(zwarte_dwie_uczelnie.uczelnie_rekordu()) == { + jednostka.uczelnia, + druga_uczelnia, + } +``` + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py::test_uczelnie_rekordu_zwraca_obie -q -p no:cacheprovider` +Expected: FAIL (`AttributeError: 'uczelnie_rekordu'`). + +- [ ] **Step 3: Dodaj metodę** + +W `src/bpp/models/abstract/disciplines.py`, klasa `ModelZPrzeliczaniemDyscyplin`, +dodaj (np. po `wszystkie_dyscypliny_rekordu`): + +```python + def uczelnie_rekordu(self): + """Distinct uczelnie wśród afiliujących, przypiętych autorów rekordu + (autor → jednostka → uczelnia). Luźny nadzbiór wystarcza.""" + from bpp.models.uczelnia import Uczelnia + + if not self.pk: + return Uczelnia.objects.none() + + uczelnia_ids = ( + self.autorzy_set.filter(afiliuje=True, przypieta=True) + .values_list("jednostka__uczelnia_id", flat=True) + .distinct() + ) + return Uczelnia.objects.filter(pk__in=list(uczelnia_ids)) +``` + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py::test_uczelnie_rekordu_zwraca_obie -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check src/bpp/models/abstract/disciplines.py +git add src/bpp/models/abstract/disciplines.py src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git commit -m "feat(multi-hosted): uczelnie_rekordu() na ModelZPrzeliczaniemDyscyplin" +``` + +--- + +## Task 4: `ISlot(original, uczelnia=None)` — opcjonalna uczelnia, rozstrzyganie, helper + +**Files:** +- Modify: `src/bpp/models/sloty/core.py:26-293` +- Test: `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py` + +- [ ] **Step 1: Napisz failing testy (scoped + rozstrzyganie + ambiguous)** + +```python +@pytest.mark.django_db +def test_islot_jawna_uczelnia_scoped(zwarte_dwie_uczelnie, jednostka): + from bpp.models.sloty.core import ISlot + + kalk = ISlot(zwarte_dwie_uczelnie, uczelnia=jednostka.uczelnia) + assert kalk.uczelnia == jednostka.uczelnia + assert kalk.wszyscy() == 1 + + +@pytest.mark.django_db +def test_islot_none_cross_uczelnia_failuje(zwarte_dwie_uczelnie, druga_uczelnia): + # 2 uczelnie w systemie + praca cross-uczelnia => niejednoznaczne => CannotAdapt + from bpp.models.sloty.core import CannotAdapt, ISlot + + with pytest.raises(CannotAdapt): + ISlot(zwarte_dwie_uczelnie) + + +@pytest.mark.django_db +def test_islot_none_jedna_uczelnia_systemu_rozstrzyga( + zwarte_z_dyscyplinami, uczelnia +): + # tylko jedna uczelnia w systemie => ISlot(obj) rozstrzyga ją + from bpp.models.sloty.core import ISlot + + kalk = ISlot(zwarte_z_dyscyplinami) + assert kalk.uczelnia == uczelnia +``` + +(`zwarte_z_dyscyplinami` — istniejący fixture z `conftest.py`, jedna uczelnia.) + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py -k islot -q -p no:cacheprovider` +Expected: FAIL (kalk nie ma `.uczelnia` / brak rozstrzygania / brak wyjątku). + +- [ ] **Step 3: Refaktor `ISlot` + helpery** + +W `src/bpp/models/sloty/core.py`. Najpierw dodaj import na górze (jeśli brak): +`from .exceptions import CannotAdapt` jest już; dodasz użycie `Uczelnia` lokalnie. + +Zamień funkcję `ISlot` (linie 26-293) na `ISlot` + `_rozstrzygnij_uczelnie` + +`_dopasuj_kalkulator`: + +```python +def _rozstrzygnij_uczelnie(original): # noqa + """ISlot bez jawnej uczelni: zwróć jednoznaczną uczelnię albo CannotAdapt.""" + from bpp.models.uczelnia import Uczelnia + + if Uczelnia.objects.count() == 1: + return Uczelnia.objects.get() + + uczelnie = list(original.uczelnie_rekordu()) + if len(uczelnie) == 1: + return uczelnie[0] + if len(uczelnie) == 0: + raise CannotAdapt("Rekord nie ma afiliujących autorów z uczelnią.") + raise CannotAdapt( + "Rekord ma autorów z wielu uczelni — podaj uczelnię jawnie " + "(ISlot(rekord, uczelnia=...)); bez niej wynik jest niejednoznaczny." + ) + + +def ISlot(original, uczelnia=None): # noqa + if isinstance(original, Patent): + raise CannotAdapt("Sloty dla patentów nie są liczone") + + if hasattr(original, "typ_kbn") and original.typ_kbn.skrot == "PW": + raise CannotAdapt("Sloty dla prac wieloośrodkowych nie są liczone.") + + if hasattr(original, "rok") and original.rok is None: + raise CannotAdapt("Rekord nie ma ustawionego roku — sloty nie są liczone.") + + if uczelnia is None: + uczelnia = _rozstrzygnij_uczelnie(original) + + if ( + hasattr(original, "status_korekty_id") + and original.status_korekty_id in uczelnia.ukryte_statusy("sloty") + ): + raise CannotAdapt( + "Sloty nie będą liczone, zgodnie z ustawieniami obiektu Uczelnia dla ukrywanych " + "statusów korekt. " + ) + + kalkulator = _dopasuj_kalkulator(original) + kalkulator.uczelnia = uczelnia + return kalkulator +``` + +Następnie utwórz `_dopasuj_kalkulator(original)` zawierający DOKŁADNIE dotychczasową +logikę selekcji — blok od `if isinstance(original, Wydawnictwo_Ciagle):` +(obecna linia 54) do końcowych `raise CannotAdapt(...)` (linie 288-293), bez zmian +wewnętrznych (konstrukcje kalkulatorów zostają `(original)` / `(original, tryb, ...)`). +Usuwasz przy okazji stary blok `if uczelnia is None: uczelnia = Uczelnia.objects.get_default()` +(linie 33-39) — przeniesione/zastąpione przez `_rozstrzygnij_uczelnie`. + +```python +def _dopasuj_kalkulator(original): # noqa + if isinstance(original, Wydawnictwo_Ciagle): + ... # 1:1 przeniesione linie 54-... (cala logika ciagle) + elif isinstance(original, Wydawnictwo_Zwarte): + ... # 1:1 logika zwarte + if hasattr(original, "rok") and hasattr(original, "punkty_kbn"): + raise CannotAdapt( + f"Nie umiem policzyc dla {original} rok {original.rok} punkty_kbn {original.punkty_kbn}" + ) + raise CannotAdapt(f"Nie umiem policzyć dla obiektu: {original!r}") +``` + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py -k islot -q -p no:cacheprovider` +Expected: PASS (3 testy). + +- [ ] **Step 5: Sanity — istniejące testy slotów dalej zielone** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_sloty.py src/bpp/tests/test_models/test_sloty/test_sloty_wydawnictwo_zwarte.py src/bpp/tests/test_models/test_sloty/test_sloty_wydawnictwo_ciagle.py -q -p no:cacheprovider` +Expected: PASS (refaktor zachowawczy; single-uczelnia rozstrzyga jak get_default). + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff check src/bpp/models/sloty/core.py +git add src/bpp/models/sloty/core.py src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git commit -m "refactor(multi-hosted): ISlot opcjonalna uczelnia + rozstrzyganie + helper" +``` + +--- + +## Task 5: `IPunktacjaCacher` — bez uczelni, pętla per uczelnia, tag, det. serialize + +**Files:** +- Modify: `src/bpp/models/sloty/core.py` (klasa `IPunktacjaCacher`) +- Test: `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py` + +- [ ] **Step 1: Napisz failing test (rebuild per uczelnia)** + +```python +@pytest.mark.django_db +def test_rebuild_tworzy_wiersze_per_uczelnia( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia +): + from bpp.models.cache import ( + Cache_Punktacja_Autora, + Cache_Punktacja_Dyscypliny, + ) + from bpp.models.sloty.core import IPunktacjaCacher + + cacher = IPunktacjaCacher(zwarte_dwie_uczelnie) + cacher.removeEntries() + cacher.rebuildEntries() + + cpd = Cache_Punktacja_Dyscypliny.objects.filter( + rekord_id=[cacher.ctype, zwarte_dwie_uczelnie.pk] + ) + assert cpd.count() == 2 + assert set(cpd.values_list("uczelnia_id", flat=True)) == { + jednostka.uczelnia_id, + druga_uczelnia.pk, + } + for row in cpd: + assert len(row.autorzy_z_dyscypliny) == 1 + + cpa = Cache_Punktacja_Autora.objects.filter( + rekord_id=[cacher.ctype, zwarte_dwie_uczelnie.pk] + ) + assert cpa.count() == 2 + assert {c.jednostka.uczelnia_id for c in cpa} == { + jednostka.uczelnia_id, + druga_uczelnia.pk, + } +``` + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py::test_rebuild_tworzy_wiersze_per_uczelnia -q -p no:cacheprovider` +Expected: FAIL (1 wiersz dyscypliny / `uczelnia_id` None / `cross-uczelnia` CannotAdapt w canAdapt). + +- [ ] **Step 3: Przepisz `IPunktacjaCacher`** + +W `src/bpp/models/sloty/core.py`, klasa `IPunktacjaCacher`. `__init__` traci +`uczelnia`; `canAdapt` opiera się o `_dopasuj_kalkulator` (uczelnia-niezależne, +nie wpada w rozstrzyganie); `removeEntries` kasuje cały rekord; nowe +`rebuildEntries` + `_uczelnie_do_przeliczenia` + `_zapisz`; `serialize` +deterministyczny. + +```python + def __init__(self, original): + self.original = original + + def canAdapt(self): + try: + _dopasuj_kalkulator(self.original) + return True + except CannotAdapt: + return False +``` + +`removeEntries` (kasuje całość po rekord_id — sprząta sieroty): + +```python + @transaction.atomic + def removeEntries(self): + self.cache_punktacja_dyscypliny.delete() + self.cache_punktacja_autora.delete() +``` + +`serialize` (deterministyczny — uczelnia + pk jako tie-breaker): + +```python + def serialize(self): + ret1 = [ + elem.serialize() + for elem in self.cache_punktacja_autora.order_by( + "jednostka__uczelnia_id", "autor__nazwisko", "dyscyplina__nazwa", "pk" + ) + ] + ret2 = [ + elem.serialize() + for elem in self.cache_punktacja_dyscypliny.order_by( + "uczelnia_id", "dyscyplina__nazwa", "pk" + ) + ] + return ret1, ret2 +``` + +`rebuildEntries` + helpery: + +```python + def _uczelnie_do_przeliczenia(self): + from bpp.models.uczelnia import Uczelnia + + if Uczelnia.objects.count() == 1: # fast-track single-install + return Uczelnia.objects.all() + return self.original.uczelnie_rekordu() + + @transaction.atomic + def rebuildEntries(self): + for uczelnia in self._uczelnie_do_przeliczenia(): + try: + kalk = ISlot(self.original, uczelnia=uczelnia) + except CannotAdapt: + continue # nie liczy się (typ/punkty/rok) lub ukryty status + self._zapisz(kalk, uczelnia) + + def _zapisz(self, kalk, uczelnia): + pk = self.get_pk() + + for dyscyplina in kalk.dyscypliny: + azd = kalk.autorzy_z_dyscypliny(dyscyplina) + if not azd: + continue + Cache_Punktacja_Dyscypliny.objects.create( + rekord_id=[pk[0], pk[1]], + dyscyplina=dyscyplina, + uczelnia=uczelnia, + pkd=kalk.punkty_pkd(dyscyplina), + slot=kalk.slot_dla_dyscypliny(dyscyplina), + autorzy_z_dyscypliny=[a.pk for a in azd], + zapisani_autorzy_z_dyscypliny=[a.zapisany_jako for a in azd], + ) + + if not self.original.pk: + return + + for wa in self.original.autorzy_set.filter(jednostka__uczelnia=uczelnia): + if ( + not wa.afiliuje + or not wa.jednostka.skupia_pracownikow + or not wa.przypieta + or not wa.rodzaj_autora_uwzgledniany_w_kalkulacjach_slotow() + ): + continue + + dyscyplina = wa.okresl_dyscypline() + if dyscyplina is None: + continue + + pkdaut = kalk.pkd_dla_autora(wa) + if pkdaut is None: + continue + Cache_Punktacja_Autora.objects.create( + rekord_id=[pk[0], pk[1]], + autor_id=wa.autor_id, + jednostka_id=wa.jednostka_id, + dyscyplina_id=dyscyplina.pk, + pkdaut=pkdaut, + slot=kalk.slot_dla_autora_z_dyscypliny(dyscyplina), + ) +``` + +USUŃ stare `rebuildEntries` i (jeśli był) atrybut `self.slot`/`self.uczelnia`. +Zostaw właściwości `ctype`, `cache_punktacja_autora`, `cache_punktacja_dyscypliny`, +`get_pk`. + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py::test_rebuild_tworzy_wiersze_per_uczelnia -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check src/bpp/models/sloty/core.py +git add src/bpp/models/sloty/core.py src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git commit -m "feat(multi-hosted): IPunktacjaCacher petla per uczelnia + tag + det. serialize" +``` + +--- + +## Task 6: `przelicz_punkty_dyscyplin` — bez parametru, bez `get_default` + +**Files:** +- Modify: `src/bpp/models/abstract/disciplines.py:12-27` +- Test: `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py` + +- [ ] **Step 1: Napisz failing testy (per uczelnia, dzielnik, determinizm)** + +```python +@pytest.mark.django_db +def test_przelicz_per_uczelnia_dzielnik_k1(zwarte_dwie_uczelnie): + from bpp.models.cache import Cache_Punktacja_Autora + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + + cpa_nowak = Cache_Punktacja_Autora.objects.get(autor__nazwisko="Nowak") + cpa_kowalski = Cache_Punktacja_Autora.objects.get(autor__nazwisko="Kowalski") + # k=1 w obrębie każdej uczelni => każdy ma pełny slot, suma = 2.0 + assert cpa_nowak.slot == cpa_kowalski.slot + assert cpa_nowak.slot + cpa_kowalski.slot == 2 + + +@pytest.mark.django_db +def test_przelicz_zwrotka_deterministyczna(zwarte_dwie_uczelnie): + a = zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + b = zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + assert str(a) == str(b) +``` + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py -k przelicz -q -p no:cacheprovider` +Expected: FAIL (stary `przelicz` ma get_default/uczelnia=None single-pass). + +- [ ] **Step 3: Przepisz `przelicz_punkty_dyscyplin`** + +W `src/bpp/models/abstract/disciplines.py` zamień metodę (usuń blok +`Uczelnia.objects.get_default()`): + +```python + def przelicz_punkty_dyscyplin(self): + from bpp.models.sloty.core import IPunktacjaCacher + + ipc = IPunktacjaCacher(self) + ipc.removeEntries() + if ipc.canAdapt(): + ipc.rebuildEntries() + return ipc.serialize() +``` + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py -k przelicz -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check src/bpp/models/abstract/disciplines.py +git add src/bpp/models/abstract/disciplines.py src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git commit -m "feat(multi-hosted): przelicz_punkty_dyscyplin bez parametru, bez get_default" +``` + +--- + +## Task 7: Migracja widoku SQL — join po uczelni + +**Files:** +- Create: `src/bpp/migrations/0425_per_uczelnia_cache_view.py` +- Test: `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py` + +- [ ] **Step 1: Napisz failing test (brak kartezjańskiej duplikacji)** + +```python +@pytest.mark.django_db +def test_widok_nie_duplikuje_miedzy_uczelniami(zwarte_dwie_uczelnie): + from django.contrib.contenttypes.models import ContentType + + from bpp.models.cache import Cache_Punktacja_Autora_Query_View + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + ctype = ContentType.objects.get_for_model(zwarte_dwie_uczelnie).pk + + rows = Cache_Punktacja_Autora_Query_View.objects.filter( + rekord_id=[ctype, zwarte_dwie_uczelnie.pk] + ) + # 2 autorow x 2 dyscyplina-agregaty bez joina po uczelni = 4 (kartezjan). + # Z naprawą: 2. + assert rows.count() == 2 +``` + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py::test_widok_nie_duplikuje_miedzy_uczelniami -q -p no:cacheprovider` +Expected: FAIL (`assert 4 == 2`). + +- [ ] **Step 3: Utwórz migrację widoku** + +Utwórz `src/bpp/migrations/0425_per_uczelnia_cache_view.py`. **Ustaw `dependencies` +na faktyczną nazwę migracji z Tasku 1** (zamień `0424_...`): + +```python +from django.db import migrations + +DROP = "DROP VIEW IF EXISTS bpp_cache_punktacja_autora_view;" + +CREATE_NEW = """ +CREATE VIEW bpp_cache_punktacja_autora_view AS +SELECT a.id, + a.rekord_id, + a.pkdaut, + a.slot, + a.autor_id, + a.dyscyplina_id, + a.jednostka_id, + d.autorzy_z_dyscypliny, + d.zapisani_autorzy_z_dyscypliny +FROM bpp_cache_punktacja_autora a +JOIN bpp_jednostka j ON j.id = a.jednostka_id +JOIN bpp_cache_punktacja_dyscypliny d + ON a.rekord_id = d.rekord_id + AND a.dyscyplina_id = d.dyscyplina_id + AND d.uczelnia_id = j.uczelnia_id; +""" + +CREATE_OLD = """ +CREATE VIEW bpp_cache_punktacja_autora_view AS +SELECT bpp_cache_punktacja_autora.id, + bpp_cache_punktacja_autora.rekord_id, + bpp_cache_punktacja_autora.pkdaut, + bpp_cache_punktacja_autora.slot, + bpp_cache_punktacja_autora.autor_id, + bpp_cache_punktacja_autora.dyscyplina_id, + bpp_cache_punktacja_autora.jednostka_id, + bpp_cache_punktacja_dyscypliny.autorzy_z_dyscypliny, + bpp_cache_punktacja_dyscypliny.zapisani_autorzy_z_dyscypliny +FROM bpp_cache_punktacja_autora, + bpp_cache_punktacja_dyscypliny +WHERE bpp_cache_punktacja_autora.rekord_id = bpp_cache_punktacja_dyscypliny.rekord_id + AND bpp_cache_punktacja_autora.dyscyplina_id = bpp_cache_punktacja_dyscypliny.dyscyplina_id; +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0424_..."), # <- nazwa wygenerowana w Tasku 1 + ] + + operations = [ + migrations.RunSQL(sql=DROP + CREATE_NEW, reverse_sql=DROP + CREATE_OLD), + ] +``` + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py::test_widok_nie_duplikuje_miedzy_uczelniami -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 5: Brak dryfu migracji** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run` +Expected: "No changes detected". + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff check src/bpp/migrations/0425_per_uczelnia_cache_view.py +git add src/bpp/migrations/0425_per_uczelnia_cache_view.py src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git commit -m "feat(multi-hosted): widok cache_punktacja_autora joinuje po uczelni" +``` + +--- + +## Task 8: Testy graniczne — ukryte_statusy, wypadnięcie uczelni, invariant + +**Files:** +- Test: `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py` + +- [ ] **Step 1: Test — `ukryte_statusy` gatuje jedną uczelnię** + +Sprawdź w `src/bpp/models/uczelnia.py` API `ukryte_statusy("sloty")` i ustaw +ukryty status dla `druga_uczelnia`. Asercja: po przeliczeniu są wiersze dla +`jednostka.uczelnia`, NIE ma dla `druga_uczelnia`. + +```python +@pytest.mark.django_db +def test_ukryte_statusy_gatuje_jedna_uczelnie( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia +): + from bpp.models.cache import Cache_Punktacja_Dyscypliny + from bpp.models.sloty.core import IPunktacjaCacher + + # Ukryj status korekty pracy dla slotów w drugiej uczelni. + # API ustawiania sprawdź w uczelnia.py (np. pole/relacja statusów); poniżej + # zastąp realnym mechanizmem: + status = zwarte_dwie_uczelnie.status_korekty + druga_uczelnia.ukryj_status_dla_slotow(status) # <- realne API z uczelnia.py + + ctype = IPunktacjaCacher(zwarte_dwie_uczelnie).ctype + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + + uczelnie = set( + Cache_Punktacja_Dyscypliny.objects.filter( + rekord_id=[ctype, zwarte_dwie_uczelnie.pk] + ).values_list("uczelnia_id", flat=True) + ) + assert jednostka.uczelnia_id in uczelnie + assert druga_uczelnia.pk not in uczelnie +``` + +Jeśli `ukryte_statusy` nie ma wygodnego settera — odczytaj implementację i ustaw +stan ręcznie. NIE pomijaj testu (kluczowy wymóg spec). + +- [ ] **Step 2: Test — wypadnięcie uczelni (brak sierot)** + +```python +@pytest.mark.django_db +def test_wypadniecie_uczelni_kasuje_sieroty( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia +): + from bpp.models.cache import Cache_Punktacja_Dyscypliny + from bpp.models.sloty.core import IPunktacjaCacher + + ctype = IPunktacjaCacher(zwarte_dwie_uczelnie).ctype + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + assert ( + Cache_Punktacja_Dyscypliny.objects.filter( + rekord_id=[ctype, zwarte_dwie_uczelnie.pk] + ).count() + == 2 + ) + + wa = zwarte_dwie_uczelnie.autorzy_set.get(autor__nazwisko="Kowalski") + wa.jednostka = jednostka + wa.save() + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + uczelnie = set( + Cache_Punktacja_Dyscypliny.objects.filter( + rekord_id=[ctype, zwarte_dwie_uczelnie.pk] + ).values_list("uczelnia_id", flat=True) + ) + assert uczelnie == {jednostka.uczelnia_id} # brak sieroty druga_uczelnia +``` + +- [ ] **Step 3: Test — invariant single-install (k=2 w jednej uczelni)** + +```python +@pytest.mark.django_db +def test_invariant_jedna_uczelnia_k2( + wydawnictwo_zwarte, + autor_jan_nowak, + autor_jan_kowalski, + jednostka, + dyscyplina1, + rodzaj_autora_n, + charaktery_formalne, + wydawca, + typy_odpowiedzialnosci, + rok, +): + from bpp.models.cache import Cache_Punktacja_Autora + + Autor_Dyscyplina.objects.create( + autor=autor_jan_nowak, dyscyplina_naukowa=dyscyplina1, rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, dyscyplina_naukowa=dyscyplina1, rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_nowak, jednostka, dyscyplina_naukowa=dyscyplina1 + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_kowalski, jednostka, dyscyplina_naukowa=dyscyplina1 + ) + wydawnictwo_zwarte.punkty_kbn = 20 + wydawnictwo_zwarte.wydawca = wydawca + wydawnictwo_zwarte.charakter_formalny = Charakter_Formalny.objects.get(skrot="KSP") + wydawnictwo_zwarte.save() + + wydawnictwo_zwarte.przelicz_punkty_dyscyplin() + slots = list( + Cache_Punktacja_Autora.objects.filter( + autor__in=[autor_jan_nowak, autor_jan_kowalski] + ).values_list("slot", flat=True) + ) + assert len(slots) == 2 + assert sum(slots) == 1 # k=2 w jednej uczelni: po pół slotu, suma 1.0 +``` + +- [ ] **Step 4: Uruchom wszystkie nowe testy graniczne** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git add src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git commit -m "test(multi-hosted): ukryte_statusy/wypadniecie uczelni/invariant single-install" +``` + +--- + +## Task 9: Regresja całościowa + dokumentacja backfillu + +**Files:** +- Modify: `docs/deweloper/audyt-multihosted-pbn.md` + +- [ ] **Step 1: Pełna regresja slotów i cache (invariant liczb)** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/ src/bpp/tests/test_cache/ -q -p no:cacheprovider` +Expected: PASS. Jeśli istniejący test asertuje stary `Cache_Punktacja_Dyscypliny.serialize()` +(4 elementy) — zaktualizuj o `uczelnia_id` (oczekiwana zmiana kontraktu). + +- [ ] **Step 2: Regresja konsumentów write-path (admin/pin-unpin/optymalizuj)** + +Run: `uv run pytest src/ewaluacja_metryki/ src/ewaluacja_optymalizuj_publikacje/ -q -p no:cacheprovider` +Expected: PASS (single-install: `IPunktacjaCacher(x).removeEntries(); +rebuildEntries()` liczy tę jedną uczelnię, identycznie). + +- [ ] **Step 3: Regresja modułu optymalizacji (symulacje konstruują cacher)** + +Run: `uv run pytest src/ewaluacja_optymalizacja/ -q -p no:cacheprovider` +Expected: PASS (callerzy `IPunktacjaCacher(x)` bez zmian; single-install OK). + +- [ ] **Step 4: System check + brak dryfu migracji** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run` +Expected: "No changes detected". + +- [ ] **Step 5: Udokumentuj backfill (deploy)** + +W `docs/deweloper/audyt-multihosted-pbn.md` dopisz sekcję: + +```markdown +## Backfill per-uczelnia cache (write-side) + +Migracje dodają nullable `uczelnia` na Cache_Punktacja_Dyscypliny i naprawiają +widok. Po deployu należy **przeliczyć cache** pełnym denorm rebuildem — nowy kod +zapisze wiersze per uczelnia. Single-install: liczby identyczne. + +Wyzwolenie: rebuild pól denorm (`cached_punkty_dyscyplin`) — komendą denorm +rebuild używaną w projekcie lub `denorms.flush()` w shellu. Konkretną komendę +potwierdzić w środowisku docelowym. + +Opcjonalnie później: migracja zacieśniająca `uczelnia` do `null=False`. +``` + +- [ ] **Step 6: Commit** + +```bash +git add docs/deweloper/audyt-multihosted-pbn.md +git commit -m "docs(multi-hosted): backfill per-uczelnia cache + regresja write-side" +``` + +--- + +## Self-review (autor planu) + +**Spec coverage:** +- Schemat (uczelnia FK + index + serialize) → Task 1 ✓ +- SlotMixin/Zwarte filtr autorów → Task 2 ✓ +- uczelnie_rekordu → Task 3 ✓ +- ISlot opcjonalna uczelnia + _rozstrzygnij_uczelnie + gate + bez get_default → Task 4 ✓ +- IPunktacjaCacher bez uczelni + pętla + fast-track + tag + det. serialize → Task 5 ✓ +- przelicz_punkty_dyscyplin bez parametru → Task 6 ✓ +- Widok SQL join po uczelni → Task 7 ✓ +- Testy: 2-uczelnie/dzielnik, rozstrzyganie/ambiguous, ukryte_statusy, widok, wypadnięcie, invariant, determinizm → Task 2/4/5/6/7/8 ✓ +- Migracja + backfill → Task 1 (nullable) + Task 9 (doc) ✓ +- Callerzy bez zmian (brak parametru uczelni) → potwierdzone; regresja Task 9 ✓ + +**Type/nazwy — spójność:** +- `ISlot(original, uczelnia=None)`, `_rozstrzygnij_uczelnie`, `_dopasuj_kalkulator`, + `IPunktacjaCacher(original)`, `_uczelnie_do_przeliczenia`, `_zapisz(kalk, uczelnia)`, + `uczelnie_rekordu()`, `Cache_Punktacja_Dyscypliny.uczelnia` — używane spójnie. + +**Znane luki / uwagi wykonawcy:** +- Task 7 zależność migracji: wstaw realną nazwę `0424_*` z Tasku 1. +- Task 4 Step 3: `_dopasuj_kalkulator` to przeniesienie 1:1 linii 54-293 — NIE + zmieniaj treści, tylko wytnij do osobnej funkcji. +- Task 8 Step 1: API `ukryte_statusy` sprawdź w `uczelnia.py`; placeholder + `ukryj_status_dla_slotow` zastąp realnym. Testu NIE pomijać. +- Task 9 Step 1: część istniejących testów może asertować stary `serialize()` — + aktualizacja o `uczelnia_id` oczekiwana. +- `canAdapt()` przez `_dopasuj_kalkulator` jest uczelnia-niezależne (nie wpada w + rozstrzyganie); dla rekordu PW może zwrócić True, a `rebuildEntries` i tak nic + nie zapisze (ISlot rzuci CannotAdapt w pętli) — zachowanie poprawne. From 9848dca8fd9ac2ce26207f96c527e1025faf073e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 00:13:54 +0200 Subject: [PATCH 071/247] feat(multi-hosted): FK uczelnia na Cache_Punktacja_Dyscypliny + serialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dodaje pole uczelnia (FK nullable) do Cache_Punktacja_Dyscypliny jako klucz partycjonowania per-uczelnia, indeks złożony (uczelnia, dyscyplina) oraz uczelnia_id jako ostatni element serialize(). Migracja 0424. Co-Authored-By: Claude Opus 4.8 --- ..._punktacja_dyscypliny_uczelnia_and_more.py | 23 +++++++++++++++++++ src/bpp/models/cache/punktacja.py | 13 ++++++++++- .../test_sloty/test_per_uczelnia.py | 16 +++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/bpp/migrations/0424_cache_punktacja_dyscypliny_uczelnia_and_more.py create mode 100644 src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py diff --git a/src/bpp/migrations/0424_cache_punktacja_dyscypliny_uczelnia_and_more.py b/src/bpp/migrations/0424_cache_punktacja_dyscypliny_uczelnia_and_more.py new file mode 100644 index 000000000..b2548cdb4 --- /dev/null +++ b/src/bpp/migrations/0424_cache_punktacja_dyscypliny_uczelnia_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.14 on 2026-06-02 22:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0423_merge_20260602_1430'), + ] + + operations = [ + migrations.AddField( + model_name='cache_punktacja_dyscypliny', + name='uczelnia', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='bpp.uczelnia'), + ), + migrations.AddIndex( + model_name='cache_punktacja_dyscypliny', + index=models.Index(fields=['uczelnia', 'dyscyplina'], name='bpp_cache_p_uczelni_65b805_idx'), + ), + ] diff --git a/src/bpp/models/cache/punktacja.py b/src/bpp/models/cache/punktacja.py index 883a49972..2ab235071 100644 --- a/src/bpp/models/cache/punktacja.py +++ b/src/bpp/models/cache/punktacja.py @@ -29,11 +29,22 @@ class Cache_Punktacja_Dyscypliny(models.Model): models.TextField(), blank=True, null=True ) + uczelnia = ForeignKey("bpp.Uczelnia", models.CASCADE, null=True, blank=True) + class Meta: ordering = ("dyscyplina__nazwa",) + indexes = [ + models.Index(fields=["uczelnia", "dyscyplina"]), + ] def serialize(self): - return [self.rekord_id, self.dyscyplina_id, str(self.pkd), str(self.slot)] + return [ + self.rekord_id, + self.dyscyplina_id, + str(self.pkd), + str(self.slot), + self.uczelnia_id, + ] class Cache_Punktacja_Autora_Base(models.Model): diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py new file mode 100644 index 000000000..1ff746595 --- /dev/null +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -0,0 +1,16 @@ +import pytest + +from bpp.models.cache import Cache_Punktacja_Dyscypliny + + +@pytest.mark.django_db +def test_cache_punktacja_dyscypliny_ma_uczelnia(uczelnia, dyscyplina1): + obj = Cache_Punktacja_Dyscypliny( + rekord_id=[1, 1], + dyscyplina=dyscyplina1, + pkd=10, + slot=1, + uczelnia=uczelnia, + ) + assert obj.uczelnia_id == uczelnia.pk + assert obj.serialize()[-1] == uczelnia.pk From 47ac5d887d50d969e6086ec229828c3b673e8ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 00:19:59 +0200 Subject: [PATCH 072/247] feat(multi-hosted): SlotMixin/Zwarte_Baza filtruja autorow po uczelni Dodaj parametr uczelnia= do SlotMixin.__init__ i Zwarte_Baza.__init__; funnel wszyscy/autorzy_z_dyscypliny/dyscypliny przez _autorzy_qs(), ktory filtruje jednostka__uczelnia gdy uczelnia jest ustawiona. Co-Authored-By: Claude Opus 4.8 --- src/bpp/models/sloty/common.py | 15 +++- src/bpp/models/sloty/wydawnictwo_zwarte.py | 10 ++- .../test_sloty/test_per_uczelnia.py | 88 +++++++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/src/bpp/models/sloty/common.py b/src/bpp/models/sloty/common.py index 0ee7344ff..d3102358a 100644 --- a/src/bpp/models/sloty/common.py +++ b/src/bpp/models/sloty/common.py @@ -10,11 +10,18 @@ class SlotMixin: """Mixin używany przez Wydawnictwo_Zwarte, Wydawnictwo_Ciagle i Patent do przeprowadzania kalkulacji na slotach.""" - def __init__(self, original): + def __init__(self, original, uczelnia=None): self.original = original + self.uczelnia = uczelnia + + def _autorzy_qs(self): + qs = self.original.autorzy_set.all() + if self.uczelnia is not None: + qs = qs.filter(jednostka__uczelnia=self.uczelnia) + return qs def wszyscy(self): - return self.original.autorzy_set.count() + return self._autorzy_qs().count() def autorzy_z_dyscypliny(self, dyscyplina_naukowa, typ_ogolny=None): ret = [] @@ -24,7 +31,7 @@ def autorzy_z_dyscypliny(self, dyscyplina_naukowa, typ_ogolny=None): # Czy typ ogólny autora (autor, redaktor) to ten, którego poszukujemy? elem_kw = {"typ_odpowiedzialnosci__typ_ogolny": typ_ogolny} - for elem in self.original.autorzy_set.filter( + for elem in self._autorzy_qs().filter( afiliuje=True, przypieta=True, # explicte -- nie chcemy, aby to pole brało udział w kalkulacjach @@ -47,7 +54,7 @@ def dyscypliny(self): return set() ret = set() - for wa in self.original.autorzy_set.all(): + for wa in self._autorzy_qs(): d = wa.okresl_dyscypline() if d is None: continue diff --git a/src/bpp/models/sloty/wydawnictwo_zwarte.py b/src/bpp/models/sloty/wydawnictwo_zwarte.py index 009e84b03..d0a4d0b20 100644 --- a/src/bpp/models/sloty/wydawnictwo_zwarte.py +++ b/src/bpp/models/sloty/wydawnictwo_zwarte.py @@ -14,11 +14,19 @@ class SlotKalkulator_Wydawnictwo_Zwarte_Baza: (80, 200 itp). """ - def __init__(self, original, tryb_kalkulacji, wiele_hst=False, poziom_wydawcy=None): + def __init__( + self, + original, + tryb_kalkulacji, + wiele_hst=False, + poziom_wydawcy=None, + uczelnia=None, + ): self.original = original self.tryb_kalkulacji = tryb_kalkulacji self.wiele_hst = wiele_hst self.poziom_wydawcy = poziom_wydawcy + self.uczelnia = uczelnia def punkty_pkd(self, dyscyplina): val = super().punkty_pkd(dyscyplina) diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index 1ff746595..0cf97324f 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -1,6 +1,13 @@ import pytest from bpp.models.cache import Cache_Punktacja_Dyscypliny +from bpp.models import ( + Autor_Dyscyplina, + Charakter_Formalny, + Jednostka, + Uczelnia, + Wydzial, +) @pytest.mark.django_db @@ -14,3 +21,84 @@ def test_cache_punktacja_dyscypliny_ma_uczelnia(uczelnia, dyscyplina1): ) assert obj.uczelnia_id == uczelnia.pk assert obj.serialize()[-1] == uczelnia.pk + + +@pytest.fixture +def druga_uczelnia(db): + from django.contrib.sites.models import Site + + site, _ = Site.objects.get_or_create( + domain="druga.testserver", defaults={"name": "druga"} + ) + return Uczelnia.objects.create(skrot="DR", nazwa="Druga uczelnia", site=site) + + +@pytest.fixture +def jednostka_drugiej_uczelni(druga_uczelnia, db): + wydzial = Wydzial.objects.create( + uczelnia=druga_uczelnia, skrot="W2", nazwa="Wydział II" + ) + return Jednostka.objects.create( + nazwa="Jedn. Drugiej Ucz.", + skrot="JDU", + wydzial=wydzial, + uczelnia=druga_uczelnia, + ) + + +@pytest.fixture +def zwarte_dwie_uczelnie( + wydawnictwo_zwarte, + autor_jan_nowak, + autor_jan_kowalski, + jednostka, + jednostka_drugiej_uczelni, + dyscyplina1, + rodzaj_autora_n, + charaktery_formalne, + wydawca, + typy_odpowiedzialnosci, + rok, +): + # Obaj autorzy w TEJ SAMEJ dyscyplinie, ale w różnych uczelniach. + Autor_Dyscyplina.objects.create( + autor=autor_jan_nowak, + dyscyplina_naukowa=dyscyplina1, + rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_nowak, jednostka, dyscyplina_naukowa=dyscyplina1 + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_kowalski, jednostka_drugiej_uczelni, dyscyplina_naukowa=dyscyplina1 + ) + wydawnictwo_zwarte.punkty_kbn = 20 + wydawnictwo_zwarte.wydawca = wydawca + wydawnictwo_zwarte.charakter_formalny = Charakter_Formalny.objects.get( + skrot="KSP" + ) + wydawnictwo_zwarte.save() + return wydawnictwo_zwarte + + +@pytest.mark.django_db +def test_slotmixin_wszyscy_scoped_po_uczelni(zwarte_dwie_uczelnie, jednostka): + from bpp.models.sloty.wydawnictwo_zwarte import ( + SlotKalkulator_Wydawnictwo_Zwarte_Prog3, + ) + + kalk_all = SlotKalkulator_Wydawnictwo_Zwarte_Prog3( + zwarte_dwie_uczelnie, tryb_kalkulacji=None + ) + kalk_u1 = SlotKalkulator_Wydawnictwo_Zwarte_Prog3( + zwarte_dwie_uczelnie, tryb_kalkulacji=None, uczelnia=jednostka.uczelnia + ) + assert kalk_all.wszyscy() == 2 + assert kalk_u1.wszyscy() == 1 From 91a07ef2609fdcbd397f44bbf00be51c2b7543dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 00:27:09 +0200 Subject: [PATCH 073/247] refactor(multi-hosted): Zwarte_Baza.__init__ uzywa super() (code review) Co-Authored-By: Claude Opus 4.8 --- src/bpp/models/sloty/wydawnictwo_zwarte.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bpp/models/sloty/wydawnictwo_zwarte.py b/src/bpp/models/sloty/wydawnictwo_zwarte.py index d0a4d0b20..964a441cb 100644 --- a/src/bpp/models/sloty/wydawnictwo_zwarte.py +++ b/src/bpp/models/sloty/wydawnictwo_zwarte.py @@ -22,11 +22,10 @@ def __init__( poziom_wydawcy=None, uczelnia=None, ): - self.original = original + super().__init__(original, uczelnia=uczelnia) self.tryb_kalkulacji = tryb_kalkulacji self.wiele_hst = wiele_hst self.poziom_wydawcy = poziom_wydawcy - self.uczelnia = uczelnia def punkty_pkd(self, dyscyplina): val = super().punkty_pkd(dyscyplina) From e08766b87ac030437191eb10940aae9740d66e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 00:29:01 +0200 Subject: [PATCH 074/247] feat(multi-hosted): uczelnie_rekordu() na ModelZPrzeliczaniemDyscyplin Co-Authored-By: Claude Opus 4.8 --- src/bpp/models/abstract/disciplines.py | 15 +++++++++++++++ .../test_models/test_sloty/test_per_uczelnia.py | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/bpp/models/abstract/disciplines.py b/src/bpp/models/abstract/disciplines.py index 8c428f747..cd7e8bcae 100644 --- a/src/bpp/models/abstract/disciplines.py +++ b/src/bpp/models/abstract/disciplines.py @@ -40,3 +40,18 @@ def wszystkie_dyscypliny_rekordu(self): .values_list("dyscyplina_naukowa") .distinct() ) + + def uczelnie_rekordu(self): + """Distinct uczelnie wśród afiliujących, przypiętych autorów rekordu + (autor → jednostka → uczelnia). Luźny nadzbiór wystarcza.""" + from bpp.models.uczelnia import Uczelnia + + if not self.pk: + return Uczelnia.objects.none() + + uczelnia_ids = ( + self.autorzy_set.filter(afiliuje=True, przypieta=True) + .values_list("jednostka__uczelnia_id", flat=True) + .distinct() + ) + return Uczelnia.objects.filter(pk__in=list(uczelnia_ids)) diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index 0cf97324f..6a2028343 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -102,3 +102,13 @@ def test_slotmixin_wszyscy_scoped_po_uczelni(zwarte_dwie_uczelnie, jednostka): ) assert kalk_all.wszyscy() == 2 assert kalk_u1.wszyscy() == 1 + + +@pytest.mark.django_db +def test_uczelnie_rekordu_zwraca_obie( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia +): + assert set(zwarte_dwie_uczelnie.uczelnie_rekordu()) == { + jednostka.uczelnia, + druga_uczelnia, + } From f750a921505a5fbd140f8ecf8badb389141dfd6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 00:35:10 +0200 Subject: [PATCH 075/247] refactor(multi-hosted): ISlot opcjonalna uczelnia + rozstrzyganie + helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Usuwa get_default() z ISlot; dodaje _rozstrzygnij_uczelnie() (1 uczelnia w systemie → ta uczelnia; cross-uczelnia bez jawnego arg → CannotAdapt) oraz _dopasuj_kalkulator() jako 1:1 przeniesienie logiki wyboru kalkulatora. ISlot ustawia kalkulator.uczelnia po konstrukcji. Co-Authored-By: Claude Opus 4.8 --- src/bpp/models/sloty/core.py | 40 ++++++++++++++----- .../test_sloty/test_per_uczelnia.py | 27 +++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/bpp/models/sloty/core.py b/src/bpp/models/sloty/core.py index 0fb157ce6..ce5889436 100644 --- a/src/bpp/models/sloty/core.py +++ b/src/bpp/models/sloty/core.py @@ -2,7 +2,7 @@ from django.db import transaction from django.utils.functional import cached_property -from bpp.models import Dyscyplina_Naukowa, Uczelnia, Wydawca +from bpp.models import Dyscyplina_Naukowa, Wydawca from bpp.models.cache import Cache_Punktacja_Autora, Cache_Punktacja_Dyscypliny from bpp.models.patent import Patent from bpp.models.sloty.wydawnictwo_ciagle import SlotKalkulator_Wydawnictwo_Ciagle_Prog3 @@ -23,6 +23,24 @@ ) +def _rozstrzygnij_uczelnie(original): # noqa + """ISlot bez jawnej uczelni: zwróć jednoznaczną uczelnię albo CannotAdapt.""" + from bpp.models.uczelnia import Uczelnia + + if Uczelnia.objects.count() == 1: + return Uczelnia.objects.get() + + uczelnie = list(original.uczelnie_rekordu()) + if len(uczelnie) == 1: + return uczelnie[0] + if len(uczelnie) == 0: + raise CannotAdapt("Rekord nie ma afiliujących autorów z uczelnią.") + raise CannotAdapt( + "Rekord ma autorów z wielu uczelni — podaj uczelnię jawnie " + "(ISlot(rekord, uczelnia=...)); bez niej wynik jest niejednoznaczny." + ) + + def ISlot(original, uczelnia=None): # noqa if isinstance(original, Patent): raise CannotAdapt("Sloty dla patentów nie są liczone") @@ -30,17 +48,14 @@ def ISlot(original, uczelnia=None): # noqa if hasattr(original, "typ_kbn") and original.typ_kbn.skrot == "PW": raise CannotAdapt("Sloty dla prac wieloośrodkowych nie są liczone.") + if hasattr(original, "rok") and original.rok is None: + raise CannotAdapt("Rekord nie ma ustawionego roku — sloty nie są liczone.") + if uczelnia is None: - # TODO(multi-hosted): sloty/punktacja docelowo per-uczelnia (autor → - # jednostka → uczelnia; wynik różny per uczelnia, do liczenia i zapisu - # OSOBNO). To osobny redesign warstwy ewaluacji (cache per - # rekord×uczelnia). Do tego czasu get_default() — NIE .get(): to - # hot-path per-save, >1 uczelnia rzuciłaby przy każdym zapisie. - uczelnia = Uczelnia.objects.get_default() + uczelnia = _rozstrzygnij_uczelnie(original) if ( - uczelnia is not None - and hasattr(original, "status_korekty_id") + hasattr(original, "status_korekty_id") and original.status_korekty_id in uczelnia.ukryte_statusy("sloty") ): raise CannotAdapt( @@ -48,9 +63,12 @@ def ISlot(original, uczelnia=None): # noqa "statusów korekt. " ) - if hasattr(original, "rok") and original.rok is None: - raise CannotAdapt("Rekord nie ma ustawionego roku — sloty nie są liczone.") + kalkulator = _dopasuj_kalkulator(original) + kalkulator.uczelnia = uczelnia + return kalkulator + +def _dopasuj_kalkulator(original): # noqa if isinstance(original, Wydawnictwo_Ciagle): if original.rok in [2017, 2018]: if original.punkty_kbn >= 30: diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index 6a2028343..e8cc274cd 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -112,3 +112,30 @@ def test_uczelnie_rekordu_zwraca_obie( jednostka.uczelnia, druga_uczelnia, } + + +@pytest.mark.django_db +def test_islot_jawna_uczelnia_scoped(zwarte_dwie_uczelnie, jednostka): + from bpp.models.sloty.core import ISlot + + kalk = ISlot(zwarte_dwie_uczelnie, uczelnia=jednostka.uczelnia) + assert kalk.uczelnia == jednostka.uczelnia + assert kalk.wszyscy() == 1 + + +@pytest.mark.django_db +def test_islot_none_cross_uczelnia_failuje(zwarte_dwie_uczelnie, druga_uczelnia): + from bpp.models.sloty.core import CannotAdapt, ISlot + + # 2 uczelnie w systemie + praca cross-uczelnia => niejednoznaczne => CannotAdapt + with pytest.raises(CannotAdapt): + ISlot(zwarte_dwie_uczelnie) + + +@pytest.mark.django_db +def test_islot_none_jedna_uczelnia_systemu_rozstrzyga(zwarte_z_dyscyplinami, uczelnia): + from bpp.models.sloty.core import ISlot + + # tylko jedna uczelnia w systemie => ISlot(obj) rozstrzyga ją + kalk = ISlot(zwarte_z_dyscyplinami) + assert kalk.uczelnia == uczelnia From c03c57bd7bad296d7d478a56b781a2d2b29ba959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 00:45:06 +0200 Subject: [PATCH 076/247] refactor(multi-hosted): noqa specificity + jasniejszy komunikat (code review) Co-Authored-By: Claude Opus 4.8 --- src/bpp/models/sloty/core.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/bpp/models/sloty/core.py b/src/bpp/models/sloty/core.py index ce5889436..640933447 100644 --- a/src/bpp/models/sloty/core.py +++ b/src/bpp/models/sloty/core.py @@ -23,7 +23,7 @@ ) -def _rozstrzygnij_uczelnie(original): # noqa +def _rozstrzygnij_uczelnie(original): """ISlot bez jawnej uczelni: zwróć jednoznaczną uczelnię albo CannotAdapt.""" from bpp.models.uczelnia import Uczelnia @@ -34,7 +34,10 @@ def _rozstrzygnij_uczelnie(original): # noqa if len(uczelnie) == 1: return uczelnie[0] if len(uczelnie) == 0: - raise CannotAdapt("Rekord nie ma afiliujących autorów z uczelnią.") + raise CannotAdapt( + "Rekord nie ma afiliujących i przypiętych autorów — " + "nie można ustalić uczelni." + ) raise CannotAdapt( "Rekord ma autorów z wielu uczelni — podaj uczelnię jawnie " "(ISlot(rekord, uczelnia=...)); bez niej wynik jest niejednoznaczny." @@ -68,7 +71,7 @@ def ISlot(original, uczelnia=None): # noqa return kalkulator -def _dopasuj_kalkulator(original): # noqa +def _dopasuj_kalkulator(original): # noqa: C901 if isinstance(original, Wydawnictwo_Ciagle): if original.rok in [2017, 2018]: if original.punkty_kbn >= 30: From d56ebf10b9fc4934b6dbba1474fdc0232a23ab7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 00:50:02 +0200 Subject: [PATCH 077/247] feat(multi-hosted): IPunktacjaCacher petla per uczelnia + tag + det. serialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IPunktacjaCacher.__init__ drops uczelnia param; computes per-university internally via _uczelnie_do_przeliczenia (fast-track single-install: one DB query; multi-install: uczelnie_rekordu() loop) - rebuildEntries iterates over universities, calls ISlot(obj, uczelnia=U), writes Cache_Punktacja_Dyscypliny rows tagged with uczelnia FK - Author loop scoped per university via autorzy_set.filter(jednostka__uczelnia=U) - canAdapt uses _dopasuj_kalkulator (uczelnia-independent) — no more _rozstrzygnij_uczelnie ambiguity error for cross-university records - serialize is now deterministic (ORDER BY uczelnia_id/nazwisko/etc.) - disciplines.przelicz_punkty_dyscyplin drops uczelnia plumbing (uczelnia param kept for backward compat but ignored with ARG002 noqa) - New test: test_rebuild_tworzy_wiersze_per_uczelnia proves 2 CPD rows + 2 CPA rows for a cross-university record, each scoped to one university Co-Authored-By: Claude Opus 4.8 --- src/bpp/models/abstract/disciplines.py | 16 +--- src/bpp/models/sloty/core.py | 76 +++++++++++-------- .../test_sloty/test_per_uczelnia.py | 37 ++++++++- 3 files changed, 85 insertions(+), 44 deletions(-) diff --git a/src/bpp/models/abstract/disciplines.py b/src/bpp/models/abstract/disciplines.py index cd7e8bcae..51121deb2 100644 --- a/src/bpp/models/abstract/disciplines.py +++ b/src/bpp/models/abstract/disciplines.py @@ -9,21 +9,13 @@ class ModelZPrzeliczaniemDyscyplin(models.Model): class Meta: abstract = True - def przelicz_punkty_dyscyplin(self, uczelnia=None): + def przelicz_punkty_dyscyplin(self, uczelnia=None): # noqa: ARG002 from bpp.models.sloty.core import IPunktacjaCacher - if uczelnia is None: - from bpp.models.uczelnia import Uczelnia - - # TODO(multi-hosted): patrz ISlot — punktacja docelowo per-uczelnia, - # osobny redesign warstwy ewaluacji. Do tego czasu get_default() - # (NIE .get() — hot-path per-save). - uczelnia = Uczelnia.objects.get_default() - - ipc = IPunktacjaCacher(self, uczelnia) + # uczelnia ignorowana — IPunktacjaCacher iteruje per-uczelnia wewnętrznie + ipc = IPunktacjaCacher(self) ipc.removeEntries() - if ipc.canAdapt(): - ipc.rebuildEntries() + ipc.rebuildEntries() return ipc.serialize() def odpiete_dyscypliny(self): diff --git a/src/bpp/models/sloty/core.py b/src/bpp/models/sloty/core.py index 640933447..2246cd733 100644 --- a/src/bpp/models/sloty/core.py +++ b/src/bpp/models/sloty/core.py @@ -315,16 +315,13 @@ def _dopasuj_kalkulator(original): # noqa: C901 class IPunktacjaCacher: - def __init__(self, original, uczelnia=None): + def __init__(self, original): self.original = original - self.slot = None - self.uczelnia = uczelnia def canAdapt(self): try: - self.slot = ISlot(self.original, uczelnia=self.uczelnia) + _dopasuj_kalkulator(self.original) return True - except CannotAdapt: return False @@ -351,46 +348,63 @@ def removeEntries(self): def serialize(self): """ - Zwraca słownik JSON z zawartością danych rekordu + Zwraca krotkę (autorzy, dyscypliny) z deterministycznie posortowaną + zawartością cache dla tego rekordu. """ - ret1 = [] - for elem in self.cache_punktacja_autora: - ret1.append(elem.serialize()) - - ret2 = [] - for elem in self.cache_punktacja_dyscypliny: - ret2.append(elem.serialize()) - + ret1 = [ + elem.serialize() + for elem in self.cache_punktacja_autora.order_by( + "jednostka__uczelnia_id", "autor__nazwisko", "dyscyplina__nazwa", "pk" + ) + ] + ret2 = [ + elem.serialize() + for elem in self.cache_punktacja_dyscypliny.order_by( + "uczelnia_id", "dyscyplina__nazwa", "pk" + ) + ] return ret1, ret2 def get_pk(self): return (self.ctype, self.original.pk) + def _uczelnie_do_przeliczenia(self): + from bpp.models.uczelnia import Uczelnia + + # Fast-track dla single-install: jedna uczelnia w systemie => bez + # enumeracji autorów rekordu (wszyscy autorzy i tak należą do tej + # jednej uczelni). Jedno zapytanie zamiast JOIN-a po autorach. + uczelnie_systemu = list(Uczelnia.objects.all()[:2]) + if len(uczelnie_systemu) == 1: + return uczelnie_systemu + return self.original.uczelnie_rekordu() + @transaction.atomic def rebuildEntries(self): - pk = self.get_pk() - - # Jeżeli nie można zaadaptować danego rekordu do kalkulatora - # punktacji, to po skasowaniu ewentualnej scache'owanej punktacji - # wyjdź z funkcji: - if self.canAdapt() is False: - return + for uczelnia in self._uczelnie_do_przeliczenia(): + try: + kalk = ISlot(self.original, uczelnia=uczelnia) + except CannotAdapt: + # Nie liczy się (typ/punkty/rok) lub ukryty status dla tej uczelni + continue + self._zapisz(kalk, uczelnia) - _slot_cache = {} + def _zapisz(self, kalk, uczelnia): + pk = self.get_pk() - for dyscyplina in self.slot.dyscypliny: - _slot_cache[dyscyplina] = self.slot.slot_dla_dyscypliny(dyscyplina) - azd = self.slot.autorzy_z_dyscypliny(dyscyplina) + for dyscyplina in kalk.dyscypliny: + azd = kalk.autorzy_z_dyscypliny(dyscyplina) if not azd: - # Na ten moment nie chcemy wpisów odnosnie dyscyplin i slotów, gdy nie ma + # Nie chcemy wpisów odnośnie dyscyplin i slotów, gdy nie ma # w nich żadnych autorów (zgłoszenie w Mantis #1009) continue Cache_Punktacja_Dyscypliny.objects.create( rekord_id=[pk[0], pk[1]], dyscyplina=dyscyplina, - pkd=self.slot.punkty_pkd(dyscyplina), - slot=_slot_cache[dyscyplina], + uczelnia=uczelnia, + pkd=kalk.punkty_pkd(dyscyplina), + slot=kalk.slot_dla_dyscypliny(dyscyplina), autorzy_z_dyscypliny=[a.pk for a in azd], zapisani_autorzy_z_dyscypliny=[a.zapisany_jako for a in azd], ) @@ -398,7 +412,7 @@ def rebuildEntries(self): if not self.original.pk: return - for wa in self.original.autorzy_set.all(): + for wa in self.original.autorzy_set.filter(jednostka__uczelnia=uczelnia): if ( not wa.afiliuje or not wa.jednostka.skupia_pracownikow @@ -414,7 +428,7 @@ def rebuildEntries(self): if dyscyplina is None: continue - pkdaut = self.slot.pkd_dla_autora(wa) + pkdaut = kalk.pkd_dla_autora(wa) if pkdaut is None: continue Cache_Punktacja_Autora.objects.create( @@ -423,5 +437,5 @@ def rebuildEntries(self): jednostka_id=wa.jednostka_id, dyscyplina_id=dyscyplina.pk, pkdaut=pkdaut, - slot=self.slot.slot_dla_autora_z_dyscypliny(dyscyplina), + slot=kalk.slot_dla_autora_z_dyscypliny(dyscyplina), ) diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index e8cc274cd..5b03f0673 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -1,6 +1,5 @@ import pytest -from bpp.models.cache import Cache_Punktacja_Dyscypliny from bpp.models import ( Autor_Dyscyplina, Charakter_Formalny, @@ -8,6 +7,7 @@ Uczelnia, Wydzial, ) +from bpp.models.cache import Cache_Punktacja_Dyscypliny @pytest.mark.django_db @@ -139,3 +139,38 @@ def test_islot_none_jedna_uczelnia_systemu_rozstrzyga(zwarte_z_dyscyplinami, ucz # tylko jedna uczelnia w systemie => ISlot(obj) rozstrzyga ją kalk = ISlot(zwarte_z_dyscyplinami) assert kalk.uczelnia == uczelnia + + +@pytest.mark.django_db +def test_rebuild_tworzy_wiersze_per_uczelnia( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia +): + from bpp.models.cache import ( + Cache_Punktacja_Autora, + Cache_Punktacja_Dyscypliny, + ) + from bpp.models.sloty.core import IPunktacjaCacher + + cacher = IPunktacjaCacher(zwarte_dwie_uczelnie) + cacher.removeEntries() + cacher.rebuildEntries() + + cpd = Cache_Punktacja_Dyscypliny.objects.filter( + rekord_id=[cacher.ctype, zwarte_dwie_uczelnie.pk] + ) + assert cpd.count() == 2 + assert set(cpd.values_list("uczelnia_id", flat=True)) == { + jednostka.uczelnia_id, + druga_uczelnia.pk, + } + for row in cpd: + assert len(row.autorzy_z_dyscypliny) == 1 + + cpa = Cache_Punktacja_Autora.objects.filter( + rekord_id=[cacher.ctype, zwarte_dwie_uczelnie.pk] + ) + assert cpa.count() == 2 + assert {c.jednostka.uczelnia_id for c in cpa} == { + jednostka.uczelnia_id, + druga_uczelnia.pk, + } From 764121d3af6db39a5797c7c9d4f59a5d550da066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 00:59:56 +0200 Subject: [PATCH 078/247] feat(multi-hosted): przelicz_punkty_dyscyplin bez parametru + guard canAdapt Remove vestigial uczelnia=None param (no callers pass it), add if ipc.canAdapt(): guard for cheap fast-exit on non-slottable records, and add regression tests proving per-university divisor k=1 and deterministic return value. Co-Authored-By: Claude Opus 4.8 --- src/bpp/models/abstract/disciplines.py | 6 +++--- .../test_sloty/test_per_uczelnia.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/bpp/models/abstract/disciplines.py b/src/bpp/models/abstract/disciplines.py index 51121deb2..9c775554f 100644 --- a/src/bpp/models/abstract/disciplines.py +++ b/src/bpp/models/abstract/disciplines.py @@ -9,13 +9,13 @@ class ModelZPrzeliczaniemDyscyplin(models.Model): class Meta: abstract = True - def przelicz_punkty_dyscyplin(self, uczelnia=None): # noqa: ARG002 + def przelicz_punkty_dyscyplin(self): from bpp.models.sloty.core import IPunktacjaCacher - # uczelnia ignorowana — IPunktacjaCacher iteruje per-uczelnia wewnętrznie ipc = IPunktacjaCacher(self) ipc.removeEntries() - ipc.rebuildEntries() + if ipc.canAdapt(): + ipc.rebuildEntries() return ipc.serialize() def odpiete_dyscypliny(self): diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index 5b03f0673..05ed0c440 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -174,3 +174,23 @@ def test_rebuild_tworzy_wiersze_per_uczelnia( jednostka.uczelnia_id, druga_uczelnia.pk, } + + +@pytest.mark.django_db +def test_przelicz_per_uczelnia_dzielnik_k1(zwarte_dwie_uczelnie): + from bpp.models.cache import Cache_Punktacja_Autora + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + + cpa_nowak = Cache_Punktacja_Autora.objects.get(autor__nazwisko="Nowak") + cpa_kowalski = Cache_Punktacja_Autora.objects.get(autor__nazwisko="Kowalski") + # k=1 w obrębie każdej uczelni => każdy ma pełny slot, suma = 2.0 + assert cpa_nowak.slot == cpa_kowalski.slot + assert cpa_nowak.slot + cpa_kowalski.slot == 2 + + +@pytest.mark.django_db +def test_przelicz_zwrotka_deterministyczna(zwarte_dwie_uczelnie): + a = zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + b = zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + assert str(a) == str(b) From 5c82a43cf35cfd2a1a64c570c2ce50f383df246a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 01:05:10 +0200 Subject: [PATCH 079/247] feat(multi-hosted): widok cache_punktacja_autora joinuje po uczelni Naprawa kartezjanskiego iloczynu w widoku bpp_cache_punktacja_autora_view: join z Cache_Punktacja_Dyscypliny uzupelniony o d.uczelnia_id = j.uczelnia_id (przez bpp_jednostka), eliminujac duplikaty w rekordach cross-uczelnia. Co-Authored-By: Claude Opus 4.8 --- .../0425_per_uczelnia_cache_view.py | 50 +++++++++++++++++++ .../test_sloty/test_per_uczelnia.py | 17 +++++++ 2 files changed, 67 insertions(+) create mode 100644 src/bpp/migrations/0425_per_uczelnia_cache_view.py diff --git a/src/bpp/migrations/0425_per_uczelnia_cache_view.py b/src/bpp/migrations/0425_per_uczelnia_cache_view.py new file mode 100644 index 000000000..28010924c --- /dev/null +++ b/src/bpp/migrations/0425_per_uczelnia_cache_view.py @@ -0,0 +1,50 @@ +from django.db import migrations + +DROP = "DROP VIEW IF EXISTS bpp_cache_punktacja_autora_view;" + +CREATE_NEW = """ +CREATE VIEW bpp_cache_punktacja_autora_view AS +SELECT a.id, + a.rekord_id, + a.pkdaut, + a.slot, + a.autor_id, + a.dyscyplina_id, + a.jednostka_id, + d.autorzy_z_dyscypliny, + d.zapisani_autorzy_z_dyscypliny +FROM bpp_cache_punktacja_autora a +JOIN bpp_jednostka j ON j.id = a.jednostka_id +JOIN bpp_cache_punktacja_dyscypliny d + ON a.rekord_id = d.rekord_id + AND a.dyscyplina_id = d.dyscyplina_id + AND d.uczelnia_id = j.uczelnia_id; +""" + +CREATE_OLD = """ +CREATE VIEW bpp_cache_punktacja_autora_view AS +SELECT bpp_cache_punktacja_autora.id, + bpp_cache_punktacja_autora.rekord_id, + bpp_cache_punktacja_autora.pkdaut, + bpp_cache_punktacja_autora.slot, + bpp_cache_punktacja_autora.autor_id, + bpp_cache_punktacja_autora.dyscyplina_id, + bpp_cache_punktacja_autora.jednostka_id, + bpp_cache_punktacja_dyscypliny.autorzy_z_dyscypliny, + bpp_cache_punktacja_dyscypliny.zapisani_autorzy_z_dyscypliny +FROM bpp_cache_punktacja_autora, + bpp_cache_punktacja_dyscypliny +WHERE bpp_cache_punktacja_autora.rekord_id = bpp_cache_punktacja_dyscypliny.rekord_id + AND bpp_cache_punktacja_autora.dyscyplina_id = bpp_cache_punktacja_dyscypliny.dyscyplina_id; +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0424_cache_punktacja_dyscypliny_uczelnia_and_more"), + ] + + operations = [ + migrations.RunSQL(sql=DROP + CREATE_NEW, reverse_sql=DROP + CREATE_OLD), + ] diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index 05ed0c440..a9430b716 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -194,3 +194,20 @@ def test_przelicz_zwrotka_deterministyczna(zwarte_dwie_uczelnie): a = zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() b = zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() assert str(a) == str(b) + + +@pytest.mark.django_db +def test_widok_nie_duplikuje_miedzy_uczelniami(zwarte_dwie_uczelnie): + from django.contrib.contenttypes.models import ContentType + + from bpp.models.cache import Cache_Punktacja_Autora_Query_View + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + ctype = ContentType.objects.get_for_model(zwarte_dwie_uczelnie).pk + + rows = Cache_Punktacja_Autora_Query_View.objects.filter( + rekord_id=[ctype, zwarte_dwie_uczelnie.pk] + ) + # 2 autorow x 2 dyscyplina-agregaty bez joina po uczelni = 4 (kartezjan). + # Z naprawą: 2. + assert rows.count() == 2 From c769576c6ce43ed370d90123d792b28a3af35f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 01:09:49 +0200 Subject: [PATCH 080/247] test(multi-hosted): ukryte_statusy/wypadniecie uczelni/invariant single-install Co-Authored-By: Claude Opus 4.8 --- .../test_sloty/test_per_uczelnia.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index a9430b716..b12637d30 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -211,3 +211,107 @@ def test_widok_nie_duplikuje_miedzy_uczelniami(zwarte_dwie_uczelnie): # 2 autorow x 2 dyscyplina-agregaty bez joina po uczelni = 4 (kartezjan). # Z naprawą: 2. assert rows.count() == 2 + + +@pytest.mark.django_db +def test_ukryte_statusy_gatuje_jedna_uczelnie( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia +): + from bpp.models import Ukryj_Status_Korekty + from bpp.models.sloty.core import IPunktacjaCacher + + # Druga uczelnia ukrywa status korekty tej pracy dla slotów; pierwsza nie. + Ukryj_Status_Korekty.objects.create( + uczelnia=druga_uczelnia, + status_korekty=zwarte_dwie_uczelnie.status_korekty, + sloty=True, + ) + + ctype = IPunktacjaCacher(zwarte_dwie_uczelnie).ctype + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + + uczelnie = set( + Cache_Punktacja_Dyscypliny.objects.filter( + rekord_id=[ctype, zwarte_dwie_uczelnie.pk] + ).values_list("uczelnia_id", flat=True) + ) + assert jednostka.uczelnia_id in uczelnie + assert druga_uczelnia.pk not in uczelnie + + +@pytest.mark.django_db +def test_wypadniecie_uczelni_kasuje_sieroty( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia +): + from bpp.models.sloty.core import IPunktacjaCacher + + ctype = IPunktacjaCacher(zwarte_dwie_uczelnie).ctype + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + assert ( + Cache_Punktacja_Dyscypliny.objects.filter( + rekord_id=[ctype, zwarte_dwie_uczelnie.pk] + ).count() + == 2 + ) + + # Przenieś autora drugiej uczelni do pierwszej jednostki i przelicz ponownie. + wa = zwarte_dwie_uczelnie.autorzy_set.get(autor__nazwisko="Kowalski") + wa.jednostka = jednostka + wa.save() + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + uczelnie = set( + Cache_Punktacja_Dyscypliny.objects.filter( + rekord_id=[ctype, zwarte_dwie_uczelnie.pk] + ).values_list("uczelnia_id", flat=True) + ) + assert uczelnie == {jednostka.uczelnia_id} # brak sieroty druga_uczelnia + + +@pytest.mark.django_db +def test_invariant_jedna_uczelnia_k2( + wydawnictwo_zwarte, + autor_jan_nowak, + autor_jan_kowalski, + jednostka, + dyscyplina1, + rodzaj_autora_n, + charaktery_formalne, + wydawca, + typy_odpowiedzialnosci, + rok, +): + from bpp.models.cache import Cache_Punktacja_Autora + + # Obaj autorzy w TEJ SAMEJ uczelni i dyscyplinie => k=2, slot dzielony. + Autor_Dyscyplina.objects.create( + autor=autor_jan_nowak, + dyscyplina_naukowa=dyscyplina1, + rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_nowak, jednostka, dyscyplina_naukowa=dyscyplina1 + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_kowalski, jednostka, dyscyplina_naukowa=dyscyplina1 + ) + wydawnictwo_zwarte.punkty_kbn = 20 + wydawnictwo_zwarte.wydawca = wydawca + wydawnictwo_zwarte.charakter_formalny = Charakter_Formalny.objects.get(skrot="KSP") + wydawnictwo_zwarte.save() + + wydawnictwo_zwarte.przelicz_punkty_dyscyplin() + slots = list( + Cache_Punktacja_Autora.objects.filter( + autor__in=[autor_jan_nowak, autor_jan_kowalski] + ).values_list("slot", flat=True) + ) + assert len(slots) == 2 + assert sum(slots) == 1 # k=2 w jednej uczelni: po pół slotu, suma 1.0 From d961b2cb414dee0afa6f37e18a694778c97cd8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 01:18:52 +0200 Subject: [PATCH 081/247] docs(multi-hosted): backfill per-uczelnia cache + regresja write-side All regression suites (slot/cache, write-path consumers, optimization) pass 100%. Migration drift check confirms no missing bpp migrations. Appends deploy/backfill instructions for Cache_Punktacja_Dyscypliny uczelnia FK to the audit document. Co-Authored-By: Claude Opus 4.8 --- docs/deweloper/audyt-multihosted-pbn.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/deweloper/audyt-multihosted-pbn.md b/docs/deweloper/audyt-multihosted-pbn.md index f81ba0575..654c00ccd 100644 --- a/docs/deweloper/audyt-multihosted-pbn.md +++ b/docs/deweloper/audyt-multihosted-pbn.md @@ -179,3 +179,23 @@ 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`. From c74e4aba5b2a779533d30106f6c1617ae3aa78f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 01:42:43 +0200 Subject: [PATCH 082/247] fix(multi-hosted): napraw regresje po merge dev (make tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - widok cache_punktacja_autora: join tolerancyjny na uczelnia_id IS NULL (legacy/przed-backfillem + fixtures bez uczelni) — raporty slotow nie znikaja miedzy migrate a denorm-rebuildem; nowe wiersze per-uczelnia matchuja exact - guard get_default: usun stale wpisy sloty/core.py + disciplines.py (get_default zrealizowany/usuniety), dodaj powiazania_autorow/queries.py (nowy app z dev) - test inwalidacji cache uczelni: oczekuj 2 delete (klucz per-site + legacy) Co-Authored-By: Claude Opus 4.8 --- src/bpp/migrations/0425_per_uczelnia_cache_view.py | 9 ++++++++- .../tests/test_models/test_struktura/test_uczelnia.py | 6 +++++- src/bpp/tests/test_multihosted_get_default_guard.py | 6 ++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/bpp/migrations/0425_per_uczelnia_cache_view.py b/src/bpp/migrations/0425_per_uczelnia_cache_view.py index 28010924c..093f69f71 100644 --- a/src/bpp/migrations/0425_per_uczelnia_cache_view.py +++ b/src/bpp/migrations/0425_per_uczelnia_cache_view.py @@ -13,12 +13,19 @@ a.jednostka_id, d.autorzy_z_dyscypliny, d.zapisani_autorzy_z_dyscypliny +-- d.uczelnia_id IS NULL: wiersze legacy/przed-backfillem (kolumna uczelnia +-- jest nullable; wypełnia ją dopiero przeliczenie per-uczelnia). Tolerujemy je, +-- żeby raporty slotów nie zniknęły między `migrate` a denorm-rebuildem oraz dla +-- danych tworzonych bez uczelni (fixtures). Nowe wiersze per-uczelnia mają +-- uczelnia_id != NULL i matchują exact (bez kartezjana). Dla danego rekordu +-- wiersze są albo wszystkie NULL (legacy), albo wszystkie nie-NULL (po +-- przeliczeniu) — nie mieszają się, więc IS NULL nie powiela. FROM bpp_cache_punktacja_autora a JOIN bpp_jednostka j ON j.id = a.jednostka_id JOIN bpp_cache_punktacja_dyscypliny d ON a.rekord_id = d.rekord_id AND a.dyscyplina_id = d.dyscyplina_id - AND d.uczelnia_id = j.uczelnia_id; + AND (d.uczelnia_id = j.uczelnia_id OR d.uczelnia_id IS NULL); """ CREATE_OLD = """ diff --git a/src/bpp/tests/test_models/test_struktura/test_uczelnia.py b/src/bpp/tests/test_models/test_struktura/test_uczelnia.py index 45b30e194..f307749c9 100644 --- a/src/bpp/tests/test_models/test_struktura/test_uczelnia.py +++ b/src/bpp/tests/test_models/test_struktura/test_uczelnia.py @@ -445,4 +445,8 @@ def test_zapis_uczelni_inwaliduje_cache_strony_glownej(uczelnia, mocker): uczelnia.save() invalidate.assert_called_once_with() - delete.assert_called_once_with(b"bpp_uczelnia") + # Po wprowadzeniu kluczowania cache context-processora per-site, sygnał kasuje + # ZARÓWNO klucz per-site (bpp_uczelnia_) JAK I legacy b"bpp_uczelnia". + assert delete.call_count == 2 + delete.assert_any_call(f"bpp_uczelnia_{uczelnia.site_id}") + delete.assert_any_call(b"bpp_uczelnia") diff --git a/src/bpp/tests/test_multihosted_get_default_guard.py b/src/bpp/tests/test_multihosted_get_default_guard.py index 91b527d70..a0b0b4106 100644 --- a/src/bpp/tests/test_multihosted_get_default_guard.py +++ b/src/bpp/tests/test_multihosted_get_default_guard.py @@ -30,8 +30,9 @@ APPROVED: dict[str, int] = { "bpp/middleware.py": 1, # świadomy fallback: Site istnieje, brak Uczelni "bpp/util/bpp_specific.py": 2, # docstring + świadomy fallback (CLI/Celery bez requestu) - "bpp/models/sloty/core.py": 1, # TODO per-uczelnia sloty (parked, hot-path) - "bpp/models/abstract/disciplines.py": 1, # TODO per-uczelnia (parked) + # bpp/models/sloty/core.py i abstract/disciplines.py: get_default USUNIĘTY + # (per-uczelnia sloty zrealizowane — ISlot._rozstrzygnij_uczelnie + przelicz + # bez parametru). Brak wpisu = guard złapie ewentualny powrót get_default. "bpp/models/abstract/pbn.py": 2, # linki PBN, metoda modelu bez requestu "bpp/models/jednostka.py": 1, # sortowanie (display), warstwa modelu "bpp/multiseek_registry/fields/numeric_fields.py": 1, # toggle IC, None-tolerant @@ -43,6 +44,7 @@ "pbn_integrator/importer/authors.py": 5, # TODO integrator per-uczelnia (parked) "pbn_integrator/utils/scientists.py": 1, # TODO integrator per-uczelnia (parked) "pbn_integrator/management/commands/pbn_integrator.py": 1, # TODO integrator per-uczelnia + "powiazania_autorow/queries.py": 1, # dev: explorer, root PBN raz (anty-N+1), display; deferred multi-host } From 383f6a3da1c0fd9c651ebbc313040830dfd458a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 07:06:04 +0200 Subject: [PATCH 083/247] docs(CLAUDE): regula - uruchamiaj testy lokalnie; local make tests || CI rownolegle OK na PR Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ea82fd68f..c5a43d10b 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ć From 18d0cd1d1c9b42783832d5d1f151336f28b9222c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 07:24:32 +0200 Subject: [PATCH 084/247] style(lint): ruff-format na plikach zmienionych vs dev CI job "Lint changed files" odpalal ruff-format (przez pre-commit, pinned v0.15.12) na plikach z diffa origin/dev..HEAD i 7 plikow nie przechodzilo formatowania. Same zmiany zawijania linii, zero zmian logiki. Naprawione tym samym pinned ruff-em co CI (lokalny 0.15.15 dawal inne wyniki). Failed run: https://github.com/iplweb/bpp/actions/runs/26865181277 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/models/sloty/core.py | 7 +++---- .../management/commands/pbn_test_wysylka_interaktywna.py | 4 +--- src/pbn_api/management/commands/util.py | 4 +++- src/pbn_downloader_app/views.py | 8 ++------ src/pbn_export_queue/tasks.py | 8 ++++---- src/pbn_import/tests/test_tasks.py | 8 ++------ src/pbn_wysylka_oswiadczen/tests/test_tasks.py | 4 +--- 7 files changed, 16 insertions(+), 27 deletions(-) diff --git a/src/bpp/models/sloty/core.py b/src/bpp/models/sloty/core.py index 2246cd733..bc6966c10 100644 --- a/src/bpp/models/sloty/core.py +++ b/src/bpp/models/sloty/core.py @@ -57,10 +57,9 @@ def ISlot(original, uczelnia=None): # noqa if uczelnia is None: uczelnia = _rozstrzygnij_uczelnie(original) - if ( - hasattr(original, "status_korekty_id") - and original.status_korekty_id in uczelnia.ukryte_statusy("sloty") - ): + if hasattr( + original, "status_korekty_id" + ) and original.status_korekty_id in uczelnia.ukryte_statusy("sloty"): raise CannotAdapt( "Sloty nie będą liczone, zgodnie z ustawieniami obiektu Uczelnia dla ukrywanych " "statusów korekt. " diff --git a/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py b/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py index 48c285421..38b13d53c 100644 --- a/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py +++ b/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py @@ -214,9 +214,7 @@ def _step_show_publication(self, publication): def _step_generate_json(self, publication): self._header("KROK 2/8 — Generowanie JSON publikacji") - adapter = WydawnictwoPBNAdapter( - publication, uczelnia=self._resolved_uczelnia - ) + adapter = WydawnictwoPBNAdapter(publication, uczelnia=self._resolved_uczelnia) js = adapter.pbn_get_json() bez_oswiadczen = "statements" not in js n_statements = len(js.get("statements", [])) if not bez_oswiadczen else 0 diff --git a/src/pbn_api/management/commands/util.py b/src/pbn_api/management/commands/util.py index 5b3809dbb..adf540212 100644 --- a/src/pbn_api/management/commands/util.py +++ b/src/pbn_api/management/commands/util.py @@ -105,4 +105,6 @@ def get_client(self, app_id, app_token, base_url, user_token, verbose=False): print("App token\t", app_token) print("Base URL\t", base_url) print("User token\t", user_token) - return BppPBNClient(transport, uczelnia=getattr(self, "_resolved_uczelnia", None)) + return BppPBNClient( + transport, uczelnia=getattr(self, "_resolved_uczelnia", None) + ) diff --git a/src/pbn_downloader_app/views.py b/src/pbn_downloader_app/views.py index 261ba750b..923033802 100644 --- a/src/pbn_downloader_app/views.py +++ b/src/pbn_downloader_app/views.py @@ -430,9 +430,7 @@ def post(self, request): # Start the task try: - download_journals.delay( - user.pk, uczelnia_id=_request_uczelnia_id(request) - ) + download_journals.delay(user.pk, uczelnia_id=_request_uczelnia_id(request)) return JsonResponse( {"success": True, "message": "Zadanie pobierania źródeł rozpoczęte."} ) @@ -528,9 +526,7 @@ def post(self, request): # Start the task try: - download_journals.delay( - user.pk, uczelnia_id=_request_uczelnia_id(request) - ) + download_journals.delay(user.pk, uczelnia_id=_request_uczelnia_id(request)) return JsonResponse( { "success": True, diff --git a/src/pbn_export_queue/tasks.py b/src/pbn_export_queue/tasks.py index 79ef461d5..4dc969727 100644 --- a/src/pbn_export_queue/tasks.py +++ b/src/pbn_export_queue/tasks.py @@ -126,7 +126,9 @@ def kolejka_ponow_wysylke_prac_po_zalogowaniu(pk): @app.task -def queue_pbn_export_batch(app_label, model_name, record_ids, user_id, uczelnia_id=None): +def queue_pbn_export_batch( + app_label, model_name, record_ids, user_id, uczelnia_id=None +): """ Queue multiple records for PBN export in batch. @@ -150,9 +152,7 @@ def queue_pbn_export_batch(app_label, model_name, record_ids, user_id, uczelnia_ except User.DoesNotExist: return - uczelnia = ( - Uczelnia.objects.filter(pk=uczelnia_id).first() if uczelnia_id else None - ) + uczelnia = Uczelnia.objects.filter(pk=uczelnia_id).first() if uczelnia_id else None model = apps.get_model(app_label, model_name) diff --git a/src/pbn_import/tests/test_tasks.py b/src/pbn_import/tests/test_tasks.py index 528a11c14..577ef4c6b 100644 --- a/src/pbn_import/tests/test_tasks.py +++ b/src/pbn_import/tests/test_tasks.py @@ -306,9 +306,7 @@ def test_run_pbn_import_uses_passed_uczelnia_not_default(admin_user): ) uczelnia2 = Uczelnia.objects.create(skrot="P2", nazwa="Druga", site=site2) - session = ImportSession.objects.create( - user=admin_user, status="pending", config={} - ) + session = ImportSession.objects.create(user=admin_user, status="pending", config={}) recorded_pk = [] @@ -341,9 +339,7 @@ def test_run_pbn_import_without_uczelnia_id_does_not_fall_back(admin_user): ) Uczelnia.objects.create(skrot="P1", nazwa="Pierwsza", site=site1) - session = ImportSession.objects.create( - user=admin_user, status="pending", config={} - ) + session = ImportSession.objects.create(user=admin_user, status="pending", config={}) mock_import_manager = MagicMock() mock_import_manager.run.return_value = {"success": True} diff --git a/src/pbn_wysylka_oswiadczen/tests/test_tasks.py b/src/pbn_wysylka_oswiadczen/tests/test_tasks.py index c8d3d97f3..07d1214df 100644 --- a/src/pbn_wysylka_oswiadczen/tests/test_tasks.py +++ b/src/pbn_wysylka_oswiadczen/tests/test_tasks.py @@ -152,9 +152,7 @@ def test_start_task_view_passes_uczelnia_id(uczelnia): pbn_user.pbn_token_possibly_valid.return_value = True user.get_pbn_user = lambda: pbn_user - with patch( - "pbn_wysylka_oswiadczen.views.wysylka_oswiadczen_task" - ) as mock_task: + with patch("pbn_wysylka_oswiadczen.views.wysylka_oswiadczen_task") as mock_task: mock_task.delay.return_value = MagicMock(id="task-123") StartTaskView().post(request) From c2bfb2c60b9d102138c2350a1dd8ffb98f44b626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 11:10:45 +0200 Subject: [PATCH 085/247] fix(multi-hosted): 0425 backfilluje uczelnia (single) / failuje (multi); widok strict Zamiast band-aida 'OR uczelnia_id IS NULL' w widoku: - RunPython backfill: 1 uczelnia => wpisz jej ID w legacy CPD; >1 z NULL-ami => raise - widok wraca do strict join (d.uczelnia_id = j.uczelnia_id) - fixture raport_slotow ustawia uczelnia (jedyny runtime tworzacy CPD bez uczelni) 0425 edytowane in-place swiadomie (niewdrozone nigdzie; decyzja usera). Self-review write-side: docs/superpowers/reviews/2026-06-03-... Co-Authored-By: Claude Opus 4.8 --- ...lf-review-per-uczelnia-sloty-write-side.md | 104 ++++++++++++++++++ .../0425_per_uczelnia_cache_view.py | 58 ++++++++-- src/raport_slotow/tests/conftest.py | 4 +- 3 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md diff --git a/docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md b/docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md new file mode 100644 index 000000000..decd7499a --- /dev/null +++ b/docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md @@ -0,0 +1,104 @@ +# Self-review — per-uczelnia sloty (write-side) + +Data: 2026-06-03. Gałąź: `feature/multi-hosted-config`. +Zakres: `git diff 58daf3e1c..HEAD` na `src/bpp/models/sloty/`, +`src/bpp/models/cache/punktacja.py`, `src/bpp/models/abstract/disciplines.py`, +migracje `0424` (FK uczelnia) i `0425` (widok SQL). + +Metoda: trzy niezależne przebiegi — (a) inline adversarial (main agent), +(b) subagent `feature-dev:code-reviewer`, (c) headless `claude -p`. Codex padł +na konfiguracji konta (model `gpt-5.3-codex` niedostępny), opencode wygenerował +treść ale nie zapisał pliku (nie uszanował write-directive) — pominięte. + +Spec: `docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md`. + +--- + +## Werdykt + +Write-side jest **poprawny i zgodny ze spec**. Brak bugów CRITICAL/HIGH. +Partycja dzielnika `k`/`m` jest szczelna, invariant single-install trzyma. +Jeden konkretny **łamacz reguły projektu** (edycja zacommitowanej migracji) do +naprawy. Reszta to hardening pod nadchodzącą fazę multi-install/read-side. + +## Konsensus (potwierdzone niezależnie przez 2–3 źródła) — POPRAWNE + +1. **Partycja dzielnika k/m — szczelna.** Każdy odczyt autorów idzie przez + `SlotMixin._autorzy_qs()` (filtr `jednostka__uczelnia` gdy uczelnia ustawiona); + jedyny bezpośredni `autorzy_set` w `_zapisz` (core.py:414) też ma filtr. + `grep autorzy_set src/bpp/models/sloty/` → tylko te dwa miejsca. (3/3) +2. **Invariant single-install — trzyma, bo `Jednostka.uczelnia` jest NOT NULL** + (jednostka.py:93). Przy jednej uczelni wszystkie jednostki (w tym „obce") + wskazują na nią → filtr `jednostka__uczelnia` to no-op → `m` bez zmian → + liczby identyczne. Fast-track `Uczelnia.objects.all()[:2]`/`len==1` bezpieczny. + **To jest load-bearing**: gdyby FK kiedyś stał się nullable, invariant cicho + pęka. (3/3) +3. **Sieroty — sprzątane.** `removeEntries()` kasuje cały rekord (obie tabele) + przed rebuildem; `rebuildEntries()` liczy tylko bieżące `uczelnie_rekordu()`. + Test „wypadnięcie uczelni" (`c769576c6`) pokrywa. (3/3) +4. **Determinizm `serialize()` — OK w praktyce.** `order_by(..., "pk")` jako + tie-breaker; klucz `(rekord, uczelnia, dyscyplina)` unikalny, więc pk realnie + nie rozstrzyga; string stabilny między rebuildami mimo nowych pk. Test + `test_przelicz_zwrotka_deterministyczna`. (3/3) +5. **Write-path nigdy nie tworzy NULL uczelnia_id** — `_zazpisz` zawsze podaje + `uczelnia=` z `_uczelnie_do_przeliczenia`. NULL pochodzi tylko z legacy/fixtures. + (me + claude) + +## Do naprawy / decyzji + +### [RULE→ROZWIĄZANE 2026-06-03] Edycja zacommitowanej migracji 0425 (claude) +`0425_per_uczelnia_cache_view.py` powstała w `5c82a43cf` (strict join +`AND d.uczelnia_id = j.uczelnia_id`), a `c74e4aba5` ZMODYFIKOWAŁ ją na +`AND (d.uczelnia_id = j.uczelnia_id OR d.uczelnia_id IS NULL)` — łamiąc regułę +CLAUDE.md „NEVER modify existing migration files". +**Decyzja usera:** 0425 NIE jest nigdzie wypchnięte/zaaplikowane (multi-install +istnieją, ale bez publikacji), więc edycja in-place jest świadomie dozwolona dla +tego konkretnego przypadku. Zamiast band-aida `IS NULL` migracja ma „robić dobrze". +**Zrobione:** 0425 przebudowane na: +- `RunPython backfill_uczelnia`: 1 uczelnia → wpisz jej ID do + `Cache_Punktacja_Dyscypliny.uczelnia_id IS NULL` (legacy); są NULL-e a uczelni + ≠ 1 → `raise RuntimeError` (głośny fail, dzielnik per-uczelnia nie do zgadnięcia); + świeża baza bez NULL-i → no-op (przechodzi), +- widok wraca do **STRICT** (`AND d.uczelnia_id = j.uczelnia_id`, bez `IS NULL`), +- fixture `raport_slotow/tests/conftest.py` ustawia teraz `uczelnia=jednostka.uczelnia` + (jedyny runtime tworzący CPD bez uczelni; produkcyjny `_zapisz` zawsze ustawia). +Weryfikacja: 151 testów zielonych (raport_slotow + test_per_uczelnia + test_sloty), +`makemigrations --check` bez dryfu w `bpp`, ruff czysty. NOT NULL na `uczelnia` +nadal niemożliwe (fixtures tworzą NULL w runtime) — zostaje na read-side. + +### [MEDIUM] `_dopasuj_kalkulator` liczy `wiele_hst`/próg globalnie (me + subagent) +`rodzaje_hst` z `wszystkie_dyscypliny_rekordu()` (WSZYSTKIE uczelnie), a kalkulator +używany per-uczelnia. Rekord cross-uczelnia mieszający HST/nie-HST ponad granicą +uczelni → każda uczelnia dziedziczy globalne `wiele_hst`, choć w jej obrębie +dyscypliny są jednorodne. Spec uznaje wybór progu za uczelnia-niezależny, ale +**brak testu** dla HST-U1 + nie-HST-U2 i efekt jest nieoczywisty (mnożnik HST). +Single-install bez wpływu. **Akcja:** test graniczny + jawny komentarz/decyzja +domenowa w read-side/federacji. + +### [MEDIUM] Widok `OR uczelnia_id IS NULL` — nie wymuszony constraintem (me + subagent; claude: bezpieczny w normalnej pracy) +Bezpieczny dopóki per-rekord wiersze są jednego „pokolenia" (atomowy remove + +non-null rebuild gwarantuje). Ale mieszany stan (częściowy backfill / ręczna +ingerencja) → kartezjan w widoku; `test_widok_nie_duplikuje` nie pokrywa mieszanki. +**Akcja:** po backfillu migracja zacieśniająca `uczelnia` do NOT NULL + usunięcie +gałęzi `IS NULL` (przywraca strict), ewentualnie test mieszanego pokolenia. + +### [MEDIUM] Brak indeksu złożonego `(rekord_id, uczelnia, dyscyplina)` (subagent) +0424 dodaje tylko `(uczelnia, dyscyplina)`; spec chciał `(rekord_id, uczelnia, +dyscyplina)` pod join widoku. To też naturalny klucz funkcjonalny tabeli. +**Akcja:** `UniqueConstraint`/`Index` w nowej migracji (przy okazji 0426). + +### [LOW/design] Mutacja `kalk.uczelnia` po konstrukcji (subagent) +`ISlot` robi `kalkulator.uczelnia = uczelnia` po `_dopasuj_kalkulator`. Bezpieczne +TYLKO dlatego, że instancja jest świeża (brak skażenia `cached_property dyscypliny` +/ `_liczba_k_cache`). Tykająca mina, gdyby ktoś zmienił uczelnię po fakcie. +**Akcja (opcjonalna):** `uczelnia` jako wymagany arg konstruktora kalkulatora. + +### [LOW/pre-existing] Asymetria `skupia_pracownikow` (subagent) +`_zapisz` filtruje `wa.jednostka.skupia_pracownikow` (CPA), ale `autorzy_z_dyscypliny` +(→ `k`, → `autorzy_z_dyscypliny` w CPD) nie. CPD może listować PK autora bez +odpowiadającego wiersza CPA. Pre-existing, nie wprowadzone tą zmianą; istotne dla +read-side (zaskoczenie konsumenta). **Akcja:** udokumentować albo zrównać. + +## Pominięte/odrzucone +- pk-tie-breaker jako źródło „wiecznie brudnego" denorm — odrzucone: klucz + unikalny, iteracja stabilna (konsensus 3/3, mój wcześniejszy LOW wycofany). diff --git a/src/bpp/migrations/0425_per_uczelnia_cache_view.py b/src/bpp/migrations/0425_per_uczelnia_cache_view.py index 093f69f71..dd26cc936 100644 --- a/src/bpp/migrations/0425_per_uczelnia_cache_view.py +++ b/src/bpp/migrations/0425_per_uczelnia_cache_view.py @@ -2,6 +2,11 @@ DROP = "DROP VIEW IF EXISTS bpp_cache_punktacja_autora_view;" +# Join uwzględnia uczelnię: wiersz autora (jego jednostka → uczelnia) łączy się +# WYŁĄCZNIE z agregatem dyscypliny SWOJEJ uczelni. Brak gałęzi `IS NULL` — +# backfill (RunPython niżej) gwarantuje, że po tej migracji żaden wiersz +# Cache_Punktacja_Dyscypliny nie ma uczelnia_id NULL (single-install dostaje ID +# domyślnej uczelni; multi-install z wierszami bez uczelni → migracja failuje). CREATE_NEW = """ CREATE VIEW bpp_cache_punktacja_autora_view AS SELECT a.id, @@ -13,19 +18,12 @@ a.jednostka_id, d.autorzy_z_dyscypliny, d.zapisani_autorzy_z_dyscypliny --- d.uczelnia_id IS NULL: wiersze legacy/przed-backfillem (kolumna uczelnia --- jest nullable; wypełnia ją dopiero przeliczenie per-uczelnia). Tolerujemy je, --- żeby raporty slotów nie zniknęły między `migrate` a denorm-rebuildem oraz dla --- danych tworzonych bez uczelni (fixtures). Nowe wiersze per-uczelnia mają --- uczelnia_id != NULL i matchują exact (bez kartezjana). Dla danego rekordu --- wiersze są albo wszystkie NULL (legacy), albo wszystkie nie-NULL (po --- przeliczeniu) — nie mieszają się, więc IS NULL nie powiela. FROM bpp_cache_punktacja_autora a JOIN bpp_jednostka j ON j.id = a.jednostka_id JOIN bpp_cache_punktacja_dyscypliny d ON a.rekord_id = d.rekord_id AND a.dyscyplina_id = d.dyscyplina_id - AND (d.uczelnia_id = j.uczelnia_id OR d.uczelnia_id IS NULL); + AND d.uczelnia_id = j.uczelnia_id; """ CREATE_OLD = """ @@ -46,6 +44,49 @@ """ +def backfill_uczelnia(apps, schema_editor): + """Wypełnij `uczelnia_id` w istniejących (legacy) wierszach + Cache_Punktacja_Dyscypliny powstałych przed dodaniem kolumny. + + - Single-install (dokładnie jedna Uczelnia): wszystkie jednostki — a więc + i autorzy — należą do tej jednej uczelni, a partycja dzielnika k/m jest + wtedy no-opem, więc legacy liczby (pkd/slot) są już poprawne; brakuje tylko + tagu uczelni. Wpisujemy ID tej (domyślnej) uczelni. + - Multi-install z wierszami bez uczelni: NIE zgadujemy. `slot`/`pkdaut` zależą + od dzielnika liczonego osobno per uczelnia, więc starego (niepartycjonowanego) + wyniku nie da się przypisać do konkretnej uczelni. Migracja failuje — admin + ma najpierw przeliczyć cache per-uczelnia (pełny denorm rebuild) albo usunąć + stary cache i zaaplikować migrację na czystych danych. + - Świeża instalacja (brak wierszy bez uczelni) — no-op, przechodzi zawsze. + """ + Uczelnia = apps.get_model("bpp", "Uczelnia") + Cache_Punktacja_Dyscypliny = apps.get_model("bpp", "Cache_Punktacja_Dyscypliny") + + null_qs = Cache_Punktacja_Dyscypliny.objects.filter(uczelnia__isnull=True) + if not null_qs.exists(): + return + + uczelnie = list(Uczelnia.objects.all()[:2]) + if len(uczelnie) == 1: + null_qs.update(uczelnia=uczelnie[0]) + return + + raise RuntimeError( + "Migracja per-uczelnia (0425): w bazie istnieją wiersze " + "Cache_Punktacja_Dyscypliny bez przypisanej uczelni, a w systemie jest " + f"{len(uczelnie)} uczelni — nie można zdeterministycznie przypisać uczelni " + "(slot/pkd liczone są osobnym dzielnikiem per uczelnia). Przelicz cache " + "per-uczelnia PRZED tą migracją (pełny denorm rebuild), albo usuń stary " + "cache punktacji i zaaplikuj migrację na czystych danych." + ) + + +def backfill_uczelnia_reverse(apps, schema_editor): + # Backfill jest jednokierunkowy: przy rollbacku nie zerujemy `uczelnia_id` + # (kolumna i tak znika przy odwróceniu migracji 0424). + pass + + class Migration(migrations.Migration): dependencies = [ @@ -53,5 +94,6 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RunPython(backfill_uczelnia, backfill_uczelnia_reverse), migrations.RunSQL(sql=DROP + CREATE_NEW, reverse_sql=DROP + CREATE_OLD), ] diff --git a/src/raport_slotow/tests/conftest.py b/src/raport_slotow/tests/conftest.py index cde808675..b2f3457c6 100644 --- a/src/raport_slotow/tests/conftest.py +++ b/src/raport_slotow/tests/conftest.py @@ -1,9 +1,8 @@ import pytest from model_bakery import baker -from raport_slotow.models.uczelnia import RaportSlotowUczelnia - from bpp.models import Cache_Punktacja_Autora_Query, Cache_Punktacja_Dyscypliny, Rekord +from raport_slotow.models.uczelnia import RaportSlotowUczelnia def _rekord_slotu_maker(autor, jednostka, dyscyplina, wydawnictwo_ciagle, rok): @@ -13,6 +12,7 @@ def _rekord_slotu_maker(autor, jednostka, dyscyplina, wydawnictwo_ciagle, rok): Cache_Punktacja_Dyscypliny.objects.create( rekord_id=rekord.pk, dyscyplina=dyscyplina, + uczelnia=jednostka.uczelnia, pkd=50, slot=20, autorzy_z_dyscypliny=[ From 2032a31e787de68c14f76637decc0c546e360c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 11:13:11 +0200 Subject: [PATCH 086/247] =?UTF-8?q?docs(multi-hosted):=20HANDOFF=20-=20w?= =?UTF-8?q?=C4=85tek=203=20(sloty=20write-side)=20done=20+=20self-review;?= =?UTF-8?q?=20read-side=20jako=20nast=C4=99pny;=20roadmapa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/superpowers/HANDOFF-multi-hosted.md | 210 +++++++++++++++-------- 1 file changed, 136 insertions(+), 74 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index c1ffada7c..82238bacc 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -1,18 +1,20 @@ -# HANDOFF — praca multi-hosted (stan na 2026-06-02) +# HANDOFF — praca multi-hosted (stan na 2026-06-03) Notatka do wznowienia po reset/compact sesji. Gałąź: `feature/multi-hosted-config`. -Wszystko zacommitowane i **wypushowane** (origin na `a834691fd`). +Stan git: lokalny HEAD `c2bfb2c60`; origin = `18d0cd1d1` (1 commit lokalny +niewypushowany — fix migracji 0425, patrz niżej). Kontekst projektu: BPP (Django), instalacja **wielouczelniana** — jedna instancja obsługuje wiele obiektów `Uczelnia`, każda z własną konfiguracją (PBN tokeny, flagi wyświetlania itd.). Cel całości: żaden runtime nie „zgaduje" uczelni -(`get_default()` = pierwsza-z-brzegu), tylko używa właściwej. +(`get_default()` = pierwsza-z-brzegu), tylko używa właściwej; a liczby +(sloty/punktacja/raporty) są liczone i pokazywane **per uczelnia**. --- -## CO ZROBIONE (2 duże wątki, oba domknięte) +## CO ZROBIONE (3 duże wątki) -### Wątek 1: rozbicie `PBNClient` na dwie warstwy (Wariant B) +### Wątek 1: rozbicie `PBNClient` na dwie warstwy (Wariant B) — KOMPLETNY Spec: `docs/superpowers/specs/2026-06-02-pbn-client-split-design.md` - **`src/pbn_client/`** — czysta, reusable warstwa protokołu PBN (transport, @@ -21,90 +23,150 @@ Spec: `docs/superpowers/specs/2026-06-02-pbn-client-split-design.md` - **`BppPBNClient`** w `pbn_api/client/__init__.py` — dziedziczy po `PBNClient`, dokłada orchestrację (`publication_sync`, `disciplines`) i **zna swoją `Uczelnia`** (`__init__(transport, uczelnia)`). `get_default()` zniknął z - `publication_sync` i z głównej ścieżki adaptera (orchestracja przekazuje - `uczelnia=self.uczelnia`). + `publication_sync` i z głównej ścieżki adaptera. - Fabryki: `Uczelnia.pbn_client()`, `PBNBaseCommand.get_client()`, fixtura - `pbn_client` → zwracają `BppPBNClient`. `pbn_api.client` to shim - re-eksportujący pełny `__all__` (kompatybilność wsteczna, 35 importów). -- `pbn_api/const.py`, `exceptions.py`, `client/transport.py`, `utils.py` → - shimy do `pbn_client`. `pbn_api/utils.py` → shim do `pbn_client/dict_utils`. -- Wariant B (NIE było osobnego pakietu `pbn_client_bpp`): orchestracja i - adaptery **zostają w `pbn_api`**. Dlaczego: ekstrakcja `pbn_api` i tak - zablokowana przez sklejenie modeli z `bpp` — patrz audyt. -- Test multi-hosted: `src/pbn_api/tests/test_multihosted.py` (dwie uczelnie → - klient czyta flagi ze SWOJEJ). + `pbn_client`. `pbn_api.client` to shim (kompatybilność wsteczna, 35 importów). +- Test: `src/pbn_api/tests/test_multihosted.py`. ### Wątek 2: cleanup `get_default` (Fazy 1–9) — KOMPLETNY Plan: `docs/superpowers/plans/2026-06-02-get-default-cleanup.md` Audyt + status: `docs/deweloper/audyt-multihosted-pbn.md` -Reguła binarna (ustalona z userem): runtime z dostępną uczelnią → **jawna -uczelnia** (`get_for_request` / argument / FK / `self.uczelnia`); reszta -akceptowalna → **`Uczelnia.objects.get()`** (single-or-fail; NIE nowa metoda). - -- Fazy 1–8 przepięły runtime na jawną uczelnię; fallbacki single-install na - `.get()`. **Rygor per-miejsce** (na życzenie usera) wyłapał 3 miejsca, gdzie - mechaniczne `.get()` było złe → cofnięte do None-tolerant/lazy: - `adapters/wydawnictwo.py` (test-only fallback), `command_helpers.py` - (clean `CommandError`), `oblicz_metryki.py` (lazy uczelnia w gałęzi liczby-N). -- **Faza 9 = guard:** `src/bpp/tests/test_multihosted_get_default_guard.py` — - zamraża whitelistę 15 świadomych miejsc; nowy `get_default` w runtime → - fail CI. (Whitelista + uzasadnienia w tym teście i w audycie.) -- Weryfikacja: **1302 passed, 2 skipped** na dotkniętym obszarze. +Reguła binarna: runtime z dostępną uczelnią → **jawna uczelnia** +(`get_for_request` / argument / FK / `self.uczelnia`); reszta akceptowalna → +`Uczelnia.objects.get()` (single-or-fail). +- **Faza 9 = guard:** `src/bpp/tests/test_multihosted_get_default_guard.py` + zamraża whitelistę świadomych miejsc; nowy `get_default` w runtime → fail CI. + +### Wątek 3: per-uczelnia liczenie slotów/punktacji — WRITE-SIDE KOMPLETNY +Spec: `docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md` +Plan: `docs/superpowers/plans/2026-06-02-per-uczelnia-sloty.md` (Taski 1–9 ✓) +Self-review: `docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md` + +**Reguła wiodąca (decyzja usera):** *true per-university partition* — dla pracy +współautorskiej między uczelniami dzielnik `k` (autorzy z dyscypliny) i `m` +(wszyscy autorzy) zawężony do autorów DANEJ uczelni. Zmienia liczby dla prac +cross-uczelnia (caveat regulacyjny MEiN odnotowany w spec). Invariant: przy +DOKŁADNIE jednej uczelni liczby identyczne jak dawniej. + +Zrobione (commity `9848dca8f`…`d961b2cb4` + `c74e4aba5` + `c2bfb2c60`): +- `Cache_Punktacja_Dyscypliny` ma FK `uczelnia` (nullable) + serialize + indeks. +- `SlotMixin._autorzy_qs()` filtruje autorów po `jednostka__uczelnia`; `wszyscy`, + `autorzy_z_dyscypliny`, `dyscypliny`, `liczba_k`, `k_przez_m` przez ten szew. +- `ISlot(original, uczelnia=None)` + `_rozstrzygnij_uczelnie` (single→ta jedna; + 1 uczelnia rekordu→ona; >1→`CannotAdapt`) + `_dopasuj_kalkulator` (selekcja + progu, uczelnia-niezależna). Usunięty wewnętrzny `get_default`. +- `IPunktacjaCacher(original)` (bez param. uczelni): `removeEntries` kasuje cały + rekord; `rebuildEntries` pętli po `_uczelnie_do_przeliczenia` (fast-track + `count()==1`) z `ISlot(..., uczelnia=U)`; `_zapisz` taguje wiersze uczelnią; + `serialize` deterministyczny. +- `uczelnie_rekordu()` na `ModelZPrzeliczaniemDyscyplin`; + `przelicz_punkty_dyscyplin()` bez parametru, bez `get_default`. +- **Migracja 0425 (fix `c2bfb2c60`):** `RunPython backfill` — 1 uczelnia → wpisz + jej ID w legacy `Cache_Punktacja_Dyscypliny.uczelnia_id IS NULL`; >1 z NULL-ami + → `raise` (głośny fail, dzielnik per-uczelnia nie do zgadnięcia); świeża baza → + no-op. Widok `bpp_cache_punktacja_autora_view` = **strict** join po uczelni + (bez `IS NULL`; band-aid usunięty). Fixture `raport_slotow` ustawia uczelnię. + +**Self-review (3 niezależne źródła):** partycja dzielnika szczelna; invariant +single-install trzyma (load-bearing: `Jednostka.uczelnia` jest NOT NULL → filtr +no-op przy jednej uczelni); brak bugów CRITICAL/HIGH. Backlog hardeningu niżej. --- -## CO ZOSTAŁO (PARKED — następne wątki) - -### A) NASTĘPNY: per-uczelnia liczenie slotów/punktacji ← brainstorm tutaj -Parked TODO w: `bpp/models/sloty/core.py` (`ISlot`), -`bpp/models/abstract/disciplines.py` (`przelicz_punkty_dyscyplin`). - -**Ustalenia domenowe (od usera, KLUCZOWE):** -- Rekord NIE ma deterministycznej uczelni — autorzy mogą być z różnych uczelni - (autor → afiliacja na jednostkę → jednostka ma uczelnię). Praca z autorami - z 10 uczelni → sloty trzeba policzyć i zapisać **osobno per uczelnia**. -- Matematyka slotów zależy od **ROKU**, nie uczelni. Z uczelni `ISlot` czyta - TYLKO `ukryte_statusy("sloty")` (rzadki filtr: „nie licz dla statusu X"). -- **Pomysł usera (tani rdzeń):** `Cache_Punktacja_Autora` jest JUŻ per-autor → - dorzucić `uczelnia_id` (= uczelnia jednostki autora) = otagowanie istniejących - wierszy, nie sztuczne mnożenie. - -**Co spec musi rozstrzygnąć (głębia):** -1. `Cache_Punktacja_Dyscypliny` to agregat per (rekord, dyscyplina) → rozbić - per (rekord, uczelnia, dyscyplina); liczenie iteruje uczelnie rekordu. -2. `ukryte_statusy` per uczelnia — `ISlot` biegnie per uczelnia z JEJ statusami - (rekord policzony dla Y, pominięty dla X). -3. Migracja + backfill (`uczelnia_id` z jednostki autora; single-install → - jedna uczelnia, zero zmiany zachowania). -4. ODCZYTY: raporty, rankingi, „liczba N", `ewaluacja_optymalizacja`, metryki, - API — filtrować po uczelni oglądającego (`get_for_request`). Duży, jednorodny - zbiór. -5. Invalidacja przy zmianie afiliacji autora; indeksy/objętość. - -### B) Integrator per-uczelnia (parked TODO) -`pbn_integrator/utils/scientists.py` (matcher), `importer/authors.py` (×5 -porównań afiliacji), `management/commands/pbn_integrator.py` (`_handle_people`). -Porównania z „naszą" uczelnią (`objects.default.pbn_uid_id`) — wymaga przekazania -uczelni docelowej przez pipeline integratora (deeper). `objects.default` zostaje -świadomie (cached; `.get()` byłby perf-regresją w pętlach + crash na >1). - -### C) Drobne -- Pełne usunięcie fallbacku `get_default` z `adapters/wydawnictwo.py` — wymaga - migracji testów adaptera (konstruują bez uczelni) + naprawy `pbn_wyslij` - (pre-existing `C901`). Niski priorytet (runtime przekazuje jawną uczelnię). +## CO ZOSTAŁO + +### A) NASTĘPNY DUŻY WĄTEK: read-side (filtrowanie odczytów po uczelni) ← spec tutaj +Po write-side cache trzyma wiersze per (rekord, uczelnia). Dopóki **odczyty** nie +filtrują po uczelni oglądającego, multi-install liczyłby międzyuczelniano. +**Single-install bezpieczny** (jeden komplet wierszy), więc read-side to osobny spec. +Kontrakt: filtrować po uczelni oglądającego (`get_for_request`) — `Cache_Punktacja_Autora` +po `jednostka__uczelnia`, `Cache_Punktacja_Dyscypliny` po `uczelnia`. + +Zinwentaryzowani konsumenci (z adnotacjami usera w spec) — STATUSY DO ROZSTRZYGNIĘCIA +w Kroku „discovery" przed spec: +1. **raport_slotow** (`core.py`, `tables.py`, `filters.py`, `views/autor.py`, + `models/uczelnia.py`) — ISTOTNE, główny konsument widoku + tabel + `_Sum`/`_Sum_Group`. Czysty filtr per-uczelnia. **Priorytet.** +2. Widok `Cache_Punktacja_Autora_Query_View` → rozszerzyć o `uczelnia` + (z `bpp_jednostka.uczelnia_id`); pipeline temp-tabel (`bpp_temporary_cpaq*`, + `bpp_temporary_cpasg*`) musi nieść uczelnię. +3. **ewaluacja_metryki** — `views/{detail,list}.py` read-only (prosty filtr); + `views/pin_unpin.py` to write-path (już OK, rebuild liczy wszystkie uczelnie). +4. **oswiadczenia**, **ewaluacja_common** (`utils.py`), **bpp/core.py**, + `management/commands/zbieraj_sloty.py` — read-only; status priorytetu „do ustalenia". +5. **ewaluacja2021 / raporty 3N** — STATUS NIEJASNY: web URL-e wyłączone, ale + żywe mgmt-commands (`raport_3n_*`, `przelicz_liczbe_n_dla_uczelni`, + `odepnij_dyscypliny`) + import `const` w `ewaluacja_common`. Decyzja + „używać/naprawiać?" do podjęcia (najpierw potwierdzić użycie 3N). +6. „Liczba N", rankingi, API — konsumują cache pośrednio (`Rekord`/serializery); + zweryfikować czy filtrują, czy idą przez widok. + +### B) ODŁOŻONE (trudniejsze): federacja optymalizacji +**ewaluacja_optymalizacja** (`core/*`, `tasks/unpinning/*`, `utils.py`, `views/*`) +i **ewaluacja_optymalizuj_publikacje** (`views.py`). Instalacja wielouczelniana to +**federacja** — optymalizacja (dobór przypięć/dyscyplin) musi maksymalizować wynik +w obrębie CAŁEJ federacji, nie pojedynczej uczelni. To NIE prosty filtr per-uczelnia +— inny problem optymalizacyjny ponad partycjonowanym cache. Write-path (rebuild po +zmianie) już poprawny; logika decyzyjna federacyjna odłożona. Osobny, późniejszy spec. + +### C) Backlog hardeningu z self-review (MEDIUM/LOW — wpiąć w read-side/federację) +1. **[MEDIUM] `_dopasuj_kalkulator` liczy `wiele_hst`/próg globalnie** (wszystkie + uczelnie), kalkulator używany per-uczelnia → rekord cross-uczelnia mieszający + HST/nie-HST ponad granicą uczelni dziedziczy globalne `wiele_hst`. Spec to + akceptuje, brak testu. Dodać test graniczny + jawną decyzję domenową. +2. **[MEDIUM] brak indeksu** `(rekord_id, uczelnia, dyscyplina)` na + `Cache_Punktacja_Dyscypliny` (jest tylko `(uczelnia, dyscyplina)`) — pod join + widoku i jako naturalny klucz. Dorzucić w nowej migracji. +3. **[LOW] asymetria `skupia_pracownikow`** (pre-existing): `_zapisz` filtruje, + `autorzy_z_dyscypliny` (→ `k`, → lista w CPD) nie → CPD może listować PK autora + bez wiersza CPA. Istotne dla read-side (zaskoczenie konsumenta) — udokumentować/zrównać. +4. **[LOW/design] mutacja `kalk.uczelnia` po konstrukcji** w `ISlot` — bezpieczna + tylko dzięki świeżej instancji. Rozważyć `uczelnia` jako wymagany arg konstruktora. +5. **NOT NULL na `Cache_Punktacja_Dyscypliny.uczelnia`** — niemożliwe dopóki + fixtures tworzą NULL w runtime; rozważyć po uporządkowaniu fixtures (read-side). + +### D) Integrator per-uczelnia (parked) +`pbn_integrator/utils/scientists.py` (matcher), `importer/authors.py` (×5 porównań +afiliacji), `management/commands/pbn_integrator.py`. Porównania z „naszą" uczelnią +(`objects.default.pbn_uid_id`) — wymaga przekazania uczelni docelowej przez pipeline. +`objects.default` zostaje świadomie (perf w pętlach). + +### E) Drobne +- Usunięcie fallbacku `get_default` z `adapters/wydawnictwo.py` — wymaga migracji + testów adaptera + naprawy `pbn_wyslij` (pre-existing `C901`). Niski priorytet. + +### F) Operacyjne (deploy write-side) +- Single-install: migracja 0425 sama wpisze ID domyślnej uczelni w legacy cache; + pełny denorm rebuild odświeży liczby (identyczne). Multi-install z danymi: + migracja 0425 **failuje** dopóki cache nie zostanie przeliczony per-uczelnia. --- -## STAN GIT -- Gałąź `feature/multi-hosted-config`, origin = `a834691fd` (wypushowane). -- Główne commity (od najnowszych): plan/audyt docs, Faza 9 guard, Fazy 8→1 - get_default, Faza 2+3 ImportManager, importer_publikacji `ImportSession.uczelnia`. +## ROADMAPA (rekomendowana kolejność — ustalona z userem 2026-06-03) + +Zatwierdzony wariant: **A (Verify → Stabilize → Investigate → Spec)**. + +1. ✅ **Self-review write-side** (zrobione) — + fix migracji 0425. +2. ✅ **Aktualizacja HANDOFF + roadmapa** (ten dokument). +3. ⏭ **Read-side discovery** — rozstrzygnąć statusy „do ustalenia" FAKTAMI z kodu + (ewaluacja2021/3N czy żywe; liczba N/rankingi/API — bezpośrednio cache czy przez + `Rekord`; realny kształt oswiadczenia/ewaluacja_common/bpp core). Output: + zredukowana lista decyzji. +4. ⏭ **Brainstorm → spec read-side → plan** (`writing-plans`). Federacja (B) NIE + wchodzi — osobny, późniejszy spec. +5. (później) Federacja optymalizacji; integrator (D); drobne (E). + +Backlog hardeningu (C) wpinać oportunistycznie w Krok 4 (te dotykające read-side: +indeks #2, asymetria #3) i federację (#1 HST). + +--- ## KOMENDY (dla agenta) - Testy: `uv run pytest <ścieżka> -q -p no:cacheprovider` (testcontainers same stawiają PG/Redis; Docker musi działać). - Guard: `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q`. +- Sloty (invariant): `uv run pytest src/bpp/tests/test_models/test_sloty/ src/raport_slotow/ -q -p no:cacheprovider`. - Lint: `uv run ruff check ` (NIE `--fix`; per CLAUDE.md fix ręcznie). -- `uv run python src/manage.py check` (z `PYTEST_TESTCONTAINERS_DISABLE=1 - DJANGO_BPP_SKIP_DOTENV=1` gdy brak dev-bazy). +- `uv run python src/manage.py makemigrations --check --dry-run` (z + `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1` gdy brak dev-bazy). From 77b7e5d00766a3502a1b522234910ccf2a0e85d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 11:35:28 +0200 Subject: [PATCH 087/247] =?UTF-8?q?docs(multi-hosted):=20read-side=20disco?= =?UTF-8?q?very=20-=20ewaluacja2021=20wygaszana=20(OUT),=20liczba=5Fn=20w?= =?UTF-8?q?=C4=85tek=20G=20(write+read),=203=20specy=20read-side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/superpowers/HANDOFF-multi-hosted.md | 48 ++++++++++++++++++------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index 82238bacc..2c426f765 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -99,8 +99,10 @@ w Kroku „discovery" przed spec: żywe mgmt-commands (`raport_3n_*`, `przelicz_liczbe_n_dla_uczelni`, `odepnij_dyscypliny`) + import `const` w `ewaluacja_common`. Decyzja „używać/naprawiać?" do podjęcia (najpierw potwierdzić użycie 3N). -6. „Liczba N", rankingi, API — konsumują cache pośrednio (`Rekord`/serializery); - zweryfikować czy filtrują, czy idą przez widok. +6. API (`api_v1/.../raport_slotow_uczelnia` viewset+serializer) NIE czyta + `Cache_Punktacja` wprost — jedzie na `raport_slotow` (model `RaportSlotowUczelnia`), + więc domyka się razem z #1. Rankingi — nie czytają cache (idą przez `Rekord`), + poza zakresem. „Liczba N" — patrz osobny wątek G (to NIE prosty filtr cache). ### B) ODŁOŻONE (trudniejsze): federacja optymalizacji **ewaluacja_optymalizacja** (`core/*`, `tasks/unpinning/*`, `utils.py`, `views/*`) @@ -126,6 +128,23 @@ zmianie) już poprawny; logika decyzyjna federacyjna odłożona. Osobny, późni 5. **NOT NULL na `Cache_Punktacja_Dyscypliny.uczelnia`** — niemożliwe dopóki fixtures tworzą NULL w runtime; rozważyć po uporządkowaniu fixtures (read-side). +### G) ewaluacja_liczba_n per-uczelnia (WRITE+READ — osobny spec) +Discovery 2026-06-03: **częściowo już per-uczelnia**, ale z luką write. +- JUŻ OK: `LiczbaNDlaUczelni` (FK `uczelnia`, `unique_together(uczelnia, + dyscyplina)`), `DyscyplinaNieRaportowana` (FK `uczelnia`), widoki + (`views/index.py` przez `get_for_request`), komenda `przelicz_n` + (`.get(pk)`/`.get()` single-or-fail), `excel_export` (filtr po uczelni). +- **LUKA (schemat/write):** `IloscUdzialowDlaAutoraZaRok` + (`unique_together(autor, dyscyplina, rok)`) i `IloscUdzialowDlaAutoraZaCalosc` + (`(autor, dyscyplina, rodzaj_autora)`) NIE mają `uczelnia` → w multi-install + autor afiliowany do >1 uczelni nie ma rozłącznych udziałów per uczelnia + (kolizja unique_together); liczenie `oblicz_liczby_n_*`/`oblicz_srednia_*` + musi zawężać autorów do uczelni. +- Zakres spec: dodać `uczelnia` FK do `IloscUdzialow*` (+ migracja + backfill + analogiczny do 0425: single → domyślna, multi z danymi → fail), poprawić + unique_together, zawęzić liczenie udziałów per uczelnia. To write+read, + bliżej write-side slotów niż filtrów odczytu. + ### D) Integrator per-uczelnia (parked) `pbn_integrator/utils/scientists.py` (matcher), `importer/authors.py` (×5 porównań afiliacji), `management/commands/pbn_integrator.py`. Porównania z „naszą" uczelnią @@ -149,16 +168,21 @@ Zatwierdzony wariant: **A (Verify → Stabilize → Investigate → Spec)**. 1. ✅ **Self-review write-side** (zrobione) — + fix migracji 0425. 2. ✅ **Aktualizacja HANDOFF + roadmapa** (ten dokument). -3. ⏭ **Read-side discovery** — rozstrzygnąć statusy „do ustalenia" FAKTAMI z kodu - (ewaluacja2021/3N czy żywe; liczba N/rankingi/API — bezpośrednio cache czy przez - `Rekord`; realny kształt oswiadczenia/ewaluacja_common/bpp core). Output: - zredukowana lista decyzji. -4. ⏭ **Brainstorm → spec read-side → plan** (`writing-plans`). Federacja (B) NIE - wchodzi — osobny, późniejszy spec. -5. (później) Federacja optymalizacji; integrator (D); drobne (E). - -Backlog hardeningu (C) wpinać oportunistycznie w Krok 4 (te dotykające read-side: -indeks #2, asymetria #3) i federację (#1 HST). +3. ✅ **Read-side discovery** (zrobione 2026-06-03) — ustalenia: ewaluacja2021 + WYGASZANA (husk, OUT); rankingi/API nie czytają cache wprost (API przez + raport_slotow); filtr czytania jednolity `jednostka__uczelnia`; + ewaluacja_liczba_n częściowo per-uczelnia z luką write (wątek G). +4. ⏭ **Specy read-side — TRZY niezależne** (każdy: brainstorm→spec→plan): + - **R1 — slot read-side (A):** widok eksponuje uczelnię + pipeline temp-tabel + niesie uczelnię + raport_slotow/API/proste filtry (metryki/oswiadczenia/ + common/bpp-core) filtrują po uczelni oglądającego. Hardening #2 (indeks), + #3 (asymetria) wpiąć tu. + - **R2 — ewaluacja_liczba_n per-uczelnia (G):** write+read, schemat + `IloscUdzialow*` + zawężenie liczenia. + - **F — federacja optymalizacji (B):** najtrudniejszy, ostatni. +5. (później) Integrator (D); drobne (E); NOT NULL na uczelnia (#5). + +Backlog hardeningu (C): #2/#3 → R1; #1 (HST globalnie) → F (federacja). --- From 8dbde71d3e3e03adba7c8f059a06b78e147beb2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 11:50:54 +0200 Subject: [PATCH 088/247] docs(multi-hosted): spec R1 - slot read-side (widok+uczelnia, hybryda, raport_slotow/API/filtry) Co-Authored-By: Claude Opus 4.8 --- ...-03-per-uczelnia-sloty-read-side-design.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-per-uczelnia-sloty-read-side-design.md diff --git a/docs/superpowers/specs/2026-06-03-per-uczelnia-sloty-read-side-design.md b/docs/superpowers/specs/2026-06-03-per-uczelnia-sloty-read-side-design.md new file mode 100644 index 000000000..87c0dc53f --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-per-uczelnia-sloty-read-side-design.md @@ -0,0 +1,165 @@ +# Design — per-uczelnia sloty: READ-SIDE (R1) + +Data: 2026-06-03 +Gałąź: `feature/multi-hosted-config` +Poprzednik: write-side (`2026-06-02-per-uczelnia-sloty-design.md`) — KOMPLETNY. +Discovery: `HANDOFF-multi-hosted.md` §A; self-review write-side: +`docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md`. + +## Cel i zakres + +Write-side zapisuje cache slotów per (rekord, uczelnia). **Dopóki ODCZYTY nie +filtrują po uczelni oglądającego, instalacja wielouczelniana liczy/pokazuje +międzyuczelniano.** R1 wprowadza filtrowanie odczytów slotowych po uczelni. + +**W zakresie R1:** +- kolumna `uczelnia` w widoku `bpp_cache_punktacja_autora_view` + pole na modelu, +- helper rozstrzygający uczelnię oglądającego (hybryda: site + override superusera), +- `raport_slotow`: `RaportSlotowUczelnia` (FK uczelnia + generacja zawężona), + `RaportSlotowAutor`, raport „zerowy", pipeline temp-tabel, +- proste filtry read-only: `ewaluacja_metryki` (`utils.py`, `views/detail.py`, + `views/list.py`), `oswiadczenia/views.py`, `ewaluacja_common/utils.py`, + `bpp/core.py` (`zbieraj_sloty`), komenda `zbieraj_sloty`, +- API `api_v1/.../raport_slotow_uczelnia` (jedzie na `RaportSlotowUczelnia`), +- hardening z self-review: indeks `(rekord_id, uczelnia, dyscyplina)` (#2), + udokumentowanie asymetrii `skupia_pracownikow` (#3). + +**Poza zakresem R1 (osobne wątki):** +- **R2 — `ewaluacja_liczba_n` per-uczelnia** (write+read, schemat `IloscUdzialow*`), +- **Federacja optymalizacji** (`ewaluacja_optymalizacja`, + `ewaluacja_optymalizuj_publikacje`) — **świadomie ODŁOŻONA, nie teraz**, +- integrator (handoff §D), drobne (§E), NOT NULL na `Cache_Punktacja_Dyscypliny.uczelnia` (#5). + +**OUT (discovery 2026-06-03):** `ewaluacja2021` (apka WYGASZANA — husk; brak URL-i, +brak komend, odsprzęgnięta); rankingi (nie czytają cache); `ewaluacja_liczba_n` +NIE czyta `Cache_Punktacja` (ma własny wymiar — R2). + +## Decyzje (od usera) + +1. **Źródło „uczelni oglądającego" — HYBRYDA.** Domyślnie `get_for_request(request)` + (każda uczelnia = osobny site/domena, user widzi swoją). Superuser może + nadpisać jawnym wyborem uczelni (param). Reszta runtime woła jeden helper. +2. **Filtr na poziomie widoku — KOLUMNA `uczelnia` w widoku** (nie rozproszony + `jednostka__uczelnia`). Czyste, wydajne (indeks), jednolite z + `Cache_Punktacja_Dyscypliny.uczelnia`. + +## Invariant zgodności + +Przy DOKŁADNIE jednej uczelni filtr `uczelnia=U` / `jednostka__uczelnia=U` jest +no-opem (wszystkie jednostki wskazują tę jedną uczelnię — `Jednostka.uczelnia` +NOT NULL). Raporty, eksporty, „autorzy zerowi", API → identyczne jak dziś. + +## Komponenty + +### 1. Widok `Cache_Punktacja_Autora_Query_View` + kolumna `uczelnia` +Stan: widok (migracja 0425, strict join po uczelni) NIE eksponuje `uczelnia`; +model `managed=False` też nie ma pola. +- Nowa migracja DROP+CREATE widoku: dodać `j.uczelnia_id` do SELECT (join `j` + do `bpp_jednostka` już jest). +- Model `Cache_Punktacja_Autora_Query_View`: dodać + `uczelnia = ForeignKey("bpp.Uczelnia", DO_NOTHING)`. +- Dzięki temu konsumenci filtrują `.filter(uczelnia=U)` bez joinu. +- `reverse_sql` przywraca wariant bez kolumny (z 0425). +> Reguła projektu: NOWY plik migracji (nie edytuj istniejących). 0425 zostaje +> jak jest (strict join + backfill). + +### 2. Helper rozstrzygania uczelni oglądającego +Nowy, jedno miejsce (np. `bpp/multidyscyplinarnosc`/`uczelnia` utils albo +`raport_slotow`/wspólny): `uczelnia_dla_odczytu(request)`: +- bazowo `Uczelnia.objects.get_for_request(request)`, +- jeśli `request.user.is_superuser` i podano jawny param (`?uczelnia=`/pole + formularza) → ta uczelnia, +- zwraca obiekt `Uczelnia` (lub `None` → zachowanie jak dziś tylko w single-edge). +Wszyscy konsumenci read-side wołają ten helper — hybryda w jednym punkcie. + +### 3. `raport_slotow` (główny konsument) +- **`RaportSlotowUczelnia`** (`models/uczelnia.py`, `long_running.Report`, + generacja w tle BEZ requestu): dodać FK `uczelnia` (nullable; migracja + + backfill single→domyślna, multi→stare raporty to artefakty historyczne, zostają; + nowe ZAWSZE ustawiają uczelnię z helpera przy zamówieniu). Generacja (pipeline + temp-tabel) filtruje feeding-queryset po `self.uczelnia`. +- **`zbieraj_sloty`** (`bpp/core.py`, czyta `Cache_Punktacja_Autora_Query`): + dodać `uczelnia_id=None` → `filter(jednostka__uczelnia_id=uczelnia_id)` gdy podane. + Wołane przez generację raportu (z `self.uczelnia`) i przez komendę + `zbieraj_sloty` (uczelnia z argumentu / `.get()` single-or-fail). +- **Raport „zerowy"** (`views/zerowy.py` → `create_temporary_table_as(...)`, + `core.autorzy_zerowi`/`autorzy_z_punktami` z `Cache_Punktacja_Autora_Query_View`): + queryset źródłowy filtruje `uczelnia=U`. Temp-tabele są session-scoped + (`CREATE TEMPORARY`), brak kolizji między uczelniami — wystarczy zawęzić źródło. +- **`RaportSlotowAutor`** (`views/autor.py`, czyta `..._Query_View`): filtr + `uczelnia = uczelnia_dla_odczytu(request)`. +- **`filters.py`/`tables.py`** (`Cache_Punktacja_Autora_Sum_Group`): zapełniane + z zawężonego źródła; filtr w pipeline, nie w tabeli prezentacyjnej. + +### 4. Proste filtry read-only +Dodać `.filter(uczelnia=U)` / `.filter(jednostka__uczelnia=U)` (`U` z helpera; +komenda → argument/`.get()`): +- `ewaluacja_metryki`: `utils.py`, `views/detail.py`, `views/list.py`, +- `oswiadczenia/views.py`, +- `ewaluacja_common/utils.py`, +- `bpp/core.py` (przez `zbieraj_sloty`), `bpp/management/commands/zbieraj_sloty.py`. + +### 5. API +`api_v1/.../raport_slotow_uczelnia` (viewset+serializer) — opiera się na +`RaportSlotowUczelnia`; po pkt 3 dziedziczy uczelnię z Report. Sprawdzić, czy +queryset viewsetu sam nie listuje cudzych raportów → ograniczyć do uczelni +żądającego (hybryda). + +## Data flow + +request → `uczelnia_dla_odczytu(request)` (site lub override superusera) → +filtr `uczelnia=U` na widoku/Query → (raport) zawężony queryset feeduje +session-scoped temp-tabele → tabela django_tables2 / eksport XLSX/PDF / API. +Komendy/tło: uczelnia z FK Report-u lub argumentu, nie z requestu. + +## Migracje +- M1 (bpp): DROP+CREATE widoku z kolumną `uczelnia_id` + model field. Nowy plik. +- M2 (bpp): indeks `(rekord_id, uczelnia, dyscyplina)` na + `Cache_Punktacja_Dyscypliny` (hardening #2). +- M3 (raport_slotow): FK `uczelnia` na `RaportSlotowUczelnia` (nullable) + + RunPython backfill (single→domyślna; multi→stare raporty zostają null). +- Reguła: tylko nowe pliki; istniejących nie edytujemy. + +## Testy +- Invariant single-install: raport uczelnia/autor/zerowy + eksport → liczby + identyczne z obecnymi (fixture jednouczelniany; ochrona regresji). +- Multi-install: 2 uczelnie, rekord współautorski → raport uczelni A widzi tylko + autorów A (i ich sloty z partycji A), raport B tylko B; brak przecieku. +- Widok: kolumna `uczelnia` = `jednostka.uczelnia` dla każdego wiersza. +- Helper: zwykły user → site; superuser + param → wybrana; non-super + param → + ignorowany (bezpieczeństwo, brak podglądu cudzej uczelni). +- Raport „zerowy" multi-install: autorzy zerowi liczeni w obrębie uczelni. +- Komenda `zbieraj_sloty` bez uczelni w single → OK; w multi bez argumentu → + jawny błąd (`.get()` single-or-fail). +- API: user uczelni A nie listuje raportów uczelni B. + +## Hardening wpięty (z self-review) +- **#2** indeks `(rekord_id, uczelnia, dyscyplina)` (M2). +- **#3** asymetria `skupia_pracownikow` (`_zapisz` filtruje, `autorzy_z_dyscypliny` + nie) — udokumentować w kodzie/komentarzu przy konsumentach widoku, by czytający + wiedział, że CPD `autorzy_z_dyscypliny` może zawierać PK bez wiersza CPA. + +## Co jeszcze do PRAWDZIWEJ wielouczelnianości (po R1) +Lista świadomie utrzymywana — stan „ile zostało do pełnej multi-uczelnianości": +1. **R2 — `ewaluacja_liczba_n` per-uczelnia (write+read).** Modele udziałów autora + `IloscUdzialowDlaAutoraZaRok`/`...ZaCalosc` NIE mają `uczelnia` → dodać FK + + migracja/backfill + poprawić `unique_together` + zawęzić liczenie udziałów + per uczelnia. (`LiczbaNDlaUczelni`, `DyscyplinaNieRaportowana`, widoki, + komenda `przelicz_n` — już per-uczelnia.) +2. **Federacja optymalizacji — ODŁOŻONA (nie teraz, decyzja usera).** + `ewaluacja_optymalizacja` (`core/*`, `tasks/unpinning/*`, `views/*`) + + `ewaluacja_optymalizuj_publikacje` muszą maksymalizować wynik w obrębie CAŁEJ + federacji uczelni, nie pojedynczej — inny problem niż filtr per-uczelnia. +3. **Hardening #1 — `_dopasuj_kalkulator` liczy `wiele_hst`/próg globalnie** + (wszystkie uczelnie), kalkulator używany per-uczelnia → rekord cross-uczelnia + mieszający HST/nie-HST dziedziczy globalne `wiele_hst`. Test + decyzja w F. +4. **NOT NULL na `Cache_Punktacja_Dyscypliny.uczelnia`** (#5) — po uporządkowaniu + fixtures tworzących NULL w runtime. +5. **Integrator per-uczelnia** (handoff §D): matcher PBN/afiliacje na `objects.default`. +6. **Drobne** (handoff §E): usunięcie `get_default` z `adapters/wydawnictwo.py`. + +## Komendy weryfikacji +- Testy: `uv run pytest src/raport_slotow/ src/ewaluacja_metryki/ src/oswiadczenia/ src/bpp/tests/test_models/test_sloty/ -q -p no:cacheprovider`. +- `uv run python src/manage.py makemigrations --check --dry-run` + (z `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1`). +- Lint: `uv run ruff check ` (NIE `--fix`). From cf2735cc9fe51fc571e501235e4b8ba91bdaf080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 12:46:07 +0200 Subject: [PATCH 089/247] docs(multi-hosted): plan implementacyjny R1 (slot read-side) - 10 taskow TDD Co-Authored-By: Claude Opus 4.8 --- ...6-06-03-per-uczelnia-sloty-read-side-R1.md | 770 ++++++++++++++++++ 1 file changed, 770 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-per-uczelnia-sloty-read-side-R1.md diff --git a/docs/superpowers/plans/2026-06-03-per-uczelnia-sloty-read-side-R1.md b/docs/superpowers/plans/2026-06-03-per-uczelnia-sloty-read-side-R1.md new file mode 100644 index 000000000..4b47b0faf --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-per-uczelnia-sloty-read-side-R1.md @@ -0,0 +1,770 @@ +# Per-uczelnia sloty READ-SIDE (R1) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Odczyty slotowe (raporty/eksporty/API) filtrują dane po uczelni +oglądającego, zachowując identyczne liczby w instalacji jednouczelnianej. + +**Architecture:** Widok `bpp_cache_punktacja_autora_view` eksponuje `uczelnia_id` +(z jednostki). Jeden helper `uczelnia_dla_odczytu(request)` rozstrzyga uczelnię +(hybryda: site + override superusera). Cross-autorowe agregatory (`create_report`, +`autorzy_zerowi`, `zbieraj_sloty`, `RaportSlotowAutor`, `oswiadczenia`) zawężają +queryset po uczelni; `RaportSlotowUczelnia` zyskuje FK `uczelnia`. Konsumenci już +zawężeni po `autor_id`+`dyscyplina_id` (część `ewaluacja_metryki`) — bez zmian, +udokumentowane. + +**Tech Stack:** Django, PostgreSQL, pytest + model_bakery, testcontainers, django_tables2. + +**Spec:** `docs/superpowers/specs/2026-06-03-per-uczelnia-sloty-read-side-design.md` + +--- + +## Uwagi wykonawcze (przeczytaj przed startem) + +- Komenda testowa: `uv run pytest <ścieżka> -q -p no:cacheprovider` (testcontainers; + Docker musi działać). +- **NIGDY nie edytuj istniejących migracji.** Nowe pliki (0425 to wyjątek już zamknięty). +- Lint: `uv run ruff check ` (NIE `--fix` — popraw ręcznie). Max 88 znaków. +- Po każdym Tasku: testy zielone → commit. +- Invariant single-install: istniejące `src/raport_slotow/` muszą pozostać zielone. +- `get_for_request` jest w `src/bpp/models/uczelnia.py:40` (`UczelniaManager`). +- Numery migracji startowe: `bpp` → 0426/0427; `raport_slotow` → 0020. + +--- + +## Task 1: Kolumna `uczelnia` w widoku `Cache_Punktacja_Autora_Query_View` + +**Files:** +- Create: `src/bpp/migrations/0426_cache_punktacja_autora_view_uczelnia.py` +- Modify: `src/bpp/models/cache/punktacja.py` (klasa `Cache_Punktacja_Autora_Query_View`) +- Test: `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py` + +- [ ] **Step 1: Napisz failing test** + +Dopisz w `test_per_uczelnia.py`: + +```python +@pytest.mark.django_db +def test_view_eksponuje_uczelnia(zwarte_dwie_uczelnie, jednostka, druga_uczelnia): + from django.contrib.contenttypes.models import ContentType + + from bpp.models.cache import Cache_Punktacja_Autora_Query_View + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + ctype = ContentType.objects.get_for_model(zwarte_dwie_uczelnie).pk + + rows = Cache_Punktacja_Autora_Query_View.objects.filter( + rekord_id=[ctype, zwarte_dwie_uczelnie.pk] + ) + for row in rows: + assert row.uczelnia_id == row.jednostka.uczelnia_id + assert set(rows.values_list("uczelnia_id", flat=True)) == { + jednostka.uczelnia_id, + druga_uczelnia.pk, + } +``` + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py::test_view_eksponuje_uczelnia -q -p no:cacheprovider` +Expected: FAIL (`FieldError: Cannot resolve keyword 'uczelnia_id'` / brak pola). + +- [ ] **Step 3: Dodaj pole na modelu** + +W `src/bpp/models/cache/punktacja.py`, klasa `Cache_Punktacja_Autora_Query_View`, +po polu `jednostka` dodaj: + +```python + uczelnia = ForeignKey("bpp.Uczelnia", DO_NOTHING) +``` + +- [ ] **Step 4: Utwórz migrację widoku (DROP+CREATE z kolumną)** + +Utwórz `src/bpp/migrations/0426_cache_punktacja_autora_view_uczelnia.py`: + +```python +from django.db import migrations + +DROP = "DROP VIEW IF EXISTS bpp_cache_punktacja_autora_view;" + +CREATE_NEW = """ +CREATE VIEW bpp_cache_punktacja_autora_view AS +SELECT a.id, + a.rekord_id, + a.pkdaut, + a.slot, + a.autor_id, + a.dyscyplina_id, + a.jednostka_id, + j.uczelnia_id, + d.autorzy_z_dyscypliny, + d.zapisani_autorzy_z_dyscypliny +FROM bpp_cache_punktacja_autora a +JOIN bpp_jednostka j ON j.id = a.jednostka_id +JOIN bpp_cache_punktacja_dyscypliny d + ON a.rekord_id = d.rekord_id + AND a.dyscyplina_id = d.dyscyplina_id + AND d.uczelnia_id = j.uczelnia_id; +""" + +CREATE_OLD = """ +CREATE VIEW bpp_cache_punktacja_autora_view AS +SELECT a.id, + a.rekord_id, + a.pkdaut, + a.slot, + a.autor_id, + a.dyscyplina_id, + a.jednostka_id, + d.autorzy_z_dyscypliny, + d.zapisani_autorzy_z_dyscypliny +FROM bpp_cache_punktacja_autora a +JOIN bpp_jednostka j ON j.id = a.jednostka_id +JOIN bpp_cache_punktacja_dyscypliny d + ON a.rekord_id = d.rekord_id + AND a.dyscyplina_id = d.dyscyplina_id + AND d.uczelnia_id = j.uczelnia_id; +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0425_per_uczelnia_cache_view"), + ] + + operations = [ + migrations.RunSQL(sql=DROP + CREATE_NEW, reverse_sql=DROP + CREATE_OLD), + ] +``` + +(Model `managed=False`, więc pole nie generuje `AddField` — `makemigrations +--check` musi dać „No changes".) + +- [ ] **Step 5: Uruchom — ma PRZEJŚĆ + brak dryfu** + +Run: `uv run pytest src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py::test_view_eksponuje_uczelnia -q -p no:cacheprovider` +Expected: PASS. +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run` (ignoruj pre-existing dryf w siteblog/raport_slotow do_roku). +Expected: brak nowych zmian dla `bpp`. + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff check src/bpp/models/cache/punktacja.py src/bpp/migrations/0426_cache_punktacja_autora_view_uczelnia.py +git add src/bpp/models/cache/punktacja.py src/bpp/migrations/0426_cache_punktacja_autora_view_uczelnia.py src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +git commit -m "feat(multi-hosted): widok cache_punktacja_autora eksponuje uczelnia_id" +``` + +--- + +## Task 2: Indeks `(rekord_id, uczelnia, dyscyplina)` (hardening #2) + +**Files:** +- Create: `src/bpp/migrations/0427_cpd_index_rekord_uczelnia_dyscyplina.py` +- Modify: `src/bpp/models/cache/punktacja.py` (Meta.indexes `Cache_Punktacja_Dyscypliny`) + +- [ ] **Step 1: Dodaj indeks na modelu** + +W `Cache_Punktacja_Dyscypliny.Meta.indexes` dopisz drugi indeks: + +```python + indexes = [ + models.Index(fields=["uczelnia", "dyscyplina"]), + models.Index(fields=["rekord_id", "uczelnia", "dyscyplina"]), + ] +``` + +- [ ] **Step 2: Wygeneruj migrację** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations bpp -n cpd_index_rekord_uczelnia_dyscyplina` +Expected: `0427_*` z `AddIndex`. (Jeśli numer inny — dostosuj zależność w kolejnych zadaniach.) + +- [ ] **Step 3: Brak dryfu + commit** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run` +Expected: brak zmian dla `bpp`. + +```bash +uv run ruff check src/bpp/models/cache/punktacja.py +git add src/bpp/models/cache/punktacja.py src/bpp/migrations/0427_*.py +git commit -m "feat(multi-hosted): indeks (rekord_id, uczelnia, dyscyplina) na CPD" +``` + +--- + +## Task 3: Helper `uczelnia_dla_odczytu(request)` (hybryda) + +**Files:** +- Create: `src/raport_slotow/uczelnia_helper.py` +- Test: `src/raport_slotow/tests/test_uczelnia_helper.py` + +- [ ] **Step 1: Napisz failing testy** + +Utwórz `src/raport_slotow/tests/test_uczelnia_helper.py`: + +```python +import pytest +from model_bakery import baker + +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + +class _Req: + def __init__(self, user, get=None): + self.user = user + self.GET = get or {} + + +@pytest.mark.django_db +def test_zwykly_user_dostaje_uczelnie_z_requestu(uczelnia, druga_uczelnia, rf): + from bpp.models import Uczelnia + + user = baker.make("bpp.BppUser", is_superuser=False) + req = _Req(user, get={"uczelnia": str(druga_uczelnia.pk)}) + # get_for_request rozstrzyga po site; bez wielu site'ów zwróci default. + result = uczelnia_dla_odczytu(req) + assert isinstance(result, Uczelnia) + # non-superuser nie może nadpisać: + assert result != druga_uczelnia or Uczelnia.objects.count() == 1 + + +@pytest.mark.django_db +def test_superuser_moze_nadpisac(uczelnia, druga_uczelnia): + user = baker.make("bpp.BppUser", is_superuser=True) + req = _Req(user, get={"uczelnia": str(druga_uczelnia.pk)}) + assert uczelnia_dla_odczytu(req) == druga_uczelnia + + +@pytest.mark.django_db +def test_superuser_zly_param_ignorowany(uczelnia, druga_uczelnia): + user = baker.make("bpp.BppUser", is_superuser=True) + req = _Req(user, get={"uczelnia": "999999"}) + # nieistniejąca uczelnia → fallback do get_for_request + from bpp.models import Uczelnia + + assert isinstance(uczelnia_dla_odczytu(req), Uczelnia) +``` + +(Fixture `druga_uczelnia` jest w `src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py` +— przenieś go do `src/conftest.py` jeśli niedostępny w `raport_slotow/tests`, albo +zdefiniuj lokalnie analogicznie: `Uczelnia.objects.create(skrot="DR", nazwa="Druga", site=)`.) + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/raport_slotow/tests/test_uczelnia_helper.py -q -p no:cacheprovider` +Expected: FAIL (ModuleNotFoundError: uczelnia_helper). + +- [ ] **Step 3: Implementacja helpera** + +Utwórz `src/raport_slotow/uczelnia_helper.py`: + +```python +"""Rozstrzyganie 'uczelni oglądającego' dla odczytów slotowych (read-side). + +Hybryda: domyślnie uczelnia z requestu (site/domena); superuser może nadpisać +jawnym parametrem ``?uczelnia=``. +""" + +from bpp.models import Uczelnia + + +def uczelnia_dla_odczytu(request): + bazowa = Uczelnia.objects.get_for_request(request) + + user = getattr(request, "user", None) + if user is not None and user.is_authenticated and user.is_superuser: + pk = request.GET.get("uczelnia") + if pk: + try: + return Uczelnia.objects.get(pk=pk) + except (Uczelnia.DoesNotExist, ValueError, TypeError): + return bazowa + return bazowa +``` + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/raport_slotow/tests/test_uczelnia_helper.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check src/raport_slotow/uczelnia_helper.py src/raport_slotow/tests/test_uczelnia_helper.py +git add src/raport_slotow/uczelnia_helper.py src/raport_slotow/tests/test_uczelnia_helper.py +git commit -m "feat(multi-hosted): helper uczelnia_dla_odczytu (hybryda site/superuser)" +``` + +--- + +## Task 4: `zbieraj_sloty` — parametr `uczelnia_id` + +**Files:** +- Modify: `src/bpp/core.py` (`zbieraj_sloty`) +- Test: `src/bpp/tests/test_core_zbieraj_sloty_uczelnia.py` (create) + +- [ ] **Step 1: Napisz failing test** + +Utwórz `src/bpp/tests/test_core_zbieraj_sloty_uczelnia.py`: + +```python +import pytest + +from bpp.core import zbieraj_sloty + + +@pytest.mark.django_db +def test_zbieraj_sloty_zaweza_po_uczelni(zwarte_dwie_uczelnie, jednostka, druga_uczelnia): + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + autor = zwarte_dwie_uczelnie.autorzy_set.first().autor + + _pkt_all, lista_all, _slot_all = zbieraj_sloty( + autor.pk, 1, zwarte_dwie_uczelnie.rok, zwarte_dwie_uczelnie.rok, + akcja="wszystko", + ) + _pkt_u, lista_u, _slot_u = zbieraj_sloty( + autor.pk, 1, zwarte_dwie_uczelnie.rok, zwarte_dwie_uczelnie.rok, + akcja="wszystko", uczelnia_id=jednostka.uczelnia_id, + ) + # zawężenie po uczelni nie może dać więcej wpisów niż bez zawężenia + assert len(lista_u) <= len(lista_all) +``` + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/bpp/tests/test_core_zbieraj_sloty_uczelnia.py -q -p no:cacheprovider` +Expected: FAIL (`TypeError: unexpected keyword 'uczelnia_id'`). + +- [ ] **Step 3: Dodaj parametr + filtr** + +W `src/bpp/core.py`, `zbieraj_sloty` — dodaj `uczelnia_id=None` na końcu sygnatury +i filtr po `jednostka__uczelnia_id`: + +```python +def zbieraj_sloty( + autor_id, + zadany_slot, + rok_min, + rok_max, + minimalny_pk=None, + dyscyplina_id=None, + jednostka_id=None, + akcja=None, + uczelnia_id=None, +): + from bpp.models.cache import Cache_Punktacja_Autora_Query + + rekordy = Cache_Punktacja_Autora_Query.objects.filter( + rekord__rok__gte=rok_min, rekord__rok__lte=rok_max, autor_id=autor_id + ) + if uczelnia_id is not None: + rekordy = rekordy.filter(jednostka__uczelnia_id=uczelnia_id) + if dyscyplina_id is not None: + rekordy = rekordy.filter(dyscyplina_id=dyscyplina_id) +``` + +(Reszta funkcji bez zmian.) + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/bpp/tests/test_core_zbieraj_sloty_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check src/bpp/core.py src/bpp/tests/test_core_zbieraj_sloty_uczelnia.py +git add src/bpp/core.py src/bpp/tests/test_core_zbieraj_sloty_uczelnia.py +git commit -m "feat(multi-hosted): zbieraj_sloty przyjmuje uczelnia_id (zawężenie)" +``` + +--- + +## Task 5: `autorzy_z_punktami` / `autorzy_zerowi` — filtr uczelni + +**Files:** +- Modify: `src/raport_slotow/core.py` +- Test: `src/raport_slotow/tests/test_core.py` + +- [ ] **Step 1: Napisz failing test** + +Dopisz w `src/raport_slotow/tests/test_core.py`: + +```python +@pytest.mark.django_db +def test_autorzy_z_punktami_filtr_uczelni(zwarte_dwie_uczelnie, jednostka, druga_uczelnia): + from raport_slotow.core import autorzy_z_punktami + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + wszyscy = set(autorzy_z_punktami()) + tylko_u1 = set(autorzy_z_punktami(uczelnia=jednostka.uczelnia)) + assert tylko_u1 <= wszyscy + assert len(tylko_u1) <= len(wszyscy) +``` + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/raport_slotow/tests/test_core.py -k autorzy_z_punktami_filtr -q -p no:cacheprovider` +Expected: FAIL (`TypeError: unexpected keyword 'uczelnia'`). + +- [ ] **Step 3: Dodaj parametr `uczelnia`** + +W `src/raport_slotow/core.py`, `autorzy_z_punktami` (i przekaż dalej z +`autorzy_zerowi`): + +```python +def autorzy_z_punktami( + od_roku=None, do_roku=None, min_pk=None, uczelnia=None +) -> List[Tuple[int, int, int]]: + kwargs = _get_kwargs(od_roku, do_roku, prefix="rekord__") + + exclude_kwargs = dict() + if min_pk is not None: + exclude_kwargs = dict(rekord__punkty_kbn__lt=min_pk) + + qs = Cache_Punktacja_Autora_Query_View.objects.all().filter(**kwargs) + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + return qs.exclude(**exclude_kwargs).values( + "autor_id", "rekord__rok", "dyscyplina_id" + ) +``` + +I `autorzy_zerowi`: + +```python +def autorzy_zerowi(od_roku=None, do_roku=None, min_pk=None, uczelnia=None): + defined = autorzy_z_dyscyplinami(od_roku=od_roku, do_roku=do_roku) + existent = autorzy_z_punktami( + od_roku=od_roku, do_roku=do_roku, min_pk=min_pk, uczelnia=uczelnia + ) + return defined.difference(existent) +``` + +> Uwaga: `autorzy_z_dyscyplinami` czyta `Autor_Dyscyplina` (deklaracje, nie cache) +> — w R1 NIE zawężamy go po uczelni (autor deklaruje dyscyplinę niezależnie od +> afiliacji); zawężenie zerowych realizuje `existent`. Udokumentuj to komentarzem. + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/raport_slotow/tests/test_core.py -k autorzy_z_punktami_filtr -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check src/raport_slotow/core.py src/raport_slotow/tests/test_core.py +git add src/raport_slotow/core.py src/raport_slotow/tests/test_core.py +git commit -m "feat(multi-hosted): autorzy_z_punktami/zerowi filtrują po uczelni" +``` + +--- + +## Task 6: `RaportSlotowUczelnia` — FK `uczelnia` + zawężenie generacji + +**Files:** +- Modify: `src/raport_slotow/models/uczelnia.py` +- Create: `src/raport_slotow/migrations/0020_raportslotowuczelnia_uczelnia.py` (przez makemigrations) +- Modify: `src/raport_slotow/views/uczelnia.py` (ustawienie uczelni przy zamówieniu) +- Test: `src/raport_slotow/tests/test_raport_slotow_uczelnia/` (nowy plik) + +- [ ] **Step 1: Napisz failing test (generacja zawężona)** + +Utwórz `src/raport_slotow/tests/test_per_uczelnia_uczelnia.py`: + +```python +import pytest + + +@pytest.mark.django_db +def test_create_report_zawezony_po_uczelni( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia, rok +): + from raport_slotow.models.uczelnia import RaportSlotowUczelnia + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + + raport = RaportSlotowUczelnia.objects.create( + od_roku=rok, do_roku=rok, uczelnia=jednostka.uczelnia, + akcja=RaportSlotowUczelnia.Akcje.WSZYSTKO, + ) + raport.create_report() + + jednostki_w_raporcie = set( + raport.raportslotowuczelniawiersz_set.values_list( + "jednostka__uczelnia_id", flat=True + ) + ) + assert jednostki_w_raporcie <= {jednostka.uczelnia_id} + assert druga_uczelnia.pk not in jednostki_w_raporcie +``` + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/raport_slotow/tests/test_per_uczelnia_uczelnia.py -q -p no:cacheprovider` +Expected: FAIL (`TypeError: 'uczelnia' is an invalid keyword`). + +- [ ] **Step 3: Dodaj FK `uczelnia` na modelu** + +W `src/raport_slotow/models/uczelnia.py`, klasa `RaportSlotowUczelnia`, dodaj pole: + +```python + uczelnia = models.ForeignKey( + "bpp.Uczelnia", on_delete=models.CASCADE, null=True, blank=True + ) +``` + +- [ ] **Step 4: Zawęź `create_report`** + +W `create_report`, filtr `kombinacje` i wywołanie `zbieraj_sloty`: + +```python + kombinacje = ( + Cache_Punktacja_Autora_Query.objects.filter( + rekord__rok__gte=self.od_roku, rekord__rok__lte=self.do_roku + ) + .values_list(*lst) + .distinct() + ) + if self.uczelnia_id is not None: + kombinacje = kombinacje.filter(jednostka__uczelnia_id=self.uczelnia_id) +``` + +oraz w wywołaniu `zbieraj_sloty(...)` dodaj `uczelnia_id=self.uczelnia_id`. +W gałęzi `pokazuj_zerowych` przekaż `uczelnia=self.uczelnia` do `autorzy_zerowi(...)`. + +- [ ] **Step 5: Ustaw uczelnię przy zamówieniu raportu** + +W `src/raport_slotow/views/uczelnia.py` znajdź widok tworzący `RaportSlotowUczelnia` +(CreateView/`form_valid`). Ustaw `form.instance.uczelnia = uczelnia_dla_odczytu(self.request)` +przed zapisem (import: `from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu`). + +- [ ] **Step 6: Migracja + backfill** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations raport_slotow -n raportslotowuczelnia_uczelnia` +Następnie do wygenerowanej migracji dopisz `RunPython` backfill (single→domyślna, +multi→stare raporty zostają null — to artefakty historyczne): + +```python + def backfill(apps, schema_editor): + Uczelnia = apps.get_model("bpp", "Uczelnia") + RSU = apps.get_model("raport_slotow", "RaportSlotowUczelnia") + u = list(Uczelnia.objects.all()[:2]) + if len(u) == 1: + RSU.objects.filter(uczelnia__isnull=True).update(uczelnia=u[0]) + + def backfill_reverse(apps, schema_editor): + pass +``` + +i dodaj `migrations.RunPython(backfill, backfill_reverse)` do `operations`. + +- [ ] **Step 7: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/raport_slotow/tests/test_per_uczelnia_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 8: Lint + commit** + +```bash +uv run ruff check src/raport_slotow/models/uczelnia.py src/raport_slotow/views/uczelnia.py src/raport_slotow/migrations/0020_*.py +git add src/raport_slotow/models/uczelnia.py src/raport_slotow/views/uczelnia.py src/raport_slotow/migrations/0020_*.py src/raport_slotow/tests/test_per_uczelnia_uczelnia.py +git commit -m "feat(multi-hosted): RaportSlotowUczelnia FK uczelnia + zawężona generacja" +``` + +--- + +## Task 7: `RaportSlotowAutor` — filtr po uczelni oglądającego + +**Files:** +- Modify: `src/raport_slotow/views/autor.py:97` +- Test: `src/raport_slotow/tests/test_views/test_raport_slotow_autor.py` + +- [ ] **Step 1: Napisz failing test** + +Dopisz test sprawdzający, że dla autora z afiliacjami w dwóch uczelniach widok +raportu (z `uczelnia_dla_odczytu` zwracającym U1) pokazuje tylko wiersze U1. +(Wzór asercji: `cpaq.values_list("uczelnia_id", flat=True)` ⊆ `{U1}`.) + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/raport_slotow/tests/test_views/test_raport_slotow_autor.py -k uczelni -q -p no:cacheprovider` +Expected: FAIL (widok pokazuje obie uczelnie). + +- [ ] **Step 3: Dodaj filtr** + +W `src/raport_slotow/views/autor.py`, w `get_tables` (linia ~97), dołóż filtr po +uczelni z helpera: + +```python + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + cpaq = Cache_Punktacja_Autora_Query_View.objects.filter( + autor=self.autor, + uczelnia=uczelnia_dla_odczytu(self.request), + rekord__rok__gte=self.kwargs["od_roku"], + rekord__rok__lte=self.kwargs["do_roku"], + pkdaut__gt=0, + ) +``` + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/raport_slotow/tests/test_views/test_raport_slotow_autor.py -q -p no:cacheprovider` +Expected: PASS (wszystkie, w tym single-install regresja). + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check src/raport_slotow/views/autor.py +git add src/raport_slotow/views/autor.py src/raport_slotow/tests/test_views/test_raport_slotow_autor.py +git commit -m "feat(multi-hosted): RaportSlotowAutor filtruje po uczelni oglądającego" +``` + +--- + +## Task 8: `oswiadczenia` — filtr po uczelni + +**Files:** +- Modify: `src/oswiadczenia/views.py:342` +- Test: `src/oswiadczenia/tests/` (dopisz) + +- [ ] **Step 1: Napisz failing test** + +Sprawdź w `src/oswiadczenia/views.py` która to klasa (kontekst `"punktacje"`). +Test: rekord współautorski 2 uczelni → kontekst `punktacje` dla uczelni U1 zawiera +tylko wiersze `jednostka__uczelnia=U1`. + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/oswiadczenia/ -k uczelni -q -p no:cacheprovider` +Expected: FAIL. + +- [ ] **Step 3: Dodaj filtr** + +W `src/oswiadczenia/views.py` (linia ~342): + +```python + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + return { + "object": self.object, + "punktacje": Cache_Punktacja_Autora.objects.filter( + rekord_id=self.object.pk, + jednostka__uczelnia=uczelnia_dla_odczytu(self.request), + ), + } +``` + +(Jeśli widok nie ma `self.request` lub to eksport bez requestu — użyj uczelni z +obiektu oświadczenia / `.get()` single-or-fail; sprawdź klasę przed edycją.) + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/oswiadczenia/ -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check src/oswiadczenia/views.py +git add src/oswiadczenia/views.py src/oswiadczenia/tests/ +git commit -m "feat(multi-hosted): oswiadczenia filtruje punktacje po uczelni" +``` + +--- + +## Task 9: API + analiza pozostałych konsumentów (bez cichego pomijania) + +**Files:** +- Modify: `src/api_v1/viewsets/raport_slotow_uczelnia.py` (jeśli listuje cudze raporty) +- Modify (komentarze): `src/ewaluacja_metryki/{utils.py,views/detail.py,views/list.py}`, + `src/ewaluacja_common/utils.py` +- Test: `src/api_v1/tests/` + +- [ ] **Step 1: API — ogranicz listing raportów do uczelni żądającego** + +Przeczytaj `src/api_v1/viewsets/raport_slotow_uczelnia.py`. Jeśli `get_queryset` +zwraca wszystkie `RaportSlotowUczelnia`, zawęź: +`.filter(uczelnia=uczelnia_dla_odczytu(self.request))`. Napisz test: user +uczelni A nie widzi raportu uczelni B. (Jeśli viewset już filtruje po użytkowniku/ +owner — udokumentuj i pomiń.) + +- [ ] **Step 2: Udokumentuj konsumentów już-zawężonych (NIE filtruj na ślepo)** + +`ewaluacja_metryki` (`utils.py`, `views/detail.py`, `views/list.py`) czyta +`Cache_Punktacja_Autora_Query` zawsze z `autor_id`+`dyscyplina_id`+(`pk__in`/ +`rekord_id__in` z metryki) → wiersze są już związane z konkretnym autorem i +dyscypliną. Dodaj **komentarz** przy każdym z tych odczytów: +`# read-side multi-uczelnia: zawężone transitive po autor_id+dyscyplina_id; +# rewizja per-uczelnia metryk należy do federacji (R-federacja), nie R1.` +Analogicznie `ewaluacja_common/utils.py` — sprawdź źródło `dozwoleni_autorzy`: +jeśli pochodzi z federacyjnego kontekstu, oznacz komentarzem „do rewizji w federacji". + +- [ ] **Step 3: Uruchom testy API + lint + commit** + +```bash +uv run pytest src/api_v1/ -q -p no:cacheprovider +uv run ruff check src/api_v1/ src/ewaluacja_metryki/ src/ewaluacja_common/utils.py +git add -A +git commit -m "feat(multi-hosted): API raport_slotow per uczelnia + adnotacje konsumentów R1" +``` + +--- + +## Task 10: Regresja całościowa read-side + hardening #3 (doc) + +**Files:** +- Modify (komentarz): `src/bpp/models/sloty/core.py` (`_zapisz`, asymetria skupia_pracownikow) + +- [ ] **Step 1: Pełna regresja konsumentów R1** + +Run: `uv run pytest src/raport_slotow/ src/oswiadczenia/ src/ewaluacja_metryki/ src/api_v1/ src/bpp/tests/test_models/test_sloty/ -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 2: Hardening #3 — udokumentuj asymetrię `skupia_pracownikow`** + +W `src/bpp/models/sloty/core.py`, `_zapisz`, przy filtrze autorów dopisz komentarz: +`# UWAGA (read-side): autorzy_z_dyscypliny w Cache_Punktacja_Dyscypliny mogą +# zawierać PK autora z jednostki skupia_pracownikow=False, dla którego NIE ma +# wiersza Cache_Punktacja_Autora (ten filtr pomija takie jednostki). Konsumenci +# widoku nie powinni zakładać 1:1 między listą CPD a wierszami CPA.` + +- [ ] **Step 3: Brak dryfu migracji + commit** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run` + +```bash +uv run ruff check src/bpp/models/sloty/core.py +git add src/bpp/models/sloty/core.py +git commit -m "docs(multi-hosted): udokumentuj asymetrię skupia_pracownikow (read-side)" +``` + +--- + +## Self-review (autor planu) + +**Spec coverage:** +- Kolumna `uczelnia` w widoku → Task 1 ✓ +- Indeks #2 → Task 2 ✓ +- Helper hybryda → Task 3 ✓ +- `zbieraj_sloty` uczelnia → Task 4 ✓ +- `autorzy_z_punktami/zerowi` → Task 5 ✓ +- `RaportSlotowUczelnia` FK + generacja → Task 6 ✓ +- `RaportSlotowAutor` → Task 7 ✓ +- `oswiadczenia` → Task 8 ✓ +- API + adnotacje (ewaluacja_metryki/common już-zawężone — bez cichego pomijania) → Task 9 ✓ +- Regresja + hardening #3 → Task 10 ✓ +- Invariant single-install: sprawdzany w każdym Tasku (istniejące testy zielone). + +**Znane luki / uwagi wykonawcy:** +- Task 3/7/8: fixture `druga_uczelnia` może wymagać przeniesienia do `src/conftest.py`. +- Task 6 Step 5/8: dokładny widok zamawiania raportu (CreateView vs FormView) — + potwierdź w `views/uczelnia.py` przed edycją. +- Task 8: klasa widoku „punktacje" — sprawdź czy ma `self.request` (widok vs eksport). +- Task 9 Step 1: viewset może już filtrować po ownerze — wtedy tylko test+doc. +- `Cache_Punktacja_Autora_Sum`/`_Sum_Group` (cpaq/cpasg) — w obecnym kodzie NIE są + populowane (legacy); R1 ich nie rusza. Jeśli plan ujawni populację — dopisz Task. +- pre-existing dryf migracji (`raport_slotow/do_roku`, `siteblog`) NIE jest nasz; + przy `makemigrations --check` ignoruj te dwa wpisy. From 1dd2d5e2936e47b7a7d8dc632fdabeb290ceb2de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 13:02:24 +0200 Subject: [PATCH 090/247] feat(multi-hosted): widok cache_punktacja_autora eksponuje uczelnia_id Co-Authored-By: Claude Opus 4.8 --- ...26_cache_punktacja_autora_view_uczelnia.py | 53 +++++++++++++++++++ src/bpp/models/cache/punktacja.py | 1 + .../test_sloty/test_per_uczelnia.py | 20 +++++++ 3 files changed, 74 insertions(+) create mode 100644 src/bpp/migrations/0426_cache_punktacja_autora_view_uczelnia.py diff --git a/src/bpp/migrations/0426_cache_punktacja_autora_view_uczelnia.py b/src/bpp/migrations/0426_cache_punktacja_autora_view_uczelnia.py new file mode 100644 index 000000000..cb8e92a30 --- /dev/null +++ b/src/bpp/migrations/0426_cache_punktacja_autora_view_uczelnia.py @@ -0,0 +1,53 @@ +from django.db import migrations + +DROP = "DROP VIEW IF EXISTS bpp_cache_punktacja_autora_view;" + +CREATE_NEW = """ +CREATE VIEW bpp_cache_punktacja_autora_view AS +SELECT a.id, + a.rekord_id, + a.pkdaut, + a.slot, + a.autor_id, + a.dyscyplina_id, + a.jednostka_id, + j.uczelnia_id, + d.autorzy_z_dyscypliny, + d.zapisani_autorzy_z_dyscypliny +FROM bpp_cache_punktacja_autora a +JOIN bpp_jednostka j ON j.id = a.jednostka_id +JOIN bpp_cache_punktacja_dyscypliny d + ON a.rekord_id = d.rekord_id + AND a.dyscyplina_id = d.dyscyplina_id + AND d.uczelnia_id = j.uczelnia_id; +""" + +CREATE_OLD = """ +CREATE VIEW bpp_cache_punktacja_autora_view AS +SELECT a.id, + a.rekord_id, + a.pkdaut, + a.slot, + a.autor_id, + a.dyscyplina_id, + a.jednostka_id, + d.autorzy_z_dyscypliny, + d.zapisani_autorzy_z_dyscypliny +FROM bpp_cache_punktacja_autora a +JOIN bpp_jednostka j ON j.id = a.jednostka_id +JOIN bpp_cache_punktacja_dyscypliny d + ON a.rekord_id = d.rekord_id + AND a.dyscyplina_id = d.dyscyplina_id + AND d.uczelnia_id = j.uczelnia_id; +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0425_per_uczelnia_cache_view"), + ] + + operations = [ + migrations.RunSQL(sql=DROP + CREATE_NEW, reverse_sql=DROP + CREATE_OLD), + ] diff --git a/src/bpp/models/cache/punktacja.py b/src/bpp/models/cache/punktacja.py index 2ab235071..22d9d4840 100644 --- a/src/bpp/models/cache/punktacja.py +++ b/src/bpp/models/cache/punktacja.py @@ -115,6 +115,7 @@ class Cache_Punktacja_Autora_Query_View(models.Model): rekord = ForeignKey("bpp.Rekord", DO_NOTHING) autor = ForeignKey(Autor, DO_NOTHING) jednostka = ForeignKey("bpp.Jednostka", DO_NOTHING) + uczelnia = ForeignKey("bpp.Uczelnia", DO_NOTHING) dyscyplina = ForeignKey(Dyscyplina_Naukowa, DO_NOTHING) pkdaut = models.DecimalField(max_digits=20, decimal_places=4) slot = models.DecimalField(max_digits=20, decimal_places=4) diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index b12637d30..89ed71d5f 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -268,6 +268,26 @@ def test_wypadniecie_uczelni_kasuje_sieroty( assert uczelnie == {jednostka.uczelnia_id} # brak sieroty druga_uczelnia +@pytest.mark.django_db +def test_view_eksponuje_uczelnia(zwarte_dwie_uczelnie, jednostka, druga_uczelnia): + from django.contrib.contenttypes.models import ContentType + + from bpp.models.cache import Cache_Punktacja_Autora_Query_View + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + ctype = ContentType.objects.get_for_model(zwarte_dwie_uczelnie).pk + + rows = Cache_Punktacja_Autora_Query_View.objects.filter( + rekord_id=[ctype, zwarte_dwie_uczelnie.pk] + ) + for row in rows: + assert row.uczelnia_id == row.jednostka.uczelnia_id + assert set(rows.values_list("uczelnia_id", flat=True)) == { + jednostka.uczelnia_id, + druga_uczelnia.pk, + } + + @pytest.mark.django_db def test_invariant_jedna_uczelnia_k2( wydawnictwo_zwarte, From 880f6d870fd5da0d4f11d2e0b6a2b2a2cd22d064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 13:07:54 +0200 Subject: [PATCH 091/247] feat(multi-hosted): indeks (rekord_id, uczelnia, dyscyplina) na CPD Co-Authored-By: Claude Sonnet 4.6 --- ...0427_cpd_index_rekord_uczelnia_dyscyplina.py | 17 +++++++++++++++++ src/bpp/models/cache/punktacja.py | 1 + 2 files changed, 18 insertions(+) create mode 100644 src/bpp/migrations/0427_cpd_index_rekord_uczelnia_dyscyplina.py diff --git a/src/bpp/migrations/0427_cpd_index_rekord_uczelnia_dyscyplina.py b/src/bpp/migrations/0427_cpd_index_rekord_uczelnia_dyscyplina.py new file mode 100644 index 000000000..479fb5601 --- /dev/null +++ b/src/bpp/migrations/0427_cpd_index_rekord_uczelnia_dyscyplina.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.14 on 2026-06-03 11:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0426_cache_punktacja_autora_view_uczelnia'), + ] + + operations = [ + migrations.AddIndex( + model_name='cache_punktacja_dyscypliny', + index=models.Index(fields=['rekord_id', 'uczelnia', 'dyscyplina'], name='bpp_cache_p_rekord__479a7e_idx'), + ), + ] diff --git a/src/bpp/models/cache/punktacja.py b/src/bpp/models/cache/punktacja.py index 22d9d4840..b8b171c30 100644 --- a/src/bpp/models/cache/punktacja.py +++ b/src/bpp/models/cache/punktacja.py @@ -35,6 +35,7 @@ class Meta: ordering = ("dyscyplina__nazwa",) indexes = [ models.Index(fields=["uczelnia", "dyscyplina"]), + models.Index(fields=["rekord_id", "uczelnia", "dyscyplina"]), ] def serialize(self): From 939f760235c9fb887aeb6360f8fa596d2c4ffaee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 13:10:20 +0200 Subject: [PATCH 092/247] feat(multi-hosted): helper uczelnia_dla_odczytu (hybryda site/superuser) Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_uczelnia_helper.py | 78 +++++++++++++++++++ src/raport_slotow/uczelnia_helper.py | 21 +++++ 2 files changed, 99 insertions(+) create mode 100644 src/raport_slotow/tests/test_uczelnia_helper.py create mode 100644 src/raport_slotow/uczelnia_helper.py diff --git a/src/raport_slotow/tests/test_uczelnia_helper.py b/src/raport_slotow/tests/test_uczelnia_helper.py new file mode 100644 index 000000000..df90d1f6a --- /dev/null +++ b/src/raport_slotow/tests/test_uczelnia_helper.py @@ -0,0 +1,78 @@ +import pytest +from django.contrib.sites.models import Site +from model_bakery import baker + +from bpp.models import Uczelnia +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + +@pytest.fixture +def uczelnia_bazowa(db, uczelnia): + """Uczelnia podstawowa powiązana z requestem przez _uczelnia.""" + return uczelnia + + +@pytest.fixture +def druga_uczelnia(db): + site, _ = Site.objects.get_or_create( + domain="druga.testserver", defaults={"name": "druga"} + ) + return Uczelnia.objects.create( + skrot="DR", nazwa="Druga uczelnia", site=site + ) + + +def _make_request(uczelnia, user, extra_get=None): + """Lekki fake request z _uczelnia, user i GET.""" + + class FakeRequest: + GET = {} + + req = FakeRequest() + req._uczelnia = uczelnia + req.user = user + if extra_get: + req.GET = extra_get + return req + + +@pytest.mark.django_db +def test_zwykly_uzytkownik_nie_moze_nadpisac_uczelni( + uczelnia_bazowa, druga_uczelnia +): + """Nie-superuser zawsze dostaje uczelnię z requestu mimo ?uczelnia=.""" + zwykly = baker.make("bpp.BppUser", is_superuser=False) + req = _make_request( + uczelnia_bazowa, zwykly, extra_get={"uczelnia": str(druga_uczelnia.pk)} + ) + + wynik = uczelnia_dla_odczytu(req) + + assert wynik == uczelnia_bazowa + + +@pytest.mark.django_db +def test_superuser_moze_nadpisac_uczelnie(uczelnia_bazowa, druga_uczelnia): + """Superuser z ?uczelnia= dostaje wybraną uczelnię.""" + su = baker.make("bpp.BppUser", is_superuser=True) + req = _make_request( + uczelnia_bazowa, su, extra_get={"uczelnia": str(druga_uczelnia.pk)} + ) + + wynik = uczelnia_dla_odczytu(req) + + assert wynik == druga_uczelnia + + +@pytest.mark.django_db +def test_superuser_niepoprawne_pk_wraca_do_bazowej(uczelnia_bazowa): + """Superuser z niepoprawną wartością ?uczelnia= dostaje uczelnię bazową.""" + su = baker.make("bpp.BppUser", is_superuser=True) + + for zly_pk in ["99999999", "abc", "", None]: + get = {"uczelnia": zly_pk} if zly_pk is not None else {} + req = _make_request(uczelnia_bazowa, su, extra_get=get) + wynik = uczelnia_dla_odczytu(req) + assert wynik == uczelnia_bazowa, ( + f"Oczekiwano uczelni bazowej dla ?uczelnia={zly_pk!r}" + ) diff --git a/src/raport_slotow/uczelnia_helper.py b/src/raport_slotow/uczelnia_helper.py new file mode 100644 index 000000000..acf453ef2 --- /dev/null +++ b/src/raport_slotow/uczelnia_helper.py @@ -0,0 +1,21 @@ +"""Rozstrzyganie 'uczelni oglądającego' dla odczytów slotowych (read-side). + +Hybryda: domyślnie uczelnia z requestu (site/domena); superuser może nadpisać +jawnym parametrem ``?uczelnia=``. +""" + +from bpp.models import Uczelnia + + +def uczelnia_dla_odczytu(request): + bazowa = Uczelnia.objects.get_for_request(request) + + user = getattr(request, "user", None) + if user is not None and user.is_authenticated and user.is_superuser: + pk = request.GET.get("uczelnia") + if pk: + try: + return Uczelnia.objects.get(pk=pk) + except (Uczelnia.DoesNotExist, ValueError, TypeError): + return bazowa + return bazowa From cb2ade9c0aec9f7a12a6568dedf6843f78c4801f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 13:16:34 +0200 Subject: [PATCH 093/247] =?UTF-8?q?feat(multi-hosted):=20zbieraj=5Fsloty?= =?UTF-8?q?=20przyjmuje=20uczelnia=5Fid=20(zaw=C4=99=C5=BCenie)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dodaje opcjonalny parametr `uczelnia_id=None` do `zbieraj_sloty` filtrujący Cache_Punktacja_Autora_Query po jednostka__uczelnia_id, co umożliwia scoping do konkretnej uczelni w instalacjach multi-hosted. Fixtures `druga_uczelnia`, `jednostka_drugiej_uczelni`, `zwarte_dwie_uczelnie` przeniesione do conftest.py katalogu test_sloty, żeby były dostępne z nowego pliku testowego. Co-Authored-By: Claude Sonnet 4.6 --- src/bpp/core.py | 4 + .../tests/test_models/test_sloty/conftest.py | 74 ++++++++++++++++++- .../test_sloty/test_per_uczelnia.py | 68 ----------------- .../test_sloty/test_zbieraj_sloty_uczelnia.py | 28 +++++++ 4 files changed, 104 insertions(+), 70 deletions(-) create mode 100644 src/bpp/tests/test_models/test_sloty/test_zbieraj_sloty_uczelnia.py diff --git a/src/bpp/core.py b/src/bpp/core.py index e385cd477..726fd2271 100644 --- a/src/bpp/core.py +++ b/src/bpp/core.py @@ -12,12 +12,16 @@ def zbieraj_sloty( dyscyplina_id=None, jednostka_id=None, akcja=None, + uczelnia_id=None, ): from bpp.models.cache import Cache_Punktacja_Autora_Query rekordy = Cache_Punktacja_Autora_Query.objects.filter( rekord__rok__gte=rok_min, rekord__rok__lte=rok_max, autor_id=autor_id ) + if uczelnia_id is not None: + rekordy = rekordy.filter(jednostka__uczelnia_id=uczelnia_id) + if dyscyplina_id is not None: rekordy = rekordy.filter(dyscyplina_id=dyscyplina_id) diff --git a/src/bpp/tests/test_models/test_sloty/conftest.py b/src/bpp/tests/test_models/test_sloty/conftest.py index 5666f6085..9c25fe187 100644 --- a/src/bpp/tests/test_models/test_sloty/conftest.py +++ b/src/bpp/tests/test_models/test_sloty/conftest.py @@ -1,14 +1,17 @@ import pytest - -from ewaluacja_common.models import Rodzaj_Autora +from django.contrib.sites.models import Site from bpp import const from bpp.models import ( Autor_Dyscyplina, Charakter_Formalny, + Jednostka, + Uczelnia, Wydawnictwo_Ciagle, Wydawnictwo_Zwarte, + Wydzial, ) +from ewaluacja_common.models import Rodzaj_Autora @pytest.fixture @@ -192,3 +195,70 @@ def referat_z_dyscyplinami(zwarte_z_dyscyplinami, charakter_referat): zwarte_z_dyscyplinami.save() return zwarte_z_dyscyplinami + + +@pytest.fixture +def druga_uczelnia(db): + site, _ = Site.objects.get_or_create( + domain="druga.testserver", defaults={"name": "druga"} + ) + return Uczelnia.objects.create( + skrot="DR", nazwa="Druga uczelnia", site=site + ) + + +@pytest.fixture +def jednostka_drugiej_uczelni(druga_uczelnia, db): + wydzial = Wydzial.objects.create( + uczelnia=druga_uczelnia, skrot="W2", nazwa="Wydział II" + ) + return Jednostka.objects.create( + nazwa="Jedn. Drugiej Ucz.", + skrot="JDU", + wydzial=wydzial, + uczelnia=druga_uczelnia, + ) + + +@pytest.fixture +def zwarte_dwie_uczelnie( + wydawnictwo_zwarte, + autor_jan_nowak, + autor_jan_kowalski, + jednostka, + jednostka_drugiej_uczelni, + dyscyplina1, + rodzaj_autora_n, + charaktery_formalne, + wydawca, + typy_odpowiedzialnosci, + rok, +): + # Obaj autorzy w TEJ SAMEJ dyscyplinie, ale w różnych uczelniach. + Autor_Dyscyplina.objects.create( + autor=autor_jan_nowak, + dyscyplina_naukowa=dyscyplina1, + rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_nowak, jednostka, dyscyplina_naukowa=dyscyplina1 + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_kowalski, + jednostka_drugiej_uczelni, + dyscyplina_naukowa=dyscyplina1, + ) + wydawnictwo_zwarte.punkty_kbn = 20 + wydawnictwo_zwarte.wydawca = wydawca + wydawnictwo_zwarte.charakter_formalny = Charakter_Formalny.objects.get( + skrot="KSP" + ) + wydawnictwo_zwarte.save() + return wydawnictwo_zwarte diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index 89ed71d5f..2f27b2fc3 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -3,9 +3,6 @@ from bpp.models import ( Autor_Dyscyplina, Charakter_Formalny, - Jednostka, - Uczelnia, - Wydzial, ) from bpp.models.cache import Cache_Punktacja_Dyscypliny @@ -23,71 +20,6 @@ def test_cache_punktacja_dyscypliny_ma_uczelnia(uczelnia, dyscyplina1): assert obj.serialize()[-1] == uczelnia.pk -@pytest.fixture -def druga_uczelnia(db): - from django.contrib.sites.models import Site - - site, _ = Site.objects.get_or_create( - domain="druga.testserver", defaults={"name": "druga"} - ) - return Uczelnia.objects.create(skrot="DR", nazwa="Druga uczelnia", site=site) - - -@pytest.fixture -def jednostka_drugiej_uczelni(druga_uczelnia, db): - wydzial = Wydzial.objects.create( - uczelnia=druga_uczelnia, skrot="W2", nazwa="Wydział II" - ) - return Jednostka.objects.create( - nazwa="Jedn. Drugiej Ucz.", - skrot="JDU", - wydzial=wydzial, - uczelnia=druga_uczelnia, - ) - - -@pytest.fixture -def zwarte_dwie_uczelnie( - wydawnictwo_zwarte, - autor_jan_nowak, - autor_jan_kowalski, - jednostka, - jednostka_drugiej_uczelni, - dyscyplina1, - rodzaj_autora_n, - charaktery_formalne, - wydawca, - typy_odpowiedzialnosci, - rok, -): - # Obaj autorzy w TEJ SAMEJ dyscyplinie, ale w różnych uczelniach. - Autor_Dyscyplina.objects.create( - autor=autor_jan_nowak, - dyscyplina_naukowa=dyscyplina1, - rok=rok, - rodzaj_autora=rodzaj_autora_n, - ) - Autor_Dyscyplina.objects.create( - autor=autor_jan_kowalski, - dyscyplina_naukowa=dyscyplina1, - rok=rok, - rodzaj_autora=rodzaj_autora_n, - ) - wydawnictwo_zwarte.dodaj_autora( - autor_jan_nowak, jednostka, dyscyplina_naukowa=dyscyplina1 - ) - wydawnictwo_zwarte.dodaj_autora( - autor_jan_kowalski, jednostka_drugiej_uczelni, dyscyplina_naukowa=dyscyplina1 - ) - wydawnictwo_zwarte.punkty_kbn = 20 - wydawnictwo_zwarte.wydawca = wydawca - wydawnictwo_zwarte.charakter_formalny = Charakter_Formalny.objects.get( - skrot="KSP" - ) - wydawnictwo_zwarte.save() - return wydawnictwo_zwarte - - @pytest.mark.django_db def test_slotmixin_wszyscy_scoped_po_uczelni(zwarte_dwie_uczelnie, jednostka): from bpp.models.sloty.wydawnictwo_zwarte import ( diff --git a/src/bpp/tests/test_models/test_sloty/test_zbieraj_sloty_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_zbieraj_sloty_uczelnia.py new file mode 100644 index 000000000..eaea4c911 --- /dev/null +++ b/src/bpp/tests/test_models/test_sloty/test_zbieraj_sloty_uczelnia.py @@ -0,0 +1,28 @@ +import pytest + +from bpp.core import zbieraj_sloty + + +@pytest.mark.django_db +def test_zbieraj_sloty_zaweza_po_uczelni( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia +): + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + autor = zwarte_dwie_uczelnie.autorzy_set.first().autor + + _pkt_all, lista_all, _slot_all = zbieraj_sloty( + autor.pk, + 1, + zwarte_dwie_uczelnie.rok, + zwarte_dwie_uczelnie.rok, + akcja="wszystko", + ) + _pkt_u, lista_u, _slot_u = zbieraj_sloty( + autor.pk, + 1, + zwarte_dwie_uczelnie.rok, + zwarte_dwie_uczelnie.rok, + akcja="wszystko", + uczelnia_id=jednostka.uczelnia_id, + ) + assert len(lista_u) <= len(lista_all) From 29734f833bb4e7ec6c20423a35e55356fe74c750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 13:22:01 +0200 Subject: [PATCH 094/247] =?UTF-8?q?feat(multi-hosted):=20autorzy=5Fz=5Fpun?= =?UTF-8?q?ktami/zerowi=20filtruj=C4=85=20po=20uczelni?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dodaje opcjonalny parametr ``uczelnia`` do ``autorzy_z_punktami`` i ``autorzy_zerowi`` w ``raport_slotow/core.py``. Gdy podany, queryset ``Cache_Punktacja_Autora_Query_View`` jest filtrowany przez ``.filter(uczelnia=uczelnia)`` — co zawęża wynik do rekordów danej uczelni. Instalacje jednodomowe nie muszą nic zmieniać (domyślnie ``None`` = bez filtrowania). Dodano fixture ``zwarte_dwie_uczelnie`` / ``druga_uczelnia`` / ``jednostka_drugiej_uczelni`` do ``raport_slotow/tests/conftest.py`` (mirrored z bpp/tests/test_models/test_sloty/conftest.py) oraz test regresyjny potwierdzający że filtr zwraca podzbiór wyniku bez filtra. Przy okazji: usunięto przestarzały import ``typing.List/Tuple`` z core.py (UP035/UP006). Co-Authored-By: Claude Sonnet 4.6 --- src/raport_slotow/core.py | 32 +++++++---- src/raport_slotow/tests/conftest.py | 86 +++++++++++++++++++++++++++- src/raport_slotow/tests/test_core.py | 21 +++++++ 3 files changed, 127 insertions(+), 12 deletions(-) diff --git a/src/raport_slotow/core.py b/src/raport_slotow/core.py index 55f6ed16d..c45dbe557 100644 --- a/src/raport_slotow/core.py +++ b/src/raport_slotow/core.py @@ -1,5 +1,3 @@ -from typing import List, Tuple - from bpp.models import Autor_Dyscyplina, Cache_Punktacja_Autora_Query_View @@ -13,7 +11,7 @@ def _get_kwargs(od_roku, do_roku, prefix=""): return kwargs -def autorzy_z_dyscyplinami(od_roku=None, do_roku=None) -> List[Tuple[int, int, int]]: +def autorzy_z_dyscyplinami(od_roku=None, do_roku=None) -> list[tuple[int, int, int]]: """ Zwraca listę autorów z dyscyplinami z bazy danych @@ -38,11 +36,12 @@ def autorzy_z_dyscyplinami(od_roku=None, do_roku=None) -> List[Tuple[int, int, i def autorzy_z_punktami( - od_roku=None, do_roku=None, min_pk=None -) -> List[Tuple[int, int, int]]: + od_roku=None, do_roku=None, min_pk=None, uczelnia=None +) -> list[tuple[int, int, int]]: """ - Zwraca listę autorów z dyscyplinami z punktami z bazy danych + Zwraca listę autorów z dyscyplinami z punktami z bazy danych. + Jeśli podano ``uczelnia``, zwraca tylko rekordy powiązane z tą uczelnią. """ kwargs = _get_kwargs(od_roku, do_roku, prefix="rekord__") @@ -51,24 +50,35 @@ def autorzy_z_punktami( if min_pk is not None: exclude_kwargs = dict(rekord__punkty_kbn__lt=min_pk) - return ( + qs = ( Cache_Punktacja_Autora_Query_View.objects.all() .filter(**kwargs) .exclude(**exclude_kwargs) - .values("autor_id", "rekord__rok", "dyscyplina_id") ) + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + + return qs.values("autor_id", "rekord__rok", "dyscyplina_id") -def autorzy_zerowi(od_roku=None, do_roku=None, min_pk=None): + +def autorzy_zerowi(od_roku=None, do_roku=None, min_pk=None, uczelnia=None): """ Zwraca listę krotek w postaci (autor_id, rok, dyscyplina_id), która zawiera listę autorów zerowych, czyli autorów, którzy mimo zadeklarowanych na dany rok dyscyplin nie posiadają w bazie żadnych punktowanych rekordów. + + Jeśli podano ``uczelnia``, wynik jest zawężony do punktów z tej uczelni. + Uwaga: ``autorzy_z_dyscyplinami`` (deklaracje) NIE jest scopowana po uczelni + — autor deklaruje dyscyplinę niezależnie od afiliacji, więc filtr uczelni + dotyczy wyłącznie strony ``existent`` (rekordów z punktami). """ # wartośći zadeklarowane w bazie danych defined = autorzy_z_dyscyplinami(od_roku=od_roku, do_roku=do_roku) - # zestawy autor/rok/dyscyplina z całej bazy danych - existent = autorzy_z_punktami(od_roku=od_roku, do_roku=do_roku, min_pk=min_pk) + # zestawy autor/rok/dyscyplina z całej bazy danych (opcjonalnie per uczelnia) + existent = autorzy_z_punktami( + od_roku=od_roku, do_roku=do_roku, min_pk=min_pk, uczelnia=uczelnia + ) return defined.difference(existent) diff --git a/src/raport_slotow/tests/conftest.py b/src/raport_slotow/tests/conftest.py index b2f3457c6..1d670e5f7 100644 --- a/src/raport_slotow/tests/conftest.py +++ b/src/raport_slotow/tests/conftest.py @@ -1,7 +1,17 @@ import pytest +from django.contrib.sites.models import Site from model_bakery import baker -from bpp.models import Cache_Punktacja_Autora_Query, Cache_Punktacja_Dyscypliny, Rekord +from bpp.models import ( + Autor_Dyscyplina, + Cache_Punktacja_Autora_Query, + Cache_Punktacja_Dyscypliny, + Charakter_Formalny, + Jednostka, + Rekord, + Uczelnia, + Wydzial, +) from raport_slotow.models.uczelnia import RaportSlotowUczelnia @@ -44,3 +54,77 @@ def rekord_slotu( @pytest.fixture def raport_slotow_uczelnia(db): return baker.make(RaportSlotowUczelnia) + + +# --------------------------------------------------------------------------- +# Fixtures for multi-uczelnia slot-cache tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def druga_uczelnia(db): + site, _ = Site.objects.get_or_create( + domain="druga.testserver", defaults={"name": "druga"} + ) + return Uczelnia.objects.create( + skrot="DR", nazwa="Druga uczelnia", site=site + ) + + +@pytest.fixture +def jednostka_drugiej_uczelni(druga_uczelnia, db): + wydzial = Wydzial.objects.create( + uczelnia=druga_uczelnia, skrot="W2", nazwa="Wydział II" + ) + return Jednostka.objects.create( + nazwa="Jedn. Drugiej Ucz.", + skrot="JDU", + wydzial=wydzial, + uczelnia=druga_uczelnia, + ) + + +@pytest.fixture +def zwarte_dwie_uczelnie( + wydawnictwo_zwarte, + autor_jan_nowak, + autor_jan_kowalski, + jednostka, + jednostka_drugiej_uczelni, + dyscyplina1, + rodzaj_autora_n, + charaktery_formalne, + wydawca, + typy_odpowiedzialnosci, + rok, +): + """Wydawnictwo_Zwarte co-authored by two authors from two different + universities (jednostka → uczelnia1, jednostka_drugiej_uczelni → + druga_uczelnia), both in the same dyscyplina1.""" + Autor_Dyscyplina.objects.create( + autor=autor_jan_nowak, + dyscyplina_naukowa=dyscyplina1, + rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_nowak, jednostka, dyscyplina_naukowa=dyscyplina1 + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_kowalski, + jednostka_drugiej_uczelni, + dyscyplina_naukowa=dyscyplina1, + ) + wydawnictwo_zwarte.punkty_kbn = 20 + wydawnictwo_zwarte.wydawca = wydawca + wydawnictwo_zwarte.charakter_formalny = Charakter_Formalny.objects.get( + skrot="KSP" + ) + wydawnictwo_zwarte.save() + return wydawnictwo_zwarte diff --git a/src/raport_slotow/tests/test_core.py b/src/raport_slotow/tests/test_core.py index 3ac156ad3..4694ba69e 100644 --- a/src/raport_slotow/tests/test_core.py +++ b/src/raport_slotow/tests/test_core.py @@ -48,3 +48,24 @@ def test_autorzy_zerowi(rekord_slotu, autor_z_dyscyplina): @pytest.mark.django_db def test_autorzy_zerowi_rok_powyzej(rekord_slotu, autor_jan_nowak, rok): assert autorzy_zerowi(od_roku=rok + 20).count() == 0 + + +@pytest.mark.django_db +def test_autorzy_z_punktami_filtr_uczelni( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia +): + from raport_slotow.core import autorzy_z_punktami + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + wszyscy = set( + autorzy_z_punktami().values_list( + "autor_id", "rekord__rok", "dyscyplina_id" + ) + ) + tylko_u1 = set( + autorzy_z_punktami(uczelnia=jednostka.uczelnia).values_list( + "autor_id", "rekord__rok", "dyscyplina_id" + ) + ) + assert tylko_u1 <= wszyscy + assert len(tylko_u1) <= len(wszyscy) From 1c31d88db312829fb189cc9db75c80590b095db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 13:31:35 +0200 Subject: [PATCH 095/247] =?UTF-8?q?feat(multi-hosted):=20RaportSlotowUczel?= =?UTF-8?q?nia=20FK=20uczelnia=20+=20zaw=C4=99=C5=BCona=20generacja?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `uczelnia` ForeignKey to RaportSlotowUczelnia, scopes create_report to the report's university (kombinacje filter + uczelnia_id in zbieraj_sloty + autorzy_zerowi), sets uczelnia from uczelnia_dla_odczytu in the creation view, adds backfill migration, and covers with a per-uczelnia isolation test. Co-Authored-By: Claude Sonnet 4.6 --- .../0020_raportslotowuczelnia_uczelnia.py | 47 +++++++++++++++++++ src/raport_slotow/models/uczelnia.py | 15 +++++- .../tests/test_per_uczelnia_uczelnia.py | 28 +++++++++++ src/raport_slotow/views/uczelnia.py | 23 +++++---- 4 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 src/raport_slotow/migrations/0020_raportslotowuczelnia_uczelnia.py create mode 100644 src/raport_slotow/tests/test_per_uczelnia_uczelnia.py diff --git a/src/raport_slotow/migrations/0020_raportslotowuczelnia_uczelnia.py b/src/raport_slotow/migrations/0020_raportslotowuczelnia_uczelnia.py new file mode 100644 index 000000000..f75043e44 --- /dev/null +++ b/src/raport_slotow/migrations/0020_raportslotowuczelnia_uczelnia.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.14 on 2026-06-03 11:27 + +import django.db.models.deletion +from django.db import migrations, models + +import bpp.models.uczelnia + + +def backfill(apps, schema_editor): + Uczelnia = apps.get_model("bpp", "Uczelnia") + RSU = apps.get_model("raport_slotow", "RaportSlotowUczelnia") + u = list(Uczelnia.objects.all()[:2]) + if len(u) == 1: + RSU.objects.filter(uczelnia__isnull=True).update(uczelnia=u[0]) + + +def backfill_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0427_cpd_index_rekord_uczelnia_dyscyplina'), + ('raport_slotow', '0019_alter_raportslotowuczelnia_do_roku'), + ] + + operations = [ + migrations.AddField( + model_name='raportslotowuczelnia', + name='uczelnia', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='bpp.uczelnia', + ), + ), + migrations.AlterField( + model_name='raportslotowuczelnia', + name='do_roku', + field=models.IntegerField( + default=bpp.models.uczelnia.UczelniaManager.do_roku_default + ), + ), + migrations.RunPython(backfill, backfill_reverse), + ] diff --git a/src/raport_slotow/models/uczelnia.py b/src/raport_slotow/models/uczelnia.py index 01a214c58..27b91349f 100644 --- a/src/raport_slotow/models/uczelnia.py +++ b/src/raport_slotow/models/uczelnia.py @@ -48,6 +48,10 @@ class Akcje(models.TextChoices): minimalny_pk = models.DecimalField(default=0, decimal_places=2, max_digits=5) + uczelnia = models.ForeignKey( + "bpp.Uczelnia", on_delete=models.CASCADE, null=True, blank=True + ) + dziel_na_jednostki_i_wydzialy = models.BooleanField( verbose_name="Dziel na jednostki i wydziały", default=True, @@ -111,6 +115,11 @@ def create_report(self): # noqa: C901 (pre-existing complexity) .distinct() ) + if self.uczelnia_id is not None: + kombinacje = kombinacje.filter( + jednostka__uczelnia_id=self.uczelnia_id + ) + total = kombinacje.count() for n, res in enumerate(kombinacje): @@ -129,6 +138,7 @@ def create_report(self): # noqa: C901 (pre-existing complexity) dyscyplina_id=dyscyplina_id, jednostka_id=jednostka_id, akcja=self.akcja, + uczelnia_id=self.uczelnia_id, ) avg = None @@ -154,7 +164,10 @@ def create_report(self): # noqa: C901 (pre-existing complexity) if self.pokazuj_zerowych: zerowi = autorzy_zerowi( - od_roku=self.od_roku, do_roku=self.do_roku, min_pk=self.minimalny_pk + od_roku=self.od_roku, + do_roku=self.do_roku, + min_pk=self.minimalny_pk, + uczelnia=self.uczelnia, ) # --- Początek --- komentarz dot. buga w Django diff --git a/src/raport_slotow/tests/test_per_uczelnia_uczelnia.py b/src/raport_slotow/tests/test_per_uczelnia_uczelnia.py new file mode 100644 index 000000000..649917464 --- /dev/null +++ b/src/raport_slotow/tests/test_per_uczelnia_uczelnia.py @@ -0,0 +1,28 @@ +import pytest +from model_bakery import baker + + +@pytest.mark.django_db +def test_create_report_zawezony_po_uczelni( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia, rok +): + from raport_slotow.models.uczelnia import RaportSlotowUczelnia + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + + raport = baker.make( + RaportSlotowUczelnia, + od_roku=rok, + do_roku=rok, + uczelnia=jednostka.uczelnia, + akcja=RaportSlotowUczelnia.Akcje.WSZYSTKO, + ) + raport.create_report() + + jednostki_w_raporcie = set( + raport.raportslotowuczelniawiersz_set.values_list( + "jednostka__uczelnia_id", flat=True + ) + ) + assert jednostki_w_raporcie <= {jednostka.uczelnia_id} + assert druga_uczelnia.pk not in jednostki_w_raporcie diff --git a/src/raport_slotow/views/uczelnia.py b/src/raport_slotow/views/uczelnia.py index f3519ed34..1271610a7 100644 --- a/src/raport_slotow/views/uczelnia.py +++ b/src/raport_slotow/views/uczelnia.py @@ -1,9 +1,14 @@ import urllib +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseRedirect +from django.utils import timezone from django_filters.views import FilterMixin from django_tables2 import RequestConfig, SingleTableMixin - from formdefaults.helpers import FormDefaultsMixin + +from bpp.views.mixins import UczelniaSettingRequiredMixin +from django_bpp.version import VERSION from long_running.tasks import perform_generic_long_running_task from long_running.views import ( CreateLongRunningOperationView, @@ -24,16 +29,9 @@ RaportSlotowUczelniaBezJednostekIWydzialowTable, RaportSlotowUczelniaTable, ) +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu from raport_slotow.util import MyExportMixin -from django.contrib.auth.mixins import LoginRequiredMixin - -from django.utils import timezone - -from bpp.views.mixins import UczelniaSettingRequiredMixin - -from django_bpp.version import VERSION - class ListaRaportSlotowUczelnia( BaseRaportAuthMixin, FormDefaultsMixin, LongRunningOperationsView @@ -60,6 +58,13 @@ class UtworzRaportSlotowUczelnia( model = RaportSlotowUczelnia task = perform_generic_long_running_task + def form_valid(self, form): + form.instance.owner = self.request.user + form.instance.uczelnia = uczelnia_dla_odczytu(self.request) + self.object = form.save() + self.task_on_commit(pk=form.instance.pk) + return HttpResponseRedirect(self.get_success_url()) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["title"] = self.title From 014c484a6ff0b851d19eda56cf2d8551f1d1af22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 13:37:53 +0200 Subject: [PATCH 096/247] refactor(multi-hosted): UtworzRaportSlotowUczelnia.form_valid deleguje do super() (code review) Co-Authored-By: Claude Sonnet 4.6 --- src/raport_slotow/views/uczelnia.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/raport_slotow/views/uczelnia.py b/src/raport_slotow/views/uczelnia.py index 1271610a7..9ecd543be 100644 --- a/src/raport_slotow/views/uczelnia.py +++ b/src/raport_slotow/views/uczelnia.py @@ -1,7 +1,6 @@ import urllib from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponseRedirect from django.utils import timezone from django_filters.views import FilterMixin from django_tables2 import RequestConfig, SingleTableMixin @@ -59,11 +58,8 @@ class UtworzRaportSlotowUczelnia( task = perform_generic_long_running_task def form_valid(self, form): - form.instance.owner = self.request.user form.instance.uczelnia = uczelnia_dla_odczytu(self.request) - self.object = form.save() - self.task_on_commit(pk=form.instance.pk) - return HttpResponseRedirect(self.get_success_url()) + return super().form_valid(form) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) From 1735ba9bdcaa0c1594251584bb951e6046cc4323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 13:45:38 +0200 Subject: [PATCH 097/247] =?UTF-8?q?feat(multi-hosted):=20RaportSlotowAutor?= =?UTF-8?q?=20filtruje=20po=20uczelni=20ogl=C4=85daj=C4=85cego?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache_Punktacja_Autora_Query_View ma pole uczelnia (Task prior). Widok get_tables() teraz zawęża queryset do uczelni z requestu przez uczelnia_dla_odczytu(request). Dla single-install filtr jest no-op. TDD: nowy test test_filtrowanie_po_uczelni.py weryfikuje red→green — autor z cache w 2 uczelniach; widok pod U1 zwraca tylko wiersze U1. Co-Authored-By: Claude Opus 4.8 --- .../test_filtrowanie_po_uczelni.py | 105 ++++++++++++++++++ src/raport_slotow/views/autor.py | 4 +- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/raport_slotow/tests/test_raport_slotow_autor/test_filtrowanie_po_uczelni.py diff --git a/src/raport_slotow/tests/test_raport_slotow_autor/test_filtrowanie_po_uczelni.py b/src/raport_slotow/tests/test_raport_slotow_autor/test_filtrowanie_po_uczelni.py new file mode 100644 index 000000000..4c7b1efb7 --- /dev/null +++ b/src/raport_slotow/tests/test_raport_slotow_autor/test_filtrowanie_po_uczelni.py @@ -0,0 +1,105 @@ +"""Testy filtrowania RaportSlotowAutor po uczelni oglądającego (Task 7). + +Scenariusz: jeden autor z wpisami cache w DWÓCH uczelniach. +Widok podpięty pod uczelnię U1 powinien zwracać wyłącznie wiersze +z uczelnia_id == U1. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Cache_Punktacja_Autora_Query_View +from raport_slotow import const +from raport_slotow.tests.conftest import _rekord_slotu_maker +from raport_slotow.views.autor import RaportSlotow + + +def _build_request(uczelnia, user, rf): + """Fake GET request z ustawioną _uczelnia i user.""" + req = rf.get("/") + req._uczelnia = uczelnia + req.user = user + return req + + +def _make_view(autor, request, rok): + """Tworzy i konfiguruje instancję RaportSlotow bez przechodzenia przez get().""" + view = RaportSlotow() + view.request = request + view.kwargs = { + "od_roku": rok, + "do_roku": rok, + "dzialanie": const.DZIALANIE_WSZYSTKO, + "minimalny_pk": None, + "slot": None, + } + view.autor = autor + return view + + +@pytest.mark.django_db +def test_get_tables_zwraca_tylko_wiersze_wlasnej_uczelni( + autor_jan_kowalski, + jednostka, + jednostka_drugiej_uczelni, + dyscyplina1, + wydawnictwo_ciagle_z_autorem, + wydawnictwo_ciagle, + rok, + rf, +): + """Widok pod uczelnią U1 nie pokazuje rekordów cache z U2.""" + uczelnia1 = jednostka.uczelnia + uczelnia2 = jednostka_drugiej_uczelni.uczelnia + + # Utwórz rekord cache dla autora w uczelni U1 + _rekord_slotu_maker( + autor_jan_kowalski, + jednostka, + dyscyplina1, + wydawnictwo_ciagle_z_autorem, + rok, + ) + + # Utwórz drugi rekord cache dla tego samego autora w uczelni U2 + _rekord_slotu_maker( + autor_jan_kowalski, + jednostka_drugiej_uczelni, + dyscyplina1, + wydawnictwo_ciagle, + rok, + ) + + # Upewnij się, że oba wiersze widoku faktycznie istnieją w DB + all_rows = Cache_Punktacja_Autora_Query_View.objects.filter( + autor=autor_jan_kowalski + ) + assert all_rows.count() == 2, ( + f"Oczekiwano 2 wierszy w widoku, znaleziono {all_rows.count()}" + ) + + uczelnia_ids_in_view = set( + all_rows.values_list("uczelnia_id", flat=True) + ) + assert uczelnia1.pk in uczelnia_ids_in_view + assert uczelnia2.pk in uczelnia_ids_in_view + + # Zbuduj request dla uczelni U1 + user = baker.make("bpp.BppUser", is_superuser=False) + request = _build_request(uczelnia1, user, rf) + + view = _make_view(autor_jan_kowalski, request, rok) + tables = view.get_tables() + + # Zbierz wszystkie wiersze ze zwróconych tabel. + # django_tables2 opakowuje queryset w TableQuerysetData; + # surowy queryset siedzi pod .data.data (atrybut wewnętrzny). + returned_uczelnia_ids = set() + for table in tables: + qs = table.data.data + returned_uczelnia_ids.update(qs.values_list("uczelnia_id", flat=True)) + + assert returned_uczelnia_ids == {uczelnia1.pk}, ( + f"Widok zwrócił wiersze z uczelni: {returned_uczelnia_ids!r}, " + f"oczekiwano tylko {uczelnia1.pk!r}" + ) diff --git a/src/raport_slotow/views/autor.py b/src/raport_slotow/views/autor.py index 04b51d263..a81a11230 100644 --- a/src/raport_slotow/views/autor.py +++ b/src/raport_slotow/views/autor.py @@ -9,13 +9,14 @@ from django.views.generic import FormView, TemplateView from django_tables2 import MultiTableMixin, RequestConfig from django_weasyprint.utils import DjangoURLFetcher +from formdefaults.helpers import FormDefaultsMixin from bpp.models import Cache_Punktacja_Autora_Query_View, Dyscyplina_Naukowa from django_bpp.version import VERSION -from formdefaults.helpers import FormDefaultsMixin from nowe_raporty.views import BaseRaportAuthMixin from raport_slotow.forms.autor import AutorRaportSlotowForm from raport_slotow.tables import RaportSlotowAutorTable +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu from raport_slotow.util import InitialValuesFromGETMixin, MyExportMixin, MyTableExport from .. import const @@ -96,6 +97,7 @@ def get_tables(self): ret = [] cpaq = Cache_Punktacja_Autora_Query_View.objects.filter( autor=self.autor, + uczelnia=uczelnia_dla_odczytu(self.request), rekord__rok__gte=self.kwargs["od_roku"], rekord__rok__lte=self.kwargs["do_roku"], pkdaut__gt=0, From 4d46a6e006d8e8a0930f03798d544c65e1f5c770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 13:52:35 +0200 Subject: [PATCH 098/247] feat(multi-hosted): oswiadczenia filtruje punktacje po uczelni OswiadczeniaPublikacji.get_context_data scopuje punktacje do uczelni odczytu przez uczelnia_dla_odczytu(request), zeby wspolna praca nie ujawniala wierszy drugiej uczelni. Co-Authored-By: Claude Sonnet 4.6 --- src/oswiadczenia/conftest.py | 16 ++++++++-- src/oswiadczenia/tests.py | 58 ++++++++++++++++++++++++++++++++++++ src/oswiadczenia/views.py | 5 +++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/oswiadczenia/conftest.py b/src/oswiadczenia/conftest.py index 1d412f455..30b53456f 100644 --- a/src/oswiadczenia/conftest.py +++ b/src/oswiadczenia/conftest.py @@ -1,5 +1,17 @@ """Pytest fixtures for oswiadczenia tests.""" -from bpp.tests.test_models.test_sloty.conftest import zwarte_z_dyscyplinami +from bpp.tests.test_models.test_sloty.conftest import ( + druga_uczelnia, + jednostka_drugiej_uczelni, + rodzaj_autora_n, + zwarte_dwie_uczelnie, + zwarte_z_dyscyplinami, +) -__all__ = ["zwarte_z_dyscyplinami"] +__all__ = [ + "druga_uczelnia", + "jednostka_drugiej_uczelni", + "rodzaj_autora_n", + "zwarte_dwie_uczelnie", + "zwarte_z_dyscyplinami", +] diff --git a/src/oswiadczenia/tests.py b/src/oswiadczenia/tests.py index ccd82442b..1994fb468 100644 --- a/src/oswiadczenia/tests.py +++ b/src/oswiadczenia/tests.py @@ -211,6 +211,64 @@ def test_oswiadczenie_wiele(zwarte_z_dyscyplinami, admin_client): # noqa assert b"window.print" in res.content +@pytest.mark.django_db +def test_oswiadczenia_publikacji_kontekst_filtruje_po_uczelni( + zwarte_dwie_uczelnie, + jednostka, + druga_uczelnia, + rf, + django_user_model, +): + """OswiadczeniaPublikacji.get_context_data zwraca tylko punktacje dla + uczelni odczytu — rzedy z drugiej uczelni sa wykluczone.""" + from oswiadczenia.views import OswiadczeniaPublikacji + + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + + from bpp.models import Cache_Punktacja_Autora, Rekord + + rekord = Rekord.objects.get_for_model(zwarte_dwie_uczelnie) + + # Upewnij sie, ze obie uczelnie maja wiersze w cache + wszystkie = Cache_Punktacja_Autora.objects.filter(rekord_id=rekord.pk) + uczelnie_w_cache = set( + wszystkie.values_list("jednostka__uczelnia_id", flat=True) + ) + assert jednostka.uczelnia_id in uczelnie_w_cache, ( + "U1 nie ma wierszy w Cache_Punktacja_Autora — test bylby pusty" + ) + assert druga_uczelnia.pk in uczelnie_w_cache, ( + "U2 nie ma wierszy w Cache_Punktacja_Autora — test bylby nie-wykluczajacy" + ) + + # Superuser z _uczelnia = U1 + user = django_user_model.objects.create_superuser( + username="test_multi_uczelnia", password="pass" + ) + request = rf.get("/") + request.user = user + request._uczelnia = jednostka.uczelnia + + view = OswiadczeniaPublikacji() + view.request = request + view.kwargs = { + "content_type_id": rekord.pk[0], + "object_id": rekord.pk[1], + } + view.object = view.get_object() + + ctx = view.get_context_data() + punktacje_ids = set( + ctx["punktacje"].values_list("jednostka__uczelnia_id", flat=True) + ) + + assert punktacje_ids == {jednostka.uczelnia_id}, ( + f"Oczekiwano tylko U1 ({jednostka.uczelnia_id}), " + f"otrzymano: {punktacje_ids}" + ) + assert druga_uczelnia.pk not in punktacje_ids + + @pytest.mark.django_db def test_remove_old_oswiadczenia_export_files(): from datetime import timedelta diff --git a/src/oswiadczenia/views.py b/src/oswiadczenia/views.py index 1e8bc4f9e..f5ba472b3 100644 --- a/src/oswiadczenia/views.py +++ b/src/oswiadczenia/views.py @@ -337,10 +337,13 @@ def get_object(self, **kwargs): return self.object def get_context_data(self, **kwargs): + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + return { "object": self.object, "punktacje": Cache_Punktacja_Autora.objects.filter( - rekord_id=self.object.pk + rekord_id=self.object.pk, + jednostka__uczelnia=uczelnia_dla_odczytu(self.request), ), } From a80c94c061e92d4cf3b443db7442449c70cc6ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 13:59:22 +0200 Subject: [PATCH 099/247] =?UTF-8?q?feat(multi-hosted):=20API=20raport=5Fsl?= =?UTF-8?q?otow=20per=20uczelnia=20+=20adnotacje=20konsument=C3=B3w=20R1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zadanie 9: - Part A: RaportSlotowUczelniaViewSet i WierszViewSet już filtrowane po owner=request.user — brak przecieku cross-university; dodano komentarze dokumentujące analizę + test izolacji per-user (58 testów przechodzi). - Part B: Cache_Punktacja_Autora_Query.objects.filter(...) w ewaluacja_metryki/utils.py, views/detail.py, views/list.py — adnotacja „zawężone transitive po autor_id+dyscyplina_id; rewizja w federacji". ewaluacja_common/utils.py — brak kontekstu request/uczelnia → komentarz „do rewizji w federacji (R-federacja), nie R1". Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_raport_slotow_uczelnia.py | 85 +++++++++++++++++++ src/api_v1/viewsets/raport_slotow_uczelnia.py | 6 ++ src/ewaluacja_common/utils.py | 4 + src/ewaluacja_metryki/utils.py | 8 ++ src/ewaluacja_metryki/views/detail.py | 6 ++ src/ewaluacja_metryki/views/list.py | 2 + 6 files changed, 111 insertions(+) create mode 100644 src/api_v1/tests/test_raport_slotow_uczelnia.py diff --git a/src/api_v1/tests/test_raport_slotow_uczelnia.py b/src/api_v1/tests/test_raport_slotow_uczelnia.py new file mode 100644 index 000000000..f8d37c653 --- /dev/null +++ b/src/api_v1/tests/test_raport_slotow_uczelnia.py @@ -0,0 +1,85 @@ +"""Tests for RaportSlotowUczelnia API viewset. + +Scoping analysis (Task 9, Part A): + Both `RaportSlotowUczelniaViewSet.get_queryset` and + `RaportSlotowUczelniaWierszViewSet.get_queryset` are scoped by + `owner=request.user` / `parent__owner=request.user`. Per-user ownership + already prevents cross-university leakage — a user can only see their own + reports regardless of which uczelnia the report was created for. + No redundant uczelnia filter was added. +""" + +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.urls import reverse +from model_bakery import baker +from rest_framework.test import APIClient + +from bpp import const +from raport_slotow.models.uczelnia import RaportSlotowUczelnia + +User = get_user_model() + + +def _make_api_user(username, password="testpass123"): + """Create a user in the 'generowanie raportów' group.""" + user = User.objects.create_user(username=username, password=password) + group, _ = Group.objects.get_or_create(name=const.GR_RAPORTY_WYSWIETLANIE) + user.groups.add(group) + return user, password + + +@pytest.mark.django_db +def test_raport_slotow_uczelnia_user_sees_only_own_report(): + """A user can only see reports owned by themselves. + + owner-scoped queryset: filter(owner=request.user) prevents user_b from + seeing user_a's report, which transitively covers multi-uczelnia isolation + (no user will see another university's reports via someone else's account). + """ + user_a, pw_a = _make_api_user("user_raport_a") + user_b, pw_b = _make_api_user("user_raport_b") + + # Create a report owned by user_a + report_a = baker.make(RaportSlotowUczelnia, owner=user_a) + # Create a report owned by user_b (should not appear for user_a) + baker.make(RaportSlotowUczelnia, owner=user_b) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=_basic_auth(user_a.username, pw_a)) + + url = reverse("api_v1:raport_slotow_uczelnia-list") + response = client.get(url) + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 1 + # The only visible report must be user_a's own report + assert str(report_a.pk) in data["results"][0]["id"] + + +@pytest.mark.django_db +def test_raport_slotow_uczelnia_other_user_report_not_visible(): + """User B cannot see user A's report — owner isolation is enforced.""" + user_a, pw_a = _make_api_user("user_raport_c") + user_b, pw_b = _make_api_user("user_raport_d") + + baker.make(RaportSlotowUczelnia, owner=user_a) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=_basic_auth(user_b.username, pw_b)) + + url = reverse("api_v1:raport_slotow_uczelnia-list") + response = client.get(url) + + assert response.status_code == 200 + assert response.json()["count"] == 0 + + +def _basic_auth(username, password): + import base64 + + credentials = f"{username}:{password}" + encoded = base64.b64encode(credentials.encode()).decode() + return f"Basic {encoded}" diff --git a/src/api_v1/viewsets/raport_slotow_uczelnia.py b/src/api_v1/viewsets/raport_slotow_uczelnia.py index bf46a52b8..09bc16985 100644 --- a/src/api_v1/viewsets/raport_slotow_uczelnia.py +++ b/src/api_v1/viewsets/raport_slotow_uczelnia.py @@ -36,6 +36,9 @@ class RaportSlotowUczelniaWierszViewSet(viewsets.ReadOnlyModelViewSet): filterset_class = RaportSlotowUczelniaWierszFilterSet def get_queryset(self): + # read-side multi-uczelnia: per-user ownership (parent__owner=request.user) + # already scopes this to the requesting user's own reports; cross-university + # leakage is prevented transitively — no redundant uczelnia filter needed. return RaportSlotowUczelniaWiersz.objects.filter( parent__owner=self.request.user ) @@ -50,6 +53,9 @@ class RaportSlotowUczelniaViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated, IsGrupaRaportyWyswietlanie] def get_queryset(self): + # read-side multi-uczelnia: per-user ownership (owner=request.user) already + # scopes this to the requesting user's own reports; cross-university leakage + # is prevented transitively — no redundant uczelnia filter needed. return RaportSlotowUczelnia.objects.filter(owner=self.request.user) serializer_class = RaportSlotowUczelniaSerializer diff --git a/src/ewaluacja_common/utils.py b/src/ewaluacja_common/utils.py index c6a209759..608e88c64 100644 --- a/src/ewaluacja_common/utils.py +++ b/src/ewaluacja_common/utils.py @@ -25,6 +25,10 @@ def get_lista_prac(nazwa_dyscypliny): from bpp.models import Cache_Punktacja_Autora_Query + # read-side: do rewizji w federacji (R-federacja), nie R1. + # dozwoleni_autorzy pochodzi z IloscUdzialowDlaAutoraZaRok (dyscyplina-wide), + # brak kontekstu uczelnia/request — zawężenie per-uczelnia wymaga przebudowy + # sygnatury w ramach prac federacyjnych. return ( Cache_Punktacja_Autora_Query.objects.filter( rekord__rok__gte=const.ROK_MIN, diff --git a/src/ewaluacja_metryki/utils.py b/src/ewaluacja_metryki/utils.py index 02dcd59e8..393d064dd 100644 --- a/src/ewaluacja_metryki/utils.py +++ b/src/ewaluacja_metryki/utils.py @@ -105,6 +105,8 @@ def oblicz_metryki_dla_autora( from bpp.models.cache import Cache_Punktacja_Autora_Query if prace_nazbierane_ids: + # read-side multi-uczelnia: zawężone transitive po autor_id+dyscyplina_id; + # rewizja per-uczelnia metryk należy do federacji, nie R1. prace_nazbierane_rekord_ids = list( Cache_Punktacja_Autora_Query.objects.filter( pk__in=prace_nazbierane_ids @@ -129,6 +131,8 @@ def oblicz_metryki_dla_autora( # Convert cache PKs to stable rekord_ids if prace_wszystkie_ids: + # read-side multi-uczelnia: zawężone transitive po autor_id+dyscyplina_id; + # rewizja per-uczelnia metryk należy do federacji, nie R1. prace_wszystkie_rekord_ids = list( Cache_Punktacja_Autora_Query.objects.filter( pk__in=prace_wszystkie_ids @@ -336,6 +340,8 @@ def _calculate_metrics_data( # Convert cache PKs to stable rekord_ids if prace_nazbierane_ids: + # read-side multi-uczelnia: zawężone transitive po autor_id+dyscyplina_id; + # rewizja per-uczelnia metryk należy do federacji, nie R1. prace_nazbierane_rekord_ids = list( Cache_Punktacja_Autora_Query.objects.filter( pk__in=prace_nazbierane_ids @@ -360,6 +366,8 @@ def _calculate_metrics_data( # Convert cache PKs to stable rekord_ids if prace_wszystkie_ids: + # read-side multi-uczelnia: zawężone transitive po autor_id+dyscyplina_id; + # rewizja per-uczelnia metryk należy do federacji, nie R1. prace_wszystkie_rekord_ids = list( Cache_Punktacja_Autora_Query.objects.filter( pk__in=prace_wszystkie_ids diff --git a/src/ewaluacja_metryki/views/detail.py b/src/ewaluacja_metryki/views/detail.py index 5a457b26a..f1f34aa98 100644 --- a/src/ewaluacja_metryki/views/detail.py +++ b/src/ewaluacja_metryki/views/detail.py @@ -132,6 +132,8 @@ def _get_collected_works_context(self, metryka): # JSONField stores tuples as lists: (51, 123) -> [51, 123] # Query by stable rekord_id (survives cache rebuilds from pin/unpin) + # read-side multi-uczelnia: zawężone transitive po autor_id+dyscyplina_id; + # rewizja per-uczelnia metryk należy do federacji, nie R1. prace_nazbierane = ( Cache_Punktacja_Autora_Query.objects.filter( rekord_id__in=metryka.prace_nazbierane, @@ -177,6 +179,8 @@ def _get_chart_data_context(self, metryka): # Convert JSON lists to tuples for PostgreSQL tuple matching # Query by stable rekord_id + # read-side multi-uczelnia: zawężone transitive po autor_id+dyscyplina_id; + # rewizja per-uczelnia metryk należy do federacji, nie R1. prace_wszystkie = ( Cache_Punktacja_Autora_Query.objects.filter( rekord_id__in=metryka.prace_wszystkie, @@ -250,6 +254,8 @@ def _get_all_works_context(self, metryka): if metryka.prace_wszystkie: # Convert JSON lists to tuples for PostgreSQL tuple matching # Query by stable rekord_id (survives cache rebuilds) + # read-side multi-uczelnia: zawężone transitive po autor_id+dyscyplina_id; + # rewizja per-uczelnia metryk należy do federacji, nie R1. prace_wszystkie = ( Cache_Punktacja_Autora_Query.objects.filter( rekord_id__in=metryka.prace_wszystkie, diff --git a/src/ewaluacja_metryki/views/list.py b/src/ewaluacja_metryki/views/list.py index a9f30e054..e85070918 100644 --- a/src/ewaluacja_metryki/views/list.py +++ b/src/ewaluacja_metryki/views/list.py @@ -256,6 +256,8 @@ def _calculate_3d_metrics(self, metryki, widok): # Get all works for this author/discipline if metryka.prace_nazbierane: # Query by stable rekord_id + # read-side multi-uczelnia: zawężone transitive po autor_id+dyscyplina_id; + # rewizja per-uczelnia metryk należy do federacji, nie R1. prace = Cache_Punktacja_Autora_Query.objects.filter( rekord_id__in=metryka.prace_nazbierane, autor_id=metryka.autor_id, From 0160addb4bcc1ebeb82cc57814b166acc5a12571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 14:02:18 +0200 Subject: [PATCH 100/247] =?UTF-8?q?docs(multi-hosted):=20udokumentuj=20asy?= =?UTF-8?q?metri=C4=99=20skupia=5Fpracownikow=20(read-side)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/bpp/models/sloty/core.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/bpp/models/sloty/core.py b/src/bpp/models/sloty/core.py index bc6966c10..a34f9f28a 100644 --- a/src/bpp/models/sloty/core.py +++ b/src/bpp/models/sloty/core.py @@ -411,6 +411,12 @@ def _zapisz(self, kalk, uczelnia): if not self.original.pk: return + # UWAGA (read-side): autorzy_z_dyscypliny zapisani w + # Cache_Punktacja_Dyscypliny mogą zawierać PK autora z jednostki + # skupia_pracownikow=False, dla którego NIE powstaje wiersz + # Cache_Punktacja_Autora (ten filtr go pomija). Konsumenci widoku + # nie powinni zakładać relacji 1:1 między listą autorów w CPD a + # wierszami CPA. for wa in self.original.autorzy_set.filter(jednostka__uczelnia=uczelnia): if ( not wa.afiliuje From fa830898536aad48aa630299e9b8b9cabd968897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 14:08:00 +0200 Subject: [PATCH 101/247] =?UTF-8?q?docs(multi-hosted):=20HANDOFF=20-=20R1?= =?UTF-8?q?=20slot=20read-side=20ZROBIONE,=20R2=20liczba=5Fn=20nast=C4=99p?= =?UTF-8?q?ny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/superpowers/HANDOFF-multi-hosted.md | 25 +++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index 2c426f765..aab895c60 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -172,15 +172,22 @@ Zatwierdzony wariant: **A (Verify → Stabilize → Investigate → Spec)**. WYGASZANA (husk, OUT); rankingi/API nie czytają cache wprost (API przez raport_slotow); filtr czytania jednolity `jednostka__uczelnia`; ewaluacja_liczba_n częściowo per-uczelnia z luką write (wątek G). -4. ⏭ **Specy read-side — TRZY niezależne** (każdy: brainstorm→spec→plan): - - **R1 — slot read-side (A):** widok eksponuje uczelnię + pipeline temp-tabel - niesie uczelnię + raport_slotow/API/proste filtry (metryki/oswiadczenia/ - common/bpp-core) filtrują po uczelni oglądającego. Hardening #2 (indeks), - #3 (asymetria) wpiąć tu. - - **R2 — ewaluacja_liczba_n per-uczelnia (G):** write+read, schemat - `IloscUdzialow*` + zawężenie liczenia. - - **F — federacja optymalizacji (B):** najtrudniejszy, ostatni. -5. (później) Integrator (D); drobne (E); NOT NULL na uczelnia (#5). +4. Specy read-side — TRZY niezależne (każdy: brainstorm→spec→plan): + - ✅ **R1 — slot read-side (A): ZROBIONE 2026-06-03.** Spec + `specs/2026-06-03-per-uczelnia-sloty-read-side-design.md`, plan + `plans/2026-06-03-per-uczelnia-sloty-read-side-R1.md`. 10 tasków + (subagent-driven, każdy spec+quality review), final review „ready to merge", + 269 testów konsumentów zielonych. Widok eksponuje `uczelnia_id` (mig 0426), + indeks (0427), helper `uczelnia_dla_odczytu` (hybryda), `zbieraj_sloty`/ + `autorzy_zerowi`/`RaportSlotowUczelnia`(mig raport_slotow 0020 +backfill)/ + `RaportSlotow`(autor)/`oswiadczenia` filtrują po uczelni; API owner-scoped; + ewaluacja_metryki/common adnotowane (już-zawężone/federacja). Hardening + #2/#3 wpięte. **Niepushowane.** + - ⏭ **R2 — ewaluacja_liczba_n per-uczelnia (G):** NASTĘPNY. write+read, schemat + `IloscUdzialow*` (+ FK uczelnia, migracja/backfill, unique_together) + + zawężenie liczenia udziałów per uczelnia. + - **F — federacja optymalizacji (B):** ODŁOŻONA (nie teraz, decyzja usera). +5. ⏭ Po R2: **integrator (D)**; potem drobne (E); NOT NULL na uczelnia (#5). Backlog hardeningu (C): #2/#3 → R1; #1 (HST globalnie) → F (federacja). From 16e0cb3699e1b8f29372be475b6f7a26929f8ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 14:35:54 +0200 Subject: [PATCH 102/247] =?UTF-8?q?feat(oidc):=20addytywne=20logowanie=20p?= =?UTF-8?q?rzez=20Keycloak=20(OIDC=20Authorization=20Code)=20=E2=80=94=20s?= =?UTF-8?q?pike=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(oidc): spec spike'a logowania przez Keycloak (OIDC Authorization Code) Faza 1: discovery spike — mozilla-django-oidc, auto-create kont, env kluczowane skrotem uczelni (DJANGO_BPP_OIDC__CLIENT_ID/SECRET/ISSUER) z fallbackiem bez skrotu. Backend loguje claimy, gating rol/deny w fazie 2. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(oidc): addytywne logowanie przez Keycloak (OIDC Authorization Code) — spike Nowa apka oidc_integration na mozilla-django-oidc. Aktywuje sie TYLKO gdy w srodowisku jest komplet DJANGO_BPP_OIDC__{CLIENT_ID,CLIENT_SECRET, ISSUER} (z fallbackiem bez skrotu). Skrot uczelni wykrywany automatycznie. Addytywnie: nie przejmuje /accounts/login/, dokłada przycisk obok hasla/ ORCID/Microsoft, backend DOPISANY do listy (ModelBackend zostaje → logowanie haslem dziala rownolegle). Backend zarejestrowany w EXTERNAL_AUTH_BACKENDS, wiec ConditionalPasswordChangeMiddleware pomija polityke hasel dla OIDC. Faza 1 (spike): BppOIDCBackend.verify_claims loguje claimy, zeby zobaczyc co wystawia Keycloak; auto-create kont wlaczone. Gating po rolach/grupach (kogo nie wpuszczamy / komu nie tworzymy kont) — faza 2. Endpointy wyprowadzane z issuera konwencja Keycloaka. Callback do zarejestrowania w realmie: https:///oidc/callback/ Przy okazji: naprawiony orphan w naglowku login.html (djlint H025). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(oidc): faza 2a — konto kazdemu (bez is_staff) + zrzut claimow na stderr BppOIDCBackend.create_user zaklada zwykle konto KAZDEMU zalogowanemu przez Keycloak: bez is_staff/is_superuser, haslo nieuzywalne (login tylko OIDC), username = preferred_username -> email -> sub. Wywolywane tylko gdy nie ma juz konta dopasowanego po e-mailu (filter_users_by_claims). verify_claims dodatkowo wypisuje klucze+wartosci claimow na stderr (banner [OIDC], sortowane) — discovery: zobaczyc co realnie wystawia realm KA bez interaktywnego testu. grep '[OIDC]' w logu serwera. Stan tymczasowy: gating po rolach/grupach (kogo nie wpuszczamy / komu nie tworzymy konta) wejdzie w fazie 2b, gdy poznamy realne klucze/role. Testy: 13/13 (w tym create_user na bazie: zwykle konto, fallback username, brak is_staff, nieuzywalne haslo; zrzut kluczy na stderr). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(oidc): przypisuj uczelnie (accessible_uczelnie) wg skrotu OIDC Konto zakladane przez OIDC dostaje accessible_uczelnie (M2M z PR #189) z Uczelnia o skrot == OIDC_LOGIN_SKROT (skrot_iexact). Ten sam skrot (np. UAFM), ktory wybral client_id/secret, wybiera tez uczelnie -> docelowe 3 backendy = 3 uczelnie przypisuja wlasciwa automatycznie. create_user przypisuje przy zakladaniu; update_user dopilnowuje istniejacych kont przy kolejnym logowaniu (idempotentnie). Brak skrotu / brak pasujacej uczelni -> konto bez przypisania (log), nie blad. Testy: +3 (przypisanie wg skrotu, brak dopasowania, brak skrotu). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../2026-06-03-oidc-keycloak-login-design.md | 157 ++++++++++++++++++ pyproject.toml | 1 + src/bpp/context_processors/oidc.py | 14 ++ src/django_bpp/external_auth.py | 1 + src/django_bpp/settings/base.py | 42 +++++ .../templates/registration/login.html | 10 +- src/django_bpp/urls.py | 9 + src/oidc_integration/__init__.py | 0 src/oidc_integration/apps.py | 6 + src/oidc_integration/backends.py | 121 ++++++++++++++ src/oidc_integration/conf.py | 76 +++++++++ src/oidc_integration/tests/__init__.py | 0 src/oidc_integration/tests/test_backends.py | 125 ++++++++++++++ src/oidc_integration/tests/test_conf.py | 85 ++++++++++ uv.lock | 29 ++++ 15 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-06-03-oidc-keycloak-login-design.md create mode 100644 src/bpp/context_processors/oidc.py create mode 100644 src/oidc_integration/__init__.py create mode 100644 src/oidc_integration/apps.py create mode 100644 src/oidc_integration/backends.py create mode 100644 src/oidc_integration/conf.py create mode 100644 src/oidc_integration/tests/__init__.py create mode 100644 src/oidc_integration/tests/test_backends.py create mode 100644 src/oidc_integration/tests/test_conf.py diff --git a/docs/superpowers/specs/2026-06-03-oidc-keycloak-login-design.md b/docs/superpowers/specs/2026-06-03-oidc-keycloak-login-design.md new file mode 100644 index 000000000..b218a0372 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-oidc-keycloak-login-design.md @@ -0,0 +1,157 @@ +# Logowanie przez OpenID Connect (Keycloak) — design + +Data: 2026-06-03 +Status: spec dla **spike'a discovery** (faza 1). Reguły ról/deny dopisywane +po obejrzeniu realnych claimów. + +## Cel + +Umożliwić użytkownikom logowanie do BPP przez zewnętrzny serwer OpenID +Connect (Keycloak realm `KA` na `auth.uafm.edu.pl`), przepływem +**Authorization Code** (redirect). Hasła użytkowników nigdy nie dotykają +BPP — uwierzytelnia je Keycloak. + +## Decyzje (ustalone z użytkownikiem) + +- **Flow**: Authorization Code (redirect), nie ROPC. +- **Provisioning**: auto-tworzenie kont `BppUser` przy pierwszym logowaniu. + (Faza 2 dołoży filtry/deny — patrz „Poza zakresem spike'a”.) +- **Biblioteka**: `mozilla-django-oidc`. Wybrana, bo jej udokumentowane + hooki (`verify_claims`, `create_user`, `filter_users_by_claims`, + `update_user`) mapują się 1:1 na wymagane reguły „kogo nie wpuszczamy / + komu nie tworzymy kont / jak nadać role”. +- **Konfiguracja**: zmienne środowiskowe, kluczowane skrótem uczelni, z + fallbackiem bez skrótu: + + | Zmienna (z prefiksem) | Fallback | Znaczenie | + |---------------------------------------|---------------------------------|---------------| + | `DJANGO_BPP_OIDC_UAFM_CLIENT_ID` | `DJANGO_BPP_OIDC_CLIENT_ID` | client_id | + | `DJANGO_BPP_OIDC_UAFM_CLIENT_SECRET` | `DJANGO_BPP_OIDC_CLIENT_SECRET` | client_secret | + | `DJANGO_BPP_OIDC_UAFM_ISSUER` | `DJANGO_BPP_OIDC_ISSUER` | issuer URL | + + `UAFM` = `Uczelnia.skrot`. Aktywny skrót wykrywany automatycznie: jeśli + w środowisku jest dokładnie jeden komplet `DJANGO_BPP_OIDC__*`, brany + jest on. Pierwszeństwo per-pole: **wariant z prefiksem > bare** (specyficzny + bije generyczny), bare jest fallbackiem. Twarde wiązanie z + `Uczelnia.objects.get_default().skrot` z bazy — faza 2. + +## Współistnienie metod logowania (wymóg) + +Strona logowania ma oferować **równocześnie wiele metod**: wbudowane +logowanie hasłem Django **ORAZ** OIDC **ORAZ** ORCID **ORAZ** Microsoft — +nie „jedna przejmuje stronę”. Konsekwencje dla spike'a OIDC: + +- OIDC jest **czysto addytywne**: NIE dotyka `/accounts/login/`, montuje + tylko trasy `oidc/` i dokłada przycisk na istniejącej stronie (wzorzec + identyczny jak istniejący przycisk ORCID: formularz hasła zostaje). +- Backend jest **dopisywany** do `AUTHENTICATION_BACKENDS` z zachowaniem + `ModelBackend` — logowanie hasłem działa równolegle. +- **Poza spike'em** (osobny krok): pełne ujednolicenie strony logowania w + „chooser” obejmujący też Microsoft — dziś `urls.py` przy włączonym + `microsoft_auth` *redirectuje* `/accounts/login/` na Microsoft i spycha + hasło na `/accounts/login/local/`. Do modelu „wiele metod naraz” trzeba + odwrócić tę logikę i znieść strażnik wzajemnego wykluczenia LDAP/Microsoft + z `base.py`. To dotyka zachowania Microsoftu → nie mieszamy do spike'a. + +## Architektura + +Nowa aplikacja `src/oidc_integration/` (wzorzec `src/orcid_integration/`): + +- `conf.py` — `discover_oidc_config()`: czyta env wg reguły wyżej, zwraca + `{client_id, client_secret, issuer}` albo `None` (gdy nie skonfigurowano). + Z `issuer` wyprowadza 4 endpointy konwencją Keycloaka + (`/protocol/openid-connect/{auth,token,userinfo,certs}`). +- `backends.py` — `BppOIDCBackend(OIDCAuthenticationBackend)`. + **Faza 2a (discovery, zaimplementowana):** + - `verify_claims()` — **wypisuje klucze i wartości claimów na stderr** + (banner `[OIDC]`, sortowane klucze) + `logger.info`, potem deleguje do + `super()`. Cel: zobaczyć realne klucze realmu bez interaktywnego testu. + - `create_user()` — **zakłada zwykłe konto KAŻDEMU** zalogowanemu, **bez** + `is_staff`/`is_superuser`, hasło nieużywalne (`username` = + `preferred_username` → `email` → `sub`). Stan tymczasowy do czasu poznania + kluczy/ról. + - **Przypisanie uczelni**: konto dostaje `accessible_uczelnie` (M2M z + PR #189) z `Uczelnia` o `skrot` == skrótowi z konfiguracji OIDC + (`OIDC_LOGIN_SKROT`). Ten sam skrót wybiera client_id/secret **i** uczelnię + → docelowe „3 backendy = 3 uczelnie" przypisują właściwą automatycznie. + `update_user()` dopilnowuje przypisania też istniejącym kontom + (idempotentnie). Brak skrótu / brak pasującej uczelni → konto bez + przypisania (log), nie błąd. + + > **Baza:** ten branch wyrasta z `feature/multi-hosted-config` (PR #189), + > bo `accessible_uczelnie` pochodzi stamtąd. PR #295 mergowalny dopiero po + > #189 (albo do jego brancha). + - **Faza 2b (po obejrzeniu kluczy):** gating po rolach/grupach + (`realm_access.roles`) w `verify_claims`, warunek „komu nie tworzymy + konta" w `create_user`, mapowanie ról w `update_user`, powiązanie z + `Autor` przez `person_id` w `filter_users_by_claims`. +- `apps.py`, `urls.py` (re-eksport `mozilla_django_oidc.urls`), + `templates/` (przycisk „Zaloguj przez ”), `tests/`. + +### Wpięcie w `settings/base.py` + +Blok warunkowy à la `if MICROSOFT_AUTH_CLIENT_ID:` — aktywny tylko gdy +`discover_oidc_config()` zwróci konfigurację: + +- dodaje `oidc_integration` do `INSTALLED_APPS`, +- ustawia `OIDC_RP_CLIENT_ID/SECRET`, `OIDC_OP_*_ENDPOINT`, + `OIDC_RP_SIGN_ALGO="RS256"`, `OIDC_RP_SCOPES="openid email profile"`, + `OIDC_CREATE_USER=True`, +- dopisuje `oidc_integration.backends.BppOIDCBackend` do + `AUTHENTICATION_BACKENDS`. + +### Pozostałe punkty wpięcia + +- `external_auth.py` — dopisać ścieżkę backendu do `EXTERNAL_AUTH_BACKENDS`, + żeby `ConditionalPasswordChangeMiddleware` pominął politykę haseł dla + userów OIDC. +- `urls.py` — warunkowy montaż `oidc/` (jak `microsoft_auth`). +- Szablon logowania — przycisk inicjujący `oidc_authentication_init`. + +## Przepływ danych + +1. User klika „Zaloguj przez UAFM” → `oidc_authentication_init` → + redirect do `authorization_endpoint` Keycloaka (z state, nonce, PKCE). +2. User uwierzytelnia się na Keycloaku → powrót na + `oidc/callback/` z kodem. +3. `OIDCAuthenticationCallbackView` wymienia kod na tokeny (backchannel), + waliduje `id_token` przez JWKS, pobiera userinfo. +4. `BppOIDCBackend.verify_claims()` — loguje claimy, przepuszcza. +5. Auto-tworzenie/pobranie `BppUser`, `login()`, sesja. + +## Obsługa błędów + +- Brak env → apka OIDC w ogóle się nie ładuje (no-op), zero wpływu na + istniejące logowanie hasłem/Microsoft/ORCID. +- Walidacja tokenu/JWKS — po stronie `mozilla-django-oidc` (rzuca → + redirect na stronę błędu logowania). +- Logowanie odmów/wyjątków przez `logger` (zgodnie z regułą: żadnego + cichego `except: pass`). + +## Testy + +- `conf.py`: rozwiązywanie env (prefiks vs fallback, brak konfiguracji, + wyprowadzanie endpointów z issuer) — czysty unit, bez sieci. +- `backends.py`: `verify_claims` loguje i deleguje; auto-create tworzy + `BppUser` (z `@pytest.mark.django_db`, `baker`). +- Bez e2e przeciw realnemu Keycloakowi — to wymaga sekretów i sieci; + testujemy ręcznie po wgraniu env. + +## Poza zakresem spike'a (faza 2, po obejrzeniu claimów) + +- Gating studentów: filtr po roli/grupie z tokenu („nie wpuszczamy” / + „nie tworzymy kont”). +- Mapowanie ról/grup Keycloaka → grupy/uprawnienia Django. +- Powiązanie z istniejącym `Autor` przez claim `person_id`. +- Wylogowanie z Keycloaka (`end_session_endpoint`). +- Twarde wiązanie skrótu z `Uczelnia` z bazy zamiast auto-detekcji env. +- Przełączenie wyprowadzania endpointów z konwencji Keycloaka na fetch + `.well-known/openid-configuration` z cache. + +## Wymagania od administratora Keycloak (realm KA) + +1. Confidential client → `client_id` + `client_secret` (mam). +2. Standard Flow ON; Valid Redirect URI: `https://bpp./oidc/callback/`. +3. Potwierdzenie `client_secret_basic`/`post` (nie tylko private_key_jwt/mTLS). +4. Scope'y w tokenie: `openid profile email` + unikalny `email`, `sub`. +5. (Faza 2) rola/grupa odróżniająca pracowników od studentów. diff --git a/pyproject.toml b/pyproject.toml index b43ce1d9c..d8a283ec6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,7 @@ dependencies = [ "django-site-blog>=0.2.1", "django-first-run-wizard>=0.1.1", "uvicorn-worker>=0.4.0", + "mozilla-django-oidc>=4.0,<5", ] [project.optional-dependencies] diff --git a/src/bpp/context_processors/oidc.py b/src/bpp/context_processors/oidc.py new file mode 100644 index 000000000..9601e10c5 --- /dev/null +++ b/src/bpp/context_processors/oidc.py @@ -0,0 +1,14 @@ +from django.conf import settings + + +def oidc_auth_status(request): + """Udostępnia szablonom status logowania OIDC (bez ruchu sieciowego). + + Flagi ustawia ``settings/base.py`` tylko gdy konfiguracja OIDC jest + obecna w środowisku; w przeciwnym razie zwracamy wartości domyślne + (wyłączone), jak robią to context processory ORCID/Microsoft. + """ + return { + "oidc_login_enabled": getattr(settings, "OIDC_LOGIN_ENABLED", False), + "oidc_login_skrot": getattr(settings, "OIDC_LOGIN_SKROT", ""), + } diff --git a/src/django_bpp/external_auth.py b/src/django_bpp/external_auth.py index d29b706a9..bbc307c66 100644 --- a/src/django_bpp/external_auth.py +++ b/src/django_bpp/external_auth.py @@ -8,4 +8,5 @@ EXTERNAL_AUTH_BACKENDS = { "microsoft_auth.backends.MicrosoftAuthenticationBackend", "orcid_integration.backends.OrcidAuthenticationBackend", + "oidc_integration.backends.BppOIDCBackend", } diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index 45cea0043..d9e94ddd9 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -296,6 +296,7 @@ def int_or_none(v): "bpp.context_processors.pbn_token_aktualny.pbn_token_aktualny", "bpp.context_processors.microsoft_auth.microsoft_auth_status", "bpp.context_processors.orcid.orcid_auth_status", + "bpp.context_processors.oidc.oidc_auth_status", "bpp.context_processors.testing.testing", "cookielaw.context_processors.cookielaw", "django_countdown.context_processors.countdown_context", @@ -1197,6 +1198,47 @@ def can_login_as(request, target_user): "orcid_integration.backends.OrcidAuthenticationBackend", ] +# +# Konfiguracja logowania OpenID Connect (Keycloak) — opcjonalna i ADDYTYWNA. +# +# Aktywuje się TYLKO gdy w środowisku jest komplet DJANGO_BPP_OIDC_*_{CLIENT_ID, +# CLIENT_SECRET,ISSUER} (z prefiksem skrótu uczelni lub bez). Nie przejmuje +# strony logowania — dokłada się jako kolejna metoda obok hasła/Microsoft/ORCID. +# Backend jest DOPISYWANY do listy (ModelBackend zostaje → logowanie hasłem +# działa równolegle). +# +from oidc_integration.conf import discover_oidc_config # noqa: E402 + +_OIDC_CONFIG = discover_oidc_config() +OIDC_LOGIN_ENABLED = _OIDC_CONFIG is not None +OIDC_LOGIN_SKROT = (_OIDC_CONFIG or {}).get("skrot") or "" + +if _OIDC_CONFIG: + if "oidc_integration" not in INSTALLED_APPS: + INSTALLED_APPS = list(INSTALLED_APPS) + ["oidc_integration"] + + OIDC_RP_CLIENT_ID = _OIDC_CONFIG["client_id"] + OIDC_RP_CLIENT_SECRET = _OIDC_CONFIG["client_secret"] + + _oidc_endpoints = _OIDC_CONFIG["endpoints"] + OIDC_OP_AUTHORIZATION_ENDPOINT = _oidc_endpoints["authorization"] + OIDC_OP_TOKEN_ENDPOINT = _oidc_endpoints["token"] + OIDC_OP_USER_ENDPOINT = _oidc_endpoints["userinfo"] + OIDC_OP_JWKS_ENDPOINT = _oidc_endpoints["jwks"] + + # Keycloak podpisuje id_token RS256; scope email konieczny do auto-create. + OIDC_RP_SIGN_ALGO = "RS256" + OIDC_RP_SCOPES = "openid email profile" + OIDC_CREATE_USER = True + + AUTHENTICATION_BACKENDS = list(AUTHENTICATION_BACKENDS) + [ + "oidc_integration.backends.BppOIDCBackend", + ] + +# +# Koniec konfiguracji OpenID Connect +# + # # Konfiguracja serwera pocztowego # diff --git a/src/django_bpp/templates/registration/login.html b/src/django_bpp/templates/registration/login.html index 902d2297a..f7a770e3d 100644 --- a/src/django_bpp/templates/registration/login.html +++ b/src/django_bpp/templates/registration/login.html @@ -6,7 +6,7 @@ {% block breadcrumbs %} {{ block.super }} -
  • Logowanie +
  • Logowanie
  • {% endblock %} {% block content %} @@ -24,6 +24,14 @@ {% endif %} + {% if oidc_login_enabled %} + + {% endif %} {% if not microsoft_login_enabled %} Zapomniałem/am hasła. {% endif %} diff --git a/src/django_bpp/urls.py b/src/django_bpp/urls.py index 9799b04f3..4beb3c4c3 100644 --- a/src/django_bpp/urls.py +++ b/src/django_bpp/urls.py @@ -374,6 +374,15 @@ def protected_media_serve(request, path, document_root=None): ] +# Logowanie OpenID Connect — addytywne, montowane tylko gdy apka aktywna. +# Trasy mozilla-django-oidc: oidc/authenticate/ (start), oidc/callback/ (powrót). +# Redirect URI do zarejestrowania w Keycloaku: https:///oidc/callback/ +if apps.is_installed("oidc_integration"): + urlpatterns += [ + path("oidc/", include("mozilla_django_oidc.urls")), + ] + + if settings.DJANGO_BPP_ENABLE_PROMETHEUS: urlpatterns += [ path("", include("django_prometheus.urls")), diff --git a/src/oidc_integration/__init__.py b/src/oidc_integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/oidc_integration/apps.py b/src/oidc_integration/apps.py new file mode 100644 index 000000000..4c47ba16b --- /dev/null +++ b/src/oidc_integration/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OidcIntegrationConfig(AppConfig): + name = "oidc_integration" + verbose_name = "Integracja z OpenID Connect (Keycloak)" diff --git a/src/oidc_integration/backends.py b/src/oidc_integration/backends.py new file mode 100644 index 000000000..316f56ef8 --- /dev/null +++ b/src/oidc_integration/backends.py @@ -0,0 +1,121 @@ +import logging +import sys + +from django.conf import settings +from mozilla_django_oidc.auth import OIDCAuthenticationBackend + +logger = logging.getLogger(__name__) + + +def _dump_claims_to_stderr(claims): + """Wypisz na stderr klucze i wartości claimów z Keycloaka. + + Cel discovery: zobaczyć, co realnie wystawia realm KA, bez interaktywnego + klikania — wynik ląduje w konsoli runservera / logu serwera. Banner ułatwia + wyłowienie tego w hałasie logów (`grep '\\[OIDC\\]'`). + """ + keys = sorted(claims.keys()) + banner = "=" * 70 + print(banner, file=sys.stderr) + print("[OIDC] Claimy otrzymane z Keycloaka (userinfo):", file=sys.stderr) + print(f"[OIDC] Klucze ({len(keys)}): {', '.join(keys)}", file=sys.stderr) + for key in keys: + print(f"[OIDC] {key} = {claims[key]!r}", file=sys.stderr) + print(banner, file=sys.stderr, flush=True) + + +class BppOIDCBackend(OIDCAuthenticationBackend): + """Backend logowania OpenID Connect (Keycloak) dla BPP. + + Faza 2a (discovery, obecna): zakładaj konto KAŻDEMU zalogowanemu — + zwykłe konto, **bez** ``is_staff``/``is_superuser`` — i wypisz na stderr + klucze claimów, żeby zobaczyć, co wystawia realm. To krok poznawczy: na + podstawie realnych kluczy zaprojektujemy właściwe reguły. + + ⚠️ „Konto każdemu" oznacza, że dowolna osoba z realmu KA (potencjalnie też + studenci — patrz scope ``kierunek-oidc``) dostanie konto BPP. Bezpieczne o + tyle, że bez ``is_staff`` nie ma dostępu do panelu/edycji. To stan tymczasowy. + + Przypisanie uczelni: konto dostaje ``accessible_uczelnie`` (M2M z PR #189) + z uczelnią o ``skrot`` == skrótowi z konfiguracji OIDC. Ten sam skrót + (np. ``UAFM``), który wybrał ``client_id``/``client_secret``, mapuje 1:1 na + ``Uczelnia.skrot`` — dzięki czemu docelowe „3 backendy = 3 uczelnie" same + przypisują właściwą uczelnię, bez dodatkowej konfiguracji. + + Faza 2b (po obejrzeniu kluczy) — TU wejdą reguły: + * gating po rolach/grupach (``realm_access.roles``): „kogo nie wpuszczamy" + → ``verify_claims`` zwraca ``False``; + * „komu nie tworzymy konta" → warunek w ``create_user``; + * mapowanie ról Keycloaka na grupy/uprawnienia → ``update_user``; + * powiązanie z istniejącym ``Autor`` przez claim ``person_id`` → + ``filter_users_by_claims``. + """ + + def verify_claims(self, claims): + _dump_claims_to_stderr(claims) + logger.info("OIDC: otrzymane claimy: %r", claims) + return super().verify_claims(claims) + + def _assign_uczelnia(self, user): + """Przypisz uczelnię docelową (``accessible_uczelnie``) wg skrótu OIDC. + + Idempotentne (``.add`` na M2M). Brak skrótu albo brak pasującej + ``Uczelnia`` → konto bez przypisania (z logiem), nie błąd. + """ + skrot = getattr(settings, "OIDC_LOGIN_SKROT", "") or "" + if not skrot: + logger.info("OIDC: brak skrótu uczelni w konfiguracji — bez przypisania") + return + + # Import lokalny: backends.py bywa ładowany bardzo wcześnie (settings). + from bpp.models import Uczelnia + + uczelnia = Uczelnia.objects.filter(skrot__iexact=skrot).first() + if uczelnia is None: + logger.warning( + "OIDC: nie znaleziono Uczelni o skrocie=%s — konto bez przypisania", + skrot, + ) + return + + user.accessible_uczelnie.add(uczelnia) + logger.info( + "OIDC: przypisano uczelnię skrot=%s do konta %s", skrot, user.username + ) + + def update_user(self, user, claims): + # Dopilnuj przypisania uczelni także istniejącym kontom przy kolejnym + # logowaniu (idempotentnie) — np. założonym przed wprowadzeniem tej logiki. + self._assign_uczelnia(user) + return user + + def create_user(self, claims): + """Załóż zwykłe konto (bez is_staff) na podstawie claimów. + + ``username`` = ``preferred_username`` → ``email`` → ``sub`` (pierwszy + niepusty). Hasło ustawiane na nieużywalne — logowanie wyłącznie przez + OIDC. Wywoływane tylko, gdy ``filter_users_by_claims`` (domyślnie po + e-mailu) nie znajdzie istniejącego konta. + """ + username = ( + claims.get("preferred_username") or claims.get("email") or claims.get("sub") + ) + email = claims.get("email") or "" + + user = self.UserModel.objects.create_user(username=username, email=email) + user.first_name = claims.get("given_name") or "" + user.last_name = claims.get("family_name") or "" + user.is_staff = False + user.is_superuser = False + user.is_active = True + user.set_unusable_password() + user.save() + + self._assign_uczelnia(user) + + logger.info( + "OIDC: utworzono konto username=%s email=%s (zwykłe, bez is_staff)", + username, + email, + ) + return user diff --git a/src/oidc_integration/conf.py b/src/oidc_integration/conf.py new file mode 100644 index 000000000..322a47b14 --- /dev/null +++ b/src/oidc_integration/conf.py @@ -0,0 +1,76 @@ +"""Odczyt konfiguracji OpenID Connect ze zmiennych środowiskowych. + +Reguła rozwiązywania pojedynczego pola: najpierw wariant z prefiksem skrótu +uczelni ``DJANGO_BPP_OIDC__``, a gdy go brak — wariant bez +skrótu ``DJANGO_BPP_OIDC_``. Wariant z prefiksem ma więc pierwszeństwo +(specyficzny > generyczny), bare jest fallbackiem. + +Aktywny ```` wykrywany jest automatycznie: jeśli w środowisku jest +dokładnie jeden komplet ``DJANGO_BPP_OIDC__CLIENT_ID``, brany jest ten +skrót. Twarde wiązanie z ``Uczelnia.skrot`` z bazy — faza 2. + +Moduł celowo NIE importuje Django (czytany jest z ``settings/base.py`` zanim +Django w pełni wstanie) — operuje wyłącznie na ``os.environ``. +""" + +import os +import re + +_PREFIX = "DJANGO_BPP_OIDC_" +_FIELDS = ("CLIENT_ID", "CLIENT_SECRET", "ISSUER") + +# Skrót składa się z liter/cyfr (bez podkreślników), żeby nie kolidować z +# bare-wariantem DJANGO_BPP_OIDC_CLIENT_ID (gdzie "CLIENT" nie jest skrótem). +_SKROT_RE = re.compile(rf"^{re.escape(_PREFIX)}(?P[A-Z0-9]+)_CLIENT_ID$") + + +def _detect_skrot(environ): + """Zwróć skrót uczelni, jeśli w środowisku jest dokładnie jeden komplet + ``DJANGO_BPP_OIDC__CLIENT_ID``. Inaczej ``None`` (0 lub ≥2).""" + skroty = {m.group("skrot") for key in environ if (m := _SKROT_RE.match(key))} + if len(skroty) == 1: + return next(iter(skroty)) + return None + + +def _get(environ, field, skrot): + """Pobierz pojedyncze pole: prefiks-skrót > bare.""" + if skrot: + value = environ.get(f"{_PREFIX}{skrot}_{field}") + if value: + return value + return environ.get(f"{_PREFIX}{field}") + + +def _keycloak_endpoints(issuer): + """Wyprowadź 4 endpointy z URL issuera konwencją Keycloaka. + + Faza 2: zamienić na fetch ``.well-known/openid-configuration`` z cache. + """ + base = issuer.rstrip("/") + "/protocol/openid-connect" + return { + "authorization": f"{base}/auth", + "token": f"{base}/token", + "userinfo": f"{base}/userinfo", + "jwks": f"{base}/certs", + } + + +def discover_oidc_config(environ=None): + """Zwróć konfigurację OIDC albo ``None``, gdy nie skonfigurowano. + + Zwracany słownik: ``client_id``, ``client_secret``, ``issuer``, ``skrot`` + (może być ``None``), ``endpoints`` (dict z 4 adresami). ``None`` oznacza + brak kompletu — aplikacja OIDC ma się wtedy w ogóle nie aktywować. + """ + environ = os.environ if environ is None else environ + skrot = _detect_skrot(environ) + + config = {field.lower(): _get(environ, field, skrot) for field in _FIELDS} + + if not (config["client_id"] and config["client_secret"] and config["issuer"]): + return None + + config["skrot"] = skrot + config["endpoints"] = _keycloak_endpoints(config["issuer"]) + return config diff --git a/src/oidc_integration/tests/__init__.py b/src/oidc_integration/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/oidc_integration/tests/test_backends.py b/src/oidc_integration/tests/test_backends.py new file mode 100644 index 000000000..7c8e59a4f --- /dev/null +++ b/src/oidc_integration/tests/test_backends.py @@ -0,0 +1,125 @@ +"""Testy backendu OIDC (faza 2a — discovery: konto każdemu + zrzut claimów). + +``BppOIDCBackend`` jest testowany na instancji utworzonej bez ``__init__`` +(``object.__new__``), bo bazowy konstruktor ``mozilla-django-oidc`` wymaga +kompletu ``OIDC_OP_*``/``OIDC_RP_*`` w ustawieniach. Testowane metody +(``verify_claims``, ``create_user``) korzystają tylko ze statycznego +``get_settings``, loggera i ``self.UserModel`` — ustawiamy je ręcznie. +""" + +import logging + +import pytest +from django.contrib.auth import get_user_model +from django.test import override_settings +from model_bakery import baker + +from oidc_integration.backends import BppOIDCBackend + + +def _backend(): + return object.__new__(BppOIDCBackend) + + +def test_verify_claims_przepuszcza_gdy_jest_email(): + assert _backend().verify_claims({"email": "jan@uafm.edu.pl"}) is True + + +def test_verify_claims_odrzuca_bez_emaila(): + # Domyślny scope zawiera "email", więc brak claimu email = odmowa. + assert _backend().verify_claims({"sub": "123"}) is False + + +def test_verify_claims_loguje_claimy(caplog): + with caplog.at_level(logging.INFO, logger="oidc_integration.backends"): + _backend().verify_claims({"email": "jan@uafm.edu.pl", "sub": "123"}) + assert any("otrzymane claimy" in rec.message for rec in caplog.records) + + +def test_verify_claims_zrzuca_klucze_na_stderr(capsys): + _backend().verify_claims( + {"email": "jan@uafm.edu.pl", "sub": "123", "person_id": "999"} + ) + err = capsys.readouterr().err + assert "[OIDC]" in err + assert "Klucze (3)" in err + # klucze wypisane alfabetycznie + assert "email" in err and "person_id" in err and "sub" in err + + +@pytest.mark.django_db +def test_create_user_zaklada_zwykle_konto_bez_is_staff(): + backend = _backend() + backend.UserModel = get_user_model() + + user = backend.create_user( + { + "preferred_username": "jkowalski", + "email": "jan@uafm.edu.pl", + "given_name": "Jan", + "family_name": "Kowalski", + "sub": "abc-123", + } + ) + + assert user.username == "jkowalski" + assert user.email == "jan@uafm.edu.pl" + assert user.first_name == "Jan" + assert user.last_name == "Kowalski" + assert user.is_staff is False + assert user.is_superuser is False + assert user.is_active is True + # logowanie wyłącznie przez OIDC — brak używalnego hasła lokalnego + assert not user.has_usable_password() + + +@pytest.mark.django_db +def test_create_user_username_fallback_do_sub(): + backend = _backend() + backend.UserModel = get_user_model() + + # brak preferred_username i email → username z sub + user = backend.create_user({"sub": "tylko-sub-789"}) + + assert user.username == "tylko-sub-789" + assert user.email == "" + assert user.is_staff is False + + +@pytest.mark.django_db +@override_settings(OIDC_LOGIN_SKROT="UAFM") +def test_create_user_przypisuje_uczelnie_wg_skrotu(): + uczelnia = baker.make("bpp.Uczelnia", skrot="UAFM") + backend = _backend() + backend.UserModel = get_user_model() + + user = backend.create_user( + {"preferred_username": "jkowalski", "email": "jan@uafm.edu.pl"} + ) + + assert list(user.accessible_uczelnie.all()) == [uczelnia] + + +@pytest.mark.django_db +@override_settings(OIDC_LOGIN_SKROT="UAFM") +def test_create_user_bez_pasujacej_uczelni_nie_przypisuje(): + # Uczelnia o innym skrócie — brak dopasowania, konto bez przypisania. + baker.make("bpp.Uczelnia", skrot="INNA") + backend = _backend() + backend.UserModel = get_user_model() + + user = backend.create_user({"preferred_username": "jkowalski"}) + + assert user.accessible_uczelnie.count() == 0 + + +@pytest.mark.django_db +@override_settings(OIDC_LOGIN_SKROT="") +def test_create_user_bez_skrotu_nie_przypisuje(): + baker.make("bpp.Uczelnia", skrot="UAFM") + backend = _backend() + backend.UserModel = get_user_model() + + user = backend.create_user({"preferred_username": "jkowalski"}) + + assert user.accessible_uczelnie.count() == 0 diff --git a/src/oidc_integration/tests/test_conf.py b/src/oidc_integration/tests/test_conf.py new file mode 100644 index 000000000..71111186f --- /dev/null +++ b/src/oidc_integration/tests/test_conf.py @@ -0,0 +1,85 @@ +"""Testy rozwiązywania konfiguracji OIDC ze zmiennych środowiskowych. + +Czysty unit — bez bazy i bez sieci. Przekazujemy własny ``environ``. +""" + +from oidc_integration.conf import discover_oidc_config + +ISSUER = "https://auth.uafm.edu.pl/auth/realms/KA" + + +def test_brak_konfiguracji_zwraca_none(): + assert discover_oidc_config({}) is None + + +def test_niepelna_konfiguracja_zwraca_none(): + # brak CLIENT_SECRET + env = { + "DJANGO_BPP_OIDC_CLIENT_ID": "abc", + "DJANGO_BPP_OIDC_ISSUER": ISSUER, + } + assert discover_oidc_config(env) is None + + +def test_bare_konfiguracja(): + env = { + "DJANGO_BPP_OIDC_CLIENT_ID": "abc", + "DJANGO_BPP_OIDC_CLIENT_SECRET": "sekret", + "DJANGO_BPP_OIDC_ISSUER": ISSUER, + } + cfg = discover_oidc_config(env) + assert cfg is not None + assert cfg["client_id"] == "abc" + assert cfg["client_secret"] == "sekret" + assert cfg["skrot"] is None + + +def test_konfiguracja_z_prefiksem_skrotu(): + env = { + "DJANGO_BPP_OIDC_UAFM_CLIENT_ID": "abc", + "DJANGO_BPP_OIDC_UAFM_CLIENT_SECRET": "sekret", + "DJANGO_BPP_OIDC_UAFM_ISSUER": ISSUER, + } + cfg = discover_oidc_config(env) + assert cfg is not None + assert cfg["skrot"] == "UAFM" + assert cfg["client_id"] == "abc" + + +def test_prefiks_ma_pierwszenstwo_nad_bare(): + env = { + "DJANGO_BPP_OIDC_UAFM_CLIENT_ID": "specyficzny", + "DJANGO_BPP_OIDC_UAFM_CLIENT_SECRET": "sekret", + "DJANGO_BPP_OIDC_UAFM_ISSUER": ISSUER, + "DJANGO_BPP_OIDC_CLIENT_ID": "generyczny", + } + cfg = discover_oidc_config(env) + assert cfg["client_id"] == "specyficzny" + + +def test_dwa_skroty_nie_wykrywaja_jednego(): + # Niejednoznaczność: dwa komplety prefiksowane → skrot None, a wtedy + # liczą się tylko wartości bare (których tu nie ma) → None. + env = { + "DJANGO_BPP_OIDC_UAFM_CLIENT_ID": "a", + "DJANGO_BPP_OIDC_UAFM_CLIENT_SECRET": "s", + "DJANGO_BPP_OIDC_UAFM_ISSUER": ISSUER, + "DJANGO_BPP_OIDC_INNA_CLIENT_ID": "b", + "DJANGO_BPP_OIDC_INNA_CLIENT_SECRET": "s2", + "DJANGO_BPP_OIDC_INNA_ISSUER": ISSUER, + } + assert discover_oidc_config(env) is None + + +def test_endpointy_wyprowadzone_z_issuera(): + env = { + "DJANGO_BPP_OIDC_CLIENT_ID": "abc", + "DJANGO_BPP_OIDC_CLIENT_SECRET": "sekret", + "DJANGO_BPP_OIDC_ISSUER": ISSUER, + } + ep = discover_oidc_config(env)["endpoints"] + base = ISSUER + "/protocol/openid-connect" + assert ep["authorization"] == f"{base}/auth" + assert ep["token"] == f"{base}/token" + assert ep["userinfo"] == f"{base}/userinfo" + assert ep["jwks"] == f"{base}/certs" diff --git a/uv.lock b/uv.lock index 0e827b4b4..abdb128a1 100644 --- a/uv.lock +++ b/uv.lock @@ -287,6 +287,7 @@ dependencies = [ { name = "langdetect", marker = "platform_python_implementation != 'PyPy'" }, { name = "markdown", marker = "platform_python_implementation != 'PyPy'" }, { name = "moai-iplweb", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mozilla-django-oidc", marker = "platform_python_implementation != 'PyPy'" }, { name = "nh3", marker = "platform_python_implementation != 'PyPy'" }, { name = "numpy", marker = "platform_python_implementation != 'PyPy'" }, { name = "openpyxl", marker = "platform_python_implementation != 'PyPy'" }, @@ -463,6 +464,7 @@ requires-dist = [ { name = "langdetect", specifier = ">=1.0.9" }, { name = "markdown", specifier = ">=3.7,<4" }, { name = "moai-iplweb", specifier = ">=2.0.2" }, + { name = "mozilla-django-oidc", specifier = ">=4.0,<5" }, { name = "nh3", specifier = ">=0.3.5" }, { name = "numpy", specifier = ">=2.4.6" }, { name = "openpyxl", specifier = ">=3.1.5" }, @@ -2771,6 +2773,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "josepy" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/ad/6f520aee9cc9618d33430380741e9ef859b2c560b1e7915e755c084f6bc0/josepy-2.2.0.tar.gz", hash = "sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9", size = 56500, upload-time = "2025-10-14T14:54:42.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/b2/b5caed897fbb1cc286c62c01feca977e08d99a17230ff3055b9a98eccf1d/josepy-2.2.0-py3-none-any.whl", hash = "sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b", size = 29211, upload-time = "2025-10-14T14:54:41.144Z" }, +] + [[package]] name = "jsbeautifier" version = "1.15.4" @@ -3301,6 +3315,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, ] +[[package]] +name = "mozilla-django-oidc" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "josepy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/f9/1ca554a62bf8a4fd31b68209df8603075c2b7436400ea3f7ddd597f204a5/mozilla-django-oidc-4.0.1.tar.gz", hash = "sha256:4ff8c64069e3e05c539cecf9345e73225a99641a25e13b7a5f933ec897b58918", size = 49027, upload-time = "2024-03-12T12:29:26.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d6/2b75bf4e742c54028ae07a1fb5a2624e5a73e9cfd2185c2df0e22cbfe14e/mozilla_django_oidc-4.0.1-py2.py3-none-any.whl", hash = "sha256:04ef58759be69f22cdc402d082480aaebf193466cad385dc9e4f8df2a0b187ca", size = 29059, upload-time = "2024-03-12T12:29:24.978Z" }, +] + [[package]] name = "msgpack" version = "1.1.2" From e5a32ea7dcdc54bb8b5a6007f72577a76e16af74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:15:39 +0200 Subject: [PATCH 103/247] =?UTF-8?q?feat(oidc):=20dokonczenie=20logowania?= =?UTF-8?q?=20=E2=80=94=20wylogowanie=20z=20Keycloaka,=20.well-known,=20ro?= =?UTF-8?q?bustness=20(#299)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zakres niezalezny od realnych claimow (gating rol swiadomie odlozony — role bywaja w access/id-tokenie, nie userinfo; nie buduje na slepo). - Wylogowanie z Keycloaka (RP-Initiated Logout): BppOIDCAwareLogoutView pod logout/ — sesja OIDC redirectuje na end_session_endpoint z id_token_hint (OIDC_STORE_ID_TOKEN=True) + post_logout_redirect_uri; reszta sesji = standardowy LogoutView. Wymaga rejestracji post-logout redirect URI w KC. - Endpointy z .well-known/openid-configuration (fetch_well_known_endpoints, krotki timeout) z fallbackiem na konwencje Keycloaka gdy IdP nieosiagalny. Siec tylko z settings; testy env-resolution bez sieci. - Robustness: unikalny username przy kolizji (base-2, base-3...). Testy: 24 (logout builder+widok, well-known parse/fallback, kolizja username). Co-authored-by: Claude Opus 4.8 (1M context) --- .../2026-06-03-oidc-keycloak-login-design.md | 25 ++++++- src/django_bpp/settings/base.py | 18 ++++- src/django_bpp/urls.py | 10 ++- src/oidc_integration/backends.py | 18 ++++- src/oidc_integration/conf.py | 60 ++++++++++++++- src/oidc_integration/logout.py | 31 ++++++++ src/oidc_integration/tests/test_backends.py | 15 ++++ src/oidc_integration/tests/test_conf.py | 50 ++++++++++++- src/oidc_integration/tests/test_logout.py | 74 +++++++++++++++++++ src/oidc_integration/views.py | 33 +++++++++ 10 files changed, 322 insertions(+), 12 deletions(-) create mode 100644 src/oidc_integration/logout.py create mode 100644 src/oidc_integration/tests/test_logout.py create mode 100644 src/oidc_integration/views.py diff --git a/docs/superpowers/specs/2026-06-03-oidc-keycloak-login-design.md b/docs/superpowers/specs/2026-06-03-oidc-keycloak-login-design.md index b218a0372..779beac2e 100644 --- a/docs/superpowers/specs/2026-06-03-oidc-keycloak-login-design.md +++ b/docs/superpowers/specs/2026-06-03-oidc-keycloak-login-design.md @@ -137,16 +137,33 @@ Blok warunkowy à la `if MICROSOFT_AUTH_CLIENT_ID:` — aktywny tylko gdy - Bez e2e przeciw realnemu Keycloakowi — to wymaga sekretów i sieci; testujemy ręcznie po wgraniu env. -## Poza zakresem spike'a (faza 2, po obejrzeniu claimów) +## Faza 2 — dokończenie (PR na `feature/multi-hosted-config`, ZAIMPLEMENTOWANE) + +Zakres niezależny od realnych claimów (gating ról świadomie odłożony, bo role +bywają w access/id-tokenie, nie w userinfo — nie buduję na ślepo): + +- **Wylogowanie z Keycloaka** (RP-Initiated Logout): `BppOIDCAwareLogoutView` + pod `logout/` — sesja OIDC → redirect na `end_session_endpoint` z + `id_token_hint` (z sesji; `OIDC_STORE_ID_TOKEN=True`) + + `post_logout_redirect_uri`; każda inna sesja → standardowy `LogoutView`. + Wymaga rejestracji post-logout redirect URI w kliencie KC. +- **Endpointy z `.well-known/openid-configuration`**: `fetch_well_known_endpoints` + pobiera prawdziwe adresy (krótki timeout), z **fallbackiem na konwencję** + Keycloaka gdy IdP nieosiągalny przy starcie. Sieć dotykana tylko z settings; + testy env-resolution bez sieci. +- **Odporność**: unikalny `username` przy kolizji (`base-2`, `base-3`…), + brak `email` obsłużony. + +## Poza zakresem — faza 2b (po obejrzeniu claimów) - Gating studentów: filtr po roli/grupie z tokenu („nie wpuszczamy” / „nie tworzymy kont”). - Mapowanie ról/grup Keycloaka → grupy/uprawnienia Django. - Powiązanie z istniejącym `Autor` przez claim `person_id`. -- Wylogowanie z Keycloaka (`end_session_endpoint`). - Twarde wiązanie skrótu z `Uczelnia` z bazy zamiast auto-detekcji env. -- Przełączenie wyprowadzania endpointów z konwencji Keycloaka na fetch - `.well-known/openid-configuration` z cache. +- Cache wyniku `.well-known` (dziś fetch przy każdym starcie procesu). +- Wiele issuerów naraz w jednym procesie (3 uczelnie / 3 OIDC) — osobne + podklasy backendu + routing per-host/mountpoint. ## Wymagania od administratora Keycloak (realm KA) diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index d9e94ddd9..1ca4a4414 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -1207,7 +1207,10 @@ def can_login_as(request, target_user): # Backend jest DOPISYWANY do listy (ModelBackend zostaje → logowanie hasłem # działa równolegle). # -from oidc_integration.conf import discover_oidc_config # noqa: E402 +from oidc_integration.conf import ( # noqa: E402 + discover_oidc_config, + fetch_well_known_endpoints, +) _OIDC_CONFIG = discover_oidc_config() OIDC_LOGIN_ENABLED = _OIDC_CONFIG is not None @@ -1220,16 +1223,25 @@ def can_login_as(request, target_user): OIDC_RP_CLIENT_ID = _OIDC_CONFIG["client_id"] OIDC_RP_CLIENT_SECRET = _OIDC_CONFIG["client_secret"] - _oidc_endpoints = _OIDC_CONFIG["endpoints"] + # Endpointy: preferuj .well-known (źródło prawdy serwera), z fallbackiem na + # konwencję Keycloaka gdy IdP nieosiągalny przy starcie. + _oidc_endpoints = ( + fetch_well_known_endpoints(_OIDC_CONFIG["issuer"]) or _OIDC_CONFIG["endpoints"] + ) OIDC_OP_AUTHORIZATION_ENDPOINT = _oidc_endpoints["authorization"] OIDC_OP_TOKEN_ENDPOINT = _oidc_endpoints["token"] - OIDC_OP_USER_ENDPOINT = _oidc_endpoints["userinfo"] + OIDC_OP_USER_ENDPOINT = _oidc_endpoints.get("userinfo", "") OIDC_OP_JWKS_ENDPOINT = _oidc_endpoints["jwks"] + # end_session: wylogowanie z Keycloaka (RP-Initiated Logout). + OIDC_OP_LOGOUT_ENDPOINT = _oidc_endpoints.get("end_session", "") + OIDC_OP_LOGOUT_URL_METHOD = "oidc_integration.logout.build_provider_logout_url" # Keycloak podpisuje id_token RS256; scope email konieczny do auto-create. OIDC_RP_SIGN_ALGO = "RS256" OIDC_RP_SCOPES = "openid email profile" OIDC_CREATE_USER = True + # Zapamiętaj id_token w sesji — potrzebny jako id_token_hint przy logout. + OIDC_STORE_ID_TOKEN = True AUTHENTICATION_BACKENDS = list(AUTHENTICATION_BACKENDS) + [ "oidc_integration.backends.BppOIDCBackend", diff --git a/src/django_bpp/urls.py b/src/django_bpp/urls.py index 4beb3c4c3..22e73fd8c 100644 --- a/src/django_bpp/urls.py +++ b/src/django_bpp/urls.py @@ -400,13 +400,21 @@ def protected_media_serve(request, path, document_root=None): # Jeżeli NIE ma włączonej aplikacji microsoft_auth, to obsługuj # własne logowanie hasłem z front-endu: # + # Logout świadomy OIDC: sesja OIDC → wyloguj też z Keycloaka; reszta → + # standardowy LogoutView. Bezpieczny nadzbiór, więc używamy go zawsze gdy + # apka OIDC jest aktywna. + if apps.is_installed("oidc_integration"): + from oidc_integration.views import BppOIDCAwareLogoutView as _LogoutView + else: + _LogoutView = LogoutView + urlpatterns += [ url( r"^accounts/login/$", HTMXAwareLoginView.as_view(authentication_form=MyAuthenticationForm), name="login_form", ), - url(r"^logout/$", login_required(LogoutView.as_view()), name="logout"), + url(r"^logout/$", login_required(_LogoutView.as_view()), name="logout"), ] else: from django_bpp.views import MicrosoftLogoutView diff --git a/src/oidc_integration/backends.py b/src/oidc_integration/backends.py index 316f56ef8..e862379b5 100644 --- a/src/oidc_integration/backends.py +++ b/src/oidc_integration/backends.py @@ -89,6 +89,21 @@ def update_user(self, user, claims): self._assign_uczelnia(user) return user + def _unique_username(self, base): + """Zwróć ``base`` albo ``base-2``/``base-3``… jeśli zajęty. + + ``create_user`` woła się tylko, gdy nie ma konta dopasowanego po + e-mailu — ale sam username mógłby kolidować z innym kontem (ten sam + ``preferred_username`` przy innym e-mailu). Bez tego byłby IntegrityError. + """ + manager = self.UserModel.objects + if not manager.filter(username=base).exists(): + return base + i = 2 + while manager.filter(username=f"{base}-{i}").exists(): + i += 1 + return f"{base}-{i}" + def create_user(self, claims): """Załóż zwykłe konto (bez is_staff) na podstawie claimów. @@ -97,9 +112,10 @@ def create_user(self, claims): OIDC. Wywoływane tylko, gdy ``filter_users_by_claims`` (domyślnie po e-mailu) nie znajdzie istniejącego konta. """ - username = ( + base_username = ( claims.get("preferred_username") or claims.get("email") or claims.get("sub") ) + username = self._unique_username(base_username) email = claims.get("email") or "" user = self.UserModel.objects.create_user(username=username, email=email) diff --git a/src/oidc_integration/conf.py b/src/oidc_integration/conf.py index 322a47b14..3d6be6087 100644 --- a/src/oidc_integration/conf.py +++ b/src/oidc_integration/conf.py @@ -13,12 +13,24 @@ Django w pełni wstanie) — operuje wyłącznie na ``os.environ``. """ +import logging import os import re +logger = logging.getLogger(__name__) + _PREFIX = "DJANGO_BPP_OIDC_" _FIELDS = ("CLIENT_ID", "CLIENT_SECRET", "ISSUER") +# Mapowanie wewnętrznych nazw endpointów → kluczy w .well-known/openid-configuration +_WELL_KNOWN_KEYS = { + "authorization": "authorization_endpoint", + "token": "token_endpoint", + "userinfo": "userinfo_endpoint", + "jwks": "jwks_uri", + "end_session": "end_session_endpoint", +} + # Skrót składa się z liter/cyfr (bez podkreślników), żeby nie kolidować z # bare-wariantem DJANGO_BPP_OIDC_CLIENT_ID (gdzie "CLIENT" nie jest skrótem). _SKROT_RE = re.compile(rf"^{re.escape(_PREFIX)}(?P[A-Z0-9]+)_CLIENT_ID$") @@ -43,9 +55,10 @@ def _get(environ, field, skrot): def _keycloak_endpoints(issuer): - """Wyprowadź 4 endpointy z URL issuera konwencją Keycloaka. + """Wyprowadź endpointy z URL issuera konwencją Keycloaka. - Faza 2: zamienić na fetch ``.well-known/openid-configuration`` z cache. + Używane jako **fallback**, gdy nie uda się pobrać + ``.well-known/openid-configuration`` (patrz ``fetch_well_known_endpoints``). """ base = issuer.rstrip("/") + "/protocol/openid-connect" return { @@ -53,9 +66,52 @@ def _keycloak_endpoints(issuer): "token": f"{base}/token", "userinfo": f"{base}/userinfo", "jwks": f"{base}/certs", + "end_session": f"{base}/logout", } +def fetch_well_known_endpoints(issuer, timeout=3): + """Pobierz endpointy z ``{issuer}/.well-known/openid-configuration``. + + Zwraca dict w wewnętrznych nazwach (jak ``_keycloak_endpoints``) albo + ``None``, gdy fetch/parse się nie powiedzie. Krótki timeout i połknięcie + błędu są celowe: serwer BPP nie może zawisnąć na starcie, gdy IdP jest + nieosiągalny — wołający degraduje wtedy do konwencji. + + NIE wołane w testach env-resolution (te używają konwencji); sieć dotykana + tylko z ``settings/base.py`` przy realnej konfiguracji. + """ + url = issuer.rstrip("/") + "/.well-known/openid-configuration" + try: + import requests + + resp = requests.get(url, timeout=timeout) + resp.raise_for_status() + doc = resp.json() + except Exception as e: # noqa: BLE001 — degradacja do konwencji jest OK + logger.warning( + "OIDC: nie udało się pobrać %s (%s) — używam konwencji Keycloaka", + url, + e, + ) + return None + + endpoints = {} + for internal, well_known in _WELL_KNOWN_KEYS.items(): + value = doc.get(well_known) + if value: + endpoints[internal] = value + + # Bez kluczowych endpointów discovery jest bezużyteczne — degraduj. + if not all(k in endpoints for k in ("authorization", "token", "jwks")): + logger.warning( + "OIDC: %s nie zawiera kompletu endpointów — używam konwencji", url + ) + return None + + return endpoints + + def discover_oidc_config(environ=None): """Zwróć konfigurację OIDC albo ``None``, gdy nie skonfigurowano. diff --git a/src/oidc_integration/logout.py b/src/oidc_integration/logout.py new file mode 100644 index 000000000..6c4638eb3 --- /dev/null +++ b/src/oidc_integration/logout.py @@ -0,0 +1,31 @@ +"""Budowanie URL-a wylogowania z serwera OIDC (Keycloak end_session). + +RP-Initiated Logout: po lokalnym wylogowaniu Django przekierowujemy usera na +``end_session_endpoint`` Keycloaka z ``id_token_hint`` (z sesji) i +``post_logout_redirect_uri``. Dzięki temu wylogowanie z BPP kończy też sesję +SSO w Keycloaku — inaczej kolejne „Zaloguj przez UAFM" logowałoby bez pytania +o hasło (cicha re-autoryzacja z żywej sesji KC). +""" + +from urllib.parse import urlencode + +from django.conf import settings + + +def build_provider_logout_url(request): + """Zwróć URL wylogowania z OP albo lokalny redirect (gdy brak end_session). + + Czyta ``oidc_id_token`` z sesji — MUSI być wywołane PRZED ``auth.logout`` + (który czyści sesję). ``OIDC_STORE_ID_TOKEN=True`` zapewnia obecność tokenu. + """ + fallback = getattr(settings, "LOGOUT_REDIRECT_URL", None) or "/" + end_session = getattr(settings, "OIDC_OP_LOGOUT_ENDPOINT", "") + if not end_session: + return fallback + + params = {"post_logout_redirect_uri": request.build_absolute_uri(fallback)} + id_token = request.session.get("oidc_id_token") + if id_token: + params["id_token_hint"] = id_token + + return f"{end_session}?{urlencode(params)}" diff --git a/src/oidc_integration/tests/test_backends.py b/src/oidc_integration/tests/test_backends.py index 7c8e59a4f..10821b303 100644 --- a/src/oidc_integration/tests/test_backends.py +++ b/src/oidc_integration/tests/test_backends.py @@ -73,6 +73,21 @@ def test_create_user_zaklada_zwykle_konto_bez_is_staff(): assert not user.has_usable_password() +@pytest.mark.django_db +def test_create_user_unika_kolizji_username(): + UserModel = get_user_model() + UserModel.objects.create_user(username="jkowalski", email="ktos@inny.pl") + + backend = _backend() + backend.UserModel = UserModel + # ten sam preferred_username, inny e-mail → username nie może kolidować + user = backend.create_user( + {"preferred_username": "jkowalski", "email": "jan@uafm.edu.pl"} + ) + + assert user.username == "jkowalski-2" + + @pytest.mark.django_db def test_create_user_username_fallback_do_sub(): backend = _backend() diff --git a/src/oidc_integration/tests/test_conf.py b/src/oidc_integration/tests/test_conf.py index 71111186f..61b1909eb 100644 --- a/src/oidc_integration/tests/test_conf.py +++ b/src/oidc_integration/tests/test_conf.py @@ -3,7 +3,7 @@ Czysty unit — bez bazy i bez sieci. Przekazujemy własny ``environ``. """ -from oidc_integration.conf import discover_oidc_config +from oidc_integration.conf import discover_oidc_config, fetch_well_known_endpoints ISSUER = "https://auth.uafm.edu.pl/auth/realms/KA" @@ -83,3 +83,51 @@ def test_endpointy_wyprowadzone_z_issuera(): assert ep["token"] == f"{base}/token" assert ep["userinfo"] == f"{base}/userinfo" assert ep["jwks"] == f"{base}/certs" + assert ep["end_session"] == f"{base}/logout" + + +def test_fetch_well_known_fallback_na_bledzie_sieci(monkeypatch): + import requests + + def boom(*args, **kwargs): + raise requests.RequestException("brak sieci") + + monkeypatch.setattr("requests.get", boom) + assert fetch_well_known_endpoints(ISSUER) is None + + +def test_fetch_well_known_parsuje_endpointy(monkeypatch): + class FakeResp: + def raise_for_status(self): + pass + + def json(self): + return { + "authorization_endpoint": "https://kc/auth", + "token_endpoint": "https://kc/token", + "userinfo_endpoint": "https://kc/userinfo", + "jwks_uri": "https://kc/certs", + "end_session_endpoint": "https://kc/logout", + } + + monkeypatch.setattr("requests.get", lambda *a, **k: FakeResp()) + ep = fetch_well_known_endpoints(ISSUER) + assert ep == { + "authorization": "https://kc/auth", + "token": "https://kc/token", + "userinfo": "https://kc/userinfo", + "jwks": "https://kc/certs", + "end_session": "https://kc/logout", + } + + +def test_fetch_well_known_niekompletny_to_none(monkeypatch): + class FakeResp: + def raise_for_status(self): + pass + + def json(self): + return {"userinfo_endpoint": "https://kc/userinfo"} # brak auth/token/jwks + + monkeypatch.setattr("requests.get", lambda *a, **k: FakeResp()) + assert fetch_well_known_endpoints(ISSUER) is None diff --git a/src/oidc_integration/tests/test_logout.py b/src/oidc_integration/tests/test_logout.py new file mode 100644 index 000000000..e4bfd57c7 --- /dev/null +++ b/src/oidc_integration/tests/test_logout.py @@ -0,0 +1,74 @@ +"""Testy wylogowania OIDC: builder URL-a + backend-aware widok.""" + +import pytest +from django.contrib.auth import BACKEND_SESSION_KEY, get_user_model +from django.contrib.sessions.backends.db import SessionStore +from django.test import override_settings +from model_bakery import baker + +from oidc_integration.logout import build_provider_logout_url +from oidc_integration.views import OIDC_BACKEND_PATH, BppOIDCAwareLogoutView + + +def _dict_request(rf, **session): + """Request z sesją-słownikiem (builder nie potrzebuje DB).""" + req = rf.post("/logout/") + req.session = dict(session) + return req + + +def _db_request(rf, **session): + """Request z realną sesją DB + pominięciem CSRF (dla widoku LogoutView).""" + req = rf.post("/logout/") + req.session = SessionStore() + for k, v in session.items(): + req.session[k] = v + req.session.save() + req._dont_enforce_csrf_checks = True + return req + + +@override_settings(OIDC_OP_LOGOUT_ENDPOINT="https://kc/logout", LOGOUT_REDIRECT_URL="/") +def test_build_logout_url_zawiera_id_token_hint_i_redirect(rf): + req = _dict_request(rf, oidc_id_token="TOK123") + url = build_provider_logout_url(req) + assert url.startswith("https://kc/logout?") + assert "id_token_hint=TOK123" in url + assert "post_logout_redirect_uri=" in url + + +@override_settings(OIDC_OP_LOGOUT_ENDPOINT="", LOGOUT_REDIRECT_URL="/start/") +def test_build_logout_url_bez_end_session_to_fallback(rf): + assert build_provider_logout_url(_dict_request(rf)) == "/start/" + + +@pytest.mark.django_db +@override_settings(OIDC_OP_LOGOUT_ENDPOINT="https://kc/logout", LOGOUT_REDIRECT_URL="/") +def test_logout_view_sesja_oidc_redirect_do_keycloaka(rf): + user = baker.make(get_user_model()) + req = _db_request( + rf, **{BACKEND_SESSION_KEY: OIDC_BACKEND_PATH, "oidc_id_token": "TOK"} + ) + req.user = user + + resp = BppOIDCAwareLogoutView.as_view()(req) + + assert resp.status_code == 302 + assert resp.url.startswith("https://kc/logout") + assert "id_token_hint=TOK" in resp.url + + +@pytest.mark.django_db +@override_settings(OIDC_OP_LOGOUT_ENDPOINT="https://kc/logout", LOGOUT_REDIRECT_URL="/") +def test_logout_view_sesja_haslowa_nie_idzie_do_keycloaka(rf): + # Inny backend w sesji → standardowe wylogowanie Django (redirect lokalny). + user = baker.make(get_user_model()) + req = _db_request( + rf, **{BACKEND_SESSION_KEY: "django.contrib.auth.backends.ModelBackend"} + ) + req.user = user + + resp = BppOIDCAwareLogoutView.as_view()(req) + + assert resp.status_code == 302 + assert "kc/logout" not in resp.url diff --git a/src/oidc_integration/views.py b/src/oidc_integration/views.py new file mode 100644 index 000000000..28edaeed8 --- /dev/null +++ b/src/oidc_integration/views.py @@ -0,0 +1,33 @@ +"""Widoki OIDC dla BPP — na razie tylko backend-aware wylogowanie.""" + +from django.contrib.auth import BACKEND_SESSION_KEY +from django.contrib.auth import logout as auth_logout +from django.contrib.auth.views import LogoutView +from django.http import HttpResponseRedirect + +from oidc_integration.logout import build_provider_logout_url + +OIDC_BACKEND_PATH = "oidc_integration.backends.BppOIDCBackend" + + +class BppOIDCAwareLogoutView(LogoutView): + """Wylogowanie świadome backendu logowania. + + Sesja zalogowana przez OIDC → wyloguj też z Keycloaka (RP-Initiated + Logout). Każda inna sesja (hasło, ORCID) → standardowe wylogowanie Django. + Bezpieczny nadzbiór ``LogoutView`` — gdy OIDC nieużyte, zachowuje się + identycznie jak oryginał. + """ + + def post(self, request, *args, **kwargs): + is_oidc_session = ( + request.user.is_authenticated + and request.session.get(BACKEND_SESSION_KEY) == OIDC_BACKEND_PATH + ) + if is_oidc_session: + # URL musi powstać PRZED auth_logout (czyta id_token z sesji). + logout_url = build_provider_logout_url(request) + auth_logout(request) + return HttpResponseRedirect(logout_url) + + return super().post(request, *args, **kwargs) From b955c13718603535d3bf9fc82af6a3f3a0eb6562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 14:31:51 +0200 Subject: [PATCH 104/247] docs(multi-hosted): spec R2 - ewaluacja_liczba_n per-uczelnia (write+read) Co-Authored-By: Claude Opus 4.8 --- ...-ewaluacja-liczba-n-per-uczelnia-design.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-ewaluacja-liczba-n-per-uczelnia-design.md diff --git a/docs/superpowers/specs/2026-06-03-ewaluacja-liczba-n-per-uczelnia-design.md b/docs/superpowers/specs/2026-06-03-ewaluacja-liczba-n-per-uczelnia-design.md new file mode 100644 index 000000000..08fa3d402 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-ewaluacja-liczba-n-per-uczelnia-design.md @@ -0,0 +1,147 @@ +# Design — `ewaluacja_liczba_n` per-uczelnia (R2, write+read) + +Data: 2026-06-03 +Gałąź: `feature/multi-hosted-config` +Kontekst: wątek G z `HANDOFF-multi-hosted.md`. Następny po R1 (slot read-side). + +## Cel i zakres + +W instalacji wielouczelnianej **liczba N liczona jest osobno per uczelnia** +(per dyscyplina). Dziś `ewaluacja_liczba_n` jest tylko CZĘŚCIOWO per-uczelnia: +`LiczbaNDlaUczelni`/`DyscyplinaNieRaportowana` mają FK `uczelnia`, widoki/komenda +przekazują uczelnię — ale **rdzeń liczenia jest globalny**: tabele udziałów +`IloscUdzialowDlaAutoraZaRok`/`...ZaCalosc` nie mają uczelni, a pipeline +`oblicz_liczby_n_dla_ewaluacji_2022_2025` kasuje i przebudowuje je GLOBALNIE +(z autorów wszystkich uczelni), więc dla każdej uczelni `LiczbaNDlaUczelni` +zawiera N policzone z autorów CAŁEJ bazy. + +**W zakresie R2:** +- FK `uczelnia` na `IloscUdzialowDlaAutoraZaRok` i `IloscUdzialowDlaAutoraZaCalosc`, +- migracja + backfill (single→domyślna, multi-z-danymi→fail; jak `0425`), +- zawężenie CAŁEGO pipeline'u liczenia (`utils.py`) per uczelnia, +- filtrowanie odczytów (`views/export.py`, `views/list.py`, `views/verify.py`) + po uczelni oglądającego, +- `oblicz_dyscypliny_nieraportowane` dostaje uczelnię (wszystkie wywołania). + +**Poza zakresem R2:** +- Import z POLON (populuje `Autor_Dyscyplina`/`wymiar_etatu`) — żyje poza tą apką; + R2 liczy z `Autor_Dyscyplina`. (Odnotowane jako osobny ewentualny wątek.) +- Federacja optymalizacji — świadomie odłożona (decyzja usera, nie teraz). +- Integrator (handoff §D), drobne (§E) — osobne wątki, po R2. + +## Reguła wiodąca (decyzja usera) + +**Uczelnia autora = `autor.aktualna_jednostka.uczelnia`.** Autor jest liczony do +liczby N danej uczelni wtedy i tylko wtedy, gdy jego `aktualna_jednostka`: +- jest ustawiona (NIE NULL), oraz +- `skupia_pracownikow == True` (NIE „obca jednostka"). + +Autor z `aktualna_jednostka=NULL` lub obcą jednostką jest **całkowicie pomijany** +— nie powstają dla niego żadne wiersze `IloscUdzialow*`, nie wchodzi do żadnej +liczby N. („Jego rekord sobie jest i tyle.") + +## Invariant zgodności + +Single-install: wszyscy autorzy z `aktualna_jednostka` w tej jednej uczelni liczą +się jak dziś → liczby N identyczne. **Jedyna świadoma różnica vs obecny stan:** +autorzy z `aktualna_jednostka=NULL`/obcą przestają być liczeni (dziś pipeline +liczył ich globalnie) — to korekta zgodna z regułą domenową, nie regresja. + +## Zmiany schematu + +`src/ewaluacja_liczba_n/models.py`: +- `IloscUdzialowDlaAutoraZaRok`: dodać `uczelnia = ForeignKey("bpp.Uczelnia", + on_delete=CASCADE, null=True, blank=True)`; `unique_together` → + `("autor", "dyscyplina_naukowa", "rok", "uczelnia")`. +- `IloscUdzialowDlaAutoraZaCalosc`: dodać `uczelnia` FK (jw.); `unique_together` → + `("autor", "dyscyplina_naukowa", "rodzaj_autora", "uczelnia")`. +- Pole nullable na czas migracji; po czystym rebuildzie zawsze wypełnione + (autorzy bez przypisania są pomijani). NOT NULL ewentualnie później. + +Migracje: +- M1: `AddField uczelnia` na obu modelach + zmiana `unique_together`. +- M2 (lub w M1, RunPython): backfill — `Uczelnia.objects.all()[:2]`: jeśli 1 → + `update(uczelnia=ta)` na wierszach `uczelnia__isnull=True` obu tabel; jeśli są + wiersze bez uczelni a uczelni ≠ 1 → `raise RuntimeError` (jak `0425`). Reverse: no-op. +- Reguła: tylko nowe pliki migracji. + +## Pipeline liczenia (`utils.py`) — zawężenie per uczelnia + +Wszystkie funkcje liczące dostają/propagują `uczelnia` i operują WYŁĄCZNIE na +wierszach tej uczelni: + +- `oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia, rok_min, rok_max)`: + - delete: `IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=uczelnia, **lata).delete()` + (dziś: globalny `filter(**lata).delete()`), + - iteracja: `Autor_Dyscyplina.objects.filter(**lata, + autor__aktualna_jednostka__uczelnia=uczelnia, + autor__aktualna_jednostka__skupia_pracownikow=True)` + (zawężenie do autorów tej uczelni; pomija NULL/obcą — `__uczelnia=` odrzuca NULL), + - `create(..., uczelnia=uczelnia)`, + - wywołania kroków 1–4 przekazują `uczelnia`. +- `oblicz_sumy_udzialow_za_calosc(uczelnia, rok_min, rok_max)`: + - delete: `IloscUdzialowDlaAutoraZaCalosc.objects.filter(uczelnia=uczelnia).delete()` + (dziś: globalny `objects.all().delete()` — to główny bug międzyuczelniany), + - agregacja tylko z `ZaRok.filter(uczelnia=uczelnia, lata)`, + - `create(..., uczelnia=uczelnia)`. +- `oblicz_srednia_liczbe_n_dla_dyscyplin(uczelnia, …)`: udziały z + `ZaRok.filter(uczelnia=uczelnia, **lata)`; reszta (zapis `LiczbaNDlaUczelni` + per uczelnia) bez zmian. +- `oblicz_dyscypliny_nieraportowane(uczelnia, rok=2025)`: suma z + `ZaRok.filter(uczelnia=uczelnia, rok=rok)`. +- `dolicz_bonus_za_nieraportowana(uczelnia, nieraportowane_ids, …)`: iteruje + `ZaCalosc.filter(uczelnia=uczelnia)`. +- `oblicz_liczbe_n_na_koniec_2025(uczelnia)`: udziały z + `ZaRok.filter(uczelnia=uczelnia, rok=2025)`. + +Zewnętrzni callerzy (`przelicz_n`, `views/index.py`, `ewaluacja_metryki/tasks.py`, +`ewaluacja_metryki/.../oblicz_metryki.py`) JUŻ przekazują `uczelnia` — bez zmian +po ich stronie (poza ewentualnym dopasowaniem sygnatur, jeśli któraś funkcja +zyskała wymagany parametr `uczelnia` — `oblicz_dyscypliny_nieraportowane` zyskuje). + +## Odczyty (read views) — filtr po uczelni oglądającego + +Źródło uczelni: `Uczelnia.objects.get_for_request(request)` (spójne z istniejącym +`views/index.py`; bez override superusera, bez zależności od `raport_slotow`). +- `views/export.py:23` (`ZaRok.filter`), `:207` (`ZaCalosc.all()`) → `.filter(uczelnia=U)`. +- `views/list.py:184,211,223` (`ZaRok`), `:331` (`ZaCalosc.all()`) → `.filter(uczelnia=U)`. +- `views/verify.py:227` (`ZaRok rok=2025`) → `.filter(uczelnia=U)`. +- `views/list.py` wywołania `oblicz_dyscypliny_nieraportowane()` (:113,243,357,412) + → przekazać `uczelnia=U`. + +(Te widoki są w kontekście jednej uczelni — `RaportSlotowUczelnia`-podobnym — +więc `get_for_request` daje uczelnię z site'u oglądającego.) + +## Testy + +- **Invariant single-install:** istniejące testy `ewaluacja_liczba_n` zielone; + fixture jednouczelniany → `LiczbaNDlaUczelni`/udziały identyczne jak dziś + (dla autorów z `aktualna_jednostka` w tej uczelni). +- **Multi-install izolacja:** 2 uczelnie, autorzy w obu (różne `aktualna_jednostka`); + `oblicz_liczby_n_dla_ewaluacji_2022_2025(U1)` potem `(U2)` → `ZaRok`/`ZaCalosc` + mają wiersze obu uczelni (drugi przebieg NIE kasuje wierszy U1); `LiczbaNDlaUczelni` + U1 policzone tylko z autorów U1, U2 tylko z autorów U2. +- **Wykluczenie nieprzypisanych:** autor z `aktualna_jednostka=NULL` i autor z + jednostką `skupia_pracownikow=False` → ZERO wierszy `IloscUdzialow*`, brak wpływu + na liczbę N. +- **Read view filtruje:** export/list/verify dla U1 nie pokazują wierszy U2. +- **Migracja backfill:** single → istniejące wiersze dostają domyślną uczelnię; + (test jednostkowy backfillu opcjonalny — trudny na świeżej bazie testowej). + +## Migracja i deploy + +- Single-install: M-backfill wpisze domyślną uczelnię w legacy `IloscUdzialow*`; + następny `przelicz_n` przeliczy poprawnie per uczelnia (identycznie). +- Multi-install z danymi: backfill **failuje** dopóki nie przeliczy się per uczelnia + (admin: usuń stary cache udziałów / przelicz). Spójne z `0425`. + +## Komendy weryfikacji + +- Testy: `uv run pytest src/ewaluacja_liczba_n/ -q -p no:cacheprovider`. +- `uv run python src/manage.py makemigrations --check --dry-run` + (z `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1`). +- Lint: `uv run ruff check ` (NIE `--fix`). + +## Po R2 (kolejka usera) + +Integrator per-uczelnia (handoff §D) → drobne (§E). Federacja optymalizacji — olana. From 044151cc367880fc83907b1af550342c8f7cd0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 14:45:46 +0200 Subject: [PATCH 105/247] docs(multi-hosted): plan implementacyjny R2 (liczba_n per-uczelnia) - 4 taski TDD Co-Authored-By: Claude Opus 4.8 --- ...6-03-ewaluacja-liczba-n-per-uczelnia-R2.md | 425 ++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-ewaluacja-liczba-n-per-uczelnia-R2.md diff --git a/docs/superpowers/plans/2026-06-03-ewaluacja-liczba-n-per-uczelnia-R2.md b/docs/superpowers/plans/2026-06-03-ewaluacja-liczba-n-per-uczelnia-R2.md new file mode 100644 index 000000000..447fb9993 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-ewaluacja-liczba-n-per-uczelnia-R2.md @@ -0,0 +1,425 @@ +# ewaluacja_liczba_n per-uczelnia (R2) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Liczba N (i tabele udziałów) liczona i zapisywana osobno per uczelnia, na podstawie `autor.aktualna_jednostka.uczelnia`, zachowując identyczne liczby w instalacji jednouczelnianej. + +**Architecture:** Tabele udziałów `IloscUdzialowDlaAutoraZaRok`/`...ZaCalosc` zyskują FK `uczelnia`. Cały pipeline w `utils.py` zawęża budowę/agregację do jednej uczelni (autorzy filtrowani po `aktualna_jednostka.uczelnia` + `skupia_pracownikow`, nieprzypisani pomijani). Widoki odczytowe filtrują po `get_for_request`. + +**Tech Stack:** Django, PostgreSQL, pytest + model_bakery, testcontainers. + +**Spec:** `docs/superpowers/specs/2026-06-03-ewaluacja-liczba-n-per-uczelnia-design.md` + +--- + +## Uwagi wykonawcze (przeczytaj przed startem) + +- Komenda testowa: `uv run pytest <ścieżka> -q -p no:cacheprovider` (testcontainers; Docker musi działać). +- **NIGDY nie edytuj istniejących migracji.** Nowe pliki (max obecny: `0008` → nowy `0009`). +- Lint: `uv run ruff check ` (NIE `--fix` — popraw ręcznie). Max 88 znaków. +- Po każdym Tasku: testy zielone → commit. +- Reguła atrybucji: uczelnia autora = `autor.aktualna_jednostka.uczelnia`; gdy NULL lub `skupia_pracownikow=False` → autor POMIJANY (zero wierszy udziałów). +- Invariant single-install: istniejące `src/ewaluacja_liczba_n/` testy zielone. +- Fixtury wielouczelniane (`zwarte_dwie_uczelnie`, `druga_uczelnia`, `jednostka_drugiej_uczelni`) są w `src/bpp/tests/test_models/test_sloty/conftest.py`; z `ewaluacja_liczba_n/tests/` NIE są widoczne — w razie potrzeby zbuduj scenariusz lokalnie (2× `Uczelnia`+`Jednostka`+autorzy z `Autor_Dyscyplina`+`wymiar_etatu`) albo dodaj do `ewaluacja_liczba_n/tests/conftest.py`. + +--- + +## Task 1: FK `uczelnia` na modelach udziałów + migracja + backfill + +**Files:** +- Modify: `src/ewaluacja_liczba_n/models.py` (`IloscUdzialowDlaAutoraZaRok`, `IloscUdzialowDlaAutoraZaCalosc`) +- Create: `src/ewaluacja_liczba_n/migrations/0009_iloscudzialow_uczelnia.py` (przez makemigrations + ręczny RunPython) +- Test: `src/ewaluacja_liczba_n/tests/test_per_uczelnia.py` (create) + +- [ ] **Step 1: Napisz failing test (model przyjmuje uczelnia + unique_together)** + +Utwórz `src/ewaluacja_liczba_n/tests/test_per_uczelnia.py`: + +```python +import pytest +from decimal import Decimal + +from model_bakery import baker + +from ewaluacja_liczba_n.models import ( + IloscUdzialowDlaAutoraZaRok, + IloscUdzialowDlaAutoraZaCalosc, +) + + +@pytest.mark.django_db +def test_zarok_ma_uczelnia(autor_jan_kowalski, dyscyplina1): + u = baker.make("bpp.Uczelnia") + obj = IloscUdzialowDlaAutoraZaRok.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + rok=2022, + ilosc_udzialow=Decimal("1.0"), + ilosc_udzialow_monografie=Decimal("0.5"), + uczelnia=u, + ) + assert obj.uczelnia_id == u.pk + + +@pytest.mark.django_db +def test_zacalosc_ma_uczelnia(autor_jan_kowalski, dyscyplina1): + u = baker.make("bpp.Uczelnia") + obj = IloscUdzialowDlaAutoraZaCalosc.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + ilosc_udzialow=Decimal("1.0"), + ilosc_udzialow_monografie=Decimal("0.5"), + uczelnia=u, + ) + assert obj.uczelnia_id == u.pk +``` + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/ewaluacja_liczba_n/tests/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: FAIL (`TypeError: unexpected keyword 'uczelnia'` / unknown field). + +- [ ] **Step 3: Dodaj pole `uczelnia` + rozszerz unique_together** + +W `src/ewaluacja_liczba_n/models.py`: + +`IloscUdzialowDlaAutoraZaRok` — dodaj pole (po `autor_dyscyplina`) i zmień Meta: +```python + uczelnia = models.ForeignKey( + "bpp.Uczelnia", on_delete=models.CASCADE, null=True, blank=True + ) + + class Meta: + verbose_name = "Ilość udziałów dla autora za rok" + verbose_name_plural = "Ilości udziałów dla autorów za rok" + unique_together = [ + ("autor", "dyscyplina_naukowa", "rok", "uczelnia"), + ] +``` + +`IloscUdzialowDlaAutoraZaCalosc` — dodaj pole (po `komentarz`) i zmień Meta: +```python + uczelnia = models.ForeignKey( + "bpp.Uczelnia", on_delete=models.CASCADE, null=True, blank=True + ) + + class Meta: + verbose_name = "Ilość udziałów dla autora za cały okres" + verbose_name_plural = "Ilości udziałów dla autorów za cały okres" + unique_together = [ + ("autor", "dyscyplina_naukowa", "rodzaj_autora", "uczelnia"), + ] +``` + +- [ ] **Step 4: Wygeneruj migrację + dodaj backfill** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations ewaluacja_liczba_n -n iloscudzialow_uczelnia` +Otrzymasz `0009_*` z `AddField` (×2) + `AlterUniqueTogether` (×2). Dopisz do niej RunPython backfill (single→domyślna; multi z wierszami→fail) i dodaj do `operations` NA KOŃCU: + +```python +def backfill_uczelnia(apps, schema_editor): + Uczelnia = apps.get_model("bpp", "Uczelnia") + ZaRok = apps.get_model("ewaluacja_liczba_n", "IloscUdzialowDlaAutoraZaRok") + ZaCalosc = apps.get_model("ewaluacja_liczba_n", "IloscUdzialowDlaAutoraZaCalosc") + + null_qs = [ + ZaRok.objects.filter(uczelnia__isnull=True), + ZaCalosc.objects.filter(uczelnia__isnull=True), + ] + if not any(qs.exists() for qs in null_qs): + return + + uczelnie = list(Uczelnia.objects.all()[:2]) + if len(uczelnie) == 1: + for qs in null_qs: + qs.update(uczelnia=uczelnie[0]) + return + + raise RuntimeError( + "Migracja liczba_n per-uczelnia (0009): istnieją wiersze udziałów bez " + f"uczelni, a w systemie jest {len(uczelnie)} uczelni — nie można " + "zdeterministycznie przypisać. Przelicz liczbę N per uczelnia (przelicz_n) " + "albo usuń stare IloscUdzialow* i zaaplikuj migrację na czystych danych." + ) + + +def backfill_uczelnia_reverse(apps, schema_editor): + pass +``` + +i `migrations.RunPython(backfill_uczelnia, backfill_uczelnia_reverse)` jako ostatnia operacja. +**Uwaga kolejności:** `AlterUniqueTogether` musi wykonać się PRZED backfillem nie jest wymagane (backfill tylko update'uje FK); ale upewnij się, że AddField jest przed RunPython (jest, makemigrations stawia je pierwsze). + +- [ ] **Step 5: Uruchom test + brak dryfu** + +Run: `uv run pytest src/ewaluacja_liczba_n/tests/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run` +Expected: brak nowych zmian dla `ewaluacja_liczba_n` (pre-existing dryf innych apek ignoruj). + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff check src/ewaluacja_liczba_n/models.py src/ewaluacja_liczba_n/migrations/0009_*.py src/ewaluacja_liczba_n/tests/test_per_uczelnia.py +git add src/ewaluacja_liczba_n/models.py src/ewaluacja_liczba_n/migrations/0009_*.py src/ewaluacja_liczba_n/tests/test_per_uczelnia.py +git commit -m "feat(multi-hosted): FK uczelnia na IloscUdzialow* + backfill (liczba_n R2)" +``` + +--- + +## Task 2: Pipeline `utils.py` — zawężenie budowy udziałów per uczelnia + +**Files:** +- Modify: `src/ewaluacja_liczba_n/utils.py` +- Test: `src/ewaluacja_liczba_n/tests/test_per_uczelnia.py` + +To rdzeń R2. Funkcje są sprzężone (`oblicz_liczby_n_dla_ewaluacji_2022_2025` orkiestruje resztę), więc zmieniamy je RAZEM; testem jednostki jest cały pipeline. + +- [ ] **Step 1: Napisz failing testy (izolacja 2 uczelni + wykluczenie nieprzypisanych)** + +Dopisz w `test_per_uczelnia.py` (zbuduj scenariusz lokalnie — 2 uczelnie, autor w każdej przez `aktualna_jednostka`, oraz autor z obcą/NULL jednostką; ustaw `Autor_Dyscyplina` z `wymiar_etatu` i `rodzaj_autora.jest_w_n=True`): + +```python +@pytest.mark.django_db +def test_pipeline_izolacja_dwie_uczelnie(db): + from decimal import Decimal + + from bpp.models import Autor, Autor_Dyscyplina, Jednostka, Uczelnia, Wydzial + from bpp.models.dyscyplina_naukowa import Dyscyplina_Naukowa + from ewaluacja_common.models import Rodzaj_Autora + from ewaluacja_liczba_n.models import ( + IloscUdzialowDlaAutoraZaRok, + LiczbaNDlaUczelni, + ) + from ewaluacja_liczba_n.utils import oblicz_liczby_n_dla_ewaluacji_2022_2025 + + u1 = baker.make(Uczelnia, skrot="U1", nazwa="U1") + u2 = baker.make(Uczelnia, skrot="U2", nazwa="U2") + j1 = baker.make(Jednostka, uczelnia=u1, skupia_pracownikow=True) + j2 = baker.make(Jednostka, uczelnia=u2, skupia_pracownikow=True) + dyscyplina = baker.make(Dyscyplina_Naukowa) + rodzaj_n = baker.make(Rodzaj_Autora, jest_w_n=True, licz_sloty=True) + + a1 = baker.make(Autor, aktualna_jednostka=j1) + a2 = baker.make(Autor, aktualna_jednostka=j2) + for autor in (a1, a2): + for rok in (2022, 2023, 2024, 2025): + baker.make( + Autor_Dyscyplina, autor=autor, rok=rok, + dyscyplina_naukowa=dyscyplina, rodzaj_autora=rodzaj_n, + wymiar_etatu=Decimal("1.0"), procent_dyscypliny=Decimal("100.0"), + ) + + oblicz_liczby_n_dla_ewaluacji_2022_2025(u1) + oblicz_liczby_n_dla_ewaluacji_2022_2025(u2) # drugi run NIE może skasować u1 + + assert IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=u1, autor=a1).exists() + assert IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=u2, autor=a2).exists() + # u1 nie zawiera autora z u2 i odwrotnie: + assert not IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=u1, autor=a2).exists() + assert not IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=u2, autor=a1).exists() + # LiczbaN policzona per uczelnia: + assert LiczbaNDlaUczelni.objects.filter(uczelnia=u1).exists() + assert LiczbaNDlaUczelni.objects.filter(uczelnia=u2).exists() + + +@pytest.mark.django_db +def test_pipeline_pomija_nieprzypisanych(db): + from decimal import Decimal + + from bpp.models import Autor, Autor_Dyscyplina, Jednostka, Uczelnia + from bpp.models.dyscyplina_naukowa import Dyscyplina_Naukowa + from ewaluacja_common.models import Rodzaj_Autora + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaRok + from ewaluacja_liczba_n.utils import oblicz_liczby_n_dla_ewaluacji_2022_2025 + + u1 = baker.make(Uczelnia, skrot="U1", nazwa="U1") + obca = baker.make(Jednostka, uczelnia=u1, skupia_pracownikow=False) + dyscyplina = baker.make(Dyscyplina_Naukowa) + rodzaj_n = baker.make(Rodzaj_Autora, jest_w_n=True, licz_sloty=True) + + a_null = baker.make(Autor, aktualna_jednostka=None) + a_obca = baker.make(Autor, aktualna_jednostka=obca) + for autor in (a_null, a_obca): + baker.make( + Autor_Dyscyplina, autor=autor, rok=2022, + dyscyplina_naukowa=dyscyplina, rodzaj_autora=rodzaj_n, + wymiar_etatu=Decimal("1.0"), procent_dyscypliny=Decimal("100.0"), + ) + + oblicz_liczby_n_dla_ewaluacji_2022_2025(u1) + + assert not IloscUdzialowDlaAutoraZaRok.objects.filter(autor=a_null).exists() + assert not IloscUdzialowDlaAutoraZaRok.objects.filter(autor=a_obca).exists() +``` + +(Sprawdź faktyczne nazwy pól `Rodzaj_Autora`/`Autor_Dyscyplina` w `src/ewaluacja_common/models.py` i `src/bpp/models/dyscyplina_naukowa.py` — dostosuj fixture, jeśli `procent_dyscypliny`/`wymiar_etatu`/`jest_w_n`/`licz_sloty` mają inne nazwy/wymogi. Cel testów niezmienny.) + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/ewaluacja_liczba_n/tests/test_per_uczelnia.py -k pipeline -q -p no:cacheprovider` +Expected: FAIL (drugi run kasuje wiersze u1; nieprzypisani są liczeni; `uczelnia` None). + +- [ ] **Step 3: Zawęź `oblicz_liczby_n_dla_ewaluacji_2022_2025`** + +W `src/ewaluacja_liczba_n/utils.py`, w `oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia, rok_min=2022, rok_max=2025)`: +- Zmień delete na per-uczelnia: +```python + IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=uczelnia, **warunek_lat).delete() +``` +- Zawęź iterację autorów (zamiast `Autor_Dyscyplina.objects.filter(**warunek_lat)`): +```python + autor_dyscypliny = Autor_Dyscyplina.objects.filter( + autor__aktualna_jednostka__uczelnia=uczelnia, + autor__aktualna_jednostka__skupia_pracownikow=True, + **warunek_lat, + ) + for ad in autor_dyscypliny: +``` +- W obu `IloscUdzialowDlaAutoraZaRok.objects.create(...)` dodaj `uczelnia=uczelnia,`. +- Przekaż `uczelnia` do kroków: `oblicz_sumy_udzialow_za_calosc(uczelnia, rok_min, rok_max)`, + `oblicz_srednia_liczbe_n_dla_dyscyplin(uczelnia, rok_min, rok_max)` (już bierze uczelnia), + `oblicz_dyscypliny_nieraportowane(uczelnia, rok_max)`, + `dolicz_bonus_za_nieraportowana(uczelnia, nieraportowane_ids, rok_min, rok_max)`. + +- [ ] **Step 4: Zawęź `oblicz_sumy_udzialow_za_calosc`** + +Sygnatura → `oblicz_sumy_udzialow_za_calosc(uczelnia, rok_min=2022, rok_max=2025)`. +- Delete per-uczelnia: `IloscUdzialowDlaAutoraZaCalosc.objects.filter(uczelnia=uczelnia).delete()` + (zamiast `objects.all().delete()`). +- Źródło agregacji: `IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=uczelnia, rok__gte=rok_min, rok__lte=rok_max)`. +- W `IloscUdzialowDlaAutoraZaCalosc.objects.create(...)` dodaj `uczelnia=uczelnia,`. + +- [ ] **Step 5: Zawęź `oblicz_srednia_liczbe_n_dla_dyscyplin`, `oblicz_dyscypliny_nieraportowane`, `dolicz_bonus_za_nieraportowana`, `oblicz_liczbe_n_na_koniec_2025`** + +- `oblicz_srednia_liczbe_n_dla_dyscyplin(uczelnia, …)`: zmień `udzialy = + IloscUdzialowDlaAutoraZaRok.objects.filter(**rok_kw)` → `.filter(uczelnia=uczelnia, **rok_kw)`. + (Reszta — delete/zapis `LiczbaNDlaUczelni` per uczelnia — bez zmian.) +- `oblicz_dyscypliny_nieraportowane(uczelnia, rok=2025)`: dodaj param `uczelnia`; + `IloscUdzialowDlaAutoraZaRok.objects.filter(rok=rok)` → `.filter(uczelnia=uczelnia, rok=rok)`. +- `dolicz_bonus_za_nieraportowana(uczelnia, nieraportowane_ids, rok_min=2022, rok_max=2025)`: + dodaj param `uczelnia`; iteracja `IloscUdzialowDlaAutoraZaCalosc.objects.select_related(...)` + → `.filter(uczelnia=uczelnia).select_related(...)`. +- `oblicz_liczbe_n_na_koniec_2025(uczelnia)`: `IloscUdzialowDlaAutoraZaRok.objects.filter(rok=2025)` + → `.filter(uczelnia=uczelnia, rok=2025)`. + +- [ ] **Step 6: Uruchom — ma PRZEJŚĆ** + +Run: `uv run pytest src/ewaluacja_liczba_n/tests/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS (izolacja + wykluczenie). + +- [ ] **Step 7: Regresja istniejących testów liczba_n (invariant single-install)** + +Run: `uv run pytest src/ewaluacja_liczba_n/ -q -p no:cacheprovider -k "not playwright"` +Expected: PASS. Jeśli któryś istniejący test woła `oblicz_dyscypliny_nieraportowane()` / +`oblicz_sumy_udzialow_za_calosc()` bez uczelni — zaktualizuj wywołanie (oczekiwana zmiana sygnatury). Jeśli test buduje udziały bez `aktualna_jednostka` na autorze, dostosuj fixture (autor musi mieć jednostkę w danej uczelni, by być liczony). + +- [ ] **Step 8: Lint + commit** + +```bash +uv run ruff check src/ewaluacja_liczba_n/utils.py src/ewaluacja_liczba_n/tests/test_per_uczelnia.py +git add src/ewaluacja_liczba_n/utils.py src/ewaluacja_liczba_n/tests/test_per_uczelnia.py +git commit -m "feat(multi-hosted): pipeline liczba_n zawężony per uczelnia (utils.py)" +``` + +--- + +## Task 3: Odczyty (views) — filtr po uczelni + nieraportowane z uczelnią + +**Files:** +- Modify: `src/ewaluacja_liczba_n/views/list.py`, `src/ewaluacja_liczba_n/views/export.py`, `src/ewaluacja_liczba_n/views/verify.py` +- Test: `src/ewaluacja_liczba_n/tests/test_per_uczelnia.py` + +- [ ] **Step 1: Napisz failing test (lista U1 nie pokazuje wierszy U2)** + +Dopisz test: zbuduj 2 uczelnie + udziały (jak Task 2), wywołaj `get_queryset` widoku +`AutorzyLiczbaNListView` (lub `UdzialyZaCaloscListView`) z requestem rozwiązującym U1 +(`request._uczelnia = u1`), asercja: queryset zawiera tylko wiersze `uczelnia=u1`. +Wzór budowy requestu jak w R1 (fake request z `_uczelnia` + `user`); zajrzyj do +istniejących testów `ewaluacja_liczba_n/tests/` po wzór instancjonowania widoku/klienta. + +- [ ] **Step 2: Uruchom — ma FAILOWAĆ** + +Run: `uv run pytest src/ewaluacja_liczba_n/tests/test_per_uczelnia.py -k "view or lista" -q -p no:cacheprovider` +Expected: FAIL (widok pokazuje obie uczelnie). + +- [ ] **Step 3: Dodaj filtr `uczelnia` w widokach** + +Wzór (źródło uczelni jak w istniejącym `views/index.py`): +```python +from bpp.models import Uczelnia +... + uczelnia = Uczelnia.objects.get_for_request(self.request) +``` +Zastosuj `.filter(uczelnia=uczelnia)` w: +- `views/list.py:184` (`AutorzyLiczbaNListView.get_queryset`, `IloscUdzialowDlaAutoraZaRok.objects.filter(...)`), +- `views/list.py:211,223` (`IloscUdzialowDlaAutoraZaRok.objects.filter(rok__gte=2022, rok__lte=2025)` w `get_context_data`), +- `views/list.py:331` (`UdzialyZaCaloscListView.get_queryset`, `IloscUdzialowDlaAutoraZaCalosc.objects.all()` → `.filter(uczelnia=uczelnia)`), +- `views/list.py:392` (`IloscUdzialowDlaAutoraZaCalosc.objects.values_list(...)` → dodaj `.filter(uczelnia=uczelnia)` przed `values_list`), +- `views/export.py:23` (`IloscUdzialowDlaAutoraZaRok.objects.filter(...)`), +- `views/export.py:207` (`IloscUdzialowDlaAutoraZaCalosc.objects.all()` → `.filter(uczelnia=uczelnia)`), +- `views/verify.py:227` (`IloscUdzialowDlaAutoraZaRok.objects.filter(rok=2025)` → dodaj `uczelnia=uczelnia`). + +Oraz przekaż uczelnię do `oblicz_dyscypliny_nieraportowane(uczelnia)` we wszystkich +wywołaniach w `views/list.py` (linie ok. 113, 243, 357, 412). + +W każdym widoku pobierz `uczelnia = Uczelnia.objects.get_for_request(self.request)` +RAZ w `get_queryset`/`get_context_data` i użyj zarówno do `.filter`, jak i do +`oblicz_dyscypliny_nieraportowane`. + +- [ ] **Step 4: Uruchom — ma PRZEJŚĆ + regresja** + +Run: `uv run pytest src/ewaluacja_liczba_n/ -q -p no:cacheprovider -k "not playwright"` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check src/ewaluacja_liczba_n/views/list.py src/ewaluacja_liczba_n/views/export.py src/ewaluacja_liczba_n/views/verify.py src/ewaluacja_liczba_n/tests/test_per_uczelnia.py +git add src/ewaluacja_liczba_n/views/ src/ewaluacja_liczba_n/tests/test_per_uczelnia.py +git commit -m "feat(multi-hosted): widoki liczba_n filtrują udziały po uczelni" +``` + +--- + +## Task 4: Regresja całościowa + brak dryfu + +**Files:** brak (gate). + +- [ ] **Step 1: Pełna regresja liczba_n + konsumenci** + +Run: `uv run pytest src/ewaluacja_liczba_n/ src/ewaluacja_metryki/ -q -p no:cacheprovider -k "not playwright"` +Expected: PASS. (`ewaluacja_metryki` woła `oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia)` +— sprawdź, że nadal przechodzi; jeśli jakiś test metryk budował dane bez `aktualna_jednostka`, +dostosuj fixture.) + +- [ ] **Step 2: Brak dryfu migracji** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run` +Expected: brak nowych zmian dla `ewaluacja_liczba_n`. + +- [ ] **Step 3: Commit (jeśli były poprawki fixtur w Step 1)** + +```bash +git add -A +git commit -m "test(multi-hosted): regresja liczba_n per-uczelnia + dostosowanie fixtur" +``` +(Jeśli nic nie zmieniono — pomiń commit.) + +--- + +## Self-review (autor planu) + +**Spec coverage:** +- FK uczelnia + unique_together + migracja/backfill → Task 1 ✓ +- Pipeline `utils.py` per uczelnia (wszystkie `oblicz_*`, delete per-uczelnia, filtr autorów po aktualna_jednostka.uczelnia + skupia_pracownikow, wykluczenie nieprzypisanych) → Task 2 ✓ +- Odczyty (export/list/verify) filtr `get_for_request` + nieraportowane z uczelnią → Task 3 ✓ +- Invariant single-install → regresja w Task 2/3/4 ✓ +- Multi-install izolacja + wykluczenie nieprzypisanych → testy Task 2 ✓ + +**Znane luki / uwagi wykonawcy:** +- Task 1 Step 4: kolejność operacji w migracji — AddField przed RunPython (makemigrations stawia je pierwsze); zweryfikuj wygenerowany plik. +- Task 2: nazwy pól `Rodzaj_Autora`/`Autor_Dyscyplina` (`jest_w_n`, `licz_sloty`, `wymiar_etatu`, `procent_dyscypliny`, `policz_udzialy`) — potwierdź w modelach przed pisaniem fixtur; cel testów niezmienny. +- Task 2 Step 7 / Task 4: istniejące testy mogą wołać `oblicz_*` bez uczelni (zmiana sygnatury) lub budować autorów bez `aktualna_jednostka` — dostosuj (oczekiwane). +- Task 3: dokładne sygnatury widoków (`get_queryset`/`get_context_data`) — przeczytaj plik przed edycją; pobierz uczelnię raz, użyj wielokrotnie. +- `oblicz_dyscypliny_nieraportowane` staje się wymagającym `uczelnia` — wszystkie wywołania (utils + views/list.py ×4) muszą przekazać uczelnię (inaczej TypeError złapie test/regresja). From 135821bd1e885bd6843b10330214b671ac72d951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 14:49:56 +0200 Subject: [PATCH 106/247] feat(multi-hosted): FK uczelnia na IloscUdzialow* + backfill (liczba_n R2) Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/0009_iloscudzialow_uczelnia.py | 95 +++++++++++++++++++ src/ewaluacja_liczba_n/models.py | 10 +- .../tests/test_per_uczelnia.py | 36 +++++++ 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 src/ewaluacja_liczba_n/migrations/0009_iloscudzialow_uczelnia.py create mode 100644 src/ewaluacja_liczba_n/tests/test_per_uczelnia.py diff --git a/src/ewaluacja_liczba_n/migrations/0009_iloscudzialow_uczelnia.py b/src/ewaluacja_liczba_n/migrations/0009_iloscudzialow_uczelnia.py new file mode 100644 index 000000000..4f9836b53 --- /dev/null +++ b/src/ewaluacja_liczba_n/migrations/0009_iloscudzialow_uczelnia.py @@ -0,0 +1,95 @@ +# Generated by Django 5.2.14 on 2026-06-03 12:47 + +import django.db.models.deletion +from django.db import migrations, models + + +def backfill_uczelnia(apps, schema_editor): + Uczelnia = apps.get_model("bpp", "Uczelnia") + ZaRok = apps.get_model( + "ewaluacja_liczba_n", "IloscUdzialowDlaAutoraZaRok" + ) + ZaCalosc = apps.get_model( + "ewaluacja_liczba_n", "IloscUdzialowDlaAutoraZaCalosc" + ) + + null_qs = [ + ZaRok.objects.filter(uczelnia__isnull=True), + ZaCalosc.objects.filter(uczelnia__isnull=True), + ] + if not any(qs.exists() for qs in null_qs): + return + + uczelnie = list(Uczelnia.objects.all()[:2]) + if len(uczelnie) == 1: + for qs in null_qs: + qs.update(uczelnia=uczelnie[0]) + return + + raise RuntimeError( + "Migracja liczba_n per-uczelnia (0009): istnieja wiersze udzialow" + " bez uczelni, a w systemie jest" + f" {len(uczelnie)} uczelni - nie mozna zdeterministycznie" + " przypisac. Przelicz liczbe N per uczelnia (przelicz_n) albo" + " usun stare IloscUdzialow* i zaaplikuj migracje na czystych" + " danych." + ) + + +def backfill_uczelnia_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0427_cpd_index_rekord_uczelnia_dyscyplina'), + ('ewaluacja_common', '0004_alter_rodzaj_autora_options'), + ('ewaluacja_liczba_n', '0008_add_sankcje'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='iloscudzialowdlaautorazacalosc', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='iloscudzialowdlaautorazarok', + unique_together=set(), + ), + migrations.AddField( + model_name='iloscudzialowdlaautorazacalosc', + name='uczelnia', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='bpp.uczelnia', + ), + ), + migrations.AddField( + model_name='iloscudzialowdlaautorazarok', + name='uczelnia', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='bpp.uczelnia', + ), + ), + migrations.AlterUniqueTogether( + name='iloscudzialowdlaautorazacalosc', + unique_together={ + ('autor', 'dyscyplina_naukowa', 'rodzaj_autora', 'uczelnia') + }, + ), + migrations.AlterUniqueTogether( + name='iloscudzialowdlaautorazarok', + unique_together={ + ('autor', 'dyscyplina_naukowa', 'rok', 'uczelnia') + }, + ), + migrations.RunPython( + backfill_uczelnia, backfill_uczelnia_reverse + ), + ] diff --git a/src/ewaluacja_liczba_n/models.py b/src/ewaluacja_liczba_n/models.py index 43d823687..13f06476f 100644 --- a/src/ewaluacja_liczba_n/models.py +++ b/src/ewaluacja_liczba_n/models.py @@ -70,6 +70,9 @@ class IloscUdzialowDlaAutoraZaRok(IloscUdzialowDlaAutoraBase): blank=True, help_text="Link do oryginalnego rekordu Autor_Dyscyplina za dany rok", ) + uczelnia = models.ForeignKey( + "bpp.Uczelnia", on_delete=models.CASCADE, null=True, blank=True + ) # Override ilosc_udzialow to add validator for yearly limit ilosc_udzialow = LiczbaNField(validators=[MaxValueValidator(4)]) @@ -78,7 +81,7 @@ class Meta: verbose_name = "Ilość udziałów dla autora za rok" verbose_name_plural = "Ilości udziałów dla autorów za rok" unique_together = [ - ("autor", "dyscyplina_naukowa", "rok"), + ("autor", "dyscyplina_naukowa", "rok", "uczelnia"), ] @@ -92,6 +95,9 @@ class IloscUdzialowDlaAutoraZaCalosc(IloscUdzialowDlaAutoraBase): null=True, blank=True, ) + uczelnia = models.ForeignKey( + "bpp.Uczelnia", on_delete=models.CASCADE, null=True, blank=True + ) komentarz = models.TextField( blank=True, @@ -106,7 +112,7 @@ class Meta: verbose_name = "Ilość udziałów dla autora za cały okres" verbose_name_plural = "Ilości udziałów dla autorów za cały okres" unique_together = [ - ("autor", "dyscyplina_naukowa", "rodzaj_autora"), + ("autor", "dyscyplina_naukowa", "rodzaj_autora", "uczelnia"), ] diff --git a/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py b/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py new file mode 100644 index 000000000..a4f5a2d4c --- /dev/null +++ b/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py @@ -0,0 +1,36 @@ +from decimal import Decimal + +import pytest +from model_bakery import baker + +from ewaluacja_liczba_n.models import ( + IloscUdzialowDlaAutoraZaCalosc, + IloscUdzialowDlaAutoraZaRok, +) + + +@pytest.mark.django_db +def test_zarok_ma_uczelnia(autor_jan_kowalski, dyscyplina1): + u = baker.make("bpp.Uczelnia") + obj = IloscUdzialowDlaAutoraZaRok.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + rok=2022, + ilosc_udzialow=Decimal("1.0"), + ilosc_udzialow_monografie=Decimal("0.5"), + uczelnia=u, + ) + assert obj.uczelnia_id == u.pk + + +@pytest.mark.django_db +def test_zacalosc_ma_uczelnia(autor_jan_kowalski, dyscyplina1): + u = baker.make("bpp.Uczelnia") + obj = IloscUdzialowDlaAutoraZaCalosc.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + ilosc_udzialow=Decimal("1.0"), + ilosc_udzialow_monografie=Decimal("0.5"), + uczelnia=u, + ) + assert obj.uczelnia_id == u.pk From cae08035bed605ec27e77028eec158f6343caf0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:01:23 +0200 Subject: [PATCH 107/247] =?UTF-8?q?feat(multi-hosted):=20pipeline=20liczba?= =?UTF-8?q?=5Fn=20zaw=C4=99=C5=BCony=20per=20uczelnia=20(utils.py)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope the entire liczba-N computation pipeline to one university: - oblicz_liczby_n_dla_ewaluacji_2022_2025: delete/iterate/write per uczelnia; filter Autor_Dyscyplina by aktualna_jednostka__uczelnia + skupia_pracownikow=True (excludes aktualna_jednostka=None authors automatically via __ lookup) - oblicz_sumy_udzialow_za_calosc: add uczelnia param; delete/read/write per uczelnia - oblicz_srednia_liczbe_n_dla_dyscyplin: filter IloscUdzialowDlaAutoraZaRok by uczelnia - oblicz_dyscypliny_nieraportowane: add uczelnia param; filter by uczelnia - dolicz_bonus_za_nieraportowana: add uczelnia as first param; filter ZaCalosc by uczelnia - oblicz_liczbe_n_na_koniec_2025: filter by uczelnia New TDD tests (red→green): - test_pipeline_izolacja_dwie_uczelnie: two-uczelnia isolation — second run must NOT wipe u1 records; cross-uczelnia autor rows absent - test_pipeline_pomija_nieprzypisanych: authors with aktualna_jednostka=None or skupia_pracownikow=False produce no IloscUdzialowDlaAutoraZaRok rows Existing test updates (expected signature change + aktualna_jednostka fixture): - test_utils.py: add jednostka fixture + aktualna_jednostka=jednostka to baker.make(Autor) for pipeline tests; add uczelnia= to manually-created IloscUdzialowDlaAutoraZaRok rows - test_rodzaj_autora.py: pass uczelnia to oblicz_sumy_udzialow_za_calosc(); add uczelnia= to IloscUdzialowDlaAutoraZaRok.objects.create() calls 17 passed, 1 skipped (pre-existing) Co-Authored-By: Claude Opus 4.8 --- .../tests/test_per_uczelnia.py | 86 +++++++++++++++++++ .../tests/test_rodzaj_autora.py | 21 +++-- src/ewaluacja_liczba_n/tests/test_utils.py | 48 ++++++++--- src/ewaluacja_liczba_n/utils.py | 75 ++++++++++------ 4 files changed, 181 insertions(+), 49 deletions(-) diff --git a/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py b/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py index a4f5a2d4c..169c7170d 100644 --- a/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py +++ b/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py @@ -9,6 +9,35 @@ ) +def _make_autor_dyscyplina(autor, rok, dyscyplina, rodzaj_autora=None): + """ + Tworzy Autor_Dyscyplina z niezerowym udziałem (wymiar_etatu=1.0, + procent_dyscypliny=100) i rodzajem autora jest_w_n=True, licz_sloty=True. + Jeśli rodzaj_autora nie jest przekazany, tworzy go (lub pobiera istniejący). + """ + from bpp.models.dyscyplina_naukowa import Autor_Dyscyplina + from ewaluacja_common.models import Rodzaj_Autora + + if rodzaj_autora is None: + rodzaj_autora, _ = Rodzaj_Autora.objects.get_or_create( + skrot="N", + defaults=dict( + nazwa="pracownik naukowy w liczbie N", + jest_w_n=True, + licz_sloty=True, + sort=1, + ), + ) + return Autor_Dyscyplina.objects.create( + autor=autor, + rok=rok, + dyscyplina_naukowa=dyscyplina, + wymiar_etatu=Decimal("1.0"), + procent_dyscypliny=Decimal("100.0"), + rodzaj_autora=rodzaj_autora, + ) + + @pytest.mark.django_db def test_zarok_ma_uczelnia(autor_jan_kowalski, dyscyplina1): u = baker.make("bpp.Uczelnia") @@ -34,3 +63,60 @@ def test_zacalosc_ma_uczelnia(autor_jan_kowalski, dyscyplina1): uczelnia=u, ) assert obj.uczelnia_id == u.pk + + +@pytest.mark.django_db +def test_pipeline_izolacja_dwie_uczelnie(db): + from bpp.models import Autor, Jednostka, Uczelnia + from bpp.models.dyscyplina_naukowa import Dyscyplina_Naukowa + from ewaluacja_liczba_n.models import ( + IloscUdzialowDlaAutoraZaRok, + LiczbaNDlaUczelni, + ) + from ewaluacja_liczba_n.utils import oblicz_liczby_n_dla_ewaluacji_2022_2025 + + u1 = baker.make(Uczelnia, skrot="U1", nazwa="U1") + u2 = baker.make(Uczelnia, skrot="U2", nazwa="U2") + j1 = baker.make(Jednostka, uczelnia=u1, skupia_pracownikow=True) + j2 = baker.make(Jednostka, uczelnia=u2, skupia_pracownikow=True) + dyscyplina = baker.make(Dyscyplina_Naukowa) + a1 = baker.make(Autor, aktualna_jednostka=j1) + a2 = baker.make(Autor, aktualna_jednostka=j2) + for autor in (a1, a2): + for rok in (2022, 2023, 2024, 2025): + _make_autor_dyscyplina(autor, rok, dyscyplina) + + oblicz_liczby_n_dla_ewaluacji_2022_2025(u1) + oblicz_liczby_n_dla_ewaluacji_2022_2025(u2) # second run must NOT wipe u1 + + assert IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=u1, autor=a1).exists() + assert IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=u2, autor=a2).exists() + assert not IloscUdzialowDlaAutoraZaRok.objects.filter( + uczelnia=u1, autor=a2 + ).exists() + assert not IloscUdzialowDlaAutoraZaRok.objects.filter( + uczelnia=u2, autor=a1 + ).exists() + assert LiczbaNDlaUczelni.objects.filter(uczelnia=u1).exists() + assert LiczbaNDlaUczelni.objects.filter(uczelnia=u2).exists() + + +@pytest.mark.django_db +def test_pipeline_pomija_nieprzypisanych(db): + from bpp.models import Autor, Jednostka, Uczelnia + from bpp.models.dyscyplina_naukowa import Dyscyplina_Naukowa + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaRok + from ewaluacja_liczba_n.utils import oblicz_liczby_n_dla_ewaluacji_2022_2025 + + u1 = baker.make(Uczelnia, skrot="U1", nazwa="U1") + obca = baker.make(Jednostka, uczelnia=u1, skupia_pracownikow=False) + dyscyplina = baker.make(Dyscyplina_Naukowa) + a_null = baker.make(Autor, aktualna_jednostka=None) + a_obca = baker.make(Autor, aktualna_jednostka=obca) + for autor in (a_null, a_obca): + _make_autor_dyscyplina(autor, 2022, dyscyplina) + + oblicz_liczby_n_dla_ewaluacji_2022_2025(u1) + + assert not IloscUdzialowDlaAutoraZaRok.objects.filter(autor=a_null).exists() + assert not IloscUdzialowDlaAutoraZaRok.objects.filter(autor=a_obca).exists() diff --git a/src/ewaluacja_liczba_n/tests/test_rodzaj_autora.py b/src/ewaluacja_liczba_n/tests/test_rodzaj_autora.py index 9a109a4d9..527bbda60 100644 --- a/src/ewaluacja_liczba_n/tests/test_rodzaj_autora.py +++ b/src/ewaluacja_liczba_n/tests/test_rodzaj_autora.py @@ -13,7 +13,7 @@ @pytest.mark.django_db def test_oblicz_sumy_udzialow_za_calosc_jeden_rodzaj_autora( - dyscyplina1, rodzaj_autora_n + uczelnia, dyscyplina1, rodzaj_autora_n ): """Test tworzenia wpisu dla jednego rodzaju autora przez wszystkie lata.""" autor = baker.make(Autor) @@ -29,17 +29,18 @@ def test_oblicz_sumy_udzialow_za_calosc_jeden_rodzaj_autora( rodzaj_autora=rodzaj_autora_n, ) - # Utwórz udziały dla każdego roku + # Utwórz udziały dla każdego roku (z uczelnia) IloscUdzialowDlaAutoraZaRok.objects.create( rok=rok, autor=autor, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("1.0"), ilosc_udzialow_monografie=Decimal("0.5"), ) # Oblicz sumy za cały okres - oblicz_sumy_udzialow_za_calosc(2022, 2025) + oblicz_sumy_udzialow_za_calosc(uczelnia, 2022, 2025) # Sprawdź wynik - powinien być jeden wpis dla rodzaju N wynik = IloscUdzialowDlaAutoraZaCalosc.objects.get( @@ -58,7 +59,7 @@ def test_oblicz_sumy_udzialow_za_calosc_jeden_rodzaj_autora( @pytest.mark.django_db def test_oblicz_sumy_udzialow_za_calosc_wiele_rodzajow_autora( - dyscyplina1, rodzaj_autora_n, rodzaj_autora_d + uczelnia, dyscyplina1, rodzaj_autora_n, rodzaj_autora_d ): """Test tworzenia oddzielnych wpisów dla różnych rodzajów autora.""" autor = baker.make(Autor) @@ -93,18 +94,19 @@ def test_oblicz_sumy_udzialow_za_calosc_wiele_rodzajow_autora( rodzaj_autora=rodzaj_autora_n, ) - # Utwórz udziały dla każdego roku + # Utwórz udziały dla każdego roku (z uczelnia) for rok in [2022, 2023, 2024]: IloscUdzialowDlaAutoraZaRok.objects.create( rok=rok, autor=autor, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("1.0"), ilosc_udzialow_monografie=Decimal("0.5"), ) # Oblicz sumy za cały okres - oblicz_sumy_udzialow_za_calosc(2022, 2025) + oblicz_sumy_udzialow_za_calosc(uczelnia, 2022, 2025) # Sprawdź że są 2 osobne wpisy: jeden dla N, jeden dla D assert IloscUdzialowDlaAutoraZaCalosc.objects.count() == 2 @@ -133,7 +135,7 @@ def test_oblicz_sumy_udzialow_za_calosc_wiele_rodzajow_autora( @pytest.mark.django_db -def test_oblicz_sumy_udzialow_za_calosc_brak_rodzaju_autora(dyscyplina1): +def test_oblicz_sumy_udzialow_za_calosc_brak_rodzaju_autora(uczelnia, dyscyplina1): """Test pomijania autorów bez przypisanego rodzaju autora.""" autor = baker.make(Autor) @@ -148,17 +150,18 @@ def test_oblicz_sumy_udzialow_za_calosc_brak_rodzaju_autora(dyscyplina1): rodzaj_autora=None, # Brak rodzaju autora ) - # Utwórz udziały dla każdego roku + # Utwórz udziały dla każdego roku (z uczelnia) IloscUdzialowDlaAutoraZaRok.objects.create( rok=rok, autor=autor, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("1.0"), ilosc_udzialow_monografie=Decimal("0.5"), ) # Oblicz sumy za cały okres - oblicz_sumy_udzialow_za_calosc(2022, 2025) + oblicz_sumy_udzialow_za_calosc(uczelnia, 2022, 2025) # Sprawdź że NIE został utworzony żaden wpis (rekordy z rodzaj_autora=None są pomijane) assert IloscUdzialowDlaAutoraZaCalosc.objects.count() == 0 diff --git a/src/ewaluacja_liczba_n/tests/test_utils.py b/src/ewaluacja_liczba_n/tests/test_utils.py index 512a549e2..c4cb3094e 100644 --- a/src/ewaluacja_liczba_n/tests/test_utils.py +++ b/src/ewaluacja_liczba_n/tests/test_utils.py @@ -17,6 +17,7 @@ @pytest.mark.parametrize("zaokraglaj", [True, False]) def test_oblicz_liczby_n_dla_ewaluacji_2022_2025_prosty( uczelnia, + jednostka, autor_jan_nowak, dyscyplina1, zaokraglaj, @@ -26,6 +27,9 @@ def test_oblicz_liczby_n_dla_ewaluacji_2022_2025_prosty( # miała liczbę N większą od 12. W ten sposób nie zostanie usunięta z wykazu # dyscyplin raportowanych. # WAŻNE: Tworzymy dane dla roku 2022 i 2025, aby dyscyplina miała N >= 12 na koniec 2025 + # Jednostka musi skupiać pracowników żeby autorzy byli uwzględniani w pipeline + jednostka.skupia_pracownikow = True + jednostka.save() for rok in [2022, 2025]: ad_kwargs = dict( dyscyplina_naukowa=dyscyplina1, @@ -35,10 +39,12 @@ def test_oblicz_liczby_n_dla_ewaluacji_2022_2025_prosty( rok=rok, ) for _elem in range(12 * 5): - autor = baker.make(Autor) + autor = baker.make(Autor, aktualna_jednostka=jednostka) Autor_Dyscyplina.objects.create(autor=autor, **ad_kwargs) - # Dodaj autor_jan_nowak tylko dla roku 2022 + # Dodaj autor_jan_nowak tylko dla roku 2022 (przypisz do jednostki uczelni) + autor_jan_nowak.aktualna_jednostka = jednostka + autor_jan_nowak.save() Autor_Dyscyplina.objects.create( autor=autor_jan_nowak, dyscyplina_naukowa=dyscyplina1, @@ -96,11 +102,12 @@ def test_oblicz_srednia_liczbe_n_dla_dyscyplin_podstawowy( rodzaj_autora=rodzaj_autora_n, ) - # Utwórz udziały + # Utwórz udziały (z uczelnia żeby funkcja je znalazła) IloscUdzialowDlaAutoraZaRok.objects.create( rok=2022, autor=autor1, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("2.0"), ilosc_udzialow_monografie=Decimal("1.0"), ) @@ -109,6 +116,7 @@ def test_oblicz_srednia_liczbe_n_dla_dyscyplin_podstawowy( rok=2022, autor=autor2, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("1.0"), ilosc_udzialow_monografie=Decimal("0.5"), ) @@ -148,6 +156,7 @@ def test_oblicz_srednia_liczbe_n_dla_dyscyplin_wieloletni( rok=rok, autor=autor, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("1.0"), ilosc_udzialow_monografie=Decimal("0.5"), ) @@ -195,11 +204,12 @@ def test_oblicz_srednia_liczbe_n_tylko_pracownicy_n( rodzaj_autora=rodzaj_autora_d, ) - # Utwórz udziały dla obu + # Utwórz udziały dla obu (z uczelnia) IloscUdzialowDlaAutoraZaRok.objects.create( rok=2022, autor=autor_n, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("2.0"), ilosc_udzialow_monografie=Decimal("1.0"), ) @@ -208,6 +218,7 @@ def test_oblicz_srednia_liczbe_n_tylko_pracownicy_n( rok=2022, autor=autor_d, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("2.0"), ilosc_udzialow_monografie=Decimal("1.0"), ) @@ -251,11 +262,12 @@ def test_oblicz_srednia_liczbe_n_autor_b_nie_liczony( rodzaj_autora=rodzaj_autora_b, ) - # Utwórz udziały dla obu + # Utwórz udziały dla obu (z uczelnia) IloscUdzialowDlaAutoraZaRok.objects.create( rok=2022, autor=autor_n, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("2.0"), ilosc_udzialow_monografie=Decimal("1.0"), ) @@ -264,6 +276,7 @@ def test_oblicz_srednia_liczbe_n_autor_b_nie_liczony( rok=2022, autor=autor_b, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("2.0"), ilosc_udzialow_monografie=Decimal("1.0"), ) @@ -296,11 +309,12 @@ def test_oblicz_srednia_liczbe_n_brak_wymiaru_etatu( rodzaj_autora=rodzaj_autora_n, ) - # Utwórz udział + # Utwórz udział (z uczelnia) IloscUdzialowDlaAutoraZaRok.objects.create( rok=2022, autor=autor, dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, ilosc_udzialow=Decimal("2.0"), ilosc_udzialow_monografie=Decimal("1.0"), ) @@ -316,11 +330,15 @@ def test_oblicz_srednia_liczbe_n_brak_wymiaru_etatu( @pytest.mark.django_db -def test_autor_typu_z_ma_udzialy_zero(uczelnia, dyscyplina1, rodzaj_autora_z): +def test_autor_typu_z_ma_udzialy_zero( + uczelnia, jednostka, dyscyplina1, rodzaj_autora_z +): """ Test że autor typu Z (licz_sloty=False) ma udziały = 0.0 ale jest widoczny w tabelach. """ - autor = baker.make(Autor) + jednostka.skupia_pracownikow = True + jednostka.save() + autor = baker.make(Autor, aktualna_jednostka=jednostka) # Autor typu Z w roku 2025 Autor_Dyscyplina.objects.create( @@ -346,14 +364,16 @@ def test_autor_typu_z_ma_udzialy_zero(uczelnia, dyscyplina1, rodzaj_autora_z): @pytest.mark.django_db def test_autor_typu_z_nie_wliczany_do_liczby_n( - uczelnia, dyscyplina1, rodzaj_autora_n, rodzaj_autora_z + uczelnia, jednostka, dyscyplina1, rodzaj_autora_n, rodzaj_autora_z ): """ Test że autorzy typu Z nie są wliczani do liczby N, mimo że są widoczni w listach. """ + jednostka.skupia_pracownikow = True + jednostka.save() # 15 autorów typu N for _i in range(15): - autor = baker.make(Autor) + autor = baker.make(Autor, aktualna_jednostka=jednostka) Autor_Dyscyplina.objects.create( autor=autor, rok=2025, @@ -365,7 +385,7 @@ def test_autor_typu_z_nie_wliczany_do_liczby_n( # 10 autorów typu Z (nie powinni być wliczani do N) for _i in range(10): - autor = baker.make(Autor) + autor = baker.make(Autor, aktualna_jednostka=jednostka) Autor_Dyscyplina.objects.create( autor=autor, rok=2025, @@ -390,12 +410,14 @@ def test_autor_typu_z_nie_wliczany_do_liczby_n( @pytest.mark.django_db def test_autor_zmienia_typ_z_n_na_z( - uczelnia, dyscyplina1, rodzaj_autora_n, rodzaj_autora_z + uczelnia, jednostka, dyscyplina1, rodzaj_autora_n, rodzaj_autora_z ): """ Test że jeśli autor zmienia typ z N na Z, to ma różne udziały w różnych latach. """ - autor = baker.make(Autor) + jednostka.skupia_pracownikow = True + jednostka.save() + autor = baker.make(Autor, aktualna_jednostka=jednostka) # Rok 2024: autor typu N Autor_Dyscyplina.objects.create( diff --git a/src/ewaluacja_liczba_n/utils.py b/src/ewaluacja_liczba_n/utils.py index 28a80222e..8481437a2 100644 --- a/src/ewaluacja_liczba_n/utils.py +++ b/src/ewaluacja_liczba_n/utils.py @@ -31,10 +31,10 @@ def oblicz_srednia_liczbe_n_dla_dyscyplin(uczelnia, rok_min=2022, rok_max=2025): } ) - # Pobierz wszystkie udziały dla autorów w okresie ewaluacji - udzialy = IloscUdzialowDlaAutoraZaRok.objects.filter(**rok_kw).select_related( - "autor", "dyscyplina_naukowa" - ) + # Pobierz wszystkie udziały dla autorów w okresie ewaluacji dla tej uczelni + udzialy = IloscUdzialowDlaAutoraZaRok.objects.filter( + uczelnia=uczelnia, **rok_kw + ).select_related("autor", "dyscyplina_naukowa") # Dla każdego udziału znajdź odpowiedni rodzaj autora from bpp.models.dyscyplina_naukowa import Autor_Dyscyplina @@ -88,12 +88,13 @@ def oblicz_srednia_liczbe_n_dla_dyscyplin(uczelnia, rok_min=2022, rok_max=2025): ) -def oblicz_dyscypliny_nieraportowane(rok=2025): +def oblicz_dyscypliny_nieraportowane(uczelnia, rok=2025): """ Oblicza zbiór ID dyscyplin nieraportowanych na podstawie sumy udziałów. Dyscyplina jest nieraportowana gdy suma udziałów < 12. Args: + uczelnia: Uczelnia dla której wykonujemy obliczenia rok: Rok dla którego sprawdzamy (domyślnie 2025) Returns: @@ -102,7 +103,7 @@ def oblicz_dyscypliny_nieraportowane(rok=2025): from django.db.models import Sum sumy = ( - IloscUdzialowDlaAutoraZaRok.objects.filter(rok=rok) + IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=uczelnia, rok=rok) .values("dyscyplina_naukowa_id") .annotate(suma=Sum("ilosc_udzialow")) ) @@ -114,7 +115,9 @@ def oblicz_dyscypliny_nieraportowane(rok=2025): } -def dolicz_bonus_za_nieraportowana(nieraportowane_ids, rok_min=2022, rok_max=2025): +def dolicz_bonus_za_nieraportowana( + uczelnia, nieraportowane_ids, rok_min=2022, rok_max=2025 +): """ Dolicza +1 slot dla autorów z dwoma dyscyplinami, gdzie jedna jest nieraportowana. @@ -127,6 +130,7 @@ def dolicz_bonus_za_nieraportowana(nieraportowane_ids, rok_min=2022, rok_max=202 i odpowiedni komentarz. Jeśli suma przekroczy 4.0, zostanie zredukowana do 4.0. Args: + uczelnia: Uczelnia dla której wykonujemy obliczenia nieraportowane_ids: set ID dyscyplin nieraportowanych rok_min, rok_max: zakres lat ewaluacji """ @@ -143,9 +147,9 @@ def dolicz_bonus_za_nieraportowana(nieraportowane_ids, rok_min=2022, rok_max=202 } # Dla każdego rekordu IloscUdzialowDlaAutoraZaCalosc sprawdź warunki - for rekord in IloscUdzialowDlaAutoraZaCalosc.objects.select_related( - "autor", "dyscyplina_naukowa", "rodzaj_autora" - ): + for rekord in IloscUdzialowDlaAutoraZaCalosc.objects.filter( + uczelnia=uczelnia + ).select_related("autor", "dyscyplina_naukowa", "rodzaj_autora"): # Pobierz Autor_Dyscyplina dla tego autora (dowolny rok z zakresu) autor_dyscyplina = Autor_Dyscyplina.objects.filter( autor_id=rekord.autor_id, @@ -196,14 +200,16 @@ def dolicz_bonus_za_nieraportowana(nieraportowane_ids, rok_min=2022, rok_max=202 @transaction.atomic -def oblicz_sumy_udzialow_za_calosc(rok_min=2022, rok_max=2025): +def oblicz_sumy_udzialow_za_calosc(uczelnia, rok_min=2022, rok_max=2025): """ - Oblicza sumę udziałów dla każdego autora, dyscypliny i rodzaju autora za cały okres ewaluacji. + Oblicza sumę udziałów dla każdego autora, dyscypliny i rodzaju autora + za cały okres ewaluacji, tylko dla danej uczelni. Tworzy osobny wpis dla każdego rodzaju autora (N, D, B, Z). Pomija rekordy gdzie rodzaj autora jest None. Args: + uczelnia: Uczelnia dla której wykonujemy obliczenia rok_min: Pierwszy rok okresu ewaluacji rok_max: Ostatni rok okresu ewaluacji """ @@ -211,8 +217,8 @@ def oblicz_sumy_udzialow_za_calosc(rok_min=2022, rok_max=2025): from bpp.models.dyscyplina_naukowa import Autor_Dyscyplina - # Wyczyść istniejące dane - IloscUdzialowDlaAutoraZaCalosc.objects.all().delete() + # Wyczyść istniejące dane dla tej uczelni + IloscUdzialowDlaAutoraZaCalosc.objects.filter(uczelnia=uczelnia).delete() # Słownik do grupowania: klucz = (autor_id, dyscyplina_id, rodzaj_autora_id) # wartość = {'suma_udzialow': ..., 'suma_monografie': ..., 'lata': set()} @@ -224,9 +230,9 @@ def oblicz_sumy_udzialow_za_calosc(rok_min=2022, rok_max=2025): } ) - # Pobierz wszystkie udziały za okres ewaluacji + # Pobierz wszystkie udziały za okres ewaluacji dla tej uczelni udzialy = IloscUdzialowDlaAutoraZaRok.objects.filter( - rok__gte=rok_min, rok__lte=rok_max + uczelnia=uczelnia, rok__gte=rok_min, rok__lte=rok_max ).select_related("autor", "dyscyplina_naukowa") # Dla każdego udziału znajdź rodzaj autora i zgrupuj @@ -296,6 +302,7 @@ def oblicz_sumy_udzialow_za_calosc(rok_min=2022, rok_max=2025): autor_id=autor_id, dyscyplina_naukowa_id=dyscyplina_id, rodzaj_autora_id=rodzaj_autora_id, + uczelnia=uczelnia, ilosc_udzialow=suma_udzialow_final, ilosc_udzialow_monografie=suma_monografie_final, komentarz=komentarz, @@ -319,10 +326,10 @@ def oblicz_liczbe_n_na_koniec_2025(uczelnia): # Słownik do przechowywania sum udziałów dla każdej dyscypliny w roku 2025 dyscyplina_stats_2025 = defaultdict(lambda: Decimal("0")) - # Pobierz wszystkie udziały dla autorów w roku 2025 - udzialy_2025 = IloscUdzialowDlaAutoraZaRok.objects.filter(rok=2025).select_related( - "autor", "dyscyplina_naukowa" - ) + # Pobierz wszystkie udziały dla autorów w roku 2025 dla tej uczelni + udzialy_2025 = IloscUdzialowDlaAutoraZaRok.objects.filter( + uczelnia=uczelnia, rok=2025 + ).select_related("autor", "dyscyplina_naukowa") # Dla każdego udziału sumuj nieważone udziały for udzial in udzialy_2025: @@ -355,50 +362,64 @@ def oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia, rok_min=2022, rok_max=2025 warunek_lat = dict(rok__gte=rok_min, rok__lte=rok_max) - IloscUdzialowDlaAutoraZaRok.objects.filter(**warunek_lat).delete() + # Usuń tylko rekordy tej uczelni (nie mazać danych innych uczelni) + IloscUdzialowDlaAutoraZaRok.objects.filter( + uczelnia=uczelnia, **warunek_lat + ).delete() wymiary_etatu = [] - for ad in Autor_Dyscyplina.objects.filter(**warunek_lat): + # Iteruj tylko autorów należących do tej uczelni z aktywną jednostką + # skupiającą pracowników; aktualna_jednostka=None → wykluczone przez + # __uczelnia lookup + for ad in Autor_Dyscyplina.objects.filter( + autor__aktualna_jednostka__uczelnia=uczelnia, + autor__aktualna_jednostka__skupia_pracownikow=True, + **warunek_lat, + ): if ad.wymiar_etatu is not None: wymiary_etatu.append(ad.wymiar_etatu) # Sprawdź czy autorowi należy liczyć sloty if ad.rodzaj_autora and not ad.rodzaj_autora.licz_sloty: # Autor typu Z (licz_sloty=False) - utwórz wpis z udziałami = 0.0 - # Dzięki temu autor będzie widoczny w tabelach, ale nie będzie wliczany do liczby N + # Dzięki temu autor będzie widoczny w tabelach, ale nie będzie + # wliczany do liczby N for dyscyplina, _ in ad.policz_udzialy(): IloscUdzialowDlaAutoraZaRok.objects.create( rok=ad.rok, autor=ad.autor, dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, ilosc_udzialow=Decimal("0.0"), ilosc_udzialow_monografie=Decimal("0.0"), autor_dyscyplina=ad, ) else: - # Normalny autor (licz_sloty=True lub rodzaj_autora=None) - oblicz rzeczywiste udziały + # Normalny autor (licz_sloty=True lub rodzaj_autora=None) — + # oblicz rzeczywiste udziały for dyscyplina, ilosc_udzialow in ad.policz_udzialy(): IloscUdzialowDlaAutoraZaRok.objects.create( rok=ad.rok, autor=ad.autor, dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, ilosc_udzialow=ilosc_udzialow, ilosc_udzialow_monografie=ilosc_udzialow / Decimal("2.0"), autor_dyscyplina=ad, ) # Krok 1: Oblicz sumy udziałów za całość (BEZ bonusu +1) - oblicz_sumy_udzialow_za_calosc(rok_min, rok_max) + oblicz_sumy_udzialow_za_calosc(uczelnia, rok_min, rok_max) # Krok 2: Policz średnią dla dyscyplin (BEZ bonusu!) oblicz_srednia_liczbe_n_dla_dyscyplin(uczelnia, rok_min, rok_max) # Krok 3: Określ dyscypliny nieraportowane (suma 2025 < 12) - nieraportowane_ids = oblicz_dyscypliny_nieraportowane(rok_max) + nieraportowane_ids = oblicz_dyscypliny_nieraportowane(uczelnia, rok_max) # Krok 4: Doliczyć +1 slot gdzie potrzeba (NA KOŃCU - nie wpływa na średnią!) - dolicz_bonus_za_nieraportowana(nieraportowane_ids, rok_min, rok_max) + dolicz_bonus_za_nieraportowana(uczelnia, nieraportowane_ids, rok_min, rok_max) # # Jeżeli suma udziałów za 4 lata jest mniejsza jak 1 i jest włączona odpowiednia flaga # # to zwiększ do 1 slota: From af1b964434bc18fa318e54ee54a80b96aafde46b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:09:46 +0200 Subject: [PATCH 108/247] =?UTF-8?q?feat(multi-hosted):=20widoki=20liczba?= =?UTF-8?q?=5Fn=20filtruj=C4=85=20udzia=C5=82y=20po=20uczelni=20+=20nierap?= =?UTF-8?q?ortowane=20z=20uczelni=C4=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_per_uczelnia.py | 53 +++++++++++++++++++ src/ewaluacja_liczba_n/views/export.py | 7 ++- src/ewaluacja_liczba_n/views/list.py | 49 ++++++++++------- src/ewaluacja_liczba_n/views/verify.py | 5 +- 4 files changed, 91 insertions(+), 23 deletions(-) diff --git a/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py b/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py index 169c7170d..c7c9d840c 100644 --- a/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py +++ b/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py @@ -120,3 +120,56 @@ def test_pipeline_pomija_nieprzypisanych(db): assert not IloscUdzialowDlaAutoraZaRok.objects.filter(autor=a_null).exists() assert not IloscUdzialowDlaAutoraZaRok.objects.filter(autor=a_obca).exists() + + +@pytest.mark.django_db +def test_autorzy_list_view_filtruje_po_uczelni(rf, db): + """AutorzyLiczbaNListView.get_queryset zwraca tylko wiersze uczelni z requestu.""" + from decimal import Decimal + + from model_bakery import baker + + from bpp.models import Autor, Jednostka, Uczelnia + from bpp.models.dyscyplina_naukowa import Dyscyplina_Naukowa + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaRok + from ewaluacja_liczba_n.views.list import AutorzyLiczbaNListView + + u1 = baker.make(Uczelnia, skrot="V1", nazwa="Uczelnia V1") + u2 = baker.make(Uczelnia, skrot="V2", nazwa="Uczelnia V2") + dyscyplina = baker.make(Dyscyplina_Naukowa) + j1 = baker.make(Jednostka, uczelnia=u1, skupia_pracownikow=True) + j2 = baker.make(Jednostka, uczelnia=u2, skupia_pracownikow=True) + a1 = baker.make(Autor, aktualna_jednostka=j1) + a2 = baker.make(Autor, aktualna_jednostka=j2) + + IloscUdzialowDlaAutoraZaRok.objects.create( + autor=a1, + dyscyplina_naukowa=dyscyplina, + rok=2022, + ilosc_udzialow=Decimal("1.0"), + ilosc_udzialow_monografie=Decimal("0.5"), + uczelnia=u1, + ) + IloscUdzialowDlaAutoraZaRok.objects.create( + autor=a2, + dyscyplina_naukowa=dyscyplina, + rok=2022, + ilosc_udzialow=Decimal("2.0"), + ilosc_udzialow_monografie=Decimal("1.0"), + uczelnia=u2, + ) + + user = baker.make("bpp.BppUser") + request = rf.get("/") + request.user = user + request._uczelnia = u1 + + view = AutorzyLiczbaNListView() + view.request = request + view.kwargs = {} + + qs = view.get_queryset() + + autor_ids = list(qs.values_list("autor_id", flat=True)) + assert a1.pk in autor_ids, "U1 row missing from queryset" + assert a2.pk not in autor_ids, "U2 row must be excluded" diff --git a/src/ewaluacja_liczba_n/views/export.py b/src/ewaluacja_liczba_n/views/export.py index 59fdb56b4..41c9665f3 100644 --- a/src/ewaluacja_liczba_n/views/export.py +++ b/src/ewaluacja_liczba_n/views/export.py @@ -7,6 +7,7 @@ from openpyxl.worksheet.table import Table, TableStyleInfo from bpp.const import GR_WPROWADZANIE_DANYCH +from bpp.models import Uczelnia from ..excel_export import LiczbaNExcelExporter from ..models import IloscUdzialowDlaAutoraZaCalosc, IloscUdzialowDlaAutoraZaRok @@ -20,8 +21,9 @@ def get_filename(self) -> str: def _get_filtered_udzialy_queryset(self, request): """Get filtered queryset based on request parameters.""" + uczelnia = Uczelnia.objects.get_for_request(request) udzialy = IloscUdzialowDlaAutoraZaRok.objects.filter( - rok__gte=2022, rok__lte=2025 + uczelnia=uczelnia, rok__gte=2022, rok__lte=2025 ) # Apply filters from URL @@ -204,7 +206,8 @@ def get_filename(self) -> str: def _get_filtered_udzialy_calosc_queryset(self, request): """Get filtered udzialy za calosc queryset.""" - udzialy = IloscUdzialowDlaAutoraZaCalosc.objects.all() + uczelnia = Uczelnia.objects.get_for_request(request) + udzialy = IloscUdzialowDlaAutoraZaCalosc.objects.filter(uczelnia=uczelnia) search = request.GET.get("search") if search: diff --git a/src/ewaluacja_liczba_n/views/list.py b/src/ewaluacja_liczba_n/views/list.py index 025f8bf29..f0dd650ba 100644 --- a/src/ewaluacja_liczba_n/views/list.py +++ b/src/ewaluacja_liczba_n/views/list.py @@ -5,7 +5,7 @@ from django.views.generic import ListView from bpp.const import GR_WPROWADZANIE_DANYCH -from bpp.models import Autor_Dyscyplina, Dyscyplina_Naukowa +from bpp.models import Autor_Dyscyplina, Dyscyplina_Naukowa, Uczelnia from ewaluacja_common.models import Rodzaj_Autora from ..models import IloscUdzialowDlaAutoraZaCalosc, IloscUdzialowDlaAutoraZaRok @@ -110,7 +110,8 @@ def _apply_filters(self, queryset): # Filtrowanie po statusie raportowana/nieraportowana raportowana = self.request.GET.get("raportowana") if raportowana: - nieraportowane_ids = oblicz_dyscypliny_nieraportowane() + uczelnia = Uczelnia.objects.get_for_request(self.request) + nieraportowane_ids = oblicz_dyscypliny_nieraportowane(uczelnia) if raportowana == "tak": queryset = queryset.exclude( dyscyplina_naukowa_id__in=nieraportowane_ids @@ -180,9 +181,10 @@ def _apply_sorting(self, queryset, sort): return queryset.order_by(*sort_fields) def get_queryset(self): - # Pobierz wszystkie udziały dla autorów + uczelnia = Uczelnia.objects.get_for_request(self.request) + # Pobierz wszystkie udziały dla autorów tej uczelni queryset = IloscUdzialowDlaAutoraZaRok.objects.filter( - rok__gte=2022, rok__lte=2025 + uczelnia=uczelnia, rok__gte=2022, rok__lte=2025 ).select_related( "autor", "dyscyplina_naukowa", @@ -205,10 +207,13 @@ def get_queryset(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + uczelnia = Uczelnia.objects.get_for_request(self.request) - # Pobierz ID dyscyplin które mają faktyczne dane + # Pobierz ID dyscyplin które mają faktyczne dane dla tej uczelni dyscypliny_z_danymi = ( - IloscUdzialowDlaAutoraZaRok.objects.filter(rok__gte=2022, rok__lte=2025) + IloscUdzialowDlaAutoraZaRok.objects.filter( + uczelnia=uczelnia, rok__gte=2022, rok__lte=2025 + ) .values_list("dyscyplina_naukowa_id", flat=True) .distinct() ) @@ -218,9 +223,11 @@ def get_context_data(self, **kwargs): pk__in=dyscypliny_z_danymi, widoczna=True ).order_by("nazwa") - # Pobierz tylko lata które faktycznie są w bazie + # Pobierz tylko lata które faktycznie są w bazie dla tej uczelni lata_z_danymi = ( - IloscUdzialowDlaAutoraZaRok.objects.filter(rok__gte=2022, rok__lte=2025) + IloscUdzialowDlaAutoraZaRok.objects.filter( + uczelnia=uczelnia, rok__gte=2022, rok__lte=2025 + ) .values_list("rok", flat=True) .distinct() .order_by("rok") @@ -240,7 +247,7 @@ def get_context_data(self, **kwargs): context["current_sort"] = self.request.GET.get("sort", "autor") # Oblicz dyscypliny nieraportowane do wyświetlania w template - context["nieraportowane_ids"] = oblicz_dyscypliny_nieraportowane() + context["nieraportowane_ids"] = oblicz_dyscypliny_nieraportowane(uczelnia) # Oblicz sumy dla przefiltrowanych danych (przed paginacją) queryset_for_sum = self.get_queryset() @@ -327,10 +334,11 @@ class UdzialyZaCaloscListView(GroupRequiredMixin, ListView): group_required = GR_WPROWADZANIE_DANYCH def get_queryset(self): - # Pobierz wszystkie udziały za cały okres - queryset = IloscUdzialowDlaAutoraZaCalosc.objects.all().select_related( - "autor", "dyscyplina_naukowa", "rodzaj_autora" - ) + uczelnia = Uczelnia.objects.get_for_request(self.request) + # Pobierz wszystkie udziały za cały okres dla tej uczelni + queryset = IloscUdzialowDlaAutoraZaCalosc.objects.filter( + uczelnia=uczelnia + ).select_related("autor", "dyscyplina_naukowa", "rodzaj_autora") # Filtrowanie po nazwisku/imieniu autora search = self.request.GET.get("search") @@ -354,7 +362,7 @@ def get_queryset(self): # Filtrowanie po statusie raportowana/nieraportowana raportowana = self.request.GET.get("raportowana") if raportowana: - nieraportowane_ids = oblicz_dyscypliny_nieraportowane() + nieraportowane_ids = oblicz_dyscypliny_nieraportowane(uczelnia) if raportowana == "tak": # Tylko dyscypliny raportowane (nie są w zbiorze nieraportowanych) queryset = queryset.exclude( @@ -387,11 +395,14 @@ def get_queryset(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + uczelnia = Uczelnia.objects.get_for_request(self.request) - # Pobierz ID dyscyplin które mają faktyczne dane - dyscypliny_z_danymi = IloscUdzialowDlaAutoraZaCalosc.objects.values_list( - "dyscyplina_naukowa_id", flat=True - ).distinct() + # Pobierz ID dyscyplin które mają faktyczne dane dla tej uczelni + dyscypliny_z_danymi = ( + IloscUdzialowDlaAutoraZaCalosc.objects.filter(uczelnia=uczelnia) + .values_list("dyscyplina_naukowa_id", flat=True) + .distinct() + ) # Filtruj tylko te dyscypliny które mają dane context["dyscypliny"] = Dyscyplina_Naukowa.objects.filter( @@ -409,7 +420,7 @@ def get_context_data(self, **kwargs): context["current_sort"] = self.request.GET.get("sort", "autor") # Oblicz dyscypliny nieraportowane do wyświetlania w template - context["nieraportowane_ids"] = oblicz_dyscypliny_nieraportowane() + context["nieraportowane_ids"] = oblicz_dyscypliny_nieraportowane(uczelnia) # Oblicz sumy dla przefiltrowanych danych (przed paginacją) queryset_for_sum = self.get_queryset() diff --git a/src/ewaluacja_liczba_n/views/verify.py b/src/ewaluacja_liczba_n/views/verify.py index bb8d745b7..59f121a8c 100644 --- a/src/ewaluacja_liczba_n/views/verify.py +++ b/src/ewaluacja_liczba_n/views/verify.py @@ -9,7 +9,7 @@ from django.views.generic import TemplateView from bpp.const import GR_WPROWADZANIE_DANYCH -from bpp.models import Autor_Dyscyplina +from bpp.models import Autor_Dyscyplina, Uczelnia from ewaluacja_common.models import Rodzaj_Autora from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaRok @@ -223,8 +223,9 @@ def get_context_data(self, **kwargs): # 5. Autorzy z obiema dyscyplinami nie-raportowanymi # Oblicz nie-raportowane dyscypliny na podstawie sumy udziałów w 2025 (suma < 12) + uczelnia = Uczelnia.objects.get_for_request(self.request) sumy_2025 = ( - IloscUdzialowDlaAutoraZaRok.objects.filter(rok=2025) + IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=uczelnia, rok=2025) .values("dyscyplina_naukowa_id") .annotate(suma=Sum("ilosc_udzialow")) ) From f85ea249570401e432e75b19b7aa55029816c835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:15:55 +0200 Subject: [PATCH 109/247] =?UTF-8?q?docs(multi-hosted):=20HANDOFF=20-=20R2?= =?UTF-8?q?=20liczba=5Fn=20ZROBIONE,=20integrator=20nast=C4=99pny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/superpowers/HANDOFF-multi-hosted.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index aab895c60..8b493c300 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -183,11 +183,20 @@ Zatwierdzony wariant: **A (Verify → Stabilize → Investigate → Spec)**. `RaportSlotow`(autor)/`oswiadczenia` filtrują po uczelni; API owner-scoped; ewaluacja_metryki/common adnotowane (już-zawężone/federacja). Hardening #2/#3 wpięte. **Niepushowane.** - - ⏭ **R2 — ewaluacja_liczba_n per-uczelnia (G):** NASTĘPNY. write+read, schemat - `IloscUdzialow*` (+ FK uczelnia, migracja/backfill, unique_together) + - zawężenie liczenia udziałów per uczelnia. - - **F — federacja optymalizacji (B):** ODŁOŻONA (nie teraz, decyzja usera). -5. ⏭ Po R2: **integrator (D)**; potem drobne (E); NOT NULL na uczelnia (#5). + - ✅ **R2 — ewaluacja_liczba_n per-uczelnia (G): ZROBIONE 2026-06-03.** Spec + `specs/2026-06-03-ewaluacja-liczba-n-per-uczelnia-design.md`, plan + `plans/2026-06-03-ewaluacja-liczba-n-per-uczelnia-R2.md`. 4 taski + (subagent-driven, review spec+jakość), final review „ready to merge", + 67 testów (liczba_n+metryki) zielonych. FK `uczelnia` na `IloscUdzialow*` + (mig 0009 +backfill single→domyślna/multi→fail), cały pipeline `utils.py` + zawężony per uczelnia (autor→uczelnia via `aktualna_jednostka.uczelnia`; + NULL/obca wykluczeni; naprawiony globalny bug `objects.all().delete()`), + widoki list/export/verify filtrują `get_for_request`, wszystkie + `oblicz_dyscypliny_nieraportowane(uczelnia)` poprawione. **Niepushowane.** + Minor follow-up (nieblokujące): `views/verify.py` liczy `Autor_Dyscyplina` + globalnie (diagnostyka, pre-existing); admin nie pokazuje `uczelnia`. + - **F — federacja optymalizacji (B):** ODŁOŻONA (decyzja usera, olana). +5. ⏭ NASTĘPNY: **integrator (D)**; potem drobne (E); NOT NULL na uczelnia (#5). Backlog hardeningu (C): #2/#3 → R1; #1 (HST globalnie) → F (federacja). From 3411b1567896e0ae44d829d306cbc905779e043b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:26:00 +0200 Subject: [PATCH 110/247] docs(multi-hosted): spec integrator per-uczelnia (thread client.uczelnia) Co-Authored-By: Claude Opus 4.8 --- ...26-06-03-integrator-per-uczelnia-design.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-integrator-per-uczelnia-design.md diff --git a/docs/superpowers/specs/2026-06-03-integrator-per-uczelnia-design.md b/docs/superpowers/specs/2026-06-03-integrator-per-uczelnia-design.md new file mode 100644 index 000000000..bb6f26e5d --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-integrator-per-uczelnia-design.md @@ -0,0 +1,80 @@ +# Design — Integrator PBN per-uczelnia (mały wątek D) + +Data: 2026-06-03 +Gałąź: `feature/multi-hosted-config` +Kontekst: wątek D z `HANDOFF-multi-hosted.md`. Po R1 (slot read-side) i R2 (liczba_n). + +## Cel i zakres + +Import publikacji z PBN (`pbn_integrator`) porównuje afiliacje autorów z „naszą" +uczelnią przez `Uczelnia.objects.default` — w multi-install „nasza" uczelnia jest +niejednoznaczna. Import biegnie per-uczelnia (każda uczelnia ma własny token PBN +i własnego `BppPBNClient`, który ZNA swoją `uczelnia` — Wątek 1). R-integrator +przepina te porównania na **uczelnię klienta** (`client.uczelnia`). + +**W zakresie:** 5 użyć `Uczelnia.objects.default` w +`pbn_integrator/importer/authors.py` (funkcja `_przetworz_afiliacje`) → parametr +`uczelnia` przekazany z `utworz_autorow` (ma `client`). Aktualizacja guard-whitelisty. + +**Poza zakresem:** reszta `pbn_integrator` nie używa `objects.default` (guard +whitelist potwierdza: jedyny wpis to `authors.py: 5`). Drobne (§E) — osobno. + +## Stan obecny (zmapowany) + +- `BppPBNClient.__init__(self, transport, uczelnia)` → `self.uczelnia` (Wątek 1, + `pbn_api/client/__init__.py:93-95`). +- Łańcuch: `importuj_publikacje_po_pbn_uid_id(pbn_uid_id, client, default_jednostka, …)` + → `utworz_{ksiazke,rozdzial,artykul}` → `utworz_autorow(ret, pbn_json, client, + default_jednostka, …)` → `_przetworz_afiliacje(...)`. **Każdy poziom ma `client`.** +- `_przetworz_afiliacje(ta_afiliacja, default_jednostka, typ_autor, typ_redaktor, + default_typ_odpowiedzialnosci=None)` (authors.py:~70-141) używa + `Uczelnia.objects.default` 5×: `.obca_jednostka` (l.93), `.pbn_uid_id` + (l.106, 115, 121, 135). +- Guard: `src/bpp/tests/test_multihosted_get_default_guard.py` — + `"pbn_integrator/importer/authors.py": 5`. + +## Zmiana + +1. `_przetworz_afiliacje(...)` zyskuje parametr `uczelnia` (dodany na końcu sygnatury, + wymagany). Wewnątrz: `Uczelnia.objects.default` → `uczelnia`: + - `jednostka = uczelnia.obca_jednostka`, + - 3× porównanie `... == uczelnia.pbn_uid_id`, + - `uczelnia.pbn_uid_id if jest_nasz else "123"`. + Import `from bpp.models import Uczelnia` może zostać (jeśli nieużywany po zmianie + — usuń, by ruff nie zgłaszał; sprawdź inne użycia w pliku). +2. `utworz_autorow(...)` woła `_przetworz_afiliacje(..., uczelnia=client.uczelnia)`. +3. Guard whitelist: usuń wpis `"pbn_integrator/importer/authors.py": 5` (po zmianie + plik nie używa `objects.default`). + +## Invariant single-install + +`client.uczelnia` w jednouczelnianej instalacji = ta jedna uczelnia = dotychczasowy +`Uczelnia.objects.default` → import zachowuje się identycznie. + +## Ryzyka / uwagi + +- `client.uczelnia` MUSI być ustawione na wejściu integratora (jest — `get_client` + buduje `BppPBNClient` z uczelnią; Wątek 1). Jeśli jakaś ścieżka testowa konstruuje + klienta bez uczelni, test to wykaże. +- `default_jednostka` jest przekazywana niezależnie (z entry-pointu, per-uczelnia) — + spójna z `client.uczelnia`. Nie ruszamy jej. + +## Testy + +- Test jednostkowy `_przetworz_afiliacje`: dla afiliacji z `institutionId == + uczelnia.pbn_uid_id` → `afiliuje=True`, `jednostka=default_jednostka`; dla obcej → + `obca_jednostka`, `afiliuje=False`. Z DWIEMA uczelniami (różne `pbn_uid_id`): + ta sama afiliacja jest „nasza" dla uczelni A, „obca" dla B. +- Guard test (`test_multihosted_get_default_guard.py`) zielony po usunięciu wpisu + (plik nie ma już `objects.default`). +- Regresja: `uv run pytest src/pbn_integrator/ -q -p no:cacheprovider` (single-install + identycznie). + +## Komendy weryfikacji + +- `uv run pytest src/pbn_integrator/ src/bpp/tests/test_multihosted_get_default_guard.py -q -p no:cacheprovider` +- Lint: `uv run ruff check src/pbn_integrator/importer/authors.py`. + +## Po integratorze + +Drobne (§E): usunięcie `get_default` z `adapters/wydawnictwo.py`. Federacja — olana. From aab1896458ad3eba4f6b2db5616860e6ff00f5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:30:32 +0200 Subject: [PATCH 111/247] feat(multi-hosted): integrator importuje per-uczelnia (client.uczelnia, bez objects.default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _przetworz_afiliacje przyjmuje teraz wymagany argument uczelnia=; wszystkie 5 wywołań Uczelnia.objects.default zastąpione przez uczelnia.obca_jednostka / uczelnia.pbn_uid_id. utworz_autorow przekazuje client.uczelnia. Import Uczelnia usunięty z authors.py. Wpis whitelisty guard multi-hosted usunięty. Co-Authored-By: Claude Sonnet 4.6 --- .../test_multihosted_get_default_guard.py | 1 - src/pbn_integrator/importer/authors.py | 22 ++--- .../tests/test_importer_authors.py | 6 ++ .../tests/test_per_uczelnia_authors.py | 99 +++++++++++++++++++ 4 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 src/pbn_integrator/tests/test_per_uczelnia_authors.py diff --git a/src/bpp/tests/test_multihosted_get_default_guard.py b/src/bpp/tests/test_multihosted_get_default_guard.py index a0b0b4106..1e3c4bc81 100644 --- a/src/bpp/tests/test_multihosted_get_default_guard.py +++ b/src/bpp/tests/test_multihosted_get_default_guard.py @@ -41,7 +41,6 @@ "pbn_api/management/commands/util.py": 1, # GUARDED count==1 (wzorzec CLI) "pbn_import/templatetags/pbn_import_tags.py": 1, # request-first, fallback bez requestu "pbn_import/utils/command_helpers.py": 1, # CLI None-tolerant + CommandError - "pbn_integrator/importer/authors.py": 5, # TODO integrator per-uczelnia (parked) "pbn_integrator/utils/scientists.py": 1, # TODO integrator per-uczelnia (parked) "pbn_integrator/management/commands/pbn_integrator.py": 1, # TODO integrator per-uczelnia "powiazania_autorow/queries.py": 1, # dev: explorer, root PBN raz (anty-N+1), display; deferred multi-host diff --git a/src/pbn_integrator/importer/authors.py b/src/pbn_integrator/importer/authors.py index 551f8ba6d..4dce14f40 100644 --- a/src/pbn_integrator/importer/authors.py +++ b/src/pbn_integrator/importer/authors.py @@ -2,7 +2,7 @@ import logging -from bpp.models import Autor, Typ_Odpowiedzialnosci, Uczelnia +from bpp.models import Autor, Typ_Odpowiedzialnosci from pbn_api.models import Scientist from pbn_integrator.utils import ( pobierz_i_zapisz_dane_jednej_osoby, @@ -72,6 +72,7 @@ def _przetworz_afiliacje( typ_odpowiedzialnosci_autor, typ_odpowiedzialnosci_redaktor, default_typ_odpowiedzialnosci=None, + uczelnia=None, ): """Process author affiliation data. @@ -82,15 +83,13 @@ def _przetworz_afiliacje( typ_odpowiedzialnosci_redaktor: Typ_Odpowiedzialnosci for "redaktor". default_typ_odpowiedzialnosci: Default responsibility type to use when affiliation data is missing. If None, defaults to autor. + uczelnia: Target Uczelnia instance for affiliation matching. + Determines which institutionId is considered "ours". Returns: Tuple of (jednostka, afiliuje, typ_odpowiedzialnosci). """ - # TODO(multi-hosted): porównania z „naszą" uczelnią (objects.default tutaj - # i niżej) docelowo per-uczelnia — wymaga przekazania uczelni docelowej - # przez pipeline integratora (deeper, jak per-uczelnia sloty). objects.default - # zostaje świadomie (NIE .get(): wołane wielokrotnie, bez kontekstu uczelni). - jednostka = Uczelnia.objects.default.obca_jednostka + jednostka = uczelnia.obca_jednostka afiliuje = False # Use provided default or fallback to autor typ_odpowiedzialnosci = default_typ_odpowiedzialnosci or typ_odpowiedzialnosci_autor @@ -103,7 +102,7 @@ def _przetworz_afiliacje( else: jest_nasz = False typ_autora = ta_afiliacja[0]["type"] - if ta_afiliacja[0]["institutionId"] == Uczelnia.objects.default.pbn_uid_id: + if ta_afiliacja[0]["institutionId"] == uczelnia.pbn_uid_id: jest_nasz = True for elem in ta_afiliacja[1:]: if elem["type"] != typ_autora: @@ -112,14 +111,12 @@ def _przetworz_afiliacje( f"{ta_afiliacja=}" ) continue - if elem["institutionId"] == Uczelnia.objects.default.pbn_uid_id: + if elem["institutionId"] == uczelnia.pbn_uid_id: jest_nasz = True ta_afiliacja = { "type": typ_autora, - "institutionId": ( - Uczelnia.objects.default.pbn_uid_id if jest_nasz else "123" - ), + "institutionId": (uczelnia.pbn_uid_id if jest_nasz else "123"), } pbn_typ_odpowiedzialnosci = ta_afiliacja.pop("type") @@ -132,7 +129,7 @@ def _przetworz_afiliacje( pbn_institution_id = ta_afiliacja.pop("institutionId") - if pbn_institution_id == Uczelnia.objects.default.pbn_uid_id: + if pbn_institution_id == uczelnia.pbn_uid_id: jednostka = default_jednostka afiliuje = True @@ -174,6 +171,7 @@ def utworz_autorow( typ_odpowiedzialnosci_autor, typ_odpowiedzialnosci_redaktor, default_typ_odpowiedzialnosci=typ_odpowiedzialnosci, + uczelnia=client.uczelnia, ) try: diff --git a/src/pbn_integrator/tests/test_importer_authors.py b/src/pbn_integrator/tests/test_importer_authors.py index 8ff763dd8..e515ca2ff 100644 --- a/src/pbn_integrator/tests/test_importer_authors.py +++ b/src/pbn_integrator/tests/test_importer_authors.py @@ -71,6 +71,7 @@ def test_returns_default_typ_when_no_affiliation_and_no_default( default_jednostka=None, typ_odpowiedzialnosci_autor=typ_autor, typ_odpowiedzialnosci_redaktor=typ_redaktor, + uczelnia=uczelnia_with_obca_jednostka, ) assert typ == typ_autor @@ -88,6 +89,7 @@ def test_returns_default_typ_when_no_affiliation_with_autor_default( typ_odpowiedzialnosci_autor=typ_autor, typ_odpowiedzialnosci_redaktor=typ_redaktor, default_typ_odpowiedzialnosci=typ_autor, + uczelnia=uczelnia_with_obca_jednostka, ) assert typ == typ_autor @@ -109,6 +111,7 @@ def test_returns_default_typ_when_no_affiliation_with_redaktor_default( typ_odpowiedzialnosci_autor=typ_autor, typ_odpowiedzialnosci_redaktor=typ_redaktor, default_typ_odpowiedzialnosci=typ_redaktor, + uczelnia=uczelnia_with_obca_jednostka, ) assert typ == typ_redaktor @@ -129,6 +132,7 @@ def test_affiliation_type_overrides_default_for_author( typ_odpowiedzialnosci_autor=typ_autor, typ_odpowiedzialnosci_redaktor=typ_redaktor, default_typ_odpowiedzialnosci=typ_redaktor, # default is redaktor + uczelnia=uczelnia_with_obca_jednostka, ) # But affiliation says AUTHOR, so return autor @@ -149,6 +153,7 @@ def test_affiliation_type_overrides_default_for_editor( typ_odpowiedzialnosci_autor=typ_autor, typ_odpowiedzialnosci_redaktor=typ_redaktor, default_typ_odpowiedzialnosci=typ_autor, # default is autor + uczelnia=uczelnia_with_obca_jednostka, ) # But affiliation says EDITOR, so return redaktor @@ -167,6 +172,7 @@ def test_affiliation_list_with_single_element( default_jednostka=None, typ_odpowiedzialnosci_autor=typ_autor, typ_odpowiedzialnosci_redaktor=typ_redaktor, + uczelnia=uczelnia_with_obca_jednostka, ) assert typ == typ_redaktor diff --git a/src/pbn_integrator/tests/test_per_uczelnia_authors.py b/src/pbn_integrator/tests/test_per_uczelnia_authors.py new file mode 100644 index 000000000..48650cdf6 --- /dev/null +++ b/src/pbn_integrator/tests/test_per_uczelnia_authors.py @@ -0,0 +1,99 @@ +"""Tests for per-uczelnia affiliation comparison in _przetworz_afiliacje. + +Verifies that _przetworz_afiliacje uses the explicitly-passed uczelnia +for affiliation matching instead of Uczelnia.objects.default. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Jednostka, Typ_Odpowiedzialnosci, Uczelnia +from pbn_api.models import Institution + + +@pytest.fixture +def typ_autor(db): + return Typ_Odpowiedzialnosci.objects.get_or_create( + nazwa="autor", defaults={"skrot": "aut."} + )[0] + + +@pytest.fixture +def typ_redaktor(db): + return Typ_Odpowiedzialnosci.objects.get_or_create( + nazwa="redaktor", defaults={"skrot": "red."} + )[0] + + +def _make_uczelnia_with_pbn(db_marker): + """Create a Uczelnia with a distinct Institution (pbn_uid) and obca_jednostka.""" + institution = baker.make(Institution) + uczelnia = baker.make(Uczelnia, pbn_uid=institution) + obca = baker.make( + Jednostka, + uczelnia=uczelnia, + skupia_pracownikow=False, + ) + uczelnia.obca_jednostka = obca + uczelnia.save() + return uczelnia + + +@pytest.fixture +def uczelnia1(db): + return _make_uczelnia_with_pbn(db) + + +@pytest.fixture +def uczelnia2(db): + return _make_uczelnia_with_pbn(db) + + +@pytest.fixture +def default_jednostka(uczelnia1): + return baker.make(Jednostka, uczelnia=uczelnia1) + + +@pytest.mark.django_db +def test_afiliacja_matches_own_uczelnia( + uczelnia1, uczelnia2, default_jednostka, typ_autor, typ_redaktor +): + """Affiliation matching uczelnia1.pbn_uid_id → afiliuje=True, default_jednostka.""" + from pbn_integrator.importer.authors import _przetworz_afiliacje + + ta_afiliacja = [{"type": "AUTHOR", "institutionId": uczelnia1.pbn_uid_id}] + + jednostka, afiliuje, typ = _przetworz_afiliacje( + ta_afiliacja=ta_afiliacja, + default_jednostka=default_jednostka, + typ_odpowiedzialnosci_autor=typ_autor, + typ_odpowiedzialnosci_redaktor=typ_redaktor, + uczelnia=uczelnia1, + ) + + assert afiliuje is True + assert jednostka == default_jednostka + assert typ == typ_autor + + +@pytest.mark.django_db +def test_afiliacja_foreign_when_different_uczelnia( + uczelnia1, uczelnia2, default_jednostka, typ_autor, typ_redaktor +): + """Same affiliation (uczelnia1 institutionId) processed with uczelnia2 + → afiliuje=False, returns uczelnia2.obca_jednostka.""" + from pbn_integrator.importer.authors import _przetworz_afiliacje + + ta_afiliacja = [{"type": "AUTHOR", "institutionId": uczelnia1.pbn_uid_id}] + + jednostka, afiliuje, typ = _przetworz_afiliacje( + ta_afiliacja=ta_afiliacja, + default_jednostka=default_jednostka, + typ_odpowiedzialnosci_autor=typ_autor, + typ_odpowiedzialnosci_redaktor=typ_redaktor, + uczelnia=uczelnia2, + ) + + assert afiliuje is False + assert jednostka == uczelnia2.obca_jednostka + assert typ == typ_autor From a982dbfe7037a9a381f85f55ba0fafd22f083e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:34:55 +0200 Subject: [PATCH 112/247] refactor(multi-hosted): _przetworz_afiliacje wymaga uczelnia (keyword-only, code review) Co-Authored-By: Claude Sonnet 4.6 --- src/pbn_integrator/importer/authors.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pbn_integrator/importer/authors.py b/src/pbn_integrator/importer/authors.py index 4dce14f40..02778b794 100644 --- a/src/pbn_integrator/importer/authors.py +++ b/src/pbn_integrator/importer/authors.py @@ -72,7 +72,8 @@ def _przetworz_afiliacje( typ_odpowiedzialnosci_autor, typ_odpowiedzialnosci_redaktor, default_typ_odpowiedzialnosci=None, - uczelnia=None, + *, + uczelnia, ): """Process author affiliation data. From 57f04db3d4c471f71143a2a26e8484e438c790a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:38:10 +0200 Subject: [PATCH 113/247] docs(multi-hosted): korekta spec integratora - 3 sites (authors done + pbn_integrator cmd + scientists matcher) Co-Authored-By: Claude Opus 4.8 --- ...26-06-03-integrator-per-uczelnia-design.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-03-integrator-per-uczelnia-design.md b/docs/superpowers/specs/2026-06-03-integrator-per-uczelnia-design.md index bb6f26e5d..3f01488a3 100644 --- a/docs/superpowers/specs/2026-06-03-integrator-per-uczelnia-design.md +++ b/docs/superpowers/specs/2026-06-03-integrator-per-uczelnia-design.md @@ -16,8 +16,24 @@ przepina te porównania na **uczelnię klienta** (`client.uczelnia`). `pbn_integrator/importer/authors.py` (funkcja `_przetworz_afiliacje`) → parametr `uczelnia` przekazany z `utworz_autorow` (ma `client`). Aktualizacja guard-whitelisty. -**Poza zakresem:** reszta `pbn_integrator` nie używa `objects.default` (guard -whitelist potwierdza: jedyny wpis to `authors.py: 5`). Drobne (§E) — osobno. +**KOREKTA zakresu (2026-06-03):** pierwotny spec błędnie twierdził „jedyny wpis to +authors.py: 5" (truncated grep). Guard whitelist ma w istocie TRZY wpisy integratora +(zgodnie z handoff §D). Pełny zakres: +- `importer/authors.py` (5) — ZROBIONE (`68959b629` + `3ca65a740`). +- `management/commands/pbn_integrator.py:221` (`_handle_people`): `pbn_uid_id = + Uczelnia.objects.default.pbn_uid_id`. `handle()` ma już `client` i `uczelnia`; + `_handle_people(opts, client, s, e)` → użyj `client.uczelnia.pbn_uid_id`. +- `utils/scientists.py:438` (matcher `matchuj_autora_po_stronie_pbn`): porównanie + `pos["institutionId"] == Uczelnia.objects.default.pbn_uid_id`. Jedyny caller: + `integruj_wszystkich_niezintegrowanych_autorow()` (globalny, iteruje wszystkich + autorów bez pbn_uid). **Reguła (jak R2):** matcher dostaje uczelnię AUTORA + (`autor.aktualna_jednostka.uczelnia`); gdy `None` (brak/obca jednostka) → + matcher nie potrafi potwierdzić „pracuje u nas", `can_be_set` zostaje False + (autor i tak bez home-uczelni — nie integrujemy go agresywnie). Sygnatura + `matchuj_autora_po_stronie_pbn(imiona, nazwisko, orcid, uczelnia)`; przy `438` + porównanie tylko gdy `uczelnia is not None` (`uczelnia.pbn_uid_id`). + +**Poza zakresem:** reszta `pbn_integrator` nie używa `objects.default`. Drobne (§E) — osobno. ## Stan obecny (zmapowany) From 777265e8df208c0ccce6b3a879c6aef7c0669763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:40:14 +0200 Subject: [PATCH 114/247] feat(multi-hosted): integrator scientists/command per-uczelnia (bez objects.default) Co-Authored-By: Claude Opus 4.8 --- .../tests/test_multihosted_get_default_guard.py | 2 -- .../management/commands/pbn_integrator.py | 6 +----- src/pbn_integrator/utils/scientists.py | 14 +++++++------- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/bpp/tests/test_multihosted_get_default_guard.py b/src/bpp/tests/test_multihosted_get_default_guard.py index 1e3c4bc81..1cfdb9cee 100644 --- a/src/bpp/tests/test_multihosted_get_default_guard.py +++ b/src/bpp/tests/test_multihosted_get_default_guard.py @@ -41,8 +41,6 @@ "pbn_api/management/commands/util.py": 1, # GUARDED count==1 (wzorzec CLI) "pbn_import/templatetags/pbn_import_tags.py": 1, # request-first, fallback bez requestu "pbn_import/utils/command_helpers.py": 1, # CLI None-tolerant + CommandError - "pbn_integrator/utils/scientists.py": 1, # TODO integrator per-uczelnia (parked) - "pbn_integrator/management/commands/pbn_integrator.py": 1, # TODO integrator per-uczelnia "powiazania_autorow/queries.py": 1, # dev: explorer, root PBN raz (anty-N+1), display; deferred multi-host } diff --git a/src/pbn_integrator/management/commands/pbn_integrator.py b/src/pbn_integrator/management/commands/pbn_integrator.py index c010ff8ca..94de23f15 100644 --- a/src/pbn_integrator/management/commands/pbn_integrator.py +++ b/src/pbn_integrator/management/commands/pbn_integrator.py @@ -214,11 +214,7 @@ def _handle_system_and_sources(self, opts, client, s, e): def _handle_people(self, opts, client, s, e): """Etapy 6-9: pobieranie i integracja ludzi.""" ea = opts["enable_all"] - # TODO(multi-hosted): integrator docelowo per-uczelnia — porównanie - # z „naszą" uczelnią wymaga przekazania uczelni DOCELOWEJ przez - # pipeline integratora (deeper, jak per-uczelnia sloty). Do tego czasu - # objects.default (NIE .get(): w pętlach i bez kontekstu uczelni). - pbn_uid_id = Uczelnia.objects.default.pbn_uid_id + pbn_uid_id = client.uczelnia.pbn_uid_id self._run_stage( opts["enable_download_people_institution"], diff --git a/src/pbn_integrator/utils/scientists.py b/src/pbn_integrator/utils/scientists.py index dc3001c47..0f60d8a2f 100644 --- a/src/pbn_integrator/utils/scientists.py +++ b/src/pbn_integrator/utils/scientists.py @@ -334,7 +334,7 @@ def weryfikuj_orcidy(client: PBNClient, instutition_id): ) -def matchuj_autora_po_stronie_pbn(imiona, nazwisko, orcid): # noqa: C901 +def matchuj_autora_po_stronie_pbn(imiona, nazwisko, orcid, uczelnia): # noqa: C901 """Match an author on the PBN side. Args: @@ -430,12 +430,9 @@ def matchuj_autora_po_stronie_pbn(imiona, nazwisko, orcid): # noqa: C901 cur_elem_points += 1 currentEmployments = elem.value_or_none("object", "currentEmployments") - if currentEmployments is not None: - # TODO(multi-hosted): matcher porównuje z „naszą" uczelnią — - # docelowo per-uczelnia (deeper integrator-threading, jak sloty). - # objects.default zostaje (cached; matcher bez kontekstu uczelni). + if currentEmployments is not None and uczelnia is not None: for pos in currentEmployments: - if pos.get("institutionId") == Uczelnia.objects.default.pbn_uid_id: + if pos.get("institutionId") == uczelnia.pbn_uid_id: can_be_set = True rated_elems.append((cur_elem_points, elem.pk)) @@ -456,7 +453,10 @@ def integruj_wszystkich_niezintegrowanych_autorow(): for autor in Autor.objects.filter(pk__in=autorzy_z_dyscyplina_ids, pbn_uid_id=None): sciencist = matchuj_autora_po_stronie_pbn( - autor.imiona, autor.nazwisko, autor.orcid + autor.imiona, + autor.nazwisko, + autor.orcid, + autor.aktualna_jednostka.uczelnia if autor.aktualna_jednostka_id else None, ) if sciencist: logger.info( From d675a2d54576d317a505660c1fdd62fcdfb9cbcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:44:13 +0200 Subject: [PATCH 115/247] =?UTF-8?q?docs(multi-hosted):=20HANDOFF=20-=20int?= =?UTF-8?q?egrator=20(D)=20ZROBIONE,=20drobne=20(E)=20nast=C4=99pne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/HANDOFF-multi-hosted.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index 8b493c300..07e7603ee 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -145,11 +145,17 @@ Discovery 2026-06-03: **częściowo już per-uczelnia**, ale z luką write. unique_together, zawęzić liczenie udziałów per uczelnia. To write+read, bliżej write-side slotów niż filtrów odczytu. -### D) Integrator per-uczelnia (parked) -`pbn_integrator/utils/scientists.py` (matcher), `importer/authors.py` (×5 porównań -afiliacji), `management/commands/pbn_integrator.py`. Porównania z „naszą" uczelnią -(`objects.default.pbn_uid_id`) — wymaga przekazania uczelni docelowej przez pipeline. -`objects.default` zostaje świadomie (perf w pętlach). +### D) Integrator per-uczelnia — ZROBIONE 2026-06-03 +Spec: `specs/2026-06-03-integrator-per-uczelnia-design.md`. 3 sites (subagent-driven ++ review): `importer/authors.py` (5×, `client.uczelnia`), `management/commands/ +pbn_integrator.py` (`_handle_people` → `client.uczelnia.pbn_uid_id`), +`utils/scientists.py` (matcher `matchuj_autora_po_stronie_pbn(..., uczelnia)`, +caller przekazuje `autor.aktualna_jednostka.uczelnia`/None). `pbn_integrator/` jest +czyste z `objects.default`; guard whitelist bez wpisów integratora. 97 testów zielone. +**Delta (świadoma):** autor z `aktualna_jednostka=None` nie jest już auto-matchowany +po danych zatrudnienia PBN (matcher zwraca None bez home-uczelni) — poprawne wg reguły +R2 (odłączony autor = nie pracownik), mała populacja. Commity `68959b629`, `3ca65a740`, +`49f320aa8`. ### E) Drobne - Usunięcie fallbacku `get_default` z `adapters/wydawnictwo.py` — wymaga migracji @@ -196,7 +202,8 @@ Zatwierdzony wariant: **A (Verify → Stabilize → Investigate → Spec)**. Minor follow-up (nieblokujące): `views/verify.py` liczy `Autor_Dyscyplina` globalnie (diagnostyka, pre-existing); admin nie pokazuje `uczelnia`. - **F — federacja optymalizacji (B):** ODŁOŻONA (decyzja usera, olana). -5. ⏭ NASTĘPNY: **integrator (D)**; potem drobne (E); NOT NULL na uczelnia (#5). +5. ✅ **integrator (D): ZROBIONE 2026-06-03.** ⏭ NASTĘPNY: drobne (E); + potem NOT NULL na uczelnia (#5), hardening #1 (HST, w federacji — olanej). Backlog hardeningu (C): #2/#3 → R1; #1 (HST globalnie) → F (federacja). From d1d5162679a32771be780cb8c9a3b21d2e0e92a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:46:31 +0200 Subject: [PATCH 116/247] refactor(multi-hosted): usun test-only get_default fallback z adaptera wydawnictwo (drobne E) Co-Authored-By: Claude Sonnet 4.6 --- src/bpp/tests/test_multihosted_get_default_guard.py | 1 - src/pbn_api/adapters/wydawnictwo.py | 8 -------- 2 files changed, 9 deletions(-) diff --git a/src/bpp/tests/test_multihosted_get_default_guard.py b/src/bpp/tests/test_multihosted_get_default_guard.py index 1cfdb9cee..37eb5375d 100644 --- a/src/bpp/tests/test_multihosted_get_default_guard.py +++ b/src/bpp/tests/test_multihosted_get_default_guard.py @@ -37,7 +37,6 @@ "bpp/models/jednostka.py": 1, # sortowanie (display), warstwa modelu "bpp/multiseek_registry/fields/numeric_fields.py": 1, # toggle IC, None-tolerant "ewaluacja2021/util.py": 1, # komentarz (nie kod) - "pbn_api/adapters/wydawnictwo.py": 1, # test-only None-tolerant fallback "pbn_api/management/commands/util.py": 1, # GUARDED count==1 (wzorzec CLI) "pbn_import/templatetags/pbn_import_tags.py": 1, # request-first, fallback bez requestu "pbn_import/utils/command_helpers.py": 1, # CLI None-tolerant + CommandError diff --git a/src/pbn_api/adapters/wydawnictwo.py b/src/pbn_api/adapters/wydawnictwo.py index fbc4c85d7..ac5219974 100644 --- a/src/pbn_api/adapters/wydawnictwo.py +++ b/src/pbn_api/adapters/wydawnictwo.py @@ -90,14 +90,6 @@ def __init__( if request is not None and uczelnia is None: uczelnia = Uczelnia.objects.get_for_request(request) - if uczelnia is None: - # Runtime callerzy przekazują JAWNĄ uczelnię (publication_sync, - # pbn_wysylka, komendy) — tu nigdy nie docierają. Ten fallback jest - # test-only: adapter bywa konstruowany w izolacji bez uczelni i ma - # zadziałać None-tolerant (domyślne flagi). Dlatego get_default() - # (None gdy 0), NIE .get() (rzuciłby w testach bez uczelni). - uczelnia = Uczelnia.objects.get_default() - if uczelnia is not None: if uczelnia.pbn_api_nie_wysylaj_prac_bez_pk: self.export_pk_zero = False From 3a3590757b1a6b67b36d26b1ce40b3e5a1d26252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 15:48:14 +0200 Subject: [PATCH 117/247] docs(multi-hosted): HANDOFF - drobne (E) ZROBIONE --- docs/superpowers/HANDOFF-multi-hosted.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index 07e7603ee..95c104718 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -157,9 +157,11 @@ po danych zatrudnienia PBN (matcher zwraca None bez home-uczelni) — poprawne w R2 (odłączony autor = nie pracownik), mała populacja. Commity `68959b629`, `3ca65a740`, `49f320aa8`. -### E) Drobne -- Usunięcie fallbacku `get_default` z `adapters/wydawnictwo.py` — wymaga migracji - testów adaptera + naprawy `pbn_wyslij` (pre-existing `C901`). Niski priorytet. +### E) Drobne — ZROBIONE 2026-06-03 +Usunięto test-only fallback `get_default` z `pbn_api/adapters/wydawnictwo.py` +(commit `be04bc2be`). Trywialne: żaden test nie polegał na fallbacku (233 testy +pbn_api zielone, zero zmian w testach), `pbn_wyslij` nietknięty, guard whitelist +bez wpisu adaptera. Adapter None-tolerant przez istniejący `if uczelnia is not None`. ### F) Operacyjne (deploy write-side) - Single-install: migracja 0425 sama wpisze ID domyślnej uczelni w legacy cache; From 6c888f24553f2df0295a1f509a1d9b37627a3bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 16:06:36 +0200 Subject: [PATCH 118/247] feat(multi-hosted): Cache_Punktacja_Dyscypliny.uczelnia NOT NULL + admin liczba_n pokazuje uczelnie Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/0428_cpd_uczelnia_not_null.py | 19 +++++++++++++++++++ src/bpp/models/cache/punktacja.py | 2 +- .../test_sloty/test_per_uczelnia.py | 16 ++++++++++++++++ src/ewaluacja_liczba_n/admin.py | 9 ++++++--- 4 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 src/bpp/migrations/0428_cpd_uczelnia_not_null.py diff --git a/src/bpp/migrations/0428_cpd_uczelnia_not_null.py b/src/bpp/migrations/0428_cpd_uczelnia_not_null.py new file mode 100644 index 000000000..2eb5da432 --- /dev/null +++ b/src/bpp/migrations/0428_cpd_uczelnia_not_null.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.14 on 2026-06-03 14:03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0427_cpd_index_rekord_uczelnia_dyscyplina'), + ] + + operations = [ + migrations.AlterField( + model_name='cache_punktacja_dyscypliny', + name='uczelnia', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bpp.uczelnia'), + ), + ] diff --git a/src/bpp/models/cache/punktacja.py b/src/bpp/models/cache/punktacja.py index b8b171c30..728499445 100644 --- a/src/bpp/models/cache/punktacja.py +++ b/src/bpp/models/cache/punktacja.py @@ -29,7 +29,7 @@ class Cache_Punktacja_Dyscypliny(models.Model): models.TextField(), blank=True, null=True ) - uczelnia = ForeignKey("bpp.Uczelnia", models.CASCADE, null=True, blank=True) + uczelnia = ForeignKey("bpp.Uczelnia", models.CASCADE) class Meta: ordering = ("dyscyplina__nazwa",) diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index 2f27b2fc3..eca88ef3b 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -220,6 +220,22 @@ def test_view_eksponuje_uczelnia(zwarte_dwie_uczelnie, jednostka, druga_uczelnia } +@pytest.mark.django_db +def test_cpd_uczelnia_not_null(dyscyplina1): + from django.db import IntegrityError, transaction + + from bpp.models.cache import Cache_Punktacja_Dyscypliny + + with pytest.raises(IntegrityError): + with transaction.atomic(): + Cache_Punktacja_Dyscypliny.objects.create( + rekord_id=[1, 1], + dyscyplina=dyscyplina1, + pkd=10, + slot=1, + ) + + @pytest.mark.django_db def test_invariant_jedna_uczelnia_k2( wydawnictwo_zwarte, diff --git a/src/ewaluacja_liczba_n/admin.py b/src/ewaluacja_liczba_n/admin.py index a65eecefb..9917de486 100644 --- a/src/ewaluacja_liczba_n/admin.py +++ b/src/ewaluacja_liczba_n/admin.py @@ -99,18 +99,19 @@ class IloscUdzialowDlaAutoraZaRokAdmin( ): resource_classes = [IloscUdzialowDlaAutoraZaRokResource] list_display = [ + "uczelnia", "autor", "dyscyplina_naukowa", "ilosc_udzialow", "ilosc_udzialow_monografie", ] - list_select_related = ["autor", "autor__tytul", "dyscyplina_naukowa"] + list_select_related = ["uczelnia", "autor", "autor__tytul", "dyscyplina_naukowa"] search_fields = [ "autor__nazwisko", "dyscyplina_naukowa__kod", "dyscyplina_naukowa__nazwa", ] - list_filter = ["dyscyplina_naukowa", "ilosc_udzialow"] + list_filter = ["uczelnia", "dyscyplina_naukowa", "ilosc_udzialow"] ordering = ("autor__nazwisko", "autor__imiona", "dyscyplina_naukowa__nazwa") @@ -120,6 +121,7 @@ class IloscUdzialowDlaAutoraZaCaloscAdmin( ): resource_classes = [IloscUdzialowDlaAutoraZaCaloscResource] list_display = [ + "uczelnia", "autor", "dyscyplina_naukowa", "rodzaj_autora", @@ -127,6 +129,7 @@ class IloscUdzialowDlaAutoraZaCaloscAdmin( "ilosc_udzialow_monografie", ] list_select_related = [ + "uczelnia", "autor", "autor__tytul", "dyscyplina_naukowa", @@ -137,7 +140,7 @@ class IloscUdzialowDlaAutoraZaCaloscAdmin( "dyscyplina_naukowa__kod", "dyscyplina_naukowa__nazwa", ] - list_filter = ["dyscyplina_naukowa", "rodzaj_autora", "ilosc_udzialow"] + list_filter = ["uczelnia", "dyscyplina_naukowa", "rodzaj_autora", "ilosc_udzialow"] ordering = ("autor__nazwisko", "autor__imiona", "dyscyplina_naukowa__nazwa") From c687ceb072a87d3131ccc6ec0261fe8b0a78271d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 16:12:34 +0200 Subject: [PATCH 119/247] fix(multi-hosted): wiele_hst liczone per-uczelnia (_dopasuj_kalkulator scoped, hardening #1) Co-Authored-By: Claude Sonnet 4.6 --- src/bpp/models/abstract/disciplines.py | 19 ++-- src/bpp/models/sloty/core.py | 6 +- .../test_sloty/test_per_uczelnia.py | 94 +++++++++++++++++++ 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/bpp/models/abstract/disciplines.py b/src/bpp/models/abstract/disciplines.py index 9c775554f..2a39728d6 100644 --- a/src/bpp/models/abstract/disciplines.py +++ b/src/bpp/models/abstract/disciplines.py @@ -21,17 +21,22 @@ def przelicz_punkty_dyscyplin(self): def odpiete_dyscypliny(self): return self.autorzy_set.exclude(dyscyplina_naukowa=None).exclude(przypieta=True) - def wszystkie_dyscypliny_rekordu(self): - """Ta funkcja zwraca każdą dyscyplinę przypiętą do pracy w postaci listy.""" + def wszystkie_dyscypliny_rekordu(self, uczelnia=None): + """Każda dyscyplina przypięta do pracy. + + Gdy podano `uczelnia`, zawęża do autorów tej uczelni + (autor → jednostka → uczelnia) — pod per-uczelniane rozstrzyganie + wiele_hst. Bez uczelni: globalnie (jak dawniej). + """ if not self.pk: return [] - return ( - self.autorzy_set.exclude(dyscyplina_naukowa=None) - .filter(przypieta=True) - .values_list("dyscyplina_naukowa") - .distinct() + qs = ( + self.autorzy_set.exclude(dyscyplina_naukowa=None).filter(przypieta=True) ) + if uczelnia is not None: + qs = qs.filter(jednostka__uczelnia=uczelnia) + return qs.values_list("dyscyplina_naukowa").distinct() def uczelnie_rekordu(self): """Distinct uczelnie wśród afiliujących, przypiętych autorów rekordu diff --git a/src/bpp/models/sloty/core.py b/src/bpp/models/sloty/core.py index a34f9f28a..1bb3ca32c 100644 --- a/src/bpp/models/sloty/core.py +++ b/src/bpp/models/sloty/core.py @@ -65,12 +65,12 @@ def ISlot(original, uczelnia=None): # noqa "statusów korekt. " ) - kalkulator = _dopasuj_kalkulator(original) + kalkulator = _dopasuj_kalkulator(original, uczelnia) kalkulator.uczelnia = uczelnia return kalkulator -def _dopasuj_kalkulator(original): # noqa: C901 +def _dopasuj_kalkulator(original, uczelnia=None): # noqa: C901 if isinstance(original, Wydawnictwo_Ciagle): if original.rok in [2017, 2018]: if original.punkty_kbn >= 30: @@ -172,7 +172,7 @@ def _dopasuj_kalkulator(original): # noqa: C901 rodzaje_hst = { Dyscyplina_Naukowa.objects.get(pk=x[0]).dyscyplina_hst - for x in original.wszystkie_dyscypliny_rekordu() + for x in original.wszystkie_dyscypliny_rekordu(uczelnia) } match len(rodzaje_hst): diff --git a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py index eca88ef3b..66a22057d 100644 --- a/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py +++ b/src/bpp/tests/test_models/test_sloty/test_per_uczelnia.py @@ -283,3 +283,97 @@ def test_invariant_jedna_uczelnia_k2( ) assert len(slots) == 2 assert sum(slots) == 1 # k=2 w jednej uczelni: po pół slotu, suma 1.0 + + +# --------------------------------------------------------------------------- +# Hardening #1 — wiele_hst liczone per-uczelnia +# --------------------------------------------------------------------------- + + +@pytest.fixture +def zwarte_cross_hst( + wydawnictwo_zwarte, + autor_jan_nowak, + autor_jan_kowalski, + jednostka, + jednostka_drugiej_uczelni, + dyscyplina1_hst, + dyscyplina2, + rodzaj_autora_n, + charaktery_formalne, + wydawca, + typy_odpowiedzialnosci, + rok, +): + """Praca z autorem HST w uczelni A i autorem nie-HST w uczelni B. + + Globalnie: dyscypliny_rekordu() → {HST, nie-HST} → wiele_hst=True. + Per-uczelnia: każda uczelnia ma jednorodny zestaw → wiele_hst=False. + """ + Autor_Dyscyplina.objects.create( + autor=autor_jan_nowak, + dyscyplina_naukowa=dyscyplina1_hst, + rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina2, + rok=rok, + rodzaj_autora=rodzaj_autora_n, + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_nowak, jednostka, dyscyplina_naukowa=dyscyplina1_hst + ) + wydawnictwo_zwarte.dodaj_autora( + autor_jan_kowalski, + jednostka_drugiej_uczelni, + dyscyplina_naukowa=dyscyplina2, + ) + wydawnictwo_zwarte.punkty_kbn = 20 + wydawnictwo_zwarte.wydawca = wydawca + wydawnictwo_zwarte.charakter_formalny = Charakter_Formalny.objects.get( + skrot="KSP" + ) + wydawnictwo_zwarte.save() + return wydawnictwo_zwarte + + +@pytest.mark.django_db +def test_wszystkie_dyscypliny_rekordu_bez_uczelni_zwraca_obie( + zwarte_cross_hst, dyscyplina1_hst, dyscyplina2 +): + """Bez filtrowania uczelni: globalnie obie dyscypliny są widoczne.""" + pks = {row[0] for row in zwarte_cross_hst.wszystkie_dyscypliny_rekordu()} + assert dyscyplina1_hst.pk in pks + assert dyscyplina2.pk in pks + + +@pytest.mark.django_db +def test_wszystkie_dyscypliny_rekordu_uczelnia_a_zwraca_tylko_hst( + zwarte_cross_hst, jednostka, dyscyplina1_hst, dyscyplina2 +): + """Per uczelnia A (HST): widoczna tylko dyscyplina HST.""" + pks = { + row[0] + for row in zwarte_cross_hst.wszystkie_dyscypliny_rekordu( + uczelnia=jednostka.uczelnia + ) + } + assert dyscyplina1_hst.pk in pks + assert dyscyplina2.pk not in pks + + +@pytest.mark.django_db +def test_wszystkie_dyscypliny_rekordu_uczelnia_b_zwraca_tylko_nie_hst( + zwarte_cross_hst, jednostka_drugiej_uczelni, dyscyplina1_hst, dyscyplina2 +): + """Per uczelnia B (nie-HST): widoczna tylko dyscyplina nie-HST.""" + pks = { + row[0] + for row in zwarte_cross_hst.wszystkie_dyscypliny_rekordu( + uczelnia=jednostka_drugiej_uczelni.uczelnia + ) + } + assert dyscyplina2.pk in pks + assert dyscyplina1_hst.pk not in pks From d8e035017b91620003abdb7ccafb596318b76292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 16:16:34 +0200 Subject: [PATCH 120/247] docs(multi-hosted): HANDOFF - HST #1 + NOT NULL #5 + admin uczelnia ZROBIONE --- docs/superpowers/HANDOFF-multi-hosted.md | 27 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index 95c104718..372d9a646 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -112,11 +112,12 @@ w obrębie CAŁEJ federacji, nie pojedynczej uczelni. To NIE prosty filtr per-uc — inny problem optymalizacyjny ponad partycjonowanym cache. Write-path (rebuild po zmianie) już poprawny; logika decyzyjna federacyjna odłożona. Osobny, późniejszy spec. -### C) Backlog hardeningu z self-review (MEDIUM/LOW — wpiąć w read-side/federację) -1. **[MEDIUM] `_dopasuj_kalkulator` liczy `wiele_hst`/próg globalnie** (wszystkie - uczelnie), kalkulator używany per-uczelnia → rekord cross-uczelnia mieszający - HST/nie-HST ponad granicą uczelni dziedziczy globalne `wiele_hst`. Spec to - akceptuje, brak testu. Dodać test graniczny + jawną decyzję domenową. +### C) Backlog hardeningu z self-review (MEDIUM/LOW) +1. ✅ **[ZROBIONE 2026-06-03] `wiele_hst` per-uczelnia.** `_dopasuj_kalkulator(original, + uczelnia=None)` + `wszystkie_dyscypliny_rekordu(uczelnia=None)` (filtr + `jednostka__uczelnia`); `ISlot` przekazuje rozstrzygniętą uczelnię, `canAdapt` + zostaje globalny. Test graniczny HST/nie-HST cross-uczelnia. Single-install + no-op. Commit `c687ceb07`. (Nie federacyjne — poprawność per-uczelnia.) 2. **[MEDIUM] brak indeksu** `(rekord_id, uczelnia, dyscyplina)` na `Cache_Punktacja_Dyscypliny` (jest tylko `(uczelnia, dyscyplina)`) — pod join widoku i jako naturalny klucz. Dorzucić w nowej migracji. @@ -125,8 +126,14 @@ zmianie) już poprawny; logika decyzyjna federacyjna odłożona. Osobny, późni bez wiersza CPA. Istotne dla read-side (zaskoczenie konsumenta) — udokumentować/zrównać. 4. **[LOW/design] mutacja `kalk.uczelnia` po konstrukcji** w `ISlot` — bezpieczna tylko dzięki świeżej instancji. Rozważyć `uczelnia` jako wymagany arg konstruktora. -5. **NOT NULL na `Cache_Punktacja_Dyscypliny.uczelnia`** — niemożliwe dopóki - fixtures tworzą NULL w runtime; rozważyć po uporządkowaniu fixtures (read-side). +5. ✅ **[ZROBIONE 2026-06-03] NOT NULL na `Cache_Punktacja_Dyscypliny.uczelnia`.** + Oba write-paths zawsze ustawiają uczelnię, 0425 zbackfillował legacy → mig 0428 + `AlterField null=False`. Commit `6c888f245` (+ admin liczba_n pokazuje uczelnię: + `uczelnia` w list_display/list_filter `IloscUdzialow*`). + +POZOSTAJE z R2 (minor, nieblokujące): `views/verify.py` (WeryfikujBazeView) liczy +`Autor_Dyscyplina` globalnie (diagnostyka superusera; nie przeciek, nie crash) — +do per-uczelnia przy okazji. ### G) ewaluacja_liczba_n per-uczelnia (WRITE+READ — osobny spec) Discovery 2026-06-03: **częściowo już per-uczelnia**, ale z luką write. @@ -204,8 +211,10 @@ Zatwierdzony wariant: **A (Verify → Stabilize → Investigate → Spec)**. Minor follow-up (nieblokujące): `views/verify.py` liczy `Autor_Dyscyplina` globalnie (diagnostyka, pre-existing); admin nie pokazuje `uczelnia`. - **F — federacja optymalizacji (B):** ODŁOŻONA (decyzja usera, olana). -5. ✅ **integrator (D): ZROBIONE 2026-06-03.** ⏭ NASTĘPNY: drobne (E); - potem NOT NULL na uczelnia (#5), hardening #1 (HST, w federacji — olanej). +5. ✅ **integrator (D): ZROBIONE.** ✅ **drobne (E): ZROBIONE.** ✅ **NOT NULL + uczelnia (#5): ZROBIONE.** ✅ **hardening #1 HST per-uczelnia: ZROBIONE** (nie + federacyjne — poprawność teraz). Federacja optymalizacji — nadal OLANA. + Pozostały minor: verify.py global Autor_Dyscyplina (diagnostyka). Backlog hardeningu (C): #2/#3 → R1; #1 (HST globalnie) → F (federacja). From ee67eb95859c30128cc312f056c9ec1dccf9800d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 16:59:50 +0200 Subject: [PATCH 121/247] fix(multi-hosted): verify.py liczy i naprawia Autor_Dyscyplina per-uczelnia (reads + POST update) Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_per_uczelnia.py | 122 ++++++++++++++++++ src/ewaluacja_liczba_n/views/verify.py | 68 +++++++--- 2 files changed, 170 insertions(+), 20 deletions(-) diff --git a/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py b/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py index c7c9d840c..8088277fe 100644 --- a/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py +++ b/src/ewaluacja_liczba_n/tests/test_per_uczelnia.py @@ -3,6 +3,8 @@ import pytest from model_bakery import baker +from bpp.models import Autor, Jednostka, Uczelnia +from bpp.models.dyscyplina_naukowa import Dyscyplina_Naukowa from ewaluacja_liczba_n.models import ( IloscUdzialowDlaAutoraZaCalosc, IloscUdzialowDlaAutoraZaRok, @@ -173,3 +175,123 @@ def test_autorzy_list_view_filtruje_po_uczelni(rf, db): autor_ids = list(qs.values_list("autor_id", flat=True)) assert a1.pk in autor_ids, "U1 row missing from queryset" assert a2.pk not in autor_ids, "U2 row must be excluded" + + +# --------------------------------------------------------------------------- +# Verify view scoping tests (R2 rule) +# --------------------------------------------------------------------------- + + +def _setup_two_universities_with_bad_ad(dyscyplina): + """ + Build two universities (U1, U2), each with one author having an + Autor_Dyscyplina record that has wymiar_etatu=None (data-quality issue). + Returns (u1, u2, a1, a2, ad1, ad2). + """ + from bpp.models.dyscyplina_naukowa import Autor_Dyscyplina + from ewaluacja_common.models import Rodzaj_Autora + + rodzaj_n, _ = Rodzaj_Autora.objects.get_or_create( + skrot="N", + defaults=dict( + nazwa="pracownik naukowy w liczbie N", + jest_w_n=True, + licz_sloty=True, + sort=1, + ), + ) + + u1 = baker.make(Uczelnia, skrot="W1", nazwa="Uczelnia W1") + u2 = baker.make(Uczelnia, skrot="W2", nazwa="Uczelnia W2") + j1 = baker.make(Jednostka, uczelnia=u1, skupia_pracownikow=True) + j2 = baker.make(Jednostka, uczelnia=u2, skupia_pracownikow=True) + a1 = baker.make(Autor, aktualna_jednostka=j1) + a2 = baker.make(Autor, aktualna_jednostka=j2) + + ad1 = Autor_Dyscyplina.objects.create( + autor=a1, + rok=2023, + dyscyplina_naukowa=dyscyplina, + wymiar_etatu=None, + procent_dyscypliny=Decimal("100.0"), + rodzaj_autora=rodzaj_n, + ) + ad2 = Autor_Dyscyplina.objects.create( + autor=a2, + rok=2023, + dyscyplina_naukowa=dyscyplina, + wymiar_etatu=None, + procent_dyscypliny=Decimal("100.0"), + rodzaj_autora=rodzaj_n, + ) + return u1, u2, a1, a2, ad1, ad2 + + +@pytest.mark.django_db +def test_weryfikuj_baze_view_bez_wymiaru_etatu_per_uczelnia(rf, db): + """ + WeryfikujBazeView.get_context_data with request._uczelnia=U1 must report + bez_wymiaru_etatu == 1 (only U1's bad record), not 2. + """ + from ewaluacja_liczba_n.views.verify import WeryfikujBazeView + + dyscyplina = baker.make(Dyscyplina_Naukowa) + u1, u2, _a1, _a2, _ad1, _ad2 = _setup_two_universities_with_bad_ad( + dyscyplina + ) + + user = baker.make("bpp.BppUser") + request = rf.get("/") + request.user = user + request._uczelnia = u1 + + view = WeryfikujBazeView() + view.request = request + view.kwargs = {} + + context = view.get_context_data() + + assert context["bez_wymiaru_etatu"] == 1, ( + f"Expected 1 (only U1 record), got {context['bez_wymiaru_etatu']}. " + "WeryfikujBazeView must scope queries to the requesting university." + ) + + +@pytest.mark.django_db +def test_ustaw_wymiar_etatu_view_post_per_uczelnia(rf, db): + """ + UstawWymiarEtatuView.post with request._uczelnia=U1 must update only U1's + Autor_Dyscyplina records; U2's record must stay None. + """ + from unittest.mock import patch + + from ewaluacja_liczba_n.views.verify import UstawWymiarEtatuView + + dyscyplina = baker.make(Dyscyplina_Naukowa) + u1, u2, _a1, _a2, ad1, ad2 = _setup_two_universities_with_bad_ad( + dyscyplina + ) + + user = baker.make("bpp.BppUser") + request = rf.post("/") + request.user = user + request._uczelnia = u1 + + view = UstawWymiarEtatuView() + view.request = request + view.kwargs = {} + + # Patch messages.success so that rf (no middleware) doesn't raise; + # we only care about DB mutation here. + with patch("ewaluacja_liczba_n.views.verify.messages"): + view.post(request) + + ad1.refresh_from_db() + ad2.refresh_from_db() + + assert ad1.wymiar_etatu == Decimal("1.0"), ( + "U1 record was not updated by UstawWymiarEtatuView.post" + ) + assert ad2.wymiar_etatu is None, ( + "U2 record must NOT be updated when request._uczelnia=U1" + ) diff --git a/src/ewaluacja_liczba_n/views/verify.py b/src/ewaluacja_liczba_n/views/verify.py index 59f121a8c..e796ea8e8 100644 --- a/src/ewaluacja_liczba_n/views/verify.py +++ b/src/ewaluacja_liczba_n/views/verify.py @@ -23,10 +23,17 @@ class WeryfikujBazeView(GroupRequiredMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + uczelnia = Uczelnia.objects.get_for_request(self.request) + ad_qs = Autor_Dyscyplina.objects.filter( + rok__gte=2022, + rok__lte=2025, + autor__aktualna_jednostka__uczelnia=uczelnia, + autor__aktualna_jednostka__skupia_pracownikow=True, + ) + # 1. Total by rodzaj_pracownika for 2022-2025 context["rodzaje_pracownika"] = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) - .values("rodzaj_autora") + ad_qs.values("rodzaj_autora") .annotate(liczba=Count("id")) .order_by("rodzaj_autora") ) @@ -63,10 +70,7 @@ def get_context_data(self, **kwargs): ] context["bez_rodzaju_zatrudnienia"] = ( - Autor_Dyscyplina.objects.filter( - rok__gte=2022, - rok__lte=2025, - ) + ad_qs .exclude(rodzaj_autora__in=known_rodzaje_ids) .count() ) @@ -76,7 +80,7 @@ def get_context_data(self, **kwargs): # 2. Records without wymiar_etatu context["bez_wymiaru_etatu"] = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + ad_qs .filter(Q(wymiar_etatu__isnull=True) | Q(wymiar_etatu=0)) .select_related("autor") .count() @@ -88,7 +92,7 @@ def get_context_data(self, **kwargs): # - if subdyscyplina_naukowa exists, procent_subdyscypliny must not be NULL or 0 # - only for authors with jest_w_n=True OR licz_sloty=True context["bez_procent_n_sloty"] = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + ad_qs .filter(Q(rodzaj_autora__jest_w_n=True) | Q(rodzaj_autora__licz_sloty=True)) .filter( Q(procent_dyscypliny__isnull=True) # Missing main discipline percentage @@ -107,7 +111,7 @@ def get_context_data(self, **kwargs): # 3.1. Records with missing percentage - any author type context["bez_procent_dowolny"] = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + ad_qs .filter( Q(procent_dyscypliny__isnull=True) | Q(procent_dyscypliny=Decimal("0")) @@ -125,7 +129,7 @@ def get_context_data(self, **kwargs): # Count records that can be auto-fixed (only single discipline, no subdyscyplina) # For N/sloty authors context["bez_procent_n_sloty_do_naprawy"] = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + ad_qs .filter(Q(rodzaj_autora__jest_w_n=True) | Q(rodzaj_autora__licz_sloty=True)) .filter(subdyscyplina_naukowa__isnull=True) .filter( @@ -136,7 +140,7 @@ def get_context_data(self, **kwargs): # For any author type context["bez_procent_dowolny_do_naprawy"] = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + ad_qs .filter(subdyscyplina_naukowa__isnull=True) .filter( Q(procent_dyscypliny__isnull=True) | Q(procent_dyscypliny=Decimal("0")) @@ -149,7 +153,7 @@ def get_context_data(self, **kwargs): # Only for authors with jest_w_n=True OR licz_sloty=True problematic_suma = [] all_records = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + ad_qs .filter(Q(rodzaj_autora__jest_w_n=True) | Q(rodzaj_autora__licz_sloty=True)) .select_related("autor", "rodzaj_autora") ) @@ -183,7 +187,7 @@ def get_context_data(self, **kwargs): # Calculate distinct number of authors context["distinct_authors_count"] = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + ad_qs .values("autor") .distinct() .count() @@ -222,8 +226,8 @@ def get_context_data(self, **kwargs): ) # 5. Autorzy z obiema dyscyplinami nie-raportowanymi - # Oblicz nie-raportowane dyscypliny na podstawie sumy udziałów w 2025 (suma < 12) - uczelnia = Uczelnia.objects.get_for_request(self.request) + # Oblicz nie-raportowane dyscypliny na podstawie sumy udziałów w 2025 + # (suma < 12); reuse the uczelnia already fetched above. sumy_2025 = ( IloscUdzialowDlaAutoraZaRok.objects.filter(uczelnia=uczelnia, rok=2025) .values("dyscyplina_naukowa_id") @@ -238,7 +242,7 @@ def get_context_data(self, **kwargs): # Znajdź autorów z dwoma dyscyplinami gdzie obie są nie-raportowane autorzy_obie_nieraportowane = [] autorzy_dwie_dyscypliny = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + ad_qs .filter(subdyscyplina_naukowa__isnull=False) .select_related("autor", "dyscyplina_naukowa", "subdyscyplina_naukowa") ) @@ -270,8 +274,14 @@ class UstawWymiarEtatuView(GroupRequiredMixin, View): group_required = GR_WPROWADZANIE_DANYCH def post(self, request): + uczelnia = Uczelnia.objects.get_for_request(request) updated = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + Autor_Dyscyplina.objects.filter( + rok__gte=2022, + rok__lte=2025, + autor__aktualna_jednostka__uczelnia=uczelnia, + autor__aktualna_jednostka__skupia_pracownikow=True, + ) .filter(Q(wymiar_etatu__isnull=True) | Q(wymiar_etatu=0)) .update(wymiar_etatu=Decimal("1.0")) ) @@ -289,9 +299,15 @@ class UstawProcentDyscyplinyNSlotyView(GroupRequiredMixin, View): group_required = GR_WPROWADZANIE_DANYCH def post(self, request): + uczelnia = Uczelnia.objects.get_for_request(request) # Only update records without subdyscyplina (single discipline) updated = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + Autor_Dyscyplina.objects.filter( + rok__gte=2022, + rok__lte=2025, + autor__aktualna_jednostka__uczelnia=uczelnia, + autor__aktualna_jednostka__skupia_pracownikow=True, + ) .filter(Q(rodzaj_autora__jest_w_n=True) | Q(rodzaj_autora__licz_sloty=True)) .filter(subdyscyplina_naukowa__isnull=True) .filter( @@ -314,9 +330,15 @@ class UstawProcentDyscyplinyDowolnyView(GroupRequiredMixin, View): group_required = GR_WPROWADZANIE_DANYCH def post(self, request): + uczelnia = Uczelnia.objects.get_for_request(request) # Only update records without subdyscyplina (single discipline) updated = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + Autor_Dyscyplina.objects.filter( + rok__gte=2022, + rok__lte=2025, + autor__aktualna_jednostka__uczelnia=uczelnia, + autor__aktualna_jednostka__skupia_pracownikow=True, + ) .filter(subdyscyplina_naukowa__isnull=True) .filter( Q(procent_dyscypliny__isnull=True) | Q(procent_dyscypliny=Decimal("0")) @@ -356,9 +378,15 @@ def post(self, request): ) ) + uczelnia = Uczelnia.objects.get_for_request(request) # Update records without rodzaj_autora or with unknown rodzaj_autora updated = ( - Autor_Dyscyplina.objects.filter(rok__gte=2022, rok__lte=2025) + Autor_Dyscyplina.objects.filter( + rok__gte=2022, + rok__lte=2025, + autor__aktualna_jednostka__uczelnia=uczelnia, + autor__aktualna_jednostka__skupia_pracownikow=True, + ) .exclude(rodzaj_autora__in=known_rodzaje_ids) .update(rodzaj_autora=rodzaj) ) From 25720665698f7529d2f2c92291595b2a26ad743d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 17:02:11 +0200 Subject: [PATCH 122/247] =?UTF-8?q?docs(multi-hosted):=20HANDOFF=20-=20ver?= =?UTF-8?q?ify.py=20per-uczelnia=20ZROBIONE=20(brak=20otwartych=20minor?= =?UTF-8?q?=C3=B3w)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/HANDOFF-multi-hosted.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index 372d9a646..6372c4f99 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -131,9 +131,11 @@ zmianie) już poprawny; logika decyzyjna federacyjna odłożona. Osobny, późni `AlterField null=False`. Commit `6c888f245` (+ admin liczba_n pokazuje uczelnię: `uczelnia` w list_display/list_filter `IloscUdzialow*`). -POZOSTAJE z R2 (minor, nieblokujące): `views/verify.py` (WeryfikujBazeView) liczy -`Autor_Dyscyplina` globalnie (diagnostyka superusera; nie przeciek, nie crash) — -do per-uczelnia przy okazji. +✅ **[ZROBIONE 2026-06-03] `views/verify.py` per-uczelnia.** WeryfikujBazeView +(reads przez bazowy `ad_qs` z filtrem `autor__aktualna_jednostka__uczelnia` + +`skupia_pracownikow`) ORAZ 4 POST-fixy (`UstawWymiarEtatu`/`UstawProcent*`/ +`UstawRodzajAutora`) — `.update()` zawężony per-uczelnia (był globalny → mutował +dane wszystkich uczelni). Commit `ee67eb958`. ### G) ewaluacja_liczba_n per-uczelnia (WRITE+READ — osobny spec) Discovery 2026-06-03: **częściowo już per-uczelnia**, ale z luką write. @@ -213,8 +215,8 @@ Zatwierdzony wariant: **A (Verify → Stabilize → Investigate → Spec)**. - **F — federacja optymalizacji (B):** ODŁOŻONA (decyzja usera, olana). 5. ✅ **integrator (D): ZROBIONE.** ✅ **drobne (E): ZROBIONE.** ✅ **NOT NULL uczelnia (#5): ZROBIONE.** ✅ **hardening #1 HST per-uczelnia: ZROBIONE** (nie - federacyjne — poprawność teraz). Federacja optymalizacji — nadal OLANA. - Pozostały minor: verify.py global Autor_Dyscyplina (diagnostyka). + federacyjne — poprawność teraz). ✅ **verify.py per-uczelnia (reads+POST): + ZROBIONE.** Federacja optymalizacji — nadal OLANA. Brak otwartych minorów. Backlog hardeningu (C): #2/#3 → R1; #1 (HST globalnie) → F (federacja). From 88b688dbc4ab4600b7872560936db2416ccb0ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 17:03:21 +0200 Subject: [PATCH 123/247] docs(multi-hosted): next-session handoff - 4 audyty (get_default/celery/uczelnia-gaps/self-review vs spec) --- docs/superpowers/NEXT-SESSION-multi-hosted.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/superpowers/NEXT-SESSION-multi-hosted.md diff --git a/docs/superpowers/NEXT-SESSION-multi-hosted.md b/docs/superpowers/NEXT-SESSION-multi-hosted.md new file mode 100644 index 000000000..64787b57b --- /dev/null +++ b/docs/superpowers/NEXT-SESSION-multi-hosted.md @@ -0,0 +1,100 @@ +# NEXT SESSION — multi-hosted audyty (do wklejenia po resecie) + +> Wklej treść sekcji „PROMPT DO WKLEJENIA" niżej jako pierwszą wiadomość po resecie. +> Reszta dokumentu to kontekst, który agent może doczytać. + +--- + +## PROMPT DO WKLEJENIA + +Jesteś świeżą sesją. Repo: `~/Programowanie/bpp-multi-hosted-config`, gałąź +`feature/multi-hosted-config` (BPP, Django). Wątki multi-hosted (R1 slot read-side, +R2 ewaluacja_liczba_n, integrator PBN, drobne, HST per-uczelnia, NOT NULL, verify.py) +są ZROBIONE i wypushowane — pełny stan w `docs/superpowers/HANDOFF-multi-hosted.md` +(przeczytaj go najpierw). Teraz cztery audyty multi-hosted. Dla każdego: ustal fakty, +pokaż znaleziska, zaproponuj fix, NIE implementuj bez mojej zgody (brainstorming → +spec → plan → subagent-driven, jak poprzednio). Zacznij od audytów 1–3 (read-only +rozpoznanie, równolegle), potem 4. + +**Audyt 1 — pozostałe `get_default`/`objects.default` bez świadomego „zostaje".** +Źródło prawdy: `src/bpp/tests/test_multihosted_get_default_guard.py` (dict `APPROVED`). +Dla KAŻDEGO wpisu z whitelisty przeczytaj kod w danym pliku i oceń: czy to NAPRAWDĘ +świadomy, nieusuwalny fallback (np. brak requestu w CLI/Celery, czysty display, +docstring/komentarz), czy „odłożone" które da się już zrobić per-uczelnia. Szczególnie +podejrzane: `powiazania_autorow/queries.py` (opis: „deferred multi-host" — to nie +„zostaje", to dług). Zwróć tabelę: plik → uzasadnienie → werdykt (ZOSTAJE / DO ZROBIENIA ++ jak). Sprawdź też `grep -rnE "objects\.default|get_default\(\)" src --include=*.py` +poza whitelistą (guard łapie runtime, ale potwierdź brak nowych w testach/komentarzach +które realnie są kodem). + +**Audyt 2 — zadania Celery bez argumentu `uczelnia`.** +Znajdź definicje tasków (`@shared_task`, `@app.task`, `@task`, `bind=True`) w całym +`src/` i wytypuj te, które dotykają logiki zależnej od uczelni (PBN klient, sloty, +liczba_n, metryki, oświadczenia, eksport) a NIE przyjmują `uczelnia`/`uczelnia_id` +ani nie wyprowadzają uczelni jawnie (np. z `get_for_request` — niedostępne w tle!, +albo z FK obiektu). Tła bez requestu to typowe miejsce regresji multi-hosted (reguła +projektu: tło → jawna uczelnia: argument / FK / `Uczelnia.objects.get()` single-or-fail, +NIGDY get_default/get_for_request). Start: `grep -rnE "@shared_task|@app\.task|@task\b|\.delay\(|\.apply_async" src --include=*.py`. Zwróć listę: task → czy +uczelnia-zależny → czy ma uczelnię → ryzyko. + +**Audyt 3 — gdzie jeszcze uczelnia się przyda.** +Poszukaj miejsc, które POWINNY być per-uczelnia, a nie są: filtry/agregaty po +`Autor_Dyscyplina`/`Cache_Punktacja_*`/`Rekord` bez uczelni w widokach/raportach/API; +flagi `Uczelnia` (pbn_*, pbn_wysylaj_*, ukryte_statusy, obca_jednostka) czytane „raz +globalnie"; rankingi/„liczba N"/eksporty/PBN-wysyłka. Skup się na ŚCIEŻKACH RUNTIME +widzianych przez użytkownika danej uczelni. Federacja optymalizacji (ewaluacja_optymalizacja, +ewaluacja_optymalizuj_publikacje) jest ŚWIADOMIE OLANA — NIE proponuj jej. Zwróć listę +kandydatów z oceną „realny multi-host gap" vs „OK/świadome". + +**Audyt 4 — self-review kodu vs SPEC i PLAN.** +Dla każdej pary spec+plan w `docs/superpowers/specs/` i `docs/superpowers/plans/` +z 2026-06-02/03 (per-uczelnia-sloty write+read, liczba_n R2, integrator) sprawdź: +czy zrealizowany kod pokrywa KAŻDY punkt spec; czy plan był adekwatny; czy są +rozjazdy (zrobione inaczej niż spec — czy świadomie), luki (spec mówił, kod nie robi), +nadmiar (kod robi, spec nie przewidywał). Użyj `git log --oneline origin/dev..HEAD` +i diffów. Dla rzetelności rozważ subagentów-recenzentów per obszar. Zwróć raport: +spec → pokrycie (✓/luka/rozjazd) + rekomendacje. + +Reguły: `uv run` zawsze; testy `-p no:cacheprovider` (testcontainers, Docker musi +działać); guard test `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q`; +lint `uv run ruff check` (NIE `--fix`); commituj/pushuj tylko gdy poproszę. + +--- + +## KONTEKST (stan na 2026-06-03, agent może doczytać) + +**Zrobione i wypushowane** (gałąź `feature/multi-hosted-config`): +- **R1 — slot read-side**: widok `bpp_cache_punktacja_autora_view` eksponuje + `uczelnia_id`; helper `uczelnia_dla_odczytu` (hybryda site+superuser); raport_slotow + (RaportSlotowUczelnia FK + generacja + RaportSlotow autor), oswiadczenia, API + filtrują po uczelni; API owner-scoped. +- **R2 — ewaluacja_liczba_n**: FK `uczelnia` na `IloscUdzialow*` (+NOT NULL mig 0428), + cały pipeline `utils.py` per-uczelnia (autor→`aktualna_jednostka.uczelnia`, NULL/obca + wykluczeni; naprawiony globalny `objects.all().delete()`), widoki list/export/verify + filtrują, admin pokazuje uczelnię. +- **Integrator PBN**: authors.py / pbn_integrator command / scientists.py matcher — + `client.uczelnia` / `autor.aktualna_jednostka.uczelnia`; `pbn_integrator/` czyste. +- **HST #1**: `wiele_hst` liczone per-uczelnia (`_dopasuj_kalkulator(original, uczelnia)` + + `wszystkie_dyscypliny_rekordu(uczelnia)`). +- **verify.py**: reads + 4 POST-fixy per-uczelnia. + +**Guard whitelist (`APPROVED`) — stan obecny (11 wpisów, materiał do Audytu 1):** +`bpp/middleware.py:1`, `bpp/util/bpp_specific.py:2`, `bpp/models/abstract/pbn.py:2`, +`bpp/models/jednostka.py:1`, `bpp/multiseek_registry/fields/numeric_fields.py:1`, +`ewaluacja2021/util.py:1`, `pbn_api/management/commands/util.py:1`, +`pbn_import/templatetags/pbn_import_tags.py:1`, `pbn_import/utils/command_helpers.py:1`, +`powiazania_autorow/queries.py:1` (← „deferred", podejrzane). +(`pbn_integrator/*` i `bpp/models/sloty/*` USUNIĘTE z whitelisty — zrobione.) + +**Reguła binarna multi-hosted (do oceniania znalezisk):** runtime z dostępną uczelnią +→ JAWNA uczelnia (`get_for_request` w widoku / argument / FK / `self.uczelnia`); tło/CLI +bez requestu → `Uczelnia.objects.get()` single-or-fail (NIGDY `get_default`/`get_for_request`). +Atrybucja autora do uczelni: `autor.aktualna_jednostka.uczelnia`, tylko gdy +`skupia_pracownikow=True` (NULL/obca → wykluczony). + +**Świadomie OLANE (nie proponować):** federacja optymalizacji +(`ewaluacja_optymalizacja`, `ewaluacja_optymalizuj_publikacje`). + +**Dokumenty:** `docs/superpowers/HANDOFF-multi-hosted.md` (master), +`docs/superpowers/specs/2026-06-0{2,3}-*`, `docs/superpowers/plans/2026-06-0{2,3}-*`, +`docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md`. From 09a03da0beaab0defcc5d1f7c5f3d1fec4e2b5fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 20:55:55 +0200 Subject: [PATCH 124/247] =?UTF-8?q?docs(multi-hosted):=20raport=204=20audy?= =?UTF-8?q?t=C3=B3w=20+=20backlog=20R3=20w=20HANDOFF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cztery audyty po domknięciu głównych wątków (get_default whitelist, taski Celery, gdzie-jeszcze-uczelnia, kod vs spec). Zbieżność Audytu 3+4b: publiczne widoki czytające Rekord wprost przeciekają międzyuczelniano (poza zakresem R1). Backlog A-D, najwyższy priorytet R3 (read-side publiczny). Co-Authored-By: Claude Opus 4.8 --- .../2026-06-03-audyty-multihosted-4x.md | 211 ++++++++++++++++++ docs/superpowers/HANDOFF-multi-hosted.md | 43 ++++ 2 files changed, 254 insertions(+) create mode 100644 docs/superpowers/2026-06-03-audyty-multihosted-4x.md diff --git a/docs/superpowers/2026-06-03-audyty-multihosted-4x.md b/docs/superpowers/2026-06-03-audyty-multihosted-4x.md new file mode 100644 index 000000000..c293cece1 --- /dev/null +++ b/docs/superpowers/2026-06-03-audyty-multihosted-4x.md @@ -0,0 +1,211 @@ +# Audyty multi-hosted (4×) — raport 2026-06-03 + +Cztery audyty wykonane po domknięciu głównych wątków multi-hosted (write/read +sloty, liczba_n R2, integrator, HST, NOT NULL, verify.py). Wszystkie read-only. +Metoda: trzy audyty rozpoznawcze (1–3) równolegle przez subagentów, potem +audyt 4 (self-review vs spec) rozbity na 4 recenzentów per obszar. + +Gałąź: `feature/multi-hosted-config` (HEAD `88b688dbc` w chwili audytu). + +--- + +## NAJWAŻNIEJSZY WNIOSEK (zbieżność audytów 3 + 4b) + +Dwa niezależne audyty wskazały **to samo**: publiczne widoki czytające `Rekord` +**wprost** przeciekają dane międzyuczelniane. R1 świadomie objął tylko *cache +slotów* (`Cache_Punktacja_*`), więc to NIE regresja R1 — ale spec R1 **nie +odnotował** tych widoków jako wyłączonych z zakresu, więc grożą zgubieniem +(„read-side wygląda na zamknięty po R1", a najwidoczniejszy przeciek zostaje). + +→ Kandydat na osobny wątek **R3 (read-side publiczny Rekord)**. Patrz backlog A. + +--- + +## Audyt 1 — `get_default`/`objects.default` poza świadomym „zostaje" + +Źródło prawdy: `src/bpp/tests/test_multihosted_get_default_guard.py` (`APPROVED`). +Guard szczelny — grep wzorcem guarda daje dokładnie whitelistę, nic ponad. +Szersze trafienia (`Engine.get_default()`, docstringi tasków opisujące że kod +*nie* robi get_default, definicje `self.get_default()` w managerze) to +false-positives względem celu. + +**9 wpisów ZOSTAJE** (legit): `middleware.py:295` (fallback Site bez Uczelni), +`bpp_specific.py:104` (request-first, fallback bez requestu CLI/Celery), +`abstract/pbn.py:25,93` (None-tolerant warstwa modelu, linki PBN), +`jednostka.py:48` (sort/display), `numeric_fields.py:73` (toggle IC None-tolerant), +`ewaluacja2021/util.py:113` (komentarz), `pbn_api/.../util.py:54` (guarded +count==1, wzorcowy CLI), `pbn_import_tags.py:23` (request-first), `command_helpers.py:43` +(CLI None-tolerant + CommandError). + +**1 wpis DO ZROBIENIA:** + +| plik:linia | werdykt | jak naprawić | +|---|---|---| +| `powiazania_autorow/queries.py:189` (`_pbn_root()`) | **DO ZROBIENIA** | Oba call-sites to `View.get(self, request, pk)` (`powiazania_autorow/views.py:48`, `:140`) — request dostępny. Zmienić `_pbn_root()` → `_pbn_root(uczelnia)`; w widokach `_pbn_root(Uczelnia.objects.get_for_request(request))`. Argument „anty-N+1" (root raz) jest ortogonalny do *którą* uczelnię. Bug kosmetyczny (zły host PBN w linkach grafu uczelni B), ale realny. Komentarz „deferred multi-host" = dług, nie świadoma decyzja. | + +--- + +## Audyt 2 — taski Celery bez argumentu `uczelnia` + +**Cała warstwa PBN (wysyłka/pobieranie/import) poprawnie naprawiona** — jawny +`uczelnia_id` + `get_for_pbn_background`/FK wpisu kolejki/`session.uczelnia`, +zero `get_default()`/`get_for_request()` w tle. Wzorcowe: `pbn_export_queue`, +`pbn_downloader_app`, `pbn_import`, `importer_publikacji`, `pbn_wysylka_oswiadczen`, +`oswiadczenia.generate_oswiadczenia_zip`, `bpp.zaktualizuj_liczbe_cytowan` (pętla +per uczelnia). + +Realne gapy = taski które **mają** `uczelnia_id`, ale globalne querysety go +ignorują (mieszają/kasują dane wszystkich uczelni): + +| task (plik:linia) | problem | ryzyko / klasyfikacja | +|---|---|---| +| `solve_single_discipline_task` (`ewaluacja_optymalizacja/tasks/optimization.py:73`) | `OptimizationRun.objects.filter(dyscyplina_naukowa=dyscyplina).delete()` bez `uczelnia=` → kasuje runy innych uczelni dla wspólnej dyscypliny; `solve_discipline(dyscyplina_nazwa=...)` nie dostaje uczelni | ŚREDNIE — **federacja olana, ale to korupcja danych, nie logika federacyjna** | +| `reset_all_pins_task` (`tasks/reset_pins.py:139`) | `Autor_Dyscyplina.filter(rok__gte=2022, rok__lte=2025)` globalnie (task ma `uczelnia_id`) | ŚREDNIE — federacja | +| `optimize_and_unpin_task` (`optimization.py:536,573`) | `Wydawnictwo_*_Autor.filter(przypieta=True)` globalnie | ŚREDNIE — federacja | +| `porownaj_dyscypliny_pbn_task` (`komparator_pbn_udzialy/tasks.py`, `utils.py:46`) | iteruje `OswiadczenieInstytucji.objects.all()` + globalny `.delete()` rozbieżności; brak arg `uczelnia` (call-site `views.py:316` ma request) | ŚREDNIE — czyta lokalny cache, nie crashuje | +| `porownaj_zrodla_task` (`pbn_komparator_zrodel/tasks.py:13`, `utils.py:227`) | `RozbieznoscZrodlaPBN.objects.all().delete()` globalnie; model bez FK uczelni → realnie NISKIE | NISKIE (dane źródeł globalne) | +| `generuj_metryki_task` (`ewaluacja_metryki/tasks.py:245`, `utils.py:556`) | `MetrykaAutora.objects.all().delete()` — model **bez FK uczelnia**; jawne komentarze „rewizja per-uczelnia = federacja" | ŚREDNIE — **świadomy deferral** (patrz backlog D) | + +Niuans krytyczny: optymalizacja jest świadomie olana, ale `OptimizationRun.delete()` +cross-uczelnia to *korupcja danych*, a nie *decyzja optymalizacyjna federacyjna*. +Minimalny scope-fix delete'ów można rozważyć niezależnie od reszty federacji. + +--- + +## Audyt 3 — gdzie jeszcze uczelnia się przyda (read-side runtime) + +Architektura: każda uczelnia na własnej domenie (`Site`); middleware ustawia +`request._uczelnia`; `get_for_request` zwraca uczelnię z domeny. Dane NIE +partycjonowane — atrybucja przez `autor → jednostka.uczelnia` (`skupia_pracownikow=True`). +Strona główna (`get_uczelnia_context_data`) JUŻ scopuje. Każdy publiczny +`Rekord.objects.all()` na domenie uczelni = przeciek. + +**5 realnych gapów (publiczna strona, wszystkie mają `get_for_request` w zasięgu):** + +| # | miejsce (plik:linia) | co | ruch | +|---|---|---|---| +| 1 | `bpp/views/mymultiseek.py:37` (→ `multiseek_registry/__init__.py:32` `Rekord.objects.all()`) | **multiwyszukiwarka** — tylko `ukryte_statusy`, brak filtra uczelni; agregaty `ctx["sumy"]` z tego samego qs | NAJWYŻSZY | +| 2 | `nowe_raporty/poziomy.py:39-42` (`_base_uczelnia`, woła `views.py:286`) | **raport „cała uczelnia"** — `obiekt`=uczelnia PRZEKAZANY ale IGNOROWANY (`Rekord.objects.all()`) | wysoki, najbardziej rażący | +| 3 | `ranking_autorow/views.py:265-291` (`Sumy`) | **ranking autorów** — filtr `jednostka__uczelnia` tylko gdy user ręcznie wybierze jednostkę; domyślnie globalny | wysoki | +| 4 | `bpp/views/browse.py:491-556` (`LataView`, `RokView`) | **browse lata/rok** — `Rekord` globalnie | średni | +| 5 | `bpp/views/oai.py:243-247` (`OAIView`) | **OAI-PMH** — `Rekord.objects.all()`, tylko `ukryte_statusy("api")`; harvester pobiera cudze rekordy | publiczny eksport | + +Wzorzec naprawy jednolity: +`.filter(autorzy__jednostka__uczelnia=uczelnia, autorzy__jednostka__skupia_pracownikow=True).distinct()` +(join przez `autorzy` mnoży wiersze → `distinct`). Rekomendacja: wspólny helper +`scope_rekord_do_uczelni(qs, uczelnia)` (analogicznie do `Rekord.objects.prace_jednostki`), +by nie powielać reguły `skupia_pracownikow` w 5 miejscach. + +**OK-świadome / minor (niski impakt, istotne tylko gdy uczelnie mają różne rooty PBN):** +`abstract/pbn.py:25,93` (`link_do_pbn`/`_format_link_pi` → `get_default().pbn_api_root`), +`powiazania_autorow/queries.py:189` (ten sam co Audyt 1), +`numeric_fields.py:73` (toggle IC). API REST (`api_v1/viewsets/*`) listuje globalnie +po modelu — to API maszynowe, osobny temat, nie blokuje. + +--- + +## Audyt 4 — self-review kodu vs SPEC i PLAN + +### 4a. Sloty WRITE-SIDE — ✓ kompletne (31/31 punktów) +Spec `2026-06-02-per-uczelnia-sloty-design.md`, plan `2026-06-02-per-uczelnia-sloty.md`. +Testy lokalnie: 18/18 `test_per_uczelnia.py`, 79/79 `test_sloty/`, brak dryfu migracji. + +- **1 świadomy, KORZYSTNY rozjazd:** spec mówił „wybór progu uczelnia-niezależny", + ale kod liczy `wiele_hst`/HST per-uczelnia (`core.py:73` `_dopasuj_kalkulator(original, uczelnia)` + + `wszystkie_dyscypliny_rekordu(uczelnia)`, commit `c687ceb07`). Lepsze niż spec + — globalne `wiele_hst` psułoby liczby cross-uczelnia. `canAdapt()` woła bez uczelni + (boolean adapt-check) — OK. **Rekomendacja:** dopisać notkę do spec, że reguła + „próg uczelnia-niezależny" została zrewidowana dla `wiele_hst`. +- **Stary self-review PRZETERMINOWANY:** `reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md` + zgłaszał jako otwarte: NOT NULL (niemożliwe), brak indeksu, widok bez uczelni — + wszystkie od tego czasu domknięte (`6c888f245`+mig 0428, mig 0427, mig 0426). + **Rekomendacja:** oznaczyć go jako SUPERSEDED. +- Brak twardych luk. Pozostałe LOW: mutacja `kalk.uczelnia` po konstrukcji + (bezpieczna przy świeżej instancji), asymetria `skupia_pracownikow` (pre-existing, + udokumentowana `core.py:411-419`). + +### 4b. Sloty READ-SIDE R1 — ✓ z 1 luką (18/19) +Spec `2026-06-03-per-uczelnia-sloty-read-side-design.md`, plan `…-R1.md`. + +- **LUKA (jedyna, realna):** komenda CLI `zbieraj_sloty` + (`bpp/management/commands/zbieraj_sloty.py:36-38`) NIE obsługuje uczelni — + woła `autor.zbieraj_sloty(slot, rok_min, rok_max)`; wrapper `Autor.zbieraj_sloty` + (`autor.py:383-402`) NIE przekazuje `uczelnia_id` do `bpp.core.zbieraj_sloty`; + brak argumentu `--uczelnia`. Spec wymieniał ją **dwukrotnie** (single-or-fail / + argument). Plan Task 4 pokrył tylko funkcję `bpp.core.zbieraj_sloty`, self-review + planu po cichu pominął CLI. Single-install no-op → niewidoczne w testach. + **Fix:** dodać `uczelnia_id` do `Autor.zbieraj_sloty` (przelot do core) + + `--uczelnia` + `.get()` single-or-fail w komendzie. +- **ROZJAZD łagodny:** API `raport_slotow_uczelnia` filtruje per-OWNER + (`viewsets/raport_slotow_uczelnia.py:38`), nie per-UCZELNIA. Bezpieczne (brak + przecieku do innego usera), zgodne z dopuszczeniem planu. Edge: superuser z + override `?uczelnia=` widzi przez API raporty wszystkich uczelni naraz. Niski + priorytet; dopisać do spec że dla R1 ownership ≈ uczelnia. +- **Scope gaps (poza spec):** 5 publicznych czytników `Rekord` = zbieżne z Audytem 3. + +### 4c. liczba_n R2 — ✓ kompletne (24/24) +Spec `2026-06-03-ewaluacja-liczba-n-per-uczelnia-design.md`, plan `…-R2.md`. +Testy: 20 passed / 1 skipped, brak dryfu migracji. + +- FK `uczelnia` na obu `IloscUdzialow*` + `unique_together` z uczelnią ✓; backfill + 0009 (single→domyślna / multi→fail) ✓; główny bug `oblicz_sumy_udzialow_za_calosc` + `objects.all().delete()` → `filter(uczelnia=...).delete()` ✓ (`utils.py:221`); + atrybucja `aktualna_jednostka.uczelnia` + `skupia_pracownikow`, NULL/obca wykluczeni + ✓; widoki list/export/verify filtrują `get_for_request` ✓. +- Oba minory follow-up z HANDOFF **DOMKNIĘTE:** verify.py Autor_Dyscyplina globalnie + → `ee67eb958`; admin nie pokazywał uczelni → `6c888f245`. +- **Poza zakresem (świadomie):** `ewaluacja_metryki` czyta + `IloscUdzialowDlaAutoraZaCalosc.objects.all()` globalnie w 5 miejscach + (`tasks.py:231,357`, `utils.py:277`, `oblicz_metryki.py:132`, `generation.py:74`). + Spec umieścił metryki poza R2. Patrz backlog D. + +### 4d. Integrator — ✓ kompletne (16/16) +Spec `2026-06-03-integrator-per-uczelnia-design.md` (bez osobnego planu — subagent-driven). + +- Wszystkie 3 sites: authors.py 5× `uczelnia.…`/`client.uczelnia` ✓; command + `_handle_people` → `client.uczelnia.pbn_uid_id` ✓; matcher + `matchuj_autora_po_stronie_pbn(…, uczelnia)` + caller `autor.aktualna_jednostka.uczelnia`/None ✓. +- Świadoma delta R2 (`aktualna_jednostka=None` → matcher zwraca `None`) obecna i + zgodna (`scientists.py:433-447`). `_przetworz_afiliacje` keyword-only `uczelnia` + (code review `a982dbfe7`). `pbn_integrator/` czyste z `objects.default`. +- **2 nieblokujące:** brak testu na deltę R2 (`uczelnia=None`→`None`) — pre-existing + gap, spec testu nie wymagał; stale docstring matcha/`_handle_people` (brak `uczelnia` + w `Args:`). Spec-bez-planu wystarczył dla 3-site mechanicznej zmiany + 1 reguły. + +--- + +## SKONSOLIDOWANY BACKLOG (priorytety) + +### A. Read-side publiczny `Rekord` (R3) — NOWY WĄTEK, najwyższy priorytet +5 widoków (Audyt 3/4b): multiwyszukiwarka, raport-uczelnia, ranking, browse lata/rok, +OAI. Najwidoczniejszy przeciek. Wspólny helper `scope_rekord_do_uczelni` + filtr +`autorzy__jednostka__uczelnia` + `skupia_pracownikow` + `distinct`. Każdy ma już +`get_for_request` w zasięgu. → brainstorm→spec→plan→subagent. + +### B. Drobne, gotowe-do-zrobienia (małe, lokalne) +- `powiazania_autorow/queries.py:_pbn_root()` → `get_for_request` (Audyt 1). +- `zbieraj_sloty` CLI + `Autor.zbieraj_sloty` → `uczelnia_id` (LUKA R1, Audyt 4b). +- Test na deltę R2 integratora (`uczelnia=None`→`None`) + docstringi (Audyt 4d). +- Oznaczyć stary self-review write-side jako SUPERSEDED; dopisać notkę HST do spec + write-side (Audyt 4a). +- (opcjonalnie) dopisać do spec R1, że ownership API ≈ uczelnia. + +### C. Federacja olana — ale z bugami korupcji danych +`OptimizationRun.delete()` / `reset_pins` / `optimize_and_unpin` / +komparatory PBN globalne `.delete()` (Audyt 2). DECYZJA: minimalny scope-fix +delete'ów teraz (chroni przed kasowaniem cudzych danych), czy całość z federacją +później? Logika decyzyjna federacyjna pozostaje olana — ale delete cross-uczelnia +to inna kategoria (integralność, nie optymalizacja). + +### D. `ewaluacja_metryki` per-uczelnia — OSOBNY WĄTEK (write+read) +`MetrykaAutora` bez FK `uczelnia` + 5 globalnych `IloscUdzialow…objects.all()` +(Audyt 2/4c). Wymaga migracji (FK + backfill jak 0009/0425) + scope delete/generate. +Analogiczny kształt do liczba_n R2. + +--- + +## Świadomie OLANE (nie ruszać bez osobnej decyzji) +Federacja optymalizacji jako *logika decyzyjna* (`ewaluacja_optymalizacja` dobór +przypięć/dyscyplin ponad partycjonowanym cache, `ewaluacja_optymalizuj_publikacje`). +UWAGA: to NIE to samo co bugi korupcji danych z backlogu C — te ostatnie warto +rozważyć niezależnie. diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index 6372c4f99..6cc4fb980 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -222,6 +222,49 @@ Backlog hardeningu (C): #2/#3 → R1; #1 (HST globalnie) → F (federacja). --- +## AUDYTY 4× (2026-06-03) — wyniki + nowy backlog + +Pełny raport: `docs/superpowers/2026-06-03-audyty-multihosted-4x.md`. +Cztery audyty po domknięciu głównych wątków (1–3 rozpoznawcze równolegle, 4 +self-review vs spec per obszar). Wszystkie read-only. **Nic nie implementowano.** + +**Najważniejsze (zbieżność Audytu 3 + 4b):** publiczne widoki czytające `Rekord` +WPROST przeciekają międzyuczelniano. R1 objął tylko cache slotów; te widoki są +poza zakresem R1 i NIE odnotowane jako wyłączone → kandydat na wątek **R3**. + +### Nowy backlog (priorytety) +- **A) R3 read-side publiczny `Rekord`** (najwyższy): 5 widoków — + multiwyszukiwarka (`bpp/views/mymultiseek.py:37` → `multiseek_registry/__init__.py:32`), + raport „cała uczelnia" (`nowe_raporty/poziomy.py:39` — `obiekt` ignorowany), + ranking (`ranking_autorow/views.py:265`), browse lata/rok (`bpp/views/browse.py:491-556`), + OAI (`bpp/views/oai.py:243`). Fix: helper `scope_rekord_do_uczelni` + + `autorzy__jednostka__uczelnia` + `skupia_pracownikow` + `distinct`. Każdy ma już + `get_for_request`. → osobny spec. +- **B) Drobne gotowe:** `powiazania_autorow/queries.py:_pbn_root()` → + `get_for_request` (jedyny realny dług z whitelisty get_default, Audyt 1); + **LUKA R1:** komenda `zbieraj_sloty` CLI + `Autor.zbieraj_sloty` nie przekazują + `uczelnia_id` (spec wymieniał dwukrotnie); test delty R2 integratora + (`uczelnia=None`→`None`) + docstringi; oznaczyć stary self-review write-side + SUPERSEDED + notka HST do spec write-side. +- **C) Federacja olana, ale bugi KORUPCJI DANYCH** (decyzja do podjęcia): + `OptimizationRun.delete()` cross-uczelnia (`ewaluacja_optymalizacja/tasks/optimization.py:73`), + `reset_all_pins_task`/`optimize_and_unpin` globalne querysety, komparatory PBN + globalny `.delete()`. To integralność, nie logika federacyjna — można scope-fix + niezależnie. +- **D) `ewaluacja_metryki` per-uczelnia** (osobny wątek write+read): `MetrykaAutora` + bez FK uczelnia + 5× globalne `IloscUdzialow…objects.all()`. Kształt jak liczba_n R2. + +### Stan zgodności ze spec (Audyt 4) +- Write-side sloty: ✓ 31/31 (1 świadomy korzystny rozjazd — HST per-uczelnia). +- Read-side R1: ✓ 18/19 (LUKA: `zbieraj_sloty` CLI; rozjazd: API per-owner). +- liczba_n R2: ✓ 24/24 (oba minory follow-up domknięte; metryki poza zakresem → D). +- Integrator: ✓ 16/16 (2 nieblokujące: test delty R2, docstring). + +### Guard get_default: nadal szczelny +10 wpisów whitelisty, 9 ZOSTAJE (świadome), 1 DO ZROBIENIA (`powiazania_autorow` → B). + +--- + ## KOMENDY (dla agenta) - Testy: `uv run pytest <ścieżka> -q -p no:cacheprovider` (testcontainers same stawiają PG/Redis; Docker musi działać). From 00e3dd4d5d91bbbab632f516a7c0a7d8e5b5d8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 20:55:55 +0200 Subject: [PATCH 125/247] docs(multi-hosted): specy R3a (widoki Rekord/Sumy) + R3b (publiczne autocomplety) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3a: helper scope_rekord_do_uczelni z guardem tylko_jedna_uczelnia (short-circuit single-install); reguła rekordowa = homepage (autorzy__jednostka__uczelnia, bez skupia_pracownikow); raport-uczelnia/browse/OAI helperem, ranking po aktualna_jednostka__uczelnia; multiseek wyniki niefiltrowane (decyzja usera). R3b: 3 publiczne autocomplety zawężone w miejscu (jednostka/wydzial FK uczelnia, autor = autor_jednostka__jednostka__uczelnia 'kiedykolwiek związany'); admin nietknięty; dwie semantyki autora (zatrudniony vs związany) udokumentowane. Co-Authored-By: Claude Opus 4.8 --- ...3-r3a-read-side-publiczny-widoki-design.md | 117 ++++++++++++++++++ ...-publiczne-autocomplety-uczelnia-design.md | 84 +++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-r3a-read-side-publiczny-widoki-design.md create mode 100644 docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md diff --git a/docs/superpowers/specs/2026-06-03-r3a-read-side-publiczny-widoki-design.md b/docs/superpowers/specs/2026-06-03-r3a-read-side-publiczny-widoki-design.md new file mode 100644 index 000000000..96db16ada --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-r3a-read-side-publiczny-widoki-design.md @@ -0,0 +1,117 @@ +# R3a — read-side publiczny: widoki listujące Rekord/Sumy per-uczelnia + +Spec. Gałąź `feature/multi-hosted-config`. Data 2026-06-03. +Powiązane: `docs/superpowers/2026-06-03-audyty-multihosted-4x.md` (Audyt 3/4b), +`docs/superpowers/specs/2026-06-03-per-uczelnia-sloty-read-side-design.md` (R1). +Para: R3b (publiczne autocomplety) — osobny spec. + +## Problem + +R1 zawęził po uczelni *cache slotów* (`Cache_Punktacja_*`). Publiczne widoki +czytające `Rekord`/`Sumy` **wprost** nadal agregują międzyuczelniano — na domenie +uczelni B użytkownik widzi rekordy/autorów uczelni A. Strona główna +(`get_uczelnia_context_data`, `bpp/views/browse.py:86`) JUŻ scopuje, ale pięć +innych publicznych ścieżek nie. To niespójność widoczna dla użytkownika. + +## Reguła atrybucji (decyzja usera) + +Dwie świadomie różne reguły — różne kategorie danych: + +1. **Rekordy** (raport-uczelnia, browse, OAI): rekord należy do uczelni U jeśli + którakolwiek z jednostek zapisanych na autorstwie należy do U: + `Rekord …filter(autorzy__jednostka__uczelnia=U).distinct()`. + **BEZ `skupia_pracownikow`** — identyczna z regułą strony głównej + (`uczelnia.jednostka_set.all()` → `autorzy__jednostka__in`). Włącza obcą + jednostkę uczelni. To „jedna z jednostek do których autor jest przypisany". +2. **Ranking** (`Sumy`): autor należy do rankingu uczelni U jeśli jest TAM + **aktualnie zatrudniony**: `autor__aktualna_jednostka__uczelnia=U`. Ranking to + lista obecnych pracowników, więc semantyka „aktualny pracownik", nie + „kiedykolwiek związany". + +## Helper współdzielony + +Nowy moduł `src/bpp/util/uczelnia_scope.py` (lub `bpp/models/cache/` jeśli +import-cykl) — jedno źródło reguły rekordowej + guard single-install: + +```python +def tylko_jedna_uczelnia() -> bool: + # fast-track jak IPunktacjaCacher._uczelnie_do_przeliczenia + return Uczelnia.objects.count() == 1 + +def scope_rekord_do_uczelni(qs, uczelnia): + """Zawęź queryset Rekordów do uczelni oglądającego. + + No-op gdy brak uczelni (brak mapowania Site→Uczelnia) albo gdy w systemie + jest dokładnie jedna uczelnia (wynik identyczny — pomijamy kosztowny JOIN + przez M2M autorzy + DISTINCT). + """ + if uczelnia is None or tylko_jedna_uczelnia(): + return qs + return qs.filter(autorzy__jednostka__uczelnia=uczelnia).distinct() +``` + +**Wydajność (obawa usera):** na single-install filtr byłby no-op, ale JOIN+DISTINCT +na `Rekord` (100k+) kosztuje. `tylko_jedna_uczelnia()` short-circuituje → zero +narzutu, wynik identyczny. `count()` na małej tabeli `bpp_uczelnia` jest tani; +zcache'ować tylko jeśli profiling pokaże potrzebę. + +Ranking nie używa tego helpera (inny model `Sumy`, lookup +`autor__aktualna_jednostka__uczelnia`), ale **używa tego samego guardu** +`tylko_jedna_uczelnia()` przed dołożeniem filtra. + +## Zmiany w widokach + +### 1. Raport „cała uczelnia" — `nowe_raporty/poziomy.py:_base_uczelnia` +Dziś `Rekord.objects.all()` / `.filter(autorzy__afiliuje=True)`, ignoruje +przekazany `obiekt` (=Uczelnia z `views.py:284`). Fix: `scope_rekord_do_uczelni(qs, obiekt)` +zachowując gałąź `afiliuje`. To najbardziej rażący przypadek (jawnie „raport TEJ +uczelni", a zwraca wszystkie). + +### 2. Browse lata/rok — `bpp/views/browse.py` `LataView`, `RokView` +`LataView` (`:491-498,502`): lista lat (`values("rok").annotate(count)`) + `count()`. +`RokView` (`:526-556`): `filter(rok=year)` + nawigacja prev/next + `total_count`. +Owinąć **każde** zapytanie `Rekord` helperem (uczelnia z `get_for_request(request)`). +Count liczony na distinct rekordach. + +### 3. OAI-PMH — `bpp/views/oai.py` `OAIView` +Bazowy `Rekord.objects.all().exclude(...)` (`:243-247`); `uczelnia` już rozwiązana +w `:187`. Zawęzić bazowy qs helperem PRZED przekazaniem do `BPPOAIDatabase`. +Harvester domeny A nie pobiera rekordów B. + +### 4. Ranking autorów — `ranking_autorow/views.py` `_apply_location_filters` +Dziś filtr `jednostka__uczelnia` aplikowany TYLKO gdy user ręcznie wybierze +jednostkę/wydział (`:265-291`). Fix: **bezwarunkowo** (gdy `not tylko_jedna_uczelnia()`) +`qset.filter(autor__aktualna_jednostka__uczelnia=U)`. Istniejące zachowanie: +`exclude(autor__aktualna_jednostka=None)` (`:255`) zostaje; toggle +`tylko_afiliowane` (`jednostka__skupia_pracownikow=True, afiliuje=True`) bez zmian. + +### 5. Multiwyszukiwarka — BEZ ZMIAN wyników (decyzja usera) +`mymultiseek.py` nie filtruje bazowego querysetu. Zawężenie realizują publiczne +autocomplety (spec R3b) — pickery jednostka/wydział/autor ograniczone do uczelni. +Świadomy kompromis: wyszukiwanie bez kryterium jednostki (tytuł/rok) zwróci +rekordy wszystkich uczelni. Multiseek zachowuje charakter „globalny". + +## Niezmienniki i przypadki brzegowe + +- **Single-install:** `tylko_jedna_uczelnia()` → wszystkie helpery no-op, wyniki + i wydajność identyczne jak dziś. Testy regresyjne `<=` muszą przejść. +- **`uczelnia is None`** (brak mapowania Site→Uczelnia, np. CLI render): helper + zwraca qs bez zmian — zachowuje obecne zachowanie, zero wyjątków. +- **`.distinct()`** obowiązkowy (JOIN przez `autorzy` mnoży wiersze). + +## Testy + +Per widok, 2 uczelnie (A, B) z rozłącznymi jednostkami/autorami/rekordami: +- raport-uczelnia: raport poziomu uczelnia A nie zawiera rekordów B. +- browse lata/rok: lista lat i prac na domenie A liczona tylko z rekordów A; + count zgodny. +- OAI: feed domeny A nie zawiera identyfikatorów rekordów B. +- ranking: domena A listuje tylko autorów z `aktualna_jednostka` w A. +- **Invariant single-install:** przy 1 uczelni każdy widok zwraca to samo co + przed zmianą (guard no-op) — test, że liczby/listy bez zmian. + +## Poza zakresem +- Multiseek result-filter (świadomie wyłączony, patrz #5). +- Publiczne autocomplety (spec R3b). +- API REST `api_v1/viewsets/*` (maszynowe, osobny temat). +- Federacja optymalizacji (świadomie olana). diff --git a/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md b/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md new file mode 100644 index 000000000..97acb1384 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md @@ -0,0 +1,84 @@ +# R3b — publiczne autocomplety zawężone per-uczelnia + +Spec. Gałąź `feature/multi-hosted-config`. Data 2026-06-03. +Powiązane: `docs/superpowers/2026-06-03-audyty-multihosted-4x.md`, R3a (widoki). +Para: R3a — widoki listujące. Ten spec realizuje „limitowanie dropdownów" w +publicznych formularzach (Multiseek, ranking). + +## Problem + +Publiczne autocomplety jednostka/wydział/autor zwracają byty WSZYSTKICH uczelni. +Na domenie uczelni B użytkownik w Multiseeku/rankingu podpowiada sobie jednostki, +wydziały i autorów uczelni A. To jest też wybrany przez usera mechanizm zawężania +multiwyszukiwarki (zamiast filtrowania wyników — patrz R3a #5): jeśli pickery są +ograniczone do uczelni, typowe wyszukiwanie „autor + jednostka" samo się limituje. + +## Klucz: zawężamy TYLKO klasy używane publicznie + +Warianty publiczne są ODRĘBNE od adminowych/edytorskich — admin zachowuje pełen +dostęp. Klasy faktycznie używane przez publiczne pola (zweryfikowane w URL-ach +i `multiseek_registry/fields/`): + +| byt | klasa (plik) | używana przez | współdzielona z edytorem? | +|---|---|---|---| +| jednostka | `WidocznaJednostkaAutocomplete` (`autocomplete/units.py:26`) | pole jednostki Multiseek (`unit_fields.py:46,84` → `jednostka-widoczna-autocomplete`) | **NIE** (tylko Multiseek) | +| wydział | `PublicWydzialAutocomplete` (`autocomplete/simple.py:199`) | `public-wydzial-autocomplete` (`unit_fields.py:158`) | NIE (publiczny) | +| autor | `PublicAutorAutocomplete` (`autocomplete/authors.py:182`) | `public-autor-autocomplete` (`author_fields.py:70`) | NIE (publiczny) | + +Każdą zawężamy **w miejscu** w `get_queryset` — bez nowych klas/URL-i, bez ryzyka +dla formularzy redakcyjnych (admin używa innych: `JednostkaAutocomplete`, +`WydzialAutocomplete`, `AutorAutocomplete`). + +## Reguły zawężania (z guardem single-install) + +Wszystkie używają `uczelnia = Uczelnia.objects.get_for_request(self.request)` +(dostępny `self.request`) oraz wspólnego guardu `tylko_jedna_uczelnia()` z R3a +(`bpp/util/uczelnia_scope.py`). No-op gdy `uczelnia is None` lub jedna uczelnia. + +### Jednostka — `WidocznaJednostkaAutocomplete` +`uczelnia.jednostka_set` / bezpośredni FK: `qs.filter(uczelnia=U)`. +Tani filtr po indeksowanym FK; guard daje zerowy narzut na single-install +(autocomplete strzela przy każdym znaku). + +### Wydział — `PublicWydzialAutocomplete` +`Wydzial.uczelnia` to bezpośredni FK (`wydzial.py:24`): `qs.filter(uczelnia=U)`. + +### Autor — `PublicAutorAutocomplete` (semantyka „kiedykolwiek związany") +Decyzja usera: w Multiseeku szukamy autora **obecnie LUB w przeszłości** +związanego z uczelnią X — czyli mającego dowolną jednostkę w historii należącą +do U. Lookup przez `Autor_Jednostka` (reverse `autor_jednostka`): +```python +qs.filter(autor_jednostka__jednostka__uczelnia=U).distinct() +``` +**NIE** `aktualna_jednostka__uczelnia` — to byłaby semantyka „aktualnie +zatrudniony", potrzebna gdzie indziej (np. ranking w R3a), ale nie tu. + +### Dwie semantyki autora (do utrwalenia w kodzie/komentarzu) +| semantyka | lookup | gdzie | +|---|---|---| +| aktualnie zatrudniony | `aktualna_jednostka__uczelnia=U` | ranking (R3a), miejsca „nasz pracownik" | +| kiedykolwiek związany | `autor_jednostka__jednostka__uczelnia=U` `.distinct()` | PublicAutorAutocomplete (ten spec) | + +## Niezmienniki i przypadki brzegowe +- **Single-install:** guard `tylko_jedna_uczelnia()` → wszystkie trzy autocomplety + bez filtra, podpowiedzi i wydajność identyczne jak dziś. +- **`uczelnia is None`:** brak filtra (zachowanie obecne). +- `.distinct()` dla autora (join przez `autor_jednostka` mnoży wiersze). +- Filtry NIE dotykają warstwy admin/edytor (inne klasy). + +## Testy +- 2 uczelnie, rozłączne jednostki/wydziały/autorzy: + - jednostka autocomplete na domenie A podpowiada tylko jednostki A. + - wydział analogicznie. + - autor: podpowiada autora związanego z A (historyczna jednostka w A) nawet + jeśli `aktualna_jednostka` jest gdzie indziej / None; NIE podpowiada autora + związanego tylko z B. +- **Invariant single-install:** przy 1 uczelni wszystkie trzy autocomplety + zwracają to samo co przed zmianą (guard no-op). +- Regresja: admin autocomplety (`JednostkaAutocomplete` etc.) nietknięte — + szybki test/asercja że nie filtrują po uczelni. + +## Poza zakresem +- Filtrowanie wyników Multiseeka (świadomie — R3a #5). +- Inne autocomplety nie-uczelniane (źródło, wydawca, konferencja, tag). +- Federacja optymalizacji (olana). From 7a00a766b3b75fd0cc8bcd4f1727cbc1a0b9d716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 21:14:18 +0200 Subject: [PATCH 126/247] feat(multi-hosted): helper scope_rekord_do_uczelni + guard single-install (R3a) Co-Authored-By: Claude Opus 4.8 --- .../tests/test_util/test_uczelnia_scope.py | 50 +++++++++++++++++++ src/bpp/util/uczelnia_scope.py | 31 ++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/bpp/tests/test_util/test_uczelnia_scope.py create mode 100644 src/bpp/util/uczelnia_scope.py diff --git a/src/bpp/tests/test_util/test_uczelnia_scope.py b/src/bpp/tests/test_util/test_uczelnia_scope.py new file mode 100644 index 000000000..3df614ae1 --- /dev/null +++ b/src/bpp/tests/test_util/test_uczelnia_scope.py @@ -0,0 +1,50 @@ +import pytest +from model_bakery import baker + +from bpp.models import Rekord +from bpp.util.uczelnia_scope import scope_rekord_do_uczelni, tylko_jedna_uczelnia + + +@pytest.mark.django_db +def test_tylko_jedna_uczelnia_true_dla_jednej(uczelnia1): + assert tylko_jedna_uczelnia() is True + + +@pytest.mark.django_db +def test_tylko_jedna_uczelnia_false_dla_dwoch(uczelnia1, uczelnia2): + assert tylko_jedna_uczelnia() is False + + +@pytest.mark.django_db +def test_scope_none_uczelnia_zwraca_qs_bez_zmian(uczelnia1): + qs = Rekord.objects.all() + assert scope_rekord_do_uczelni(qs, None) is qs + + +@pytest.mark.django_db +def test_scope_single_install_short_circuit(uczelnia1): + qs = Rekord.objects.all() + assert scope_rekord_do_uczelni(qs, uczelnia1) is qs + + +@pytest.mark.django_db +def test_scope_dwie_uczelnie_filtruje( + uczelnia1, + uczelnia2, + jednostka_uczelnia1, + jednostka_uczelnia2, + autor_uczelnia1, + autor_uczelnia2, +): + w1 = baker.make("bpp.Wydawnictwo_Ciagle", tytul_oryginalny="MOJA") + w1.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1) + w2 = baker.make("bpp.Wydawnictwo_Ciagle", tytul_oryginalny="OBCA") + w2.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + + tytuly = set( + scope_rekord_do_uczelni(Rekord.objects.all(), uczelnia1).values_list( + "tytul_oryginalny", flat=True + ) + ) + assert "MOJA" in tytuly + assert "OBCA" not in tytuly diff --git a/src/bpp/util/uczelnia_scope.py b/src/bpp/util/uczelnia_scope.py new file mode 100644 index 000000000..7daf3c2cc --- /dev/null +++ b/src/bpp/util/uczelnia_scope.py @@ -0,0 +1,31 @@ +"""Zawężanie querysetów Rekordów do uczelni oglądającego (multi-hosted, read-side). + +Jedno źródło reguły rekordowej + guard single-install. Reguła atrybucji = +strona główna (``get_uczelnia_context_data``): rekord należy do uczelni, gdy +którakolwiek z jednostek zapisanych na autorstwie należy do tej uczelni. +BEZ ``skupia_pracownikow`` (włącznie z obcą jednostką) — świadoma decyzja +(spec R3a). +""" + + +def tylko_jedna_uczelnia() -> bool: + """True, gdy w systemie jest dokładnie jedna uczelnia. + + Fast-track jak ``IPunktacjaCacher._uczelnie_do_przeliczenia``: przy jednej + uczelni filtr per-uczelnia jest no-op, więc pomijamy go (i kosztowny JOIN). + """ + from bpp.models import Uczelnia + + return Uczelnia.objects.count() == 1 + + +def scope_rekord_do_uczelni(qs, uczelnia): + """Zawęź queryset ``Rekord`` do uczelni oglądającego. + + No-op (zwraca ten sam qs) gdy brak uczelni (brak mapowania Site→Uczelnia) + albo gdy w systemie jest dokładnie jedna uczelnia — wynik identyczny, + a unikamy JOIN-a przez M2M ``autorzy`` + ``DISTINCT``. + """ + if uczelnia is None or tylko_jedna_uczelnia(): + return qs + return qs.filter(autorzy__jednostka__uczelnia=uczelnia).distinct() From caa012c2e772a88855c14ba56f3050fbc7724284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 21:20:55 +0200 Subject: [PATCH 127/247] =?UTF-8?q?feat(multi-hosted):=20raport=20poziom-u?= =?UTF-8?q?czelnia=20zaw=C4=99=C5=BCony=20per=20uczelnia=20(R3a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/nowe_raporty/poziomy.py | 7 +++-- .../test_poziom_uczelnia_per_uczelnia.py | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 src/nowe_raporty/tests/test_poziom_uczelnia_per_uczelnia.py diff --git a/src/nowe_raporty/poziomy.py b/src/nowe_raporty/poziomy.py index b4b0afd7d..52e0c9d7b 100644 --- a/src/nowe_raporty/poziomy.py +++ b/src/nowe_raporty/poziomy.py @@ -14,6 +14,7 @@ from bpp.models.autor import Autor from bpp.models.cache import Rekord from bpp.models.struktura import Jednostka, Wydzial +from bpp.util.uczelnia_scope import scope_rekord_do_uczelni from .models import DefinicjaRaportu @@ -38,8 +39,10 @@ def _base_wydzial(obiekt, tylko_afiliowane): def _base_uczelnia(obiekt, tylko_afiliowane): if tylko_afiliowane: - return Rekord.objects.filter(autorzy__afiliuje=True) - return Rekord.objects.all() + qs = Rekord.objects.filter(autorzy__afiliuje=True) + else: + qs = Rekord.objects.all() + return scope_rekord_do_uczelni(qs, obiekt) def _pole(label, model, url): diff --git a/src/nowe_raporty/tests/test_poziom_uczelnia_per_uczelnia.py b/src/nowe_raporty/tests/test_poziom_uczelnia_per_uczelnia.py new file mode 100644 index 000000000..0dfadf43a --- /dev/null +++ b/src/nowe_raporty/tests/test_poziom_uczelnia_per_uczelnia.py @@ -0,0 +1,27 @@ +import pytest +from model_bakery import baker + +from nowe_raporty.poziomy import _base_uczelnia + + +@pytest.mark.django_db +def test_base_uczelnia_wyklucza_obca_uczelnie( + uczelnia1, + uczelnia2, + jednostka_uczelnia1, + jednostka_uczelnia2, + autor_uczelnia1, + autor_uczelnia2, +): + w1 = baker.make("bpp.Wydawnictwo_Ciagle", tytul_oryginalny="MOJA") + w1.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1) + w2 = baker.make("bpp.Wydawnictwo_Ciagle", tytul_oryginalny="OBCA") + w2.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + + tytuly = set( + _base_uczelnia(uczelnia1, tylko_afiliowane=False).values_list( + "tytul_oryginalny", flat=True + ) + ) + assert "MOJA" in tytuly + assert "OBCA" not in tytuly From 75de645df040bc6e69ef3acd38b686cd96af32f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 21:26:51 +0200 Subject: [PATCH 128/247] =?UTF-8?q?feat(multi-hosted):=20browse=20lata/rok?= =?UTF-8?q?=20zaw=C4=99=C5=BCone=20per=20uczelnia=20(R3a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../test_browse/test_lata_rok_per_uczelnia.py | 60 +++++++++++++++++++ src/bpp/views/browse.py | 26 ++++++-- 2 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 src/bpp/tests/test_views/test_browse/test_lata_rok_per_uczelnia.py diff --git a/src/bpp/tests/test_views/test_browse/test_lata_rok_per_uczelnia.py b/src/bpp/tests/test_views/test_browse/test_lata_rok_per_uczelnia.py new file mode 100644 index 000000000..dacbc26bd --- /dev/null +++ b/src/bpp/tests/test_views/test_browse/test_lata_rok_per_uczelnia.py @@ -0,0 +1,60 @@ +import pytest +from model_bakery import baker + +from bpp.views.browse import LataView, RokView +from fixtures.conftest_multisite import make_request_for_site + + +def _dwie_prace( + jednostka_uczelnia1, jednostka_uczelnia2, autor_uczelnia1, autor_uczelnia2 +): + w1 = baker.make("bpp.Wydawnictwo_Ciagle", rok=2020) + w1.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1) + w2 = baker.make("bpp.Wydawnictwo_Ciagle", rok=2021) + w2.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + + +@pytest.mark.django_db +def test_lata_view_liczy_tylko_swoja_uczelnie( + uczelnia1, + uczelnia2, + site1, + jednostka_uczelnia1, + jednostka_uczelnia2, + autor_uczelnia1, + autor_uczelnia2, + settings, +): + settings.ALLOWED_HOSTS = ["*"] + _dwie_prace( + jednostka_uczelnia1, jednostka_uczelnia2, autor_uczelnia1, autor_uczelnia2 + ) + view = LataView() + view.request = make_request_for_site(site1) + view.kwargs = {} + lata = {y["year"] for y in view.get_queryset()} + assert 2020 in lata + assert 2021 not in lata + + +@pytest.mark.django_db +def test_rok_view_listuje_tylko_swoja_uczelnie( + uczelnia1, + uczelnia2, + site1, + jednostka_uczelnia1, + jednostka_uczelnia2, + autor_uczelnia1, + autor_uczelnia2, + settings, +): + settings.ALLOWED_HOSTS = ["*"] + _dwie_prace( + jednostka_uczelnia1, jednostka_uczelnia2, autor_uczelnia1, autor_uczelnia2 + ) + view = RokView() + view.request = make_request_for_site(site1) + view.kwargs = {"rok": 2021} + view.object_list = view.get_queryset() + ctx = view.get_context_data() + assert ctx["total_count"] == 0 # praca 2021 należy do uczelni2 diff --git a/src/bpp/views/browse.py b/src/bpp/views/browse.py index ed41cba7d..3b28cb1ca 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -43,6 +43,7 @@ ZakresLatQueryObject, ZrodloQueryObject, ) +from bpp.util.uczelnia_scope import scope_rekord_do_uczelni logger = logging.getLogger(__name__) @@ -489,9 +490,11 @@ class LataView(ListView): paginate_by = None def get_queryset(self): + uczelnia = Uczelnia.objects.get_for_request(self.request) + qs = scope_rekord_do_uczelni(Rekord.objects.all(), uczelnia) return [ {"year": row["rok"], "count": row["count"]} - for row in Rekord.objects.values("rok") + for row in qs.values("rok") .annotate(count=Count("*")) .filter(count__gt=0) .order_by("-rok") @@ -499,7 +502,10 @@ def get_queryset(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["total_publications"] = Rekord.objects.count() + uczelnia = Uczelnia.objects.get_for_request(self.request) + context["total_publications"] = scope_rekord_do_uczelni( + Rekord.objects.all(), uczelnia + ).count() # Add current year for reference context["current_year"] = timezone.now().year @@ -535,7 +541,10 @@ def get_queryset(self): raise Http404("Nieprawidłowy rok") from e # Get publications for this year - return Rekord.objects.filter(rok=year).order_by("-ostatnio_zmieniony") + uczelnia = Uczelnia.objects.get_for_request(self.request) + return scope_rekord_do_uczelni( + Rekord.objects.filter(rok=year), uczelnia + ).order_by("-ostatnio_zmieniony") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -546,14 +555,19 @@ def get_context_data(self, **kwargs): context["prev_year"] = None context["next_year"] = None - if Rekord.objects.filter(rok=year - 1).exists(): + uczelnia = Uczelnia.objects.get_for_request(self.request) + + def _scoped(qs): + return scope_rekord_do_uczelni(qs, uczelnia) + + if _scoped(Rekord.objects.filter(rok=year - 1)).exists(): context["prev_year"] = year - 1 - if Rekord.objects.filter(rok=year + 1).exists(): + if _scoped(Rekord.objects.filter(rok=year + 1)).exists(): context["next_year"] = year + 1 # Get total count for the year - context["total_count"] = Rekord.objects.filter(rok=year).count() + context["total_count"] = _scoped(Rekord.objects.filter(rok=year)).count() return context From 27e92ebc9f654396cc313bca24323e2f090edc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 21:37:36 +0200 Subject: [PATCH 129/247] =?UTF-8?q?feat(multi-hosted):=20OAI=20feed=20zaw?= =?UTF-8?q?=C4=99=C5=BCony=20per=20uczelnia=20(R3a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../tests/test_views/test_oai_per_uczelnia.py | 38 +++++++++++++++++++ src/bpp/views/oai.py | 7 +++- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/bpp/tests/test_views/test_oai_per_uczelnia.py diff --git a/src/bpp/tests/test_views/test_oai_per_uczelnia.py b/src/bpp/tests/test_views/test_oai_per_uczelnia.py new file mode 100644 index 000000000..848c01333 --- /dev/null +++ b/src/bpp/tests/test_views/test_oai_per_uczelnia.py @@ -0,0 +1,38 @@ +import pytest +from django.urls import reverse +from model_bakery import baker + + +@pytest.mark.django_db +def test_oai_listrecords_wyklucza_obca_uczelnie( + uczelnia1, + uczelnia2, + site1, + jednostka_uczelnia1, + jednostka_uczelnia2, + autor_uczelnia1, + autor_uczelnia2, + client, + settings, +): + settings.ALLOWED_HOSTS = ["*"] # pozwól na HTTP_HOST domeny uczelni + chf = baker.make("bpp.Charakter_Formalny", skrot="OAI-TST", nazwa_w_primo="Artykuł") + w1 = baker.make( + "bpp.Wydawnictwo_Ciagle", + tytul_oryginalny="PRACA-MOJA", + rok=2020, + charakter_formalny=chf, + ) + w1.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1) + w2 = baker.make( + "bpp.Wydawnictwo_Ciagle", + tytul_oryginalny="PRACA-OBCA", + rok=2020, + charakter_formalny=chf, + ) + w2.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + + url = reverse("bpp:oai") + "?verb=ListRecords&metadataPrefix=oai_dc" + body = client.get(url, HTTP_HOST=site1.domain).content.decode("utf-8") + assert "PRACA-MOJA" in body + assert "PRACA-OBCA" not in body diff --git a/src/bpp/views/oai.py b/src/bpp/views/oai.py index 2ca19b93e..66e6f5935 100644 --- a/src/bpp/views/oai.py +++ b/src/bpp/views/oai.py @@ -16,6 +16,7 @@ from moai.server import FeedConfig from bpp.models import Rekord, Uczelnia +from bpp.util.uczelnia_scope import scope_rekord_do_uczelni class CacheMetadata: @@ -240,10 +241,12 @@ def get(self, request, *args, **kwargs): url = "/".join(urlparts) - db = BPPOAIDatabase( + uczelnia = Uczelnia.objects.get_for_request(request) + base_qs = scope_rekord_do_uczelni( Rekord.objects.all().exclude(charakter_formalny__nazwa_w_primo=""), - request=request, + uczelnia, ) + db = BPPOAIDatabase(base_qs, request=request) oai_server = OAIServerFactory(db, FeedConfig("bpp", base_url)) return HttpResponse( content=oai_server.handleRequest(request.GET), From 0800073aa55c46af1cbbdc6ddd67d5cde5607266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 21:45:49 +0200 Subject: [PATCH 130/247] =?UTF-8?q?feat(multi-hosted):=20ranking=20autor?= =?UTF-8?q?=C3=B3w=20zaw=C4=99=C5=BCony=20po=20aktualnej=20jednostce=20ucz?= =?UTF-8?q?elni=20(R3a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../test_ranking_per_uczelnia.py | 30 +++++++++++++++++++ src/ranking_autorow/views.py | 6 ++++ 2 files changed, 36 insertions(+) create mode 100644 src/ranking_autorow/test_ranking_per_uczelnia.py diff --git a/src/ranking_autorow/test_ranking_per_uczelnia.py b/src/ranking_autorow/test_ranking_per_uczelnia.py new file mode 100644 index 000000000..c2cfe74d8 --- /dev/null +++ b/src/ranking_autorow/test_ranking_per_uczelnia.py @@ -0,0 +1,30 @@ +import pytest +from model_bakery import baker + +from fixtures.conftest_multisite import make_request_for_site +from ranking_autorow.views import RankingAutorow + + +@pytest.mark.django_db +def test_ranking_listuje_tylko_aktualnych_pracownikow_uczelni( + settings, + uczelnia1, + uczelnia2, + site1, + jednostka_uczelnia1, + jednostka_uczelnia2, + autor_uczelnia1, + autor_uczelnia2, +): + settings.ALLOWED_HOSTS = ["*"] + w1 = baker.make("bpp.Wydawnictwo_Ciagle", impact_factor=10, rok=2020) + w1.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1) + w2 = baker.make("bpp.Wydawnictwo_Ciagle", impact_factor=10, rok=2020) + w2.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + + r = RankingAutorow( + request=make_request_for_site(site1), kwargs=dict(od_roku=0, do_roku=3030) + ) + autorzy = {row.autor.pk for row in r.get_queryset()} + assert autor_uczelnia1.pk in autorzy + assert autor_uczelnia2.pk not in autorzy diff --git a/src/ranking_autorow/views.py b/src/ranking_autorow/views.py index d8f91d91e..d130889c6 100644 --- a/src/ranking_autorow/views.py +++ b/src/ranking_autorow/views.py @@ -22,6 +22,7 @@ Uczelnia, ) from bpp.models.struktura import Wydzial +from bpp.util.uczelnia_scope import tylko_jedna_uczelnia from .forms import RankingAutorowForm @@ -229,6 +230,11 @@ def _apply_location_filters(self, qset): if wydzialy: qset = qset.filter(jednostka__wydzial__in=wydzialy) + # Multi-hosted: ranking = obecni pracownicy tej uczelni. + # No-op na single-install (guard) — wynik bez zmian. + if uczelnia is not None and not tylko_jedna_uczelnia(): + qset = qset.filter(autor__aktualna_jednostka__uczelnia=uczelnia) + return qset def _apply_type_filters(self, qset): From 24e0b0fa0a45102e10a9d721a69dfae6387b5ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 21:50:56 +0200 Subject: [PATCH 131/247] =?UTF-8?q?docs(multi-hosted):=20HANDOFF=20-=20R3a?= =?UTF-8?q?=20read-side=20widoki=20ZROBIONE,=20R3b=20nast=C4=99pny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/HANDOFF-multi-hosted.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index 6cc4fb980..a212e8fd0 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -233,13 +233,16 @@ WPROST przeciekają międzyuczelniano. R1 objął tylko cache slotów; te widoki poza zakresem R1 i NIE odnotowane jako wyłączone → kandydat na wątek **R3**. ### Nowy backlog (priorytety) -- **A) R3 read-side publiczny `Rekord`** (najwyższy): 5 widoków — - multiwyszukiwarka (`bpp/views/mymultiseek.py:37` → `multiseek_registry/__init__.py:32`), - raport „cała uczelnia" (`nowe_raporty/poziomy.py:39` — `obiekt` ignorowany), - ranking (`ranking_autorow/views.py:265`), browse lata/rok (`bpp/views/browse.py:491-556`), - OAI (`bpp/views/oai.py:243`). Fix: helper `scope_rekord_do_uczelni` + - `autorzy__jednostka__uczelnia` + `skupia_pracownikow` + `distinct`. Każdy ma już - `get_for_request`. → osobny spec. +- **A) R3 read-side publiczny `Rekord`** — podzielone na R3a (widoki) + R3b (autocomplety). + - ✅ **R3a ZROBIONE (2026-06-03):** helper `bpp.util.uczelnia_scope.scope_rekord_do_uczelni` + + guard `tylko_jedna_uczelnia` (short-circuit single-install); raport poziom-uczelnia, + browse lata/rok, OAI feed zawężone (reguła homepage `autorzy__jednostka__uczelnia`); + ranking po `autor__aktualna_jednostka__uczelnia` (obecni pracownicy). Multiseek wyniki + świadomie NIE filtrowane. 6 tasków TDD (subagent-driven, spec+quality review każdy), + pełna regresja zielona, invariant single-install trzyma. Plan + `plans/2026-06-03-r3a-read-side-publiczny-widoki.md`. Niepushowane. + - **R3b (publiczne autocomplety) — NASTĘPNY.** Spec + `specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md`. Zależy od helpera R3a. - **B) Drobne gotowe:** `powiazania_autorow/queries.py:_pbn_root()` → `get_for_request` (jedyny realny dług z whitelisty get_default, Audyt 1); **LUKA R1:** komenda `zbieraj_sloty` CLI + `Autor.zbieraj_sloty` nie przekazują From 056e31d855f74ec64d693e14b7c809cd014086c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 22:07:19 +0200 Subject: [PATCH 132/247] =?UTF-8?q?docs(multi-hosted):=20plan=20implementa?= =?UTF-8?q?cyjny=20R3a=20(6=20task=C3=B3w=20TDD,=20subagent-driven)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...26-06-03-r3a-read-side-publiczny-widoki.md | 597 ++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-r3a-read-side-publiczny-widoki.md diff --git a/docs/superpowers/plans/2026-06-03-r3a-read-side-publiczny-widoki.md b/docs/superpowers/plans/2026-06-03-r3a-read-side-publiczny-widoki.md new file mode 100644 index 000000000..72024adb9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-r3a-read-side-publiczny-widoki.md @@ -0,0 +1,597 @@ +# R3a — read-side publiczny (widoki Rekord/Sumy) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Zawęzić pięć publicznych widoków czytających `Rekord`/`Sumy` do uczelni oglądającego (domena→Site→Uczelnia), bez regresji na single-install. + +**Architecture:** Jeden współdzielony helper `scope_rekord_do_uczelni(qs, uczelnia)` z guardem `tylko_jedna_uczelnia()` (short-circuit single-install, pomija kosztowny JOIN+DISTINCT). Reguła rekordowa = strona główna (`autorzy__jednostka__uczelnia`, bez `skupia_pracownikow`). Ranking osobno (lookup `autor__aktualna_jednostka__uczelnia`, ten sam guard). + +**Tech Stack:** Django, pytest + model_bakery, testcontainers (PG/Redis), `uv run`. + +Spec: `docs/superpowers/specs/2026-06-03-r3a-read-side-publiczny-widoki-design.md`. + +## Infrastruktura testowa (KLUCZOWE — czytaj przed Taskiem 1) + +Multi-site fixtury są zarejestrowane jako pytest plugin (`src/conftest.py` → +`pytest_plugins = [... "fixtures.conftest_multisite"]`), więc dostępne wszędzie. +Plik: `src/fixtures/conftest_multisite.py`. Używaj ich zamiast ręcznego setupu: + +- `uczelnia1`/`uczelnia2` — Uczelnie powiązane z `site1`/`site2` (FK `site`). +- `wydzial_uczelnia1/2`, `jednostka_uczelnia1/2` — struktura per uczelnia + (jednostki mają `uczelnia=...`; **NIE** mają `skupia_pracownikow` ustawionego + jawnie — domyślna wartość modelu; reguła rekordowa i tak go nie wymaga). +- `autor_uczelnia1/2` — autorzy z `aktualna_jednostka=jednostka_uczelniaN` + ORAZ wpisem `Autor_Jednostka` (historia) w tej jednostce. +- `make_request_for_site(site, path="/", user=None)` — tworzy `RequestFactory` + request z `HTTP_HOST=site.domain` i **odpala `SiteResolutionMiddleware`**, więc + `request._uczelnia` jest ustawione. To deterministyczny szew: `get_for_request` + zwróci uczelnię tego site (bez zależności od `SITE_ID`/ALLOWED_HOSTS). + +Wzorzec testu widoku (queryset bez HTTP): +```python +view = LataView() +view.request = make_request_for_site(site1) +view.kwargs = {} +result = view.get_queryset() +``` +Tworzenie pracy w jednostce: `wc = baker.make("bpp.Wydawnictwo_Ciagle", rok=2020); wc.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1)`. +`Rekord` to widok SQL — odzwierciedla pracę natychmiast (brak rebuildu). + +**Reguły wykonawcze (per HANDOFF):** +- Testy: `uv run pytest <ścieżka> -q -p no:cacheprovider` (Docker musi działać). +- Guard get_default: `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q` po każdym tasku. +- Lint: `uv run ruff check ` (NIE `--fix`). +- Commit po każdym tasku. Push dopiero na prośbę usera. + +--- + +### Task 1: Helper `scope_rekord_do_uczelni` + guard `tylko_jedna_uczelnia` + +**Files:** +- Create: `src/bpp/util/uczelnia_scope.py` +- Create: `src/bpp/tests/test_util/__init__.py` (jeśli brak) +- Test: `src/bpp/tests/test_util/test_uczelnia_scope.py` + +- [ ] **Step 1: Write the failing test** + +```python +# src/bpp/tests/test_util/test_uczelnia_scope.py +import pytest +from model_bakery import baker + +from bpp.models import Rekord +from bpp.util.uczelnia_scope import scope_rekord_do_uczelni, tylko_jedna_uczelnia + + +@pytest.mark.django_db +def test_tylko_jedna_uczelnia_true_dla_jednej(uczelnia1): + assert tylko_jedna_uczelnia() is True + + +@pytest.mark.django_db +def test_tylko_jedna_uczelnia_false_dla_dwoch(uczelnia1, uczelnia2): + assert tylko_jedna_uczelnia() is False + + +@pytest.mark.django_db +def test_scope_none_uczelnia_zwraca_qs_bez_zmian(uczelnia1): + qs = Rekord.objects.all() + assert scope_rekord_do_uczelni(qs, None) is qs + + +@pytest.mark.django_db +def test_scope_single_install_short_circuit(uczelnia1): + # jedna uczelnia => guard zwraca ten sam obiekt qs (brak JOIN) + qs = Rekord.objects.all() + assert scope_rekord_do_uczelni(qs, uczelnia1) is qs + + +@pytest.mark.django_db +def test_scope_dwie_uczelnie_filtruje( + uczelnia1, uczelnia2, jednostka_uczelnia1, jednostka_uczelnia2, + autor_uczelnia1, autor_uczelnia2, +): + w1 = baker.make("bpp.Wydawnictwo_Ciagle", tytul_oryginalny="MOJA") + w1.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1) + w2 = baker.make("bpp.Wydawnictwo_Ciagle", tytul_oryginalny="OBCA") + w2.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + + tytuly = set( + scope_rekord_do_uczelni(Rekord.objects.all(), uczelnia1).values_list( + "tytul_oryginalny", flat=True + ) + ) + assert "MOJA" in tytuly + assert "OBCA" not in tytuly +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_util/test_uczelnia_scope.py -q -p no:cacheprovider` +Expected: FAIL (ModuleNotFoundError: `bpp.util.uczelnia_scope`) + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/bpp/util/uczelnia_scope.py +"""Zawężanie querysetów Rekordów do uczelni oglądającego (multi-hosted, read-side). + +Jedno źródło reguły rekordowej + guard single-install. Reguła atrybucji = +strona główna (``get_uczelnia_context_data``): rekord należy do uczelni, gdy +którakolwiek z jednostek zapisanych na autorstwie należy do tej uczelni. +BEZ ``skupia_pracownikow`` (włącznie z obcą jednostką) — świadoma decyzja +(spec R3a). +""" + + +def tylko_jedna_uczelnia() -> bool: + """True, gdy w systemie jest dokładnie jedna uczelnia. + + Fast-track jak ``IPunktacjaCacher._uczelnie_do_przeliczenia``: przy jednej + uczelni filtr per-uczelnia jest no-op, więc pomijamy go (i kosztowny JOIN). + """ + from bpp.models import Uczelnia + + return Uczelnia.objects.count() == 1 + + +def scope_rekord_do_uczelni(qs, uczelnia): + """Zawęź queryset ``Rekord`` do uczelni oglądającego. + + No-op (zwraca ten sam qs) gdy brak uczelni (brak mapowania Site→Uczelnia) + albo gdy w systemie jest dokładnie jedna uczelnia — wynik identyczny, + a unikamy JOIN-a przez M2M ``autorzy`` + ``DISTINCT``. + """ + if uczelnia is None or tylko_jedna_uczelnia(): + return qs + return qs.filter(autorzy__jednostka__uczelnia=uczelnia).distinct() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/bpp/tests/test_util/test_uczelnia_scope.py -q -p no:cacheprovider` +Expected: PASS (5 passed). + +- [ ] **Step 5: Lint + guard + commit** + +```bash +uv run ruff check src/bpp/util/uczelnia_scope.py src/bpp/tests/test_util/test_uczelnia_scope.py +uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q +git add src/bpp/util/uczelnia_scope.py src/bpp/tests/test_util/ +git commit -m "feat(multi-hosted): helper scope_rekord_do_uczelni + guard single-install (R3a)" +``` + +--- + +### Task 2: Raport „cała uczelnia" zawężony (`nowe_raporty/poziomy.py`) + +**Files:** +- Modify: `src/nowe_raporty/poziomy.py` (funkcja `_base_uczelnia` ~`:38-41`; import) +- Test: `src/nowe_raporty/tests/test_poziom_uczelnia_per_uczelnia.py` + +- [ ] **Step 1: Write the failing test** + +```python +# src/nowe_raporty/tests/test_poziom_uczelnia_per_uczelnia.py +import pytest +from model_bakery import baker + +from nowe_raporty.poziomy import _base_uczelnia + + +@pytest.mark.django_db +def test_base_uczelnia_wyklucza_obca_uczelnie( + uczelnia1, uczelnia2, jednostka_uczelnia1, jednostka_uczelnia2, + autor_uczelnia1, autor_uczelnia2, +): + w1 = baker.make("bpp.Wydawnictwo_Ciagle", tytul_oryginalny="MOJA") + w1.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1) + w2 = baker.make("bpp.Wydawnictwo_Ciagle", tytul_oryginalny="OBCA") + w2.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + + tytuly = set( + _base_uczelnia(uczelnia1, tylko_afiliowane=False).values_list( + "tytul_oryginalny", flat=True + ) + ) + assert "MOJA" in tytuly + assert "OBCA" not in tytuly +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/nowe_raporty/tests/test_poziom_uczelnia_per_uczelnia.py -q -p no:cacheprovider` +Expected: FAIL ("OBCA" w wyniku — `_base_uczelnia` zwraca `Rekord.objects.all()`). + +- [ ] **Step 3: Write minimal implementation** + +W `src/nowe_raporty/poziomy.py` dodaj import (przy innych z `bpp`): + +```python +from bpp.util.uczelnia_scope import scope_rekord_do_uczelni +``` + +Zamień `_base_uczelnia`: + +```python +def _base_uczelnia(obiekt, tylko_afiliowane): + if tylko_afiliowane: + qs = Rekord.objects.filter(autorzy__afiliuje=True) + else: + qs = Rekord.objects.all() + return scope_rekord_do_uczelni(qs, obiekt) +``` + +`obiekt` to Uczelnia (z `views.py:284` `get_object` → `get_for_request`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/nowe_raporty/tests/test_poziom_uczelnia_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. Regresja: `uv run pytest src/nowe_raporty/ -q -p no:cacheprovider`. + +- [ ] **Step 5: Lint + guard + commit** + +```bash +uv run ruff check src/nowe_raporty/poziomy.py src/nowe_raporty/tests/test_poziom_uczelnia_per_uczelnia.py +uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q +git add src/nowe_raporty/poziomy.py src/nowe_raporty/tests/test_poziom_uczelnia_per_uczelnia.py +git commit -m "feat(multi-hosted): raport poziom-uczelnia zawężony per uczelnia (R3a)" +``` + +--- + +### Task 3: Browse lata/rok zawężone (`bpp/views/browse.py`) + +**Files:** +- Modify: `src/bpp/views/browse.py` (`LataView` ~`:485-518`, `RokView` ~`:520-556`; importy) +- Test: `src/bpp/tests/test_views/test_browse/test_lata_rok_per_uczelnia.py` + +- [ ] **Step 1: Write the failing test** + +```python +# src/bpp/tests/test_views/test_browse/test_lata_rok_per_uczelnia.py +import pytest +from model_bakery import baker + +from bpp.views.browse import LataView, RokView +from fixtures.conftest_multisite import make_request_for_site + + +def _dwie_prace(jednostka_uczelnia1, jednostka_uczelnia2, + autor_uczelnia1, autor_uczelnia2): + w1 = baker.make("bpp.Wydawnictwo_Ciagle", rok=2020) + w1.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1) + w2 = baker.make("bpp.Wydawnictwo_Ciagle", rok=2021) + w2.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + + +@pytest.mark.django_db +def test_lata_view_liczy_tylko_swoja_uczelnie( + uczelnia1, uczelnia2, site1, jednostka_uczelnia1, jednostka_uczelnia2, + autor_uczelnia1, autor_uczelnia2, +): + _dwie_prace(jednostka_uczelnia1, jednostka_uczelnia2, + autor_uczelnia1, autor_uczelnia2) + view = LataView() + view.request = make_request_for_site(site1) + view.kwargs = {} + lata = {y["year"] for y in view.get_queryset()} + assert 2020 in lata + assert 2021 not in lata + + +@pytest.mark.django_db +def test_rok_view_listuje_tylko_swoja_uczelnie( + uczelnia1, uczelnia2, site1, jednostka_uczelnia1, jednostka_uczelnia2, + autor_uczelnia1, autor_uczelnia2, +): + _dwie_prace(jednostka_uczelnia1, jednostka_uczelnia2, + autor_uczelnia1, autor_uczelnia2) + view = RokView() + view.request = make_request_for_site(site1) + view.kwargs = {"rok": 2021} + view.object_list = view.get_queryset() + ctx = view.get_context_data() + assert ctx["total_count"] == 0 # praca 2021 należy do uczelni2 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_views/test_browse/test_lata_rok_per_uczelnia.py -q -p no:cacheprovider` +Expected: FAIL (2021 w latach; total_count == 1). + +- [ ] **Step 3: Write minimal implementation** + +W `src/bpp/views/browse.py` dodaj importy (przy istniejących z `bpp`): + +```python +from bpp.models import Uczelnia +from bpp.util.uczelnia_scope import scope_rekord_do_uczelni +``` + +`LataView.get_queryset`: + +```python + def get_queryset(self): + uczelnia = Uczelnia.objects.get_for_request(self.request) + qs = scope_rekord_do_uczelni(Rekord.objects.all(), uczelnia) + return [ + {"year": row["rok"], "count": row["count"]} + for row in qs.values("rok") + .annotate(count=Count("*")) + .filter(count__gt=0) + .order_by("-rok") + ] +``` + +`LataView.get_context_data` — zamień `Rekord.objects.count()`: + +```python + uczelnia = Uczelnia.objects.get_for_request(self.request) + context["total_publications"] = scope_rekord_do_uczelni( + Rekord.objects.all(), uczelnia + ).count() +``` + +`RokView.get_queryset` — owiń zwracany queryset: + +```python + uczelnia = Uczelnia.objects.get_for_request(self.request) + return scope_rekord_do_uczelni( + Rekord.objects.filter(rok=year), uczelnia + ).order_by("-ostatnio_zmieniony") +``` + +`RokView.get_context_data` — owiń prev/next/total: + +```python + uczelnia = Uczelnia.objects.get_for_request(self.request) + + def _scoped(qs): + return scope_rekord_do_uczelni(qs, uczelnia) + + if _scoped(Rekord.objects.filter(rok=year - 1)).exists(): + context["prev_year"] = year - 1 + if _scoped(Rekord.objects.filter(rok=year + 1)).exists(): + context["next_year"] = year + 1 + context["total_count"] = _scoped(Rekord.objects.filter(rok=year)).count() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/bpp/tests/test_views/test_browse/test_lata_rok_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. Regresja: `uv run pytest src/bpp/tests/test_views/test_browse/ src/bpp/tests/test_views/test_views_browse.py -q -p no:cacheprovider`. + +- [ ] **Step 5: Lint + guard + commit** + +```bash +uv run ruff check src/bpp/views/browse.py src/bpp/tests/test_views/test_browse/test_lata_rok_per_uczelnia.py +uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q +git add src/bpp/views/browse.py src/bpp/tests/test_views/test_browse/test_lata_rok_per_uczelnia.py +git commit -m "feat(multi-hosted): browse lata/rok zawężone per uczelnia (R3a)" +``` + +--- + +### Task 4: OAI-PMH zawężony (`bpp/views/oai.py`) + +**Files:** +- Modify: `src/bpp/views/oai.py` (`OAIView.get`, konstrukcja `BPPOAIDatabase` ~`:243-247`; import) +- Test: `src/bpp/tests/test_views/test_oai_per_uczelnia.py` + +Uzasadnienie chokepointu: `BPPOAIDatabase` trzyma `self.original` i używa go we WSZYSTKICH metodach (`record_count`, `oai_earliest_datestamp`, `oai_query`). Zawężenie bazowego querysetu RAZ przy konstrukcji pokrywa cały feed. + +- [ ] **Step 1: Write the failing test** + +```python +# src/bpp/tests/test_views/test_oai_per_uczelnia.py +import pytest +from model_bakery import baker +from django.urls import reverse + + +@pytest.mark.django_db +def test_oai_listrecords_wyklucza_obca_uczelnie( + uczelnia1, uczelnia2, site1, jednostka_uczelnia1, jednostka_uczelnia2, + autor_uczelnia1, autor_uczelnia2, client, settings, +): + settings.ALLOWED_HOSTS = ["*"] # pozwól na HTTP_HOST domeny uczelni + w1 = baker.make("bpp.Wydawnictwo_Ciagle", tytul_oryginalny="PRACA-MOJA", rok=2020) + w1.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1) + w2 = baker.make("bpp.Wydawnictwo_Ciagle", tytul_oryginalny="PRACA-OBCA", rok=2020) + w2.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + + url = reverse("bpp:oai") + "?verb=ListRecords&metadataPrefix=oai_dc" + body = client.get(url, HTTP_HOST=site1.domain).content.decode("utf-8") + assert "PRACA-MOJA" in body + assert "PRACA-OBCA" not in body +``` + +Uwaga wykonawcza: jeśli `client.get` z `HTTP_HOST` nie rozwiązuje uczelni przez +middleware (np. `SITE_ID` wymuszony) — przełącz test na bezpośredni szew: +zbuduj `request = make_request_for_site(site1, path=...)` i wywołaj +`OAIView().get(request, ...)`, asercja na `response.content`. Sens testu bez zmian: +feed domeny uczelnia1 zawiera „PRACA-MOJA", nie „PRACA-OBCA". + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/bpp/tests/test_views/test_oai_per_uczelnia.py -q -p no:cacheprovider` +Expected: FAIL ("PRACA-OBCA" w feedzie). + +- [ ] **Step 3: Write minimal implementation** + +W `src/bpp/views/oai.py` dodaj import helpera (obok istniejących; `Uczelnia` jest już importowane — użyte w `oai_query:186`): + +```python +from bpp.util.uczelnia_scope import scope_rekord_do_uczelni +``` + +W `OAIView.get` zamień konstrukcję `BPPOAIDatabase`: + +```python + uczelnia = Uczelnia.objects.get_for_request(request) + base_qs = scope_rekord_do_uczelni( + Rekord.objects.all().exclude(charakter_formalny__nazwa_w_primo=""), + uczelnia, + ) + db = BPPOAIDatabase(base_qs, request=request) +``` + +(Istniejący filtr `ukryte_statusy("api")` w `oai_query` zostaje — działa na zawężonym `self.original`.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/bpp/tests/test_views/test_oai_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. Regresja: `uv run pytest src/bpp/tests/test_views/test_oai.py -q -p no:cacheprovider`. + +- [ ] **Step 5: Lint + guard + commit** + +```bash +uv run ruff check src/bpp/views/oai.py src/bpp/tests/test_views/test_oai_per_uczelnia.py +uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q +git add src/bpp/views/oai.py src/bpp/tests/test_views/test_oai_per_uczelnia.py +git commit -m "feat(multi-hosted): OAI feed zawężony per uczelnia (R3a)" +``` + +--- + +### Task 5: Ranking autorów zawężony po aktualnej jednostce (`ranking_autorow/views.py`) + +**Files:** +- Modify: `src/ranking_autorow/views.py` (`_apply_location_filters` ~`:221-232`; import) +- Test: `src/ranking_autorow/tests/test_ranking_per_uczelnia.py` (utwórz katalog `tests/` + `__init__.py` jeśli brak; obecne testy są w `tests.py` — nowy moduł nie koliduje) + +Reguła: ranking = obecni pracownicy uczelni → `autor__aktualna_jednostka__uczelnia=U`, bezwarunkowo (gdy `not tylko_jedna_uczelnia()`). Istniejące `exclude(autor__aktualna_jednostka=None)` zostaje. + +- [ ] **Step 1: Write the failing test** + +```python +# src/ranking_autorow/tests/test_ranking_per_uczelnia.py +import pytest +from model_bakery import baker + +from ranking_autorow.views import RankingAutorow +from fixtures.conftest_multisite import make_request_for_site + + +@pytest.mark.django_db +def test_ranking_listuje_tylko_aktualnych_pracownikow_uczelni( + uczelnia1, uczelnia2, site1, jednostka_uczelnia1, jednostka_uczelnia2, + autor_uczelnia1, autor_uczelnia2, +): + # praca każdego autora w jego jednostce (Sumy = widok po Rekord) + w1 = baker.make("bpp.Wydawnictwo_Ciagle", impact_factor=10, rok=2020) + w1.dodaj_autora(autor_uczelnia1, jednostka_uczelnia1) + w2 = baker.make("bpp.Wydawnictwo_Ciagle", impact_factor=10, rok=2020) + w2.dodaj_autora(autor_uczelnia2, jednostka_uczelnia2) + + r = RankingAutorow( + request=make_request_for_site(site1), kwargs=dict(od_roku=0, do_roku=3030) + ) + autorzy = {row.autor_id for row in r.get_queryset()} + assert autor_uczelnia1.pk in autorzy + assert autor_uczelnia2.pk not in autorzy +``` + +Uwaga: `RankingAutorow.get_queryset()` grupuje `Sumy` po autorze; `row.autor_id` +jest dostępny. Jeśli atrybut inny — sprawdź `views.py:265-291` (`group_by("autor")`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/ranking_autorow/tests/test_ranking_per_uczelnia.py -q -p no:cacheprovider` +Expected: FAIL (autor_uczelnia2 w rankingu — filtr po uczelni dziś tylko przy ręcznym wyborze jednostki). + +- [ ] **Step 3: Write minimal implementation** + +W `src/ranking_autorow/views.py` dodaj import: + +```python +from bpp.util.uczelnia_scope import tylko_jedna_uczelnia +``` + +W `_apply_location_filters`, przed `return qset`, dodaj: + +```python + def _apply_location_filters(self, qset): + jednostki = self.get_jednostki() + if jednostki: + qset = qset.filter(jednostka__in=jednostki) + + uczelnia = Uczelnia.objects.get_for_request(self.request) + if uczelnia and uczelnia.uzywaj_wydzialow and not jednostki: + wydzialy = self.get_wydzialy() + if wydzialy: + qset = qset.filter(jednostka__wydzial__in=wydzialy) + + # Multi-hosted: ranking = obecni pracownicy tej uczelni. + # No-op na single-install (guard) — wynik bez zmian. + if uczelnia is not None and not tylko_jedna_uczelnia(): + qset = qset.filter(autor__aktualna_jednostka__uczelnia=uczelnia) + + return qset +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/ranking_autorow/tests/test_ranking_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. Regresja: `uv run pytest src/ranking_autorow/ -q -p no:cacheprovider`. + +- [ ] **Step 5: Lint + guard + commit** + +```bash +uv run ruff check src/ranking_autorow/views.py src/ranking_autorow/tests/test_ranking_per_uczelnia.py +uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q +git add src/ranking_autorow/views.py src/ranking_autorow/tests/ +git commit -m "feat(multi-hosted): ranking autorów zawężony po aktualnej jednostce uczelni (R3a)" +``` + +--- + +### Task 6: Regresja całościowa + zamknięcie + +**Files:** brak zmian kodu (weryfikacja). + +- [ ] **Step 1: Pełna regresja dotkniętych obszarów** + +Run: +```bash +uv run pytest src/bpp/tests/test_util/ src/nowe_raporty/ \ + src/bpp/tests/test_views/test_browse/ src/bpp/tests/test_views/test_views_browse.py \ + src/bpp/tests/test_views/test_oai.py src/bpp/tests/test_views/test_oai_per_uczelnia.py \ + src/ranking_autorow/ src/bpp/tests/test_multisite/ \ + -q -p no:cacheprovider +``` +Expected: wszystko zielone (testy izolacji + istniejące = invariant single-install). + +- [ ] **Step 2: Guard get_default** + +Run: `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q` +Expected: PASS (R3a nie wprowadza `get_default`). + +- [ ] **Step 3: Migracje bez dryfu** (R3a nie zmienia modeli — sanity) + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run` +Expected: brak nowych migracji dla `bpp`/`nowe_raporty`/`ranking_autorow`. + +- [ ] **Step 4: Aktualizacja HANDOFF** + +Dopisz w `docs/superpowers/HANDOFF-multi-hosted.md` (sekcja „AUDYTY 4×"): R3a (widoki) ZROBIONE, R3b następny. Commit: +```bash +git add docs/superpowers/HANDOFF-multi-hosted.md +git commit -m "docs(multi-hosted): HANDOFF - R3a read-side widoki ZROBIONE" +``` + +--- + +## Notatki wykonawcze +- Helper z Taska 1 jest też zależnością R3b (`tylko_jedna_uczelnia`) — R3a pierwszy. +- Reguła rekordowa NIE wymaga `skupia_pracownikow` (świadome, = homepage). Fixtury + `jednostka_uczelnia1/2` mają `uczelnia` ustawioną — to wystarcza. +- `dodaj_autora(autor, jednostka)` to metoda `Wydawnictwo_Ciagle` (patrz + `ranking_autorow/tests.py`). `Rekord`/`Sumy` to widoki SQL — odzwierciedlają + dane natychmiast. +- Single-install invariant trzyma się na guardzie `tylko_jedna_uczelnia()`: + dopóki testy regresyjne (istniejące, single-install) są zielone, zachowanie + produkcyjne dla jednej uczelni jest niezmienione. +- Import `from fixtures.conftest_multisite import make_request_for_site` działa, + bo `src/` jest na PYTHONPATH (pytest rootdir) i `fixtures` to pakiet. From 7c3ad4e10ff6dfe2658ffb07c2b255d2bc8379dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 22:20:41 +0200 Subject: [PATCH 133/247] =?UTF-8?q?docs(multi-hosted):=20plan=20R3b=20+=20?= =?UTF-8?q?u=C5=9Bci=C5=9Blenie=20spec=20(autor=20=3D=20obecnie=20LUB=20w?= =?UTF-8?q?=20przesz=C5=82o=C5=9Bci,=20OR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan implementacyjny R3b (4 taski TDD): wspólny mixin UczelniaScopedAutocompleteMixin (OR listy lookupów + distinct, guard single-install z R3a), zastosowany do jednostka/wydzial (FK uczelnia) i autor (aktualna_jednostka OR autor_jednostka history). Admin nietknięty. Spec autora doprecyzowany: AutorAutocompleteBase już grupuje per-uczelnia (aktualna vs historia); OR obu lookupów = grupy 1+2 (nie gubi obecnego pracownika bez wiersza historii). Co-Authored-By: Claude Opus 4.8 --- ...-03-r3b-publiczne-autocomplety-uczelnia.md | 332 ++++++++++++++++++ ...-publiczne-autocomplety-uczelnia-design.md | 21 +- 2 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-03-r3b-publiczne-autocomplety-uczelnia.md diff --git a/docs/superpowers/plans/2026-06-03-r3b-publiczne-autocomplety-uczelnia.md b/docs/superpowers/plans/2026-06-03-r3b-publiczne-autocomplety-uczelnia.md new file mode 100644 index 000000000..0351cfc23 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-r3b-publiczne-autocomplety-uczelnia.md @@ -0,0 +1,332 @@ +# R3b — publiczne autocomplety per-uczelnia Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Zawęzić trzy publiczne autocomplety (jednostka, wydział, autor) do uczelni oglądającego, żeby pickery w Multiseeku/rankingu nie podpowiadały bytów innych uczelni. Bez regresji na single-install, bez dotykania autocompletów admina/edytora. + +**Architecture:** Jeden wspólny mixin `UczelniaScopedAutocompleteMixin` (w `bpp/views/autocomplete/mixins.py`) nadpisuje `get_queryset`: woła `super().get_queryset()`, a potem — gdy jest uczelnia z requestu i NIE single-install (guard `tylko_jedna_uczelnia` z R3a) — filtruje przez OR listy lookupów `uczelnia_lookups` + `.distinct()`. Trzy publiczne klasy dodają mixin (pierwszy w MRO) i ustawiają `uczelnia_lookups`. Admin/edytor używają innych klas — nietknięte. + +**Tech Stack:** Django, django-autocomplete-light (dal), pytest + model_bakery, testcontainers, `uv run`. + +Spec: `docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md`. Zależy od R3a (helper `bpp.util.uczelnia_scope.tylko_jedna_uczelnia`, już na origin). + +## Reguły wykonawcze (per HANDOFF + nauki z R3a) +- Testy: `uv run pytest <ścieżka> -q -p no:cacheprovider` (Docker działa). +- Lint: `uv run ruff check ` ORAZ `uv run ruff format --check ` przed commitem. Jeśli format-check zgłasza plik → `uv run ruff format ` (to projektowy formatter z pre-commit hooka, NIE zakazany `ruff check --fix`), potem re-weryfikuj oba. +- Guard: `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q` po każdym tasku. +- `make_request_for_site(site)` z `fixtures.conftest_multisite` odpala `SiteResolutionMiddleware` (woła `get_host()`) → w testach ustaw `settings.ALLOWED_HOSTS = ["*"]` (fixtura `settings`), inaczej `DisallowedHost`. +- Test autocompletu: instancjonuj widok, ustaw `view.request = make_request_for_site(site1)` i `view.q = ""`, wołaj `view.get_queryset()` (dal czyta `self.q`; przy bezpośrednim wywołaniu ustaw je ręcznie). Asercje na pk/zawartości zwróconego querysetu. +- Commit po każdym tasku. Push tylko na prośbę. + +## Kontekst klas (zweryfikowany) +- **jednostka** (multiseek): `WidocznaJednostkaAutocomplete` (`bpp/views/autocomplete/units.py:26`), `qset = Jednostka.objects.widoczne()`, dziedziczy `get_queryset` z `JednostkaAutocomplete` (filtr po `self.q`, `order_by`). `Jednostka.uczelnia` to FK. Używana TYLKO przez multiseek (`unit_fields.py:46,84`), nie przez admina. +- **wydział**: `PublicWydzialAutocomplete` (`bpp/views/autocomplete/simple.py:199`), `qset = Wydzial.objects.filter(widoczny=True)`, `get_queryset` z `NazwaLubSkrotMixin`. `Wydzial.uczelnia` to FK (`wydzial.py:24`). +- **autor**: `PublicAutorAutocomplete` (`bpp/views/autocomplete/authors.py:182`) dziedziczy `AutorAutocompleteBase`. Jego `get_queryset` (`authors.py:42-87`) JUŻ anotuje grupy per-uczelnia (`aktualna_jednostka` vs `autor_jednostka` history) i sortuje — ale NIE filtruje. Zwraca pełny queryset Autor (nie sliced). Reguła „kiedykolwiek związany" = grupy 1+2 = `aktualna_jednostka__uczelnia` OR `autor_jednostka__jednostka__uczelnia`. + +--- + +### Task 1: Mixin `UczelniaScopedAutocompleteMixin` + zastosowanie do jednostki + +**Files:** +- Modify: `src/bpp/views/autocomplete/mixins.py` (dodaj mixin) +- Modify: `src/bpp/views/autocomplete/units.py` (`WidocznaJednostkaAutocomplete`) +- Test: `src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py` (nowy) + +- [ ] **Step 1: Write the failing test** + +```python +# src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +import pytest + +from fixtures.conftest_multisite import make_request_for_site + + +@pytest.mark.django_db +def test_jednostka_autocomplete_zawezony_do_uczelni( + uczelnia1, uczelnia2, site1, jednostka_uczelnia1, jednostka_uczelnia2, settings +): + settings.ALLOWED_HOSTS = ["*"] + from bpp.views.autocomplete.units import WidocznaJednostkaAutocomplete + + view = WidocznaJednostkaAutocomplete() + view.request = make_request_for_site(site1) + view.q = "" + pks = set(view.get_queryset().values_list("pk", flat=True)) + assert jednostka_uczelnia1.pk in pks + assert jednostka_uczelnia2.pk not in pks +``` + +Uwaga: fixtury `jednostka_uczelnia1/2` tworzą jednostki przez `Jednostka.objects.create(...)`. Sprawdź czy są „widoczne" (`Jednostka.objects.widoczne()`); jeśli `widoczne()` wymaga flagi (np. `widoczna=True`/`pokazuj_*`) której fixtura nie ustawia, jednostka może nie wejść do `qset` NIEZALEŻNIE od uczelni — wtedy ustaw wymaganą flagę w teście (`jednostka_uczelnia1.widoczna = True; jednostka_uczelnia1.save()`), żeby test mierzył filtr uczelni, a nie widoczność. Zajrzyj do `Jednostka.objects.widoczne()` definicji. + +- [ ] **Step 2: Run test, verify it FAILS** (jednostka_uczelnia2 obecna) + +Run: `uv run pytest src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py -q -p no:cacheprovider` + +- [ ] **Step 3: Write minimal implementation** + +W `src/bpp/views/autocomplete/mixins.py` dodaj na końcu: + +```python +class UczelniaScopedAutocompleteMixin: + """Publiczny autocomplete zawężony do uczelni oglądającego (multi-hosted). + + No-op gdy brak uczelni w requeście (brak mapowania Site→Uczelnia) albo gdy + w systemie jest jedna uczelnia (guard ``tylko_jedna_uczelnia`` z R3a) — + podpowiedzi i wydajność wtedy identyczne jak dawniej. + + Podklasy ustawiają ``uczelnia_lookups`` — krotkę ścieżek ORM od modelu do + ``Uczelnia``; są łączone przez OR. ``.distinct()`` zawsze (joiny po historii + mnożą wiersze; dla FK jest nieszkodliwe). + """ + + uczelnia_lookups = ("uczelnia",) + + def get_queryset(self): + qs = super().get_queryset() + + from django.db.models import Q + + from bpp.models import Uczelnia + from bpp.util.uczelnia_scope import tylko_jedna_uczelnia + + uczelnia = Uczelnia.objects.get_for_request(self.request) + if uczelnia is not None and not tylko_jedna_uczelnia(): + warunek = Q() + for lookup in self.uczelnia_lookups: + warunek |= Q(**{lookup: uczelnia}) + qs = qs.filter(warunek).distinct() + return qs +``` + +W `src/bpp/views/autocomplete/units.py` zmień import i `WidocznaJednostkaAutocomplete`: + +```python +from .mixins import SanitizedAutocompleteMixin, UczelniaScopedAutocompleteMixin +``` +```python +class WidocznaJednostkaAutocomplete( + UczelniaScopedAutocompleteMixin, JednostkaAutocomplete +): + """Autocomplete for visible organizational units (per-uczelnia, multi-hosted).""" + + qset = Jednostka.objects.widoczne().select_related("wydzial") + # uczelnia_lookups domyślne ("uczelnia",) — Jednostka.uczelnia to FK +``` + +MRO: mixin PIERWSZY → jego `get_queryset` woła `super().get_queryset()` = `JednostkaAutocomplete.get_queryset` (filtr `self.q` + order_by), a potem filtruje po uczelni. + +- [ ] **Step 4: Run test, verify it PASSES.** Regresja autocomplete jednostki: `uv run pytest src/bpp/tests/test_views/ -k "jednostka or autocomplete" -q -p no:cacheprovider` (i `uv run pytest src/bpp/tests/test_multiseek/ -q -p no:cacheprovider` jeśli istnieje). + +- [ ] **Step 5: Lint + format + guard + commit** + +```bash +uv run ruff check src/bpp/views/autocomplete/mixins.py src/bpp/views/autocomplete/units.py src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +uv run ruff format --check src/bpp/views/autocomplete/mixins.py src/bpp/views/autocomplete/units.py src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q +git add src/bpp/views/autocomplete/mixins.py src/bpp/views/autocomplete/units.py src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +git commit -m "feat(multi-hosted): mixin UczelniaScopedAutocomplete + jednostka autocomplete per uczelnia (R3b)" +``` + +--- + +### Task 2: Wydział autocomplete per-uczelnia + +**Files:** +- Modify: `src/bpp/views/autocomplete/simple.py` (`PublicWydzialAutocomplete` + import) +- Test: dopisz do `src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py` + +- [ ] **Step 1: Write the failing test** (dopisz funkcję) + +```python +@pytest.mark.django_db +def test_wydzial_autocomplete_zawezony_do_uczelni( + uczelnia1, uczelnia2, site1, wydzial_uczelnia1, wydzial_uczelnia2, settings +): + settings.ALLOWED_HOSTS = ["*"] + from bpp.views.autocomplete.simple import PublicWydzialAutocomplete + + view = PublicWydzialAutocomplete() + view.request = make_request_for_site(site1) + view.q = "" + pks = set(view.get_queryset().values_list("pk", flat=True)) + assert wydzial_uczelnia1.pk in pks + assert wydzial_uczelnia2.pk not in pks +``` + +Uwaga: `PublicWydzialAutocomplete.qset = Wydzial.objects.filter(widoczny=True)`. Fixtury `wydzial_uczelnia1/2` tworzą wydziały przez `Wydzial.objects.create(...)` — jeśli `widoczny` domyślnie False, ustaw `wydzial_uczelnia1.widoczny = True; .save()` (i analogicznie u2, by test mierzył filtr uczelni nie widoczności). Sprawdź default pola `Wydzial.widoczny`. + +- [ ] **Step 2: Run test, verify it FAILS** (wydzial_uczelnia2 obecny) + +Run: `uv run pytest src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py::test_wydzial_autocomplete_zawezony_do_uczelni -q -p no:cacheprovider` + +- [ ] **Step 3: Write minimal implementation** + +W `src/bpp/views/autocomplete/simple.py` zaimportuj mixin (przy istniejącym imporcie z `.mixins`): +```python +from .mixins import SanitizedAutocompleteMixin, UczelniaScopedAutocompleteMixin +``` +(jeśli import z `.mixins` ma inną formę — dostosuj, nie duplikuj.) + +Zmień klasę: +```python +class PublicWydzialAutocomplete( + UczelniaScopedAutocompleteMixin, + SanitizedAutocompleteMixin, + NazwaLubSkrotMixin, + autocomplete.Select2QuerySetView, +): + """Public autocomplete for visible departments (per-uczelnia, multi-hosted).""" + + qset = Wydzial.objects.filter(widoczny=True) + # uczelnia_lookups domyślne ("uczelnia",) — Wydzial.uczelnia to FK +``` + +MRO: mixin pierwszy → `super().get_queryset()` schodzi do `NazwaLubSkrotMixin.get_queryset`. + +- [ ] **Step 4: Run test, verify it PASSES.** Regresja: `uv run pytest src/bpp/tests/test_views/ -k "wydzial or autocomplete" -q -p no:cacheprovider`. + +- [ ] **Step 5: Lint + format + guard + commit** + +```bash +uv run ruff check src/bpp/views/autocomplete/simple.py src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +uv run ruff format --check src/bpp/views/autocomplete/simple.py src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q +git add src/bpp/views/autocomplete/simple.py src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +git commit -m "feat(multi-hosted): wydzial autocomplete per uczelnia (R3b)" +``` + +--- + +### Task 3: Autor autocomplete „kiedykolwiek związany" per-uczelnia + +**Files:** +- Modify: `src/bpp/views/autocomplete/authors.py` (`PublicAutorAutocomplete` + import) +- Test: dopisz do `src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py` + +Reguła (decyzja usera): public autor picker = autor związany z uczelnią OBECNIE lub w PRZESZŁOŚCI → `aktualna_jednostka__uczelnia` OR `autor_jednostka__jednostka__uczelnia`. To grupy 1+2 z istniejącego grupowania w `AutorAutocompleteBase`; grupa 3 (zewnętrzni) odpada. NIE samo `aktualna_jednostka` (to byłaby tylko „obecnie zatrudniony"). + +- [ ] **Step 1: Write the failing test** (dopisz; uwzględnij autora HISTORYCZNEGO — aktualna jednostka gdzie indziej, ale historia w U1) + +```python +@pytest.mark.django_db +def test_autor_autocomplete_kiedykolwiek_zwiazany( + uczelnia1, uczelnia2, site1, + jednostka_uczelnia1, jednostka_uczelnia2, + autor_uczelnia1, autor_uczelnia2, settings, +): + settings.ALLOWED_HOSTS = ["*"] + from model_bakery import baker + + from bpp.views.autocomplete.authors import PublicAutorAutocomplete + + # autor historyczny: aktualna jednostka w U2, ale wpis Autor_Jednostka w U1 + autor_hist = baker.make("bpp.Autor", aktualna_jednostka=jednostka_uczelnia2) + baker.make("bpp.Autor_Jednostka", autor=autor_hist, jednostka=jednostka_uczelnia1) + + view = PublicAutorAutocomplete() + view.request = make_request_for_site(site1) + view.q = "" + pks = set(view.get_queryset().values_list("pk", flat=True)) + assert autor_uczelnia1.pk in pks # obecny pracownik U1 + assert autor_hist.pk in pks # historycznie związany z U1 + assert autor_uczelnia2.pk not in pks # tylko U2 → zewnętrzny dla U1 +``` + +- [ ] **Step 2: Run test, verify it FAILS** (autor_uczelnia2 obecny — base nie filtruje, tylko grupuje) + +Run: `uv run pytest src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py::test_autor_autocomplete_kiedykolwiek_zwiazany -q -p no:cacheprovider` + +- [ ] **Step 3: Write minimal implementation** + +W `src/bpp/views/autocomplete/authors.py` zaimportuj mixin: +```python +from .mixins import UczelniaScopedAutocompleteMixin +``` +(dostosuj do istniejących importów z `.mixins`/`.` — nie duplikuj.) + +Zmień `PublicAutorAutocomplete`: +```python +class PublicAutorAutocomplete(UczelniaScopedAutocompleteMixin, AutorAutocompleteBase): + """Public author autocomplete — autorzy związani z uczelnią obecnie lub w + przeszłości (multi-hosted). Grupowanie/sortowanie z bazy zachowane.""" + + uczelnia_lookups = ( + "aktualna_jednostka__uczelnia", + "autor_jednostka__jednostka__uczelnia", + ) + + def get_text_for_result(self, result): + return str(result) +``` + +WAŻNE: zachowaj istniejące ciało `PublicAutorAutocomplete` (jeśli ma metodę zwracającą `str(result)` — `authors.py:185-187` — przenieś ją bez zmian; nie usuwaj). MRO: mixin pierwszy → `super().get_queryset()` = `AutorAutocompleteBase.get_queryset` (grupowanie + order_by), potem filtr OR + distinct. `.distinct()` współgra z anotacjami/`order_by("grupa_uczelnia",...)` (Postgres: kolumny order_by są w SELECT, dedup po pełnych wierszach). + +- [ ] **Step 4: Run test, verify it PASSES.** Regresja autorów: `uv run pytest src/bpp/tests/test_views/ -k "autor or autocomplete" -q -p no:cacheprovider` (zwróć uwagę czy jakiś istniejący test zakładał, że publiczny autocomplete pokazuje autorów zewnętrznych — jeśli tak, to był single-install i guard go nie rusza; jeśli multi-uczelnia i pada, zgłoś jako koncept do rozstrzygnięcia, nie nadpisuj testu po cichu). + +- [ ] **Step 5: Lint + format + guard + commit** + +```bash +uv run ruff check src/bpp/views/autocomplete/authors.py src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +uv run ruff format --check src/bpp/views/autocomplete/authors.py src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q +git add src/bpp/views/autocomplete/authors.py src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +git commit -m "feat(multi-hosted): autor autocomplete kiedykolwiek-zwiazany per uczelnia (R3b)" +``` + +--- + +### Task 4: Regresja całościowa + admin nietknięty + HANDOFF + +**Files:** brak zmian kodu (weryfikacja + doc). + +- [ ] **Step 1: Regresja dotkniętych obszarów + autocomplete admina** + +Run: +```bash +uv run pytest src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py \ + src/bpp/tests/test_views/ -k "autocomplete or jednostka or wydzial or autor" \ + src/bpp/tests/test_multisite/ \ + -q -p no:cacheprovider +``` +Expected: zielone. Jeśli istnieją testy multiseek (`src/bpp/tests/test_multiseek/`), dorzuć je. Każda regresja przy jednej uczelni = guard no-op, więc istniejące testy single-install muszą przejść. + +- [ ] **Step 2: Potwierdź, że admin/edytor autocomplety NIE są zawężone** + +Szybka asercja, że `JednostkaAutocomplete` (admin) i `WydzialAutocomplete` (admin) NIE dziedziczą `UczelniaScopedAutocompleteMixin` (czyli pokazują wszystkie uczelnie). Dopisz do pliku testowego: +```python +def test_admin_autocomplety_nie_sa_zawezone(): + from bpp.views.autocomplete.mixins import UczelniaScopedAutocompleteMixin + from bpp.views.autocomplete.units import JednostkaAutocomplete + from bpp.views.autocomplete.simple import WydzialAutocomplete + + assert not issubclass(JednostkaAutocomplete, UczelniaScopedAutocompleteMixin) + assert not issubclass(WydzialAutocomplete, UczelniaScopedAutocompleteMixin) +``` +Run ten test; commituj go razem z krokiem 3 (lub osobno). + +- [ ] **Step 3: Guard get_default** + +Run: `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q` → PASS. + +- [ ] **Step 4: Migracje bez dryfu** (R3b nie zmienia modeli) + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run` +Expected: brak nowych migracji dla `bpp` z R3b (pre-existing/third-party drift z R3a-Task6 może się powtórzyć — nie z R3b). + +- [ ] **Step 5: Aktualizacja HANDOFF + commit** + +W `docs/superpowers/HANDOFF-multi-hosted.md` (sekcja AUDYTY 4×, bullet A) oznacz R3b ZROBIONE (analogicznie do R3a). Commit: +```bash +git add docs/superpowers/HANDOFF-multi-hosted.md src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +git commit -m "docs(multi-hosted): HANDOFF - R3b autocomplety ZROBIONE; test admin-nietkniety" +``` + +--- + +## Notatki wykonawcze +- Mixin MUSI być PIERWSZY w MRO każdej z trzech klas, inaczej `super().get_queryset()` nie złapie istniejącej logiki filtra-tekstowego/grupowania. +- Reguła autora to OR dwóch lookupów (obecnie LUB w przeszłości) — świadome uściślenie spec (sam `autor_jednostka` zgubiłby obecnego pracownika bez wiersza historii). Udokumentowane w docstringu klasy. +- `.distinct()` zawsze (mixin) — konieczne dla joinu po historii autora; nieszkodliwe dla FK jednostki/wydziału. +- Guard `tylko_jedna_uczelnia()` (z R3a) → single-install: mixin nie filtruje, podpowiedzi identyczne, zero narzutu. Autor: istniejące grupowanie i tak działa tylko gdy `request._uczelnia` ustawione — bez zmian. +- Admin/edytor autocomplety (`JednostkaAutocomplete`, `WydzialAutocomplete`, `AutorAutocomplete`) NIE dostają mixinu — pełny dostęp zachowany (Task 4 Step 2 to pilnuje). +- Jeśli `widoczne()`/`widoczny=True` wymaga flag których fixtury nie ustawiają — ustaw je w teście, żeby mierzyć filtr UCZELNI, nie widoczność (patrz uwagi w Taskach 1–2). diff --git a/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md b/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md index 97acb1384..d500c0247 100644 --- a/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md +++ b/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md @@ -45,19 +45,28 @@ Tani filtr po indeksowanym FK; guard daje zerowy narzut na single-install ### Autor — `PublicAutorAutocomplete` (semantyka „kiedykolwiek związany") Decyzja usera: w Multiseeku szukamy autora **obecnie LUB w przeszłości** -związanego z uczelnią X — czyli mającego dowolną jednostkę w historii należącą -do U. Lookup przez `Autor_Jednostka` (reverse `autor_jednostka`): +związanego z uczelnią X. „Obecnie" = aktualna jednostka w U; „w przeszłości" = +dowolna jednostka w historii (`Autor_Jednostka`) w U. Filtr = OR obu lookupów ++ `.distinct()`: ```python -qs.filter(autor_jednostka__jednostka__uczelnia=U).distinct() +qs.filter( + Q(aktualna_jednostka__uczelnia=U) + | Q(autor_jednostka__jednostka__uczelnia=U) +).distinct() ``` -**NIE** `aktualna_jednostka__uczelnia` — to byłaby semantyka „aktualnie -zatrudniony", potrzebna gdzie indziej (np. ranking w R3a), ale nie tu. +Dlaczego OR, a nie sam `autor_jednostka`: `AutorAutocompleteBase.get_queryset` +(`authors.py:42-87`) JUŻ rozróżnia grupę „nasza uczelnia" (`aktualna_jednostka`) +od „historycznie" (`autor_jednostka`) — model dopuszcza obecnego pracownika +BEZ wiersza historii, więc sam `autor_jednostka` mógłby go zgubić. OR = dokładnie +grupy 1+2 z istniejącego grupowania (zewnętrzni = grupa 3 odpadają). To NIE samo +`aktualna_jednostka__uczelnia` (tamto = wyłącznie „aktualnie zatrudniony", +potrzebne np. w rankingu R3a). ### Dwie semantyki autora (do utrwalenia w kodzie/komentarzu) | semantyka | lookup | gdzie | |---|---|---| | aktualnie zatrudniony | `aktualna_jednostka__uczelnia=U` | ranking (R3a), miejsca „nasz pracownik" | -| kiedykolwiek związany | `autor_jednostka__jednostka__uczelnia=U` `.distinct()` | PublicAutorAutocomplete (ten spec) | +| obecnie LUB w przeszłości związany | `Q(aktualna_jednostka__uczelnia=U) \| Q(autor_jednostka__jednostka__uczelnia=U)` `.distinct()` | PublicAutorAutocomplete (ten spec) | ## Niezmienniki i przypadki brzegowe - **Single-install:** guard `tylko_jedna_uczelnia()` → wszystkie trzy autocomplety From c7ca60c664e87b7779f32594907948f0bffd3d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 22:25:26 +0200 Subject: [PATCH 134/247] feat(multi-hosted): mixin UczelniaScopedAutocomplete + jednostka autocomplete per uczelnia (R3b) --- .../test_autocomplete_per_uczelnia.py | 27 +++++++++++++++ src/bpp/views/autocomplete/mixins.py | 34 +++++++++++++++++++ src/bpp/views/autocomplete/units.py | 8 +++-- 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py diff --git a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py new file mode 100644 index 000000000..63b1a4d0f --- /dev/null +++ b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py @@ -0,0 +1,27 @@ +import pytest + +from bpp.models import Jednostka +from fixtures.conftest_multisite import make_request_for_site + + +@pytest.mark.django_db +def test_jednostka_autocomplete_zawezony_do_uczelni( + uczelnia1, uczelnia2, site1, jednostka_uczelnia1, jednostka_uczelnia2, settings +): + settings.ALLOWED_HOSTS = ["*"] + from bpp.views.autocomplete.units import WidocznaJednostkaAutocomplete + + # WidocznaJednostkaAutocomplete.qset = Jednostka.objects.widoczne(), tj. + # filter(widoczna=True). Pole `widoczna` ma default=True, ale ustawiamy je + # jawnie na obu jednostkach, by test mierzył filtr per-uczelnia, a nie + # widoczność. + Jednostka.objects.filter( + pk__in=[jednostka_uczelnia1.pk, jednostka_uczelnia2.pk] + ).update(widoczna=True) + + view = WidocznaJednostkaAutocomplete() + view.request = make_request_for_site(site1) + view.q = "" + pks = set(view.get_queryset().values_list("pk", flat=True)) + assert jednostka_uczelnia1.pk in pks + assert jednostka_uczelnia2.pk not in pks diff --git a/src/bpp/views/autocomplete/mixins.py b/src/bpp/views/autocomplete/mixins.py index a9ee57d40..3d0caa0ff 100644 --- a/src/bpp/views/autocomplete/mixins.py +++ b/src/bpp/views/autocomplete/mixins.py @@ -25,3 +25,37 @@ def dispatch(self, request, *args, **kwargs): request.GET["q"] = q[: self.MAX_QUERY_LENGTH] return super().dispatch(request, *args, **kwargs) + + +class UczelniaScopedAutocompleteMixin: + """Publiczny autocomplete zawężony do uczelni oglądającego (multi-hosted). + + No-op gdy brak uczelni w requeście (brak mapowania Site→Uczelnia) albo gdy + w systemie jest jedna uczelnia (guard ``tylko_jedna_uczelnia`` z R3a) — + podpowiedzi i wydajność wtedy identyczne jak dawniej. + + Podklasy ustawiają ``uczelnia_lookups`` — krotkę ścieżek ORM od modelu do + ``Uczelnia``; są łączone przez OR. ``.distinct()`` zawsze (joiny po historii + mnożą wiersze; dla FK jest nieszkodliwe). + + UWAGA: umieszczaj ten mixin PRZED konkretnym widokiem w MRO — inaczej + ``get_queryset`` bazy przesłoni ten i zawężenie po cichu się nie wykona. + """ + + uczelnia_lookups = ("uczelnia",) + + def get_queryset(self): + qs = super().get_queryset() + + from django.db.models import Q + + from bpp.models import Uczelnia + from bpp.util.uczelnia_scope import tylko_jedna_uczelnia + + uczelnia = Uczelnia.objects.get_for_request(self.request) + if uczelnia is not None and not tylko_jedna_uczelnia(): + warunek = Q() + for lookup in self.uczelnia_lookups: + warunek |= Q(**{lookup: uczelnia}) + qs = qs.filter(warunek).distinct() + return qs diff --git a/src/bpp/views/autocomplete/units.py b/src/bpp/views/autocomplete/units.py index c9395ca4b..b3e4e707e 100644 --- a/src/bpp/views/autocomplete/units.py +++ b/src/bpp/views/autocomplete/units.py @@ -6,7 +6,7 @@ from bpp.models import Jednostka from .base import JednostkaMixin -from .mixins import SanitizedAutocompleteMixin +from .mixins import SanitizedAutocompleteMixin, UczelniaScopedAutocompleteMixin class JednostkaAutocomplete( @@ -23,8 +23,10 @@ def get_queryset(self): return qs.order_by(*Jednostka.objects.get_default_ordering()) -class WidocznaJednostkaAutocomplete(JednostkaAutocomplete): - """Autocomplete for visible organizational units.""" +class WidocznaJednostkaAutocomplete( + UczelniaScopedAutocompleteMixin, JednostkaAutocomplete +): + """Autocomplete for visible organizational units (per-uczelnia, multi-hosted).""" qset = Jednostka.objects.widoczne().select_related("wydzial") From 0bca819328e47329a69a8e1ba892f8fe8872ade3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 22:34:36 +0200 Subject: [PATCH 135/247] feat(multi-hosted): wydzial autocomplete per uczelnia (R3b) Co-Authored-By: Claude Opus 4.8 --- .../test_autocomplete_per_uczelnia.py | 24 ++++++++++++++++++- src/bpp/views/autocomplete/simple.py | 9 ++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py index 63b1a4d0f..a093ba674 100644 --- a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +++ b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py @@ -1,6 +1,6 @@ import pytest -from bpp.models import Jednostka +from bpp.models import Jednostka, Wydzial from fixtures.conftest_multisite import make_request_for_site @@ -25,3 +25,25 @@ def test_jednostka_autocomplete_zawezony_do_uczelni( pks = set(view.get_queryset().values_list("pk", flat=True)) assert jednostka_uczelnia1.pk in pks assert jednostka_uczelnia2.pk not in pks + + +@pytest.mark.django_db +def test_wydzial_autocomplete_zawezony_do_uczelni( + uczelnia1, uczelnia2, site1, wydzial_uczelnia1, wydzial_uczelnia2, settings +): + settings.ALLOWED_HOSTS = ["*"] + from bpp.views.autocomplete.simple import PublicWydzialAutocomplete + + # PublicWydzialAutocomplete.qset = Wydzial.objects.filter(widoczny=True). + # Pole `widoczny` ma default=True, ale ustawiamy je jawnie na obu + # wydziałach, by test mierzył filtr per-uczelnia, a nie widoczność. + Wydzial.objects.filter(pk__in=[wydzial_uczelnia1.pk, wydzial_uczelnia2.pk]).update( + widoczny=True + ) + + view = PublicWydzialAutocomplete() + view.request = make_request_for_site(site1) + view.q = "" + pks = set(view.get_queryset().values_list("pk", flat=True)) + assert wydzial_uczelnia1.pk in pks + assert wydzial_uczelnia2.pk not in pks diff --git a/src/bpp/views/autocomplete/simple.py b/src/bpp/views/autocomplete/simple.py index 897ed4b98..87a30bac4 100644 --- a/src/bpp/views/autocomplete/simple.py +++ b/src/bpp/views/autocomplete/simple.py @@ -30,7 +30,7 @@ NazwaTrigramMixin, autocomplete_create_error, ) -from .mixins import SanitizedAutocompleteMixin +from .mixins import SanitizedAutocompleteMixin, UczelniaScopedAutocompleteMixin class _OrderedSlicedQuerySetSequence(QuerySetSequence): @@ -197,9 +197,12 @@ class WydzialAutocomplete( class PublicWydzialAutocomplete( - SanitizedAutocompleteMixin, NazwaLubSkrotMixin, autocomplete.Select2QuerySetView + UczelniaScopedAutocompleteMixin, + SanitizedAutocompleteMixin, + NazwaLubSkrotMixin, + autocomplete.Select2QuerySetView, ): - """Public autocomplete for visible departments.""" + """Public autocomplete for visible departments (per-uczelnia, multi-hosted).""" qset = Wydzial.objects.filter(widoczny=True) From 98753722f630db8d2f6d1f3816418f29f16be176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 22:40:29 +0200 Subject: [PATCH 136/247] feat(multi-hosted): autor autocomplete kiedykolwiek-zwiazany per uczelnia (R3b) Co-Authored-By: Claude Opus 4.8 --- .../test_autocomplete_per_uczelnia.py | 53 +++++++++++++++++++ src/bpp/views/autocomplete/authors.py | 16 ++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py index a093ba674..e6a0ea33a 100644 --- a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +++ b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py @@ -47,3 +47,56 @@ def test_wydzial_autocomplete_zawezony_do_uczelni( pks = set(view.get_queryset().values_list("pk", flat=True)) assert wydzial_uczelnia1.pk in pks assert wydzial_uczelnia2.pk not in pks + + +@pytest.mark.django_db +def test_autor_autocomplete_kiedykolwiek_zwiazany( + uczelnia1, + uczelnia2, + site1, + jednostka_uczelnia1, + jednostka_uczelnia2, + autor_uczelnia1, + autor_uczelnia2, + settings, +): + settings.ALLOWED_HOSTS = ["*"] + from model_bakery import baker + + from bpp.views.autocomplete.authors import PublicAutorAutocomplete + + # autor historyczny: aktualna jednostka w U2, ale wpis Autor_Jednostka w U1 + autor_hist = baker.make("bpp.Autor", aktualna_jednostka=jednostka_uczelnia2) + baker.make("bpp.Autor_Jednostka", autor=autor_hist, jednostka=jednostka_uczelnia1) + + view = PublicAutorAutocomplete() + view.request = make_request_for_site(site1) + view.q = "" + pks = set(view.get_queryset().values_list("pk", flat=True)) + assert autor_uczelnia1.pk in pks # obecny pracownik U1 + assert autor_hist.pk in pks # historycznie związany z U1 + assert autor_uczelnia2.pk not in pks # tylko U2 → zewnętrzny dla U1 + + +@pytest.mark.django_db +def test_autor_autocomplete_dedup_wielokrotna_historia( + uczelnia1, uczelnia2, site1, jednostka_uczelnia1, settings +): + """Autor z wieloma wpisami Autor_Jednostka w U1 pojawia się DOKŁADNIE raz + (kontrakt .distinct() — join po historii mnoży wiersze).""" + settings.ALLOWED_HOSTS = ["*"] + from model_bakery import baker + + from bpp.views.autocomplete.authors import PublicAutorAutocomplete + + # druga jednostka tej samej uczelni U1, by autor miał 2 wpisy historii w U1 + jedn_u1_b = baker.make("bpp.Jednostka", uczelnia=uczelnia1) + autor = baker.make("bpp.Autor", aktualna_jednostka=jednostka_uczelnia1) + baker.make("bpp.Autor_Jednostka", autor=autor, jednostka=jednostka_uczelnia1) + baker.make("bpp.Autor_Jednostka", autor=autor, jednostka=jedn_u1_b) + + view = PublicAutorAutocomplete() + view.request = make_request_for_site(site1) + view.q = "" + pk_list = list(view.get_queryset().values_list("pk", flat=True)) + assert pk_list.count(autor.pk) == 1 # nie zduplikowany mimo 2 wpisów historii diff --git a/src/bpp/views/autocomplete/authors.py b/src/bpp/views/autocomplete/authors.py index 362137cd7..0becc9ec6 100644 --- a/src/bpp/views/autocomplete/authors.py +++ b/src/bpp/views/autocomplete/authors.py @@ -21,7 +21,7 @@ from pbn_api.models import OsobaZInstytucji from .base import autocomplete_create_error -from .mixins import SanitizedAutocompleteMixin +from .mixins import SanitizedAutocompleteMixin, UczelniaScopedAutocompleteMixin class AutorAutocompleteBase( @@ -179,8 +179,18 @@ def create_object(self, text): return obj -class PublicAutorAutocomplete(AutorAutocompleteBase): - """Public autocomplete for authors (no create, no PBN/MNISW markers).""" +class PublicAutorAutocomplete(UczelniaScopedAutocompleteMixin, AutorAutocompleteBase): + """Public autocomplete for authors (no create, no PBN/MNISW markers). + + Autorzy związani z uczelnią obecnie lub w przeszłości (multi-hosted) — + grupy 1+2 z grupowania bazy; grupa „zewnętrzni" odpada. Grupowanie i + sortowanie z bazy zachowane. + """ + + uczelnia_lookups = ( + "aktualna_jednostka__uczelnia", + "autor_jednostka__jednostka__uczelnia", + ) def get_result_label(self, result): """Return clean author name without PBN/MNISW markers.""" From 7035c533e55b44df9d2d4732d7333066b289125b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 22:49:40 +0200 Subject: [PATCH 137/247] docs(multi-hosted): HANDOFF - R3b autocomplety ZROBIONE; test admin-nietkniety --- docs/superpowers/HANDOFF-multi-hosted.md | 11 +++++++++-- .../test_views/test_autocomplete_per_uczelnia.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index a212e8fd0..c200f0ef4 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -241,8 +241,15 @@ poza zakresem R1 i NIE odnotowane jako wyłączone → kandydat na wątek **R3** świadomie NIE filtrowane. 6 tasków TDD (subagent-driven, spec+quality review każdy), pełna regresja zielona, invariant single-install trzyma. Plan `plans/2026-06-03-r3a-read-side-publiczny-widoki.md`. Niepushowane. - - **R3b (publiczne autocomplety) — NASTĘPNY.** Spec - `specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md`. Zależy od helpera R3a. + - ✅ **R3b ZROBIONE (2026-06-03):** publiczne autocomplety jednostka/wydział/autor + zawężone per-uczelnia przez wspólny mixin `UczelniaScopedAutocompleteMixin` + (OR listy `uczelnia_lookups` + distinct, guard single-install z R3a). Jednostka/ + wydział po FK `uczelnia`; autor = „obecnie LUB w przeszłości związany" + (`aktualna_jednostka__uczelnia` OR `autor_jednostka__jednostka__uczelnia`). Admin/ + edytor autocomplety nietknięte. 4 taski TDD (subagent-driven, spec+quality review), + regresja zielona, invariant single-install trzyma. Plan + `plans/2026-06-03-r3b-publiczne-autocomplety-uczelnia.md`. Niepushowane. + Tym samym **R3 (a+b) read-side publiczny domknięty**. - **B) Drobne gotowe:** `powiazania_autorow/queries.py:_pbn_root()` → `get_for_request` (jedyny realny dług z whitelisty get_default, Audyt 1); **LUKA R1:** komenda `zbieraj_sloty` CLI + `Autor.zbieraj_sloty` nie przekazują diff --git a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py index e6a0ea33a..2d8005f95 100644 --- a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +++ b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py @@ -100,3 +100,16 @@ def test_autor_autocomplete_dedup_wielokrotna_historia( view.q = "" pk_list = list(view.get_queryset().values_list("pk", flat=True)) assert pk_list.count(autor.pk) == 1 # nie zduplikowany mimo 2 wpisów historii + + +def test_admin_autocomplety_nie_sa_zawezone(): + """Admin/edytor autocomplety NIE dziedziczą scopingu — pełny dostęp do + wszystkich uczelni (multi-hosted: tylko publiczne pickery są zawężone).""" + from bpp.views.autocomplete.mixins import UczelniaScopedAutocompleteMixin + from bpp.views.autocomplete.units import JednostkaAutocomplete + from bpp.views.autocomplete.simple import WydzialAutocomplete + from bpp.views.autocomplete.authors import AutorAutocomplete + + assert not issubclass(JednostkaAutocomplete, UczelniaScopedAutocompleteMixin) + assert not issubclass(WydzialAutocomplete, UczelniaScopedAutocompleteMixin) + assert not issubclass(AutorAutocomplete, UczelniaScopedAutocompleteMixin) From e48ccc5b456c9a614414663180bf0e758207c00e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 3 Jun 2026 22:57:40 +0200 Subject: [PATCH 138/247] fix(multi-hosted): UczelniaScopedAutocomplete mixin toleruje brak self.request (regresja test_autocomplete_security) --- .../test_autocomplete_per_uczelnia.py | 19 +++++++++++++++++-- src/bpp/views/autocomplete/mixins.py | 6 +++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py index 2d8005f95..b2a4c64bb 100644 --- a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +++ b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py @@ -105,11 +105,26 @@ def test_autor_autocomplete_dedup_wielokrotna_historia( def test_admin_autocomplety_nie_sa_zawezone(): """Admin/edytor autocomplety NIE dziedziczą scopingu — pełny dostęp do wszystkich uczelni (multi-hosted: tylko publiczne pickery są zawężone).""" + from bpp.views.autocomplete.authors import AutorAutocomplete from bpp.views.autocomplete.mixins import UczelniaScopedAutocompleteMixin - from bpp.views.autocomplete.units import JednostkaAutocomplete from bpp.views.autocomplete.simple import WydzialAutocomplete - from bpp.views.autocomplete.authors import AutorAutocomplete + from bpp.views.autocomplete.units import JednostkaAutocomplete assert not issubclass(JednostkaAutocomplete, UczelniaScopedAutocompleteMixin) assert not issubclass(WydzialAutocomplete, UczelniaScopedAutocompleteMixin) assert not issubclass(AutorAutocomplete, UczelniaScopedAutocompleteMixin) + + +@pytest.mark.django_db +def test_autocomplete_get_queryset_bez_requestu_nie_wywala(uczelnia1, uczelnia2): + """Mixin toleruje brak self.request (no-op) — kontrakt defensywny bazy. + + Niektóre testy/ścieżki instancjonują widok bez requestu i wołają + get_queryset() bezpośrednio; mixin nie może na tym wywalić AttributeError. + """ + from bpp.views.autocomplete.units import WidocznaJednostkaAutocomplete + + view = WidocznaJednostkaAutocomplete() # brak view.request + view.q = "" + # nie powinno rzucić AttributeError — po prostu nie zawęża + list(view.get_queryset()) diff --git a/src/bpp/views/autocomplete/mixins.py b/src/bpp/views/autocomplete/mixins.py index 3d0caa0ff..54d37f7d8 100644 --- a/src/bpp/views/autocomplete/mixins.py +++ b/src/bpp/views/autocomplete/mixins.py @@ -52,7 +52,11 @@ def get_queryset(self): from bpp.models import Uczelnia from bpp.util.uczelnia_scope import tylko_jedna_uczelnia - uczelnia = Uczelnia.objects.get_for_request(self.request) + request = getattr(self, "request", None) + if request is None: + return qs + + uczelnia = Uczelnia.objects.get_for_request(request) if uczelnia is not None and not tylko_jedna_uczelnia(): warunek = Q() for lookup in self.uczelnia_lookups: From f446fc2687e2a397b56ffa96fc8d6851d699ff24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 07:56:06 +0200 Subject: [PATCH 139/247] feat(multi-hosted): PublicJednostkaAutocomplete per uczelnia (R3b follow-up: ranking/zglos picker) --- .../test_autocomplete_per_uczelnia.py | 24 +++++++++++++++++++ src/bpp/views/autocomplete/units.py | 6 +++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py index b2a4c64bb..6b2e5b088 100644 --- a/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py +++ b/src/bpp/tests/test_views/test_autocomplete_per_uczelnia.py @@ -115,6 +115,30 @@ def test_admin_autocomplety_nie_sa_zawezone(): assert not issubclass(AutorAutocomplete, UczelniaScopedAutocompleteMixin) +@pytest.mark.django_db +def test_public_jednostka_autocomplete_zawezony_do_uczelni( + uczelnia1, uczelnia2, site1, jednostka_uczelnia1, jednostka_uczelnia2, settings +): + settings.ALLOWED_HOSTS = ["*"] + from bpp.views.autocomplete.units import PublicJednostkaAutocomplete + + # PublicJednostkaAutocomplete używa Jednostka.objects.publiczne(), tj. + # widoczne().filter(aktualna=True) → filter(widoczna=True, aktualna=True). + # `aktualna` ma default=False, więc fixtury (Jednostka.objects.create bez + # tego pola) NIE są publiczne. Ustawiamy widoczna=True ORAZ aktualna=True na + # obu jednostkach, by test mierzył filtr per-uczelnia, a nie publiczność. + Jednostka.objects.filter( + pk__in=[jednostka_uczelnia1.pk, jednostka_uczelnia2.pk] + ).update(widoczna=True, aktualna=True) + + view = PublicJednostkaAutocomplete() + view.request = make_request_for_site(site1) + view.q = "" + pks = set(view.get_queryset().values_list("pk", flat=True)) + assert jednostka_uczelnia1.pk in pks + assert jednostka_uczelnia2.pk not in pks + + @pytest.mark.django_db def test_autocomplete_get_queryset_bez_requestu_nie_wywala(uczelnia1, uczelnia2): """Mixin toleruje brak self.request (no-op) — kontrakt defensywny bazy. diff --git a/src/bpp/views/autocomplete/units.py b/src/bpp/views/autocomplete/units.py index b3e4e707e..d1797ddbd 100644 --- a/src/bpp/views/autocomplete/units.py +++ b/src/bpp/views/autocomplete/units.py @@ -31,7 +31,9 @@ class WidocznaJednostkaAutocomplete( qset = Jednostka.objects.widoczne().select_related("wydzial") -class PublicJednostkaAutocomplete(JednostkaAutocomplete): - """Public autocomplete for public organizational units.""" +class PublicJednostkaAutocomplete( + UczelniaScopedAutocompleteMixin, JednostkaAutocomplete +): + """Public autocomplete for public organizational units (per-uczelnia, multi-hosted).""" qset = Jednostka.objects.publiczne().select_related("wydzial") From 7f68d56669523355306d9c46a16b2d8d877cea29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 07:58:03 +0200 Subject: [PATCH 140/247] docs(multi-hosted): R3b follow-up - PublicJednostkaAutocomplete + fix no-request w spec/HANDOFF Co-Authored-By: Claude Opus 4.8 --- docs/superpowers/HANDOFF-multi-hosted.md | 8 ++++++-- ...6-06-03-r3b-publiczne-autocomplety-uczelnia-design.md | 9 ++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index c200f0ef4..37e2fd3fb 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -248,8 +248,12 @@ poza zakresem R1 i NIE odnotowane jako wyłączone → kandydat na wątek **R3** (`aktualna_jednostka__uczelnia` OR `autor_jednostka__jednostka__uczelnia`). Admin/ edytor autocomplety nietknięte. 4 taski TDD (subagent-driven, spec+quality review), regresja zielona, invariant single-install trzyma. Plan - `plans/2026-06-03-r3b-publiczne-autocomplety-uczelnia.md`. Niepushowane. - Tym samym **R3 (a+b) read-side publiczny domknięty**. + `plans/2026-06-03-r3b-publiczne-autocomplety-uczelnia.md`. + Follow-up (2026-06-04): holistyczny review złapał regresję (mixin czytał + `self.request` bezwarunkowo → fix `e48ccc5b4` toleruje brak requestu) ORAZ + dodano **4. picker `PublicJednostkaAutocomplete`** (filtr jednostki w + rankingu / „zgłoś publikację", był poza pierwotnym zakresem — commit + `f446fc268`). Tym samym **R3 (a+b) read-side publiczny domknięty**. - **B) Drobne gotowe:** `powiazania_autorow/queries.py:_pbn_root()` → `get_for_request` (jedyny realny dług z whitelisty get_default, Audyt 1); **LUKA R1:** komenda `zbieraj_sloty` CLI + `Autor.zbieraj_sloty` nie przekazują diff --git a/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md b/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md index d500c0247..675ab963f 100644 --- a/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md +++ b/docs/superpowers/specs/2026-06-03-r3b-publiczne-autocomplety-uczelnia-design.md @@ -21,10 +21,17 @@ i `multiseek_registry/fields/`): | byt | klasa (plik) | używana przez | współdzielona z edytorem? | |---|---|---|---| -| jednostka | `WidocznaJednostkaAutocomplete` (`autocomplete/units.py:26`) | pole jednostki Multiseek (`unit_fields.py:46,84` → `jednostka-widoczna-autocomplete`) | **NIE** (tylko Multiseek) | +| jednostka (multiseek) | `WidocznaJednostkaAutocomplete` (`autocomplete/units.py:26`) | pole jednostki Multiseek (`unit_fields.py:46,84` → `jednostka-widoczna-autocomplete`) | **NIE** (tylko Multiseek) | +| jednostka (publiczna) | `PublicJednostkaAutocomplete` (`autocomplete/units.py:34`) | filtr jednostki w rankingu (`ranking_autorow/forms.py`), formularz „zgłoś publikację" (`zglos_publikacje/forms.py`) → `jednostka-publiczna-autocomplete` | NIE (publiczny) | | wydział | `PublicWydzialAutocomplete` (`autocomplete/simple.py:199`) | `public-wydzial-autocomplete` (`unit_fields.py:158`) | NIE (publiczny) | | autor | `PublicAutorAutocomplete` (`autocomplete/authors.py:182`) | `public-autor-autocomplete` (`author_fields.py:70`) | NIE (publiczny) | +> **Follow-up (2026-06-04):** `PublicJednostkaAutocomplete` dodana po holistycznym +> review R3b — pierwotny spec obejmował tylko 3 klasy multiseekowe, ale ten +> picker (ranking/„zgłoś publikację") też przeciekał jednostki innych uczelni. +> Ten sam mixin, domyślne `uczelnia_lookups=("uczelnia",)` (FK). `publiczne()` +> = `widoczne().filter(aktualna=True)`. + Każdą zawężamy **w miejscu** w `get_queryset` — bez nowych klas/URL-i, bez ryzyka dla formularzy redakcyjnych (admin używa innych: `JednostkaAutocomplete`, `WydzialAutocomplete`, `AutorAutocomplete`). From 77524dfd624f12522cd6e89523b8bc8cc1bb0b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 08:13:33 +0200 Subject: [PATCH 141/247] fix(multi-hosted): powiazania_autorow _pbn_root per-uczelnia (z requestu, nie get_default) - B1 Co-Authored-By: Claude Opus 4.8 --- .../test_multihosted_get_default_guard.py | 1 - src/powiazania_autorow/queries.py | 10 ++++++---- .../test_pbn_root_per_uczelnia.py | 19 +++++++++++++++++++ src/powiazania_autorow/views.py | 6 +++--- 4 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 src/powiazania_autorow/test_pbn_root_per_uczelnia.py diff --git a/src/bpp/tests/test_multihosted_get_default_guard.py b/src/bpp/tests/test_multihosted_get_default_guard.py index 37eb5375d..8688616da 100644 --- a/src/bpp/tests/test_multihosted_get_default_guard.py +++ b/src/bpp/tests/test_multihosted_get_default_guard.py @@ -40,7 +40,6 @@ "pbn_api/management/commands/util.py": 1, # GUARDED count==1 (wzorzec CLI) "pbn_import/templatetags/pbn_import_tags.py": 1, # request-first, fallback bez requestu "pbn_import/utils/command_helpers.py": 1, # CLI None-tolerant + CommandError - "powiazania_autorow/queries.py": 1, # dev: explorer, root PBN raz (anty-N+1), display; deferred multi-host } diff --git a/src/powiazania_autorow/queries.py b/src/powiazania_autorow/queries.py index aae700ba0..fab3a09e6 100644 --- a/src/powiazania_autorow/queries.py +++ b/src/powiazania_autorow/queries.py @@ -182,11 +182,13 @@ def _metryki_prac(autor_ids): } -def _pbn_root(): - """Root API PBN-u Uczelni domyślnej (raz na request).""" - from bpp.models import Uczelnia +def _pbn_root(uczelnia): + """Root API PBN danej uczelni (pobierany raz na request). - uczelnia = Uczelnia.objects.get_default() + Multi-hosted: uczelnię bierze wołający z requestu + (``Uczelnia.objects.get_for_request(request)``) — NIE zgadujemy + ``get_default()``. ``None`` gdy brak uczelni → brak linków PBN. + """ return uczelnia.pbn_api_root if uczelnia is not None else None diff --git a/src/powiazania_autorow/test_pbn_root_per_uczelnia.py b/src/powiazania_autorow/test_pbn_root_per_uczelnia.py new file mode 100644 index 000000000..4e65ac50a --- /dev/null +++ b/src/powiazania_autorow/test_pbn_root_per_uczelnia.py @@ -0,0 +1,19 @@ +import pytest + +from powiazania_autorow.queries import _pbn_root + + +@pytest.mark.django_db +def test_pbn_root_uzywa_uczelni_z_argumentu(uczelnia1, uczelnia2): + uczelnia1.pbn_api_root = "https://pbn-U1.example/api" + uczelnia1.save() + uczelnia2.pbn_api_root = "https://pbn-U2.example/api" + uczelnia2.save() + + assert _pbn_root(uczelnia1) == "https://pbn-U1.example/api" + assert _pbn_root(uczelnia2) == "https://pbn-U2.example/api" + + +@pytest.mark.django_db +def test_pbn_root_none_uczelnia_zwraca_none(): + assert _pbn_root(None) is None diff --git a/src/powiazania_autorow/views.py b/src/powiazania_autorow/views.py index 6bc27fc6c..5bcf67574 100644 --- a/src/powiazania_autorow/views.py +++ b/src/powiazania_autorow/views.py @@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404 from django.views.generic import TemplateView, View -from bpp.models import Autor +from bpp.models import Autor, Uczelnia from .queries import ( MAKS_GLEBOKOSC_FILTR, @@ -45,7 +45,7 @@ class GrafPowiazanDaneView(View): def get(self, request, pk): autor = get_object_or_404(Autor.objects.select_related("tytul"), pk=pk) - pbn_root = _pbn_root() + pbn_root = _pbn_root(Uczelnia.objects.get_for_request(request)) filtr = _filtr_z_request(request) if filtr.aktywny(): @@ -137,7 +137,7 @@ def get(self, request, pk): a.pk: a for a in Autor.objects.filter(id__in=visited).select_related("tytul") } - pbn_root = _pbn_root() + pbn_root = _pbn_root(Uczelnia.objects.get_for_request(request)) metryki = _metryki_prac(list(visited)) nodes = [] From 7be2b623802d095c9341e4704a4e903f00ffd6c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 08:19:31 +0200 Subject: [PATCH 142/247] fix(multi-hosted): zbieraj_sloty CLI single-or-fail uczelnia + Autor.zbieraj_sloty przelot (B2) Co-Authored-By: Claude Opus 4.8 --- src/bpp/management/commands/zbieraj_sloty.py | 52 +++++++++++++++++-- src/bpp/models/autor.py | 2 + ...agement_commands_zbieraj_sloty_uczelnia.py | 49 +++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/bpp/tests/test_management_commands_zbieraj_sloty_uczelnia.py diff --git a/src/bpp/management/commands/zbieraj_sloty.py b/src/bpp/management/commands/zbieraj_sloty.py index cff2fd4cd..689b7fb31 100644 --- a/src/bpp/management/commands/zbieraj_sloty.py +++ b/src/bpp/management/commands/zbieraj_sloty.py @@ -3,9 +3,10 @@ from decimal import Decimal from django.core.management import BaseCommand +from django.core.management.base import CommandError from django.db import transaction -from bpp.models import Autor, Cache_Punktacja_Autora, Rekord +from bpp.models import Autor, Cache_Punktacja_Autora, Rekord, Uczelnia logger = logging.getLogger("django") @@ -28,14 +29,59 @@ def add_arguments(self, parser): type=int, default=2020, ) + parser.add_argument( + "--uczelnia", + type=int, + default=None, + help="ID uczelni (wymagane gdy >1 uczelnia)", + ) + + def _resolve_uczelnia(self, uczelnia_id): + """Uczelnia dla komendy CLI (single-or-fail). + + - ``--uczelnia`` zawsze honorowane (i walidowane), + - przy dokładnie jednej uczelni używamy jej (``get()`` — count==1), + - przy wielu uczelniach brak ``--uczelnia`` to ``CommandError`` — + bez cichego wyboru pierwszej-z-brzegu. + """ + if uczelnia_id is not None: + try: + return Uczelnia.objects.get(pk=uczelnia_id) + except Uczelnia.DoesNotExist as e: + raise CommandError(f"Brak uczelni o id={uczelnia_id}.") from e + + count = Uczelnia.objects.count() + if count == 0: + return None + if count == 1: + return Uczelnia.objects.get() + raise CommandError( + "W systemie jest więcej niż jedna uczelnia — podaj --uczelnia, " + "żeby ograniczyć zbieranie slotów do jednej uczelni." + ) @transaction.atomic def handle( - self, autor_id, slot, rok_min, rok_max, verbosity, xls, *args, **options + self, + autor_id, + slot, + rok_min, + rok_max, + verbosity, + xls, + uczelnia, + *args, + **options, ): autor = Autor.objects.get(id=autor_id) + uczelnia_obj = self._resolve_uczelnia(uczelnia) - res, lista = autor.zbieraj_sloty(slot, rok_min, rok_max) + res, lista = autor.zbieraj_sloty( + slot, + rok_min, + rok_max, + uczelnia_id=uczelnia_obj.pk if uczelnia_obj else None, + ) wiersze = [] wiersze.append(["Parametry:"]) diff --git a/src/bpp/models/autor.py b/src/bpp/models/autor.py index 4b3199fc5..23ee0e0d6 100644 --- a/src/bpp/models/autor.py +++ b/src/bpp/models/autor.py @@ -389,6 +389,7 @@ def zbieraj_sloty( dyscyplina_id=None, jednostka_id=None, akcja=None, + uczelnia_id=None, ): return zbieraj_sloty( autor_id=self.pk, @@ -399,6 +400,7 @@ def zbieraj_sloty( dyscyplina_id=dyscyplina_id, jednostka_id=jednostka_id, akcja=akcja, + uczelnia_id=uczelnia_id, ) @property diff --git a/src/bpp/tests/test_management_commands_zbieraj_sloty_uczelnia.py b/src/bpp/tests/test_management_commands_zbieraj_sloty_uczelnia.py new file mode 100644 index 000000000..ad05ba959 --- /dev/null +++ b/src/bpp/tests/test_management_commands_zbieraj_sloty_uczelnia.py @@ -0,0 +1,49 @@ +"""Testy multi-hosted dla komendy ``zbieraj_sloty`` (audyt follow-up B2). + +Kontrakt do zablokowania: +(a) ``Autor.zbieraj_sloty`` przekazuje ``uczelnia_id`` do funkcji + ``bpp.core.zbieraj_sloty`` (scope per-uczelnia). +(b) Komenda CLI: gdy >1 uczelnia i brak ``--uczelnia`` -> ``CommandError`` + (bez cichego wyboru pierwszej-z-brzegu). +""" + +from unittest.mock import patch + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError +from model_bakery import baker + + +@pytest.mark.django_db +def test_autor_zbieraj_sloty_przekazuje_uczelnia_id(): + autor = baker.make("bpp.Autor") + with patch("bpp.models.autor.zbieraj_sloty") as m: + m.return_value = (0, []) + autor.zbieraj_sloty(4, 2017, 2020, uczelnia_id=123) + assert m.call_args.kwargs["uczelnia_id"] == 123 + + +@pytest.mark.django_db +def test_command_wiele_uczelni_bez_flagi_to_commanderror(): + autor = baker.make("bpp.Autor") + baker.make("bpp.Uczelnia") + baker.make("bpp.Uczelnia") + + with pytest.raises(CommandError): + call_command("zbieraj_sloty", autor.pk) + + +@pytest.mark.django_db +def test_command_jedna_uczelnia_nie_wymaga_flagi(): + autor = baker.make("bpp.Autor") + baker.make("bpp.Uczelnia") + + with patch("bpp.models.autor.zbieraj_sloty") as m: + m.return_value = (0, []) + # Komenda kończy się sys.exit(0) przy braku --xls; łapiemy SystemExit, + # liczy się tylko że NIE poleciał CommandError "podaj --uczelnia". + with pytest.raises(SystemExit): + call_command("zbieraj_sloty", autor.pk) + + assert m.call_args.kwargs["uczelnia_id"] is not None From cddd8ff91f9a2a82a09370bb63ee139335009459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 08:24:01 +0200 Subject: [PATCH 143/247] test(multi-hosted): lock integrator R2 delta (uczelnia=None -> brak auto-matchu) + docstringi (B3) Co-Authored-By: Claude Opus 4.8 --- .../management/commands/pbn_integrator.py | 8 +- .../tests/test_matcher_r2_delta.py | 105 ++++++++++++++++++ src/pbn_integrator/utils/scientists.py | 3 + 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 src/pbn_integrator/tests/test_matcher_r2_delta.py diff --git a/src/pbn_integrator/management/commands/pbn_integrator.py b/src/pbn_integrator/management/commands/pbn_integrator.py index 94de23f15..525191e5f 100644 --- a/src/pbn_integrator/management/commands/pbn_integrator.py +++ b/src/pbn_integrator/management/commands/pbn_integrator.py @@ -212,7 +212,13 @@ def _handle_system_and_sources(self, opts, client, s, e): ) def _handle_people(self, opts, client, s, e): - """Etapy 6-9: pobieranie i integracja ludzi.""" + """Etapy 6-9: pobieranie i integracja ludzi. + + Macierzysta uczelnia bierze się z ``client.uczelnia`` (jej + ``pbn_uid_id``) i jest używana do matchowania autorów po danych + zatrudnienia PBN (patrz reguła R2 w + ``matchuj_autora_po_stronie_pbn``). + """ ea = opts["enable_all"] pbn_uid_id = client.uczelnia.pbn_uid_id diff --git a/src/pbn_integrator/tests/test_matcher_r2_delta.py b/src/pbn_integrator/tests/test_matcher_r2_delta.py new file mode 100644 index 000000000..472a80fbe --- /dev/null +++ b/src/pbn_integrator/tests/test_matcher_r2_delta.py @@ -0,0 +1,105 @@ +"""Lock the multi-hosted R2 delta in matchuj_autora_po_stronie_pbn. + +Konsekwentna decyzja projektowa (reguła R2): autor bez macierzystej +uczelni (``aktualna_jednostka=None`` → ``uczelnia=None``) NIE jest +auto-matchowany po danych zatrudnienia PBN. Branch ``can_be_set`` w +``matchuj_autora_po_stronie_pbn`` wymaga ``uczelnia is not None`` ORAZ +zgodności ``institutionId`` w ``currentEmployments`` z +``uczelnia.pbn_uid_id``. Gdy ``uczelnia is None`` — branch jest pomijany, +``can_be_set`` zostaje False i funkcja zwraca None. + +Ten test jest charakteryzacyjny (zachowanie już istnieje w kodzie) i +nie-wakuotyczny: pozytywna kontrola (z prawdziwą uczelnią) dowodzi, że +fixtura DA SIĘ zmatchować, więc wynik None dla ``uczelnia=None`` wynika +z delty R2, a nie z błędnych danych. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Jednostka, Uczelnia +from pbn_api.models import Institution, Scientist +from pbn_integrator.utils.scientists import matchuj_autora_po_stronie_pbn + +IMIONA = "Jan Sebastian" +NAZWISKO = "Kowalski-Testowy" + + +def _make_uczelnia_with_pbn(): + institution = baker.make(Institution) + uczelnia = baker.make(Uczelnia, pbn_uid=institution) + obca = baker.make(Jednostka, uczelnia=uczelnia, skupia_pracownikow=False) + uczelnia.obca_jednostka = obca + uczelnia.save() + return uczelnia + + +def _make_scientist(*, employments_institution_id, extra_marker, rich=False): + """Scientist niez-API-instytucji, dopasowany po imieniu/nazwisku. + + ``versions[*].object`` zawiera lastName/name (żeby trafił w + ``versions__contains`` query) oraz currentEmployments z podanym + institutionId (żeby branch ``can_be_set`` mógł go zaakceptować przy + podanej uczelni). ``rich=True`` dokłada legacyIdentifiers + + qualifications, żeby rekord miał ściśle więcej punktów ratingu i + deterministycznie wylądował jako ``rated_elems[0]``. + """ + obj = { + "lastName": NAZWISKO, + "name": IMIONA, + "currentEmployments": [ + { + "institutionId": employments_institution_id, + "institutionDisplayName": f"Inst {extra_marker}", + } + ], + "externalIdentifiers": {"marker": extra_marker}, + } + if rich: + obj["legacyIdentifiers"] = {"legacy": extra_marker} + obj["qualifications"] = "dr hab." + return baker.make( + Scientist, + from_institution_api=False, + versions=[{"current": True, "object": obj}], + ) + + +@pytest.mark.django_db +def test_r2_delta_uczelnia_none_brak_matchu_a_z_uczelnia_match(): + """uczelnia=None → None (delta R2); ta sama fixtura z uczelnią → match.""" + uczelnia = _make_uczelnia_with_pbn() + + # DWA rekordy z tym samym imieniem/nazwiskiem, oba spoza API + # instytucji → ``.get()`` w name-path rzuca MultipleObjectsReturned i + # wchodzimy w pętlę ratingu, gdzie żyje branch uczelnia/can_be_set. + s1 = _make_scientist( + employments_institution_id=uczelnia.pbn_uid_id, + extra_marker="A", + rich=True, + ) + _make_scientist( + employments_institution_id="INNA-INSTYTUCJA", + extra_marker="B", + ) + + # Sanity: brak rekordu z API instytucji, więc nie ma wcześniejszego + # return-a; w grze jest tylko ścieżka non-API z ratingiem. + assert not Scientist.objects.filter(from_institution_api=True).exists() + + # NEGATYWNA (delta): bez macierzystej uczelni nie matchujemy. + res_none = matchuj_autora_po_stronie_pbn( + IMIONA, NAZWISKO, orcid=None, uczelnia=None + ) + assert res_none is None + + # POZYTYWNA kontrola: z uczelnią, której pbn_uid_id jest w + # currentEmployments → zwracamy Scientisa (dowodzi, że fixtura jest + # matchowalna, a None powyżej wynika z delty R2). + res_match = matchuj_autora_po_stronie_pbn( + IMIONA, NAZWISKO, orcid=None, uczelnia=uczelnia + ) + assert res_match is not None + assert isinstance(res_match, Scientist) + # Zwrócony rekord to ten z employmentem w naszej uczelni. + assert res_match.pk == s1.pk diff --git a/src/pbn_integrator/utils/scientists.py b/src/pbn_integrator/utils/scientists.py index 0f60d8a2f..e7be769c2 100644 --- a/src/pbn_integrator/utils/scientists.py +++ b/src/pbn_integrator/utils/scientists.py @@ -341,6 +341,9 @@ def matchuj_autora_po_stronie_pbn(imiona, nazwisko, orcid, uczelnia): # noqa: C imiona: First names. nazwisko: Last name. orcid: ORCID identifier. + uczelnia: Home-uczelnia autora (z aktualna_jednostka) lub None. + Gdy None, autor nie jest auto-matchowany po danych zatrudnienia + PBN (reguła R2 — odłączony autor = nie pracownik). Returns: Scientist object or None. From d02fe6219486c613ae4407091cca05c829204b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 08:26:13 +0200 Subject: [PATCH 144/247] docs(multi-hosted): higiena - self-review superseded, notka HST (write-side), API ownership R1 (B4+B5) Co-Authored-By: Claude Opus 4.8 --- ...-06-03-self-review-per-uczelnia-sloty-write-side.md | 10 ++++++++++ .../specs/2026-06-02-per-uczelnia-sloty-design.md | 10 ++++++++++ .../2026-06-03-per-uczelnia-sloty-read-side-design.md | 7 +++++++ 3 files changed, 27 insertions(+) diff --git a/docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md b/docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md index decd7499a..e86375f43 100644 --- a/docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md +++ b/docs/superpowers/reviews/2026-06-03-self-review-per-uczelnia-sloty-write-side.md @@ -1,5 +1,15 @@ # Self-review — per-uczelnia sloty (write-side) +> **⚠️ SUPERSEDED (2026-06-04).** Ten self-review jest PRZETERMINOWANY — opisuje +> stan na swoją datę. Jego otwarte MEDIUM/LOW zostały od tego czasu domknięte: +> NOT NULL na `Cache_Punktacja_Dyscypliny.uczelnia` (commit `6c888f245`, mig +> `0428`), indeks `(rekord_id, uczelnia, dyscyplina)` (mig `0427`), ekspozycja +> `uczelnia` w widoku (mig `0426`), `wiele_hst`/HST per-uczelnia (`c687ceb07`). +> Twierdzenia „NOT NULL niemożliwe" / „widok nie eksponuje uczelni" / „brak +> indeksu" już NIE są prawdą. Aktualny przegląd całości: Audyt 4a w +> `docs/superpowers/2026-06-03-audyty-multihosted-4x.md`. Trzymane jako zapis +> historyczny. + Data: 2026-06-03. Gałąź: `feature/multi-hosted-config`. Zakres: `git diff 58daf3e1c..HEAD` na `src/bpp/models/sloty/`, `src/bpp/models/cache/punktacja.py`, `src/bpp/models/abstract/disciplines.py`, diff --git a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md index 55a436c55..e54e115f4 100644 --- a/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md +++ b/docs/superpowers/specs/2026-06-02-per-uczelnia-sloty-design.md @@ -106,6 +106,16 @@ i zapytaniach grupujących per uczelnia. Decyzja usera: trzymać wyprowadzaną. ## Kalkulator slotów (`ISlot(publikacja, uczelnia=None)` — opcjonalna uczelnia) +> **Korekta (2026-06-04, hardening #1 — commit `c687ceb07`):** reguła „wybór +> progu/klasy `_dopasuj_kalkulator` jest niezależny od uczelni" została +> ŚWIADOMIE ZREWIDOWANA dla `wiele_hst`. `_dopasuj_kalkulator(original, +> uczelnia=None)` liczy `rodzaje_hst` z `wszystkie_dyscypliny_rekordu(uczelnia)` +> ZAWĘŻONEGO per-uczelnia — bo mnożnik HST 1.5× to cecha per-dyscyplina/uczelnia, +> a globalne `wiele_hst` psułoby liczby dla prac cross-uczelnia. `canAdapt()` +> woła bez uczelni (globalny boolean adapt-check). Single-install: filtr no-op → +> liczby identyczne. Patrz Audyt 4a. Reszta sekcji (poniżej) opisuje pierwotny +> zamysł — selekcja progu poza HST nadal jest uczelnia-niezależna. + Kalkulacje robi się przez `ISlot(publikacja)`; dla wygody przyjmuje opcjonalną `uczelnia`. Wybór progu/klasy (`_dopasuj_kalkulator`) jest niezależny od uczelni — od niej zależy tylko liczenie autorów (dzielnik). Split na uczelnie robi cacher diff --git a/docs/superpowers/specs/2026-06-03-per-uczelnia-sloty-read-side-design.md b/docs/superpowers/specs/2026-06-03-per-uczelnia-sloty-read-side-design.md index 87c0dc53f..a51be35ba 100644 --- a/docs/superpowers/specs/2026-06-03-per-uczelnia-sloty-read-side-design.md +++ b/docs/superpowers/specs/2026-06-03-per-uczelnia-sloty-read-side-design.md @@ -105,6 +105,13 @@ komenda → argument/`.get()`): queryset viewsetu sam nie listuje cudzych raportów → ograniczyć do uczelni żądającego (hybryda). +> **Realizacja (2026-06-04, Audyt 4b):** viewset filtruje per-OWNER +> (`request.user`), nie per-UCZELNIA. Bezpieczne — user nie zobaczy cudzego +> raportu. Dla R1 przyjmujemy **ownership ≈ uczelnia** (świadoma decyzja). Edge +> nieobsłużony: superuser z override `?uczelnia=` widzi przez API własne raporty +> WSZYSTKICH uczelni naraz (mała populacja, akceptowalne dla R1). Pełny per-uczelnia +> filtr API — ewentualny późniejszy follow-up. + ## Data flow request → `uczelnia_dla_odczytu(request)` (site lub override superusera) → From 2531472396d370be0a807c2cdd399bda57e4f758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 08:28:34 +0200 Subject: [PATCH 145/247] docs(multi-hosted): HANDOFF - B1-B5 ZROBIONE; brief next-session spec D (ewaluacja_metryki per-uczelnia) Co-Authored-By: Claude Opus 4.8 --- docs/superpowers/HANDOFF-multi-hosted.md | 33 +++-- .../NEXT-SESSION-metryki-per-uczelnia.md | 117 ++++++++++++++++++ 2 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/NEXT-SESSION-metryki-per-uczelnia.md diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index 37e2fd3fb..2940cbb5e 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -254,19 +254,32 @@ poza zakresem R1 i NIE odnotowane jako wyłączone → kandydat na wątek **R3** dodano **4. picker `PublicJednostkaAutocomplete`** (filtr jednostki w rankingu / „zgłoś publikację", był poza pierwotnym zakresem — commit `f446fc268`). Tym samym **R3 (a+b) read-side publiczny domknięty**. -- **B) Drobne gotowe:** `powiazania_autorow/queries.py:_pbn_root()` → - `get_for_request` (jedyny realny dług z whitelisty get_default, Audyt 1); - **LUKA R1:** komenda `zbieraj_sloty` CLI + `Autor.zbieraj_sloty` nie przekazują - `uczelnia_id` (spec wymieniał dwukrotnie); test delty R2 integratora - (`uczelnia=None`→`None`) + docstringi; oznaczyć stary self-review write-side - SUPERSEDED + notka HST do spec write-side. +- ✅ **B) Drobne ZROBIONE (2026-06-04):** + - **B1** `powiazania_autorow/queries.py:_pbn_root(uczelnia)` — z requestu + (`get_for_request`), nie `get_default`; usunięty OSTATNI dług z whitelisty + get_default. Commit `77524dfd6`. + - **B2** `zbieraj_sloty` CLI: `Autor.zbieraj_sloty` przelot `uczelnia_id` + + komenda `--uczelnia` single-or-fail (`.get()` dla count==1, nie `get_default` + → guard nietknięty). Commit `7be2b6238`. + - **B3** test delty R2 integratora (`uczelnia=None`→`None`, realne fixtury + Scientist, pozytywna+negatywna) + docstringi `matchuj_autora`/`_handle_people` + (zero zmian logiki). Commit `cddd8ff91`. + - **B4+B5** higiena docs: stary self-review write-side oznaczony SUPERSEDED; + notka HST per-uczelnia w spec write-side; notka „ownership≈uczelnia" (API + per-owner) w spec R1. Commit `d02fe6219`. - **C) Federacja olana, ale bugi KORUPCJI DANYCH** (decyzja do podjęcia): `OptimizationRun.delete()` cross-uczelnia (`ewaluacja_optymalizacja/tasks/optimization.py:73`), `reset_all_pins_task`/`optimize_and_unpin` globalne querysety, komparatory PBN globalny `.delete()`. To integralność, nie logika federacyjna — można scope-fix niezależnie. -- **D) `ewaluacja_metryki` per-uczelnia** (osobny wątek write+read): `MetrykaAutora` - bez FK uczelnia + 5× globalne `IloscUdzialow…objects.all()`. Kształt jak liczba_n R2. +- **D) `ewaluacja_metryki` per-uczelnia — NASTĘPNY (wymaga spec-a).** `MetrykaAutora` + bez FK uczelnia (`unique_together(autor,dyscyplina)` bez uczelni) + globalne + `IloscUdzialowDlaAutoraZaCalosc.objects.all()` (`tasks.py:231,357`, `utils.py:277`, + `oblicz_metryki.py:132`, `generation.py:74`) + globalny rebuild + `MetrykaAutora.objects.all().delete()` (`utils.py:556`, `tasks.py:245`) + odczyty + eksport/statystyki (`export_helpers.py:11,357`, `statistics.py:50`). Kształt jak + liczba_n R2 (FK+backfill+scope pipeline+widoki). **Pełny brief + prompt do + wklejenia po resecie: `docs/superpowers/NEXT-SESSION-metryki-per-uczelnia.md`.** ### Stan zgodności ze spec (Audyt 4) - Write-side sloty: ✓ 31/31 (1 świadomy korzystny rozjazd — HST per-uczelnia). @@ -275,7 +288,9 @@ poza zakresem R1 i NIE odnotowane jako wyłączone → kandydat na wątek **R3** - Integrator: ✓ 16/16 (2 nieblokujące: test delty R2, docstring). ### Guard get_default: nadal szczelny -10 wpisów whitelisty, 9 ZOSTAJE (świadome), 1 DO ZROBIENIA (`powiazania_autorow` → B). +Po B1: **9 wpisów whitelisty, wszystkie ZOSTAJĄ** (świadome fallbacki bez requestu / +None-tolerant warstwa modelu / display / guarded count==1 / komentarz). Zero +otwartych długów (`powiazania_autorow` usunięty w B1, commit `77524dfd6`). --- diff --git a/docs/superpowers/NEXT-SESSION-metryki-per-uczelnia.md b/docs/superpowers/NEXT-SESSION-metryki-per-uczelnia.md new file mode 100644 index 000000000..cf93995cc --- /dev/null +++ b/docs/superpowers/NEXT-SESSION-metryki-per-uczelnia.md @@ -0,0 +1,117 @@ +# NEXT SESSION — ewaluacja_metryki per-uczelnia (spec D) — do wklejenia po resecie + +> Wklej sekcję „PROMPT DO WKLEJENIA" jako pierwszą wiadomość po resecie sesji. +> Reszta to kontekst, który agent doczyta. + +--- + +## PROMPT DO WKLEJENIA + +Jesteś świeżą sesją. Repo: `~/Programowanie/bpp-multi-hosted-config`, gałąź +`feature/multi-hosted-config` (BPP, Django, instalacja wielouczelniana). Cały +read-side publiczny (R3a widoki + R3b autocomplety), sloty write/read (R1), +ewaluacja_liczba_n (R2), integrator, HST, verify.py oraz drobiazgi audytowe +(B1–B5) są ZROBIONE i wypushowane — pełny stan w +`docs/superpowers/HANDOFF-multi-hosted.md` (przeczytaj NAJPIERW). + +Teraz nowy wątek: **ewaluacja_metryki per-uczelnia (wątek D)**. Chcę, żeby +liczenie „liczby N" ORAZ metryki ewaluacyjne były per-uczelnia. To wymaga +**spec-a** — przejdź ścieżką brainstorming → spec → plan → subagent-driven +(jak poprzednie wątki). Zacznij od `superpowers:brainstorming`. NIE pisz kodu +przed zatwierdzeniem spec-a. + +Zanim zadasz pytania, zrób krótki recon (read-only): `src/ewaluacja_metryki/` +(modele, `utils.py`, `tasks.py`, `views/`, `management/commands/oblicz_metryki.py`, +`export_helpers.py`) — żeby pytania były konkretne. Najważniejsze fakty (do +potwierdzenia w kodzie) są w sekcji KONTEKST niżej. + +Reguły: `uv run` zawsze; testy `-p no:cacheprovider` (testcontainers, Docker +musi działać); guard `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q`; +lint `uv run ruff check` ORAZ `uv run ruff format --check` (NIE `--fix`); +commit/push tylko na moją prośbę. + +--- + +## KONTEKST (stan na 2026-06-04, agent może doczytać) + +### Co to za wątek i dlaczego +`ewaluacja_metryki` liczy metryki ewaluacyjne autorów (m.in. „liczba N", +średnie udziały, bonusy). R2 (`ewaluacja_liczba_n`) zawęził już **źródło** +udziałów per-uczelnia (FK `uczelnia` na `IloscUdzialowDlaAutoraZaRok/ZaCalosc`, +cały pipeline `ewaluacja_liczba_n/utils.py`). ALE **konsument** w +`ewaluacja_metryki` czyta te udziały i pisze metryki **globalnie** — więc w +multi-install metryki mieszają uczelnie. + +### Luki (zweryfikowane — Audyt 2 + 4c) +1. **`MetrykaAutora` (`ewaluacja_metryki/models.py:9`) NIE ma FK `uczelnia`.** + `unique_together = [("autor", "dyscyplina_naukowa")]` (models.py:115) — bez + uczelni. W multi-install autor afiliowany do >1 uczelni nie ma rozłącznych + metryk per uczelnia (kolizja unique_together). Pola: `autor`, + `dyscyplina_naukowa`, `jednostka` (FK), brak `uczelnia`. +2. **Globalne odczyty `IloscUdzialowDlaAutoraZaCalosc.objects.all()`:** + `tasks.py:231`, `tasks.py:357`, `utils.py:277`, `oblicz_metryki.py:132`, + `views/generation.py:74` — liczą metryki ze WSZYSTKICH uczelni naraz. +3. **Globalny rebuild `MetrykaAutora.objects.all().delete()`:** `utils.py:556`, + `tasks.py:245-246` — kasuje metryki wszystkich uczelni przy przeliczaniu jednej. +4. **Globalne odczyty `MetrykaAutora.objects.all()`** (eksport/statystyki): + `export_helpers.py:11,357`, `views/statistics.py:50`. Te są read-side widoków + — powinny filtrować po uczelni oglądającego (`get_for_request`). + Uwaga: liczne `MetrykaAutora.objects.filter(autor=...)` (per-autor, transitive, + np. `views/detail.py`, `views/list.py`, `export.py`, `pin_unpin.py`) są + prawdopodobnie OK (zawężone przez autora) — do oceny w recon. + +### Kształt rozwiązania (wzorzec R2 — `ewaluacja_liczba_n`) +To jest **write+read**, bliżej R2 niż filtrów odczytu. Analogicznie do R2: +- **FK `uczelnia` na `MetrykaAutora`** (+ poprawić `unique_together` na + `("autor","dyscyplina_naukowa","uczelnia")`) + migracja + backfill: single → + domyślna uczelnia; multi-z-danymi → **fail** (jak mig `0009` w R2 / `0425` + w write-side). NIGDY nie modyfikuj istniejących migracji. +- **Pipeline liczenia** (`utils.py`, `tasks.py`, `oblicz_metryki.py`) zawężony + per uczelnia: odczyt `IloscUdzialow*` po `uczelnia`, atrybucja autora przez + `aktualna_jednostka.uczelnia` (reguła R2, niżej), delete/rebuild scoped per + uczelnia (naprawić globalny `objects.all().delete()`). +- **Generation task** (`generuj_metryki_task` w `tasks.py`) — przyjmuje + `uczelnia_id` (część liczba_n już go ma; rozszerzyć na metryki). Tło → + `Uczelnia.objects.get_for_pbn_background(uczelnia_id)` / `.get()` single-or-fail, + NIGDY `get_default`/`get_for_request`. +- **Widoki read-side** (list/detail/statistics/export, `export_helpers.py`) + filtrują `MetrykaAutora` po `uczelnia_dla_odczytu(request)` (helper z R1, + hybryda site+superuser; `src/raport_slotow/uczelnia_helper.py`). + +### Reguła atrybucji autora do uczelni (binarna, projektowa) +`autor.aktualna_jednostka.uczelnia`, tylko gdy `jednostka.skupia_pracownikow=True` +(NULL/obca jednostka → autor wykluczony). To reguła R2/verify; dla METRYK +(write-side liczenia) prawdopodobnie ta sama. Read-side widoków → filtr po +uczelni oglądającego. + +### Niezmiennik +Single-install: backfill wpisuje domyślną uczelnię, filtry stają się no-op → +liczby/metryki identyczne jak dziś. Wszystkie testy regresyjne metryk muszą +przejść. Wzorzec guardu `Uczelnia.objects.count()==1` jak w R3a +(`bpp/util/uczelnia_scope.tylko_jedna_uczelnia`). + +### Infrastruktura testowa (gotowa, używaj jej) +`fixtures.conftest_multisite` (zarejestrowana jako pytest plugin w +`src/conftest.py`): fixtury `uczelnia1/2`, `site1/2`, `jednostka_uczelnia1/2`, +`autor_uczelnia1/2`, helper `make_request_for_site(site)` (odpala +`SiteResolutionMiddleware` → ustawia `request._uczelnia`; w testach widoków +ustaw `settings.ALLOWED_HOSTS=["*"]`). Wzorzec testu izolacji: 2 uczelnie, +asercja pozytywna+negatywna (moja jest, obca nie). + +### Dokumenty referencyjne +- Spec R2 (wzorzec): `specs/2026-06-03-ewaluacja-liczba-n-per-uczelnia-design.md` +- Plan R2: `plans/2026-06-03-ewaluacja-liczba-n-per-uczelnia-R2.md` +- Write-side (FK+backfill+migracja 0425 wzorzec): `specs/2026-06-02-per-uczelnia-sloty-design.md` +- Audyty (znaleziska D): `2026-06-03-audyty-multihosted-4x.md` (Audyt 2 #5, Audyt 4c) +- Master: `HANDOFF-multi-hosted.md` + +### Decyzje do rozstrzygnięcia w brainstormingu (przykładowe pytania) +- Czy „liczba N" w tytule = to co R2 (`LiczbaNDlaUczelni`, już per-uczelnia) czy + coś jeszcze w metrykach? (potwierdzić zakres — może część już zrobiona w R2). +- Czy `MetrykaAutora.jednostka` wystarcza do wyprowadzenia uczelni (jednostka→uczelnia) + zamiast nowego FK? (rozważyć — ale unique_together i tak wymaga uczelni jawnie; + jednostka może być NULL). +- Read-side eksportów/statystyk: filtr po uczelni oglądającego czy zostawić + globalne dla superusera (jak hybryda R1)? +- Zakres widoków: które `MetrykaAutora.objects.filter(autor=...)` są już-zawężone + (transitive) a które wymagają jawnej uczelni. From 32823854591bf809886f4817dfccb4de2f0805a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 09:55:31 +0200 Subject: [PATCH 146/247] fix(multi-hosted): przemapuj_zrodlo/przemapuj_zrodla_pbn enqueue z uczelnia z requestu (B6) Co-Authored-By: Claude Opus 4.8 --- .../tests/test_enqueue_uczelnia.py | 82 +++++++++++++++++++ src/przemapuj_zrodla_pbn/views.py | 9 +- .../tests/test_enqueue_uczelnia.py | 33 ++++++++ src/przemapuj_zrodlo/views.py | 8 +- 4 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 src/przemapuj_zrodla_pbn/tests/test_enqueue_uczelnia.py create mode 100644 src/przemapuj_zrodlo/tests/test_enqueue_uczelnia.py diff --git a/src/przemapuj_zrodla_pbn/tests/test_enqueue_uczelnia.py b/src/przemapuj_zrodla_pbn/tests/test_enqueue_uczelnia.py new file mode 100644 index 000000000..341d09ab8 --- /dev/null +++ b/src/przemapuj_zrodla_pbn/tests/test_enqueue_uczelnia.py @@ -0,0 +1,82 @@ +"""Test B6 (multi-hosted): widok ``przemapuj_zrodlo`` musi zapisywać +``uczelnia`` na wpisie kolejki eksportu PBN, pobraną z requestu. + +Na instalacji multi-hosted wpis bez ``uczelnia`` failuje w czasie wysyłki +(``Uczelnia.objects.get()`` -> ``MultipleObjectsReturned``). + +Widok jest sterowany przez Django test ``Client`` (POST z ``confirm``) +z ``HTTP_HOST`` ustawionym na domenę site1 — to faktycznie przechodzi +przez middleware rozpoznający uczelnię, więc test dowodzi, że WIDOK +przekazuje uczelnię (a nie tylko że manager kolejki ją wspiera). +""" + +import pytest +from django.urls import reverse +from model_bakery import baker + +from pbn_export_queue.models import PBN_Export_Queue + + +@pytest.mark.django_db +def test_przemapuj_zrodlo_enqueue_sets_uczelnia_from_request( + client, uczelnia1, site1, settings, denorms +): + settings.ALLOWED_HOSTS = ["*"] + + user = baker.make("bpp.BppUser") + client.force_login(user) + + journal_deleted = baker.make( + "pbn_api.Journal", + status="DELETED", + title="Skasowana Gazeta", + issn="1234-5678", + eissn="", + websiteLink="", + ) + zrodlo_stare = baker.make( + "bpp.Zrodlo", + nazwa="Skasowana Gazeta", + pbn_uid=journal_deleted, + issn="1234-5678", + ) + + journal_active = baker.make( + "pbn_api.Journal", + status="ACTIVE", + title="Skasowana Gazeta Nowa", + issn="1234-5678", + mniswId="12345", + eissn="", + websiteLink="", + ) + zrodlo_nowe = baker.make( + "bpp.Zrodlo", + nazwa="Skasowana Gazeta Nowa", + pbn_uid=journal_active, + issn="1234-5678", + ) + + pub = baker.make("bpp.Wydawnictwo_Ciagle", zrodlo=zrodlo_stare) + + # Materializuj cache, żeby Rekord.objects.filter(zrodlo=...) widział rekord + denorms.flush() + + url = reverse( + "przemapuj_zrodla_pbn:przemapuj_zrodlo", kwargs={"zrodlo_id": zrodlo_stare.pk} + ) + response = client.post( + url, + { + "typ_wyboru": "zrodlo", + "zrodlo_docelowe": zrodlo_nowe.pk, + "confirm": "1", + }, + HTTP_HOST=site1.domain, + ) + + assert response.status_code == 302 + + wpis = PBN_Export_Queue.objects.filter_rekord_do_wysylki(pub).first() + assert wpis is not None, "rekord powinien trafić do kolejki PBN" + assert wpis.uczelnia_id == uczelnia1.pk diff --git a/src/przemapuj_zrodla_pbn/views.py b/src/przemapuj_zrodla_pbn/views.py index f888b6293..469936c28 100644 --- a/src/przemapuj_zrodla_pbn/views.py +++ b/src/przemapuj_zrodla_pbn/views.py @@ -8,7 +8,7 @@ from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect, render -from bpp.models import Rekord, Rodzaj_Zrodla, Wydawnictwo_Ciagle, Zrodlo +from bpp.models import Rekord, Rodzaj_Zrodla, Uczelnia, Wydawnictwo_Ciagle, Zrodlo from pbn_api.const import ACTIVE, DELETED from pbn_api.models import Journal from pbn_export_queue.models import PBN_Export_Queue @@ -561,10 +561,15 @@ def przemapuj_zrodlo(request, zrodlo_id): # noqa: C901 # Dodaj do kolejki PBN sukces_pbn = 0 bledy_pbn = [] + # Rozwiąż uczelnię raz, poza pętlą (na multi-hosted + # decyduje, do którego PBN-a wpis zostanie wysłany). + uczelnia = Uczelnia.objects.get_for_request(request) for original_rekord in rekordy_do_wyslania: try: PBN_Export_Queue.objects.sprobuj_utowrzyc_wpis( - user=request.user, rekord=original_rekord + user=request.user, + rekord=original_rekord, + uczelnia=uczelnia, ) sukces_pbn += 1 except Exception as e: diff --git a/src/przemapuj_zrodlo/tests/test_enqueue_uczelnia.py b/src/przemapuj_zrodlo/tests/test_enqueue_uczelnia.py new file mode 100644 index 000000000..38ecab0df --- /dev/null +++ b/src/przemapuj_zrodlo/tests/test_enqueue_uczelnia.py @@ -0,0 +1,33 @@ +"""Test B6 (multi-hosted): widok przemapowania źródła musi zapisywać +``uczelnia`` na wpisie kolejki eksportu PBN, pobraną z requestu. + +Na instalacji multi-hosted wpis bez ``uczelnia`` failuje w czasie wysyłki +(``Uczelnia.objects.get()`` -> ``MultipleObjectsReturned``). +""" + +import pytest +from model_bakery import baker + +from fixtures.conftest_multisite import make_request_for_site +from pbn_export_queue.models import PBN_Export_Queue +from przemapuj_zrodlo.views import PrzemapujZrodloView + + +@pytest.mark.django_db +def test_enqueue_sets_uczelnia_from_request(uczelnia1, site1, settings): + settings.ALLOWED_HOSTS = ["*"] + + user = baker.make("bpp.BppUser") + pub = baker.make("bpp.Wydawnictwo_Ciagle") + + view = PrzemapujZrodloView() + view.request = make_request_for_site(site1, user=user) + + sukces, bledy = view._enqueue_publikacje_to_pbn([pub]) + + assert sukces == 1 + assert bledy == [] + + wpis = PBN_Export_Queue.objects.filter_rekord_do_wysylki(pub).first() + assert wpis is not None + assert wpis.uczelnia_id == uczelnia1.pk diff --git a/src/przemapuj_zrodlo/views.py b/src/przemapuj_zrodlo/views.py index 7ced0e44f..12de6288e 100644 --- a/src/przemapuj_zrodlo/views.py +++ b/src/przemapuj_zrodlo/views.py @@ -8,7 +8,7 @@ from django.views.generic import FormView, View from bpp.const import GR_WPROWADZANIE_DANYCH -from bpp.models import Wydawnictwo_Ciagle, Zrodlo +from bpp.models import Uczelnia, Wydawnictwo_Ciagle, Zrodlo from pbn_api.exceptions import AlreadyEnqueuedError from pbn_export_queue.models import PBN_Export_Queue @@ -145,10 +145,14 @@ def _enqueue_publikacje_to_pbn(self, publikacje_do_wyslania): sukces_pbn = 0 bledy_pbn = [] + # Rozwiąż uczelnię raz, poza pętlą (na multi-hosted decyduje o tym, + # do którego PBN-a wpis zostanie wysłany). + uczelnia = Uczelnia.objects.get_for_request(self.request) + for pub in publikacje_do_wyslania: try: PBN_Export_Queue.objects.sprobuj_utowrzyc_wpis( - user=self.request.user, rekord=pub + user=self.request.user, rekord=pub, uczelnia=uczelnia ) sukces_pbn += 1 except AlreadyEnqueuedError: From 4dd25a52adac4f880a32e4898d93081a7e38b027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 09:56:45 +0200 Subject: [PATCH 147/247] =?UTF-8?q?docs(multi-hosted):=20HANDOFF=20-=20B6?= =?UTF-8?q?=20(kolejka=20PBN=20uczelnio-zale=C5=BCna,=20enqueue=20przemapu?= =?UTF-8?q?j=5F*=20naprawione)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/superpowers/HANDOFF-multi-hosted.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index 2940cbb5e..db9cc9fe9 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -267,6 +267,16 @@ poza zakresem R1 i NIE odnotowane jako wyłączone → kandydat na wątek **R3** - **B4+B5** higiena docs: stary self-review write-side oznaczony SUPERSEDED; notka HST per-uczelnia w spec write-side; notka „ownership≈uczelnia" (API per-owner) w spec R1. Commit `d02fe6219`. + - **B6** (znalezisko z pytania usera) **kolejka PBN jest uczelnio-zależna** — + `PBN_Export_Queue.uczelnia` FK (nullable), `send_to_pbn` buduje klienta z + `entry.uczelnia`, fallback `None`→`Uczelnia.objects.get()` single-or-fail + (głośny fail na multi, NIE get_default). Główne enqueue (admin batch, + importer) tagują uczelnię. Dwa enqueue w `przemapuj_zrodlo/views.py` i + `przemapuj_zrodla_pbn/views.py` NIE tagowały (wpisy `uczelnia=None` → padały + przy wysyłce na multi) → naprawione: `uczelnia=get_for_request(request)` + (hoisted przed pętlę). Commit `32823854`. UWAGA: drugi agent dorabia + równolegle soft-deletes + event WYCOFANIE w kolejce — model `PBN_Export_Queue` + świadomie NIEtknięty w B6. - **C) Federacja olana, ale bugi KORUPCJI DANYCH** (decyzja do podjęcia): `OptimizationRun.delete()` cross-uczelnia (`ewaluacja_optymalizacja/tasks/optimization.py:73`), `reset_all_pins_task`/`optimize_and_unpin` globalne querysety, komparatory PBN From 16786180e0ea7556c292347ce741921be2da9b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 11:11:30 +0200 Subject: [PATCH 148/247] feat(metryki): FK uczelnia + unique_together per uczelnia (D, task 1) Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/0006_metrykaautora_uczelnia.py | 62 +++++++++++++++++++ src/ewaluacja_metryki/models.py | 11 +++- .../tests/test_per_uczelnia.py | 37 +++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py create mode 100644 src/ewaluacja_metryki/tests/test_per_uczelnia.py diff --git a/src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py b/src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py new file mode 100644 index 000000000..32cd9fe8a --- /dev/null +++ b/src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py @@ -0,0 +1,62 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def backfill_uczelnia(apps, schema_editor): + Uczelnia = apps.get_model("bpp", "Uczelnia") + MetrykaAutora = apps.get_model("ewaluacja_metryki", "MetrykaAutora") + + null_qs = MetrykaAutora.objects.filter(uczelnia__isnull=True) + if not null_qs.exists(): + return + + uczelnie = list(Uczelnia.objects.all()[:2]) + if len(uczelnie) == 1: + null_qs.update(uczelnia=uczelnie[0]) + return + + # MetrykaAutora to regenerowalny cache (delete+create przy generowaniu); + # przy >1 uczelni nie da się zdeterministycznie przypisać legacy wierszy, + # więc czyścimy — odtworzą się przy najbliższym generuj_metryki per uczelnia. + null_qs.delete() + + +def backfill_uczelnia_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0428_cpd_uczelnia_not_null"), + ("ewaluacja_metryki", "0005_alter_metrykaautora_rodzaj_autora_and_more"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="metrykaautora", + unique_together=set(), + ), + migrations.AddField( + model_name="metrykaautora", + name="uczelnia", + field=models.ForeignKey( + blank=True, + help_text="Uczelnia, dla której policzono metrykę (multi-hosted)", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="bpp.uczelnia", + ), + ), + migrations.AlterUniqueTogether( + name="metrykaautora", + unique_together={("autor", "dyscyplina_naukowa", "uczelnia")}, + ), + migrations.AddIndex( + model_name="metrykaautora", + index=models.Index( + fields=["uczelnia", "-srednia_za_slot_nazbierana"], + name="ewaluacja_m_uczelni_1e8d4d_idx", + ), + ), + migrations.RunPython(backfill_uczelnia, backfill_uczelnia_reverse), + ] diff --git a/src/ewaluacja_metryki/models.py b/src/ewaluacja_metryki/models.py index 7136304ef..925a210cd 100644 --- a/src/ewaluacja_metryki/models.py +++ b/src/ewaluacja_metryki/models.py @@ -22,6 +22,14 @@ class MetrykaAutora(models.Model): help_text="Główna jednostka autora w okresie ewaluacji", ) + uczelnia = models.ForeignKey( + "bpp.Uczelnia", + on_delete=models.CASCADE, + null=True, + blank=True, + help_text="Uczelnia, dla której policzono metrykę (multi-hosted)", + ) + # Dane z algorytmu plecakowego (nazbierane optymalne) slot_maksymalny = models.DecimalField( max_digits=10, @@ -112,12 +120,13 @@ class MetrykaAutora(models.Model): class Meta: verbose_name = "Metryka autora" verbose_name_plural = "Metryki autorów" - unique_together = [("autor", "dyscyplina_naukowa")] + unique_together = [("autor", "dyscyplina_naukowa", "uczelnia")] ordering = ["-srednia_za_slot_nazbierana", "autor__nazwisko", "autor__imiona"] indexes = [ models.Index(fields=["-srednia_za_slot_nazbierana"]), models.Index(fields=["jednostka", "-srednia_za_slot_nazbierana"]), models.Index(fields=["dyscyplina_naukowa", "-srednia_za_slot_nazbierana"]), + models.Index(fields=["uczelnia", "-srednia_za_slot_nazbierana"]), ] def __str__(self): diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py new file mode 100644 index 000000000..71e88dd78 --- /dev/null +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -0,0 +1,37 @@ +from decimal import Decimal + +import pytest +from model_bakery import baker + +from ewaluacja_metryki.models import MetrykaAutora + + +def _make_metryka(autor, dyscyplina, uczelnia, **kw): + defaults = dict( + slot_maksymalny=Decimal("4.0"), + slot_nazbierany=Decimal("2.0"), + punkty_nazbierane=Decimal("100.0"), + slot_wszystkie=Decimal("3.0"), + punkty_wszystkie=Decimal("150.0"), + ) + defaults.update(kw) + return MetrykaAutora.objects.create( + autor=autor, dyscyplina_naukowa=dyscyplina, uczelnia=uczelnia, **defaults + ) + + +@pytest.mark.django_db +def test_metryka_ma_uczelnia(autor_jan_kowalski, dyscyplina1): + u = baker.make("bpp.Uczelnia") + m = _make_metryka(autor_jan_kowalski, dyscyplina1, u) + assert m.uczelnia_id == u.pk + + +@pytest.mark.django_db +def test_metryka_unique_together_z_uczelnia(autor_jan_kowalski, dyscyplina1): + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + # ta sama (autor, dyscyplina), różne uczelnie → OK (rozłączne metryki) + _make_metryka(autor_jan_kowalski, dyscyplina1, u1) + _make_metryka(autor_jan_kowalski, dyscyplina1, u2) + assert MetrykaAutora.objects.count() == 2 From 735a44e1996d13f852bdbac8320cdbbe8e757771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 11:19:28 +0200 Subject: [PATCH 149/247] fix(metryki): napraw test unique_together + komentarz backfill 0-uczelni (D, task 1 review) Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/0006_metrykaautora_uczelnia.py | 5 +++-- src/ewaluacja_metryki/tests/test_models.py | 11 +++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py b/src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py index 32cd9fe8a..9b173d1ce 100644 --- a/src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py +++ b/src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py @@ -16,8 +16,9 @@ def backfill_uczelnia(apps, schema_editor): return # MetrykaAutora to regenerowalny cache (delete+create przy generowaniu); - # przy >1 uczelni nie da się zdeterministycznie przypisać legacy wierszy, - # więc czyścimy — odtworzą się przy najbliższym generuj_metryki per uczelnia. + # przy >1 uczelni (lub 0 uczelni) nie da się zdeterministycznie przypisać + # legacy wierszy, więc czyścimy — odtworzą się przy najbliższym + # generuj_metryki per uczelnia. null_qs.delete() diff --git a/src/ewaluacja_metryki/tests/test_models.py b/src/ewaluacja_metryki/tests/test_models.py index f50b4fe63..1719bca74 100644 --- a/src/ewaluacja_metryki/tests/test_models.py +++ b/src/ewaluacja_metryki/tests/test_models.py @@ -177,14 +177,18 @@ def test_status_generowania_czas_trwania(): @pytest.mark.django_db def test_metryka_autora_unique_together(): - """Test że para autor-dyscyplina musi być unikalna""" + """Test że trójka autor-dyscyplina-uczelnia musi być unikalna""" + from django.db import IntegrityError + autor = baker.make(Autor) dyscyplina = baker.make(Dyscyplina_Naukowa) + uczelnia = baker.make("bpp.Uczelnia") baker.make( MetrykaAutora, autor=autor, dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, slot_maksymalny=Decimal("4.0"), slot_nazbierany=Decimal("3.0"), punkty_nazbierane=Decimal("120.0"), @@ -192,13 +196,12 @@ def test_metryka_autora_unique_together(): punkty_wszystkie=Decimal("120.0"), ) - # Próba utworzenia drugiej metryki dla tej samej pary powinna się nie udać - from django.db import IntegrityError - + # Próba utworzenia drugiej metryki dla tej samej trójki powinna się nie udać with pytest.raises(IntegrityError): MetrykaAutora.objects.create( autor=autor, dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, slot_maksymalny=Decimal("4.0"), slot_nazbierany=Decimal("3.0"), punkty_nazbierane=Decimal("120.0"), From 3a7b2542d39e4fd1337f49524c3c06ef41b0ac26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 11:24:47 +0200 Subject: [PATCH 150/247] feat(metryki): StatusGenerowania per uczelnia, koniec singletonu (D, task 2) Co-Authored-By: Claude Sonnet 4.6 --- .../0007_statusgenerowania_uczelnia.py | 48 +++++++++++++++++++ src/ewaluacja_metryki/models.py | 16 +++++-- src/ewaluacja_metryki/tests/test_models.py | 22 ++++----- .../tests/test_per_uczelnia.py | 13 +++++ 4 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 src/ewaluacja_metryki/migrations/0007_statusgenerowania_uczelnia.py diff --git a/src/ewaluacja_metryki/migrations/0007_statusgenerowania_uczelnia.py b/src/ewaluacja_metryki/migrations/0007_statusgenerowania_uczelnia.py new file mode 100644 index 000000000..8a65634ca --- /dev/null +++ b/src/ewaluacja_metryki/migrations/0007_statusgenerowania_uczelnia.py @@ -0,0 +1,48 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def backfill_status_uczelnia(apps, schema_editor): + Uczelnia = apps.get_model("bpp", "Uczelnia") + StatusGenerowania = apps.get_model("ewaluacja_metryki", "StatusGenerowania") + + null_qs = StatusGenerowania.objects.filter(uczelnia__isnull=True) + if not null_qs.exists(): + return + + uczelnie = list(Uczelnia.objects.all()[:2]) + if len(uczelnie) == 1: + null_qs.update(uczelnia=uczelnie[0]) + return + + # Status to ulotny stan postępu, nie dane — przy >1 (lub 0) uczelni usuń + # osierocony singleton (odtworzy się przy następnym generowaniu per uczelnia). + null_qs.delete() + + +def backfill_status_uczelnia_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0428_cpd_uczelnia_not_null"), + ("ewaluacja_metryki", "0006_metrykaautora_uczelnia"), + ] + + operations = [ + migrations.AddField( + model_name="statusgenerowania", + name="uczelnia", + field=models.OneToOneField( + blank=True, + help_text="Uczelnia, której dotyczy ten status (multi-hosted)", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="bpp.uczelnia", + ), + ), + migrations.RunPython( + backfill_status_uczelnia, backfill_status_uczelnia_reverse + ), + ] diff --git a/src/ewaluacja_metryki/models.py b/src/ewaluacja_metryki/models.py index 925a210cd..09e626379 100644 --- a/src/ewaluacja_metryki/models.py +++ b/src/ewaluacja_metryki/models.py @@ -225,6 +225,14 @@ class StatusGenerowania(models.Model): help_text="ID zadania Celery", ) + uczelnia = models.OneToOneField( + "bpp.Uczelnia", + on_delete=models.CASCADE, + null=True, + blank=True, + help_text="Uczelnia, której dotyczy ten status (multi-hosted)", + ) + class Meta: verbose_name = "Status generowania metryk" verbose_name_plural = "Status generowania metryk" @@ -238,14 +246,12 @@ def __str__(self): return "Brak informacji o generowaniu" def save(self, *args, **kwargs): - # Singleton - zawsze nadpisuj rekord o id=1 - self.pk = 1 super().save(*args, **kwargs) @classmethod - def get_or_create(cls): - """Pobierz lub utwórz instancję singleton""" - obj, created = cls.objects.get_or_create(pk=1) + def get_or_create(cls, uczelnia=None): + """Pobierz lub utwórz status dla danej uczelni (per-uczelnia, multi-hosted).""" + obj, created = cls.objects.get_or_create(uczelnia=uczelnia) return obj def rozpocznij_generowanie(self, task_id="", liczba_do_przetworzenia=0): diff --git a/src/ewaluacja_metryki/tests/test_models.py b/src/ewaluacja_metryki/tests/test_models.py index 1719bca74..ed6ec7960 100644 --- a/src/ewaluacja_metryki/tests/test_models.py +++ b/src/ewaluacja_metryki/tests/test_models.py @@ -96,19 +96,15 @@ def test_metryka_autora_czy_pelne_wykorzystanie(): @pytest.mark.django_db -def test_status_generowania_singleton(): - """Test że StatusGenerowania działa jako singleton""" - status1 = StatusGenerowania.get_or_create() - status2 = StatusGenerowania.get_or_create() - - assert status1.pk == 1 - assert status2.pk == 1 - assert status1.pk == status2.pk - - # Próba utworzenia nowego też da pk=1 - status3 = StatusGenerowania() - status3.save() - assert status3.pk == 1 +def test_status_generowania_get_or_create_idempotent(): + """Test że get_or_create jest idempotentne dla tej samej uczelni (NULL = brak)""" + from model_bakery import baker + + u = baker.make("bpp.Uczelnia") + status1 = StatusGenerowania.get_or_create(uczelnia=u) + status2 = StatusGenerowania.get_or_create(uczelnia=u) + + assert status1.pk == status2.pk # ten sam rekord dla tej samej uczelni @pytest.mark.django_db diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index 71e88dd78..eb03504af 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -35,3 +35,16 @@ def test_metryka_unique_together_z_uczelnia(autor_jan_kowalski, dyscyplina1): _make_metryka(autor_jan_kowalski, dyscyplina1, u1) _make_metryka(autor_jan_kowalski, dyscyplina1, u2) assert MetrykaAutora.objects.count() == 2 + + +@pytest.mark.django_db +def test_status_generowania_per_uczelnia(): + from ewaluacja_metryki.models import StatusGenerowania + + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + s1 = StatusGenerowania.get_or_create(uczelnia=u1) + s2 = StatusGenerowania.get_or_create(uczelnia=u2) + assert s1.pk != s2.pk + assert s1.uczelnia_id == u1.pk + assert s2.uczelnia_id == u2.pk From 04760696745ca9d3109d081757005dc2e2211920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 11:32:33 +0200 Subject: [PATCH 151/247] refactor(metryki): cleanup StatusGenerowania docstring/save/import (D, task 2 review) Co-Authored-By: Claude Sonnet 4.6 --- src/ewaluacja_metryki/models.py | 5 +---- src/ewaluacja_metryki/tests/test_per_uczelnia.py | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/ewaluacja_metryki/models.py b/src/ewaluacja_metryki/models.py index 09e626379..545d37ed8 100644 --- a/src/ewaluacja_metryki/models.py +++ b/src/ewaluacja_metryki/models.py @@ -186,7 +186,7 @@ def czy_pelne_wykorzystanie(self): class StatusGenerowania(models.Model): - """Model przechowujący informację o ostatnim generowaniu metryk (singleton)""" + """Model przechowujący informację o ostatnim generowaniu metryk (per uczelnia).""" data_rozpoczecia = models.DateTimeField( null=True, blank=True, help_text="Data rozpoczęcia ostatniego generowania" @@ -245,9 +245,6 @@ def __str__(self): else: return "Brak informacji o generowaniu" - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - @classmethod def get_or_create(cls, uczelnia=None): """Pobierz lub utwórz status dla danej uczelni (per-uczelnia, multi-hosted).""" diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index eb03504af..514f8cb1c 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -3,7 +3,7 @@ import pytest from model_bakery import baker -from ewaluacja_metryki.models import MetrykaAutora +from ewaluacja_metryki.models import MetrykaAutora, StatusGenerowania def _make_metryka(autor, dyscyplina, uczelnia, **kw): @@ -39,8 +39,6 @@ def test_metryka_unique_together_z_uczelnia(autor_jan_kowalski, dyscyplina1): @pytest.mark.django_db def test_status_generowania_per_uczelnia(): - from ewaluacja_metryki.models import StatusGenerowania - u1 = baker.make("bpp.Uczelnia") u2 = baker.make("bpp.Uczelnia") s1 = StatusGenerowania.get_or_create(uczelnia=u1) From eb315b8d769ca979552a3e71ed4a119c47927392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 11:44:36 +0200 Subject: [PATCH 152/247] fix(metryki): pipeline utils per uczelnia, naprawa knapsack leak + slot over-aggregation + global delete (D, task 3) Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_per_uczelnia.py | 34 +++++++++++++ .../tests/test_slot_percentage_update.py | 8 ++- src/ewaluacja_metryki/utils.py | 50 +++++++++++++++---- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index 514f8cb1c..a02a37fa9 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -46,3 +46,37 @@ def test_status_generowania_per_uczelnia(): assert s1.pk != s2.pk assert s1.uczelnia_id == u1.pk assert s2.uczelnia_id == u2.pk + + +@pytest.mark.django_db +def test_oblicz_metryki_dla_autora_nie_sumuje_slotow_z_innej_uczelni( + autor_jan_kowalski, dyscyplina1 +): + """Regresja R2: slot_maksymalny nie może sumować udziałów wszystkich uczelni.""" + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc + from ewaluacja_metryki.utils import oblicz_metryki_dla_autora + + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + IloscUdzialowDlaAutoraZaCalosc.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + rodzaj_autora=None, + ilosc_udzialow=Decimal("4.0"), + ilosc_udzialow_monografie=Decimal("0"), + uczelnia=u1, + ) + IloscUdzialowDlaAutoraZaCalosc.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + rodzaj_autora=None, + ilosc_udzialow=Decimal("9.0"), + ilosc_udzialow_monografie=Decimal("0"), + uczelnia=u2, + ) + metryka, _ = oblicz_metryki_dla_autora( + autor=autor_jan_kowalski, dyscyplina=dyscyplina1, uczelnia=u1 + ) + # slot_maksymalny = 4.0 (tylko u1), NIE 13.0 (suma u1+u2) + assert metryka.slot_maksymalny == Decimal("4.0") + assert metryka.uczelnia_id == u1.pk diff --git a/src/ewaluacja_metryki/tests/test_slot_percentage_update.py b/src/ewaluacja_metryki/tests/test_slot_percentage_update.py index 6e133637f..dc87e47e2 100644 --- a/src/ewaluacja_metryki/tests/test_slot_percentage_update.py +++ b/src/ewaluacja_metryki/tests/test_slot_percentage_update.py @@ -85,10 +85,11 @@ def test_procent_wykorzystania_slotow_updates_correctly(denorms, rodzaj_autora_n cacher.removeEntries() cacher.rebuildEntries() - # Calculate metrics + # Calculate metrics (uczelnia=None — IloscUdzialow created without uczelnia) metryka, created = oblicz_metryki_dla_autora( autor=autor, dyscyplina=dyscyplina, + uczelnia=None, rok_min=2022, rok_max=2025, ) @@ -107,6 +108,7 @@ def test_procent_wykorzystania_slotow_updates_correctly(denorms, rodzaj_autora_n metryka2, created = oblicz_metryki_dla_autora( autor=autor, dyscyplina=dyscyplina, + uczelnia=None, rok_min=2022, rok_max=2025, ) @@ -148,6 +150,7 @@ def test_procent_wykorzystania_handles_zero_slot_maksymalny(rodzaj_autora_n): metryka, created = oblicz_metryki_dla_autora( autor=autor, dyscyplina=dyscyplina, + uczelnia=None, rok_min=2022, rok_max=2025, slot_maksymalny=Decimal("0"), # Force zero @@ -212,10 +215,11 @@ def test_averages_calculated_correctly(rodzaj_autora_n): cacher.removeEntries() cacher.rebuildEntries() - # Calculate metrics + # Calculate metrics (uczelnia=None — IloscUdzialow created without uczelnia) metryka, _ = oblicz_metryki_dla_autora( autor=autor, dyscyplina=dyscyplina, + uczelnia=None, rok_min=2022, rok_max=2025, ) diff --git a/src/ewaluacja_metryki/utils.py b/src/ewaluacja_metryki/utils.py index 393d064dd..2674bda03 100644 --- a/src/ewaluacja_metryki/utils.py +++ b/src/ewaluacja_metryki/utils.py @@ -32,6 +32,7 @@ def get_default_rodzaje_autora(): def oblicz_metryki_dla_autora( autor, dyscyplina, + uczelnia, rok_min=2022, rok_max=2025, minimalny_pk=Decimal("0.01"), @@ -43,6 +44,7 @@ def oblicz_metryki_dla_autora( Args: autor: Obiekt Autor dyscyplina: Obiekt Dyscyplina_Naukowa + uczelnia: Obiekt Uczelnia (wymagany - metryki są per uczelnia) rok_min: Początkowy rok okresu ewaluacji rok_max: Końcowy rok okresu ewaluacji minimalny_pk: Minimalny próg punktów @@ -55,9 +57,9 @@ def oblicz_metryki_dla_autora( if slot_maksymalny is None: from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc - # Agreguj slot limits across all rodzaj_autora types + # Agreguj slot limits dla danej uczelni (fix: nie sumuj wszystkich uczelni) aggregated = IloscUdzialowDlaAutoraZaCalosc.objects.filter( - autor=autor, dyscyplina_naukowa=dyscyplina + autor=autor, dyscyplina_naukowa=dyscyplina, uczelnia=uczelnia ).aggregate(total_slots=Sum("ilosc_udzialow")) if aggregated["total_slots"] is not None: @@ -99,6 +101,7 @@ def oblicz_metryki_dla_autora( rok_max=rok_max, minimalny_pk=minimalny_pk, dyscyplina_id=dyscyplina.pk, + uczelnia_id=uczelnia.pk if uczelnia is not None else None, ) # Convert cache PKs to stable rekord_ids (survives cache rebuilds from pin/unpin) @@ -127,6 +130,7 @@ def oblicz_metryki_dla_autora( minimalny_pk=minimalny_pk, dyscyplina_id=dyscyplina.pk, akcja="wszystko", + uczelnia_id=uczelnia.pk if uczelnia is not None else None, ) # Convert cache PKs to stable rekord_ids @@ -168,12 +172,13 @@ def oblicz_metryki_dla_autora( # update_or_create() doesn't reliably detect changes in JSONField (prace_nazbierane) with transaction.atomic(): MetrykaAutora.objects.filter( - autor=autor, dyscyplina_naukowa=dyscyplina + autor=autor, dyscyplina_naukowa=dyscyplina, uczelnia=uczelnia ).delete() metryka = MetrykaAutora.objects.create( autor=autor, dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, jednostka=jednostka, slot_maksymalny=slot_maksymalny, slot_nazbierany=slot_nazbierany_decimal, @@ -237,10 +242,15 @@ def przelicz_metryki_dla_publikacji(publikacja, rok_min=2022, rok_max=2025): # Przelicz metryki dla wszystkich par (autor, dyscyplina) for autor, dyscyplina in autorzy_do_przeliczenia: + jednostka = autor.aktualna_jednostka + if jednostka is None or not jednostka.skupia_pracownikow: + continue # reguła R2: brak home-uczelni → brak metryki + uczelnia = jednostka.uczelnia try: metryka, _ = oblicz_metryki_dla_autora( autor=autor, dyscyplina=dyscyplina, + uczelnia=uczelnia, rok_min=rok_min, rok_max=rok_max, ) @@ -269,14 +279,16 @@ def _prepare_rodzaje_autora(rodzaje_autora): return rodzaje_autora -def _get_ilosc_udzialow_queryset(ilosc_udzialow_queryset): +def _get_ilosc_udzialow_queryset(ilosc_udzialow_queryset, uczelnia=None): """Pobiera queryset IloscUdzialowDlaAutoraZaCalosc.""" from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc - if ilosc_udzialow_queryset is None: - return IloscUdzialowDlaAutoraZaCalosc.objects.all() - else: + if ilosc_udzialow_queryset is not None: return ilosc_udzialow_queryset + qs = IloscUdzialowDlaAutoraZaCalosc.objects.all() + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + return qs def _should_skip_author(autor, dyscyplina, rodzaje_autora, rok_min=2022, rok_max=2025): @@ -320,7 +332,7 @@ def _should_skip_author(autor, dyscyplina, rodzaje_autora, rok_min=2022, rok_max def _calculate_metrics_data( - autor, dyscyplina, slot_maksymalny, rok_min, rok_max, minimalny_pk + autor, dyscyplina, uczelnia, slot_maksymalny, rok_min, rok_max, minimalny_pk ): """Oblicza dane metryk dla autora.""" from bpp.models.cache import Cache_Punktacja_Autora_Query @@ -336,6 +348,7 @@ def _calculate_metrics_data( rok_max=rok_max, minimalny_pk=minimalny_pk, dyscyplina_id=dyscyplina.pk, + uczelnia_id=uczelnia.pk if uczelnia is not None else None, ) # Convert cache PKs to stable rekord_ids @@ -362,6 +375,7 @@ def _calculate_metrics_data( minimalny_pk=minimalny_pk, dyscyplina_id=dyscyplina.pk, akcja="wszystko", + uczelnia_id=uczelnia.pk if uczelnia is not None else None, ) # Convert cache PKs to stable rekord_ids @@ -389,6 +403,7 @@ def _calculate_metrics_data( def _create_or_update_metryka( autor, dyscyplina, + uczelnia, jednostka, slot_maksymalny, metrics_data, @@ -400,6 +415,7 @@ def _create_or_update_metryka( return MetrykaAutora.objects.update_or_create( autor=autor, dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, defaults={ "jednostka": jednostka, "slot_maksymalny": slot_maksymalny, @@ -432,6 +448,7 @@ def _process_single_author( """Przetwarza pojedynczego autora i zwraca wynik.""" autor = ilosc_udzialow.autor dyscyplina = ilosc_udzialow.dyscyplina_naukowa + uczelnia = ilosc_udzialow.uczelnia slot_maksymalny = ilosc_udzialow.ilosc_udzialow # Wywołaj progress callback jeśli jest dostępny @@ -474,7 +491,13 @@ def _process_single_author( # Oblicz dane metryk metrics_data = _calculate_metrics_data( - autor, dyscyplina, slot_maksymalny, rok_min, rok_max, minimalny_pk + autor, + dyscyplina, + uczelnia, + slot_maksymalny, + rok_min, + rok_max, + minimalny_pk, ) # Pobierz rodzaj autora @@ -486,6 +509,7 @@ def _process_single_author( metryka, created = _create_or_update_metryka( autor, dyscyplina, + uczelnia, jednostka, slot_maksymalny, metrics_data, @@ -520,6 +544,7 @@ def generuj_metryki( progress_callback=None, logger_output=None, ilosc_udzialow_queryset=None, + uczelnia=None, ): """ Generuje metryki ewaluacyjne dla autorów. @@ -544,7 +569,7 @@ def generuj_metryki( - total: całkowita liczba autorów do przetworzenia """ rodzaje_autora = _prepare_rodzaje_autora(rodzaje_autora) - ilosc_udzialow_qs = _get_ilosc_udzialow_queryset(ilosc_udzialow_queryset) + ilosc_udzialow_qs = _get_ilosc_udzialow_queryset(ilosc_udzialow_queryset, uczelnia) total = ilosc_udzialow_qs.count() @@ -553,7 +578,10 @@ def generuj_metryki( logger_output.write(f"Znaleziono {total} autorów do przetworzenia") if nadpisz: - MetrykaAutora.objects.all().delete() + qs = MetrykaAutora.objects.all() + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + qs.delete() processed = 0 skipped = 0 From e7b131262cea1ed34fa2048d433ebe37d5007dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 11:53:16 +0200 Subject: [PATCH 153/247] perf/refactor(metryki): select_related uczelnia (N+1) + hoist uczelnia_id idiom (D, task 3 review) Co-Authored-By: Claude Sonnet 4.6 --- src/ewaluacja_metryki/utils.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ewaluacja_metryki/utils.py b/src/ewaluacja_metryki/utils.py index 2674bda03..69e6e8d5e 100644 --- a/src/ewaluacja_metryki/utils.py +++ b/src/ewaluacja_metryki/utils.py @@ -90,6 +90,8 @@ def oblicz_metryki_dla_autora( if autor_dyscyplina and autor_dyscyplina.rodzaj_autora: rodzaj_autora_skrot = autor_dyscyplina.rodzaj_autora.skrot + uczelnia_id = uczelnia.pk if uczelnia is not None else None + # Oblicz metryki algorytmem plecakowym ( punkty_nazbierane, @@ -101,7 +103,7 @@ def oblicz_metryki_dla_autora( rok_max=rok_max, minimalny_pk=minimalny_pk, dyscyplina_id=dyscyplina.pk, - uczelnia_id=uczelnia.pk if uczelnia is not None else None, + uczelnia_id=uczelnia_id, ) # Convert cache PKs to stable rekord_ids (survives cache rebuilds from pin/unpin) @@ -130,7 +132,7 @@ def oblicz_metryki_dla_autora( minimalny_pk=minimalny_pk, dyscyplina_id=dyscyplina.pk, akcja="wszystko", - uczelnia_id=uczelnia.pk if uczelnia is not None else None, + uczelnia_id=uczelnia_id, ) # Convert cache PKs to stable rekord_ids @@ -337,6 +339,8 @@ def _calculate_metrics_data( """Oblicza dane metryk dla autora.""" from bpp.models.cache import Cache_Punktacja_Autora_Query + uczelnia_id = uczelnia.pk if uczelnia is not None else None + # Oblicz metryki algorytmem plecakowym ( punkty_nazbierane, @@ -348,7 +352,7 @@ def _calculate_metrics_data( rok_max=rok_max, minimalny_pk=minimalny_pk, dyscyplina_id=dyscyplina.pk, - uczelnia_id=uczelnia.pk if uczelnia is not None else None, + uczelnia_id=uczelnia_id, ) # Convert cache PKs to stable rekord_ids @@ -375,7 +379,7 @@ def _calculate_metrics_data( minimalny_pk=minimalny_pk, dyscyplina_id=dyscyplina.pk, akcja="wszystko", - uczelnia_id=uczelnia.pk if uczelnia is not None else None, + uczelnia_id=uczelnia_id, ) # Convert cache PKs to stable rekord_ids @@ -589,7 +593,7 @@ def generuj_metryki( # Przetwarzaj autorów po kolei z aktualizacją statusu for idx, ilosc_udzialow in enumerate( - ilosc_udzialow_qs.select_related("autor", "dyscyplina_naukowa"), 1 + ilosc_udzialow_qs.select_related("autor", "dyscyplina_naukowa", "uczelnia"), 1 ): result, msg = _process_single_author( ilosc_udzialow, From 837564963d59ab02efbf0a0e1a575912ff2a04b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 12:01:13 +0200 Subject: [PATCH 154/247] feat(metryki): taski Celery scope per uczelnia + status per uczelnia + chord callback (D, task 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oblicz_metryki_dla_autora_task: dodano uczelnia_id param; StatusGenerowania.objects.filter(uczelnia_id=...).update(...) zamiast globalnego .objects.update(...) - finalizuj_generowanie_metryk: dodano uczelnia_id param; Uczelnia.objects.get(pk=uczelnia_id) single-or-fail; StatusGenerowania.get_or_create(uczelnia=uczelnia) - generuj_metryki_task_parallel: resolve uczelnia przed try; status per-uczelnia; queryset + delete scoped do uczelnia; uczelnia_id przekazywany do subtasków i chord callback - generuj_metryki_task: resolve uczelnia przed try; status per-uczelnia; queryset scoped; generuj_metryki(..., uczelnia=uczelnia) zamiast uczelnia=None (fix globalnego delete) - test_per_uczelnia.py: nowy test TDD test_generuj_metryki_task_scope_per_uczelnia - test_tasks.py: naprawiono test_finalizuj_generowanie_metryk (baker.make Uczelnia + uczelnia_id) i test_generuj_metryki_task_parallel_uruchamia_chord (mock .filter() zamiast .all()) Co-Authored-By: Claude Sonnet 4.6 --- src/ewaluacja_metryki/tasks.py | 75 +++++++++++-------- .../tests/test_per_uczelnia.py | 25 +++++++ src/ewaluacja_metryki/tests/test_tasks.py | 25 ++++--- 3 files changed, 82 insertions(+), 43 deletions(-) diff --git a/src/ewaluacja_metryki/tasks.py b/src/ewaluacja_metryki/tasks.py index af60c6cdd..fea58bd6f 100644 --- a/src/ewaluacja_metryki/tasks.py +++ b/src/ewaluacja_metryki/tasks.py @@ -27,6 +27,7 @@ def oblicz_metryki_dla_autora_task( rok_max=2025, minimalny_pk=0.01, rodzaje_autora=None, + uczelnia_id=None, ): """ Celery task do obliczania metryki dla pojedynczego autora-dyscypliny. @@ -37,6 +38,7 @@ def oblicz_metryki_dla_autora_task( rok_max: Końcowy rok okresu ewaluacji minimalny_pk: Minimalny próg punktów rodzaje_autora: Lista rodzajów autorów do przetworzenia + uczelnia_id: ID uczelni — używane do per-uczelnia scope StatusGenerowania Returns: Dict z kluczami: status ("processed"/"skipped"/"error"), autor, dyscyplina @@ -72,8 +74,8 @@ def oblicz_metryki_dla_autora_task( logger_output=None, ) - # Atomowo zwiększ licznik przetworzonych tasków - StatusGenerowania.objects.update( + # Atomowo zwiększ licznik przetworzonych tasków (per uczelnia) + StatusGenerowania.objects.filter(uczelnia_id=uczelnia_id).update( liczba_przetworzonych=F("liczba_przetworzonych") + 1 ) @@ -89,8 +91,8 @@ def oblicz_metryki_dla_autora_task( f"IloscUdzialowDlaAutoraZaCalosc o ID {ilosc_udzialow_id} nie istnieje" ) logger.error(error_msg) - # Atomowo zwiększ licznik przetworzonych tasków - StatusGenerowania.objects.update( + # Atomowo zwiększ licznik przetworzonych tasków (per uczelnia) + StatusGenerowania.objects.filter(uczelnia_id=uczelnia_id).update( liczba_przetworzonych=F("liczba_przetworzonych") + 1 ) return { @@ -103,8 +105,8 @@ def oblicz_metryki_dla_autora_task( error_msg = f"Błąd przy przetwarzaniu ID {ilosc_udzialow_id}: {str(e)}" logger.error(error_msg) rollbar.report_exc_info(sys.exc_info()) - # Atomowo zwiększ licznik przetworzonych tasków - StatusGenerowania.objects.update( + # Atomowo zwiększ licznik przetworzonych tasków (per uczelnia) + StatusGenerowania.objects.filter(uczelnia_id=uczelnia_id).update( liczba_przetworzonych=F("liczba_przetworzonych") + 1 ) return { @@ -116,17 +118,21 @@ def oblicz_metryki_dla_autora_task( @shared_task -def finalizuj_generowanie_metryk(results): +def finalizuj_generowanie_metryk(results, uczelnia_id=None): """ Callback task wywoływany po zakończeniu wszystkich tasków obliczania metryk. Args: results: Lista wyników z tasków oblicz_metryki_dla_autora_task + uczelnia_id: ID uczelni — per-uczelnia scope StatusGenerowania Returns: Dict z podsumowaniem: processed, skipped, errors, total """ - status = StatusGenerowania.get_or_create() + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id else Uczelnia.objects.get() + ) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) # Odśwież status z bazy danych aby pobrać aktualną wartość liczba_przetworzonych # zaktualizowaną atomowo przez poszczególne taski @@ -204,7 +210,10 @@ def generuj_metryki_task_parallel( rodzaje_autora = get_default_rodzaje_autora() - status = StatusGenerowania.get_or_create() + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id else Uczelnia.objects.get() + ) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) try: # Krok 1: Przelicz liczby N jeśli włączone @@ -213,11 +222,6 @@ def generuj_metryki_task_parallel( status.ostatni_komunikat = "Przeliczanie liczby N..." status.save() - uczelnia = ( - Uczelnia.objects.get(pk=uczelnia_id) - if uczelnia_id - else Uczelnia.objects.get() - ) oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) logger.info("Przeliczono liczby N pomyślnie") @@ -226,9 +230,8 @@ def generuj_metryki_task_parallel( from .models import MetrykaAutora - # Filtruj tylko wpisy z odpowiednimi rodzajami autorów - # aby uniknąć duplikatów dla tego samego autora+dyscypliny - queryset = IloscUdzialowDlaAutoraZaCalosc.objects.all() + # Filtruj wpisy dla tej uczelni oraz (opcjonalnie) rodzajów autorów + queryset = IloscUdzialowDlaAutoraZaCalosc.objects.filter(uczelnia=uczelnia) if rodzaje_autora: queryset = queryset.filter(rodzaj_autora__skrot__in=rodzaje_autora) @@ -240,10 +243,11 @@ def generuj_metryki_task_parallel( f"(task_id: {self.request.id})" ) - # Krok 3: Usuń stare metryki jeśli nadpisz=True + # Krok 3: Usuń stare metryki tej uczelni jeśli nadpisz=True if nadpisz: - deleted_count = MetrykaAutora.objects.all().count() - MetrykaAutora.objects.all().delete() + qs = MetrykaAutora.objects.filter(uczelnia=uczelnia) + deleted_count = qs.count() + qs.delete() logger.info(f"Usunięto {deleted_count} starych metryk") # Krok 4: Zainicjuj status generowania @@ -260,24 +264,28 @@ def generuj_metryki_task_parallel( rok_max=rok_max, minimalny_pk=minimalny_pk, rodzaje_autora=rodzaje_autora, + uczelnia_id=uczelnia.pk, ) for autor_id in ids_list ] ) # Krok 6: Uruchom chord (group + callback) i zapisz group_id - job = chord(task_group)(finalizuj_generowanie_metryk.s()) + job = chord(task_group)(finalizuj_generowanie_metryk.s(uczelnia_id=uczelnia.pk)) # Group ID jest dostępny w job.parent group_id = job.parent.id if hasattr(job, "parent") and job.parent else None logger.info( - f"Utworzono chord z {total_count} taskami. Chord ID: {job.id}, Group ID: {group_id}" + f"Utworzono chord z {total_count} taskami. " + f"Chord ID: {job.id}, Group ID: {group_id}" ) return { "success": True, - "message": f"Uruchomiono równoległe generowanie metryk dla {total_count} autorów", + "message": ( + f"Uruchomiono równoległe generowanie metryk dla {total_count} autorów" + ), "total": total_count, "task_id": self.request.id, "group_id": group_id, @@ -328,7 +336,11 @@ def generuj_metryki_task( from .utils import get_default_rodzaje_autora rodzaje_autora = get_default_rodzaje_autora() - status = StatusGenerowania.get_or_create() + + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id else Uczelnia.objects.get() + ) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) # NOTE: Sprawdzanie w_trakcie przeniesione do widoku UruchomGenerowanie # Status jest ustawiany w widoku przed uruchomieniem taska, więc tutaj nie sprawdzamy @@ -340,11 +352,6 @@ def generuj_metryki_task( status.ostatni_komunikat = "Przeliczanie liczby N..." status.save() - uczelnia = ( - Uczelnia.objects.get(pk=uczelnia_id) - if uczelnia_id - else Uczelnia.objects.get() - ) oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) logger.info("Przeliczono liczby N pomyślnie") @@ -353,8 +360,8 @@ def generuj_metryki_task( from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc - # Policz tylko wpisy z odpowiednimi rodzajami autorów - queryset = IloscUdzialowDlaAutoraZaCalosc.objects.all() + # Policz tylko wpisy tej uczelni z odpowiednimi rodzajami autorów + queryset = IloscUdzialowDlaAutoraZaCalosc.objects.filter(uczelnia=uczelnia) if rodzaje_autora: queryset = queryset.filter(rodzaj_autora__skrot__in=rodzaje_autora) total_count = queryset.count() @@ -365,7 +372,8 @@ def generuj_metryki_task( status.save() logger.info( - f"Rozpoczęto generowanie metryk dla {total_count} autorów (task_id: {self.request.id})" + f"Rozpoczęto generowanie metryk dla {total_count} autorów " + f"(task_id: {self.request.id})" ) # Callback do aktualizacji statusu @@ -386,7 +394,7 @@ def update_progress(current, total, autor, dyscyplina, processed): }, ) - # Wywołaj wspólną funkcję generuj_metryki + # Wywołaj wspólną funkcję generuj_metryki (z uczelnia → scoped delete) wynik = generuj_metryki( rok_min=rok_min, rok_max=rok_max, @@ -394,6 +402,7 @@ def update_progress(current, total, autor, dyscyplina, processed): nadpisz=nadpisz, rodzaje_autora=rodzaje_autora, progress_callback=update_progress, + uczelnia=uczelnia, ) _liczba_przetworzonych = wynik["processed"] diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index a02a37fa9..8ea66d9ef 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -80,3 +80,28 @@ def test_oblicz_metryki_dla_autora_nie_sumuje_slotow_z_innej_uczelni( # slot_maksymalny = 4.0 (tylko u1), NIE 13.0 (suma u1+u2) assert metryka.slot_maksymalny == Decimal("4.0") assert metryka.uczelnia_id == u1.pk + + +@pytest.mark.django_db +def test_generuj_metryki_task_scope_per_uczelnia(autor_jan_kowalski, dyscyplina1): + """Task generuje metryki tylko dla swojej uczelni, nie wyciera innej.""" + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc + from ewaluacja_metryki.tasks import generuj_metryki_task + + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + # istniejąca metryka u2 — nie wolno jej skasować + _make_metryka(autor_jan_kowalski, dyscyplina1, u2) + IloscUdzialowDlaAutoraZaCalosc.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + rodzaj_autora=None, + ilosc_udzialow=Decimal("4.0"), + ilosc_udzialow_monografie=Decimal("0"), + uczelnia=u1, + ) + generuj_metryki_task( + uczelnia_id=u1.pk, przelicz_liczbe_n=False, rodzaje_autora=[" "] + ) + # metryka u2 nadal istnieje (scoped delete nie wyciera obcej uczelni) + assert MetrykaAutora.objects.filter(uczelnia=u2).exists() diff --git a/src/ewaluacja_metryki/tests/test_tasks.py b/src/ewaluacja_metryki/tests/test_tasks.py index 162473ea4..bb5d9f136 100644 --- a/src/ewaluacja_metryki/tests/test_tasks.py +++ b/src/ewaluacja_metryki/tests/test_tasks.py @@ -1,6 +1,7 @@ from unittest.mock import patch import pytest +from model_bakery import baker @pytest.mark.django_db @@ -98,8 +99,11 @@ def test_finalizuj_generowanie_metryk(): from ewaluacja_metryki.models import StatusGenerowania from ewaluacja_metryki.tasks import finalizuj_generowanie_metryk + # Jedna uczelnia wymagana przez finalizuj_generowanie_metryk (per-uczelnia status) + uczelnia = baker.make("bpp.Uczelnia") + # Przygotuj status generowania - status = StatusGenerowania.get_or_create() + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) status.rozpocznij_generowanie(task_id="test-task-id", liczba_do_przetworzenia=5) # Przygotuj wyniki z tasków @@ -137,14 +141,14 @@ def test_finalizuj_generowanie_metryk(): ] # Symuluj atomowe update'y licznika przez poszczególne taski - # (w prawdziwym scenariuszu każdy task wywołuje StatusGenerowania.objects.update(...)) + # (każdy task wywołuje StatusGenerowania.objects.filter(uczelnia_id=...).update(...)) for _result in results: - StatusGenerowania.objects.update( + StatusGenerowania.objects.filter(uczelnia_id=uczelnia.pk).update( liczba_przetworzonych=F("liczba_przetworzonych") + 1 ) - # Wywołaj callback - result = finalizuj_generowanie_metryk(results) + # Wywołaj callback z uczelnia_id (per-uczelnia scope) + result = finalizuj_generowanie_metryk(results, uczelnia_id=uczelnia.pk) # Sprawdź wynik - teraz używa liczba_przetworzonych z bazy (5 - wszystkie taski się zakończyły) assert result["success"] is True @@ -228,18 +232,19 @@ def test_generuj_metryki_task_parallel_uruchamia_chord(uczelnia): with patch( "ewaluacja_liczba_n.models.IloscUdzialowDlaAutoraZaCalosc" ) as mock_ilosc: - # Mock queryset chain: all() -> filter() -> values_list() + # Mock queryset chain: filter(uczelnia=...) -> filter(rodzaj_autora__skrot__in=...) + # -> values_list() — task używa .filter() zamiast .all() (per-uczelnia scope) mock_queryset = MagicMock() mock_queryset.filter.return_value = mock_queryset mock_queryset.values_list.return_value = [1, 2, 3] - mock_ilosc.objects.all.return_value = mock_queryset + mock_ilosc.objects.filter.return_value = mock_queryset - # Mock MetrykaAutora + # Mock MetrykaAutora — task używa .filter(uczelnia=...) zamiast .all() with patch( "ewaluacja_metryki.tasks.MetrykaAutora", create=True ) as mock_metryka: - mock_metryka.objects.all.return_value.count.return_value = 0 - mock_metryka.objects.all.return_value.delete.return_value = None + mock_metryka.objects.filter.return_value.count.return_value = 0 + mock_metryka.objects.filter.return_value.delete.return_value = None # Mock StatusGenerowania żeby nie zapisywał do bazy with patch( From d8979381f9d9b9e2da69897fadf51de4dc689f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 12:11:04 +0200 Subject: [PATCH 155/247] =?UTF-8?q?refactor(metryki):=20=5Fresolve=5Fuczel?= =?UTF-8?q?nia=20helper=20+=20czytelny=20early-return=20na=20b=C5=82=C4=85?= =?UTF-8?q?d=20resolucji=20(D,=20task=204=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/ewaluacja_metryki/tasks.py | 39 ++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/ewaluacja_metryki/tasks.py b/src/ewaluacja_metryki/tasks.py index fea58bd6f..b5e365591 100644 --- a/src/ewaluacja_metryki/tasks.py +++ b/src/ewaluacja_metryki/tasks.py @@ -14,6 +14,17 @@ logger = logging.getLogger(__name__) +def _resolve_uczelnia(uczelnia_id): + """Single-or-fail: jawne uczelnia_id albo jedyna uczelnia w bazie. + + NIGDY get_default(). Rzuca Uczelnia.DoesNotExist / MultipleObjectsReturned, + które wołający task obsługuje czytelnym early-returnem. + """ + if uczelnia_id: + return Uczelnia.objects.get(pk=uczelnia_id) + return Uczelnia.objects.get() + + @shared_task( bind=True, autoretry_for=(Exception,), @@ -129,9 +140,7 @@ def finalizuj_generowanie_metryk(results, uczelnia_id=None): Returns: Dict z podsumowaniem: processed, skipped, errors, total """ - uczelnia = ( - Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id else Uczelnia.objects.get() - ) + uczelnia = _resolve_uczelnia(uczelnia_id) status = StatusGenerowania.get_or_create(uczelnia=uczelnia) # Odśwież status z bazy danych aby pobrać aktualną wartość liczba_przetworzonych @@ -210,9 +219,15 @@ def generuj_metryki_task_parallel( rodzaje_autora = get_default_rodzaje_autora() - uczelnia = ( - Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id else Uczelnia.objects.get() - ) + try: + uczelnia = _resolve_uczelnia(uczelnia_id) + except (Uczelnia.DoesNotExist, Uczelnia.MultipleObjectsReturned) as e: + logger.error(f"Nie można rozstrzygnąć uczelni (uczelnia_id={uczelnia_id}): {e}") + return { + "success": False, + "message": f"Nie można rozstrzygnąć uczelni: {e}", + "error": str(e), + } status = StatusGenerowania.get_or_create(uczelnia=uczelnia) try: @@ -337,9 +352,15 @@ def generuj_metryki_task( rodzaje_autora = get_default_rodzaje_autora() - uczelnia = ( - Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id else Uczelnia.objects.get() - ) + try: + uczelnia = _resolve_uczelnia(uczelnia_id) + except (Uczelnia.DoesNotExist, Uczelnia.MultipleObjectsReturned) as e: + logger.error(f"Nie można rozstrzygnąć uczelni (uczelnia_id={uczelnia_id}): {e}") + return { + "success": False, + "message": f"Nie można rozstrzygnąć uczelni: {e}", + "error": str(e), + } status = StatusGenerowania.get_or_create(uczelnia=uczelnia) # NOTE: Sprawdzanie w_trakcie przeniesione do widoku UruchomGenerowanie From be164065519f693bbf43351657eddb4dea4686c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 12:20:20 +0200 Subject: [PATCH 156/247] feat(metryki): CLI oblicz_metryki scope per uczelnia single-or-fail (D, task 5) Resolve uczelnia eagerly at top of handle() (single-or-fail, not lazy), filter IloscUdzialowDlaAutoraZaCalosc by uczelnia, pass uczelnia= to generuj_metryki so --nadpisz delete is scoped and never touches other uczelnie. Update test_commands.py (5 tests) to create explicit Uczelnia and pass --uczelnia-id; add regression test in test_per_uczelnia.py. Co-Authored-By: Claude Sonnet 4.6 --- .../management/commands/oblicz_metryki.py | 27 +++--- src/ewaluacja_metryki/tests/test_commands.py | 87 +++++++++++++++++-- .../tests/test_per_uczelnia.py | 30 +++++++ 3 files changed, 125 insertions(+), 19 deletions(-) diff --git a/src/ewaluacja_metryki/management/commands/oblicz_metryki.py b/src/ewaluacja_metryki/management/commands/oblicz_metryki.py index f83e8bf86..31d61a5be 100644 --- a/src/ewaluacja_metryki/management/commands/oblicz_metryki.py +++ b/src/ewaluacja_metryki/management/commands/oblicz_metryki.py @@ -74,17 +74,19 @@ def handle(self, *args, **options): bez_liczby_n = options["bez_liczby_n"] rodzaje_autora = options.get("rodzaje_autora", ["N", "D", "B", "Z", " "]) + # Rozwiąż uczelnię raz — wymagana do scope'owania zarówno źródłowego QS + # jak i scoped delete wewnątrz generuj_metryki (nadpisz). + # single-or-fail: .get() rzuca DoesNotExist lub MultipleObjectsReturned + # gdy brak lub >1 uczelni bez --uczelnia-id — to zamierzone zachowanie. + uczelnia_id = options.get("uczelnia_id") + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) + if uczelnia_id + else Uczelnia.objects.get() + ) + # Krok 1: Przelicz liczby N, chyba że pominięto if not bez_liczby_n: - # Uczelnia potrzebna TYLKO do liczby N — rozwiązujemy leniwie, żeby - # --bez-liczby-n nie wymagało uczelni (i .get() nie rzucało, gdy - # w bazie jest 0 lub >1 uczelni, np. w danych testowych). - uczelnia_id = options.get("uczelnia_id") - if uczelnia_id: - uczelnia = Uczelnia.objects.get(pk=uczelnia_id) - else: - uczelnia = Uczelnia.objects.get() - self.stdout.write( self.style.WARNING("Krok 1/2: Przeliczanie liczby N dla uczelni...") ) @@ -128,8 +130,10 @@ def handle(self, *args, **options): rodzaje_str = ", ".join([rodzaje_nazwy.get(r, r) for r in rodzaje_autora]) self.stdout.write(f"Rodzaje autorów: {rodzaje_str}") - # Filtruj IloscUdzialowDlaAutoraZaCalosc - ilosc_udzialow_qs = IloscUdzialowDlaAutoraZaCalosc.objects.all() + # Filtruj IloscUdzialowDlaAutoraZaCalosc — scope per uczelnia od razu + ilosc_udzialow_qs = IloscUdzialowDlaAutoraZaCalosc.objects.filter( + uczelnia=uczelnia + ) if options["autor_id"]: ilosc_udzialow_qs = ilosc_udzialow_qs.filter(autor_id=options["autor_id"]) @@ -158,6 +162,7 @@ def handle(self, *args, **options): rodzaje_autora=rodzaje_autora, logger_output=self.stdout, ilosc_udzialow_queryset=ilosc_udzialow_qs, + uczelnia=uczelnia, ) # Wyświetl podsumowanie diff --git a/src/ewaluacja_metryki/tests/test_commands.py b/src/ewaluacja_metryki/tests/test_commands.py index 6198dbbbd..d26663d4e 100644 --- a/src/ewaluacja_metryki/tests/test_commands.py +++ b/src/ewaluacja_metryki/tests/test_commands.py @@ -22,6 +22,9 @@ def test_oblicz_metryki_command_basic(rodzaj_autora_n): """Test podstawowego działania komendy oblicz_metryki""" # Stwórz dane testowe + from bpp.models import Uczelnia + + uczelnia = baker.make(Uczelnia) jednostka = baker.make(Jednostka, nazwa="Instytut") autor = baker.make( Autor, nazwisko="Testowy", imiona="Jan", aktualna_jednostka=jednostka @@ -40,6 +43,7 @@ def test_oblicz_metryki_command_basic(rodzaj_autora_n): dyscyplina_naukowa=dyscyplina, ilosc_udzialow=Decimal("4.0"), ilosc_udzialow_monografie=Decimal("1.0"), + uczelnia=uczelnia, ) # Stwórz Autor_Dyscyplina z rodzajem 'N' @@ -65,7 +69,13 @@ def test_oblicz_metryki_command_basic(rodzaj_autora_n): # Wywołaj komendę out = StringIO() - call_command("oblicz_metryki", "--bez-liczby-n", stdout=out) + call_command( + "oblicz_metryki", + "--bez-liczby-n", + "--uczelnia-id", + str(uczelnia.pk), + stdout=out, + ) output = out.getvalue() @@ -93,6 +103,9 @@ def test_oblicz_metryki_command_basic(rodzaj_autora_n): @pytest.mark.django_db def test_oblicz_metryki_command_filters(rodzaj_autora_n): """Test filtrowania po autorze, dyscyplinie i jednostce""" + from bpp.models import Uczelnia + + uczelnia = baker.make(Uczelnia) # Stwórz jednostki najpierw jednostka1 = baker.make(Jednostka, nazwa="Jednostka1") @@ -126,6 +139,7 @@ def test_oblicz_metryki_command_filters(rodzaj_autora_n): dyscyplina_naukowa=dyscyplina1, ilosc_udzialow=Decimal("4.0"), ilosc_udzialow_monografie=Decimal("1.0"), + uczelnia=uczelnia, ) baker.make( IloscUdzialowDlaAutoraZaCalosc, @@ -133,6 +147,7 @@ def test_oblicz_metryki_command_filters(rodzaj_autora_n): dyscyplina_naukowa=dyscyplina2, ilosc_udzialow=Decimal("4.0"), ilosc_udzialow_monografie=Decimal("1.0"), + uczelnia=uczelnia, ) # Stwórz Autor_Dyscyplina z rodzajem 'N' dla obu autorów @@ -162,21 +177,39 @@ def test_oblicz_metryki_command_filters(rodzaj_autora_n): } # Test filtra po autorze - call_command("oblicz_metryki", "--bez-liczby-n", autor_id=autor1.pk) + call_command( + "oblicz_metryki", + "--bez-liczby-n", + "--uczelnia-id", + str(uczelnia.pk), + autor_id=autor1.pk, + ) assert MetrykaAutora.objects.filter(autor=autor1).exists() assert not MetrykaAutora.objects.filter(autor=autor2).exists() MetrykaAutora.objects.all().delete() # Test filtra po dyscyplinie - call_command("oblicz_metryki", "--bez-liczby-n", dyscyplina_id=dyscyplina2.pk) + call_command( + "oblicz_metryki", + "--bez-liczby-n", + "--uczelnia-id", + str(uczelnia.pk), + dyscyplina_id=dyscyplina2.pk, + ) assert not MetrykaAutora.objects.filter(dyscyplina_naukowa=dyscyplina1).exists() assert MetrykaAutora.objects.filter(dyscyplina_naukowa=dyscyplina2).exists() MetrykaAutora.objects.all().delete() # Test filtra po jednostce - call_command("oblicz_metryki", "--bez-liczby-n", jednostka_id=jednostka1.pk) + call_command( + "oblicz_metryki", + "--bez-liczby-n", + "--uczelnia-id", + str(uczelnia.pk), + jednostka_id=jednostka1.pk, + ) assert MetrykaAutora.objects.filter(autor=autor1).exists() assert not MetrykaAutora.objects.filter(autor=autor2).exists() @@ -184,7 +217,9 @@ def test_oblicz_metryki_command_filters(rodzaj_autora_n): @pytest.mark.django_db def test_oblicz_metryki_command_error_handling(rodzaj_autora_n): """Test obsługi błędów w komendzie""" + from bpp.models import Uczelnia + uczelnia = baker.make(Uczelnia) autor = baker.make(Autor, nazwisko="Błędny") dyscyplina = baker.make(Dyscyplina_Naukowa) @@ -194,6 +229,7 @@ def test_oblicz_metryki_command_error_handling(rodzaj_autora_n): dyscyplina_naukowa=dyscyplina, ilosc_udzialow=Decimal("4.0"), ilosc_udzialow_monografie=Decimal("1.0"), + uczelnia=uczelnia, ) # Stwórz Autor_Dyscyplina z rodzajem 'N' @@ -210,7 +246,13 @@ def test_oblicz_metryki_command_error_handling(rodzaj_autora_n): mock_calculate.side_effect = Exception("Test error") out = StringIO() - call_command("oblicz_metryki", "--bez-liczby-n", stdout=out) + call_command( + "oblicz_metryki", + "--bez-liczby-n", + "--uczelnia-id", + str(uczelnia.pk), + stdout=out, + ) output = out.getvalue() @@ -226,7 +268,9 @@ def test_oblicz_metryki_command_error_handling(rodzaj_autora_n): @pytest.mark.django_db def test_oblicz_metryki_command_parameters(rodzaj_autora_n): """Test parametrów rok_min, rok_max i minimalny_pk""" + from bpp.models import Uczelnia + uczelnia = baker.make(Uczelnia) autor = baker.make(Autor) dyscyplina = baker.make(Dyscyplina_Naukowa) @@ -236,6 +280,7 @@ def test_oblicz_metryki_command_parameters(rodzaj_autora_n): dyscyplina_naukowa=dyscyplina, ilosc_udzialow=Decimal("4.0"), ilosc_udzialow_monografie=Decimal("1.0"), + uczelnia=uczelnia, ) # Stwórz Autor_Dyscyplina z rodzajem 'N' w zakresie lat testowanych (2020-2023) @@ -264,6 +309,8 @@ def test_oblicz_metryki_command_parameters(rodzaj_autora_n): call_command( "oblicz_metryki", "--bez-liczby-n", + "--uczelnia-id", + str(uczelnia.pk), rok_min=2020, rok_max=2023, minimalny_pk=5.0, @@ -292,6 +339,9 @@ def test_oblicz_metryki_command_rodzaj_autora_filter( rodzaj_autora_n, rodzaj_autora_b, rodzaj_autora_d, rodzaj_autora_z ): """Test filtrowania po rodzaju autora""" + from bpp.models import Uczelnia + + uczelnia = baker.make(Uczelnia) # Stwórz trzech autorów z różnymi rodzajami autor_n = baker.make(Autor, nazwisko="Pracownik") @@ -307,6 +357,7 @@ def test_oblicz_metryki_command_rodzaj_autora_filter( dyscyplina_naukowa=dyscyplina, ilosc_udzialow=Decimal("4.0"), ilosc_udzialow_monografie=Decimal("1.0"), + uczelnia=uczelnia, ) baker.make( IloscUdzialowDlaAutoraZaCalosc, @@ -314,6 +365,7 @@ def test_oblicz_metryki_command_rodzaj_autora_filter( dyscyplina_naukowa=dyscyplina, ilosc_udzialow=Decimal("4.0"), ilosc_udzialow_monografie=Decimal("1.0"), + uczelnia=uczelnia, ) baker.make( IloscUdzialowDlaAutoraZaCalosc, @@ -321,6 +373,7 @@ def test_oblicz_metryki_command_rodzaj_autora_filter( dyscyplina_naukowa=dyscyplina, ilosc_udzialow=Decimal("4.0"), ilosc_udzialow_monografie=Decimal("1.0"), + uczelnia=uczelnia, ) # Autor_Dyscyplina - N, D, B @@ -358,7 +411,13 @@ def test_oblicz_metryki_command_rodzaj_autora_filter( # Domyślnie powinno generować dla wszystkich rodzajów (N, D, B, Z, " ") out = StringIO() - call_command("oblicz_metryki", "--bez-liczby-n", stdout=out) + call_command( + "oblicz_metryki", + "--bez-liczby-n", + "--uczelnia-id", + str(uczelnia.pk), + stdout=out, + ) assert MetrykaAutora.objects.filter(autor=autor_n).exists() assert MetrykaAutora.objects.filter(autor=autor_d).exists() @@ -373,7 +432,13 @@ def test_oblicz_metryki_command_rodzaj_autora_filter( # Z opcją --rodzaje-autora N powinno generować tylko dla N out = StringIO() call_command( - "oblicz_metryki", "--bez-liczby-n", "--rodzaje-autora", "N", stdout=out + "oblicz_metryki", + "--bez-liczby-n", + "--uczelnia-id", + str(uczelnia.pk), + "--rodzaje-autora", + "N", + stdout=out, ) assert MetrykaAutora.objects.filter(autor=autor_n).exists() @@ -390,7 +455,13 @@ def test_oblicz_metryki_command_rodzaj_autora_filter( # Z opcją --rodzaje-autora B powinno generować tylko dla B out = StringIO() call_command( - "oblicz_metryki", "--bez-liczby-n", "--rodzaje-autora", "B", stdout=out + "oblicz_metryki", + "--bez-liczby-n", + "--uczelnia-id", + str(uczelnia.pk), + "--rodzaje-autora", + "B", + stdout=out, ) assert not MetrykaAutora.objects.filter(autor=autor_n).exists() diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index 8ea66d9ef..5407df509 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -82,6 +82,36 @@ def test_oblicz_metryki_dla_autora_nie_sumuje_slotow_z_innej_uczelni( assert metryka.uczelnia_id == u1.pk +@pytest.mark.django_db +def test_command_oblicz_metryki_scope_uczelnia(autor_jan_kowalski, dyscyplina1): + """Komenda oblicz_metryki z --uczelnia-id scope'uje delete i źródłowy QS.""" + from django.core.management import call_command + + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc + + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + _make_metryka(autor_jan_kowalski, dyscyplina1, u2) # must survive + IloscUdzialowDlaAutoraZaCalosc.objects.create( + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + rodzaj_autora=None, + ilosc_udzialow=Decimal("4.0"), + ilosc_udzialow_monografie=Decimal("0"), + uczelnia=u1, + ) + call_command( + "oblicz_metryki", + "--bez-liczby-n", + "--nadpisz", + "--uczelnia-id", + str(u1.pk), + "--rodzaje-autora", + " ", + ) + assert MetrykaAutora.objects.filter(uczelnia=u2).exists() # u2 nietknięta + + @pytest.mark.django_db def test_generuj_metryki_task_scope_per_uczelnia(autor_jan_kowalski, dyscyplina1): """Task generuje metryki tylko dla swojej uczelni, nie wyciera innej.""" From 678a3fc114918bb7194b3081b3f566e5b5e03934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 12:28:17 +0200 Subject: [PATCH 157/247] test/refactor(metryki): pozytywna asercja u1 + options[] + import housekeeping (D, task 5 review) Co-Authored-By: Claude Opus 4.8 --- .../management/commands/oblicz_metryki.py | 2 +- src/ewaluacja_metryki/tests/test_commands.py | 11 +--- .../tests/test_per_uczelnia.py | 56 +++++++++++++++---- 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/ewaluacja_metryki/management/commands/oblicz_metryki.py b/src/ewaluacja_metryki/management/commands/oblicz_metryki.py index 31d61a5be..7f13f6ef2 100644 --- a/src/ewaluacja_metryki/management/commands/oblicz_metryki.py +++ b/src/ewaluacja_metryki/management/commands/oblicz_metryki.py @@ -78,7 +78,7 @@ def handle(self, *args, **options): # jak i scoped delete wewnątrz generuj_metryki (nadpisz). # single-or-fail: .get() rzuca DoesNotExist lub MultipleObjectsReturned # gdy brak lub >1 uczelni bez --uczelnia-id — to zamierzone zachowanie. - uczelnia_id = options.get("uczelnia_id") + uczelnia_id = options["uczelnia_id"] uczelnia = ( Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id diff --git a/src/ewaluacja_metryki/tests/test_commands.py b/src/ewaluacja_metryki/tests/test_commands.py index d26663d4e..efd8856f8 100644 --- a/src/ewaluacja_metryki/tests/test_commands.py +++ b/src/ewaluacja_metryki/tests/test_commands.py @@ -12,6 +12,7 @@ Autor_Jednostka, Dyscyplina_Naukowa, Jednostka, + Uczelnia, ) from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc from ewaluacja_metryki.models import MetrykaAutora @@ -22,8 +23,6 @@ def test_oblicz_metryki_command_basic(rodzaj_autora_n): """Test podstawowego działania komendy oblicz_metryki""" # Stwórz dane testowe - from bpp.models import Uczelnia - uczelnia = baker.make(Uczelnia) jednostka = baker.make(Jednostka, nazwa="Instytut") autor = baker.make( @@ -103,8 +102,6 @@ def test_oblicz_metryki_command_basic(rodzaj_autora_n): @pytest.mark.django_db def test_oblicz_metryki_command_filters(rodzaj_autora_n): """Test filtrowania po autorze, dyscyplinie i jednostce""" - from bpp.models import Uczelnia - uczelnia = baker.make(Uczelnia) # Stwórz jednostki najpierw @@ -217,8 +214,6 @@ def test_oblicz_metryki_command_filters(rodzaj_autora_n): @pytest.mark.django_db def test_oblicz_metryki_command_error_handling(rodzaj_autora_n): """Test obsługi błędów w komendzie""" - from bpp.models import Uczelnia - uczelnia = baker.make(Uczelnia) autor = baker.make(Autor, nazwisko="Błędny") dyscyplina = baker.make(Dyscyplina_Naukowa) @@ -268,8 +263,6 @@ def test_oblicz_metryki_command_error_handling(rodzaj_autora_n): @pytest.mark.django_db def test_oblicz_metryki_command_parameters(rodzaj_autora_n): """Test parametrów rok_min, rok_max i minimalny_pk""" - from bpp.models import Uczelnia - uczelnia = baker.make(Uczelnia) autor = baker.make(Autor) dyscyplina = baker.make(Dyscyplina_Naukowa) @@ -339,8 +332,6 @@ def test_oblicz_metryki_command_rodzaj_autora_filter( rodzaj_autora_n, rodzaj_autora_b, rodzaj_autora_d, rodzaj_autora_z ): """Test filtrowania po rodzaju autora""" - from bpp.models import Uczelnia - uczelnia = baker.make(Uczelnia) # Stwórz trzech autorów z różnymi rodzajami diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index 5407df509..8bfb283e9 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -1,4 +1,5 @@ from decimal import Decimal +from unittest.mock import patch import pytest from model_bakery import baker @@ -83,15 +84,25 @@ def test_oblicz_metryki_dla_autora_nie_sumuje_slotow_z_innej_uczelni( @pytest.mark.django_db -def test_command_oblicz_metryki_scope_uczelnia(autor_jan_kowalski, dyscyplina1): - """Komenda oblicz_metryki z --uczelnia-id scope'uje delete i źródłowy QS.""" +def test_command_oblicz_metryki_scope_uczelnia( + autor_jan_kowalski, dyscyplina1, rodzaj_autora_n +): + """Komenda oblicz_metryki z --uczelnia-id scope'uje delete i źródłowy QS. + + Asercja pozytywna: komenda musi wygenerować metrykę dla u1. + Asercja negatywna: istniejąca metryka u2 (sprzed uruchomienia) musi + przeżyć (scoped --nadpisz nie wyciera obcej uczelni). + """ from django.core.management import call_command + from bpp.models import Autor_Dyscyplina from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc u1 = baker.make("bpp.Uczelnia") u2 = baker.make("bpp.Uczelnia") _make_metryka(autor_jan_kowalski, dyscyplina1, u2) # must survive + + # IloscUdzialowDlaAutoraZaCalosc dla u1 — źródło slot_maksymalny IloscUdzialowDlaAutoraZaCalosc.objects.create( autor=autor_jan_kowalski, dyscyplina_naukowa=dyscyplina1, @@ -100,15 +111,40 @@ def test_command_oblicz_metryki_scope_uczelnia(autor_jan_kowalski, dyscyplina1): ilosc_udzialow_monografie=Decimal("0"), uczelnia=u1, ) - call_command( - "oblicz_metryki", - "--bez-liczby-n", - "--nadpisz", - "--uczelnia-id", - str(u1.pk), - "--rodzaje-autora", - " ", + + # Autor_Dyscyplina wymagany przez _should_skip_author (rok w 2022-2025, + # rodzaj_autora.skrot == "N" pasujący do --rodzaje-autora N) + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, + rok=2022, + dyscyplina_naukowa=dyscyplina1, + wymiar_etatu=Decimal("1.0"), + procent_dyscypliny=Decimal("100.0"), + rodzaj_autora=rodzaj_autora_n, ) + + # Mockujemy _calculate_metrics_data — zbieraj_sloty potrzebuje cache + # który jest pusty w tym teście; interesuje nas tu tylko logika scope'owania. + with patch("ewaluacja_metryki.utils._calculate_metrics_data") as mock_calc: + mock_calc.return_value = { + "punkty_nazbierane": Decimal("100.0"), + "prace_nazbierane_ids": [], + "slot_nazbierany": Decimal("2.0"), + "punkty_wszystkie": Decimal("100.0"), + "prace_wszystkie_ids": [], + "slot_wszystkie": Decimal("2.0"), + } + call_command( + "oblicz_metryki", + "--bez-liczby-n", + "--nadpisz", + "--uczelnia-id", + str(u1.pk), + "--rodzaje-autora", + "N", + ) + + assert MetrykaAutora.objects.filter(uczelnia=u1).exists() # u1 wygenerowana assert MetrykaAutora.objects.filter(uczelnia=u2).exists() # u2 nietknięta From df9bd966600d889afe6bca3119eae578c818047e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 12:31:52 +0200 Subject: [PATCH 158/247] feat(metryki): helper scope_metryki + generation view per uczelnia (D, task 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nowy moduł `ewaluacja_metryki/uczelnia_scope.py`: `scope_metryki(qs, uczelnia)` z guard single-install (no-op gdy tylko jedna uczelnia lub uczelnia=None). - `UruchomGenerowanieView.post`: uczelnia z `uczelnia_dla_odczytu(request)`, `StatusGenerowania.get_or_create(uczelnia=uczelnia)`, `total_count` filtrowany przez uczelnia, `generuj_metryki_task_parallel.delay(..., uczelnia_id=uczelnia.pk if uczelnia else None)`. - `StatusGenerowaniaView.get` i `StatusGenerowaniaPartialView.get`: analogicznie — status per uczelnia z requestu. - 2 nowe testy TDD: single-install no-op, multi-install filter. - Wynik: 57 passed (było 55); guard green; ruff clean. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_per_uczelnia.py | 23 +++++++++++++++++++ src/ewaluacja_metryki/uczelnia_scope.py | 15 ++++++++++++ src/ewaluacja_metryki/views/generation.py | 18 +++++++++++---- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/ewaluacja_metryki/uczelnia_scope.py diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index 8bfb283e9..33113eb1b 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -171,3 +171,26 @@ def test_generuj_metryki_task_scope_per_uczelnia(autor_jan_kowalski, dyscyplina1 ) # metryka u2 nadal istnieje (scoped delete nie wyciera obcej uczelni) assert MetrykaAutora.objects.filter(uczelnia=u2).exists() + + +@pytest.mark.django_db +def test_scope_metryki_single_install_noop(autor_jan_kowalski, dyscyplina1): + from ewaluacja_metryki.uczelnia_scope import scope_metryki + + u = baker.make("bpp.Uczelnia") # dokładnie 1 uczelnia + _make_metryka(autor_jan_kowalski, dyscyplina1, u) + qs = scope_metryki(MetrykaAutora.objects.all(), u) + assert qs.count() == 1 # no-op, nie filtruje + + +@pytest.mark.django_db +def test_scope_metryki_multi_filtruje(autor_jan_kowalski, dyscyplina1): + from ewaluacja_metryki.uczelnia_scope import scope_metryki + + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + autor2 = baker.make("bpp.Autor") + _make_metryka(autor_jan_kowalski, dyscyplina1, u1) + _make_metryka(autor2, dyscyplina1, u2) + qs = scope_metryki(MetrykaAutora.objects.all(), u1) + assert list(qs.values_list("uczelnia_id", flat=True)) == [u1.pk] diff --git a/src/ewaluacja_metryki/uczelnia_scope.py b/src/ewaluacja_metryki/uczelnia_scope.py new file mode 100644 index 000000000..54ab6eac6 --- /dev/null +++ b/src/ewaluacja_metryki/uczelnia_scope.py @@ -0,0 +1,15 @@ +"""Zawężanie querysetów MetrykaAutora do uczelni oglądającego (read-side). + +Hybryda uczelni (site + superuser ?uczelnia=) rozstrzygana w widoku przez +`raport_slotow.uczelnia_helper.uczelnia_dla_odczytu`; tutaj sam filtr + +guard single-install (no-op przy dokładnie jednej uczelni, jak R1/R3a). +""" + +from bpp.util.uczelnia_scope import tylko_jedna_uczelnia + + +def scope_metryki(qs, uczelnia): + """Zawęź queryset MetrykaAutora do uczelni; no-op przy single-install/None.""" + if uczelnia is None or tylko_jedna_uczelnia(): + return qs + return qs.filter(uczelnia=uczelnia) diff --git a/src/ewaluacja_metryki/views/generation.py b/src/ewaluacja_metryki/views/generation.py index 7c4818200..c9c9444ef 100644 --- a/src/ewaluacja_metryki/views/generation.py +++ b/src/ewaluacja_metryki/views/generation.py @@ -6,6 +6,7 @@ from django.views import View from ewaluacja_common.models import Rodzaj_Autora +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu from ..models import StatusGenerowania from ..tasks import generuj_metryki_task_parallel @@ -36,7 +37,8 @@ def post(self, request, *args, **kwargs): return redirect("ewaluacja_metryki:lista") # Sprawdź czy generowanie nie jest już w trakcie - status = StatusGenerowania.get_or_create() + uczelnia = uczelnia_dla_odczytu(request) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) if status.w_trakcie: if request.headers.get("HX-Request"): # Dla HTMX zwróć aktualny status @@ -71,7 +73,12 @@ def post(self, request, *args, **kwargs): # Oblicz liczbę autorów do przetworzenia (aby ustawić status od razu) from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc - total_count = IloscUdzialowDlaAutoraZaCalosc.objects.all().count() + if uczelnia: + total_count = IloscUdzialowDlaAutoraZaCalosc.objects.filter( + uczelnia=uczelnia + ).count() + else: + total_count = IloscUdzialowDlaAutoraZaCalosc.objects.count() # Uruchom równoległy task (z domyślnym przeliczaniem liczby N) result = generuj_metryki_task_parallel.delay( @@ -81,6 +88,7 @@ def post(self, request, *args, **kwargs): nadpisz=nadpisz, przelicz_liczbe_n=True, # Zawsze przeliczaj liczbę N przy generowaniu metryk rodzaje_autora=rodzaje_autora, + uczelnia_id=uczelnia.pk if uczelnia else None, ) # KLUCZOWE: Ustaw status w_trakcie=True OD RAZU w widoku @@ -126,7 +134,8 @@ class StatusGenerowaniaView(View): """Widok zwracający status generowania jako JSON (dla AJAX)""" def get(self, request, *args, **kwargs): - status = StatusGenerowania.get_or_create() + uczelnia = uczelnia_dla_odczytu(request) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) return JsonResponse( { @@ -169,7 +178,8 @@ def get(self, request, *args, **kwargs): from django.shortcuts import render # Pobierz status generowania - status = StatusGenerowania.get_or_create() + uczelnia = uczelnia_dla_odczytu(request) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) # Sprawdź czy poprzedni status był w trakcie (dla odświeżenia strony po zakończeniu) previous_status_was_running = ( From 115399114329cce839f82fc91d133f66baf6f5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 12:46:15 +0200 Subject: [PATCH 159/247] feat(metryki): read-side lista + statystyki filtrowane per uczelnia (D, task 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widoki MetrykiListView i StatystykiView zawężają swoje QS do uczelni oglądającego (scope_metryki + uczelnia_dla_odczytu). Dropdowny jednostek i dyscyplin w liście również scoped. StatusGenerowania.get_or_create przekazuje uczelnia z requestu. Single-install jest no-op (guard w scope_metryki). Naprawiony test_metryki_list_view_filtering_by_jednostka (jawne uczelnia= na baker.make, by był odporny na wielouczelniowość). Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_per_uczelnia.py | 20 ++++++ src/ewaluacja_metryki/tests/test_views.py | 18 ++++- src/ewaluacja_metryki/views/list.py | 46 ++++++++++--- src/ewaluacja_metryki/views/statistics.py | 67 ++++++++++++------- 4 files changed, 115 insertions(+), 36 deletions(-) diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index 33113eb1b..9abc22ad6 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -194,3 +194,23 @@ def test_scope_metryki_multi_filtruje(autor_jan_kowalski, dyscyplina1): _make_metryka(autor2, dyscyplina1, u2) qs = scope_metryki(MetrykaAutora.objects.all(), u1) assert list(qs.values_list("uczelnia_id", flat=True)) == [u1.pk] + + +@pytest.mark.django_db +def test_lista_metryk_filtruje_po_uczelni( + client, settings, django_user_model, dyscyplina1, uczelnia1, uczelnia2, site1 +): + from django.urls import reverse + + settings.ALLOWED_HOSTS = ["*"] + autor1 = baker.make("bpp.Autor") + autor2 = baker.make("bpp.Autor") + _make_metryka(autor1, dyscyplina1, uczelnia1) + _make_metryka(autor2, dyscyplina1, uczelnia2) + + su = django_user_model.objects.create_superuser("su_d7", "su_d7@x.pl", "x") + client.force_login(su) + resp = client.get(reverse("ewaluacja_metryki:lista"), HTTP_HOST=site1.domain) + assert resp.status_code == 200 + uczelnie = {m.uczelnia_id for m in resp.context["metryki"]} + assert uczelnie == {uczelnia1.pk} # tylko uczelnia z site1, nie uczelnia2 diff --git a/src/ewaluacja_metryki/tests/test_views.py b/src/ewaluacja_metryki/tests/test_views.py index e1cca29a4..40294b01e 100644 --- a/src/ewaluacja_metryki/tests/test_views.py +++ b/src/ewaluacja_metryki/tests/test_views.py @@ -105,15 +105,26 @@ def test_metryki_list_view_filtering_by_nazwisko(admin_user, client): @pytest.mark.django_db def test_metryki_list_view_filtering_by_jednostka(admin_user, client): - """Test filtrowania po jednostce""" + """Test filtrowania po jednostce. + + Używa jednej jawnej Uczelni dla obu jednostek i metryk, + co zapewnia spójne działanie scope_metryki (single-install no-op + lub multi-install z tym samym scopem co request). + """ + from bpp.models import Uczelnia as UczelniaModel + client.force_login(admin_user) - jednostka1 = baker.make(Jednostka, nazwa="Instytut Informatyki") - jednostka2 = baker.make(Jednostka, nazwa="Instytut Fizyki") + # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) + # i jednocześnie metryki mają spójne uczelnia_id z request-resolution + u = baker.make(UczelniaModel) + jednostka1 = baker.make(Jednostka, nazwa="Instytut Informatyki", uczelnia=u) + jednostka2 = baker.make(Jednostka, nazwa="Instytut Fizyki", uczelnia=u) metryka1 = baker.make( MetrykaAutora, jednostka=jednostka1, + uczelnia=u, slot_maksymalny=Decimal("4.0"), slot_nazbierany=Decimal("3.0"), punkty_nazbierane=Decimal("120.0"), @@ -123,6 +134,7 @@ def test_metryki_list_view_filtering_by_jednostka(admin_user, client): metryka2 = baker.make( MetrykaAutora, jednostka=jednostka2, + uczelnia=u, slot_maksymalny=Decimal("4.0"), slot_nazbierany=Decimal("3.0"), punkty_nazbierane=Decimal("120.0"), diff --git a/src/ewaluacja_metryki/views/list.py b/src/ewaluacja_metryki/views/list.py index e85070918..be3fdf4a2 100644 --- a/src/ewaluacja_metryki/views/list.py +++ b/src/ewaluacja_metryki/views/list.py @@ -4,8 +4,10 @@ from bpp.models import Jednostka, Wydzial from bpp.models.uczelnia import Uczelnia from ewaluacja_common.models import Rodzaj_Autora +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu from ..models import MetrykaAutora, StatusGenerowania +from ..uczelnia_scope import scope_metryki from .mixins import EwaluacjaRequiredMixin, ma_pelne_uprawnienia_ewaluacji @@ -130,13 +132,18 @@ def get_queryset(self): .values("count") ) - queryset = ( + uczelnia = uczelnia_dla_odczytu(self.request) + queryset = scope_metryki( super() .get_queryset() .select_related( - "autor", "dyscyplina_naukowa", "jednostka", "jednostka__wydzial" + "autor", + "dyscyplina_naukowa", + "jednostka", + "jednostka__wydzial", ) - .annotate(autor_discipline_count=Subquery(discipline_count)) + .annotate(autor_discipline_count=Subquery(discipline_count)), + uczelnia, ) queryset = self._apply_filters(queryset) @@ -165,13 +172,25 @@ def _get_jednostki_wydzialy_context(self): context = {} # Sprawdź czy uczelnia używa wydziałów - uczelnia = Uczelnia.objects.get_for_request(self.request) - context["uzywa_wydzialow"] = uczelnia.uzywaj_wydzialow if uczelnia else False + uczelnia = uczelnia_dla_odczytu(self.request) + viewing_uczelnia = Uczelnia.objects.get_for_request(self.request) + context["uzywa_wydzialow"] = ( + viewing_uczelnia.uzywaj_wydzialow if viewing_uczelnia else False + ) + + # Autorzy mający metryki w bieżącej uczelni (scoped) + scoped_metryki_autorzy = ( + scope_metryki(MetrykaAutora.objects.all(), uczelnia) + .values_list("autor_id", flat=True) + .distinct() + ) # Jeśli wydzial jest wybrany, filtruj jednostki tylko z tego wydziału wydzial_id = self.request.GET.get("wydzial") jednostki_queryset = Jednostka.objects.filter( - pk__in=Autor.objects.filter(metryki__isnull=False) + pk__in=Autor.objects.filter( + pk__in=scoped_metryki_autorzy, + ) .values_list("aktualna_jednostka", flat=True) .distinct() ).distinct() @@ -185,7 +204,9 @@ def _get_jednostki_wydzialy_context(self): # Buduj listę wydziałów na podstawie aktualnych jednostek autorów z metrykami context["wydzialy"] = ( Wydzial.objects.filter( - jednostka__in=Autor.objects.filter(metryki__isnull=False) + jednostka__in=Autor.objects.filter( + pk__in=scoped_metryki_autorzy, + ) .values_list("aktualna_jednostka", flat=True) .distinct() ) @@ -203,8 +224,14 @@ def _get_dyscypliny_context(self): """Get dyscypliny list for filters.""" from bpp.models import Dyscyplina_Naukowa + uczelnia = uczelnia_dla_odczytu(self.request) + scoped_dyscypliny_ids = ( + scope_metryki(MetrykaAutora.objects.all(), uczelnia) + .values_list("dyscyplina_naukowa_id", flat=True) + .distinct() + ) dyscypliny = ( - Dyscyplina_Naukowa.objects.filter(metrykaautora__isnull=False) + Dyscyplina_Naukowa.objects.filter(pk__in=scoped_dyscypliny_ids) .distinct() .order_by("nazwa") ) @@ -226,7 +253,8 @@ def _get_statistics_context(self): def _get_status_context(self): """Get generation status and progress information.""" - status = StatusGenerowania.get_or_create() + uczelnia = uczelnia_dla_odczytu(self.request) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) context = { "status_generowania": status, "dostepne_rodzaje_autorow": Rodzaj_Autora.objects.filter( diff --git a/src/ewaluacja_metryki/views/statistics.py b/src/ewaluacja_metryki/views/statistics.py index 8a14198e3..f25280133 100644 --- a/src/ewaluacja_metryki/views/statistics.py +++ b/src/ewaluacja_metryki/views/statistics.py @@ -1,7 +1,10 @@ from django.db.models import Avg, Count, Sum from django.views.generic import ListView +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + from ..models import MetrykaAutora +from ..uczelnia_scope import scope_metryki from .mixins import EwaluacjaRequiredMixin, ma_pelne_uprawnienia_ewaluacji @@ -26,28 +29,38 @@ class StatystykiView( context_object_name = "top_autorzy_pkd" # Renamed for clarity def get_queryset(self): - # Top 20 autorów wg średniej PKDaut/slot - return MetrykaAutora.objects.select_related( - "autor", "dyscyplina_naukowa", "jednostka" + # Top 20 autorów wg średniej PKDaut/slot (scoped do uczelni) + uczelnia = uczelnia_dla_odczytu(self.request) + return scope_metryki( + MetrykaAutora.objects.select_related( + "autor", "dyscyplina_naukowa", "jednostka" + ), + uczelnia, ).order_by("-srednia_za_slot_nazbierana")[:20] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + uczelnia = uczelnia_dla_odczytu(self.request) + # Scoped base queryset — wszystkie metryki dla oglądanej uczelni + wszystkie = scope_metryki(MetrykaAutora.objects.all(), uczelnia) + # Keep the old name for backward compatibility in template context["top_autorzy"] = context["top_autorzy_pkd"] - # Top 20 autorów wg slotów wypełnionych (the absolute top - highest slot filling AND highest PKDaut/slot) + # Top 20 autorów wg slotów wypełnionych context["top_autorzy_sloty"] = ( - MetrykaAutora.objects.select_related( - "autor", "dyscyplina_naukowa", "jednostka" + scope_metryki( + MetrykaAutora.objects.select_related( + "autor", "dyscyplina_naukowa", "jednostka" + ), + uczelnia, ) - .filter(slot_nazbierany__gt=0) # Exclude those with zero slots + .filter(slot_nazbierany__gt=0) .order_by("-slot_nazbierany", "-srednia_za_slot_nazbierana")[:20] ) # Statystyki globalne - wszystkie = MetrykaAutora.objects.all() context["statystyki_globalne"] = wszystkie.aggregate( liczba_wierszy=Count("id"), liczba_autorow=Count("autor", distinct=True), @@ -59,8 +72,11 @@ def get_context_data(self, **kwargs): # Najniższe 20 autorów wg PKDaut/slot (nie-zerowych) context["bottom_autorzy_pkd"] = ( - MetrykaAutora.objects.select_related( - "autor", "dyscyplina_naukowa", "jednostka" + scope_metryki( + MetrykaAutora.objects.select_related( + "autor", "dyscyplina_naukowa", "jednostka" + ), + uczelnia, ) .filter(srednia_za_slot_nazbierana__gt=0) .order_by("srednia_za_slot_nazbierana")[:20] @@ -68,8 +84,11 @@ def get_context_data(self, **kwargs): # Najniższe 20 autorów wg slotów wypełnionych (nie-zerowych) context["bottom_autorzy_sloty"] = ( - MetrykaAutora.objects.select_related( - "autor", "dyscyplina_naukowa", "jednostka" + scope_metryki( + MetrykaAutora.objects.select_related( + "autor", "dyscyplina_naukowa", "jednostka" + ), + uczelnia, ) .filter(slot_nazbierany__gt=0) .order_by("slot_nazbierany")[:20] @@ -79,8 +98,11 @@ def get_context_data(self, **kwargs): from bpp.models import Autor_Dyscyplina autorzy_zerowi_raw = ( - MetrykaAutora.objects.select_related( - "autor", "dyscyplina_naukowa", "jednostka" + scope_metryki( + MetrykaAutora.objects.select_related( + "autor", "dyscyplina_naukowa", "jednostka" + ), + uczelnia, ) .filter(srednia_za_slot_nazbierana=0) .order_by("autor__nazwisko", "autor__imiona") @@ -105,7 +127,8 @@ def get_context_data(self, **kwargs): ) lata_list = list(lata_dyscypliny) - # Dodaj do listy tylko jeśli autor ma przynajmniej jeden rok z jest_w_n=True + # Dodaj do listy tylko jeśli autor ma przynajmniej jeden rok + # z jest_w_n=True if lata_list: metryka.lata_zerowe = lata_list autorzy_zerowi.append(metryka) @@ -113,8 +136,8 @@ def get_context_data(self, **kwargs): context["autorzy_zerowi"] = autorzy_zerowi # Statystyki wg jednostek - jednostki_stats = ( - MetrykaAutora.objects.values("jednostka__nazwa", "jednostka__skrot") + context["jednostki_stats"] = ( + wszystkie.values("jednostka__nazwa", "jednostka__skrot") .annotate( liczba_autorow=Count("id"), srednia_wykorzystania=Avg("procent_wykorzystania_slotow"), @@ -123,13 +146,10 @@ def get_context_data(self, **kwargs): ) .order_by("-srednia_pkd_slot")[:10] ) - context["jednostki_stats"] = jednostki_stats # Statystyki wg dyscyplin - dyscypliny_stats = ( - MetrykaAutora.objects.values( - "dyscyplina_naukowa__nazwa", "dyscyplina_naukowa__kod" - ) + context["dyscypliny_stats"] = ( + wszystkie.values("dyscyplina_naukowa__nazwa", "dyscyplina_naukowa__kod") .annotate( liczba_autorow=Count("id"), srednia_wykorzystania=Avg("procent_wykorzystania_slotow"), @@ -138,9 +158,8 @@ def get_context_data(self, **kwargs): ) .order_by("-srednia_pkd_slot") ) - context["dyscypliny_stats"] = dyscypliny_stats - # Rozkład wykorzystania slotów + # Rozkład wykorzystania slotów (wszystkie = już scoped) context["wykorzystanie_ranges"] = { "0-25%": wszystkie.filter(procent_wykorzystania_slotow__lt=25).count(), "25-50%": wszystkie.filter( From aa0e9c7dc276e72b54ad61393e81f64500a4652f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 12:58:36 +0200 Subject: [PATCH 160/247] =?UTF-8?q?fix(metryki):=20scope=20discipline=5Fco?= =?UTF-8?q?unt=20subquery=20+=20uzywa=5Fwydzialow=20po=20uczelni=20ogl?= =?UTF-8?q?=C4=85daj=C4=85cego=20(D,=20task=207=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_per_uczelnia.py | 36 +++++++++++++++++++ src/ewaluacja_metryki/views/list.py | 15 ++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index 9abc22ad6..acf1b6d7b 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -214,3 +214,39 @@ def test_lista_metryk_filtruje_po_uczelni( assert resp.status_code == 200 uczelnie = {m.uczelnia_id for m in resp.context["metryki"]} assert uczelnie == {uczelnia1.pk} # tylko uczelnia z site1, nie uczelnia2 + + +@pytest.mark.django_db +def test_autor_discipline_count_scoped_per_uczelnia( + client, + settings, + django_user_model, + dyscyplina1, + dyscyplina2, + uczelnia1, + uczelnia2, + site1, +): + """Regresja D/Fix-1: autor_discipline_count zlicza dyscypliny TYLKO z uczelni oglądanej. + + Autor ma 1 dyscyplinę w u1 i 1 dyscyplinę w u2. + Gdy patrzymy przez site1 (→ uczelnia1), annotacja powinna wynosić 1, nie 2. + """ + from django.urls import reverse + + settings.ALLOWED_HOSTS = ["*"] + autor = baker.make("bpp.Autor") + _make_metryka(autor, dyscyplina1, uczelnia1) + _make_metryka(autor, dyscyplina2, uczelnia2) + + su = django_user_model.objects.create_superuser("su_disc", "su_disc@x.pl", "x") + client.force_login(su) + resp = client.get(reverse("ewaluacja_metryki:lista"), HTTP_HOST=site1.domain) + assert resp.status_code == 200 + + metryki = list(resp.context["metryki"]) + # Site1 → uczelnia1: only the u1 metryka should be visible + assert len(metryki) == 1 + assert metryki[0].uczelnia_id == uczelnia1.pk + # discipline_count must reflect only u1 disciplines (1), not u1+u2 total (2) + assert metryki[0].autor_discipline_count == 1 diff --git a/src/ewaluacja_metryki/views/list.py b/src/ewaluacja_metryki/views/list.py index be3fdf4a2..b1f745b54 100644 --- a/src/ewaluacja_metryki/views/list.py +++ b/src/ewaluacja_metryki/views/list.py @@ -2,7 +2,6 @@ from django.views.generic import ListView from bpp.models import Jednostka, Wydzial -from bpp.models.uczelnia import Uczelnia from ewaluacja_common.models import Rodzaj_Autora from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu @@ -124,9 +123,12 @@ def _apply_sorting(self, queryset): def get_queryset(self): from django.db.models import Count, OuterRef, Subquery - # Subquery to count disciplines for each author + # Subquery to count disciplines for each author within their own uczelnia discipline_count = ( - MetrykaAutora.objects.filter(autor=OuterRef("autor")) + MetrykaAutora.objects.filter( + autor=OuterRef("autor"), + uczelnia=OuterRef("uczelnia"), + ) .values("autor") .annotate(count=Count("dyscyplina_naukowa")) .values("count") @@ -171,12 +173,9 @@ def _get_jednostki_wydzialy_context(self): context = {} - # Sprawdź czy uczelnia używa wydziałów + # Sprawdź czy uczelnia używa wydziałów (scope per oglądanej uczelni) uczelnia = uczelnia_dla_odczytu(self.request) - viewing_uczelnia = Uczelnia.objects.get_for_request(self.request) - context["uzywa_wydzialow"] = ( - viewing_uczelnia.uzywaj_wydzialow if viewing_uczelnia else False - ) + context["uzywa_wydzialow"] = uczelnia.uzywaj_wydzialow if uczelnia else False # Autorzy mający metryki w bieżącej uczelni (scoped) scoped_metryki_autorzy = ( From 76845347e9b285152b5cbc6fdc06c36bb51bc92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 13:03:30 +0200 Subject: [PATCH 161/247] feat(metryki): eksporty XLSX scoped per uczelnia (base_qs, Opcja A) (D, task 8) Co-Authored-By: Claude Sonnet 4.6 --- src/ewaluacja_metryki/export_helpers.py | 65 ++++++++++++------- .../tests/test_per_uczelnia.py | 30 +++++++++ src/ewaluacja_metryki/views/export.py | 40 ++++++++++-- 3 files changed, 106 insertions(+), 29 deletions(-) diff --git a/src/ewaluacja_metryki/export_helpers.py b/src/ewaluacja_metryki/export_helpers.py index 85517f0dc..fd2907ab6 100644 --- a/src/ewaluacja_metryki/export_helpers.py +++ b/src/ewaluacja_metryki/export_helpers.py @@ -1,14 +1,14 @@ """Helper functions for XLSX export views in ewaluacja_metryki.""" -def export_globalne_stats(ws, header_font, header_fill, header_alignment): +def export_globalne_stats(ws, header_font, header_fill, header_alignment, base_qs=None): """Export global statistics table.""" from django.db.models import Avg, Count, Sum from .models import MetrykaAutora ws.title = "Statystyki globalne" - wszystkie = MetrykaAutora.objects.all() + wszystkie = base_qs if base_qs is not None else MetrykaAutora.objects.all() stats = wszystkie.aggregate( liczba_wierszy=Count("id"), liczba_autorow=Count("autor", distinct=True), @@ -119,14 +119,17 @@ def add_autofilter_and_freeze(ws, headers, last_data_row): ws.freeze_panes = ws["A2"] -def export_top_autorzy(ws, header_font, header_fill, header_alignment, thin_border): +def export_top_autorzy( + ws, header_font, header_fill, header_alignment, thin_border, base_qs=None +): """Export top 20 authors by PKDaut/slot.""" from .models import MetrykaAutora ws.title = "Top 20 autorów PKDaut-slot" - queryset = MetrykaAutora.objects.select_related( - "autor", "dyscyplina_naukowa", "jednostka" - ).order_by("-srednia_za_slot_nazbierana")[:20] + base = base_qs if base_qs is not None else MetrykaAutora.objects.all() + queryset = base.select_related("autor", "dyscyplina_naukowa", "jednostka").order_by( + "-srednia_za_slot_nazbierana" + )[:20] headers = get_author_metrics_headers() write_headers(ws, headers, header_font, header_fill, header_alignment, thin_border) @@ -139,13 +142,16 @@ def export_top_autorzy(ws, header_font, header_fill, header_alignment, thin_bord add_autofilter_and_freeze(ws, headers, last_data_row) -def export_top_sloty(ws, header_font, header_fill, header_alignment, thin_border): +def export_top_sloty( + ws, header_font, header_fill, header_alignment, thin_border, base_qs=None +): """Export top 20 authors by slots filled.""" from .models import MetrykaAutora ws.title = "Top 20 autorów sloty wypełnione" + base = base_qs if base_qs is not None else MetrykaAutora.objects.all() queryset = ( - MetrykaAutora.objects.select_related("autor", "dyscyplina_naukowa", "jednostka") + base.select_related("autor", "dyscyplina_naukowa", "jednostka") .filter(slot_nazbierany__gt=0) .order_by("-slot_nazbierany", "-srednia_za_slot_nazbierana")[:20] ) @@ -157,13 +163,16 @@ def export_top_sloty(ws, header_font, header_fill, header_alignment, thin_border write_author_metric_row(ws, row_idx, metryka, row_idx - 1) -def export_bottom_pkd(ws, header_font, header_fill, header_alignment, thin_border): +def export_bottom_pkd( + ws, header_font, header_fill, header_alignment, thin_border, base_qs=None +): """Export bottom 20 authors by PKDaut/slot.""" from .models import MetrykaAutora ws.title = "Najniższe 20 PKDaut-slot" + base = base_qs if base_qs is not None else MetrykaAutora.objects.all() queryset = ( - MetrykaAutora.objects.select_related("autor", "dyscyplina_naukowa", "jednostka") + base.select_related("autor", "dyscyplina_naukowa", "jednostka") .filter(srednia_za_slot_nazbierana__gt=0) .order_by("srednia_za_slot_nazbierana")[:20] ) @@ -175,13 +184,16 @@ def export_bottom_pkd(ws, header_font, header_fill, header_alignment, thin_borde write_author_metric_row(ws, row_idx, metryka, row_idx - 1) -def export_bottom_sloty(ws, header_font, header_fill, header_alignment, thin_border): +def export_bottom_sloty( + ws, header_font, header_fill, header_alignment, thin_border, base_qs=None +): """Export bottom 20 authors by slots filled.""" from .models import MetrykaAutora ws.title = "Najniższe 20 sloty wypełnione" + base = base_qs if base_qs is not None else MetrykaAutora.objects.all() queryset = ( - MetrykaAutora.objects.select_related("autor", "dyscyplina_naukowa", "jednostka") + base.select_related("autor", "dyscyplina_naukowa", "jednostka") .filter(slot_nazbierany__gt=0) .order_by("slot_nazbierany")[:20] ) @@ -193,7 +205,9 @@ def export_bottom_sloty(ws, header_font, header_fill, header_alignment, thin_bor write_author_metric_row(ws, row_idx, metryka, row_idx - 1) -def export_zerowi(ws, header_font, header_fill, header_alignment, thin_border): +def export_zerowi( + ws, header_font, header_fill, header_alignment, thin_border, base_qs=None +): """Export authors with zero metrics. UWAGA: Eksportowane są tylko autorzy i lata, za które mieli @@ -205,8 +219,9 @@ def export_zerowi(ws, header_font, header_fill, header_alignment, thin_border): ws.title = "Autorzy zerowi" + base = base_qs if base_qs is not None else MetrykaAutora.objects.all() queryset_raw = ( - MetrykaAutora.objects.select_related("autor", "dyscyplina_naukowa", "jednostka") + base.select_related("autor", "dyscyplina_naukowa", "jednostka") .filter(srednia_za_slot_nazbierana=0) .order_by("autor__nazwisko", "autor__imiona") ) @@ -268,15 +283,18 @@ def export_zerowi(ws, header_font, header_fill, header_alignment, thin_border): lp += 1 -def export_jednostki(ws, header_font, header_fill, header_alignment, thin_border): +def export_jednostki( + ws, header_font, header_fill, header_alignment, thin_border, base_qs=None +): """Export unit statistics.""" from django.db.models import Avg, Count, Sum from .models import MetrykaAutora ws.title = "Statystyki jednostek" + base = base_qs if base_qs is not None else MetrykaAutora.objects.all() stats = ( - MetrykaAutora.objects.values("jednostka__nazwa", "jednostka__skrot") + base.values("jednostka__nazwa", "jednostka__skrot") .annotate( liczba_autorow=Count("id"), srednia_wykorzystania=Avg("procent_wykorzystania_slotow"), @@ -308,17 +326,18 @@ def export_jednostki(ws, header_font, header_fill, header_alignment, thin_border ws.cell(row=row_idx, column=5, value=f"{stat['suma_punktow'] or 0:.0f}") -def export_dyscypliny(ws, header_font, header_fill, header_alignment, thin_border): +def export_dyscypliny( + ws, header_font, header_fill, header_alignment, thin_border, base_qs=None +): """Export discipline statistics.""" from django.db.models import Avg, Count, Sum from .models import MetrykaAutora ws.title = "Statystyki dyscyplin" + base = base_qs if base_qs is not None else MetrykaAutora.objects.all() stats = ( - MetrykaAutora.objects.values( - "dyscyplina_naukowa__nazwa", "dyscyplina_naukowa__kod" - ) + base.values("dyscyplina_naukowa__nazwa", "dyscyplina_naukowa__kod") .annotate( liczba_autorow=Count("id"), srednia_wykorzystania=Avg("procent_wykorzystania_slotow"), @@ -349,12 +368,14 @@ def export_dyscypliny(ws, header_font, header_fill, header_alignment, thin_borde ws.cell(row=row_idx, column=6, value=f"{stat['suma_punktow'] or 0:.0f}") -def export_wykorzystanie(ws, header_font, header_fill, header_alignment, thin_border): +def export_wykorzystanie( + ws, header_font, header_fill, header_alignment, thin_border, base_qs=None +): """Export slot utilization distribution.""" from .models import MetrykaAutora ws.title = "Rozkład wykorzystania slotów" - wszystkie = MetrykaAutora.objects.all() + wszystkie = base_qs if base_qs is not None else MetrykaAutora.objects.all() headers = ["Przedział", "Liczba wierszy", "Procent"] write_headers(ws, headers, header_font, header_fill, header_alignment, thin_border) diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index acf1b6d7b..f28913bb5 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -250,3 +250,33 @@ def test_autor_discipline_count_scoped_per_uczelnia( assert metryki[0].uczelnia_id == uczelnia1.pk # discipline_count must reflect only u1 disciplines (1), not u1+u2 total (2) assert metryki[0].autor_discipline_count == 1 + + +@pytest.mark.django_db +def test_export_globalne_stats_scoped( + autor_jan_kowalski, dyscyplina1, uczelnia1, uczelnia2 +): + from openpyxl import Workbook + from openpyxl.styles import Alignment, Font, PatternFill + + from ewaluacja_metryki.export_helpers import export_globalne_stats + from ewaluacja_metryki.uczelnia_scope import scope_metryki + + autor2 = baker.make("bpp.Autor") + _make_metryka(autor_jan_kowalski, dyscyplina1, uczelnia1) + _make_metryka(autor2, dyscyplina1, uczelnia2) + + base = scope_metryki(MetrykaAutora.objects.all(), uczelnia1) + ws = Workbook().active + header_font = Font(bold=True) + header_fill = PatternFill( + start_color="366092", end_color="366092", fill_type="solid" + ) + header_alignment = Alignment(horizontal="center") + export_globalne_stats(ws, header_font, header_fill, header_alignment, base_qs=base) + # "Liczba autorów" row value == 1 (tylko uczelnia1) + found = None + for row in ws.iter_rows(min_row=2, max_col=2, values_only=True): + if row[0] == "Liczba autorów": + found = row[1] + assert found == 1 diff --git a/src/ewaluacja_metryki/views/export.py b/src/ewaluacja_metryki/views/export.py index 3e37fccd3..0e36701dd 100644 --- a/src/ewaluacja_metryki/views/export.py +++ b/src/ewaluacja_metryki/views/export.py @@ -60,6 +60,8 @@ def _create_response(self, wb, table_type): def get(self, request, table_type): from django.http import HttpResponse + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + from ..export_helpers import ( auto_adjust_column_widths, export_bottom_pkd, @@ -72,6 +74,8 @@ def get(self, request, table_type): export_wykorzystanie, export_zerowi, ) + from ..models import MetrykaAutora + from ..uczelnia_scope import scope_metryki # Dispatch table to appropriate export handler table_handlers = { @@ -93,12 +97,22 @@ def get(self, request, table_type): self._setup_workbook_and_styles() ) + uczelnia = uczelnia_dla_odczytu(request) + base_qs = scope_metryki(MetrykaAutora.objects.all(), uczelnia) + # Call the appropriate handler handler = table_handlers[table_type] if table_type == "globalne": - handler(ws, header_font, header_fill, header_alignment) + handler(ws, header_font, header_fill, header_alignment, base_qs=base_qs) else: - handler(ws, header_font, header_fill, header_alignment, thin_border) + handler( + ws, + header_font, + header_fill, + header_alignment, + thin_border, + base_qs=base_qs, + ) auto_adjust_column_widths(ws) @@ -592,22 +606,34 @@ def _create_response(self, wb): def get(self, request): from django.db.models import Count, OuterRef, Subquery + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + from ..uczelnia_scope import scope_metryki + # Setup workbook and styles styles = self._setup_workbook_styles() ws = styles["ws"] wb = styles["wb"] - # Build queryset with discipline count annotation + uczelnia = uczelnia_dla_odczytu(request) + + # Subquery to count disciplines for each author within their own uczelnia discipline_count = ( - MetrykaAutora.objects.filter(autor=OuterRef("autor")) + MetrykaAutora.objects.filter( + autor=OuterRef("autor"), + uczelnia=OuterRef("uczelnia"), + ) .values("autor") .annotate(count=Count("dyscyplina_naukowa")) .values("count") ) - queryset = MetrykaAutora.objects.select_related( - "autor", "dyscyplina_naukowa", "jednostka", "jednostka__wydzial" - ).annotate(autor_discipline_count=Subquery(discipline_count)) + queryset = scope_metryki( + MetrykaAutora.objects.select_related( + "autor", "dyscyplina_naukowa", "jednostka", "jednostka__wydzial" + ).annotate(autor_discipline_count=Subquery(discipline_count)), + uczelnia, + ) # Apply filters and sorting queryset = self._apply_filters_to_queryset(queryset, request) From 1ee9b598a3bc4f664cc7460bb26a42e2186b03b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 13:13:08 +0200 Subject: [PATCH 162/247] =?UTF-8?q?fix(metryki):=20scope=20widoczno=C5=9B?= =?UTF-8?q?=C4=87=20kolumny=20dyscypliny=20w=20eksporcie=20+=20ujednolicen?= =?UTF-8?q?ie=20nazw=20(D,=20task=208=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/ewaluacja_metryki/export_helpers.py | 18 +++++++++--------- src/ewaluacja_metryki/views/export.py | 18 +++++++++++++----- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/ewaluacja_metryki/export_helpers.py b/src/ewaluacja_metryki/export_helpers.py index fd2907ab6..d45699dc4 100644 --- a/src/ewaluacja_metryki/export_helpers.py +++ b/src/ewaluacja_metryki/export_helpers.py @@ -8,8 +8,8 @@ def export_globalne_stats(ws, header_font, header_fill, header_alignment, base_q from .models import MetrykaAutora ws.title = "Statystyki globalne" - wszystkie = base_qs if base_qs is not None else MetrykaAutora.objects.all() - stats = wszystkie.aggregate( + base = base_qs if base_qs is not None else MetrykaAutora.objects.all() + stats = base.aggregate( liczba_wierszy=Count("id"), liczba_autorow=Count("autor", distinct=True), srednia_wykorzystania=Avg("procent_wykorzystania_slotow"), @@ -375,36 +375,36 @@ def export_wykorzystanie( from .models import MetrykaAutora ws.title = "Rozkład wykorzystania slotów" - wszystkie = base_qs if base_qs is not None else MetrykaAutora.objects.all() + base = base_qs if base_qs is not None else MetrykaAutora.objects.all() headers = ["Przedział", "Liczba wierszy", "Procent"] write_headers(ws, headers, header_font, header_fill, header_alignment, thin_border) - total = wszystkie.count() + total = base.count() ranges = [ - ("0-25%", wszystkie.filter(procent_wykorzystania_slotow__lt=25).count()), + ("0-25%", base.filter(procent_wykorzystania_slotow__lt=25).count()), ( "25-50%", - wszystkie.filter( + base.filter( procent_wykorzystania_slotow__gte=25, procent_wykorzystania_slotow__lt=50, ).count(), ), ( "50-75%", - wszystkie.filter( + base.filter( procent_wykorzystania_slotow__gte=50, procent_wykorzystania_slotow__lt=75, ).count(), ), ( "75-99%", - wszystkie.filter( + base.filter( procent_wykorzystania_slotow__gte=75, procent_wykorzystania_slotow__lt=99, ).count(), ), - ("99-100%", wszystkie.filter(procent_wykorzystania_slotow__gte=99).count()), + ("99-100%", base.filter(procent_wykorzystania_slotow__gte=99).count()), ] for row_idx, (range_name, count) in enumerate(ranges, 2): diff --git a/src/ewaluacja_metryki/views/export.py b/src/ewaluacja_metryki/views/export.py index 0e36701dd..fa67a2283 100644 --- a/src/ewaluacja_metryki/views/export.py +++ b/src/ewaluacja_metryki/views/export.py @@ -4,7 +4,6 @@ from django.views import View from bpp.models import Jednostka, Wydzial -from bpp.models.uczelnia import Uczelnia from ewaluacja_common.models import Rodzaj_Autora from ..models import MetrykaAutora @@ -259,14 +258,23 @@ def _apply_sorting_to_queryset(self, queryset, request): def _determine_visible_columns(self, request): """Determine which columns should be visible in export.""" from bpp.models import Dyscyplina_Naukowa + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + from ..models import MetrykaAutora + from ..uczelnia_scope import scope_metryki - uczelnia = Uczelnia.objects.get_for_request(request) + uczelnia = uczelnia_dla_odczytu(request) uzywa_wydzialow = uczelnia.uzywaj_wydzialow if uczelnia else False - wszystkie_dyscypliny = Dyscyplina_Naukowa.objects.filter( - metrykaautora__isnull=False + scoped_disc_ids = ( + scope_metryki(MetrykaAutora.objects.all(), uczelnia) + .values_list("dyscyplina_naukowa_id", flat=True) + .distinct() + ) + dyscypliny = Dyscyplina_Naukowa.objects.filter( + pk__in=scoped_disc_ids ).distinct() - tylko_jedna_dyscyplina = wszystkie_dyscypliny.count() == 1 + tylko_jedna_dyscyplina = dyscypliny.count() == 1 return { "uzywa_wydzialow": uzywa_wydzialow, From 5cd2d6130d8c4c8584a14e09f990f83418cc8f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 13:17:28 +0200 Subject: [PATCH 163/247] feat(metryki): detail ranking/inne_dyscypliny + pin_unpin redirect per uczelnia (D, task 9) Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_per_uczelnia.py | 29 +++++++++++++++++++ src/ewaluacja_metryki/views/detail.py | 17 +++++++++-- src/ewaluacja_metryki/views/pin_unpin.py | 21 +++++++++++--- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/ewaluacja_metryki/tests/test_per_uczelnia.py b/src/ewaluacja_metryki/tests/test_per_uczelnia.py index f28913bb5..d761255a0 100644 --- a/src/ewaluacja_metryki/tests/test_per_uczelnia.py +++ b/src/ewaluacja_metryki/tests/test_per_uczelnia.py @@ -280,3 +280,32 @@ def test_export_globalne_stats_scoped( if row[0] == "Liczba autorów": found = row[1] assert found == 1 + + +@pytest.mark.django_db +def test_detail_pozycja_w_jednostce_per_uczelnia(dyscyplina1, uczelnia1, uczelnia2): + """_get_position_context liczy pozycję tylko w obrębie uczelni metryki.""" + from ewaluacja_metryki.views.detail import MetrykaDetailView + + autor1 = baker.make("bpp.Autor") + autor2 = baker.make("bpp.Autor") + jedn = baker.make("bpp.Jednostka", uczelnia=uczelnia1) + m1 = _make_metryka( + autor1, + dyscyplina1, + uczelnia1, + jednostka=jedn, + srednia_za_slot_nazbierana=Decimal("5.0"), + ) + # obca metryka w tym samym jednostka_id ale uczelnia2 (sztuczny edge) + _make_metryka( + autor2, + dyscyplina1, + uczelnia2, + jednostka=jedn, + srednia_za_slot_nazbierana=Decimal("9.0"), + ) + view = MetrykaDetailView() + ctx = view._get_position_context(m1) + # liczba_w_jednostce liczona w obrębie uczelnia1 → 1 (tylko m1) + assert ctx["liczba_w_jednostce"] == 1 diff --git a/src/ewaluacja_metryki/views/detail.py b/src/ewaluacja_metryki/views/detail.py index f1f34aa98..3462e4cfb 100644 --- a/src/ewaluacja_metryki/views/detail.py +++ b/src/ewaluacja_metryki/views/detail.py @@ -3,8 +3,10 @@ from django.views.generic import DetailView from ewaluacja_common.models import Rodzaj_Autora +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu from ..models import MetrykaAutora +from ..uczelnia_scope import scope_metryki from .mixins import EwaluacjaRequiredMixin, ma_pelne_uprawnienia_ewaluacji @@ -38,6 +40,10 @@ def get_object(self, queryset=None): "Brak uprawnień do przeglądania metryk innego autora." ) + # Ogranicz do uczelni oglądającego (defense-in-depth: przy multi-install + # ta sama para autor+dyscyplina może istnieć w >1 uczelni). + queryset = scope_metryki(queryset, uczelnia_dla_odczytu(self.request)) + try: obj = queryset.get( autor__slug=autor_slug, @@ -364,7 +370,7 @@ def _get_unpinned_works_context(self, metryka): return context def _get_position_context(self, metryka): - """Get author's position in unit rankings.""" + """Get author's position in unit rankings (scoped to metryka's uczelnia).""" context = {} if metryka.jednostka: @@ -372,6 +378,7 @@ def _get_position_context(self, metryka): MetrykaAutora.objects.filter( jednostka=metryka.jednostka, dyscyplina_naukowa=metryka.dyscyplina_naukowa, + uczelnia=metryka.uczelnia, srednia_za_slot_nazbierana__gt=metryka.srednia_za_slot_nazbierana, ).count() + 1 @@ -380,6 +387,7 @@ def _get_position_context(self, metryka): context["liczba_w_jednostce"] = MetrykaAutora.objects.filter( jednostka=metryka.jednostka, dyscyplina_naukowa=metryka.dyscyplina_naukowa, + uczelnia=metryka.uczelnia, ).count() return context @@ -392,9 +400,12 @@ def get_context_data(self, **kwargs): self.request.user ) - # Pobierz inne dyscypliny tego samego autora + # Pobierz inne dyscypliny tego samego autora — tylko tej samej uczelni inne_dyscypliny = ( - MetrykaAutora.objects.filter(autor=metryka.autor) + MetrykaAutora.objects.filter( + autor=metryka.autor, + uczelnia=metryka.uczelnia, + ) .exclude(pk=metryka.pk) .select_related("dyscyplina_naukowa") .order_by("dyscyplina_naukowa__nazwa") diff --git a/src/ewaluacja_metryki/views/pin_unpin.py b/src/ewaluacja_metryki/views/pin_unpin.py index 1b1c05627..6e219d8a5 100644 --- a/src/ewaluacja_metryki/views/pin_unpin.py +++ b/src/ewaluacja_metryki/views/pin_unpin.py @@ -1,6 +1,9 @@ from django.core.exceptions import PermissionDenied from django.views import View +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + +from ..uczelnia_scope import scope_metryki from .mixins import EwaluacjaRequiredMixin, ma_pelne_uprawnienia_ewaluacji @@ -67,12 +70,17 @@ def post(self, request, content_type_id, object_id, autor_id, dyscyplina_id): # Redirect back to the detail view # Get MetrykaAutora for the author and discipline to redirect to the correct detail page + # Scoped to viewing uczelnia (defense-in-depth: multi-install, .first() picks + # the viewing-uczelnia's metryka when author exists in >1 uczelnia). from django.urls import reverse from ..models import MetrykaAutora - metryka = MetrykaAutora.objects.filter( - autor_id=autor_id, dyscyplina_naukowa_id=dyscyplina_id + metryka = scope_metryki( + MetrykaAutora.objects.filter( + autor_id=autor_id, dyscyplina_naukowa_id=dyscyplina_id + ), + uczelnia_dla_odczytu(request), ).first() if metryka: @@ -140,12 +148,17 @@ def post(self, request, content_type_id, object_id, autor_id, dyscyplina_id): # Redirect back to the detail view # Get MetrykaAutora for the author and discipline to redirect to the correct detail page + # Scoped to viewing uczelnia (defense-in-depth: multi-install, .first() picks + # the viewing-uczelnia's metryka when author exists in >1 uczelnia). from django.urls import reverse from ..models import MetrykaAutora - metryka = MetrykaAutora.objects.filter( - autor_id=autor_id, dyscyplina_naukowa_id=dyscyplina_id + metryka = scope_metryki( + MetrykaAutora.objects.filter( + autor_id=autor_id, dyscyplina_naukowa_id=dyscyplina_id + ), + uczelnia_dla_odczytu(request), ).first() if metryka: From d6cbecb254db3e5316cc9766b3b8b58f092b0246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 13:23:07 +0200 Subject: [PATCH 164/247] feat(metryki): admin pokazuje uczelnia w MetrykaAutora (parytet R2) (D, task 10) Co-Authored-By: Claude Sonnet 4.6 --- src/ewaluacja_metryki/admin.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ewaluacja_metryki/admin.py b/src/ewaluacja_metryki/admin.py index 7b43e5cd3..831dc9b98 100644 --- a/src/ewaluacja_metryki/admin.py +++ b/src/ewaluacja_metryki/admin.py @@ -11,6 +11,7 @@ class MetrykaAutoraAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): list_display = [ "autor", "dyscyplina_naukowa", + "uczelnia", "jednostka", "slot_maksymalny", "slot_nazbierany", @@ -20,7 +21,12 @@ class MetrykaAutoraAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): "data_obliczenia", ] - list_filter = ["dyscyplina_naukowa", "jednostka", "procent_wykorzystania_slotow"] + list_filter = [ + "uczelnia", + "dyscyplina_naukowa", + "jednostka", + "procent_wykorzystania_slotow", + ] search_fields = ["autor__nazwisko", "autor__imiona", "jednostka__nazwa"] @@ -34,7 +40,7 @@ class MetrykaAutoraAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): fieldsets = [ ( "Podstawowe informacje", - {"fields": ("autor", "dyscyplina_naukowa", "jednostka")}, + {"fields": ("autor", "dyscyplina_naukowa", "uczelnia", "jednostka")}, ), ("Parametry ewaluacji", {"fields": ("rok_min", "rok_max", "slot_maksymalny")}), ( From b58d203adc7b7e7d47b336b766203d6454ed8c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 13:37:05 +0200 Subject: [PATCH 165/247] feat(metryki): MetrykaAutora.uczelnia NOT NULL po backfillu; StatusGenerowania zostaje nullable (federacja) (D, task 11) Co-Authored-By: Claude Sonnet 4.6 --- .../0008_metrykaautora_uczelnia_notnull.py | 30 ++++++++++++++++++ src/ewaluacja_metryki/models.py | 7 +++-- .../tests/test_discipline_filter.py | 19 ++++++++++++ src/ewaluacja_metryki/tests/test_models.py | 2 ++ .../tests/test_slot_percentage_update.py | 31 ++++++++++++------- src/ewaluacja_metryki/tests/test_views.py | 21 +++++++++++++ 6 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 src/ewaluacja_metryki/migrations/0008_metrykaautora_uczelnia_notnull.py diff --git a/src/ewaluacja_metryki/migrations/0008_metrykaautora_uczelnia_notnull.py b/src/ewaluacja_metryki/migrations/0008_metrykaautora_uczelnia_notnull.py new file mode 100644 index 000000000..e60a4e864 --- /dev/null +++ b/src/ewaluacja_metryki/migrations/0008_metrykaautora_uczelnia_notnull.py @@ -0,0 +1,30 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Ustaw MetrykaAutora.uczelnia jako NOT NULL. + + Poprzednia migracja 0006 wykonała backfill: wszystkie wiersze mają już + uczelnia != NULL (lub zostały usunięte). Teraz usuwamy null=True/blank=True + z definicji pola — zmiana jest bezpieczna. + + StatusGenerowania.uczelnia pozostaje nullable — patrz komentarz w models.py. + """ + + dependencies = [ + ("bpp", "0428_cpd_uczelnia_not_null"), + ("ewaluacja_metryki", "0007_statusgenerowania_uczelnia"), + ] + + operations = [ + migrations.AlterField( + model_name="metrykaautora", + name="uczelnia", + field=models.ForeignKey( + help_text="Uczelnia, dla której policzono metrykę (multi-hosted)", + on_delete=django.db.models.deletion.CASCADE, + to="bpp.uczelnia", + ), + ), + ] diff --git a/src/ewaluacja_metryki/models.py b/src/ewaluacja_metryki/models.py index 545d37ed8..e431ac929 100644 --- a/src/ewaluacja_metryki/models.py +++ b/src/ewaluacja_metryki/models.py @@ -25,8 +25,6 @@ class MetrykaAutora(models.Model): uczelnia = models.ForeignKey( "bpp.Uczelnia", on_delete=models.CASCADE, - null=True, - blank=True, help_text="Uczelnia, dla której policzono metrykę (multi-hosted)", ) @@ -225,6 +223,11 @@ class StatusGenerowania(models.Model): help_text="ID zadania Celery", ) + # Zostaje nullable: moduł ewaluacja_optymalizacja (poza zakresem federacji) + # używa StatusGenerowania.get_or_create() bez argumentu w 3 miejscach + # (views/unpinning_analysis.py:39,149, views/unpinning_list.py:137), + # co rozwiązuje się do wiersza uczelnia=None. Pełny per-uczelnia status + # należy do późniejszych prac federacyjnych. uczelnia = models.OneToOneField( "bpp.Uczelnia", on_delete=models.CASCADE, diff --git a/src/ewaluacja_metryki/tests/test_discipline_filter.py b/src/ewaluacja_metryki/tests/test_discipline_filter.py index ac98917ac..a0d5bf05a 100644 --- a/src/ewaluacja_metryki/tests/test_discipline_filter.py +++ b/src/ewaluacja_metryki/tests/test_discipline_filter.py @@ -10,6 +10,11 @@ @pytest.mark.django_db def test_discipline_filter_hidden_when_only_one(admin_user, db): """Test that discipline filter is hidden when there's only one discipline""" + from bpp.models import Uczelnia as UczelniaModel + + # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) + uczelnia = baker.make(UczelniaModel) + # Create only one discipline with metrics dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="Test Discipline") autor = baker.make("bpp.Autor") @@ -21,6 +26,7 @@ def test_discipline_filter_hidden_when_only_one(admin_user, db): autor=autor, dyscyplina_naukowa=dyscyplina, jednostka=jednostka, + uczelnia=uczelnia, slot_maksymalny=4.0, slot_nazbierany=2.0, punkty_nazbierane=100.0, @@ -53,6 +59,11 @@ def test_discipline_filter_hidden_when_only_one(admin_user, db): @pytest.mark.django_db def test_discipline_filter_shown_when_multiple(admin_user, db): """Test that discipline filter is shown when there are multiple disciplines""" + from bpp.models import Uczelnia as UczelniaModel + + # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) + uczelnia = baker.make(UczelniaModel) + # Create multiple disciplines with metrics dyscyplina1 = baker.make(Dyscyplina_Naukowa, nazwa="Discipline 1") dyscyplina2 = baker.make(Dyscyplina_Naukowa, nazwa="Discipline 2") @@ -65,6 +76,7 @@ def test_discipline_filter_shown_when_multiple(admin_user, db): autor=autor, dyscyplina_naukowa=dyscyplina1, jednostka=jednostka, + uczelnia=uczelnia, slot_maksymalny=4.0, slot_nazbierany=2.0, punkty_nazbierane=100.0, @@ -76,6 +88,7 @@ def test_discipline_filter_shown_when_multiple(admin_user, db): autor=autor, dyscyplina_naukowa=dyscyplina2, jednostka=jednostka, + uczelnia=uczelnia, slot_maksymalny=4.0, slot_nazbierany=1.5, punkty_nazbierane=80.0, @@ -109,6 +122,11 @@ def test_discipline_filter_shown_when_multiple(admin_user, db): @pytest.mark.django_db def test_column_sizing_with_one_discipline(admin_user, db): """Test that column sizing adjusts when discipline filter is hidden""" + from bpp.models import Uczelnia as UczelniaModel + + # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) + uczelnia = baker.make(UczelniaModel) + # Create only one discipline dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="Single Discipline") autor = baker.make("bpp.Autor") @@ -119,6 +137,7 @@ def test_column_sizing_with_one_discipline(admin_user, db): autor=autor, dyscyplina_naukowa=dyscyplina, jednostka=jednostka, + uczelnia=uczelnia, slot_maksymalny=4.0, slot_nazbierany=2.0, punkty_nazbierane=100.0, diff --git a/src/ewaluacja_metryki/tests/test_models.py b/src/ewaluacja_metryki/tests/test_models.py index ed6ec7960..21b515c05 100644 --- a/src/ewaluacja_metryki/tests/test_models.py +++ b/src/ewaluacja_metryki/tests/test_models.py @@ -14,11 +14,13 @@ def test_metryka_autora_create(): autor = baker.make(Autor) dyscyplina = baker.make(Dyscyplina_Naukowa) jednostka = baker.make(Jednostka) + uczelnia = baker.make("bpp.Uczelnia") metryka = MetrykaAutora.objects.create( autor=autor, dyscyplina_naukowa=dyscyplina, jednostka=jednostka, + uczelnia=uczelnia, slot_maksymalny=Decimal("4.0"), slot_nazbierany=Decimal("3.5"), punkty_nazbierane=Decimal("140.0"), diff --git a/src/ewaluacja_metryki/tests/test_slot_percentage_update.py b/src/ewaluacja_metryki/tests/test_slot_percentage_update.py index dc87e47e2..3efc49407 100644 --- a/src/ewaluacja_metryki/tests/test_slot_percentage_update.py +++ b/src/ewaluacja_metryki/tests/test_slot_percentage_update.py @@ -21,8 +21,10 @@ def test_procent_wykorzystania_slotow_updates_correctly(denorms, rodzaj_autora_n): """Test that slot utilization percentage is correctly calculated and updated""" - # Create test data - jednostka = baker.make(Jednostka, skupia_pracownikow=True) + # Create test data — jednostka musi należeć do uczelni, + # bo zbieraj_sloty filtruje Cache przez jednostka__uczelnia_id. + uczelnia = baker.make("bpp.Uczelnia") + jednostka = baker.make(Jednostka, skupia_pracownikow=True, uczelnia=uczelnia) autor = baker.make(Autor, nazwisko="TestAutor", imiona="Jan") dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="Informatyka") @@ -35,11 +37,12 @@ def test_procent_wykorzystania_slotow_updates_correctly(denorms, rodzaj_autora_n rodzaj_autora=rodzaj_autora_n, ) - # Set maximum slots for the author + # Set maximum slots for the author (per-uczelnia scope) baker.make( IloscUdzialowDlaAutoraZaCalosc, autor=autor, dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, ilosc_udzialow=Decimal("4"), # Maximum 4 slots ilosc_udzialow_monografie=Decimal("1"), # Required field ) @@ -85,11 +88,11 @@ def test_procent_wykorzystania_slotow_updates_correctly(denorms, rodzaj_autora_n cacher.removeEntries() cacher.rebuildEntries() - # Calculate metrics (uczelnia=None — IloscUdzialow created without uczelnia) + # Calculate metrics — IloscUdzialow created with uczelnia (per-uczelnia scope) metryka, created = oblicz_metryki_dla_autora( autor=autor, dyscyplina=dyscyplina, - uczelnia=None, + uczelnia=uczelnia, rok_min=2022, rok_max=2025, ) @@ -108,7 +111,7 @@ def test_procent_wykorzystania_slotow_updates_correctly(denorms, rodzaj_autora_n metryka2, created = oblicz_metryki_dla_autora( autor=autor, dyscyplina=dyscyplina, - uczelnia=None, + uczelnia=uczelnia, rok_min=2022, rok_max=2025, ) @@ -134,6 +137,7 @@ def test_procent_wykorzystania_handles_zero_slot_maksymalny(rodzaj_autora_n): jednostka = baker.make(Jednostka, skupia_pracownikow=True) # noqa autor = baker.make(Autor, nazwisko="ZeroSlot", imiona="Test") dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="Matematyka") + uczelnia = baker.make("bpp.Uczelnia") baker.make( Autor_Dyscyplina, @@ -143,14 +147,14 @@ def test_procent_wykorzystania_handles_zero_slot_maksymalny(rodzaj_autora_n): rodzaj_autora=rodzaj_autora_n, ) - # Don't create IloscUdzialowDlaAutoraZaCalosc - will default to 4 - # But we'll test with slot_maksymalny=0 case + # Don't create IloscUdzialowDlaAutoraZaCalosc — test with slot_maksymalny=0 + # (forced via explicit arg, bypasses DB lookup) # Calculate metrics with forced slot_maksymalny=0 metryka, created = oblicz_metryki_dla_autora( autor=autor, dyscyplina=dyscyplina, - uczelnia=None, + uczelnia=uczelnia, rok_min=2022, rok_max=2025, slot_maksymalny=Decimal("0"), # Force zero @@ -165,7 +169,9 @@ def test_procent_wykorzystania_handles_zero_slot_maksymalny(rodzaj_autora_n): def test_averages_calculated_correctly(rodzaj_autora_n): """Test that average points per slot are calculated correctly""" - jednostka = baker.make(Jednostka, skupia_pracownikow=True) + # jednostka musi należeć do uczelni — zbieraj_sloty filtruje po uczelnia_id + uczelnia = baker.make("bpp.Uczelnia") + jednostka = baker.make(Jednostka, skupia_pracownikow=True, uczelnia=uczelnia) autor = baker.make(Autor, nazwisko="Average", imiona="Test") dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="Fizyka") @@ -182,6 +188,7 @@ def test_averages_calculated_correctly(rodzaj_autora_n): IloscUdzialowDlaAutoraZaCalosc, autor=autor, dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, ilosc_udzialow=Decimal("4"), ilosc_udzialow_monografie=Decimal("2"), ) @@ -215,11 +222,11 @@ def test_averages_calculated_correctly(rodzaj_autora_n): cacher.removeEntries() cacher.rebuildEntries() - # Calculate metrics (uczelnia=None — IloscUdzialow created without uczelnia) + # Calculate metrics — IloscUdzialow created with uczelnia (per-uczelnia scope) metryka, _ = oblicz_metryki_dla_autora( autor=autor, dyscyplina=dyscyplina, - uczelnia=None, + uczelnia=uczelnia, rok_min=2022, rok_max=2025, ) diff --git a/src/ewaluacja_metryki/tests/test_views.py b/src/ewaluacja_metryki/tests/test_views.py index 40294b01e..ce371a348 100644 --- a/src/ewaluacja_metryki/tests/test_views.py +++ b/src/ewaluacja_metryki/tests/test_views.py @@ -22,8 +22,13 @@ def test_metryki_list_view_requires_login(client): @pytest.mark.django_db def test_metryki_list_view_logged_in(admin_user, client): """Test widoku listy dla zalogowanego użytkownika""" + from bpp.models import Uczelnia as UczelniaModel + client.force_login(admin_user) + # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) + uczelnia = baker.make(UczelniaModel) + # Stwórz dane testowe autor = baker.make(Autor, nazwisko="Kowalski", imiona="Jan") dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="Informatyka") @@ -36,6 +41,7 @@ def test_metryki_list_view_logged_in(admin_user, client): autor=autor, dyscyplina_naukowa=dyscyplina, jednostka=jednostka, + uczelnia=uczelnia, slot_maksymalny=Decimal("4.0"), slot_nazbierany=Decimal("3.5"), punkty_nazbierane=Decimal("140.0"), @@ -48,6 +54,7 @@ def test_metryki_list_view_logged_in(admin_user, client): baker.make( MetrykaAutora, dyscyplina_naukowa=dyscyplina2, + uczelnia=uczelnia, slot_maksymalny=Decimal("4.0"), slot_nazbierany=Decimal("3.0"), punkty_nazbierane=Decimal("120.0"), @@ -186,8 +193,13 @@ def test_metryka_detail_view(admin_user, client): @pytest.mark.django_db def test_statystyki_view(admin_user, client): """Test widoku statystyk""" + from bpp.models import Uczelnia as UczelniaModel + client.force_login(admin_user) + # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) + uczelnia = baker.make(UczelniaModel) + # Stwórz dyscyplinę raz i użyj dla wszystkich metryk # (unika race condition z unikalnym polem kod w Dyscyplina_Naukowa) dyscyplina = baker.make(Dyscyplina_Naukowa) @@ -197,6 +209,7 @@ def test_statystyki_view(admin_user, client): baker.make( MetrykaAutora, dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, slot_maksymalny=Decimal("4.0"), slot_nazbierany=Decimal(f"{3.0 + i * 0.2}"), punkty_nazbierane=Decimal(f"{120.0 + i * 10}"), @@ -366,11 +379,18 @@ def test_status_generowania_in_context(admin_user, client): @pytest.mark.django_db def test_metryki_list_view_sorting(admin_user, client): """Test sortowania listy metryk""" + from bpp.models import Uczelnia as UczelniaModel + client.force_login(admin_user) + # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True); + # obie metryki mają to samo uczelnia_id, więc view widzi obie. + uczelnia = baker.make(UczelniaModel) + # Stwórz metryki z różnymi średnimi metryka1 = baker.make( MetrykaAutora, + uczelnia=uczelnia, slot_maksymalny=Decimal("4.0"), slot_nazbierany=Decimal("4.0"), punkty_nazbierane=Decimal("200.0"), # średnia 50 @@ -380,6 +400,7 @@ def test_metryki_list_view_sorting(admin_user, client): metryka2 = baker.make( MetrykaAutora, + uczelnia=uczelnia, slot_maksymalny=Decimal("4.0"), slot_nazbierany=Decimal("4.0"), punkty_nazbierane=Decimal("120.0"), # średnia 30 From 589625dd2b968f6f85388d30670ff32af072914c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 13:48:01 +0200 Subject: [PATCH 166/247] =?UTF-8?q?test(metryki):=20hoist=20Uczelnia=20imp?= =?UTF-8?q?orts=20+=20sp=C3=B3jne=20uczelnia=20wiring=20(D,=20task=2011=20?= =?UTF-8?q?review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_discipline_filter.py | 14 ++++---------- .../tests/test_slot_percentage_update.py | 4 ++-- src/ewaluacja_metryki/tests/test_views.py | 18 +++++------------- 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/src/ewaluacja_metryki/tests/test_discipline_filter.py b/src/ewaluacja_metryki/tests/test_discipline_filter.py index a0d5bf05a..b8568fbb9 100644 --- a/src/ewaluacja_metryki/tests/test_discipline_filter.py +++ b/src/ewaluacja_metryki/tests/test_discipline_filter.py @@ -3,17 +3,15 @@ from model_bakery import baker from bpp.const import GR_WPROWADZANIE_DANYCH -from bpp.models import Dyscyplina_Naukowa +from bpp.models import Dyscyplina_Naukowa, Uczelnia from ewaluacja_metryki.models import MetrykaAutora @pytest.mark.django_db def test_discipline_filter_hidden_when_only_one(admin_user, db): """Test that discipline filter is hidden when there's only one discipline""" - from bpp.models import Uczelnia as UczelniaModel - # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) - uczelnia = baker.make(UczelniaModel) + uczelnia = baker.make(Uczelnia) # Create only one discipline with metrics dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="Test Discipline") @@ -59,10 +57,8 @@ def test_discipline_filter_hidden_when_only_one(admin_user, db): @pytest.mark.django_db def test_discipline_filter_shown_when_multiple(admin_user, db): """Test that discipline filter is shown when there are multiple disciplines""" - from bpp.models import Uczelnia as UczelniaModel - # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) - uczelnia = baker.make(UczelniaModel) + uczelnia = baker.make(Uczelnia) # Create multiple disciplines with metrics dyscyplina1 = baker.make(Dyscyplina_Naukowa, nazwa="Discipline 1") @@ -122,10 +118,8 @@ def test_discipline_filter_shown_when_multiple(admin_user, db): @pytest.mark.django_db def test_column_sizing_with_one_discipline(admin_user, db): """Test that column sizing adjusts when discipline filter is hidden""" - from bpp.models import Uczelnia as UczelniaModel - # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) - uczelnia = baker.make(UczelniaModel) + uczelnia = baker.make(Uczelnia) # Create only one discipline dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="Single Discipline") diff --git a/src/ewaluacja_metryki/tests/test_slot_percentage_update.py b/src/ewaluacja_metryki/tests/test_slot_percentage_update.py index 3efc49407..d2194efac 100644 --- a/src/ewaluacja_metryki/tests/test_slot_percentage_update.py +++ b/src/ewaluacja_metryki/tests/test_slot_percentage_update.py @@ -134,10 +134,10 @@ def test_procent_wykorzystania_slotow_updates_correctly(denorms, rodzaj_autora_n def test_procent_wykorzystania_handles_zero_slot_maksymalny(rodzaj_autora_n): """Test that percentage calculation handles zero slot_maksymalny gracefully""" - jednostka = baker.make(Jednostka, skupia_pracownikow=True) # noqa + uczelnia = baker.make("bpp.Uczelnia") + jednostka = baker.make(Jednostka, skupia_pracownikow=True, uczelnia=uczelnia) # noqa: F841 autor = baker.make(Autor, nazwisko="ZeroSlot", imiona="Test") dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="Matematyka") - uczelnia = baker.make("bpp.Uczelnia") baker.make( Autor_Dyscyplina, diff --git a/src/ewaluacja_metryki/tests/test_views.py b/src/ewaluacja_metryki/tests/test_views.py index ce371a348..28403d787 100644 --- a/src/ewaluacja_metryki/tests/test_views.py +++ b/src/ewaluacja_metryki/tests/test_views.py @@ -4,7 +4,7 @@ from django.urls import reverse from model_bakery import baker -from bpp.models import Autor, Dyscyplina_Naukowa, Jednostka +from bpp.models import Autor, Dyscyplina_Naukowa, Jednostka, Uczelnia from ewaluacja_metryki.models import MetrykaAutora, StatusGenerowania @@ -22,12 +22,10 @@ def test_metryki_list_view_requires_login(client): @pytest.mark.django_db def test_metryki_list_view_logged_in(admin_user, client): """Test widoku listy dla zalogowanego użytkownika""" - from bpp.models import Uczelnia as UczelniaModel - client.force_login(admin_user) # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) - uczelnia = baker.make(UczelniaModel) + uczelnia = baker.make(Uczelnia) # Stwórz dane testowe autor = baker.make(Autor, nazwisko="Kowalski", imiona="Jan") @@ -118,13 +116,11 @@ def test_metryki_list_view_filtering_by_jednostka(admin_user, client): co zapewnia spójne działanie scope_metryki (single-install no-op lub multi-install z tym samym scopem co request). """ - from bpp.models import Uczelnia as UczelniaModel - client.force_login(admin_user) # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) # i jednocześnie metryki mają spójne uczelnia_id z request-resolution - u = baker.make(UczelniaModel) + u = baker.make(Uczelnia) jednostka1 = baker.make(Jednostka, nazwa="Instytut Informatyki", uczelnia=u) jednostka2 = baker.make(Jednostka, nazwa="Instytut Fizyki", uczelnia=u) @@ -193,12 +189,10 @@ def test_metryka_detail_view(admin_user, client): @pytest.mark.django_db def test_statystyki_view(admin_user, client): """Test widoku statystyk""" - from bpp.models import Uczelnia as UczelniaModel - client.force_login(admin_user) # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True) - uczelnia = baker.make(UczelniaModel) + uczelnia = baker.make(Uczelnia) # Stwórz dyscyplinę raz i użyj dla wszystkich metryk # (unika race condition z unikalnym polem kod w Dyscyplina_Naukowa) @@ -379,13 +373,11 @@ def test_status_generowania_in_context(admin_user, client): @pytest.mark.django_db def test_metryki_list_view_sorting(admin_user, client): """Test sortowania listy metryk""" - from bpp.models import Uczelnia as UczelniaModel - client.force_login(admin_user) # Jedna uczelnia — scope_metryki jest no-op (tylko_jedna_uczelnia=True); # obie metryki mają to samo uczelnia_id, więc view widzi obie. - uczelnia = baker.make(UczelniaModel) + uczelnia = baker.make(Uczelnia) # Stwórz metryki z różnymi średnimi metryka1 = baker.make( From 5ddd3fefabefe1abe048674c54f78ebbb6da1282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 13:52:23 +0200 Subject: [PATCH 167/247] =?UTF-8?q?docs(metryki):=20popraw=20myl=C4=85cy?= =?UTF-8?q?=20komentarz=20'Singleton'=20w=20StatusGenerowaniaAdmin=20(D,?= =?UTF-8?q?=20final=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ewaluacja_metryki/admin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ewaluacja_metryki/admin.py b/src/ewaluacja_metryki/admin.py index 831dc9b98..714edaf09 100644 --- a/src/ewaluacja_metryki/admin.py +++ b/src/ewaluacja_metryki/admin.py @@ -115,9 +115,10 @@ def status_display(self, obj): status_display.short_description = "Status" def has_add_permission(self, request): - # Singleton - nie pozwalaj dodawać nowych + # Wiersze per uczelnia tworzy proces generowania (get_or_create), + # nie ręcznie w adminie. return False def has_delete_permission(self, request, obj=None): - # Singleton - nie pozwalaj usuwać + # Status per uczelnia zarządzany automatycznie — bez ręcznego usuwania. return False From d947f0e6954eed746f744651e8a6efb7dcd311e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 13:54:20 +0200 Subject: [PATCH 168/247] =?UTF-8?q?docs(multi-hosted):=20w=C4=85tek=20D=20?= =?UTF-8?q?(ewaluacja=5Fmetryki=20per-uczelnia)=20ZROBIONY=20=E2=80=94=20s?= =?UTF-8?q?pec+plan+HANDOFF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/HANDOFF-multi-hosted.md | 37 +- ...26-06-04-ewaluacja-metryki-per-uczelnia.md | 1254 +++++++++++++++++ ...4-ewaluacja-metryki-per-uczelnia-design.md | 271 ++++ 3 files changed, 1554 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-04-ewaluacja-metryki-per-uczelnia.md create mode 100644 docs/superpowers/specs/2026-06-04-ewaluacja-metryki-per-uczelnia-design.md diff --git a/docs/superpowers/HANDOFF-multi-hosted.md b/docs/superpowers/HANDOFF-multi-hosted.md index db9cc9fe9..6edbb56b6 100644 --- a/docs/superpowers/HANDOFF-multi-hosted.md +++ b/docs/superpowers/HANDOFF-multi-hosted.md @@ -282,14 +282,35 @@ poza zakresem R1 i NIE odnotowane jako wyłączone → kandydat na wątek **R3** `reset_all_pins_task`/`optimize_and_unpin` globalne querysety, komparatory PBN globalny `.delete()`. To integralność, nie logika federacyjna — można scope-fix niezależnie. -- **D) `ewaluacja_metryki` per-uczelnia — NASTĘPNY (wymaga spec-a).** `MetrykaAutora` - bez FK uczelnia (`unique_together(autor,dyscyplina)` bez uczelni) + globalne - `IloscUdzialowDlaAutoraZaCalosc.objects.all()` (`tasks.py:231,357`, `utils.py:277`, - `oblicz_metryki.py:132`, `generation.py:74`) + globalny rebuild - `MetrykaAutora.objects.all().delete()` (`utils.py:556`, `tasks.py:245`) + odczyty - eksport/statystyki (`export_helpers.py:11,357`, `statistics.py:50`). Kształt jak - liczba_n R2 (FK+backfill+scope pipeline+widoki). **Pełny brief + prompt do - wklejenia po resecie: `docs/superpowers/NEXT-SESSION-metryki-per-uczelnia.md`.** +- ✅ **D) `ewaluacja_metryki` per-uczelnia — ZROBIONE (2026-06-04).** Spec + `specs/2026-06-04-ewaluacja-metryki-per-uczelnia-design.md`, plan + `plans/2026-06-04-ewaluacja-metryki-per-uczelnia.md`. 11 tasków + (subagent-driven, każdy spec+quality review + finalny holistyczny review + „ready to merge", 148 testów metryki+optymalizacja+guard zielonych). Commity + `16786180e`…`5ddd3fefa` (19 szt., wszystkie `ewaluacja_metryki/`). **Niepushowane.** + - **Schemat:** FK `uczelnia` na `MetrykaAutora` (mig 0006, backfill: single→domyślna, + multi→**wyczyść** regenerowalny cache; `unique_together(autor,dyscyplina,uczelnia)`, + indeks) → NOT NULL (mig 0008). `StatusGenerowania` FK `uczelnia` per-uczelnia + (mig 0007, koniec singletonu pk=1) — **świadomie ZOSTAJE nullable** (odłożona + federacja `ewaluacja_optymalizacja` woła `get_or_create()` no-arg → wiersz None). + - **Write (naprawiony uśpiony bug R2):** `oblicz_metryki_dla_autora` agregował + `IloscUdzialow.aggregate(Sum)` po WSZYSTKICH uczelniach (zawyżony slot_maksymalny) + + `MetrykaAutora.all().delete()` globalny + knapsack leak (`zbieraj_sloty` bez + `uczelnia_id`). Teraz: bulk dziedziczy uczelnię z wiersza `IloscUdzialow`, + pin/unpin z `aktualna_jednostka.uczelnia` (reguła R2), delete/agregat/knapsack + scoped; taski/CLI/widok single-or-fail (`_resolve_uczelnia`, NIE get_default); + status per-uczelnia (chord callback dostaje `uczelnia_id`). + - **Read:** helper `ewaluacja_metryki/uczelnia_scope.scope_metryki` (hybryda + `uczelnia_dla_odczytu` + guard `tylko_jedna_uczelnia`); list/statistics/export + (Opcja A: helpery dostają `base_qs`)/detail(ranking+inne_dyscypliny)/pin_unpin + zawężone. Transitive `Cache_Punktacja_Autora_Query` (autor_id+dyscyplina_id) + zostają (federacja). Admin pokazuje `uczelnia`. + - **Invariant single-install:** wszystkie filtry no-op (1 uczelnia) → liczby + identyczne. **Deploy:** 0006→0007→0008; multi-install z danymi czyści metryki + (regeneracja przy następnym generowaniu per uczelnia). + - **Follow-up (nieblokujące):** federacja `ewaluacja_optymalizacja` nadal czyta + `StatusGenerowania.get_or_create()` no-arg (wiersz None) — pełne per-uczelnia + statusu tam należy do wątku federacji (B/F). ### Stan zgodności ze spec (Audyt 4) - Write-side sloty: ✓ 31/31 (1 świadomy korzystny rozjazd — HST per-uczelnia). diff --git a/docs/superpowers/plans/2026-06-04-ewaluacja-metryki-per-uczelnia.md b/docs/superpowers/plans/2026-06-04-ewaluacja-metryki-per-uczelnia.md new file mode 100644 index 000000000..b794c79d9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-ewaluacja-metryki-per-uczelnia.md @@ -0,0 +1,1254 @@ +# ewaluacja_metryki per-uczelnia — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Zawęzić liczenie i odczyt metryk ewaluacyjnych (`MetrykaAutora`) per +uczelnia w instalacji wielouczelnianej, naprawiając przy okazji uśpiony bug R2 +(globalna agregacja slotów + globalny destrukcyjny delete). + +**Architecture:** Mirror wzorca R2 (`ewaluacja_liczba_n` per-uczelnia). FK +`uczelnia` na `MetrykaAutora` i `StatusGenerowania`; pipeline zapisu zawężony +per uczelnia (bulk dziedziczy uczelnię z wiersza `IloscUdzialow`, pin/unpin z +`autor.aktualna_jednostka.uczelnia`); odczyty filtrowane hybrydą +`uczelnia_dla_odczytu` z guardem single-install `tylko_jedna_uczelnia()`. + +**Tech Stack:** Django 5.2, pytest + model_bakery, testcontainers (PG/Redis), +Celery (chord/group), `fixtures.conftest_multisite` (uczelnia1/2, site1/2). + +**Spec:** `docs/superpowers/specs/2026-06-04-ewaluacja-metryki-per-uczelnia-design.md` + +**Reguły wykonawcze:** +- `uv run` przy KAŻDEJ komendzie Pythona; testy z `-p no:cacheprovider`. +- NIE modyfikować istniejących migracji. +- Po każdym tasku: `uv run ruff check ` ORAZ `uv run ruff format ` + (format wolno; `check --fix` NIE — fix ręcznie). +- Guard musi zostać zielony bez nowych wpisów: + `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q`. +- Commit po każdym tasku (na branchu `feature/multi-hosted-config`). + +--- + +## File Structure + +- `src/ewaluacja_metryki/models.py` — FK `uczelnia` na `MetrykaAutora` + + `StatusGenerowania`; koniec singletonu. +- `src/ewaluacja_metryki/migrations/0006_*`, `0007_*`, `0008_*` — nowe migracje. +- `src/ewaluacja_metryki/uczelnia_scope.py` — NOWY helper read-side + (`scope_metryki(qs, uczelnia)`), analogiczny do `bpp.util.uczelnia_scope`. +- `src/ewaluacja_metryki/utils.py` — pipeline zapisu zawężony per uczelnia. +- `src/ewaluacja_metryki/tasks.py` — taski Celery + status per-uczelnia. +- `src/ewaluacja_metryki/management/commands/oblicz_metryki.py` — CLI scoping. +- `src/ewaluacja_metryki/views/{generation,statistics,list,detail,export,pin_unpin}.py` + — read-side + uruchamianie generowania per uczelnia. +- `src/ewaluacja_metryki/export_helpers.py` — helpery przyjmują `base_qs`. +- `src/ewaluacja_metryki/admin.py` — `uczelnia` w adminie. +- `src/ewaluacja_metryki/tests/test_per_uczelnia.py` — NOWY plik testów izolacji. + +--- + +## Task 1: FK `uczelnia` na `MetrykaAutora` + migracja 0006 + +**Files:** +- Modify: `src/ewaluacja_metryki/models.py:9-121` (MetrykaAutora) +- Create: `src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py` +- Test: `src/ewaluacja_metryki/tests/test_per_uczelnia.py` + +- [ ] **Step 1: Write failing test** + +```python +# src/ewaluacja_metryki/tests/test_per_uczelnia.py +from decimal import Decimal + +import pytest +from model_bakery import baker + +from ewaluacja_metryki.models import MetrykaAutora + + +def _make_metryka(autor, dyscyplina, uczelnia, **kw): + defaults = dict( + slot_maksymalny=Decimal("4.0"), + slot_nazbierany=Decimal("2.0"), + punkty_nazbierane=Decimal("100.0"), + slot_wszystkie=Decimal("3.0"), + punkty_wszystkie=Decimal("150.0"), + ) + defaults.update(kw) + return MetrykaAutora.objects.create( + autor=autor, dyscyplina_naukowa=dyscyplina, uczelnia=uczelnia, **defaults + ) + + +@pytest.mark.django_db +def test_metryka_ma_uczelnia(autor_jan_kowalski, dyscyplina1): + u = baker.make("bpp.Uczelnia") + m = _make_metryka(autor_jan_kowalski, dyscyplina1, u) + assert m.uczelnia_id == u.pk + + +@pytest.mark.django_db +def test_metryka_unique_together_z_uczelnia(autor_jan_kowalski, dyscyplina1): + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + # ta sama (autor, dyscyplina), różne uczelnie → OK (rozłączne metryki) + _make_metryka(autor_jan_kowalski, dyscyplina1, u1) + _make_metryka(autor_jan_kowalski, dyscyplina1, u2) + assert MetrykaAutora.objects.count() == 2 +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: FAIL — `MetrykaAutora() got unexpected keyword 'uczelnia'`. + +- [ ] **Step 3: Add FK + unique_together + index to model** + +W `src/ewaluacja_metryki/models.py`, w klasie `MetrykaAutora` po polu +`jednostka` (linia ~23) dodać: + +```python + uczelnia = models.ForeignKey( + "bpp.Uczelnia", + on_delete=models.CASCADE, + null=True, + blank=True, + help_text="Uczelnia, dla której policzono metrykę (multi-hosted)", + ) +``` + +W `class Meta` zmienić `unique_together` i dodać indeks: + +```python + unique_together = [("autor", "dyscyplina_naukowa", "uczelnia")] + ordering = ["-srednia_za_slot_nazbierana", "autor__nazwisko", "autor__imiona"] + indexes = [ + models.Index(fields=["-srednia_za_slot_nazbierana"]), + models.Index(fields=["jednostka", "-srednia_za_slot_nazbierana"]), + models.Index(fields=["dyscyplina_naukowa", "-srednia_za_slot_nazbierana"]), + models.Index(fields=["uczelnia", "-srednia_za_slot_nazbierana"]), + ] +``` + +- [ ] **Step 4: Create migration 0006 (backfill clear-on-multi)** + +```python +# src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py +import django.db.models.deletion +from django.db import migrations, models + + +def backfill_uczelnia(apps, schema_editor): + Uczelnia = apps.get_model("bpp", "Uczelnia") + MetrykaAutora = apps.get_model("ewaluacja_metryki", "MetrykaAutora") + + null_qs = MetrykaAutora.objects.filter(uczelnia__isnull=True) + if not null_qs.exists(): + return + + uczelnie = list(Uczelnia.objects.all()[:2]) + if len(uczelnie) == 1: + null_qs.update(uczelnia=uczelnie[0]) + return + + # MetrykaAutora to regenerowalny cache (delete+create przy generowaniu); + # przy >1 uczelni nie da się zdeterministycznie przypisać legacy wierszy, + # więc czyścimy — odtworzą się przy najbliższym generuj_metryki per uczelnia. + null_qs.delete() + + +def backfill_uczelnia_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0428_cpd_uczelnia_not_null"), + ("ewaluacja_metryki", "0005_alter_metrykaautora_rodzaj_autora_and_more"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="metrykaautora", + unique_together=set(), + ), + migrations.AddField( + model_name="metrykaautora", + name="uczelnia", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="bpp.uczelnia", + ), + ), + migrations.AlterUniqueTogether( + name="metrykaautora", + unique_together={("autor", "dyscyplina_naukowa", "uczelnia")}, + ), + migrations.AddIndex( + model_name="metrykaautora", + index=models.Index( + fields=["uczelnia", "-srednia_za_slot_nazbierana"], + name="ewaluacja_m_uczelni_idx", + ), + ), + migrations.RunPython(backfill_uczelnia, backfill_uczelnia_reverse), + ] +``` + +UWAGA: zależności (`bpp` ostatnia migracja, dokładny `name` indeksu, numer +poprzedniej migracji metryki) **zweryfikować** komendą w Step 5 — jeśli +`makemigrations --check` zgłosi rozjazd, dostosuj `dependencies`/`name`. + +- [ ] **Step 5: Verify migration graph spójny** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run ewaluacja_metryki` +Expected: "No changes detected" (model = migracja). Jeśli wskazuje brakującą +migrację — znaczy że nazwa indeksu/pole nie zgadza się z modelem; dostosuj. + +- [ ] **Step 6: Run tests, verify pass** + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS (2 testy). + +- [ ] **Step 7: Commit** + +```bash +git add src/ewaluacja_metryki/models.py src/ewaluacja_metryki/migrations/0006_metrykaautora_uczelnia.py src/ewaluacja_metryki/tests/test_per_uczelnia.py +git commit -m "feat(metryki): FK uczelnia + unique_together per uczelnia (D, task 1)" +``` + +--- + +## Task 2: `StatusGenerowania` per-uczelnia + migracja 0007 + +**Files:** +- Modify: `src/ewaluacja_metryki/models.py:179-282` (StatusGenerowania) +- Create: `src/ewaluacja_metryki/migrations/0007_statusgenerowania_uczelnia.py` +- Test: `src/ewaluacja_metryki/tests/test_per_uczelnia.py` + +- [ ] **Step 1: Write failing test** + +```python +@pytest.mark.django_db +def test_status_generowania_per_uczelnia(): + from ewaluacja_metryki.models import StatusGenerowania + + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + s1 = StatusGenerowania.get_or_create(uczelnia=u1) + s2 = StatusGenerowania.get_or_create(uczelnia=u2) + assert s1.pk != s2.pk + assert s1.uczelnia_id == u1.pk + assert s2.uczelnia_id == u2.pk +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_per_uczelnia.py::test_status_generowania_per_uczelnia -q -p no:cacheprovider` +Expected: FAIL — `get_or_create() got unexpected keyword 'uczelnia'`. + +- [ ] **Step 3: Update model** + +W `StatusGenerowania` dodać pole (po `task_id`, linia ~217): + +```python + uczelnia = models.ForeignKey( + "bpp.Uczelnia", + on_delete=models.CASCADE, + null=True, + blank=True, + unique=True, + help_text="Uczelnia, której dotyczy ten status (multi-hosted)", + ) +``` + +Zmienić `save()` (usunąć wymuszanie singletonu) — linia ~231: + +```python + def save(self, *args, **kwargs): + super().save(*args, **kwargs) +``` + +Zmienić `get_or_create` (linia ~236): + +```python + @classmethod + def get_or_create(cls, uczelnia=None): + """Pobierz lub utwórz status dla danej uczelni (per-uczelnia, multi-hosted).""" + obj, created = cls.objects.get_or_create(uczelnia=uczelnia) + return obj +``` + +- [ ] **Step 4: Create migration 0007** + +```python +# src/ewaluacja_metryki/migrations/0007_statusgenerowania_uczelnia.py +import django.db.models.deletion +from django.db import migrations, models + + +def backfill_status_uczelnia(apps, schema_editor): + Uczelnia = apps.get_model("bpp", "Uczelnia") + StatusGenerowania = apps.get_model("ewaluacja_metryki", "StatusGenerowania") + + null_qs = StatusGenerowania.objects.filter(uczelnia__isnull=True) + if not null_qs.exists(): + return + + uczelnie = list(Uczelnia.objects.all()[:2]) + if len(uczelnie) == 1: + null_qs.update(uczelnia=uczelnie[0]) + return + + # Status to ulotny stan postępu, nie dane — usuń osierocony singleton. + null_qs.delete() + + +def backfill_status_uczelnia_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0428_cpd_uczelnia_not_null"), + ("ewaluacja_metryki", "0006_metrykaautora_uczelnia"), + ] + + operations = [ + migrations.AddField( + model_name="statusgenerowania", + name="uczelnia", + field=models.ForeignKey( + blank=True, + null=True, + unique=True, + on_delete=django.db.models.deletion.CASCADE, + to="bpp.uczelnia", + ), + ), + migrations.RunPython( + backfill_status_uczelnia, backfill_status_uczelnia_reverse + ), + ] +``` + +- [ ] **Step 5: Verify migration graph + run test** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run ewaluacja_metryki` +Expected: "No changes detected". + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ewaluacja_metryki/models.py src/ewaluacja_metryki/migrations/0007_statusgenerowania_uczelnia.py src/ewaluacja_metryki/tests/test_per_uczelnia.py +git commit -m "feat(metryki): StatusGenerowania per uczelnia, koniec singletonu (D, task 2)" +``` + +--- + +## Task 3: Pipeline zapisu `utils.py` zawężony per uczelnia + +**Files:** +- Modify: `src/ewaluacja_metryki/utils.py` (cały pipeline) +- Test: `src/ewaluacja_metryki/tests/test_per_uczelnia.py` + +Naprawiamy 3 luki: knapsack leak (brak `uczelnia_id` w `zbieraj_sloty`), +globalna agregacja slotu w `oblicz_metryki_dla_autora`, globalny delete/odczyt +w `generuj_metryki`. + +- [ ] **Step 1: Write failing test (izolacja + brak sumowania slotów)** + +```python +@pytest.mark.django_db +def test_oblicz_metryki_dla_autora_nie_sumuje_slotow_z_innej_uczelni( + autor_jan_kowalski, dyscyplina1 +): + """Regresja R2: slot_maksymalny nie może sumować udziałów wszystkich uczelni.""" + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc + from ewaluacja_metryki.utils import oblicz_metryki_dla_autora + + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + IloscUdzialowDlaAutoraZaCalosc.objects.create( + autor=autor_jan_kowalski, dyscyplina_naukowa=dyscyplina1, + rodzaj_autora=None, ilosc_udzialow=Decimal("4.0"), uczelnia=u1, + ) + IloscUdzialowDlaAutoraZaCalosc.objects.create( + autor=autor_jan_kowalski, dyscyplina_naukowa=dyscyplina1, + rodzaj_autora=None, ilosc_udzialow=Decimal("9.0"), uczelnia=u2, + ) + metryka, _ = oblicz_metryki_dla_autora( + autor=autor_jan_kowalski, dyscyplina=dyscyplina1, uczelnia=u1 + ) + # slot_maksymalny = 4.0 (tylko u1), NIE 13.0 (suma u1+u2) + assert metryka.slot_maksymalny == Decimal("4.0") + assert metryka.uczelnia_id == u1.pk +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `uv run pytest "src/ewaluacja_metryki/tests/test_per_uczelnia.py::test_oblicz_metryki_dla_autora_nie_sumuje_slotow_z_innej_uczelni" -q -p no:cacheprovider` +Expected: FAIL — `oblicz_metryki_dla_autora() got unexpected keyword 'uczelnia'` +(albo slot_maksymalny == 13.0). + +- [ ] **Step 3: Update `oblicz_metryki_dla_autora`** + +Sygnatura (linia 32) — dodać `uczelnia` jako parametr po `dyscyplina`: + +```python +def oblicz_metryki_dla_autora( + autor, + dyscyplina, + uczelnia, + rok_min=2022, + rok_max=2025, + minimalny_pk=Decimal("0.01"), + slot_maksymalny=None, +): +``` + +Agregacja slotu (linia ~59) — dodać filtr `uczelnia`: + +```python + aggregated = IloscUdzialowDlaAutoraZaCalosc.objects.filter( + autor=autor, dyscyplina_naukowa=dyscyplina, uczelnia=uczelnia + ).aggregate(total_slots=Sum("ilosc_udzialow")) +``` + +Oba wywołania `autor.zbieraj_sloty(...)` (linie ~96 i ~123) — dodać +`uczelnia_id=uczelnia.pk`. + +Blok create (linia ~169) — scope delete + tag uczelnia: + +```python + with transaction.atomic(): + MetrykaAutora.objects.filter( + autor=autor, dyscyplina_naukowa=dyscyplina, uczelnia=uczelnia + ).delete() + + metryka = MetrykaAutora.objects.create( + autor=autor, + dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, + jednostka=jednostka, + # ... reszta pól bez zmian +``` + +- [ ] **Step 4: Update `przelicz_metryki_dla_publikacji` (pin/unpin path)** + +W pętli (linia ~239) wyprowadź uczelnię z `aktualna_jednostka` i pomiń autora +bez home-uczelni (reguła R2): + +```python + for autor, dyscyplina in autorzy_do_przeliczenia: + jednostka = autor.aktualna_jednostka + if jednostka is None or not jednostka.skupia_pracownikow: + continue # reguła R2: brak home-uczelni → brak metryki + uczelnia = jednostka.uczelnia + try: + metryka, _ = oblicz_metryki_dla_autora( + autor=autor, + dyscyplina=dyscyplina, + uczelnia=uczelnia, + rok_min=rok_min, + rok_max=rok_max, + ) + results.append((autor, dyscyplina, metryka)) + except Exception as e: + logger.info( + f"Pominięto przeliczanie metryki dla {autor} - {dyscyplina.nazwa}: {e}" + ) + continue +``` + +- [ ] **Step 5: Update `generuj_metryki` + helpery (bulk path)** + +`_get_ilosc_udzialow_queryset` (linia ~272) — przyjmuje `uczelnia`: + +```python +def _get_ilosc_udzialow_queryset(ilosc_udzialow_queryset, uczelnia=None): + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc + + if ilosc_udzialow_queryset is not None: + return ilosc_udzialow_queryset + qs = IloscUdzialowDlaAutoraZaCalosc.objects.all() + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + return qs +``` + +`_create_or_update_metryka` (linia ~389) — dodać `uczelnia` jako parametr i do +lookupu + defaults: + +```python +def _create_or_update_metryka( + autor, dyscyplina, uczelnia, jednostka, slot_maksymalny, + metrics_data, rok_min, rok_max, rodzaj_autora_skrot, +): + return MetrykaAutora.objects.update_or_create( + autor=autor, + dyscyplina_naukowa=dyscyplina, + uczelnia=uczelnia, + defaults={ + "jednostka": jednostka, + # ... reszta defaults bez zmian + }, + ) +``` + +`_calculate_metrics_data` (linia ~322) — dodać `uczelnia` parametr i przekazać +`uczelnia_id=uczelnia.pk` do obu `zbieraj_sloty`. + +`_process_single_author` (linia ~420) — uczelnia z wiersza: + +```python + autor = ilosc_udzialow.autor + dyscyplina = ilosc_udzialow.dyscyplina_naukowa + uczelnia = ilosc_udzialow.uczelnia + slot_maksymalny = ilosc_udzialow.ilosc_udzialow +``` + +...przekazać `uczelnia` do `_calculate_metrics_data` i `_create_or_update_metryka`. + +`generuj_metryki` (linia ~514) — nowy param `uczelnia=None`; przekazać do +`_get_ilosc_udzialow_queryset`; scoped delete: + +```python +def generuj_metryki( + rok_min=2022, rok_max=2025, minimalny_pk=Decimal("0.01"), nadpisz=True, + rodzaje_autora=None, progress_callback=None, logger_output=None, + ilosc_udzialow_queryset=None, uczelnia=None, +): + ... + ilosc_udzialow_qs = _get_ilosc_udzialow_queryset(ilosc_udzialow_queryset, uczelnia) + ... + if nadpisz: + qs = MetrykaAutora.objects.all() + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + qs.delete() +``` + +- [ ] **Step 6: Run test + existing utils tests** + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_per_uczelnia.py src/ewaluacja_metryki/tests/test_commands.py -q -p no:cacheprovider` +Expected: PASS (nowy + brak regresji). + +- [ ] **Step 7: Commit** + +```bash +git add src/ewaluacja_metryki/utils.py src/ewaluacja_metryki/tests/test_per_uczelnia.py +git commit -m "fix(metryki): pipeline utils zawężony per uczelnia, naprawa knapsack leak + global delete (D, task 3)" +``` + +--- + +## Task 4: `tasks.py` — taski Celery + status per-uczelnia + +**Files:** +- Modify: `src/ewaluacja_metryki/tasks.py` +- Test: `src/ewaluacja_metryki/tests/test_tasks.py` (rozszerzyć) + +- [ ] **Step 1: Write failing test** + +```python +# dopisać do src/ewaluacja_metryki/tests/test_per_uczelnia.py +@pytest.mark.django_db +def test_generuj_metryki_task_scope_per_uczelnia(autor_jan_kowalski, dyscyplina1): + """Task generuje metryki tylko dla swojej uczelni, nie wyciera innej.""" + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc + from ewaluacja_metryki.models import MetrykaAutora + from ewaluacja_metryki.tasks import generuj_metryki_task + + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + # istniejąca metryka u2 (nie ruszać) + _make_metryka(autor_jan_kowalski, dyscyplina1, u2) + IloscUdzialowDlaAutoraZaCalosc.objects.create( + autor=autor_jan_kowalski, dyscyplina_naukowa=dyscyplina1, + rodzaj_autora=None, ilosc_udzialow=Decimal("4.0"), uczelnia=u1, + ) + generuj_metryki_task( + uczelnia_id=u1.pk, przelicz_liczbe_n=False, rodzaje_autora=[" "] + ) + # metryka u2 nadal istnieje (scoped delete nie wyciera obcej uczelni) + assert MetrykaAutora.objects.filter(uczelnia=u2).exists() +``` + +- [ ] **Step 2: Run, verify fails** + +Run: `uv run pytest "src/ewaluacja_metryki/tests/test_per_uczelnia.py::test_generuj_metryki_task_scope_per_uczelnia" -q -p no:cacheprovider` +Expected: FAIL — metryka u2 skasowana przez globalny `MetrykaAutora.objects.all().delete()`. + +- [ ] **Step 3: Update `generuj_metryki_task`** + +W `generuj_metryki_task` (linia ~305): rozstrzygnij uczelnię raz, użyj do +statusu, queryset, przekaż do `generuj_metryki`: + +```python + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) + if uczelnia_id + else Uczelnia.objects.get() + ) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) + ... + # Krok 1 liczba_n: użyj już-rozstrzygniętej `uczelnia` zamiast ponownego get() + if przelicz_liczbe_n: + oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia=uczelnia) + ... + queryset = IloscUdzialowDlaAutoraZaCalosc.objects.filter(uczelnia=uczelnia) + if rodzaje_autora: + queryset = queryset.filter(rodzaj_autora__skrot__in=rodzaje_autora) + total_count = queryset.count() + ... + wynik = generuj_metryki( + rok_min=rok_min, rok_max=rok_max, minimalny_pk=Decimal(str(minimalny_pk)), + nadpisz=nadpisz, rodzaje_autora=rodzaje_autora, + progress_callback=update_progress, uczelnia=uczelnia, + ) +``` + +Błędna ścieżka `except` (linia ~423) — `status` już ma uczelnię. + +- [ ] **Step 4: Update `generuj_metryki_task_parallel` + `finalizuj_generowanie_metryk`** + +`generuj_metryki_task_parallel` (linia ~177): rozstrzygnij `uczelnia`, +`StatusGenerowania.get_or_create(uczelnia=uczelnia)`; queryset +`.filter(uczelnia=uczelnia)`; scoped delete; przekaż `uczelnia_id` do callbacku: + +```python + if nadpisz: + qs = MetrykaAutora.objects.filter(uczelnia=uczelnia) + deleted_count = qs.count() + qs.delete() + ... + job = chord(task_group)( + finalizuj_generowanie_metryk.s(uczelnia_id=uczelnia.pk) + ) +``` + +`finalizuj_generowanie_metryk` (linia ~118) — dodać `uczelnia_id`: + +```python +@shared_task +def finalizuj_generowanie_metryk(results, uczelnia_id=None): + from bpp.models import Uczelnia + + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id + else Uczelnia.objects.get() + ) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) + status.refresh_from_db() + ... +``` + +UWAGA: subtask `oblicz_metryki_dla_autora_task` czyta uczelnię z wiersza +`IloscUdzialow` przez `_process_single_author` (Task 3) — bez zmian sygnatury. +`StatusGenerowania.objects.update(...)` (linie ~76, ~93, ~107) zawęzić do +`StatusGenerowania.objects.filter(uczelnia=uczelnia).update(...)` — ale subtask +nie zna uczelni; zamiast tego przekaż `uczelnia_id` do +`oblicz_metryki_dla_autora_task.s(...)` i filtruj po nim. Dodać `uczelnia_id` +parametr do subtaska i `StatusGenerowania.objects.filter(uczelnia_id=uczelnia_id)`. + +- [ ] **Step 5: Run tests** + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_per_uczelnia.py src/ewaluacja_metryki/tests/test_tasks.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ewaluacja_metryki/tasks.py src/ewaluacja_metryki/tests/test_per_uczelnia.py +git commit -m "feat(metryki): taski Celery scope per uczelnia + status per uczelnia (D, task 4)" +``` + +--- + +## Task 5: CLI `oblicz_metryki` scope per uczelnia + +**Files:** +- Modify: `src/ewaluacja_metryki/management/commands/oblicz_metryki.py:69-161` +- Test: `src/ewaluacja_metryki/tests/test_commands.py` (rozszerzyć) + +- [ ] **Step 1: Write failing test** + +```python +@pytest.mark.django_db +def test_command_oblicz_metryki_scope_uczelnia(autor_jan_kowalski, dyscyplina1): + from django.core.management import call_command + + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc + from ewaluacja_metryki.models import MetrykaAutora + + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + _make_metryka(autor_jan_kowalski, dyscyplina1, u2) + IloscUdzialowDlaAutoraZaCalosc.objects.create( + autor=autor_jan_kowalski, dyscyplina_naukowa=dyscyplina1, + rodzaj_autora=None, ilosc_udzialow=Decimal("4.0"), uczelnia=u1, + ) + call_command( + "oblicz_metryki", "--bez-liczby-n", "--nadpisz", + "--uczelnia-id", str(u1.pk), "--rodzaje-autora", " ", + ) + assert MetrykaAutora.objects.filter(uczelnia=u2).exists() # u2 nietknięta +``` + +- [ ] **Step 2: Run, verify fails** + +Run: `uv run pytest "src/ewaluacja_metryki/tests/test_commands.py::test_command_oblicz_metryki_scope_uczelnia" -q -p no:cacheprovider` +Expected: FAIL — globalny delete wyciera u2. + +- [ ] **Step 3: Update command `handle`** + +Rozstrzygnij uczelnię na początku (single-or-fail), użyj jej do scope i +przekaż do `generuj_metryki`. W `handle` (linia ~69): + +```python + uczelnia_id = options.get("uczelnia_id") + uczelnia = ( + Uczelnia.objects.get(pk=uczelnia_id) + if uczelnia_id + else Uczelnia.objects.get() + ) +``` + +(`--bez-liczby-n` nie wymaga już osobnego leniwego rozwiązywania — uczelnia +rozstrzygana raz na górze; jeśli `Uczelnia.objects.get()` rzuca przy 0/>1 +uczelni bez `--uczelnia-id`, to świadomy single-or-fail.) + +`ilosc_udzialow_qs` (linia ~132): `IloscUdzialowDlaAutoraZaCalosc.objects.filter(uczelnia=uczelnia)`. + +Wywołanie `generuj_metryki(...)` (linia ~153): dodać `uczelnia=uczelnia`. + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_commands.py src/ewaluacja_metryki/tests/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ewaluacja_metryki/management/commands/oblicz_metryki.py src/ewaluacja_metryki/tests/test_commands.py +git commit -m "feat(metryki): CLI oblicz_metryki scope per uczelnia single-or-fail (D, task 5)" +``` + +--- + +## Task 6: Helper read-side + `views/generation.py` + +**Files:** +- Create: `src/ewaluacja_metryki/uczelnia_scope.py` +- Modify: `src/ewaluacja_metryki/views/generation.py` +- Test: `src/ewaluacja_metryki/tests/test_per_uczelnia.py` + +- [ ] **Step 1: Create read-side helper** + +```python +# src/ewaluacja_metryki/uczelnia_scope.py +"""Zawężanie querysetów MetrykaAutora do uczelni oglądającego (read-side). + +Hybryda uczelni z `raport_slotow.uczelnia_helper.uczelnia_dla_odczytu` +(site + superuser ?uczelnia=) + guard single-install (no-op przy 1 uczelni). +""" + +from bpp.util.uczelnia_scope import tylko_jedna_uczelnia + + +def scope_metryki(qs, uczelnia): + """Zawęź queryset MetrykaAutora do uczelni; no-op przy single-install/None.""" + if uczelnia is None or tylko_jedna_uczelnia(): + return qs + return qs.filter(uczelnia=uczelnia) +``` + +- [ ] **Step 2: Write failing test (generation view przekazuje uczelnia_id)** + +```python +@pytest.mark.django_db +def test_scope_metryki_single_install_noop(autor_jan_kowalski, dyscyplina1): + from ewaluacja_metryki.models import MetrykaAutora + from ewaluacja_metryki.uczelnia_scope import scope_metryki + + u = baker.make("bpp.Uczelnia") # dokładnie 1 uczelnia + _make_metryka(autor_jan_kowalski, dyscyplina1, u) + qs = scope_metryki(MetrykaAutora.objects.all(), u) + assert qs.count() == 1 # no-op, nie filtruje + + +@pytest.mark.django_db +def test_scope_metryki_multi_filtruje(autor_jan_kowalski, dyscyplina1): + from ewaluacja_metryki.models import MetrykaAutora + from ewaluacja_metryki.uczelnia_scope import scope_metryki + + u1 = baker.make("bpp.Uczelnia") + u2 = baker.make("bpp.Uczelnia") + _make_metryka(autor_jan_kowalski, dyscyplina1, u1) + _make_metryka(autor_jan_kowalski, dyscyplina1, u2) + qs = scope_metryki(MetrykaAutora.objects.all(), u1) + assert list(qs.values_list("uczelnia_id", flat=True)) == [u1.pk] +``` + +- [ ] **Step 3: Run, verify fails** + +Run: `uv run pytest "src/ewaluacja_metryki/tests/test_per_uczelnia.py::test_scope_metryki_single_install_noop" "src/ewaluacja_metryki/tests/test_per_uczelnia.py::test_scope_metryki_multi_filtruje" -q -p no:cacheprovider` +Expected: FAIL (helper nie istnieje) → po Step 1 PASS. + +- [ ] **Step 4: Update `generation.py`** + +`UruchomGenerowanieView.post` — uczelnia z requestu, status per-uczelnia, +total_count scoped: + +```python + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + uczelnia = uczelnia_dla_odczytu(request) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) + if status.w_trakcie: + ... + ... + from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaCalosc + total_count = IloscUdzialowDlaAutoraZaCalosc.objects.filter( + uczelnia=uczelnia + ).count() if uczelnia else IloscUdzialowDlaAutoraZaCalosc.objects.count() + + result = generuj_metryki_task_parallel.delay( + rok_min=rok_min, rok_max=rok_max, minimalny_pk=minimalny_pk, + nadpisz=nadpisz, przelicz_liczbe_n=True, rodzaje_autora=rodzaje_autora, + uczelnia_id=uczelnia.pk if uczelnia else None, + ) + status.rozpocznij_generowanie( + task_id=str(result.id), liczba_do_przetworzenia=total_count + ) +``` + +`StatusGenerowaniaView.get` i `StatusGenerowaniaPartialView.get` — +`uczelnia = uczelnia_dla_odczytu(request)` + `StatusGenerowania.get_or_create(uczelnia=uczelnia)`. + +- [ ] **Step 5: Run tests** + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_per_uczelnia.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ewaluacja_metryki/uczelnia_scope.py src/ewaluacja_metryki/views/generation.py src/ewaluacja_metryki/tests/test_per_uczelnia.py +git commit -m "feat(metryki): helper scope_metryki + generation view per uczelnia (D, task 6)" +``` + +--- + +## Task 7: Read-side `views/statistics.py` + `views/list.py` + +**Files:** +- Modify: `src/ewaluacja_metryki/views/statistics.py`, `src/ewaluacja_metryki/views/list.py` +- Test: `src/ewaluacja_metryki/tests/test_per_uczelnia.py` + +- [ ] **Step 1: Write failing test (widok listy nie pokazuje obcej uczelni)** + +```python +@pytest.mark.django_db +def test_lista_metryk_filtruje_po_uczelni(client, settings, django_user_model, + autor_jan_kowalski, dyscyplina1, + uczelnia1, uczelnia2, site1): + from ewaluacja_metryki.models import MetrykaAutora + + settings.ALLOWED_HOSTS = ["*"] + _make_metryka(autor_jan_kowalski, dyscyplina1, uczelnia1) + autor2 = baker.make("bpp.Autor") + _make_metryka(autor2, dyscyplina1, uczelnia2) + + su = django_user_model.objects.create_superuser("su", "su@x.pl", "x") + client.force_login(su) + resp = client.get("/ewaluacja_metryki/", HTTP_HOST=site1.domain) + metryki = resp.context["metryki"] + uczelnie = {m.uczelnia_id for m in metryki} + assert uczelnie == {uczelnia1.pk} # tylko uczelnia z site1 +``` + +(URL i `context_object_name="metryki"` zweryfikuj w `urls.py`; dostosuj ścieżkę.) + +- [ ] **Step 2: Run, verify fails** + +Run: `uv run pytest "src/ewaluacja_metryki/tests/test_per_uczelnia.py::test_lista_metryk_filtruje_po_uczelni" -q -p no:cacheprovider` +Expected: FAIL — widać metryki obu uczelni. + +- [ ] **Step 3: Update `list.py`** + +W `get_queryset` (linia ~122) zawęź bazę po uczelni: + +```python + def get_queryset(self): + from django.db.models import Count, OuterRef, Subquery + + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + from ..uczelnia_scope import scope_metryki + + uczelnia = uczelnia_dla_odczytu(self.request) + discipline_count = (...) # bez zmian + queryset = scope_metryki( + super().get_queryset() + .select_related("autor", "dyscyplina_naukowa", "jednostka", "jednostka__wydzial") + .annotate(autor_discipline_count=Subquery(discipline_count)), + uczelnia, + ) + queryset = self._apply_filters(queryset) + queryset = self._apply_sorting(queryset) + return queryset +``` + +`_get_status_context` (linia ~227) — status per-uczelnia: + +```python + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + uczelnia = uczelnia_dla_odczytu(self.request) + status = StatusGenerowania.get_or_create(uczelnia=uczelnia) +``` + +- [ ] **Step 4: Update `statistics.py`** + +W `StatystykiView` rozstrzygnij uczelnię raz i zbuduj `base`; wszystkie +`MetrykaAutora.objects.all()`/`.select_related(...)`/`.values(...)` zastąp +`base = scope_metryki(MetrykaAutora.objects.all(), uczelnia)` jako źródłem: + +```python + def get_queryset(self): + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + from ..uczelnia_scope import scope_metryki + + uczelnia = uczelnia_dla_odczytu(self.request) + return scope_metryki( + MetrykaAutora.objects.select_related( + "autor", "dyscyplina_naukowa", "jednostka" + ), + uczelnia, + ).order_by("-srednia_za_slot_nazbierana")[:20] +``` + +W `get_context_data` rozstrzygnij `uczelnia` raz na górze i każde +`MetrykaAutora.objects.<...>` zamień na `scope_metryki(MetrykaAutora.objects.<...>, uczelnia)` +(top_autorzy_sloty, statystyki_globalne `wszystkie`, bottom_*, autorzy_zerowi_raw, +jednostki_stats, dyscypliny_stats, wykorzystanie_ranges). `wszystkie` policz raz: +`wszystkie = scope_metryki(MetrykaAutora.objects.all(), uczelnia)`. + +- [ ] **Step 5: Run tests** + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_per_uczelnia.py src/ewaluacja_metryki/tests/test_views.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ewaluacja_metryki/views/statistics.py src/ewaluacja_metryki/views/list.py src/ewaluacja_metryki/tests/test_per_uczelnia.py +git commit -m "feat(metryki): read-side lista + statystyki filtrowane per uczelnia (D, task 7)" +``` + +--- + +## Task 8: Read-side `views/export.py` + `export_helpers.py` (Opcja A) + +**Files:** +- Modify: `src/ewaluacja_metryki/export_helpers.py`, `src/ewaluacja_metryki/views/export.py` +- Test: `src/ewaluacja_metryki/tests/test_per_uczelnia.py` + +- [ ] **Step 1: Write failing test (eksport globalny widzi tylko swoją uczelnię)** + +```python +@pytest.mark.django_db +def test_export_globalne_stats_scoped(autor_jan_kowalski, dyscyplina1, uczelnia1, uczelnia2): + from openpyxl import Workbook + + from ewaluacja_metryki.export_helpers import export_globalne_stats + from ewaluacja_metryki.models import MetrykaAutora + from ewaluacja_metryki.uczelnia_scope import scope_metryki + + _make_metryka(autor_jan_kowalski, dyscyplina1, uczelnia1) + autor2 = baker.make("bpp.Autor") + _make_metryka(autor2, dyscyplina1, uczelnia2) + + base = scope_metryki(MetrykaAutora.objects.all(), uczelnia1) + ws = Workbook().active + export_globalne_stats(ws, None, None, None, base_qs=base) + # wiersz "Liczba autorów" == 1 (tylko uczelnia1) + assert ws.cell(row=3, column=2).value == 1 +``` + +- [ ] **Step 2: Run, verify fails** + +Run: `uv run pytest "src/ewaluacja_metryki/tests/test_per_uczelnia.py::test_export_globalne_stats_scoped" -q -p no:cacheprovider` +Expected: FAIL — `export_globalne_stats() got unexpected keyword 'base_qs'`. + +- [ ] **Step 3: Update `export_helpers.py` (każdy export_* przyjmuje base_qs)** + +Każda funkcja `export_*` dostaje parametr `base_qs` i używa go zamiast +wewnętrznego `MetrykaAutora.objects.all()`/`.objects.<...>`. Wzorzec dla +`export_globalne_stats`: + +```python +def export_globalne_stats(ws, header_font, header_fill, header_alignment, base_qs=None): + from django.db.models import Avg, Count, Sum + + from .models import MetrykaAutora + + wszystkie = base_qs if base_qs is not None else MetrykaAutora.objects.all() + ws.title = "Statystyki globalne" + stats = wszystkie.aggregate(...) # bez zmian +``` + +Analogicznie: `export_top_autorzy`, `export_top_sloty`, `export_bottom_pkd`, +`export_bottom_sloty`, `export_zerowi`, `export_jednostki`, `export_dyscypliny`, +`export_wykorzystanie` — wszystkie zamieniają `MetrykaAutora.objects` na +`base_qs` (gdzie robią `.select_related/.values/.filter`, startują od `base_qs`). + +- [ ] **Step 4: Update `views/export.py`** + +`ExportStatystykiXLSX.get` i `ExportListaXLSX.get` — rozstrzygnij uczelnię, +zbuduj `base_qs = scope_metryki(MetrykaAutora.objects.all(), uczelnia)` i +przekaż `base_qs=` do każdego wywołania helpera. Dla `ExportListaXLSX` (jeśli +buduje własny queryset `MetrykaAutora.objects.select_related(...)`) — owinąć w +`scope_metryki(..., uczelnia)`. + +```python + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + from ..uczelnia_scope import scope_metryki + + uczelnia = uczelnia_dla_odczytu(request) + base_qs = scope_metryki(MetrykaAutora.objects.all(), uczelnia) + # ...przy każdym export_*(ws, ..., base_qs=base_qs) +``` + +- [ ] **Step 5: Run tests** + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_per_uczelnia.py src/ewaluacja_metryki/tests/test_views.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ewaluacja_metryki/export_helpers.py src/ewaluacja_metryki/views/export.py src/ewaluacja_metryki/tests/test_per_uczelnia.py +git commit -m "feat(metryki): eksporty XLSX scoped per uczelnia (base_qs, Opcja A) (D, task 8)" +``` + +--- + +## Task 9: Read-side `views/detail.py` + `views/pin_unpin.py` + +**Files:** +- Modify: `src/ewaluacja_metryki/views/detail.py`, `src/ewaluacja_metryki/views/pin_unpin.py` +- Test: `src/ewaluacja_metryki/tests/test_per_uczelnia.py` + +- [ ] **Step 1: Write failing test (ranking w jednostce nie miesza uczelni)** + +```python +@pytest.mark.django_db +def test_detail_pozycja_w_jednostce_per_uczelnia(autor_jan_kowalski, dyscyplina1, + uczelnia1, uczelnia2): + """_get_position_context liczy pozycję tylko w obrębie uczelni metryki.""" + from ewaluacja_metryki.models import MetrykaAutora + from ewaluacja_metryki.views.detail import MetrykaDetailView + + jedn = baker.make("bpp.Jednostka", uczelnia=uczelnia1) + m1 = _make_metryka(autor_jan_kowalski, dyscyplina1, uczelnia1, jednostka=jedn, + srednia_za_slot_nazbierana=Decimal("5.0")) + # obca metryka w tej samej jednostce-id ale uczelnia2 (sztuczny edge) + autor2 = baker.make("bpp.Autor") + _make_metryka(autor2, dyscyplina1, uczelnia2, jednostka=jedn, + srednia_za_slot_nazbierana=Decimal("9.0")) + + view = MetrykaDetailView() + ctx = view._get_position_context(m1) + # liczba_w_jednostce liczona w obrębie uczelnia1 → 1 (tylko m1) + assert ctx["liczba_w_jednostce"] == 1 +``` + +- [ ] **Step 2: Run, verify fails** + +Run: `uv run pytest "src/ewaluacja_metryki/tests/test_per_uczelnia.py::test_detail_pozycja_w_jednostce_per_uczelnia" -q -p no:cacheprovider` +Expected: FAIL — liczy 2 (miesza uczelnie). + +- [ ] **Step 3: Update `detail.py`** + +`_get_position_context` (linia ~366) — dodać `uczelnia=metryka.uczelnia` do obu +filtrów: + +```python + if metryka.jednostka: + context["pozycja_w_jednostce"] = ( + MetrykaAutora.objects.filter( + jednostka=metryka.jednostka, + dyscyplina_naukowa=metryka.dyscyplina_naukowa, + uczelnia=metryka.uczelnia, + srednia_za_slot_nazbierana__gt=metryka.srednia_za_slot_nazbierana, + ).count() + 1 + ) + context["liczba_w_jednostce"] = MetrykaAutora.objects.filter( + jednostka=metryka.jednostka, + dyscyplina_naukowa=metryka.dyscyplina_naukowa, + uczelnia=metryka.uczelnia, + ).count() +``` + +`get_context_data` `inne_dyscypliny` (linia ~396) — dodać `uczelnia=metryka.uczelnia`: + +```python + inne_dyscypliny = ( + MetrykaAutora.objects.filter(autor=metryka.autor, uczelnia=metryka.uczelnia) + .exclude(pk=metryka.pk) + .select_related("dyscyplina_naukowa") + .order_by("dyscyplina_naukowa__nazwa") + ) +``` + +(`get_object` zostaje po `autor__slug`+`dyscyplina_naukowa__kod` — jeśli autor +ma metryki na >1 uczelni, dodać `.filter(uczelnia=uczelnia_dla_odczytu(request))` +do `queryset` w `get_object`, by pokazać metrykę uczelni oglądającego. Transitive +`Cache_Punktacja_Autora_Query` queries po `autor_id`+`dyscyplina_id` zostają.) + +- [ ] **Step 4: Update `pin_unpin.py` redirect lookup** + +Oba widoki (`PrzypnijDyscyplineView`, `OdepnijDyscyplineView`) — redirect lookup +`MetrykaAutora.objects.filter(autor_id, dyscyplina_naukowa_id)` (linie ~74, ~147) +dodać uczelnię autora (defense-in-depth): + +```python + from ..uczelnia_scope import scope_metryki + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + metryka = scope_metryki( + MetrykaAutora.objects.filter( + autor_id=autor_id, dyscyplina_naukowa_id=dyscyplina_id + ), + uczelnia_dla_odczytu(request), + ).first() +``` + +- [ ] **Step 5: Run tests** + +Run: `uv run pytest src/ewaluacja_metryki/tests/test_per_uczelnia.py src/ewaluacja_metryki/tests/test_views.py -q -p no:cacheprovider` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ewaluacja_metryki/views/detail.py src/ewaluacja_metryki/views/pin_unpin.py src/ewaluacja_metryki/tests/test_per_uczelnia.py +git commit -m "feat(metryki): detail ranking + pin/unpin redirect per uczelnia (D, task 9)" +``` + +--- + +## Task 10: Admin — `uczelnia` w `MetrykaAutoraAdmin` + +**Files:** +- Modify: `src/ewaluacja_metryki/admin.py:9-69` + +- [ ] **Step 1: Update admin (parytet R2)** + +```python + list_display = [ + "autor", "dyscyplina_naukowa", "uczelnia", "jednostka", + "slot_maksymalny", "slot_nazbierany", "punkty_nazbierane", + "srednia_za_slot_nazbierana", "procent_wykorzystania_slotow", + "data_obliczenia", + ] + list_filter = ["uczelnia", "dyscyplina_naukowa", "jednostka", "procent_wykorzystania_slotow"] +``` + +Fieldset „Podstawowe informacje": `{"fields": ("autor", "dyscyplina_naukowa", "uczelnia", "jednostka")}`. + +- [ ] **Step 2: Smoke check (admin import + system check)** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py check` +Expected: "System check identified no issues". + +- [ ] **Step 3: Commit** + +```bash +git add src/ewaluacja_metryki/admin.py +git commit -m "feat(metryki): admin pokazuje uczelnia (parytet R2) (D, task 10)" +``` + +--- + +## Task 11: Migracja 0008 NOT NULL + pełna regresja + +**Files:** +- Modify: `src/ewaluacja_metryki/models.py` (usunąć `null=True, blank=True`) +- Create: `src/ewaluacja_metryki/migrations/0008_uczelnia_notnull.py` + +- [ ] **Step 1: Make FK NOT NULL in models** + +`MetrykaAutora.uczelnia`: usunąć `null=True, blank=True`. +`StatusGenerowania.uczelnia`: usunąć `null=True, blank=True` (zostaje `unique=True`). + +- [ ] **Step 2: Generate migration** + +Run: `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations ewaluacja_metryki --name uczelnia_notnull` +Expected: utworzy `0008_uczelnia_notnull.py` z dwoma `AlterField` (null=False). +Zweryfikuj treść (dwa `AlterField`, brak innych zmian). + +- [ ] **Step 3: Full regression `ewaluacja_metryki`** + +Run: `uv run pytest src/ewaluacja_metryki/ -q -p no:cacheprovider` +Expected: PASS. Jeśli któryś istniejący test tworzy `MetrykaAutora`/`StatusGenerowania` +bez uczelni i `model_bakery` nie dofilluje (lub dofilluje spurious Uczelnia +psując asercje count) — popraw fixture/test, dodając jawną `uczelnia=...`. + +- [ ] **Step 4: Guard + sloty invariant + makemigrations check** + +```bash +uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q +PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations --check --dry-run +``` +Expected: guard zielony (bez nowych wpisów); "No changes detected". + +- [ ] **Step 5: Lint całość** + +```bash +uv run ruff check src/ewaluacja_metryki/ +uv run ruff format --check src/ewaluacja_metryki/ +``` +Expected: clean (fix ręcznie jeśli zgłosi). + +- [ ] **Step 6: Commit** + +```bash +git add src/ewaluacja_metryki/models.py src/ewaluacja_metryki/migrations/0008_uczelnia_notnull.py +git commit -m "feat(metryki): uczelnia NOT NULL po backfillu (D, task 11)" +``` + +--- + +## Self-Review (autor planu) + +**Spec coverage:** +- Schemat MetrykaAutora FK+unique+index → Task 1. ✓ +- StatusGenerowania per-uczelnia → Task 2. ✓ +- Migracje 0006/0007/0008 (backfill clear-on-multi, NOT NULL) → Task 1/2/11. ✓ +- Knapsack leak (`zbieraj_sloty(uczelnia_id)`) → Task 3 (Step 3/5). ✓ +- Globalny delete + odczyt źródła → Task 3 (generuj_metryki) + Task 4/5. ✓ +- Bulk tag z wiersza / pin-unpin z aktualna_jednostka → Task 3 (Step 3-5). ✓ +- Taski single-or-fail + status per-uczelnia + chord callback uczelnia_id → Task 4. ✓ +- CLI scope → Task 5. ✓ +- generation view + status views → Task 6. ✓ +- Read-side statistics/list/export/detail/pin_unpin + helper → Task 6-9. ✓ +- Admin → Task 10. ✓ +- Testy izolacji/invariant/knapsack/read/pin-unpin/status → rozsiane TDD + Task 11 regresja. ✓ + +**Założenia do zweryfikowania w trakcie (flagi dla wykonawcy):** +- Numery migracji `bpp` w `dependencies` (0428) i poprzedniej migracji metryki + (0005) — potwierdzić `ls migrations/` + `makemigrations --check`. +- URL listy metryk w teście Task 7 — sprawdzić `ewaluacja_metryki/urls.py`. +- `model_bakery` zachowanie przy NOT NULL FK (Task 11) — może dofillować Uczelnia. +- Subtask `oblicz_metryki_dla_autora_task` + `StatusGenerowania.update` w trybie + parallel (Task 4 Step 4) — przekazać `uczelnia_id` do subtaska. diff --git a/docs/superpowers/specs/2026-06-04-ewaluacja-metryki-per-uczelnia-design.md b/docs/superpowers/specs/2026-06-04-ewaluacja-metryki-per-uczelnia-design.md new file mode 100644 index 000000000..d597346fd --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-ewaluacja-metryki-per-uczelnia-design.md @@ -0,0 +1,271 @@ +# Design — `ewaluacja_metryki` per-uczelnia (wątek D, write+read) + +Data: 2026-06-04 +Gałąź: `feature/multi-hosted-config` +Kontekst: wątek D z `HANDOFF-multi-hosted.md`. Następny po R2 +(`ewaluacja_liczba_n` per-uczelnia). Brief: `NEXT-SESSION-metryki-per-uczelnia.md`. + +## Cel i zakres + +W instalacji wielouczelnianej **metryki ewaluacyjne autorów liczone i pokazywane +są per uczelnia**. R2 zawęził już *źródło* udziałów per uczelnia (FK `uczelnia` +na `IloscUdzialowDlaAutoraZaRok/ZaCalosc`, cały pipeline `ewaluacja_liczba_n`). +ALE **konsument** w `ewaluacja_metryki` czyta te udziały i pisze +`MetrykaAutora` **globalnie** — więc w multi-install metryki mieszają uczelnie, +a generowanie dla jednej uczelni niszczy metryki pozostałych. + +**„Liczba N" w tytule wątku = R2 (zrobione).** `generuj_metryki_task` woła +`oblicz_liczby_n_dla_ewaluacji_2022_2025(uczelnia)` jako krok 1 — to już +per-uczelnia. Wątek D dotyczy **wyłącznie konsumenta-metryki** (krok 2+). + +**W zakresie D:** +- FK `uczelnia` na `MetrykaAutora` + `unique_together` z uczelnią + indeks, +- FK `uczelnia` na `StatusGenerowania` (per-uczelnia status generowania; + koniec globalnego singletonu `pk=1`), +- migracja + backfill (single→domyślna; multi-z-danymi→**wyczyść** regenerowalny + cache; potem osobna migracja NOT NULL), +- zawężenie pipeline'u liczenia (`utils.py`, `tasks.py`, `oblicz_metryki.py`, + ścieżka pin/unpin) per uczelnia — w tym naprawa **globalnego destrukcyjnego + delete** i **knapsack leak** (patrz niżej), +- filtrowanie odczytów (`list`, `detail`, `statistics`, `export`, + `export_helpers`, `pin_unpin` redirect) po uczelni oglądającego, +- admin: pokaż `uczelnia` (parytet z R2). + +**Poza zakresem D:** +- Reguła atrybucji autor→uczelnia z R2 (`aktualna_jednostka.uczelnia` + + `skupia_pracownikow`) — **nie zmieniamy jej**, tylko **stosujemy**: ścieżka + bulk dziedziczy uczelnię z wiersza `IloscUdzialow` (który R2 już zatagował tą + regułą), a ścieżka pin/unpin wyprowadza ją bezpośrednio z + `autor.aktualna_jednostka.uczelnia` (patrz „Reguła wiodąca”). +- Federacja optymalizacji (`ewaluacja_optymalizacja`) — świadomie odłożona. + +## Reguła wiodąca (decyzja usera) + +**Metryka jest dla uczelni, na której autor ma udziały.** Jedna metryka na +krotkę `(autor, dyscyplina, uczelnia)`. Autor może (latentnie) mieć metryki na +wielu uczelniach — read-side pokazuje tę dla **uczelni oglądającego** (z +requestu/site, z opcją override superusera). + +**Atrybucja (ścieżka bulk) = wiersz źródłowy.** `metryka.uczelnia = +ilosc_udzialow.uczelnia` — wiersz `IloscUdzialowDlaAutoraZaCalosc` jest już +per-uczelnia (R2), więc batch nie zgaduje, tylko dziedziczy. Pole `jednostka` FK +pozostaje jak dziś (`autor.aktualna_jednostka`, display). W ścieżce bulk NIE +re-derywujemy uczelni z `aktualna_jednostka` — bierzemy ją wprost z wiersza. +(Ścieżka pin/unpin nie ma wiersza do dziedziczenia — patrz niżej.) + +**Pin/unpin: uczelnia z `aktualna_jednostka`.** Ścieżka pin/unpin nie iteruje +wierszy `IloscUdzialow`, tylko bierze parę `(autor, dyscyplina)` — więc uczelnię +wyprowadza **bezpośrednio z `autor.aktualna_jednostka.uczelnia`** (reguła R2: +`NULL`/jednostka bez `skupia_pracownikow` → autor wykluczony, metryka pomijana). +To ta sama reguła, którą R2 zatagował wiersze `IloscUdzialow`, więc wynik jest +spójny ze ścieżką bulk. Recompute celuje w jedną uczelnię (autor ma jedną +`aktualna_jednostka`); żadnej pętli po uczelniach nie potrzeba. + +## Invariant zgodności + +Single-install: backfill wpisuje domyślną uczelnię; guard +`tylko_jedna_uczelnia()` short-circuituje filtry read-side (no-op); ścieżka write +operuje na jednej uczelni jak dziś → liczby/metryki **identyczne**. Wszystkie +istniejące testy `ewaluacja_metryki` muszą przejść. + +**Jedyna świadoma różnica vs obecny stan (multi-install):** generowanie i odczyty +są zawężone per uczelnia — to cel, nie regresja. + +## Zmiany schematu — `src/ewaluacja_metryki/models.py` + +### `MetrykaAutora` +- dodać `uczelnia = ForeignKey("bpp.Uczelnia", on_delete=CASCADE, null=True, + blank=True)` (nullable tylko na czas migracji; docelowo NOT NULL), +- `unique_together` → `("autor", "dyscyplina_naukowa", "uczelnia")` + (dziś: `("autor", "dyscyplina_naukowa")`), +- dodać `models.Index(fields=["uczelnia", "-srednia_za_slot_nazbierana"])` + (filtr + ordering listy read-side). + +### `StatusGenerowania` (koniec globalnego singletonu) +Dziś singleton wymuszany `self.pk = 1` w `save()` + `get_or_create(pk=1)`. +Per-uczelnia status oznacza **jeden wiersz na uczelnię**: +- dodać `uczelnia = ForeignKey("bpp.Uczelnia", on_delete=CASCADE, null=True, + blank=True, unique=True)` (docelowo NOT NULL; `unique` = co najwyżej jeden + status na uczelnię), +- `save()`: usunąć `self.pk = 1` (już nie singleton), +- `get_or_create()` → `get_or_create(uczelnia)`: + `cls.objects.get_or_create(uczelnia=uczelnia)`, +- wszystkie wołania `StatusGenerowania.get_or_create()` (tasks/views) przekazują + rozstrzygniętą uczelnię (write: argument taska; read/UI: `uczelnia_dla_odczytu`). + +### Migracje (nowe pliki; **NIGDY** nie ruszamy istniejących) +`0006_metrykaautora_uczelnia.py`: +- `AlterUniqueTogether(name='metrykaautora', unique_together=set())`, +- `AddField uczelnia` (nullable), +- `AlterUniqueTogether → {("autor","dyscyplina_naukowa","uczelnia")}`, +- `AddIndex`, +- `RunPython` backfill (`MetrykaAutora` to **regenerowalny cache**): + `Uczelnia.objects.all()[:2]` — jeśli istnieją wiersze `uczelnia__isnull=True` + i uczelni jest dokładnie 1 → `update(uczelnia=ta)`; jeśli uczelni ≠ 1 a są + NULL-e → `MetrykaAutora.objects.all().delete()` (metryki odtworzą się przy + następnym `generuj_metryki` per-uczelnia — niższe tarcie niż twardy fail, + bo to cache, nie źródło). Reverse: no-op. + +`0007_statusgenerowania_uczelnia.py`: +- `AddField uczelnia` (nullable, `unique=True`), +- `RunPython` backfill: single → istniejący wiersz `pk=1` dostaje domyślną + uczelnię; multi z NULL-em → `StatusGenerowania.objects.filter( + uczelnia__isnull=True).delete()` (status to ulotny stan postępu, nie dane). + Reverse: no-op. + +`0008_metrykaautora_statusgenerowania_uczelnia_notnull.py` (po backfillach): +- `AlterField` `MetrykaAutora.uczelnia` → `null=False`, +- `AlterField` `StatusGenerowania.uczelnia` → `null=False`. + (Osobna migracja `AlterField`, bo NOT NULL może wejść dopiero po wypełnieniu + wszystkich wierszy backfillem; parytet z 0428 write-side.) + +**Numeracja migracji do potwierdzenia przy `makemigrations`** (zależna od +aktualnego stanu zależności `bpp`/`ewaluacja_common`). + +## Pipeline liczenia (write) — zawężenie per uczelnia + +Trzy luki (wszystkie dziś globalne) do naprawy: + +1. **Knapsack leak.** `_calculate_metrics_data` i `oblicz_metryki_dla_autora` + wołają `autor.zbieraj_sloty(...)` **bez `uczelnia_id`** → `bpp/core.py:22` + nie filtruje `jednostka__uczelnia_id` → kandydaci z cache WSZYSTKICH uczelni. + Fix: przekazać `uczelnia_id=` do obu wywołań `zbieraj_sloty` + (nazbierane i „wszystko"). +2. **Destrukcyjny global delete.** `MetrykaAutora.objects.all().delete()` + (`utils.py:556` w `generuj_metryki`, `tasks.py:245-246` w + `generuj_metryki_task_parallel`) → `filter(uczelnia=U).delete()`. +3. **Globalny odczyt źródła.** `IloscUdzialowDlaAutoraZaCalosc.objects.all()` + (`utils.py:277` w `_get_ilosc_udzialow_queryset`, `tasks.py:231`/`:357`, + `oblicz_metryki.py:132`) → `filter(uczelnia=U)`. + +Atrybucja metryki: tworzenie wiersza zawsze ustawia `metryka.uczelnia` z +**przekazanej** uczelni (parametr funkcji), różny jest tylko jej **źródło**: +- **bulk** (`_process_single_author`, subtask parallel): `ilosc_udzialow.uczelnia` + — każdy wiersz `IloscUdzialow` ją niesie, więc ścieżka per-wiersz tag-uje + metrykę i przekazuje `uczelnia_id` do knapsacka bez dodatkowego parametru + orkiestracji; +- **pin/unpin** (`oblicz_metryki_dla_autora`): uczelnia przekazana przez + `przelicz_metryki_dla_publikacji` z `autor.aktualna_jednostka.uczelnia`. + +Funkcje: +- `generuj_metryki(..., uczelnia)`: nowy param `uczelnia`; + `_get_ilosc_udzialow_queryset` zawęża po `uczelnia`; delete `nadpisz` → + `MetrykaAutora.objects.filter(uczelnia=uczelnia).delete()`. +- `_process_single_author` / `_create_or_update_metryka`: tag `uczelnia` z + `ilosc_udzialow.uczelnia`; `zbieraj_sloty(uczelnia_id=ilosc_udzialow.uczelnia_id)`. +- `oblicz_metryki_dla_autora(autor, dyscyplina, uczelnia, ...)`: agregat + `IloscUdzialow.filter(autor, dyscyplina, uczelnia)`; `zbieraj_sloty(uczelnia_id=...)`; + delete/create `filter(... uczelnia=uczelnia)` / `uczelnia=uczelnia`. +- `przelicz_metryki_dla_publikacji(publikacja)`: dla każdej pary + `(autor, dyscyplina)` wyprowadź uczelnię z `autor.aktualna_jednostka.uczelnia` + (reguła R2; jednostka `NULL`/bez `skupia_pracownikow` → pomiń autora, brak + metryki) i wywołaj `oblicz_metryki_dla_autora(autor, dyscyplina, uczelnia)`. + Jedna uczelnia na autora (jedna `aktualna_jednostka`). + +Punkty wejścia — **single-or-fail** (jak B2 `zbieraj_sloty`): +- `generuj_metryki_task` / `generuj_metryki_task_parallel(..., uczelnia_id=None)`: + `Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id else Uczelnia.objects.get()` + (NIGDY `get_default`); scope queryset + delete + przekazanie `uczelnia` do + `generuj_metryki`; `StatusGenerowania.get_or_create(uczelnia)` (status + per-uczelnia — krok 4 init i finalizacja celują w wiersz tej uczelni). Krok 1 + (liczba_n) już per-uczelnia. Subtask parallel (`oblicz_metryki_dla_autora_task`) + czyta uczelnię z wiersza `IloscUdzialow` — bez nowego parametru; delete + + status init są na poziomie orkiestracji (`generuj_metryki_task_parallel`). + `finalizuj_generowanie_metryk` musi dostać `uczelnia_id` (chord callback), by + zamknąć właściwy wiersz statusu. +- CLI `oblicz_metryki`: ma już `--uczelnia-id`; rozszerzyć single-or-fail na + całość (nie tylko liczbę N); zawęzić `ilosc_udzialow_qs` po uczelni; przekazać + `uczelnia` do `generuj_metryki`. +- `views/generation.py::UruchomGenerowanieView`: rozstrzygnij uczelnię z requestu + (`uczelnia_dla_odczytu`) i przekaż `uczelnia_id` do + `generuj_metryki_task_parallel.delay(...)`; `total_count` z + `IloscUdzialow.filter(uczelnia=U).count()`; `StatusGenerowania.get_or_create( + uczelnia=U)` (init `rozpocznij_generowanie` na wierszu tej uczelni). Widoki + statusu (`StatusGenerowaniaView`, `StatusGenerowaniaPartialView`) też + rozstrzygają uczelnię z requestu i pokazują jej wiersz. + +## Read-side — hybryda `uczelnia_dla_odczytu` (R1), defense-in-depth + +Źródło uczelni oglądającego: `raport_slotow.uczelnia_helper.uczelnia_dla_odczytu(request)` +(get_for_request + superuser `?uczelnia=`). Guard +`bpp.util.uczelnia_scope.tylko_jedna_uczelnia()` → short-circuit single-install +(filtr no-op, zero zmian w zapytaniach gdy 1 uczelnia → invariant). + +Wzorzec: widok rozstrzyga uczelnię raz, buduje `base = MetrykaAutora.objects +.filter(uczelnia=U)` (albo niezawężony przy single-install) i z niego korzysta. + +- `views/statistics.py`: wszystkie `MetrykaAutora.objects.all()` / agregaty + (globalne, jednostki, dyscypliny, rozkład wykorzystania, top/bottom) → z + `base`. +- `views/list.py`: `get_queryset` base po uczelni; listy filtrów + (`_get_jednostki_wydzialy_context`, `_get_dyscypliny_context`, + `_get_statistics_context`) po uczelni; `_get_status_context` → + `StatusGenerowania.get_or_create(uczelnia=U)` (pasek postępu tej uczelni). +- `views/detail.py`: `get_object` zawęża po uczelni (autor z metrykami na >1 + uczelni → pokazujemy tę z otwartej); ranking `_get_position_context` po uczelni. +- `views/export.py`: `ExportListaXLSX` base po uczelni; `ExportStatystykiXLSX` + rozstrzyga uczelnię, buduje `base_qs` i przekazuje do helperów. +- `export_helpers.py` (**Opcja A**): każdy `export_*` przyjmuje już-zawężony + `base_qs` z widoku zamiast wewnętrznego `MetrykaAutora.objects.all()`. Helpery + stają się uczelnia-agnostyczne (czysta prezentacja); decyzja per-uczelnia + + guard żyją w jednym miejscu (widok). `export_zerowi` (iteracja + + `Autor_Dyscyplina`) też operuje na `base_qs`. +- `views/pin_unpin.py`: redirect-lookup `MetrykaAutora.filter(autor, dyscyplina) + .first()` → dodać `uczelnia=U` (jawny filtr, defense-in-depth). + +## Admin (drobne, parytet R2) + +`MetrykaAutoraAdmin`: `uczelnia` w `list_display`, `list_filter`, oraz w +fieldset „Podstawowe informacje". + +## Testy (`fixtures.conftest_multisite`) + +- **Invariant single-install:** istniejące testy `ewaluacja_metryki` zielone; + fixture jednouczelniany → metryki identyczne; guard → filtry no-op. +- **Izolacja multi-install:** 2 uczelnie, autorzy z udziałami w obu (różne + `IloscUdzialow.uczelnia`); generowanie dla U1 potem U2 → generowanie U2 **NIE** + wyciera metryk U1 (scoped delete); każda metryka z poprawną `uczelnia`. +- **Knapsack scoping:** metryka liczona dla U1 bierze kandydatów tylko z cache U1 + (`zbieraj_sloty(uczelnia_id=U1)`) — asercja, że praca z jednostki U2 nie wpływa + na slot/punkty metryki U1. +- **Read defense-in-depth:** list/detail/statistics/export dla U1 nie pokazują + metryk U2 (asercja pozytywna: moja jest; negatywna: obca nie). Superuser + `?uczelnia=U2` przełącza widok. +- **Pin/unpin:** recompute dla publikacji zawęża metrykę do uczelni autora + (`aktualna_jednostka.uczelnia`); autor z jednostki bez `skupia_pracownikow` / + `NULL` → metryka pomijana; nie tworzy/nie rusza metryk innej uczelni. +- **Status per-uczelnia:** generowanie dla U1 ustawia/odczytuje wiersz + `StatusGenerowania` U1, nie miesza z postępem U2; `get_or_create(uczelnia=U2)` + zwraca osobny wiersz. Widok statusu pokazuje pasek właściwej uczelni. +- **Migracja backfill:** single → legacy wiersze dostają domyślną uczelnię (test + jednostkowy opcjonalny — trudny na świeżej bazie testowej). + +## Migracja i deploy + +- Single-install: backfill wpisze domyślną uczelnię w legacy `MetrykaAutora` i + `StatusGenerowania`; następne generowanie przeliczy poprawnie per uczelnia + (identycznie). NOT NULL wchodzi w `0008` po backfillu. +- Multi-install z danymi: backfill **czyści** legacy `MetrykaAutora` + (regenerowalny cache → odtworzy się przy najbliższym `generuj_metryki` + per-uczelnia) oraz usuwa osierocony wiersz `StatusGenerowania` bez uczelni. + Świadoma różnica vs `0425`/`0009` (te robiły twardy fail) — uzasadniona tym, + że metryki i status to dane **pochodne/ulotne**, nie źródło prawdy. +- Kolejność deployu: `0006` (FK+backfill metryk) → `0007` (FK+backfill statusu) + → `0008` (NOT NULL obu). Po deployu uruchomić generowanie per uczelnia. + +## Komendy weryfikacji + +- Testy: `uv run pytest src/ewaluacja_metryki/ -q -p no:cacheprovider`. +- Guard: `uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q`. +- `uv run python src/manage.py makemigrations --check --dry-run` + (z `PYTEST_TESTCONTAINERS_DISABLE=1 DJANGO_BPP_SKIP_DOTENV=1`). +- Lint: `uv run ruff check ` ORAZ `uv run ruff format --check ` + (NIE `--fix`). + +## Dokumenty referencyjne + +- Spec R2 (wzorzec): `specs/2026-06-03-ewaluacja-liczba-n-per-uczelnia-design.md` +- Migracja R2 backfill (wzorzec): `ewaluacja_liczba_n/migrations/0009_iloscudzialow_uczelnia.py` +- Write-side sloty (FK+backfill 0425): `specs/2026-06-02-per-uczelnia-sloty-design.md` +- Read-side R1 (helper hybryda): `raport_slotow/uczelnia_helper.py` +- Master: `HANDOFF-multi-hosted.md`; brief: `NEXT-SESSION-metryki-per-uczelnia.md` From e79328a83a7d73815492021b494edf62cecd0970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 14:05:44 +0200 Subject: [PATCH 169/247] =?UTF-8?q?docs(multi-hosted):=20brief=20next-sess?= =?UTF-8?q?ion=20=E2=80=94=20audyt=20pokrycia=20uczelnia=20(get=5Fdefault?= =?UTF-8?q?=20+=20brakuj=C4=85ca=20uczelnia)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NEXT-SESSION-audyt-uczelnia-coverage.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/NEXT-SESSION-audyt-uczelnia-coverage.md diff --git a/docs/superpowers/NEXT-SESSION-audyt-uczelnia-coverage.md b/docs/superpowers/NEXT-SESSION-audyt-uczelnia-coverage.md new file mode 100644 index 000000000..fbadef381 --- /dev/null +++ b/docs/superpowers/NEXT-SESSION-audyt-uczelnia-coverage.md @@ -0,0 +1,156 @@ +# NEXT SESSION — audyt pokrycia multi-hosted (get_default + brakująca uczelnia) + +> Cel sesji: **zweryfikować, że WSZYSTKIE miejsca, gdzie był `Uczelnia.objects.get_default()`/`.default` +> ALBO gdzie uczelnia NIE była podawana mimo że potrzebna, są naprawione** — +> a to co zostaje, jest świadomie odłożone (z uzasadnieniem), nie zapomniane. +> Wklej sekcję „PROMPT DO WKLEJENIA" jako pierwszą wiadomość po resecie. + +--- + +## PROMPT DO WKLEJENIA + +Jesteś świeżą sesją. Repo: `~/Programowanie/bpp-multi-hosted-config`, gałąź +`feature/multi-hosted-config` (BPP, Django, instalacja **wielouczelniana** — +jedna instancja obsługuje wiele `Uczelnia`; żaden runtime nie może „zgadywać" +uczelni przez `get_default()` = pierwsza-z-brzegu, tylko używać właściwej; a +liczby/raporty mają być liczone i pokazywane **per uczelnia**). + +Duże wątki są ZROBIONE i w PR #189 (→ `dev`): split PBN client, cleanup +`get_default` (+guard), write-side sloty, read-side R1, liczba_n R2, R3a/R3b +read-side publiczny, integrator, drobiazgi B1–B6, oraz najnowszy **D +(ewaluacja_metryki per-uczelnia)**. Pełny stan: `docs/superpowers/HANDOFF-multi-hosted.md` +(**przeczytaj NAJPIERW**). + +**Twoje zadanie: AUDYT POKRYCIA, nie implementacja.** Zweryfikuj systematycznie, +że nie ma niezałatanego buga multi-hosted. To audyt **read-only** — NIE pisz kodu +ani nie commituj bez mojej zgody. Produkt końcowy: **raport** w +`docs/superpowers/2026-06-XX-audyt-uczelnia-coverage.md` z tabelą znalezisk +podzielonych na: ✅ naprawione / 🟡 świadomie odłożone (z uzasadnieniem) / +🔴 LUKA (zapomniane, wymaga naprawy) — z `plik:linia` dla każdego. + +Są **DWIE klasy** buga, audytuj OBIE: + +**Klasa 1 — `get_default()` / `objects.default` (łapie guard, ale zweryfikuj wpisy).** +Guard `src/bpp/tests/test_multihosted_get_default_guard.py` zamraża whitelistę +10 plików (każdy = świadoma decyzja). Dla KAŻDEGO wpisu whitelisty otwórz plik, +przeczytaj użycie i oceń, czy uzasadnienie nadal trzyma (świadomy fallback bez +requestu / None-tolerant warstwa modelu / display / guarded count==1 / komentarz), +czy to ukryta luka runtime która powinna dostać jawną uczelnię. Uruchom guard: +`uv run pytest src/bpp/tests/test_multihosted_get_default_guard.py -q -p no:cacheprovider` +(musi być zielony). Sprawdź też, czy poza whitelistą faktycznie nic nie ma +(guard to robi, ale potwierdź regexem, że nie ma wariantów typu +`Uczelnia.objects.get_default ` z innym formatowaniem, albo aliasów managera). + +**Klasa 2 — brakująca uczelnia tam, gdzie potrzebna (NIC tego nie łapie automatycznie).** +To trudniejsza, cichsza klasa. Szukaj w ścieżkach **runtime** (widoki, taski, +komendy, serializery API, context processors) wzorców: +- `.objects.all()` / `.filter(...)` BEZ filtra uczelni na modelach które + są **partycjonowane per-uczelnia** — sprawdź które modele mają FK `uczelnia` + (grep `uczelnia = models.ForeignKey` / `models.OneToOneField`) i prześledź ich + odczyty/zapisy. Znane partycjonowane: `Cache_Punktacja_Dyscypliny`, + `IloscUdzialowDlaAutoraZaRok/ZaCalosc`, `LiczbaNDlaUczelni`, + `DyscyplinaNieRaportowana`, `MetrykaAutora`, `StatusGenerowania`, + `PBN_Export_Queue`, `RaportSlotowUczelnia` (+ inne — zweryfikuj grepem). +- funkcje/metody liczące „per uczelnia", które NIE przyjmują `uczelnia`/`uczelnia_id` + albo przyjmują, ale wołający go nie przekazuje (jak bug D: `generuj_metryki_task` + miał `uczelnia_id`, ale nie przekazywał do `generuj_metryki` → globalny delete). +- globalne `.delete()` / `.update()` na partycjonowanych modelach (klasyczny + data-corruption multi-hosted — patrz Audyt C niżej). +- widoki czytające bez `get_for_request` / `uczelnia_dla_odczytu`. + +Reguły rozstrzygania uczelni (z poprzednich wątków — sprawdzaj zgodność): +- **runtime z requestem** → `Uczelnia.objects.get_for_request(request)` (write) + / `uczelnia_dla_odczytu(request)` (read, hybryda + superuser `?uczelnia=`). +- **tło/CLI/Celery** → `Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id else + Uczelnia.objects.get()` (single-or-fail) albo `get_for_pbn_background(id)`. + NIGDY `get_default()` w runtime. +- **read-side** → guard single-install `tylko_jedna_uczelnia()` → filtr no-op + przy 1 uczelni (wzorzec `bpp.util.uczelnia_scope`, + `ewaluacja_metryki.uczelnia_scope.scope_metryki`, + `raport_slotow.uczelnia_helper.uczelnia_dla_odczytu`). +- **atrybucja autora→uczelnia** (write liczeń) → `autor.aktualna_jednostka.uczelnia` + tylko gdy `jednostka.skupia_pracownikow=True` (NULL/obca → wykluczony). + +Najpierw zrób recon (read-only): przeczytaj HANDOFF + Audyt 4x +(`docs/superpowers/2026-06-03-audyty-multihosted-4x.md`) + guard. Następnie +**dispatch równoległych subagentów-audytorów** (np. po obszarach: `bpp/views`, +`bpp/models`, `api_v1`, `pbn_api`+`pbn_integrator`, `raport_slotow`+`ewaluacja_*`, +`powiazania_autorow`+reszta) — każdy zwraca strukturalną listę znalezisk klasy 2 +ze statusem. Zsyntezuj w jeden raport. Skoryguj fałszywe alarmy (np. odczyt +zawężony tranzytywnie przez `autor_id`+`dyscyplina_id` jest OK; multiseek wyniki +świadomie NIE filtrowane — patrz R3a). + +Reguły: `uv run` zawsze; audyt read-only (bez kodu/commitów bez zgody); raport +po polsku; ścieżki jako `plik:linia`. + +--- + +## KONTEKST (stan 2026-06-04, agent doczyta) + +### Co odróżnia tę sesję od poprzednich +Poprzednie wątki naprawiały **konkretne obszary** (sloty, liczba_n, metryki, +read-side publiczny, integrator). Ta sesja to **audyt poprzeczny**: czy gdzieś +między obszarami została luka — zwłaszcza klasy 2 (cicha brakująca uczelnia), +której żaden guard nie łapie. Bug D (znaleziony dopiero przy implementacji +metryk: `oblicz_metryki_dla_autora` sumował sloty wszystkich uczelni, bo R2 +rozbił źródło, a konsument nie był zaktualizowany) to **dowód, że takie luki +istnieją na styku wątków** — szukaj analogicznych „konsument nie nadążył za +partycjonowaniem źródła". + +### Guard get_default — whitelista do weryfikacji (10 wpisów, `src/bpp/tests/test_multihosted_get_default_guard.py`) +1. `bpp/middleware.py` (1) — świadomy fallback: Site istnieje, brak Uczelni. +2. `bpp/util/bpp_specific.py` (2) — docstring + fallback CLI/Celery bez requestu. +3. `bpp/models/abstract/pbn.py` (2) — linki PBN, metoda modelu bez requestu. +4. `bpp/models/jednostka.py` (1) — sortowanie (display). +5. `bpp/multiseek_registry/fields/numeric_fields.py` (1) — toggle IC, None-tolerant. +6. `ewaluacja2021/util.py` (1) — komentarz (nie kod; ewaluacja2021 = husk/wygaszane). +7. `pbn_api/management/commands/util.py` (1) — GUARDED count==1. +8. `pbn_import/templatetags/pbn_import_tags.py` (1) — request-first, fallback bez requestu. +9. `pbn_import/utils/command_helpers.py` (1) — CLI None-tolerant + CommandError. +(`bpp/models/sloty/core.py` i `abstract/disciplines.py` — get_default USUNIĘTY, +brak wpisu = świadomie; guard złapie powrót.) + +Dla każdego: czy to naprawdę „display/fallback/CLI" czy ukryta ścieżka runtime? +ewaluacja2021 — potwierdź, że to martwy kod (web URL-e wyłączone wg HANDOFF). + +### Znane ODŁOŻONE luki (status 🟡 — potwierdź, że nadal świadomie odłożone, nie zapomniane) +Z Audytu 4x (`docs/superpowers/2026-06-03-audyty-multihosted-4x.md`, sekcja C) +i HANDOFF: +- **Federacja `ewaluacja_optymalizacja`** (decyzja usera: OLANA jako logika + federacyjna, ale **bugi KORUPCJI DANYCH** to integralność, nie federacja): + - `OptimizationRun.delete()` cross-uczelnia (`tasks/optimization.py:73`), + - `reset_all_pins_task`/`optimize_and_unpin` globalne querysety, + - komparatory PBN globalny `.delete()`. + - `StatusGenerowania.get_or_create()` **no-arg** w `ewaluacja_optymalizacja/ + views/unpinning_analysis.py:39,149` i `unpinning_list.py:137` (czyta wiersz + `uczelnia=None`; to dlatego `StatusGenerowania.uczelnia` ZOSTAŁ nullable w D). + Oceń: które z nich to czysta korupcja danych (do naprawy scope-fixem niezależnie + od logiki federacyjnej), a które wymagają decyzji federacyjnej. +- **Multiseek wyniki** świadomie NIE filtrowane per-uczelnia (decyzja R3a) — OK. +- **`MetrykaAutora` transitive `Cache_Punktacja_Autora_Query`** (autor_id+dyscyplina_id) + — świadomie zawężone tranzytywnie, rewizja należy do federacji (komentarze w + `ewaluacja_metryki/views/detail.py`). + +### Dokumenty referencyjne (źródła prawdy o tym co już zrobione) +- Master: `docs/superpowers/HANDOFF-multi-hosted.md` (sekcje „CO ZROBIONE", + „ROADMAPA", „AUDYTY 4×", „Guard get_default: nadal szczelny"). +- Audyt 4x: `docs/superpowers/2026-06-03-audyty-multihosted-4x.md`. +- Cleanup get_default (plan + reguła binarna): `plans/2026-06-02-get-default-cleanup.md`, + `docs/deweloper/audyt-multihosted-pbn.md`. +- Specy per-obszar (wzorce poprawnego scopingu): write-side + `specs/2026-06-02-per-uczelnia-sloty-design.md`; R1 + `specs/2026-06-03-per-uczelnia-sloty-read-side-design.md`; R2 + `specs/2026-06-03-ewaluacja-liczba-n-per-uczelnia-design.md`; R3a/b + `specs/2026-06-03-r3a-*`, `specs/2026-06-03-r3b-*`; integrator + `specs/2026-06-03-integrator-per-uczelnia-design.md`; D + `specs/2026-06-04-ewaluacja-metryki-per-uczelnia-design.md`. +- Helpery scopingu (wzorzec do naśladowania/sprawdzania): `bpp/util/uczelnia_scope.py`, + `raport_slotow/uczelnia_helper.py`, `ewaluacja_metryki/uczelnia_scope.py`. + +### Definicja „done" tej sesji +Raport, w którym KAŻDE miejsce dotykające uczelni (oba klasy) ma jednoznaczny +status ✅/🟡/🔴 z `plik:linia`, lista 🔴 (luki do naprawy) jest jawna i +priorytetyzowana, a lista 🟡 (odłożone) ma uzasadnienie. Jeśli 🔴 = puste → +potwierdzenie, że pokrycie multi-hosted jest pełne (modulo świadomie odłożona +federacja). Implementację ewentualnych 🔴 ustalamy PO raporcie (osobny wątek +spec→plan→subagent jak poprzednio). From 6a7cec689f9f5d2b3c806157bf666d157807141c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:32:45 +0200 Subject: [PATCH 170/247] =?UTF-8?q?fix(raport=5Fslotow):=20raport=20zerowy?= =?UTF-8?q?=20zaw=C4=99=C5=BCa=20'existent'=20do=20uczelni=20ogl=C4=85daj?= =?UTF-8?q?=C4=85cego=20(audyt=20uczelnia,=20track=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dokończenie połowicznego fixu 29734f833: widok przekazuje uczelnia_dla_odczytu do autorzy_zerowi. Autor z punktami tylko na innej uczelni nie znika z raportu zerowego tej uczelni. No-op single-install. Co-Authored-By: Claude Opus 4.8 --- .../test_raport_slotow_zerowy_per_uczelnia.py | 69 +++++++++++++++++++ src/raport_slotow/views/zerowy.py | 9 +++ 2 files changed, 78 insertions(+) create mode 100644 src/raport_slotow/tests/test_views/test_raport_slotow_zerowy_per_uczelnia.py diff --git a/src/raport_slotow/tests/test_views/test_raport_slotow_zerowy_per_uczelnia.py b/src/raport_slotow/tests/test_views/test_raport_slotow_zerowy_per_uczelnia.py new file mode 100644 index 000000000..ae89bbf40 --- /dev/null +++ b/src/raport_slotow/tests/test_views/test_raport_slotow_zerowy_per_uczelnia.py @@ -0,0 +1,69 @@ +"""Track 2 (audyt uczelnia 2026-06-04): raport słotów zerowy zawęża stronę +'existent' (punkty) do uczelni oglądającego. + +Połowiczny fix ``29734f833`` dodał parametr ``uczelnia`` do +``autorzy_z_punktami``/``autorzy_zerowi`` w ``core.py``, ale widok +``RaportSlotowZerowyWyniki.get_queryset`` go nie przekazywał — autor z punktami +TYLKO na uczelni U2 był błędnie wykluczany z raportu zerowego uczelni U1 +(fałszywy negatyw: ma punkty „gdzieś", więc nie-zerowy, mimo że nie na U1). +""" + +from unittest.mock import MagicMock + +import pytest +from django.contrib.auth.models import AnonymousUser + +from bpp.models import Autor_Dyscyplina +from raport_slotow.forms.zerowy import RaportSlotowZerowyParametryFormularz +from raport_slotow.tests.conftest import _rekord_slotu_maker +from raport_slotow.views.zerowy import RaportSlotowZerowyWyniki + + +@pytest.mark.django_db +def test_zerowy_zaweza_punkty_do_uczelni_ogladajacego( + autor_jan_kowalski, + jednostka, + jednostka_drugiej_uczelni, + dyscyplina1, + wydawnictwo_ciagle_z_autorem, + rok, + rf, +): + uczelnia1 = jednostka.uczelnia + + # strona 'defined' — deklaracja dyscypliny, niezależna od uczelni + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, rok=rok, dyscyplina_naukowa=dyscyplina1 + ) + + # strona 'existent' — punkty TYLKO na uczelni U2 + wydawnictwo_ciagle_z_autorem.rok = rok + wydawnictwo_ciagle_z_autorem.save() + _rekord_slotu_maker( + autor_jan_kowalski, + jednostka_drugiej_uczelni, + dyscyplina1, + wydawnictwo_ciagle_z_autorem, + rok, + ) + + request = rf.get("/") + request._uczelnia = uczelnia1 + request.user = AnonymousUser() + + view = RaportSlotowZerowyWyniki(min_pk=0) + view.request = request + view.form = MagicMock() + view.form.cleaned_data = { + "od_roku": rok, + "do_roku": rok, + "min_pk": 0, + "rodzaj_raportu": ( + RaportSlotowZerowyParametryFormularz.RodzajeRaportu.SUMA_LAT + ), + } + + autor_ids = set(view.get_queryset().values_list("autor_id", flat=True)) + + # Na U1 autor NIE ma punktów → jest zerowy (mimo punktów na U2). + assert autor_jan_kowalski.id in autor_ids diff --git a/src/raport_slotow/views/zerowy.py b/src/raport_slotow/views/zerowy.py index eddd77e1a..e0840c56e 100644 --- a/src/raport_slotow/views/zerowy.py +++ b/src/raport_slotow/views/zerowy.py @@ -17,6 +17,7 @@ from raport_slotow.forms.zerowy import RaportSlotowZerowyParametryFormularz from raport_slotow.models import RaportZerowyEntry from raport_slotow.tables import RaportSlotowZerowyTable +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu from raport_slotow.util import MyExportMixin, create_temporary_table_as @@ -77,10 +78,18 @@ def get_queryset(self): do_roku = self.form.cleaned_data["do_roku"] + # Strona 'existent' (punkty) zawężona do uczelni oglądającego — + # autor z punktami na innej uczelni nie może „znikać" z raportu + # zerowego tej uczelni. Strona 'defined' (deklaracje) pozostaje + # globalna (deklaracja dyscypliny jest niezależna od afiliacji). + request = getattr(self, "request", None) + uczelnia = uczelnia_dla_odczytu(request) if request is not None else None + res = autorzy_zerowi( min_pk=self.form.cleaned_data["min_pk"], od_roku=od_roku, do_roku=do_roku, + uczelnia=uczelnia, ) create_temporary_table_as("raport_slotow_raportzerowyentry", res) with connection.cursor() as cursor: From c1f2ad27544bf2bd71c7c7989ca439d71be57fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:32:56 +0200 Subject: [PATCH 171/247] =?UTF-8?q?fix(oswiadczenia):=20wydruk=20o=C5=9Bwi?= =?UTF-8?q?adcze=C5=84=202022-25=20per-uczelnia=20+=20uczelnia=5Fid=20do?= =?UTF-8?q?=20ZIP-taska=20(audyt=20uczelnia,=20track=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_base_queryset/build_queryset_for_task zawężają jednostka__uczelnia; StartExportTaskView przekazuje uczelnia_id do generate_oswiadczenia_zip (koniec MultipleObjectsReturned w multi-install). Koniec przecieku oświadczeń cross-uczelnia. Co-Authored-By: Claude Opus 4.8 --- src/oswiadczenia/tasks.py | 17 +++++-- src/oswiadczenia/test_per_uczelnia.py | 69 +++++++++++++++++++++++++++ src/oswiadczenia/views.py | 31 +++++++++--- 3 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 src/oswiadczenia/test_per_uczelnia.py diff --git a/src/oswiadczenia/tasks.py b/src/oswiadczenia/tasks.py index 9e39132ce..94a17b78a 100644 --- a/src/oswiadczenia/tasks.py +++ b/src/oswiadczenia/tasks.py @@ -114,8 +114,12 @@ def build_declarations_list(queryset, uczelnia): return declarations -def build_queryset_for_task(task): - """Build filtered queryset for declarations export task.""" +def build_queryset_for_task(task, uczelnia=None): + """Build filtered queryset for declarations export task. + + Zawężony do ``uczelnia`` (``jednostka__uczelnia``) — tło nie może mieszać + oświadczeń autorów różnych uczelni do jednego eksportu. + """ queryset = ( Autorzy.objects.exclude(dyscyplina_naukowa=None) .filter( @@ -125,6 +129,9 @@ def build_queryset_for_task(task): .select_related("autor", "rekord", "dyscyplina_naukowa") ) + if uczelnia is not None: + queryset = queryset.filter(jednostka__uczelnia=uczelnia) + if task.szukaj_autor: queryset = queryset.filter( Q(autor__nazwisko__icontains=task.szukaj_autor) @@ -541,7 +548,9 @@ def generate_oswiadczenia_zip(self, task_id: int, uczelnia_id=None): Args: task_id: ID of OswiadczeniaExportTask record. - uczelnia_id: ID of Uczelnia (defaults to get_default()). + uczelnia_id: ID uczelni oglądającego (przekazane z widoku). Brak → + single-or-fail ``Uczelnia.objects.get()`` (multi-hosted: głośny + fail zamiast zgadywania pierwszej-z-brzegu). Returns: dict with status and task_id. @@ -555,12 +564,12 @@ def generate_oswiadczenia_zip(self, task_id: int, uczelnia_id=None): task.save() try: - queryset = build_queryset_for_task(task) uczelnia = ( Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id else Uczelnia.objects.get() ) + queryset = build_queryset_for_task(task, uczelnia=uczelnia) declarations = build_declarations_list(queryset, uczelnia) task.total_items = len(declarations) diff --git a/src/oswiadczenia/test_per_uczelnia.py b/src/oswiadczenia/test_per_uczelnia.py new file mode 100644 index 000000000..8d49ad3ec --- /dev/null +++ b/src/oswiadczenia/test_per_uczelnia.py @@ -0,0 +1,69 @@ +"""Track 3 (audyt uczelnia 2026-06-04): wydruk oświadczeń 2022-25 zawężony do +uczelni oglądającego. + +``get_base_queryset`` / ``build_queryset_for_task`` budowały qs ``Autorzy`` tylko +po roku/autorze/tytule/dyscyplinie — bez ``jednostka__uczelnia`` → admin uczelni +U1 widział/eksportował oświadczenia autorów uczelni U2 (przeciek cross-uczelnia). +""" + +import pytest +from django.contrib.sites.models import Site +from model_bakery import baker + +from bpp.models import ( + Autor_Dyscyplina, + Jednostka, + Uczelnia, + Wydawnictwo_Ciagle, + Wydzial, +) +from oswiadczenia.views import WydrukOswiadczen2022View + + +@pytest.fixture +def jednostka_drugiej_uczelni(db): + site = baker.make(Site, domain="druga-osw.testserver", name="druga-osw") + uczelnia2 = Uczelnia.objects.create(skrot="DR2", nazwa="Druga uczelnia", site=site) + wydzial = Wydzial.objects.create(uczelnia=uczelnia2, skrot="W2", nazwa="Wydział II") + return Jednostka.objects.create( + nazwa="Jedn. Drugiej Ucz.", skrot="JDU2", wydzial=wydzial, uczelnia=uczelnia2 + ) + + +@pytest.mark.django_db +def test_wydruk_oswiadczen_zaweza_do_uczelni_ogladajacego( + autor_jan_kowalski, + autor_jan_nowak, + jednostka, + jednostka_drugiej_uczelni, + dyscyplina1, + denorms, + typy_odpowiedzialnosci, + charaktery_formalne, + rf, +): + uczelnia1 = jednostka.uczelnia + + # Kowalski → U1, Nowak → U2; obaj z dyscypliną, rok w zakresie 2022-25. + for autor in (autor_jan_kowalski, autor_jan_nowak): + Autor_Dyscyplina.objects.create( + autor=autor, rok=2023, dyscyplina_naukowa=dyscyplina1 + ) + wc1 = baker.make(Wydawnictwo_Ciagle, rok=2023, punkty_kbn=5) + wc1.dodaj_autora(autor_jan_kowalski, jednostka, dyscyplina_naukowa=dyscyplina1) + wc2 = baker.make(Wydawnictwo_Ciagle, rok=2023, punkty_kbn=5) + wc2.dodaj_autora( + autor_jan_nowak, jednostka_drugiej_uczelni, dyscyplina_naukowa=dyscyplina1 + ) + denorms.flush() + + request = rf.get("/") + request._uczelnia = uczelnia1 + + view = WydrukOswiadczen2022View() + view.request = request + + autor_ids = set(view.get_queryset().values_list("autor_id", flat=True)) + + assert autor_jan_kowalski.id in autor_ids # U1 — widoczny + assert autor_jan_nowak.id not in autor_ids # U2 — NIE przecieka diff --git a/src/oswiadczenia/views.py b/src/oswiadczenia/views.py index f5ba472b3..ed36277d2 100644 --- a/src/oswiadczenia/views.py +++ b/src/oswiadczenia/views.py @@ -86,14 +86,21 @@ def clean_przypieta(self): return self.cleaned_data.get("przypieta") or "" -def get_base_queryset(): - """Return base queryset for declarations (Autorzy with discipline assigned).""" - return ( +def get_base_queryset(uczelnia=None): + """Return base queryset for declarations (Autorzy with discipline assigned). + + Zawężony do uczelni oglądającego (``jednostka__uczelnia``) — oświadczenia + autorów innej uczelni nie mogą przeciekać na liście/eksporcie tej uczelni. + """ + qs = ( Autorzy.objects.exclude(dyscyplina_naukowa=None) .filter(rekord__rok__gte=DEFAULT_ROK_OD, rekord__rok__lte=DEFAULT_ROK_DO) .select_related("autor", "rekord", "dyscyplina_naukowa") .order_by("rekord__rok", "autor__nazwisko", "autor__imiona") ) + if uczelnia is not None: + qs = qs.filter(jednostka__uczelnia=uczelnia) + return qs def apply_filters( @@ -185,11 +192,13 @@ def get_filter_params(self): return rok_od, rok_do, szukaj_autor, szukaj_tytul, dyscyplina, przypieta def get_queryset(self): + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + rok_od, rok_do, szukaj_autor, szukaj_tytul, dyscyplina, przypieta = ( self.get_filter_params() ) return apply_filters( - get_base_queryset(), + get_base_queryset(uczelnia=uczelnia_dla_odczytu(self.request)), rok_od, rok_do, szukaj_autor, @@ -373,11 +382,13 @@ def get_filter_params(self): return rok_od, rok_do, szukaj_autor, szukaj_tytul, dyscyplina, przypieta def get(self, request): + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + rok_od, rok_do, szukaj_autor, szukaj_tytul, dyscyplina, przypieta = ( self.get_filter_params() ) - queryset = get_base_queryset() + queryset = get_base_queryset(uczelnia=uczelnia_dla_odczytu(request)) queryset = apply_filters( queryset, rok_od, rok_do, szukaj_autor, szukaj_tytul, dyscyplina, przypieta ) @@ -508,8 +519,14 @@ def post(self, request): limit=limit, ) - # Start Celery task - generate_oswiadczenia_zip.delay(task.pk) + # Start Celery task — przekaż uczelnię oglądającego (multi-hosted), + # zadanie NIE robi fallbacku do pierwszej-z-brzegu (MultipleObjects). + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + uczelnia = uczelnia_dla_odczytu(request) + generate_oswiadczenia_zip.delay( + task.pk, uczelnia_id=uczelnia.pk if uczelnia else None + ) return redirect("oswiadczenia:task-status", task_id=task.pk) From 8b6ea82dac3d09c2c964e4f8b682949ec2b0107c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:33:05 +0200 Subject: [PATCH 172/247] =?UTF-8?q?fix(zglos=5Fpublikacje):=20wizard=20prz?= =?UTF-8?q?ekazuje=20uczelni=C4=99=20do=20formy/instancji=20(audyt=20uczel?= =?UTF-8?q?nia,=20track=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_form_kwargs podaje uczelnię z requestu; DaneForm ustawia instance._uczelnia. Koniec MultipleObjectsReturned w walidacji opłat i konstrukcji formy przy >1 uczelni. Co-Authored-By: Claude Opus 4.8 --- src/zglos_publikacje/forms.py | 5 +++ .../tests/test_per_uczelnia.py | 34 +++++++++++++++++++ src/zglos_publikacje/views.py | 3 ++ 3 files changed, 42 insertions(+) create mode 100644 src/zglos_publikacje/tests/test_per_uczelnia.py diff --git a/src/zglos_publikacje/forms.py b/src/zglos_publikacje/forms.py index 6ea2e08fa..31bbc2e46 100644 --- a/src/zglos_publikacje/forms.py +++ b/src/zglos_publikacje/forms.py @@ -315,6 +315,11 @@ def __init__(self, *args, rodzaj=None, forma_dostepu=None, **kw): if uczelnia is None: uczelnia = Uczelnia.objects.get() + # Przepisz uczelnię oglądającego na instancję, żeby walidacja opłat + # w ``Zgloszenie_Publikacji.clean`` użyła JEJ, a nie zgadywała przez + # ``Uczelnia.objects.get()`` (crash przy >1 uczelni, multi-hosted). + self.instance._uczelnia = uczelnia + if ( uczelnia is not None and not uczelnia.pytaj_o_zgode_na_publikacje_pelnego_tekstu diff --git a/src/zglos_publikacje/tests/test_per_uczelnia.py b/src/zglos_publikacje/tests/test_per_uczelnia.py new file mode 100644 index 000000000..19c634d92 --- /dev/null +++ b/src/zglos_publikacje/tests/test_per_uczelnia.py @@ -0,0 +1,34 @@ +"""Track 3 (audyt uczelnia 2026-06-04): wizard zgłaszania publikacji nie może +crashować w instalacji multi-hosted. + +``Zgloszenie_Publikacji_DaneForm`` przy braku ``uczelnia`` robiło +``Uczelnia.objects.get()`` (→ ``MultipleObjectsReturned`` przy >1 uczelni), +a ``Zgloszenie_Publikacji.clean`` (walidacja opłat) używała ``self._uczelnia``, +które NIGDY nie było ustawiane → ta sama awaria. Forma musi przepisać uczelnię +oglądającego na ``instance._uczelnia``. +""" + +import pytest +from django.contrib.sites.models import Site +from model_bakery import baker + +from bpp.models import Uczelnia +from zglos_publikacje.forms import Zgloszenie_Publikacji_DaneForm + + +@pytest.fixture +def dwie_uczelnie(db, uczelnia): + site = baker.make(Site, domain="druga-zgl.testserver", name="druga-zgl") + uczelnia2 = Uczelnia.objects.create(skrot="DR3", nazwa="Druga uczelnia", site=site) + return uczelnia, uczelnia2 + + +@pytest.mark.django_db +def test_daneform_przepisuje_uczelnie_na_instancje(dwie_uczelnie): + """Forma ustawia ``instance._uczelnia`` na przekazaną uczelnię — żeby + ``model.clean`` nie zgadywał (i nie crashował) w multi-hosted.""" + uczelnia1, _uczelnia2 = dwie_uczelnie + + form = Zgloszenie_Publikacji_DaneForm(uczelnia=uczelnia1) + + assert form.instance._uczelnia == uczelnia1 diff --git a/src/zglos_publikacje/views.py b/src/zglos_publikacje/views.py index 13c5288e6..ca1bd5b4c 100644 --- a/src/zglos_publikacje/views.py +++ b/src/zglos_publikacje/views.py @@ -242,6 +242,9 @@ def get_form_kwargs(self, step=None): step1 = self.get_cleaned_data_for_step("1") or {} kwargs["rodzaj"] = step0.get("rodzaj") kwargs["forma_dostepu"] = step1.get("forma_dostepu") + # Multi-hosted: forma musi dostać uczelnię z requestu, inaczej + # spada do Uczelnia.objects.get() (crash przy >1 uczelni). + kwargs["uczelnia"] = Uczelnia.objects.get_for_request(self.request) return kwargs def get_form_instance(self, step): From 35c3be3fc3ca04dd03f51cecca22b9d839514eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:33:20 +0200 Subject: [PATCH 173/247] =?UTF-8?q?chore(bpp):=20usu=C5=84=20nieu=C5=BCywa?= =?UTF-8?q?n=C4=85=20komend=C4=99=20wyczysc=5Fbaze=20(audyt=20uczelnia,=20?= =?UTF-8?q?track=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foot-gun multi-hosted: rozwiązywała uczelnię, ale kasowała globalnie. Komenda nieużywana (brak Makefile/CI/skryptu/kodu/testu; jedynie wzmianka w docs). Co-Authored-By: Claude Opus 4.8 --- src/bpp/management/commands/wyczysc_baze.py | 136 -------------------- 1 file changed, 136 deletions(-) delete mode 100644 src/bpp/management/commands/wyczysc_baze.py diff --git a/src/bpp/management/commands/wyczysc_baze.py b/src/bpp/management/commands/wyczysc_baze.py deleted file mode 100644 index 68d3bcd08..000000000 --- a/src/bpp/management/commands/wyczysc_baze.py +++ /dev/null @@ -1,136 +0,0 @@ -import random - -from coverage.html import os -from django.conf import settings -from django.core.management import BaseCommand -from django.db import transaction - -from bpp.models import ( - Autor, - Konferencja, - Patent, - Patent_Autor, - Praca_Doktorska, - Praca_Habilitacyjna, - Uczelnia, - Wydawca, - Wydawnictwo_Ciagle, - Wydawnictwo_Ciagle_Autor, - Wydawnictwo_Zwarte, - Wydawnictwo_Zwarte_Autor, - Zrodlo, -) -from bpp.util import pbar -from pbn_api.models import ( - Conference, - Discipline, - DisciplineGroup, - Institution, - Journal, - Language, - OswiadczenieInstytucji, - Publication, - PublikacjaInstytucji, - Publisher, - Scientist, - SentData, -) - - -class Command(BaseCommand): - help = ( - "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( - "--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() - - challenge = "".join(random.sample("abcdefghijklmnopqrstuvwxzy!@#$^^&", 5)) - 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("=================") - - if not tylko_publikacje: - print( - f"Aby skasować wszystkich autorów, publikacje i dane z PBN, wpisz znaki '{challenge}' " - f"lub naciśnij CTRL+C aby wyjść. " - ) - else: - print( - f"Aby skasować tylko publikacje po stronie BPP -- BEZ autorow w BPP, bez Źródeł w BPP, bez danych z " - f"PBN, wpisz znaki '{challenge}' lub naciśnij CTRL+C aby wyjść. " - ) - - reply = input("> ") - if challenge != reply: - print("Wychodzę z programu") - return - - klasy_do_skasowania = [ - # BPP - Wydawnictwo_Ciagle_Autor, - Wydawnictwo_Zwarte_Autor, - Patent_Autor, - Wydawnictwo_Ciagle, - Wydawnictwo_Zwarte, - Patent, - Praca_Doktorska, - Praca_Habilitacyjna, - ] - - if not tylko_publikacje: - klasy_do_skasowania += [ - # BPP - Zrodlo, - Wydawca, - Konferencja, - Autor, - # PBN - Journal, - Scientist, - Conference, - Discipline, - DisciplineGroup, - Institution, - Language, - OswiadczenieInstytucji, - Publication, - PublikacjaInstytucji, - Publisher, - Scientist, - SentData, - ] - - for klass in pbar( - klasy_do_skasowania, - label="Kasuję dane z bazy BPP:", - ): - klass.objects.all().delete() From 90a0f5828b8c113fb4aca833b2f7959e42972e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:33:32 +0200 Subject: [PATCH 174/247] docs(pbn_api): komentarze per-uczelnia na modelach lustra PBN (audyt uczelnia, track 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OsobaZInstytucji/PublikacjaInstytucji(_V2)/OswiadczenieInstytucji: FK uczelnia nullable świadomie — wiersz wiąże się z instytucją przez institutionId (== uczelnia.pbn_uid). Bez zmian schematu/logiki. Co-Authored-By: Claude Opus 4.8 --- src/pbn_api/models/osoba_z_instytucji.py | 8 ++++++++ src/pbn_api/models/oswiadczenie_instytucji.py | 6 ++++++ src/pbn_api/models/publikacja_instytucji.py | 7 +++++++ 3 files changed, 21 insertions(+) diff --git a/src/pbn_api/models/osoba_z_instytucji.py b/src/pbn_api/models/osoba_z_instytucji.py index 13d3b6475..c8740a9ed 100644 --- a/src/pbn_api/models/osoba_z_instytucji.py +++ b/src/pbn_api/models/osoba_z_instytucji.py @@ -8,6 +8,14 @@ class OsobaZInstytucji(models.Model): firstName = models.TextField() lastName = models.TextField() institutionId = models.ForeignKey("pbn_api.Institution", on_delete=models.PROTECT) + # Multi-hosted (audyt uczelnia 2026-06-04): to lustro danych PBN. FK + # ``uczelnia`` jest nullable ŚWIADOMIE — wiersz i tak jest jednoznacznie + # związany z instytucją PBN przez ``institutionId`` (odpowiednik uczelni + # w PBN, == ``uczelnia.pbn_uid``), więc brak twardego tagu uczelni to + # brak wygody filtrowania, NIE korupcja danych. Pełne per-uczelnia + # tagowanie write-side odłożone (integrator nie wpisuje tu uczelni). + # UWAGA: ``personId`` jest OneToOne — w multi-hosted ostatnia uczelnia + # nadpisuje wiersz (konflikt strukturalny do rozważenia osobno). uczelnia = models.ForeignKey( "bpp.Uczelnia", on_delete=models.CASCADE, diff --git a/src/pbn_api/models/oswiadczenie_instytucji.py b/src/pbn_api/models/oswiadczenie_instytucji.py index 3f66196e9..fe2632e70 100644 --- a/src/pbn_api/models/oswiadczenie_instytucji.py +++ b/src/pbn_api/models/oswiadczenie_instytucji.py @@ -28,6 +28,12 @@ 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) + # Multi-hosted (audyt uczelnia 2026-06-04): lustro danych PBN. FK nullable + # ŚWIADOMIE — wiersz wiąże się z instytucją przez ``institutionId`` + # (== ``uczelnia.pbn_uid``), więc brak tagu uczelni to brak wygody + # filtrowania, nie korupcja. Write-side tagowanie odłożone. UWAGA: + # override ``delete()`` kasuje ``SentData`` po ``publicationId`` — po + # tagowaniu SentData per-uczelnia (Track 4) trzeba tu zawęzić też uczelnię. uczelnia = models.ForeignKey( "bpp.Uczelnia", on_delete=models.CASCADE, diff --git a/src/pbn_api/models/publikacja_instytucji.py b/src/pbn_api/models/publikacja_instytucji.py index 44ccd61ee..f789b8b99 100644 --- a/src/pbn_api/models/publikacja_instytucji.py +++ b/src/pbn_api/models/publikacja_instytucji.py @@ -6,6 +6,10 @@ 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) + # Multi-hosted (audyt uczelnia 2026-06-04): lustro danych PBN. FK nullable + # ŚWIADOMIE — wiersz wiąże się z instytucją przez ``institutionId`` + # (== ``uczelnia.pbn_uid``), więc brak tagu uczelni to brak wygody + # filtrowania, nie korupcja. Write-side tagowanie odłożone. uczelnia = models.ForeignKey( "bpp.Uczelnia", on_delete=models.CASCADE, @@ -30,6 +34,9 @@ class PublikacjaInstytucji_V2(models.Model): o oświadczeniach instytucji. """ + # Multi-hosted (audyt uczelnia 2026-06-04): lustro danych PBN, FK nullable + # świadomie — wiązanie z instytucją przez ``objectId``/PBN. Patrz + # ``PublikacjaInstytucji.uczelnia``. uczelnia = models.ForeignKey( "bpp.Uczelnia", on_delete=models.CASCADE, From 4cd8a8afb2ca81ffcfa877520f6db90322c9cb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:33:43 +0200 Subject: [PATCH 175/247] fix(bpp): profil pokazuje metryki tylko z uczelni z requestu (audyt uczelnia, track 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfilUzytkownikaView scope_metryki(uczelnia_dla_odczytu(request)) — autor afiliowany do wielu uczelni nie widzi metryk obcej uczelni. No-op single-install. Co-Authored-By: Claude Opus 4.8 --- .../test_views/test_profile_per_uczelnia.py | 55 +++++++++++++++++++ src/bpp/views/profile.py | 10 +++- 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/bpp/tests/test_views/test_profile_per_uczelnia.py diff --git a/src/bpp/tests/test_views/test_profile_per_uczelnia.py b/src/bpp/tests/test_views/test_profile_per_uczelnia.py new file mode 100644 index 000000000..6eeab4d49 --- /dev/null +++ b/src/bpp/tests/test_views/test_profile_per_uczelnia.py @@ -0,0 +1,55 @@ +"""Track 1 (audyt uczelnia 2026-06-04): profil użytkownika pokazuje metryki +TYLKO z uczelni oglądającego (tej z requestu). + +``ProfilUzytkownikaView`` robił ``MetrykaAutora.objects.filter(autor=autor)`` +bez zawężenia — autor afiliowany do >1 uczelni widział na profilu metryki ze +WSZYSTKICH uczelni (przeoczenie wątku D, który objął tylko ``ewaluacja_metryki/ +views/``, nie profil w ``bpp/views/``). +""" + +import pytest +from django.contrib.sites.models import Site +from model_bakery import baker + +from bpp.models import Uczelnia +from bpp.views.profile import ProfilUzytkownikaView + + +@pytest.fixture +def druga_uczelnia_profile(db): + site = baker.make(Site, domain="druga-prof.testserver", name="druga-prof") + return Uczelnia.objects.create(skrot="DRP", nazwa="Druga", site=site) + + +@pytest.mark.django_db +def test_profil_metryki_zaweza_do_uczelni_ogladajacego( + autor_jan_kowalski, uczelnia, druga_uczelnia_profile, dyscyplina1, rf +): + from ewaluacja_metryki.models import MetrykaAutora + + baker.make( + MetrykaAutora, + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + uczelnia=uczelnia, + ) + baker.make( + MetrykaAutora, + autor=autor_jan_kowalski, + dyscyplina_naukowa=dyscyplina1, + uczelnia=druga_uczelnia_profile, + ) + + user = baker.make("bpp.BppUser", autor=autor_jan_kowalski) + request = rf.get("/") + request._uczelnia = uczelnia + request.user = user + + view = ProfilUzytkownikaView() + view.request = request + view.kwargs = {} + + ctx = view.get_context_data() + uczelnia_ids = set(ctx["metryki"].values_list("uczelnia_id", flat=True)) + + assert uczelnia_ids == {uczelnia.pk} diff --git a/src/bpp/views/profile.py b/src/bpp/views/profile.py index 85041d113..8a16e5125 100644 --- a/src/bpp/views/profile.py +++ b/src/bpp/views/profile.py @@ -17,9 +17,15 @@ def get_context_data(self, **kwargs): if autor: from ewaluacja_metryki.models import MetrykaAutora + from ewaluacja_metryki.uczelnia_scope import scope_metryki + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu - context["metryki"] = MetrykaAutora.objects.filter( - autor=autor + # Metryki pokazujemy z JEDNEJ uczelni — tej z requestu. Autor + # afiliowany do wielu uczelni nie może widzieć tu metryk obcej + # uczelni (no-op przy single-install). + context["metryki"] = scope_metryki( + MetrykaAutora.objects.filter(autor=autor), + uczelnia_dla_odczytu(self.request), ).select_related("dyscyplina_naukowa") return context From 21b0e279a4be41de250468e1bd2b0f69e7ddd4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:33:54 +0200 Subject: [PATCH 176/247] =?UTF-8?q?fix(bpp):=20LataAutocomplete=20zaw?= =?UTF-8?q?=C4=99=C5=BCony=20do=20uczelni=20ogl=C4=85daj=C4=85cego=20(audy?= =?UTF-8?q?t=20uczelnia,=20track=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publiczny picker lat przez scope_rekord_do_uczelni (jak LataView w R3a) — koniec podpowiadania lat z rekordów innych uczelni. No-op single-install. Co-Authored-By: Claude Opus 4.8 --- .../test_lata_autocomplete_per_uczelnia.py | 98 +++++++++++++++++++ src/bpp/views/autocomplete/simple.py | 20 +++- 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 src/bpp/tests/test_views/test_lata_autocomplete_per_uczelnia.py diff --git a/src/bpp/tests/test_views/test_lata_autocomplete_per_uczelnia.py b/src/bpp/tests/test_views/test_lata_autocomplete_per_uczelnia.py new file mode 100644 index 000000000..7441bf7c5 --- /dev/null +++ b/src/bpp/tests/test_views/test_lata_autocomplete_per_uczelnia.py @@ -0,0 +1,98 @@ +"""Track 1 (audyt uczelnia 2026-06-04): publiczny ``LataAutocomplete`` zawęża +lata do uczelni oglądającego (jak ``LataView`` w R3a). + +Było ``Rekord.objects.all()`` globalnie — picker lat na domenie U1 podpowiadał +lata z rekordów wszystkich uczelni. +""" + +import pytest +from django.contrib.sites.models import Site +from model_bakery import baker + +from bpp.models import ( + Autor_Dyscyplina, + Jednostka, + Uczelnia, + Wydawnictwo_Ciagle, + Wydzial, +) +from bpp.views.autocomplete.simple import LataAutocomplete + + +@pytest.fixture +def jednostka_drugiej_uczelni(db): + site = baker.make(Site, domain="druga-lata.testserver", name="druga-lata") + uczelnia2 = Uczelnia.objects.create(skrot="DRL", nazwa="Druga", site=site) + wydzial = Wydzial.objects.create(uczelnia=uczelnia2, skrot="W2", nazwa="Wydz II") + return Jednostka.objects.create( + nazwa="Jedn II", skrot="JDL", wydzial=wydzial, uczelnia=uczelnia2 + ) + + +@pytest.mark.django_db +def test_lata_autocomplete_zaweza_do_uczelni( + autor_jan_kowalski, + autor_jan_nowak, + jednostka, + jednostka_drugiej_uczelni, + dyscyplina1, + denorms, + typy_odpowiedzialnosci, + charaktery_formalne, + rf, +): + uczelnia1 = jednostka.uczelnia + + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, rok=2020, dyscyplina_naukowa=dyscyplina1 + ) + Autor_Dyscyplina.objects.create( + autor=autor_jan_nowak, rok=2021, dyscyplina_naukowa=dyscyplina1 + ) + wc1 = baker.make(Wydawnictwo_Ciagle, rok=2020, punkty_kbn=5) + wc1.dodaj_autora(autor_jan_kowalski, jednostka, dyscyplina_naukowa=dyscyplina1) + wc2 = baker.make(Wydawnictwo_Ciagle, rok=2021, punkty_kbn=5) + wc2.dodaj_autora( + autor_jan_nowak, jednostka_drugiej_uczelni, dyscyplina_naukowa=dyscyplina1 + ) + denorms.flush() + + request = rf.get("/") + request._uczelnia = uczelnia1 + + view = LataAutocomplete() + view.q = "" + view.request = request + + lata = set(view.get_queryset()) + + assert 2020 in lata # U1 + assert 2021 not in lata # U2 — nie przecieka + + +@pytest.mark.django_db +def test_lata_autocomplete_single_install_bez_zmian( + autor_jan_kowalski, + jednostka, + dyscyplina1, + denorms, + typy_odpowiedzialnosci, + charaktery_formalne, + rf, +): + """Invariant: przy jednej uczelni picker działa jak dawniej.""" + Autor_Dyscyplina.objects.create( + autor=autor_jan_kowalski, rok=2020, dyscyplina_naukowa=dyscyplina1 + ) + wc = baker.make(Wydawnictwo_Ciagle, rok=2020, punkty_kbn=5) + wc.dodaj_autora(autor_jan_kowalski, jednostka, dyscyplina_naukowa=dyscyplina1) + denorms.flush() + + request = rf.get("/") + request._uczelnia = jednostka.uczelnia + + view = LataAutocomplete() + view.q = "" + view.request = request + + assert 2020 in set(view.get_queryset()) diff --git a/src/bpp/views/autocomplete/simple.py b/src/bpp/views/autocomplete/simple.py index 87a30bac4..276c8be68 100644 --- a/src/bpp/views/autocomplete/simple.py +++ b/src/bpp/views/autocomplete/simple.py @@ -73,14 +73,24 @@ def get_queryset(self): class LataAutocomplete(SanitizedAutocompleteMixin, autocomplete.Select2QuerySetView): - """Autocomplete for years (lata) from Rekord cache.""" + """Autocomplete for years (lata) from Rekord cache. - qset = ( - Rekord.objects.all().values_list("rok", flat=True).distinct().order_by("-rok") - ) + Zawężony do uczelni oglądającego (jak ``LataView`` w R3a) — picker lat na + domenie jednej uczelni nie podpowiada lat z rekordów innych uczelni. + """ def get_queryset(self): - qs = self.qset + from bpp.util.uczelnia_scope import scope_rekord_do_uczelni + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + + qs = ( + scope_rekord_do_uczelni( + Rekord.objects.all(), uczelnia_dla_odczytu(self.request) + ) + .values_list("rok", flat=True) + .distinct() + .order_by("-rok") + ) if self.q: qs = qs.filter(rok=self.q) return qs From 43beb74517a8376e950a2f7ca737a3071dc04967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:34:08 +0200 Subject: [PATCH 177/247] fix(bpp): publiczny global-search nawigacyjny per-uczelnia (audyt uczelnia, track 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GlobalNavigationAutocomplete zawęża jednostki (FK uczelnia), autorów (reguła R3b: obecnie/w przeszłości), rekordy (scope_rekord_do_uczelni); Zrodlo globalne (słownik). Funkcje globalne_wyszukiwanie_* dostają opcjonalny uczelnia= (admin nie podaje). Refaktor get_queryset (C901). No-op single-install. Co-Authored-By: Claude Opus 4.8 --- .../test_global_nav_per_uczelnia.py | 49 ++++++++++ src/bpp/views/autocomplete/navigation.py | 94 ++++++++++--------- src/bpp/views/autocomplete/search_services.py | 58 +++++++++--- 3 files changed, 144 insertions(+), 57 deletions(-) create mode 100644 src/bpp/tests/test_views/test_global_nav_per_uczelnia.py diff --git a/src/bpp/tests/test_views/test_global_nav_per_uczelnia.py b/src/bpp/tests/test_views/test_global_nav_per_uczelnia.py new file mode 100644 index 000000000..5ff0d359b --- /dev/null +++ b/src/bpp/tests/test_views/test_global_nav_per_uczelnia.py @@ -0,0 +1,49 @@ +"""Track 1 (audyt uczelnia 2026-06-04): publiczny ``GlobalNavigationAutocomplete`` +zawęża wyniki (jednostki/autorzy/rekordy) do uczelni oglądającego. + +Globalna wyszukiwarka nawigacyjna szukała jednostek/autorów/rekordów globalnie +(tylko ``ukryte_statusy``) — R3b objął dedykowane pickery, ten search-box +pominął. +""" + +import pytest +from django.contrib.auth.models import AnonymousUser +from django.contrib.sites.models import Site +from model_bakery import baker + +from bpp.models import Jednostka, Uczelnia, Wydzial +from bpp.views.autocomplete.navigation import GlobalNavigationAutocomplete + + +@pytest.fixture +def jednostka_drugiej_uczelni(db): + site = baker.make(Site, domain="druga-nav.testserver", name="druga-nav") + uczelnia2 = Uczelnia.objects.create(skrot="DRN", nazwa="Druga", site=site) + wydzial = Wydzial.objects.create(uczelnia=uczelnia2, skrot="W2", nazwa="Wydz II") + return Jednostka.objects.create( + nazwa="Instytut Testowy Beta", skrot="JDN", wydzial=wydzial, uczelnia=uczelnia2 + ) + + +@pytest.mark.django_db +def test_global_nav_zaweza_jednostki_do_uczelni( + jednostka, jednostka_drugiej_uczelni, denorms, rf +): + j1 = jednostka + j1.nazwa = "Instytut Testowy Alfa" + j1.save() + denorms.flush() + + request = rf.get("/?q=Instytut") + request._uczelnia = j1.uczelnia + request.user = AnonymousUser() + + view = GlobalNavigationAutocomplete() + view.request = request + view.q = "Instytut" + + jednostki = [o for o in view.get_queryset() if isinstance(o, Jednostka)] + uczelnie = {j.uczelnia_id for j in jednostki} + + assert j1.pk in {j.pk for j in jednostki} # U1 widoczny + assert jednostka_drugiej_uczelni.uczelnia_id not in uczelnie # U2 nie przecieka diff --git a/src/bpp/views/autocomplete/navigation.py b/src/bpp/views/autocomplete/navigation.py index 6640b07b5..87db29e57 100644 --- a/src/bpp/views/autocomplete/navigation.py +++ b/src/bpp/views/autocomplete/navigation.py @@ -127,64 +127,72 @@ def get_results(self, context): for model, results in groups.items() ] - def get_queryset(self): - if not hasattr(self, "q"): - return [] - - if not self.q: - return [] - - querysets = [] - globalne_wyszukiwanie_jednostki(querysets, self.q) + def _uczelnia_scope(self): + """Uczelnia oglądającego do zawężenia global-searcha. - globalne_wyszukiwanie_autora(querysets, self.q) + ``None`` (brak zawężenia) gdy brak requestu albo single-install + (guard ``tylko_jedna_uczelnia`` — wyniki/wydajność jak dawniej). + """ + from bpp.util.uczelnia_scope import tylko_jedna_uczelnia - globalne_wyszukiwanie_zrodla(querysets, self.q) + if not hasattr(self, "request") or tylko_jedna_uczelnia(): + return None + return Uczelnia.objects.get_for_request(self.request) - # Rekord + def _rekord_querysets(self, uczelnia): + """Querysety Rekordów do global-searcha, zawężone do uczelni.""" + from bpp.util.uczelnia_scope import scope_rekord_do_uczelni - rekord_qset_ftx = Rekord.objects.fulltext_filter(self.q) + ftx = scope_rekord_do_uczelni(Rekord.objects.fulltext_filter(self.q), uczelnia) - rekord_qset_doi = Rekord.objects.filter(doi__iexact=self.q) - rekord_qset_isbn = Rekord.objects.filter(isbn__iexact=self.q) - rekord_qset_pbn = None + qry = Q(pk__in=Rekord.objects.filter(doi__iexact=self.q).values_list("pk")) + qry |= Q(pk__in=Rekord.objects.filter(isbn__iexact=self.q)) if jest_pbn_uid(self.q): - rekord_qset_pbn = Rekord.objects.filter(pbn_uid_id=self.q) - - qry = Q(pk__in=rekord_qset_doi.values_list("pk")) - qry |= Q(pk__in=rekord_qset_isbn) - if rekord_qset_pbn: - qry |= Q(pk__in=rekord_qset_pbn.values_list("pk")) - - rekord_qset = Rekord.objects.filter(qry).only("tytul_oryginalny") + qry |= Q(pk__in=Rekord.objects.filter(pbn_uid_id=self.q).values_list("pk")) + glowny = scope_rekord_do_uczelni( + Rekord.objects.filter(qry).only("tytul_oryginalny"), uczelnia + ) if hasattr(self, "request") and self.request.user.is_anonymous: - uczelnia = Uczelnia.objects.get_for_request(self.request) - if uczelnia is not None: - rekord_qset_ftx = rekord_qset_ftx.exclude( - status_korekty_id__in=uczelnia.ukryte_statusy("podglad") - ) + uczelnia_status = Uczelnia.objects.get_for_request(self.request) + if uczelnia_status is not None: + ukryte = uczelnia_status.ukryte_statusy("podglad") + ftx = ftx.exclude(status_korekty_id__in=ukryte) + glowny = glowny.exclude(status_korekty_id__in=ukryte) - rekord_qset = rekord_qset.exclude( - status_korekty_id__in=uczelnia.ukryte_statusy("podglad") - ) - querysets.append(rekord_qset_ftx) - querysets.append(rekord_qset) + rekordy = [ftx, glowny] - this_is_an_id = False try: this_is_an_id = int(self.q) except (TypeError, ValueError): - pass - - if this_is_an_id: - querysets.append( - Rekord.objects.annotate( - _object_id=RawSQL("(id)[2]", [], output_field=IntegerField()) + this_is_an_id = None + if this_is_an_id is not None: + rekordy.append( + scope_rekord_do_uczelni( + Rekord.objects.annotate( + _object_id=RawSQL("(id)[2]", [], output_field=IntegerField()) + ) + .filter(_object_id=this_is_an_id) + .only("tytul_oryginalny"), + uczelnia, ) - .filter(_object_id=this_is_an_id) - .only("tytul_oryginalny") ) + return rekordy + + def get_queryset(self): + if not hasattr(self, "q") or not self.q: + return [] + + # Multi-hosted: publiczny global-search zawężamy do uczelni oglądającego + # (jednostki/autorzy/rekordy). Zrodlo nie ma FK uczelnia (słownik + # współdzielony). No-op przy single-install (uczelnia → None). + uczelnia = self._uczelnia_scope() + + querysets = [] + globalne_wyszukiwanie_jednostki(querysets, self.q, uczelnia=uczelnia) + globalne_wyszukiwanie_autora(querysets, self.q, uczelnia=uczelnia) + globalne_wyszukiwanie_zrodla(querysets, self.q) + querysets.extend(self._rekord_querysets(uczelnia)) ret = QuerySetSequence(*querysets) return self.mixup_querysets(ret) diff --git a/src/bpp/views/autocomplete/search_services.py b/src/bpp/views/autocomplete/search_services.py index d7b06a7b4..e6feeddf1 100644 --- a/src/bpp/views/autocomplete/search_services.py +++ b/src/bpp/views/autocomplete/search_services.py @@ -1,5 +1,6 @@ """Global search functions for autocomplete views.""" +from django.db.models import Q from django.db.models.aggregates import Count from bpp import const @@ -39,34 +40,63 @@ def jest_pbn_uid(s): return jest_czyms(s, const.PBN_UID_LEN) -def globalne_wyszukiwanie_autora(querysets, q): - """Add author search querysets.""" +def globalne_wyszukiwanie_autora(querysets, q, uczelnia=None): + """Add author search querysets. + + Gdy podano ``uczelnia`` (multi-hosted, publiczny global-search), zawęża do + autorów związanych z uczelnią obecnie LUB w przeszłości (reguła R3b z + ``PublicAutorAutocomplete``). ``uczelnia=None`` → globalnie (admin search). + """ + + def _scope(qs): + if uczelnia is None: + return qs + return qs.filter( + Q(aktualna_jednostka__uczelnia=uczelnia) + | Q(autor_jednostka__jednostka__uczelnia=uczelnia) + ).distinct() + if jest_orcid(q): querysets.append( - Autor.objects.filter(orcid__icontains=q) - .only(*AUTOR_ONLY) - .select_related(*AUTOR_SELECT_RELATED) + _scope( + Autor.objects.filter(orcid__icontains=q) + .only(*AUTOR_ONLY) + .select_related(*AUTOR_SELECT_RELATED) + ) ) if jest_pbn_uid(q): querysets.append( - Autor.objects.filter(pbn_uid_id=q).only(*AUTOR_ONLY).select_related("tytul") + _scope( + Autor.objects.filter(pbn_uid_id=q) + .only(*AUTOR_ONLY) + .select_related("tytul") + ) ) querysets.append( - Autor.objects.fulltext_filter(q) - .annotate(Count("wydawnictwo_ciagle")) - .only(*AUTOR_ONLY) - .select_related(*AUTOR_SELECT_RELATED) - .order_by("-search__rank", "-wydawnictwo_ciagle__count") + _scope( + Autor.objects.fulltext_filter(q) + .annotate(Count("wydawnictwo_ciagle")) + .only(*AUTOR_ONLY) + .select_related(*AUTOR_SELECT_RELATED) + .order_by("-search__rank", "-wydawnictwo_ciagle__count") + ) ) -def globalne_wyszukiwanie_jednostki(querysets, s): - """Add unit search querysets.""" +def globalne_wyszukiwanie_jednostki(querysets, s, uczelnia=None): + """Add unit search querysets. + + Gdy podano ``uczelnia`` (multi-hosted, publiczny global-search), zawęża + jednostki po FK ``uczelnia``. ``None`` → globalnie (admin search). + """ def _fun(qry): - return qry.only("pk", "nazwa", "wydzial__skrot").select_related("wydzial") + qry = qry.only("pk", "nazwa", "wydzial__skrot").select_related("wydzial") + if uczelnia is not None: + qry = qry.filter(uczelnia=uczelnia) + return qry querysets.append(_fun(Jednostka.objects.fulltext_filter(s))) From e054a203560a87fdfc3cb6bedd828d4d4ea13680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:34:24 +0200 Subject: [PATCH 178/247] fix(bpp): tabela punktacji na stronie rekordu per-uczelnia (audyt uczelnia, track 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PracaViewMixin ustawia rekord._uczelnia_ogladajacego; Rekord.punktacja_autora/dyscypliny/ma_punktacje_sloty zawężają (CPA jednostka__uczelnia, CPD uczelnia). Koniec przecieku slotów/punktów innych uczelni na publicznej stronie rekordu. Brak atrybutu (admin) → globalnie; no-op single-install. Co-Authored-By: Claude Opus 4.8 --- src/bpp/models/cache/rekord.py | 36 +++++-- .../test_rekord_punktacja_per_uczelnia.py | 100 ++++++++++++++++++ src/bpp/views/browse.py | 7 ++ 3 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 src/bpp/tests/test_models/test_rekord_punktacja_per_uczelnia.py diff --git a/src/bpp/models/cache/rekord.py b/src/bpp/models/cache/rekord.py index 129e5d278..29cc9720f 100644 --- a/src/bpp/models/cache/rekord.py +++ b/src/bpp/models/cache/rekord.py @@ -298,16 +298,24 @@ def js_safe_pk(self): def form_post_pk(self): return "{" + f"{self.pk[0]:d},{self.pk[1]:d}" + "}" + def _uczelnia_punktacji(self): + """Uczelnia oglądającego ustawiona przez widok rekordu (multi-hosted). + + Tabela punktacji na publicznej stronie rekordu ma pokazywać sloty/ + punkty tylko uczelni z requestu. Widok (``PracaViewMixin``) ustawia + ``rekord._uczelnia_ogladajacego``; gdy atrybutu brak (admin, inne + konteksty) albo single-install — zwracamy ``None`` (brak zawężenia). + """ + from bpp.util.uczelnia_scope import tylko_jedna_uczelnia + + uczelnia = getattr(self, "_uczelnia_ogladajacego", None) + if uczelnia is None or tylko_jedna_uczelnia(): + return None + return uczelnia + @cached_property def ma_punktacje_sloty(self): - return ( - Cache_Punktacja_Autora.objects.filter( - rekord_id=[self.id[0], self.id[1]] - ).exists() - or Cache_Punktacja_Dyscypliny.objects.filter( - rekord_id=[self.id[0], self.id[1]] - ).exists() - ) + return self.punktacja_autora.exists() or self.punktacja_dyscypliny.exists() @cached_property def ma_odpiete_dyscypliny(self): @@ -319,13 +327,21 @@ def ma_odpiete_dyscypliny(self): @cached_property def punktacja_dyscypliny(self): - return Cache_Punktacja_Dyscypliny.objects.filter( + qs = Cache_Punktacja_Dyscypliny.objects.filter( rekord_id=[self.id[0], self.id[1]] ) + uczelnia = self._uczelnia_punktacji() + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + return qs @cached_property def punktacja_autora(self): - return Cache_Punktacja_Autora.objects.filter(rekord_id=[self.id[0], self.id[1]]) + qs = Cache_Punktacja_Autora.objects.filter(rekord_id=[self.id[0], self.id[1]]) + uczelnia = self._uczelnia_punktacji() + if uczelnia is not None: + qs = qs.filter(jednostka__uczelnia=uczelnia) + return qs @cached_property def pierwszy_autor_afiliowany(self): diff --git a/src/bpp/tests/test_models/test_rekord_punktacja_per_uczelnia.py b/src/bpp/tests/test_models/test_rekord_punktacja_per_uczelnia.py new file mode 100644 index 000000000..e646a932e --- /dev/null +++ b/src/bpp/tests/test_models/test_rekord_punktacja_per_uczelnia.py @@ -0,0 +1,100 @@ +"""Track 1 (audyt uczelnia 2026-06-04): tabela punktacji na stronie rekordu +pokazuje sloty/punkty TYLKO uczelni oglądającego. + +``Rekord.punktacja_autora`` / ``punktacja_dyscypliny`` / ``ma_punktacje_sloty`` +filtrowały tylko po ``rekord_id`` → publiczna strona rekordu (renderowana na +domenie jednej uczelni) pokazywała CPD/CPA wszystkich uczelni współautorskiego +rekordu. Widok ustawia ``rekord._uczelnia_ogladajacego``; metody zawężają +(CPD po ``uczelnia``, CPA po ``jednostka__uczelnia``). +""" + +import pytest +from django.contrib.sites.models import Site +from model_bakery import baker + +from bpp.models import ( + Cache_Punktacja_Autora, + Cache_Punktacja_Dyscypliny, + Jednostka, + Uczelnia, + Wydzial, +) +from bpp.models.cache import Rekord + + +@pytest.fixture +def jednostka_drugiej_uczelni(db): + site = baker.make(Site, domain="druga-rek.testserver", name="druga-rek") + uczelnia2 = Uczelnia.objects.create(skrot="DRR", nazwa="Druga", site=site) + wydzial = Wydzial.objects.create(uczelnia=uczelnia2, skrot="W2", nazwa="Wydz II") + return Jednostka.objects.create( + nazwa="Jedn II", skrot="JDR", wydzial=wydzial, uczelnia=uczelnia2 + ) + + +@pytest.mark.django_db +def test_punktacja_rekordu_zaweza_do_uczelni_ogladajacego( + autor_jan_kowalski, jednostka, jednostka_drugiej_uczelni, dyscyplina1 +): + uczelnia1 = jednostka.uczelnia + uczelnia2 = jednostka_drugiej_uczelni.uczelnia + rid = [1, 999] + + for ucz in (uczelnia1, uczelnia2): + Cache_Punktacja_Dyscypliny.objects.create( + rekord_id=rid, + dyscyplina=dyscyplina1, + uczelnia=ucz, + pkd=50, + slot=20, + autorzy_z_dyscypliny=[autor_jan_kowalski.pk], + zapisani_autorzy_z_dyscypliny=["x"], + ) + for jedn in (jednostka, jednostka_drugiej_uczelni): + Cache_Punktacja_Autora.objects.create( + rekord_id=rid, + autor=autor_jan_kowalski, + jednostka=jedn, + dyscyplina=dyscyplina1, + pkdaut=50, + slot=20, + ) + + rekord = Rekord() + rekord.id = (1, 999) + rekord._uczelnia_ogladajacego = uczelnia1 + + cpd_uczelnie = set( + rekord.punktacja_dyscypliny.values_list("uczelnia_id", flat=True) + ) + cpa_uczelnie = set( + rekord.punktacja_autora.values_list("jednostka__uczelnia_id", flat=True) + ) + + assert cpd_uczelnie == {uczelnia1.pk} + assert cpa_uczelnie == {uczelnia1.pk} + assert rekord.ma_punktacje_sloty is True + + +@pytest.mark.django_db +def test_punktacja_rekordu_bez_uczelni_globalnie( + autor_jan_kowalski, jednostka, dyscyplina1 +): + """Bez ``_uczelnia_ogladajacego`` (np. admin) zachowanie globalne — brak + regresji w kontekstach, które nie ustawiają uczelni oglądającego.""" + uczelnia1 = jednostka.uczelnia + rid = [1, 998] + Cache_Punktacja_Dyscypliny.objects.create( + rekord_id=rid, + dyscyplina=dyscyplina1, + uczelnia=uczelnia1, + pkd=50, + slot=20, + autorzy_z_dyscypliny=[autor_jan_kowalski.pk], + zapisani_autorzy_z_dyscypliny=["x"], + ) + + rekord = Rekord() + rekord.id = (1, 998) + + assert rekord.punktacja_dyscypliny.count() == 1 diff --git a/src/bpp/views/browse.py b/src/bpp/views/browse.py index 3b28cb1ca..620f81b05 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -669,8 +669,15 @@ def post(self, *args, **kw): class PracaViewMixin: def get(self, request, *args, **kwargs): + from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu + self.object = self.get_object() + # Multi-hosted: tabela punktacji na stronie rekordu pokazuje sloty/ + # punkty tylko uczelni oglądającego (CPD po uczelni, CPA po + # jednostka__uczelnia). No-op przy single-install. + self.object._uczelnia_ogladajacego = uczelnia_dla_odczytu(request) + if request.user.is_anonymous: # Jeżeli użytkownik jest anonimowy, to może obejmować go ukrywanie statusów uczelnia = Uczelnia.objects.get_for_request(request) From 81d1ba7e3aea7c3ac6ca56348a566f9c776fe68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:34:40 +0200 Subject: [PATCH 179/247] docs(multi-hosted): raport audytu pokrycia uczelnia + specy track 4/6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raport (klasa 1 get_default/first + klasa 2 brakująca uczelnia, 10 LUK + stan realizacji 9 napraw). Specy execution-ready: SentData per-uczelnia (track 4) i Uczelnia.objects.first() sweep + guard (track 6). Co-Authored-By: Claude Opus 4.8 --- .../2026-06-04-audyt-uczelnia-coverage.md | 196 ++++++++++++++++++ ...-04-sentdata-per-uczelnia-track4-design.md | 113 ++++++++++ ...6-04-uczelnia-first-sweep-track6-design.md | 77 +++++++ 3 files changed, 386 insertions(+) create mode 100644 docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md create mode 100644 docs/superpowers/specs/2026-06-04-sentdata-per-uczelnia-track4-design.md create mode 100644 docs/superpowers/specs/2026-06-04-uczelnia-first-sweep-track6-design.md diff --git a/docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md b/docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md new file mode 100644 index 000000000..de86db55a --- /dev/null +++ b/docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md @@ -0,0 +1,196 @@ +# Audyt pokrycia uczelnia (multi-hosted) — raport 2026-06-04 + +Audyt **read-only** pokrycia bugów multi-hosted po domknięciu głównych wątków +(write/read sloty R1, liczba_n R2, R3a/R3b publiczny, integrator, metryki D, +drobne B1–B6). Cel: zweryfikować systematycznie, że nie ma niezałatanego buga. + +Metoda: recon (HANDOFF + Audyt 4× + guard) → 6 równoległych subagentów-audytorów +po obszarach (`bpp/views`, `bpp/models`, `api_v1`, `pbn_api`+integrator+komparatory, +`raport_slotow`+`ewaluacja_*`, `powiazania_autorow`+commands+admin) → osobista +weryfikacja każdego czystego 🔴 → synteza. Gałąź `feature/multi-hosted-config`. + +Klasyfikacja statusów odzwierciedla **decyzje produktowe usera z 2026-06-04** +(sekcja „Decyzje usera" niżej). + +--- + +## Klasa 1 — `get_default()` / `objects.default` (guard) + +**Guard zielony** (`test_multihosted_get_default_guard.py` — 1 passed). Whitelista += **9 plików**, wszystkie ZOSTAJĄ (świadome fallbacki bez requestu / None-tolerant +warstwa modelu / display / guarded count==1 / komentarz). Weryfikacja każdego wpisu +(Audyt 1 z 4× + ponowny grep): brak ukrytej luki runtime. Poza whitelistą — nic +(potwierdzone regexem; warianty `Jednostka.objects.get_default_ordering` i +`self.get_default()` w definicji managera nie pasują do wzorca i NIE są bugiem). + +### 🔴 NOWE: druga ślepa plamka guarda — `Uczelnia.objects.first()` + +Guard pilnuje wyłącznie `get_default()`/`objects.default`. Wzorzec **równoważny +semantycznie** (zgadnij pierwszą-z-brzegu uczelnię) `Uczelnia.objects.first()` +występuje **28×** w runtime, zdominowany przez widoki `ewaluacja_optymalizacja`: + +| plik | liczność | +|---|---| +| `ewaluacja_optymalizacja/views/evaluation_browser/views.py` | 6 | +| `ewaluacja_optymalizacja/views/{unpinning_list,pins,optimize_unpin,discipline_swap_list}.py` | 2 każdy | +| `ewaluacja_optymalizacja/views/{unpinning_analysis,unpin_sensible,index,discipline_swap_analysis,bulk_optimization}.py` | 1 każdy | +| `ewaluacja_optymalizacja/{management/commands,core}` | 3 | +| `bpp/models/{jednostka,autor}.py`, `bpp/admin/core.py`, demo/debug | po 1 (warstwa modelu/UI/demo — niska waga) | + +To ten sam klasa-ryzyka co pierwotny bug B1, ale niewidoczny dla guarda. W widokach +`ewaluacja_optymalizacja` (request dostępny) `Uczelnia.objects.first()` → optymalizacja +DLA PIERWSZEJ uczelni niezależnie od hosta. **Rekomendacja:** rozszerzyć guard o +wzorzec `Uczelnia\.objects\.first\(\)` (z whitelistą dla demo/debug/warstwy modelu). + +--- + +## Klasa 2 — brakująca uczelnia (znaleziska) + +### 🔴 LUKI — do naprawy (zweryfikowane osobiście, decyzje usera wbudowane) + +| # | plik:linia | znalezisko | reguła naprawy (decyzja usera) | +|---|---|---|---| +| 1 | `bpp/models/cache/rekord.py:302-328` | `punktacja_autora`/`punktacja_dyscypliny`/`ma_punktacje_sloty` filtrują tylko `rekord_id` → **publiczna strona rekordu** pokazuje CPD/CPA wszystkich uczelni. Szablony (`browse/praca_tabela*.html`) mają `uczelnia` w kontekście. R3a objął listy, nie szczegół rekordu. | rekord wyszukiwalny globalnie; tabelka punktacji zawężona: CPA przez `jednostka__uczelnia`, CPD przez `uczelnia` oglądającego. Guard `tylko_jedna_uczelnia()` no-op. | +| 2 | `bpp/views/profile.py:21` | `MetrykaAutora.objects.filter(autor=autor)` bez uczelni — profil pokazuje metryki ze WSZYSTKICH uczelni autora (przeoczenie wątku D — D objął `ewaluacja_metryki/views/`, nie profil w `bpp/views/`). | pokaż, ale tylko z JEDNEJ uczelni — tej z requestu: `scope_metryki(qs, uczelnia_dla_odczytu(request))`. | +| 3 | `bpp/views/autocomplete/navigation.py:130-190` (+ `search_services.py`) | publiczny `GlobalNavigationAutocomplete` (bez auth) szuka jednostek/autorów/rekordów globalnie, tylko `ukryte_statusy("podglad")`. R3b objął dedykowane pickery (`UczelniaScopedAutocompleteMixin`), ten global-search pominął. | jednostki: filtr `uczelnia`; autorzy: „ma ≥1 jednostkę z uczelni" (reguła R3b autor: `aktualna_jednostka__uczelnia` OR `autor_jednostka__jednostka__uczelnia`); rekordy: przez `autorzy__jednostka__uczelnia`. | +| 4 | `bpp/views/autocomplete/simple.py:74` | `LataAutocomplete` — `Rekord.objects.all()` globalnie; niespójne z zawężonym `LataView` (R3a). | zawęź przez `autorzy__jednostka__uczelnia` (jak rekordy wyżej). | +| 5 | `raport_slotow/views/zerowy.py:80` | `autorzy_zerowi(min_pk, od_roku, do_roku)` bez `uczelnia=` → strona „existent" liczona z punktów wszystkich uczelni; autor z punktami tylko na uczelni B błędnie wykluczony z raportu zerowego uczelni A. **Połowiczny fix** (commit `29734f833` dodał parametr do `core.py`, call-site go nie przekazuje). | `uczelnia=uczelnia_dla_odczytu(self.request)`. `Cache_Punktacja_Autora_Query_View` ma kolumnę `uczelnia` (autor→jednostka→uczelnia w każdej pracy) — `autorzy_z_punktami` już filtruje po niej. | +| 6 | `pbn_api/models/sentdata.py` (`SentDataManager`) + `pbn_api/client/publication_sync.py:204-238` | `SentData` ma nullable FK `uczelnia`, ale `get_for_rec` szuka po `(object_id, content_type)` bez uczelni, a żadna metoda nie ustawia/filtruje `uczelnia`. Dwie uczelnie wysyłające ten sam rekord **współdzielą i nadpisują** jeden wiersz → `bad_uploads`/`check_if_upload_needed`/`only_new` błędnie pomijają wysyłkę do drugiego profilu PBN (zgubiona wysyłka). | tagować per-uczelnia: rekord na 2-3 profile instytucji → 2-3 osobno oznaczone `SentData`; klucz lookup `(object_id, content_type, uczelnia)`. Wymaga migracji + backfill. | +| 7 | `oswiadczenia/tasks.py:117` + `views.py:89,191,375` | „Wydruk oświadczeń 2022-25" (lista HTML, XLSX, ZIP/PDF/DOCX) buduje qs `Autorzy` tylko po roku/autorze/tytule/dyscyplinie — bez `jednostka__uczelnia` → admin widzi/eksportuje oświadczenia WSZYSTKICH uczelni. Wzorzec poprawny obok: `OswiadczeniaPublikacji.get_context_data` (views.py:346). | scope qs przez `jednostka__uczelnia=uczelnia_dla_odczytu(request)`. | +| 8 | `oswiadczenia/tasks.py:539,559` + `views.py:512` | `generate_oswiadczenia_zip` ma `uczelnia_id=None`→`Uczelnia.objects.get()`; widok robi `.delay(task.pk)` bez `uczelnia_id`, model `OswiadczeniaExportTask` bez pola uczelnia → multi-install **crash** `MultipleObjectsReturned`. (Korekta briefu: NIE jest „per uczelnia".) | przekazać `uczelnia_id` do taska (z `get_for_request`); dodać pole/przelot. | +| 9 | `zglos_publikacje/views.py:238` + `forms.py:303,316` + `models.py:253-256` | Wizard zgłaszania publikacji: widok zna uczelnię (`dispatch`), ale `get_form_kwargs` nie przekazuje jej do formy → `Uczelnia.objects.get()` → `MultipleObjectsReturned`. `Zgloszenie_Publikacji._uczelnia` nigdy nie ustawiane → ta sama awaria w walidacji opłat. **Publiczny wizard niedziałający w multi-hosted.** | przekazać `uczelnia` do `get_form_kwargs`/`form.__init__` i ustawić `instance._uczelnia`. | +| 10 | `bpp/management/commands/wyczysc_baze.py:60-66,136` | Komenda rozwiązuje `uczelnia` i drukuje „Baza danych czyja?", ale kasuje `klass.objects.all().delete()` **globalnie** — `wyczysc_baze --uczelnia=2` wyczyści dane wszystkich uczelni. UI sugeruje scope, kod kasuje globalnie (foot-gun). | scope delete po rozwiązanej uczelni (lub jawnie udokumentować, że to global-only). | + +### 🟡 ŚWIADOMIE ODŁOŻONE (potwierdzone, że nadal istnieją) + +**Federacja optymalizacji — globalne delete/update (backlog C, integralność danych).** +Świadomie olane jako *logika federacyjna*, ale to *korupcja danych*: +- `ewaluacja_optymalizacja/tasks/optimization.py:73` (`solve_single_discipline_task`), + `:536,573` (`optimize_and_unpin_task`), `tasks/reset_pins.py:139` (`reset_all_pins_task`). +- **NOWE instancje tej samej klasy:** `management/commands/solve_uczelnia.py:108`, + `solve_helpers/persistence.py:28` (`OptimizationRun.filter(dyscyplina).delete()` + bez uczelni), `views/unpinning_analysis.py:162` (`MetrykaAutora.objects.all().delete()` + globalny, `Uczelnia.objects.first()`). + +**Federacja read-side (świadomie olana lub do decyzji produktowej):** +- `raport_slotow/views/ewaluacja.py:91`, `upowaznienie_pbn.py:90` — listy autorstw + ewaluacyjnych bez scope po uczelni (model DB-view bez kolumny uczelnia). +- `ewaluacja_common/utils.py:17,33` (`get_lista_prac`) — bez uczelni, ale **dormant** + (brak żywych callerów, tylko testy). +- `ewaluacja_optymalizuj_publikacje/views.py:320-380` — `MetrykaAutora.get(autor, dyscyplina)` + bez uczelni → przy >1 uczelni `MultipleObjectsReturned` (HTTP 500, fail-loud). + +**pbn_api — lustro danych PBN (decyzja usera: 🟡, udokumentować w komentarzach):** +- `OsobaZInstytucji` (`pbn_integrator/utils/scientists.py:205`, OneToOne `personId`), + `PublikacjaInstytucji(_V2)` (`mongodb_ops.py:198`, `publications.py:124`), + `OswiadczenieInstytucji` (`mongodb_ops.py:304`) — write nie taguje FK `uczelnia` + (nullable, NULL w runtime). Praktycznie OK, bo `uczelnia.pbn_uid` (institution_id) + jednoznacznie wiąże wiersz z instytucją PBN; brak FK = brak wygody filtrowania, + nie korupcja. **Działanie: dobre komentarze opisujące to, nie pilny fix.** +- Powiązane globalne delete (kompounduje, ale przy mapowaniu institution_id niegroźne): + `publication_sync.py:238`, `oswiadczenie_instytucji.py:201` (kasuje `SentData` + cross-uczelnia — istotne po naprawie #6), `pbn_integrator/utils/statements.py:472`. +- `komparator_pbn_udzialy` (`tasks.py:13`, `utils.py:46,245`) — `porownaj_dyscypliny_pbn_task` + globalny `.delete()` + iteracja `OswiadczenieInstytucji.objects.all()`; modele wynikowe + komparatora bez FK uczelnia → 🟡 (federacja-adjacent). + +**API REST (`api_v1`) — „API maszynowe, osobny temat" (Audyt 3):** +- `viewsets/struktura.py:11,16` (`Jednostka`/`Wydzial` — jawny FK uczelnia, listowane + globalnie) — **najmocniejszy kandydat do pull-forward** gdyby API miało być per-uczelnia. +- `viewsets/recent_author_publications.py` — `AllowAny` + CORS `*` + **ignoruje + `nie_eksportuj_przez_api`/`ukryte_statusy`** (w odróżnieniu od bratnich viewsetów) + → ortogonalny bug eksportu, niezależny od multi-hosted. +- `viewsets/raport_slotow_uczelnia.py` — per-OWNER (R1, świadomy rozjazd, bezpieczny). +- Globalne listy publikacji/autorów (ciagle/zwarte/doktorska/habilitacyjna/patent + + `*_Autor`) — atrybuowane tranzytywnie; dane z natury publiczne (bibliografia). + +**UI / config / inne:** +- `bpp/multiseek_registry/__init__.py:31` — multiseek base `Rekord.objects.all()` + (R3a by-design: wyszukiwarka globalna). +- `bpp/multiseek_registry/fields/numeric_fields.py:70-73` — toggle „Index Copernicus" + per pierwszej uczelni (widoczność pola, nie dane). +- `bpp/tasks.py:36-51` (`_zaktualizuj_liczbe_cytowan`) — pętla per uczelnia OK, ale + wewnętrzne `klass.objects.all()` nie scope'owane (jawny FIXME: redundancja + + last-write-wins między WoS-klientami). +- `bpp/admin/core.py:195` (form default `afiliuje` z pierwszej uczelni), + adminy publikacji bez `SiteFilteredAdminMixin` (changelist cross-uczelnia, superuser + i tak zwolniony) — design admina. +- Komendy one-off install-specific: `mapuj_kierunki_studiow.py`, + `fix_pbn_import_oswiadczen_ksiazki.py` (fail-loud). + +### ✅ POTWIERDZONE jako poprawne (zbiorczo) + +- **Write-side sloty** (`bpp/models/sloty/*`, `cache/punktacja.py`): `ISlot`, + `IPunktacjaCacher`, `_autorzy_qs` filtrują `jednostka__uczelnia`; CPD tagowane. +- **R1 sloty read-side**: `raport_slotow/views/{autor,uczelnia}.py` przez + `uczelnia_dla_odczytu`. +- **R2 liczba_n** (`ewaluacja_liczba_n/utils.py`, `views/verify.py`): wszystkie + delete/create/agregaty z `uczelnia=`; atrybucja `aktualna_jednostka.uczelnia` + + `skupia_pracownikow`. +- **D metryki** (`ewaluacja_metryki/views/*`, `utils.py`, `tasks.py`): `scope_metryki`, + `_resolve_uczelnia` single-or-fail, StatusGenerowania per-uczelnia, delete scoped. +- **R3a publiczny**: `nowe_raporty`, `browse` (Lata/Rok), `oai`, `ranking_autorow` + przez `scope_rekord_do_uczelni` + `tylko_jedna_uczelnia`. +- **R3b pickery**: `units.py`, `simple.py` (Wydzial), `authors.py` przez + `UczelniaScopedAutocompleteMixin`. +- **PBN push/queue**: `PBN_Export_Queue.uczelnia` + `send_to_pbn` (`entry.uczelnia`); + downloadery/import/wysyłka-oświadczeń z jawnym `uczelnia_id`+`get_for_pbn_background`; + B6 `przemapuj_*` taguje uczelnię. +- **B1** `powiazania_autorow/queries.py:_pbn_root` przez `get_for_request`. +- **importer_publikacji** (`ImportSession.uczelnia`), **zbieraj_sloty** CLI + (single-or-fail), **integrator** matching/klient (delta R2). + +--- + +## Decyzje usera (2026-06-04) — wiążąca klasyfikacja + +1. **#1 (punktacja rekordu):** rekord wyszukiwalny wszędzie; tabelka punktacji + tylko dla `jednostka__uczelnia` oglądającego. → 🔴 naprawić. +2. **#2 (metryki profilu):** pokazać, ale z jednej uczelni — z requestu. → 🔴 naprawić. +3. **#3 (global autocomplete):** jednostki filtrować; autorzy „≥1 jednostka z uczelni"; + rekordy przez `autorzy`. → 🔴 naprawić. +4. **#4 (LataAutocomplete):** jak #3 (przez `autorzy`). → 🔴 naprawić. +5. **#5 (zerowy.py):** „trzeba zrobić, proste" — dokończyć połowiczny fix. → 🔴 naprawić. +6. **SentData:** tagować per-uczelnia (N profili instytucji = N wierszy). → 🔴 naprawić. +7. **OsobaZInstytucji / PublikacjaInstytucji / OswiadczenieInstytucji:** teoretycznie + per-uczelnia, praktycznie OK (mapowanie `institution_id`) → 🟡 **udokumentować + w komentarzach**, nie pilny fix. + +--- + +## Podsumowanie liczbowe + +- **Klasa 1** (`get_default`): guard szczelny (9 wpisów OK). **NOWE:** druga ślepa + plamka `Uczelnia.objects.first()` (28×, ~25 w `ewaluacja_optymalizacja` runtime). +- **Klasa 2:** **10 🔴 LUK** do naprawy (wszystkie zweryfikowane osobiście); + ~20 miejsc 🟡 świadomie odłożonych (federacja optymalizacji, pbn_api lustro, + API maszynowe, UI/config); rdzeń wątków R1/R2/R3/D/B1–B6 potwierdzony ✅. + +--- + +## STAN REALIZACJI (2026-06-04, po decyzjach usera) + +Wszystkie naprawy: TDD (RED→GREEN), invariant single-install, lint czysty. + +### ✅ ZROBIONE (9 napraw) +| Track | plik(i) | test | +|---|---|---| +| 2 | `raport_slotow/views/zerowy.py` (+ `…/tests/…/test_raport_slotow_zerowy_per_uczelnia.py`) | 16 ✅ | +| 3a | `oswiadczenia/{views,tasks}.py` (+ `oswiadczenia/test_per_uczelnia.py`) | 18 ✅ | +| 3b | `zglos_publikacje/{forms,views}.py` (+ `…/tests/test_per_uczelnia.py`) | 44 ✅ | +| 5 | `bpp/management/commands/wyczysc_baze.py` — **USUNIĘTA** (nieużywana) | — | +| 7 | `pbn_api/models/{osoba_z_instytucji,publikacja_instytucji,oswiadczenie_instytucji}.py` — komentarze | — | +| 1a | `bpp/views/profile.py` (+ `…/test_views/test_profile_per_uczelnia.py`) | 1 ✅ | +| 1b | `bpp/views/autocomplete/simple.py` `LataAutocomplete` (+ test) | 2 ✅ | +| 1c | `bpp/views/autocomplete/{navigation,search_services}.py` (+ test) | 132 ✅ (autocomplete regr.) | +| 1d | `bpp/models/cache/rekord.py` + `bpp/views/browse.py` (+ test) | 22 ✅ (browse regr.) | + +### 📋 SPEC (execution-ready, do wykonania w świeżym kontekście / subagentem) +- **Track 4 — SentData per-uczelnia:** `specs/2026-06-04-sentdata-per-uczelnia-track4-design.md` + (outward-facing, ~8 call-site'ów spójnościowych + migracja — świadomie NIE + robione połowicznie na rozciągniętym kontekście). +- **Track 6 — `Uczelnia.objects.first()` sweep + guard:** `specs/2026-06-04-uczelnia-first-sweep-track6-design.md` + (28 wystąpień; druga ślepa plamka guarda). + +### 🟡 Odłożone (bez akcji, odnotowane): federacja optymalizacji (backlog C globalne +delete), API REST maszynowe, pbn_api lustro (Track 7 = komentarze), multiseek by-design. diff --git a/docs/superpowers/specs/2026-06-04-sentdata-per-uczelnia-track4-design.md b/docs/superpowers/specs/2026-06-04-sentdata-per-uczelnia-track4-design.md new file mode 100644 index 000000000..d0ea1cd1c --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-sentdata-per-uczelnia-track4-design.md @@ -0,0 +1,113 @@ +# Spec — Track 4: `SentData` per-uczelnia (klucz wysyłki PBN) + +Audyt: `docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md` (🔴 #6). +Decyzja usera: rekord wysyłany na N profili instytucji → N osobno oznaczonych +`SentData`; klucz lookup `(object_id, content_type, uczelnia)`. + +## Dlaczego to outward-facing i krytyczne dla spójności + +`SentData` to **stan wysyłki do zewnętrznego PBN** (`uploaded_okay`, +`submitted_successfully`, `data_sent`). Steruje czy w ogóle wyślemy +(`check_if_upload_needed`, `bad_uploads`, `only_new`). W multi-hosted dwie +uczelnie wysyłające ten sam rekord BPP **współdzielą i nadpisują** jeden wiersz +(`get_for_rec` po samym `object_id`+`content_type`) → wysyłka do drugiego profilu +PBN jest błędnie pomijana (zgubiona) albo nadpisana. + +**Spójność krytyczna:** gdy zaczną istnieć ≥2 wiersze na `(object_id, +content_type)`, KAŻDE `get_for_rec(rec)` BEZ uczelni rzuci +`MultipleObjectsReturned`. Dlatego WSZYSTKIE call-site'y muszą być zmienione +atomowo — nie wolno zrobić tego połowicznie. + +## Stan obecny +FK `uczelnia` na `SentData` JUŻ istnieje (nullable, `sentdata.py:137`), ale: +- `get_for_rec(rec)` filtruje tylko `(object_id, content_type)`; +- żadna metoda managera nie ustawia/filtruje `uczelnia`; +- wszystkie wiersze w runtime mają `uczelnia IS NULL` (integrator nigdy nie taguje). + +## Zmiana managera (`pbn_api/models/sentdata.py`, `SentDataManager`) + +Dodać parametr `uczelnia` do metod (keyword, dla zgodności default `None` → +zachowanie globalne tylko gdy NULL-owy świat; realne callery ZAWSZE podają): + +| metoda | zmiana | +|---|---| +| `get_for_rec(self, rec, uczelnia=None)` | `qs = filter(object_id, content_type)`; `if uczelnia is not None: qs = qs.filter(uczelnia=uczelnia)`; `return qs.get()` | +| `check_if_needed(self, rec, data, uczelnia=None)` | przelot do `get_for_rec(rec, uczelnia)` | +| `check_if_upload_needed(self, rec, data, uczelnia=None)` | przelot do `get_for_rec(rec, uczelnia)` | +| `create_or_update_before_upload(self, rec, data, api_url="", uczelnia=None)` | `get_for_rec(rec, uczelnia)`; w `except DoesNotExist`: `create(..., uczelnia=uczelnia)` | +| `mark_as_successful(self, rec, pbn_uid_id=None, api_response_status="", uczelnia=None)` | `get_for_rec(rec, uczelnia)` | +| `mark_as_failed(self, rec, exception="", api_response_status="", uczelnia=None)` | `get_for_rec(rec, uczelnia)` | +| `updated(self, rec, data, pbn_uid_id=None, uploaded_okay=True, exception="", uczelnia=None)` | `get_for_rec(rec, uczelnia)`; create z `uczelnia=uczelnia` | +| `ids_for_model(self, model, uczelnia=None)` | `qs = filter(content_type)`; `if uczelnia: qs = qs.filter(uczelnia=uczelnia)` | +| `bad_uploads(self, model, uczelnia=None)` | `ids_for_model(model, uczelnia).filter(uploaded_okay=False)...` | + +## Call-site'y (WSZYSTKIE — atomowo) + +1. **`pbn_api/client/publication_sync.py`** (`PublicationSyncMixin`, ma `self.uczelnia`): + - `:84` `check_if_upload_needed(rec, js)` → `+ uczelnia=self.uczelnia` + - `:87` `get_for_rec(rec)` → `+ self.uczelnia` + - `:204` `create_or_update_before_upload(rec, js, api_url=...)` → `+ uczelnia=self.uczelnia` + - `:208` `mark_as_successful(rec, ...)` → `+ uczelnia=self.uczelnia` + - `:210,215` `mark_as_failed(rec, ...)` → `+ uczelnia=self.uczelnia` + - `:667` `get_for_rec(pub)` → `+ self.uczelnia` +2. **`pbn_integrator/utils/synchronization.py`** (`tworz_woluminy_do_synchronizacji` + ma `client` → `client.uczelnia`): + - `:204,244` `bad_uploads(Wydawnictwo_*)` → `+ uczelnia=client.uczelnia` + - `:210,250` `ids_for_model(Wydawnictwo_*)` → `+ uczelnia=client.uczelnia` + - (zweryfikować, że funkcja ma `client` w zasięgu w tych liniach) +3. **`bpp/admin/helpers/pbn_api/common.py:177,241`** `get_for_rec(obj)` (link admin do + wysłanych danych): przekazać uczelnię z kontekstu akcji adminowej (ta sama, którą + akcja użyła do uploadu — `get_for_request(request)`). Jeśli funkcja nie ma + uczelni w zasięgu → dodać parametr i przekazać od wołającego (akcja PBN w + adminie zna `get_for_request`). +4. **`bpp/system.py:142`** — sprawdzić użycie `SentData` (lista importów/cleanup); + jeśli to globalny `.delete()`/iteracja → zawęzić lub udokumentować. +5. **`pbn_api/models/oswiadczenie_instytucji.py:201`** (override `delete()`): + `SentData.objects.filter(pbn_uid_id=self.publicationId_id).delete()` — po + tagowaniu dodać `uczelnia=self.uczelnia` do filtra (kasuj tylko SentData tej + uczelni). Wymaga, by `OswiadczenieInstytucji.uczelnia` było ustawione (patrz + Track 7 — obecnie nullable; jeśli NULL → zachowanie jak dawniej, global delete, + ale to akceptowalne do czasu pełnego tagowania lustra). +6. **`pbn_export_queue/views/{utils,detail_views}.py`** — zweryfikować użycia + `SentData` (grep wskazał plik); jeśli `get_for_rec`/display → przekazać uczelnię + wpisu kolejki (`entry.uczelnia`). +7. **`pbn_integrator/utils/cleanup.py`** — zweryfikować (prawdop. global cleanup). + +## Migracja + backfill + +Schemat: FK już istnieje (nullable) → **brak zmiany schematu**, tylko data-migration: +- `RunPython`: jeśli `Uczelnia.objects.count() == 1` → `SentData.objects.filter( + uczelnia__isnull=True).update(uczelnia=)`. +- Multi-install z NULL-ami → **NIE failować, NIE kasować**: zostaw NULL. Nowy + lookup filtruje po uczelni → NULL-owe wiersze stają się niewidoczne dla + keyed-lookup, więc kolejna wysyłka utworzy poprawnie otagowany wiersz (co + najwyżej jeden redundantny re-send per `(rec, uczelnia)` — samonaprawcze, + bezpieczne; PBN przyjmie idempotentnie). Odnotować w komentarzu migracji. +- `uczelnia` **zostaje nullable** (faza przejściowa; NULL = legacy/nieotagowane). + +## Test plan (TDD) + +`pbn_api/tests/test_sentdata_per_uczelnia.py`: +1. `test_get_for_rec_per_uczelnia`: 2 uczelnie, `create_or_update_before_upload( + rec, d1, uczelnia=U1)` + `(rec, d2, uczelnia=U2)` → `count()==2`; + `get_for_rec(rec, U1).data_sent == d1`; `get_for_rec(rec, U2).data_sent == d2`. +2. `test_mark_successful_izolacja`: `mark_as_successful(rec, uczelnia=U1)` → + `get_for_rec(rec, U1).uploaded_okay is True` AND `get_for_rec(rec, U2). + uploaded_okay is False` (brak współdzielenia stanu). +3. `test_check_if_upload_needed_per_uczelnia`: po sukcesie U1, `check_if_upload_needed( + rec, same_data, U1) is False` ale `(rec, same_data, U2) is True`. +4. `test_bad_uploads_per_uczelnia`: `bad_uploads(model, U1)` nie zawiera rekordu + którego zła wysyłka była na U2. +5. Regresja: cała sucha `pbn_api/tests/` (test_client_upload, test_client_sync, + test_bpp_admin_helpers — te WOŁAJĄ zmienione metody, zaktualizować je o + `uczelnia=`). +6. `makemigrations --check` zielony; migracja backfill testowana single+multi. + +## Inwariant +Single-install: po backfillu wszystkie wiersze mają tę jedną uczelnię; lookup z +`uczelnia=` = no-op. Zero zmian zachowania wysyłki przy 1 uczelni. + +## Kolejność wykonania (atomowa) +manager → publication_sync → synchronization → admin/common → system/cleanup/ +queue → oswiadczenie_instytucji.delete → migracja → testy istniejące (dopisać +`uczelnia=`) → nowe testy → pełna regresja `pbn_api` + `pbn_integrator`. diff --git a/docs/superpowers/specs/2026-06-04-uczelnia-first-sweep-track6-design.md b/docs/superpowers/specs/2026-06-04-uczelnia-first-sweep-track6-design.md new file mode 100644 index 000000000..2620514d2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-uczelnia-first-sweep-track6-design.md @@ -0,0 +1,77 @@ +# Spec — Track 6: `Uczelnia.objects.first()` sweep + rozszerzenie guarda + +Audyt: `docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md` (Klasa 1, druga +ślepa plamka). Gałąź `feature/multi-hosted-config`. + +## Problem + +Guard `test_multihosted_get_default_guard.py` pilnuje wyłącznie +`Uczelnia.objects.get_default()` / `Uczelnia.objects.default`. Wzorzec +**semantycznie równoważny** `Uczelnia.objects.first()` (zgaduje pierwszą-z-brzegu +uczelnię) jest niewidoczny dla guarda i występuje **28×** w runtime. W widoku z +requestem `Uczelnia.objects.first()` → operacja DLA PIERWSZEJ uczelni niezależnie +od hosta (ten sam klasa-bug co B1, tylko inny zapis). + +## Inwentarz (28 wystąpień — do bezwzględnej weryfikacji) + +| plik | liczność | klasa | +|---|---|---| +| `ewaluacja_optymalizacja/views/evaluation_browser/views.py` | 6 | 🔴 runtime view (request dostępny) | +| `ewaluacja_optymalizacja/views/{unpinning_list,pins,optimize_unpin,discipline_swap_list}.py` | 2 ea | 🔴 runtime view | +| `ewaluacja_optymalizacja/views/{unpinning_analysis,unpin_sensible,index,discipline_swap_analysis,bulk_optimization}.py` | 1 ea | 🔴 runtime view | +| `ewaluacja_optymalizacja/management/commands/{solve_uczelnia,solve_evaluation}.py` | 1 ea | 🟡 CLU → single-or-fail | +| `ewaluacja_optymalizacja/core/__init__.py` | 1 | weryfikować (runtime vs setup) | +| `bpp/models/{jednostka,autor}.py` | 1 ea | 🟡 warstwa modelu (display/sort) — prawdop. whitelist | +| `bpp/admin/core.py` | 1 | 🟡 form default UI (jak w whitelist get_default) | +| `bpp/management/commands/debug_setup_initial_data.py` | 1 | 🟡 debug — whitelist | +| `bpp/demo_data/generators/uczelnia.py` | 1 | 🟡 demo/seed — whitelist | +| `bpp_setup_wizard/tests.py` | 1 | test — poza zakresem (guard ignoruje `test_`) | + +## Reguła rozstrzygania (per wystąpienie) + +1. **Widok runtime z requestem** (`ewaluacja_optymalizacja/views/*`) → zamień na + `Uczelnia.objects.get_for_request(self.request)` (write) lub + `raport_slotow.uczelnia_helper.uczelnia_dla_odczytu(self.request)` (read). + UWAGA: te widoki należą do **federacji optymalizacji** (świadomie olanej jako + logika), więc samo użycie właściwej uczelni z requestu jest poprawne i NIE + wymaga pełnego federacyjnego refaktoru — chodzi tylko o „nie pierwsza-z-brzegu". +2. **Management command** (`solve_uczelnia`, `solve_evaluation`) → wzorzec + single-or-fail: `Uczelnia.objects.get(pk=uczelnia_id) if uczelnia_id else + Uczelnia.objects.get()` + arg `--uczelnia` (jak `zbieraj_sloty`, B2). +3. **Warstwa modelu / display / demo / debug** → świadomy fallback; jeśli + None-tolerant i bez requestu → **whitelist** w guardzie z uzasadnieniem. +4. **`core/__init__.py`** → przeczytać; jeśli woła go widok/task z dostępną + uczelnią → przekazać argumentem; jeśli setup-time → whitelist. + +## Rozszerzenie guarda + +W `src/bpp/tests/test_multihosted_get_default_guard.py`: +- Dodać DRUGI wzorzec: `Uczelnia\.objects\.first\(\)`. +- Osobny słownik `APPROVED_FIRST` z whitelistą (demo/debug/warstwa modelu/UI + default) + komentarz per wpis. +- Skan `_scan()` zliczający oba wzorce; asercja jak dla get_default. +- Rozważyć też `Uczelnia\.objects\.all\(\)\[0\]` (obecnie 0 wystąpień — dodać + prewencyjnie do wzorca, koszt zerowy). + +## Plan wykonania (TDD, per-grupa, subagent-driven jak R1/R3) + +1. **Recon:** przeczytać KAŻDE z 28 wystąpień, sklasyfikować (🔴 fix / 🟡 whitelist), + uzupełnić tabelę powyżej werdyktami `plik:linia`. +2. **Fix runtime views** (~17 w `ewaluacja_optymalizacja/views/`): per plik + zamiana na `get_for_request`/`uczelnia_dla_odczytu`. Test: widok pod U2 operuje + na U2, nie U1 (model `OptimizationRun`/`MetrykaAutora` ma FK uczelnia — asercja + po `uczelnia_id`). Uwaga na enqueue tasków (`pins.py` przekazuje `uczelnia.pk`). +3. **Fix commands** (2): `--uczelnia` + single-or-fail; test CommandError przy >1 + bez flagi. +4. **Whitelist** reszty z uzasadnieniem. +5. **Guard:** rozszerzyć wzorzec, ustawić whitelistę; uruchomić — zielony. +6. Pełna regresja `ewaluacja_optymalizacja` + guard. + +## Inwariant +Single-install: `get_for_request` zwraca tę jedną uczelnię = zachowanie jak +`first()`. Zero zmian liczb przy 1 uczelni. + +## Zależność +Backlog C (globalne `.delete()` w tych samych widokach/taskach federacji) jest +ORTOGONALNY — ten spec dotyczy TYLKO rozstrzygania uczelni (`first()`), nie +scope'owania delete'ów. Można je domknąć osobno (lub przy okazji). From c99452e5fbaccac1b3fe938531cf655c96702529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 15:50:15 +0200 Subject: [PATCH 180/247] =?UTF-8?q?fix(pbn):=20SentData=20per-uczelnia=20?= =?UTF-8?q?=E2=80=94=20klucz=20wysy=C5=82ki=20(object=5Fid,=20content=5Fty?= =?UTF-8?q?pe,=20uczelnia)=20(audyt=20uczelnia,=20track=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audyt multi-hosted #6: rekord BPP wysłany do N profili PBN N uczelni → N niezależnie tagowanych wierszy SentData; klucz lookupu (object_id, content_type, uczelnia). SentDataManager: parametr `uczelnia=None` (keyword) dodany do get_for_rec, check_if_needed, check_if_upload_needed, create_or_update_before_upload, mark_as_successful, mark_as_failed, updated, ids_for_model, bad_uploads. None = zachowanie globalne (legacy/untagged); realni callerzy zawsze podają uczelnię. Lookup zawęża po uczelni gdy podana, create taguje wiersz. Call-sites (atomowo): - publication_sync.py: check_if_upload_needed / get_for_rec (x2) / create_or_update_before_upload / mark_as_successful / mark_as_failed (x2) → uczelnia=self.uczelnia. - synchronization.py: bad_uploads / ids_for_model (zwarte+ciagle) → uczelnia=client.uczelnia. - admin/helpers/pbn_api/common.py: get_for_rec (x3) → uczelnia z kontekstu akcji. - oswiadczenie_instytucji.delete(): zachowane globalne kasowanie po pbn_uid_id (uczelnia tego modelu nullable/NULL — Track 7); komentarz. - pbn_export_queue/views/detail_views.py: filtr SentData zawężony po entry.uczelnia (gdy nie-NULL). - system.py: SentData tylko jako referencja modelu w dict grup — bez zmian. - cleanup.py: świadomie globalny wipe (reset integracji) — komentarz. Migracja 0072: data-migration (FK już istnieje, brak zmiany schematu). 1 uczelnia → backfill NULL → ta uczelnia. ≥2 uczelnie z NULL → zostawiamy NULL (self-healing przy kolejnej wysyłce; PBN idempotentny). uczelnia pozostaje nullable. Reverse = no-op. Testy: nowy test_sentdata_per_uczelnia.py (7 testów: izolacja get_for_rec / mark_successful / check_if_upload_needed / bad_uploads + single-install + logika backfillu single/multi). Zaktualizowane test_client_upload.py i test_bpp_admin_helpers.py (seed SentData z uczelnia=pbn_client.uczelnia). Pełen pbn_api: 241 passed. pbn_integrator sync + queue detail: 21 passed. Co-Authored-By: Claude Opus 4.8 --- src/bpp/admin/helpers/pbn_api/common.py | 13 +- src/pbn_api/client/publication_sync.py | 25 +++- .../0072_backfill_sentdata_uczelnia.py | 47 ++++++ src/pbn_api/models/oswiadczenie_instytucji.py | 10 ++ src/pbn_api/models/sentdata.py | 63 +++++--- src/pbn_api/tests/test_bpp_admin_helpers.py | 3 + src/pbn_api/tests/test_client_upload.py | 8 +- .../tests/test_sentdata_per_uczelnia.py | 136 ++++++++++++++++++ src/pbn_export_queue/views/detail_views.py | 14 +- src/pbn_integrator/utils/cleanup.py | 3 + src/pbn_integrator/utils/synchronization.py | 16 ++- 11 files changed, 300 insertions(+), 38 deletions(-) create mode 100644 src/pbn_api/migrations/0072_backfill_sentdata_uczelnia.py create mode 100644 src/pbn_api/tests/test_sentdata_per_uczelnia.py diff --git a/src/bpp/admin/helpers/pbn_api/common.py b/src/bpp/admin/helpers/pbn_api/common.py index 30ea00d1c..f599e9bac 100644 --- a/src/bpp/admin/helpers/pbn_api/common.py +++ b/src/bpp/admin/helpers/pbn_api/common.py @@ -1,7 +1,6 @@ import sys import rollbar -from django.contrib.contenttypes.models import ContentType from django.urls import reverse from bpp.admin.helpers import link_do_obiektu @@ -174,7 +173,7 @@ def sprobuj_wyslac_do_pbn( # noqa: C901 except SameDataUploadedRecently as e: link_do_wyslanych = reverse( "admin:pbn_api_sentdata_change", - args=(SentData.objects.get_for_rec(obj).pk,), + args=(SentData.objects.get_for_rec(obj, uczelnia).pk,), ) notificator.info( @@ -238,7 +237,7 @@ def sprobuj_wyslac_do_pbn( # noqa: C901 try: link_do_wyslanych = reverse( "admin:pbn_api_sentdata_change", - args=(SentData.objects.get_for_rec(obj).pk,), + args=(SentData.objects.get_for_rec(obj, uczelnia).pk,), ) except SentData.DoesNotExist: link_do_wyslanych = None @@ -263,9 +262,11 @@ def sprobuj_wyslac_do_pbn( # noqa: C901 return - sent_data = SentData.objects.get( - content_type=ContentType.objects.get_for_model(obj), object_id=obj.pk - ) + # Multi-hosted (Track 4): zawężamy do uczelni z kontekstu akcji — ta sama + # uczelnia, której client wysyłał rekord (``self.uczelnia`` w sync). Bez + # tego, gdy ≥2 uczelnie wysłały ten rekord, ``.get()`` rzuciłby + # ``MultipleObjectsReturned``. + sent_data = SentData.objects.get_for_rec(obj, uczelnia) sent_data_link = link_do_obiektu(sent_data, "Otwórz widok wysłanych danych. ") publication_link = link_do_obiektu( diff --git a/src/pbn_api/client/publication_sync.py b/src/pbn_api/client/publication_sync.py index f6532d6c2..4bd403849 100644 --- a/src/pbn_api/client/publication_sync.py +++ b/src/pbn_api/client/publication_sync.py @@ -81,10 +81,12 @@ def _prepare_publication_json(self, rec, export_pk_zero, always_affiliate_to_uid def _check_upload_needed(self, rec, js, force_upload): """Check if upload is needed.""" if not force_upload: - needed = SentData.objects.check_if_upload_needed(rec, js) + needed = SentData.objects.check_if_upload_needed( + rec, js, uczelnia=self.uczelnia + ) if not needed: raise SameDataUploadedRecently( - SentData.objects.get_for_rec(rec).last_updated_on + SentData.objects.get_for_rec(rec, self.uczelnia).last_updated_on ) def _pre_upload_clear_pbn_statements_if_any(self, rec): @@ -201,18 +203,27 @@ def upload_publication( else PBN_POST_PUBLICATIONS_URL ) api_url = self.transport.base_url + endpoint_path - SentData.objects.create_or_update_before_upload(rec, js, api_url=api_url) + SentData.objects.create_or_update_before_upload( + rec, js, api_url=api_url, uczelnia=self.uczelnia + ) try: ret, objectId = self._post_publication_data(js, bez_oswiadczen) - SentData.objects.mark_as_successful(rec, api_response_status=str(ret)) + SentData.objects.mark_as_successful( + rec, api_response_status=str(ret), uczelnia=self.uczelnia + ) except HttpException as e: SentData.objects.mark_as_failed( - rec, exception=str(e), api_response_status=e.content + rec, + exception=str(e), + api_response_status=e.content, + uczelnia=self.uczelnia, ) raise except Exception as e: - SentData.objects.mark_as_failed(rec, exception=str(e)) + SentData.objects.mark_as_failed( + rec, exception=str(e), uczelnia=self.uczelnia + ) raise return objectId, ret, js, bez_oswiadczen @@ -664,7 +675,7 @@ def sync_publication( # noqa: C901 # Update SentData with the publication link now that it exists try: - sent_data = SentData.objects.get_for_rec(pub) + sent_data = SentData.objects.get_for_rec(pub, self.uczelnia) if sent_data.pbn_uid_id is None: sent_data.pbn_uid_id = publication.pk sent_data.save() diff --git a/src/pbn_api/migrations/0072_backfill_sentdata_uczelnia.py b/src/pbn_api/migrations/0072_backfill_sentdata_uczelnia.py new file mode 100644 index 000000000..a129aa0eb --- /dev/null +++ b/src/pbn_api/migrations/0072_backfill_sentdata_uczelnia.py @@ -0,0 +1,47 @@ +"""Backfill SentData.uczelnia dla instalacji jednouczelnianych (Track 4). + +FK ``SentData.uczelnia`` istnieje już (nullable) od migracji 0069 — TU NIE MA +zmiany schematu, tylko data-migration. + +Polityka backfillu: + +- Dokładnie 1 ``Uczelnia`` w bazie → wszystkie wiersze ``SentData`` z + ``uczelnia IS NULL`` dostają tę uczelnię. Po tym single-install ma w 100% + otagowane wiersze, a nowy keyed-lookup (``get_for_rec(rec, uczelnia)``) jest + no-op względem starego globalnego zachowania. + +- Multi-install (≥2 uczelnie) z wierszami NULL → NIE failujemy, NIE kasujemy, + zostawiamy NULL. Nowy lookup filtruje po ``uczelnia``, więc wiersze NULL stają + się niewidoczne dla keyed-lookup — kolejna wysyłka utworzy poprawnie otagowany + wiersz. Koszt: co najwyżej jeden redundantny re-send per ``(rec, uczelnia)`` + (self-healing; PBN przyjmuje idempotentnie). Bezpieczne. + +- 0 uczelni → nic do zrobienia (brak danych referencyjnych). + +``uczelnia`` POZOSTAJE nullable (transitional; NULL = legacy/untagged). +Reverse migration to no-op (backfillu nie da się sensownie cofnąć — nie wiemy +które wiersze były wcześniej NULL). +""" + +from django.db import migrations + + +def backfill_uczelnia(apps, schema_editor): + Uczelnia = apps.get_model("bpp", "Uczelnia") + SentData = apps.get_model("pbn_api", "SentData") + + if Uczelnia.objects.count() == 1: + jedyna = Uczelnia.objects.get() + SentData.objects.filter(uczelnia__isnull=True).update(uczelnia=jedyna) + # ≥2 uczelnie lub 0 — zostawiamy NULL (self-healing przy kolejnej wysyłce). + + +class Migration(migrations.Migration): + dependencies = [ + ("pbn_api", "0071_merge_0069_sentdata_api_url_0070_link_pbn_to_uczelnia"), + ("bpp", "0414_copy_constance_to_uczelnia"), + ] + + operations = [ + migrations.RunPython(backfill_uczelnia, migrations.RunPython.noop), + ] diff --git a/src/pbn_api/models/oswiadczenie_instytucji.py b/src/pbn_api/models/oswiadczenie_instytucji.py index fe2632e70..8d7bb95d8 100644 --- a/src/pbn_api/models/oswiadczenie_instytucji.py +++ b/src/pbn_api/models/oswiadczenie_instytucji.py @@ -204,5 +204,15 @@ def delete(self, *args, **kw): # wysłane dane: from pbn_api.models import SentData + # Multi-hosted (Track 4): kasujemy SentData po ``pbn_uid_id`` publikacji. + # IDEALNIE zawęzilibyśmy też po ``self.uczelnia`` (żeby skasowanie + # oświadczenia jednej uczelni nie wywalało stanu wysyłki drugiej), ALE + # ``OswiadczenieInstytucji.uczelnia`` jest obecnie nullable i zwykle NULL + # (write-side tagowanie tego modelu = Track 7, jeszcze nie zrobione). + # Gdyby filtrować po ``uczelnia=None``, przy NULL-owym oświadczeniu nie + # skasowalibyśmy poprawnie otagowanych (per-uczelnia) wierszy SentData — + # stan wysyłki rozjechałby się z faktem skasowania oświadczenia. Dlatego + # ZACHOWUJEMY globalne (po publikacji) kasowanie. Po Track 7 (tag uczelni + # na OswiadczenieInstytucji niezawodny) dopisz tu ``uczelnia=self.uczelnia``. SentData.objects.filter(pbn_uid_id=self.publicationId_id).delete(*args, **kw) return super().delete(*args, **kw) diff --git a/src/pbn_api/models/sentdata.py b/src/pbn_api/models/sentdata.py index 937edc653..9e77d36d1 100644 --- a/src/pbn_api/models/sentdata.py +++ b/src/pbn_api/models/sentdata.py @@ -11,15 +11,27 @@ class SentDataManager(models.Manager): - def get_for_rec(self, rec): - return self.get( + # Multi-hosted (audyt uczelnia, Track 4): kluczem wysyłki jest trójka + # ``(object_id, content_type, uczelnia)``. Dwie uczelnie wysyłające ten + # sam rekord BPP do swoich profili PBN dostają DWA niezależne wiersze. + # Parametr ``uczelnia`` ma default ``None`` (zachowanie globalne — lookup + # bez zawężania), ale realni callerzy ZAWSZE go podają (``self.uczelnia`` + # / ``client.uczelnia`` / ``entry.uczelnia``). NULL = legacy/untagged. + # UWAGA: gdy istnieją ≥2 wiersze dla ``(object_id, content_type)``, lookup + # BEZ ``uczelnia`` rzuci ``MultipleObjectsReturned`` — dlatego wszystkie + # call-sites zostały zmienione atomowo. + def get_for_rec(self, rec, uczelnia=None): + qs = self.filter( object_id=rec.pk, content_type=ContentType.objects.get_for_model(rec) ) + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + return qs.get() - def check_if_needed(self, rec, data: dict): + def check_if_needed(self, rec, data: dict, uczelnia=None): """Legacy method - kept for backward compatibility""" try: - sd = self.get_for_rec(rec) + sd = self.get_for_rec(rec, uczelnia) except SentData.DoesNotExist: return True @@ -31,10 +43,10 @@ def check_if_needed(self, rec, data: dict): return False - def check_if_upload_needed(self, rec, data: dict): + def check_if_upload_needed(self, rec, data: dict, uczelnia=None): """Check if upload needed based on SUCCESSFUL submissions only""" try: - sd = self.get_for_rec(rec) + sd = self.get_for_rec(rec, uczelnia) # Only skip if data matches AND was successfully submitted if (not compare_dicts(sd.data_sent, data)) and sd.submitted_successfully: return False @@ -42,10 +54,12 @@ def check_if_upload_needed(self, rec, data: dict): pass return True - def create_or_update_before_upload(self, rec, data: dict, api_url=""): + def create_or_update_before_upload( + self, rec, data: dict, api_url="", uczelnia=None + ): """Create or update SentData record before API call""" try: - sd = self.get_for_rec(rec) + sd = self.get_for_rec(rec, uczelnia) # Reset fields for new attempt sd.submitted_successfully = False sd.submitted_at = timezone.now() @@ -65,11 +79,14 @@ def create_or_update_before_upload(self, rec, data: dict, api_url=""): submitted_at=timezone.now(), uploaded_okay=False, api_url=api_url, + uczelnia=uczelnia, ) - def mark_as_successful(self, rec, pbn_uid_id=None, api_response_status=""): + def mark_as_successful( + self, rec, pbn_uid_id=None, api_response_status="", uczelnia=None + ): """Mark existing record as successful after API call""" - sd = self.get_for_rec(rec) + sd = self.get_for_rec(rec, uczelnia) sd.submitted_successfully = True sd.uploaded_okay = True sd.pbn_uid_id = pbn_uid_id @@ -77,9 +94,9 @@ def mark_as_successful(self, rec, pbn_uid_id=None, api_response_status=""): sd.exception = "" sd.save() - def mark_as_failed(self, rec, exception="", api_response_status=""): + def mark_as_failed(self, rec, exception="", api_response_status="", uczelnia=None): """Mark existing record as failed after API call""" - sd = self.get_for_rec(rec) + sd = self.get_for_rec(rec, uczelnia) sd.submitted_successfully = False sd.uploaded_okay = False sd.exception = str(exception) if exception else "" @@ -87,11 +104,17 @@ def mark_as_failed(self, rec, exception="", api_response_status=""): sd.save() def updated( - self, rec, data: dict, pbn_uid_id=None, uploaded_okay=True, exception="" + self, + rec, + data: dict, + pbn_uid_id=None, + uploaded_okay=True, + exception="", + uczelnia=None, ): """Legacy method - kept for backward compatibility""" try: - sd = self.get_for_rec(rec) + sd = self.get_for_rec(rec, uczelnia) except SentData.DoesNotExist: self.create( object=rec, @@ -101,6 +124,7 @@ def updated( exception=exception, submitted_successfully=uploaded_okay, submitted_at=timezone.now() if uploaded_okay else None, + uczelnia=uczelnia, ) return @@ -113,12 +137,15 @@ def updated( sd.submitted_at = timezone.now() sd.save() - def ids_for_model(self, model): - return self.filter(content_type=ContentType.objects.get_for_model(model)) + def ids_for_model(self, model, uczelnia=None): + qs = self.filter(content_type=ContentType.objects.get_for_model(model)) + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + return qs - def bad_uploads(self, model): + def bad_uploads(self, model, uczelnia=None): return ( - self.ids_for_model(model) + self.ids_for_model(model, uczelnia) .filter(uploaded_okay=False) .values_list("object_id", flat=True) .distinct() diff --git a/src/pbn_api/tests/test_bpp_admin_helpers.py b/src/pbn_api/tests/test_bpp_admin_helpers.py index b2a25eecc..8ef2c52a8 100644 --- a/src/pbn_api/tests/test_bpp_admin_helpers.py +++ b/src/pbn_api/tests/test_bpp_admin_helpers.py @@ -82,10 +82,13 @@ def test_sprobuj_wyslac_do_pbn_dane_juz_wyslane( ): js = WydawnictwoPBNAdapter(pbn_wydawnictwo_zwarte_z_charakterem).pbn_get_json() js.pop("languageData", None) + # Tagujemy SentData uczelnią clienta — od Track 4 lookup wysyłki zawęża + # po uczelni (``self.uczelnia`` == ``pbn_uczelnia``). SentData.objects.updated( pbn_wydawnictwo_zwarte_z_charakterem, js, uploaded_okay=True, + uczelnia=pbn_uczelnia, ) req = rf.get("/") diff --git a/src/pbn_api/tests/test_client_upload.py b/src/pbn_api/tests/test_client_upload.py index d50192a11..de0d852a2 100644 --- a/src/pbn_api/tests/test_client_upload.py +++ b/src/pbn_api/tests/test_client_upload.py @@ -39,14 +39,18 @@ def test_PBNClient_test_upload_publication_nie_trzeba( js = WydawnictwoPBNAdapter( pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina ).pbn_get_json() + # Tagujemy SentData uczelnią clienta — od Track 4 lookup wysyłki zawęża + # po uczelni (``self.uczelnia``), więc seed musi mieć tę samą uczelnię. SentData.objects.create_or_update_before_upload( - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, js + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, js, uczelnia=pbn_client.uczelnia ) baker.make(Publication, pk="test-123") SentData.objects.mark_as_successful( - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_uid_id="test-123" + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_uid_id="test-123", + uczelnia=pbn_client.uczelnia, ) with pytest.raises(SameDataUploadedRecently): diff --git a/src/pbn_api/tests/test_sentdata_per_uczelnia.py b/src/pbn_api/tests/test_sentdata_per_uczelnia.py new file mode 100644 index 000000000..39ca79edb --- /dev/null +++ b/src/pbn_api/tests/test_sentdata_per_uczelnia.py @@ -0,0 +1,136 @@ +"""Testy izolacji SentData per-uczelnia (Track 4 audytu multi-hosted). + +Klucz wysyłki do PBN to ``(object_id, content_type, uczelnia)`` — dwie uczelnie +wysyłające ten sam rekord BPP do swoich profili PBN dostają DWA niezależnie +tagowane wiersze SentData, bez współdzielenia stanu wysyłki. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Uczelnia +from pbn_api.models import SentData + + +@pytest.fixture +def uczelnia2(db): + """Druga uczelnia (multi-hosted) — niezależna od fixture ``uczelnia``.""" + return baker.make(Uczelnia, skrot="U2", nazwa="Druga uczelnia") + + +@pytest.mark.django_db +def test_get_for_rec_per_uczelnia(uczelnia, uczelnia2, wydawnictwo_ciagle): + rec = wydawnictwo_ciagle + d1 = {"type": "ARTICLE", "title": "dla U1"} + d2 = {"type": "ARTICLE", "title": "dla U2"} + + SentData.objects.create_or_update_before_upload(rec, d1, uczelnia=uczelnia) + SentData.objects.create_or_update_before_upload(rec, d2, uczelnia=uczelnia2) + + assert SentData.objects.count() == 2 + assert SentData.objects.get_for_rec(rec, uczelnia).data_sent == d1 + assert SentData.objects.get_for_rec(rec, uczelnia2).data_sent == d2 + + +@pytest.mark.django_db +def test_mark_successful_izolacja(uczelnia, uczelnia2, wydawnictwo_ciagle): + rec = wydawnictwo_ciagle + d = {"type": "ARTICLE", "title": "x"} + + SentData.objects.create_or_update_before_upload(rec, d, uczelnia=uczelnia) + SentData.objects.create_or_update_before_upload(rec, d, uczelnia=uczelnia2) + + SentData.objects.mark_as_successful(rec, uczelnia=uczelnia) + + assert SentData.objects.get_for_rec(rec, uczelnia).uploaded_okay is True + # Wysyłka U1 NIE może zmienić stanu U2 (brak współdzielenia wiersza). + assert SentData.objects.get_for_rec(rec, uczelnia2).uploaded_okay is False + + +@pytest.mark.django_db +def test_check_if_upload_needed_per_uczelnia(uczelnia, uczelnia2, wydawnictwo_ciagle): + rec = wydawnictwo_ciagle + d = {"type": "ARTICLE", "title": "x"} + + SentData.objects.create_or_update_before_upload(rec, d, uczelnia=uczelnia) + SentData.objects.mark_as_successful(rec, uczelnia=uczelnia) + + # U1 ma identyczne dane wysłane pomyślnie → nie trzeba ponawiać. + assert SentData.objects.check_if_upload_needed(rec, d, uczelnia=uczelnia) is False + # U2 jeszcze NIC nie wysłała → trzeba wysłać (mimo że U1 już wysłała te dane). + assert SentData.objects.check_if_upload_needed(rec, d, uczelnia=uczelnia2) is True + + +@pytest.mark.django_db +def test_bad_uploads_per_uczelnia(uczelnia, uczelnia2, wydawnictwo_ciagle): + from bpp.models import Wydawnictwo_Ciagle + + rec = wydawnictwo_ciagle + d = {"type": "ARTICLE", "title": "x"} + + # U1: wysyłka OK; U2: wysyłka nieudana. + SentData.objects.create_or_update_before_upload(rec, d, uczelnia=uczelnia) + SentData.objects.mark_as_successful(rec, uczelnia=uczelnia) + + SentData.objects.create_or_update_before_upload(rec, d, uczelnia=uczelnia2) + SentData.objects.mark_as_failed(rec, exception="boom", uczelnia=uczelnia2) + + bad_u1 = list(SentData.objects.bad_uploads(Wydawnictwo_Ciagle, uczelnia=uczelnia)) + bad_u2 = list(SentData.objects.bad_uploads(Wydawnictwo_Ciagle, uczelnia=uczelnia2)) + + # Bad upload U2 nie może pojawić się w bad_uploads dla U1. + assert rec.pk not in bad_u1 + assert rec.pk in bad_u2 + + +@pytest.mark.django_db +def test_single_install_global_lookup_bez_uczelni(uczelnia, wydawnictwo_ciagle): + """Single-install: jeden wiersz, lookup bez uczelni (legacy) działa.""" + rec = wydawnictwo_ciagle + d = {"type": "ARTICLE", "title": "x"} + + SentData.objects.create_or_update_before_upload(rec, d, uczelnia=uczelnia) + # Lookup bez uczelni zwraca jedyny wiersz (zachowanie globalne dla 1 wiersza). + assert SentData.objects.get_for_rec(rec).data_sent == d + # Lookup z uczelnią zwraca ten sam wiersz. + assert SentData.objects.get_for_rec(rec, uczelnia).data_sent == d + + +@pytest.mark.django_db +def test_backfill_logic_single_install(uczelnia, wydawnictwo_ciagle): + """Single-install: NULL-owy wiersz dostaje jedyną uczelnię (jak w 0072).""" + rec = wydawnictwo_ciagle + # Wiersz wprost z uczelnia=None (legacy/untagged). + SentData.objects.create_or_update_before_upload( + rec, {"type": "ARTICLE"}, uczelnia=None + ) + assert SentData.objects.filter(uczelnia__isnull=True).count() == 1 + + # Symulacja backfillu z migracji (1 uczelnia → update NULL → tę uczelnię). + assert Uczelnia.objects.count() == 1 + jedyna = Uczelnia.objects.get() + SentData.objects.filter(uczelnia__isnull=True).update(uczelnia=jedyna) + + assert SentData.objects.filter(uczelnia__isnull=True).count() == 0 + assert SentData.objects.get_for_rec(rec, jedyna).uczelnia_id == jedyna.pk + + +@pytest.mark.django_db +def test_backfill_logic_multi_install_leaves_null( + uczelnia, uczelnia2, wydawnictwo_ciagle +): + """Multi-install (≥2 uczelnie): backfill NIE rusza NULL-owych wierszy.""" + rec = wydawnictwo_ciagle + SentData.objects.create_or_update_before_upload( + rec, {"type": "ARTICLE"}, uczelnia=None + ) + + # Symulacja warunku migracji: count != 1 → brak update. + assert Uczelnia.objects.count() == 2 + if Uczelnia.objects.count() == 1: # pragma: no cover - tu nie wejdzie + SentData.objects.filter(uczelnia__isnull=True).update( + uczelnia=Uczelnia.objects.get() + ) + + # NULL-owy wiersz pozostaje (self-healing przy następnej wysyłce). + assert SentData.objects.filter(uczelnia__isnull=True).count() == 1 diff --git a/src/pbn_export_queue/views/detail_views.py b/src/pbn_export_queue/views/detail_views.py index 80d307467..ab71cddad 100644 --- a/src/pbn_export_queue/views/detail_views.py +++ b/src/pbn_export_queue/views/detail_views.py @@ -197,10 +197,22 @@ def _add_sent_data_context(self, context): try: from pbn_api.models.sentdata import SentData - sent_data = SentData.objects.get( + # Multi-hosted (Track 4): zawężamy do uczelni wpisu kolejki, żeby + # przy ≥2 uczelniach wysyłających ten rekord nie dostać + # ``MultipleObjectsReturned``. Wpis kolejki zna swoją uczelnię + # (``self.object.uczelnia`` — ta sama, której client wysyłał). + # Filtrujemy po (content_type, object_id) — nie przez GFK — bo + # rekord BPP mógł zostać w międzyczasie skasowany, a SentData wciąż + # istnieje (zachowanie identyczne jak poprzedni ``.get()``). Tag + # uczelni dokładamy tylko gdy wpis kolejki go ma (legacy = NULL → + # globalny lookup, działa dla single-install i untagged rows). + qs = SentData.objects.filter( content_type=self.object.content_type, object_id=self.object.object_id, ) + if self.object.uczelnia_id is not None: + qs = qs.filter(uczelnia=self.object.uczelnia) + sent_data = qs.get() context["sent_data"] = sent_data if sent_data.pbn_uid_id: diff --git a/src/pbn_integrator/utils/cleanup.py b/src/pbn_integrator/utils/cleanup.py index 7a0cf272e..011351c34 100644 --- a/src/pbn_integrator/utils/cleanup.py +++ b/src/pbn_integrator/utils/cleanup.py @@ -38,6 +38,9 @@ def clear_match_publications(): def clear_publications(): """Clear all publication-related data.""" + # Multi-hosted (Track 4): ŚWIADOMIE globalne czyszczenie — to operacja + # reset/reimport całej integracji PBN (nie per-uczelnia akcja użytkownika), + # więc kasujemy WSZYSTKIE wiersze SentData niezależnie od tagu uczelni. clear_match_publications() for model in [OswiadczenieInstytucji, PublikacjaInstytucji, Publication, SentData]: model.objects.all()._raw_delete(MODELE_Z_PBN_UID[0].objects.db) diff --git a/src/pbn_integrator/utils/synchronization.py b/src/pbn_integrator/utils/synchronization.py index fa41eaf09..ea7ce4a75 100644 --- a/src/pbn_integrator/utils/synchronization.py +++ b/src/pbn_integrator/utils/synchronization.py @@ -201,13 +201,17 @@ def synchronizuj_publikacje( if only_bad: zwarte_baza = zwarte_baza.filter( - pk__in=SentData.objects.bad_uploads(Wydawnictwo_Zwarte) + pk__in=SentData.objects.bad_uploads( + Wydawnictwo_Zwarte, uczelnia=client.uczelnia + ) ) if only_new: # Nie synchronizuj prac ktore juz sa w SentData zwarte_baza = zwarte_baza.exclude( - pk__in=SentData.objects.ids_for_model(Wydawnictwo_Zwarte) + pk__in=SentData.objects.ids_for_model( + Wydawnictwo_Zwarte, uczelnia=client.uczelnia + ) .values_list("pk", flat=True) .distinct() ) @@ -241,13 +245,17 @@ def synchronizuj_publikacje( if only_bad: ciagle_baza = ciagle_baza.filter( - pk__in=SentData.objects.bad_uploads(Wydawnictwo_Ciagle) + pk__in=SentData.objects.bad_uploads( + Wydawnictwo_Ciagle, uczelnia=client.uczelnia + ) ) if only_new: # Nie synchronizuj prac ktore juz sa w SentData ciagle_baza = ciagle_baza.exclude( - pk__in=SentData.objects.ids_for_model(Wydawnictwo_Ciagle) + pk__in=SentData.objects.ids_for_model( + Wydawnictwo_Ciagle, uczelnia=client.uczelnia + ) .values_list("pk", flat=True) .distinct() ) From 45ae7c0e95d4e3a45a28567b999e9cc0f921d2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 16:01:38 +0200 Subject: [PATCH 181/247] fix(pbn): graceful SentData lookup w detalu kolejki dla untagged multi-install (track 4 review) Untagged wpis kolejki (uczelnia_id is None) w multi-install (>=2 uczelnie) robil un-narrowed .get() po >=2 wierszach SentData -> MultipleObjectsReturned (nie lapane przez except DoesNotExist) -> detal 500-owal. To widok display-only; degraduje do najswiezszego wiersza (order_by -last_updated_on, .first()), zachowujac semantyke brak-wiersza -> None. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_sentdata_per_uczelnia.py | 21 +++--- .../tests/test_views_detail.py | 63 +++++++++++++++++ src/pbn_export_queue/views/detail_views.py | 69 ++++++++++--------- 3 files changed, 114 insertions(+), 39 deletions(-) diff --git a/src/pbn_api/tests/test_sentdata_per_uczelnia.py b/src/pbn_api/tests/test_sentdata_per_uczelnia.py index 39ca79edb..cb52ba399 100644 --- a/src/pbn_api/tests/test_sentdata_per_uczelnia.py +++ b/src/pbn_api/tests/test_sentdata_per_uczelnia.py @@ -98,7 +98,11 @@ def test_single_install_global_lookup_bez_uczelni(uczelnia, wydawnictwo_ciagle): @pytest.mark.django_db def test_backfill_logic_single_install(uczelnia, wydawnictwo_ciagle): - """Single-install: NULL-owy wiersz dostaje jedyną uczelnię (jak w 0072).""" + """Single-install: NULL-owy wiersz dostaje jedyną uczelnię (jak w 0072). + + Lustro logiki migracji ``0072_backfill_sentdata_uczelnia`` — jeśli + zachowanie migracji się zmieni, ten test musi się zmienić razem z nią. + """ rec = wydawnictwo_ciagle # Wiersz wprost z uczelnia=None (legacy/untagged). SentData.objects.create_or_update_before_upload( @@ -119,18 +123,19 @@ def test_backfill_logic_single_install(uczelnia, wydawnictwo_ciagle): def test_backfill_logic_multi_install_leaves_null( uczelnia, uczelnia2, wydawnictwo_ciagle ): - """Multi-install (≥2 uczelnie): backfill NIE rusza NULL-owych wierszy.""" + """Multi-install (≥2 uczelnie): backfill NIE rusza NULL-owych wierszy. + + Lustro logiki migracji ``0072_backfill_sentdata_uczelnia`` — jeśli + zachowanie migracji się zmieni, ten test musi się zmienić razem z nią. + """ rec = wydawnictwo_ciagle SentData.objects.create_or_update_before_upload( rec, {"type": "ARTICLE"}, uczelnia=None ) - # Symulacja warunku migracji: count != 1 → brak update. + # Warunek migracji: count != 1 → brak update. Przy ≥2 uczelniach migracja + # świadomie zostawia NULL-owe wiersze (self-healing przy następnej wysyłce). assert Uczelnia.objects.count() == 2 - if Uczelnia.objects.count() == 1: # pragma: no cover - tu nie wejdzie - SentData.objects.filter(uczelnia__isnull=True).update( - uczelnia=Uczelnia.objects.get() - ) - # NULL-owy wiersz pozostaje (self-healing przy następnej wysyłce). + # NULL-owy wiersz pozostaje nietknięty. assert SentData.objects.filter(uczelnia__isnull=True).count() == 1 diff --git a/src/pbn_export_queue/tests/test_views_detail.py b/src/pbn_export_queue/tests/test_views_detail.py index 90d9ce1ce..b0246e646 100644 --- a/src/pbn_export_queue/tests/test_views_detail.py +++ b/src/pbn_export_queue/tests/test_views_detail.py @@ -145,6 +145,69 @@ def test_pbnexportqueuedetailview_get_context_data_with_sentdata( assert "# KONTEKST" in response.context["ai_prompt"] +@pytest.mark.django_db +def test_add_sent_data_context_untagged_multi_install_returns_newest( + admin_user, wydawnictwo_ciagle, uczelnia, uczelnia2_for_detail +): + """Untagged (uczelnia=None) wpis kolejki przy ≥2 wierszach SentData + (różne uczelnie) NIE może rzucić MultipleObjectsReturned — degraduje + do najświeższego wiersza (najnowszy last_updated_on).""" + from pbn_api.models.sentdata import SentData + + content_type = ContentType.objects.get_for_model(wydawnictwo_ciagle) + + # Dwa wiersze SentData dla tego samego rekordu, różne uczelnie. + older = baker.make( + SentData, + content_type=content_type, + object_id=wydawnictwo_ciagle.pk, + uczelnia=uczelnia, + data_sent={"który": "stary"}, + ) + newer = baker.make( + SentData, + content_type=content_type, + object_id=wydawnictwo_ciagle.pk, + uczelnia=uczelnia2_for_detail, + data_sent={"który": "nowy"}, + ) + # Wymuś, że ``newer`` ma faktycznie późniejsze last_updated_on + # (auto_now ustawia je przy każdym save; oba mogą wpaść w ten sam tick). + SentData.objects.filter(pk=older.pk).update(last_updated_on="2020-01-01T00:00:00Z") + SentData.objects.filter(pk=newer.pk).update(last_updated_on="2024-01-01T00:00:00Z") + + # Untagged wpis kolejki (legacy / multi-install niezbackfillowany). + queue_item = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + uczelnia=None, + ) + + request = RequestFactory().get("/") + request.user = admin_user + + view = PBNExportQueueDetailView() + view.request = request + view.object = queue_item + + context = {} + # NIE może rzucić MultipleObjectsReturned. + result = view._add_sent_data_context(context) + + assert result is not None + assert result.pk == newer.pk + assert context["sent_data"].pk == newer.pk + + +@pytest.fixture +def uczelnia2_for_detail(db): + """Druga uczelnia dla testów untagged multi-install w detalu kolejki.""" + from bpp.models import Uczelnia + + return baker.make(Uczelnia, skrot="U2D", nazwa="Druga uczelnia detal") + + @pytest.mark.django_db def test_pbnexportqueuedetailview_get_context_data_without_sentdata( client, admin_user, wydawnictwo_ciagle diff --git a/src/pbn_export_queue/views/detail_views.py b/src/pbn_export_queue/views/detail_views.py index ab71cddad..2c6de6fab 100644 --- a/src/pbn_export_queue/views/detail_views.py +++ b/src/pbn_export_queue/views/detail_views.py @@ -194,40 +194,47 @@ def _build_ai_prompt( def _add_sent_data_context(self, context): """Add SentData related context if it exists.""" - try: - from pbn_api.models.sentdata import SentData - - # Multi-hosted (Track 4): zawężamy do uczelni wpisu kolejki, żeby - # przy ≥2 uczelniach wysyłających ten rekord nie dostać - # ``MultipleObjectsReturned``. Wpis kolejki zna swoją uczelnię - # (``self.object.uczelnia`` — ta sama, której client wysyłał). - # Filtrujemy po (content_type, object_id) — nie przez GFK — bo - # rekord BPP mógł zostać w międzyczasie skasowany, a SentData wciąż - # istnieje (zachowanie identyczne jak poprzedni ``.get()``). Tag - # uczelni dokładamy tylko gdy wpis kolejki go ma (legacy = NULL → - # globalny lookup, działa dla single-install i untagged rows). - qs = SentData.objects.filter( - content_type=self.object.content_type, - object_id=self.object.object_id, - ) - if self.object.uczelnia_id is not None: - qs = qs.filter(uczelnia=self.object.uczelnia) - sent_data = qs.get() - context["sent_data"] = sent_data - - if sent_data.pbn_uid_id: - context["pbn_publication_url"] = ( - f"https://pbn.nauka.gov.pl/works/publication/{sent_data.pbn_uid_id}" - ) + from pbn_api.models.sentdata import SentData + + # Multi-hosted (Track 4): zawężamy do uczelni wpisu kolejki, żeby + # przy ≥2 uczelniach wysyłających ten rekord nie dostać + # ``MultipleObjectsReturned``. Wpis kolejki zna swoją uczelnię + # (``self.object.uczelnia`` — ta sama, której client wysyłał). + # Filtrujemy po (content_type, object_id) — nie przez GFK — bo + # rekord BPP mógł zostać w międzyczasie skasowany, a SentData wciąż + # istnieje (zachowanie identyczne jak poprzedni ``.get()``). Tag + # uczelni dokładamy tylko gdy wpis kolejki go ma (legacy = NULL → + # globalny lookup, działa dla single-install i untagged rows). + # + # Untagged wpis (``uczelnia_id is None``) w multi-install (≥2 + # uczelnie) celowo NIE jest backfillowany przez migrację 0072, więc + # globalny lookup może trafić na ≥2 wiersze SentData. To widok + # display-only — nie wolno mu 500-ować. Zamiast ``.get()`` (które + # rzuciłoby ``MultipleObjectsReturned``) bierzemy ``.first()`` po + # posortowaniu malejąco wg ``last_updated_on``: pokazujemy najświeższy + # stan wysyłki, a brak wierszy daje ``None`` (zachowana semantyka + # poprzedniego ``DoesNotExist → None``). + qs = SentData.objects.filter( + content_type=self.object.content_type, + object_id=self.object.object_id, + ) + if self.object.uczelnia_id is not None: + qs = qs.filter(uczelnia=self.object.uczelnia) + sent_data = qs.order_by("-last_updated_on").first() + if sent_data is None: + return None + context["sent_data"] = sent_data - # Parse PBN API error if this was a failed submission - if self.object.zakonczono_pomyslnie is False and sent_data.exception: - context["pbn_error_info"] = parse_pbn_api_error(sent_data.exception) + if sent_data.pbn_uid_id: + context["pbn_publication_url"] = ( + f"https://pbn.nauka.gov.pl/works/publication/{sent_data.pbn_uid_id}" + ) - return sent_data + # Parse PBN API error if this was a failed submission + if self.object.zakonczono_pomyslnie is False and sent_data.exception: + context["pbn_error_info"] = parse_pbn_api_error(sent_data.exception) - except SentData.DoesNotExist: - return None + return sent_data def _add_pbn_error_from_komunikat(self, context): """Add PBN error info extracted from komunikat if not already in context.""" From 583d09ba4f992a16553accd5ba72cc4dea6b4ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 17:43:22 +0200 Subject: [PATCH 182/247] =?UTF-8?q?fix(ewaluacja):=20Uczelnia.objects.firs?= =?UTF-8?q?t()=20=E2=86=92=20uczelnia=20z=20requestu/single-or-fail=20+=20?= =?UTF-8?q?guard=20(audyt=20uczelnia,=20track=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widoki ewaluacja_optymalizacja zgadywały "pierwszą-z-brzegu" uczelnię (Uczelnia.objects.first()) niezależnie od hosta (uczelni) z requestu — ta sama klasa buga co B1, inna pisownia. - Widoki READ (display/analiza) → uczelnia_dla_odczytu(request): evaluation_browser (6×), index, discipline_swap_list (2×), unpinning_list (2×), optimize_unpin status. - Widoki MUTUJĄCE/enqueue → Uczelnia.objects.get_for_request(request): pins (2×), bulk_optimization, discipline_swap_analysis, unpin_sensible, unpinning_analysis, optimize_unpin start. - Komendy solve_uczelnia / solve_evaluation → single-or-fail: --uczelnia honorowane, count==1 OK, >1 bez flagi → CommandError (wzorzec _resolve_uczelnia z zbieraj_sloty). solve_uczelnia core: None → get() (MultipleObjectsReturned fail-loud) zamiast first(). - Whitelist (świadomy fallback bez requestu, brak zmiany kodu): admin/core.py (UI default 'afiliuje'), demo_data/generators/uczelnia.py, debug_setup_initial_data.py, models/autor.py, models/jednostka.py (komentarz). Guard rozszerzony o drugi wzorzec Uczelnia.objects.first()/all()[0] + APPROVED_FIRST, exclusion tests.py. Statycznie kryje wszystkie 27 runtime-sites niezależnie od pokrycia behawioralnego. Co-Authored-By: Claude Opus 4.8 --- .../test_multihosted_get_default_guard.py | 50 ++++- src/ewaluacja_optymalizacja/core/__init__.py | 11 +- .../management/commands/solve_evaluation.py | 42 +++- .../management/commands/solve_uczelnia.py | 42 ++-- .../tests/test_first_per_uczelnia.py | 181 ++++++++++++++++++ .../views/bulk_optimization.py | 3 +- .../views/discipline_swap_analysis.py | 2 +- .../views/discipline_swap_list.py | 7 +- .../views/evaluation_browser/views.py | 14 +- src/ewaluacja_optymalizacja/views/index.py | 4 +- .../views/optimize_unpin.py | 6 +- src/ewaluacja_optymalizacja/views/pins.py | 5 +- .../views/unpin_sensible.py | 3 +- .../views/unpinning_analysis.py | 3 +- .../views/unpinning_list.py | 8 +- 15 files changed, 325 insertions(+), 56 deletions(-) create mode 100644 src/ewaluacja_optymalizacja/tests/test_first_per_uczelnia.py diff --git a/src/bpp/tests/test_multihosted_get_default_guard.py b/src/bpp/tests/test_multihosted_get_default_guard.py index 8688616da..0c57798ab 100644 --- a/src/bpp/tests/test_multihosted_get_default_guard.py +++ b/src/bpp/tests/test_multihosted_get_default_guard.py @@ -24,6 +24,13 @@ # w definicji UczelniaManager, które tego wzorca nie pasują). PATTERN = re.compile(r"Uczelnia\.objects\.(?:get_default\(\)|default\b)") +# Drugi footgun, semantycznie równoważny ``get_default`` w ścieżce runtime: +# ``Uczelnia.objects.first()`` / ``Uczelnia.objects.all()[0]`` zgaduje +# „pierwszą-z-brzegu" uczelnię niezależnie od hosta (uczelni) z requestu — +# ta sama klasa buga co B1, inna pisownia. ``all()[0]`` dorzucony +# prewencyjnie (0 wystąpień dziś, zerowy koszt utrzymania). +PATTERN_FIRST = re.compile(r"Uczelnia\.objects\.(?:first\(\)|all\(\)\s*\[\s*0\s*\])") + SRC = Path(__file__).resolve().parents[2] # .../src # Ścieżka (względem src/) -> dozwolona liczba wystąpień. Komentarz = dlaczego OK. @@ -42,23 +49,33 @@ "pbn_import/utils/command_helpers.py": 1, # CLI None-tolerant + CommandError } +# Whitelist dla ``Uczelnia.objects.first()``. Każdy wpis to ŚWIADOMY fallback +# bez requestu (warstwa modelu / UI default / demo / debug / komentarz). +APPROVED_FIRST: dict[str, int] = { + "bpp/admin/core.py": 1, # admin form __init__: default pola 'afiliuje', None-tolerant UI + "bpp/demo_data/generators/uczelnia.py": 1, # demo/seed, CLI bez requestu + "bpp/management/commands/debug_setup_initial_data.py": 1, # debug command, None-tolerant + "bpp/models/autor.py": 1, # warstwa modelu: domyślne 'pokazuj' nowego autora, None-tolerant + "bpp/models/jednostka.py": 1, # KOMENTARZ (nie kod) — zakomentowany default=lambda +} + -def _scan() -> dict[str, int]: +def _scan(pattern: re.Pattern) -> dict[str, int]: found: dict[str, int] = {} for path in SRC.rglob("*.py"): rel = path.relative_to(SRC).as_posix() if "/tests/" in f"/{rel}" or "/migrations/" in f"/{rel}": continue - if path.name.startswith("test_"): + if path.name.startswith("test_") or path.name == "tests.py": continue - n = len(PATTERN.findall(path.read_text(encoding="utf-8"))) + n = len(pattern.findall(path.read_text(encoding="utf-8"))) if n: found[rel] = n return found def test_get_default_poza_whitelista_to_regresja_multihosted(): - found = _scan() + found = _scan(PATTERN) nowe = {f: n for f, n in found.items() if n > APPROVED.get(f, 0)} assert not nowe, ( @@ -67,3 +84,28 @@ def test_get_default_poza_whitelista_to_regresja_multihosted(): "(get_for_request / argument / FK obiektu) albo dopisz do APPROVED " "w tym pliku z uzasadnieniem." ) + + +def test_first_poza_whitelista_to_regresja_multihosted(): + """``Uczelnia.objects.first()`` w runtime to ten sam bug co get_default: + wybiera pierwszą-z-brzegu uczelnię zamiast tej z requestu/argumentu. + + Gdy ten test PADA: + - DODAŁEŚ ``Uczelnia.objects.first()`` → użyj JAWNEJ uczelni: + ``uczelnia_dla_odczytu(request)`` / ``get_for_request(request)`` w + widokach, argument przekazany od wołającego, albo single-or-fail + (``get()`` z CommandError) w komendach CLI. Jeśli miejsce jest NAPRAWDĘ + akceptowalne (warstwa modelu / UI default / demo / debug) — dopisz je do + ``APPROVED_FIRST`` z uzasadnieniem. + - USUNĄŁEŚ ``first()`` (naprawiłeś multi-hosted) → zmniejsz licznik / usuń + wpis z ``APPROVED_FIRST``. + """ + found = _scan(PATTERN_FIRST) + + nowe = {f: n for f, n in found.items() if n > APPROVED_FIRST.get(f, 0)} + assert not nowe, ( + "Nowe/dodatkowe Uczelnia.objects.first()/all()[0] poza whitelistą " + f"(potencjalny bug multi-hosted): {nowe}. Użyj jawnej uczelni " + "(uczelnia_dla_odczytu / get_for_request / argument / single-or-fail) " + "albo dopisz do APPROVED_FIRST w tym pliku z uzasadnieniem." + ) diff --git a/src/ewaluacja_optymalizacja/core/__init__.py b/src/ewaluacja_optymalizacja/core/__init__.py index 0ccb7dc2e..b5af277a7 100644 --- a/src/ewaluacja_optymalizacja/core/__init__.py +++ b/src/ewaluacja_optymalizacja/core/__init__.py @@ -382,13 +382,16 @@ def solve_uczelnia(uczelnia_id: int | None = None, min_liczba_n: int = 12): """ from bpp.models import Uczelnia - # Get university + # Get university (single-or-fail: bez jawnego uczelnia_id NIE zgadujemy + # pierwszej-z-brzegu uczelni; przy >1 uczelni get() rzuca + # MultipleObjectsReturned — fail-loud zamiast cichego błędu multi-hosted). if uczelnia_id: uczelnia = Uczelnia.objects.get(pk=uczelnia_id) else: - uczelnia = Uczelnia.objects.first() - if not uczelnia: - raise ValueError("No university found in database") + try: + uczelnia = Uczelnia.objects.get() + except Uczelnia.DoesNotExist as e: + raise ValueError("No university found in database") from e # Get disciplines with liczba_n >= min_liczba_n liczba_n_query = LiczbaNDlaUczelni.objects.filter( diff --git a/src/ewaluacja_optymalizacja/management/commands/solve_evaluation.py b/src/ewaluacja_optymalizacja/management/commands/solve_evaluation.py index c8a76c5c4..32b498325 100644 --- a/src/ewaluacja_optymalizacja/management/commands/solve_evaluation.py +++ b/src/ewaluacja_optymalizacja/management/commands/solve_evaluation.py @@ -8,6 +8,7 @@ """ from django.core.management import BaseCommand +from django.core.management.base import CommandError from django.db import transaction from bpp.models import Dyscyplina_Naukowa, Uczelnia @@ -115,6 +116,36 @@ def add_arguments(self, parser): "re-pin authors who have free slots to unselected publications" ), ) + parser.add_argument( + "--uczelnia", + type=int, + default=None, + help="University ID (wymagane gdy w systemie jest >1 uczelnia)", + ) + + def _resolve_uczelnia(self, uczelnia_id): + """Uczelnia dla komendy CLI (single-or-fail). + + - ``--uczelnia`` zawsze honorowane (i walidowane), + - przy dokładnie jednej uczelni używamy jej (``get()`` — count==1), + - przy wielu uczelniach brak ``--uczelnia`` to ``CommandError`` — + bez cichego wyboru pierwszej-z-brzegu (bug multi-hosted). + """ + if uczelnia_id is not None: + try: + return Uczelnia.objects.get(pk=uczelnia_id) + except Uczelnia.DoesNotExist as e: + raise CommandError(f"Brak uczelni o id={uczelnia_id}.") from e + + count = Uczelnia.objects.count() + if count == 0: + raise CommandError("Brak uczelni w bazie danych.") + if count == 1: + return Uczelnia.objects.get() + raise CommandError( + "W systemie jest więcej niż jedna uczelnia — podaj --uczelnia, " + "żeby ograniczyć optymalizację do jednej uczelni." + ) @transaction.atomic def handle( # noqa: C901 @@ -130,9 +161,12 @@ def handle( # noqa: C901 auto_unpin, analyze_pinning, enable_pinning, + uczelnia, *args, **options, ): + uczelnia_obj = self._resolve_uczelnia(uczelnia) + def log_callback(msg, style=None): if style == "ERROR": self.stdout.write(self.style.ERROR(msg)) @@ -143,7 +177,7 @@ def log_callback(msg, style=None): else: self.stdout.write(msg) - liczba_n = self._lookup_liczba_n(dyscyplina) + liczba_n = self._lookup_liczba_n(dyscyplina, uczelnia_obj) # Pre-optimization: capacity-based unpinning analysis / application if analyze_unpinning or auto_unpin: @@ -267,14 +301,16 @@ def log_callback(msg, style=None): algorithm_mode, ) - def _lookup_liczba_n(self, dyscyplina): + def _lookup_liczba_n(self, dyscyplina, uczelnia): """Look up the institutional N-limit (3N - sankcje) for the discipline. + ``uczelnia`` to JAWNIE rozstrzygnięta uczelnia (single-or-fail / + ``--uczelnia``) — NIE zgadujemy pierwszej-z-brzegu. + Logs a SUCCESS or WARNING message via ``self.stdout`` and returns ``float`` or ``None`` when no limit is configured. """ try: - uczelnia = Uczelnia.objects.first() dyscyplina_obj = Dyscyplina_Naukowa.objects.get(nazwa=dyscyplina) if uczelnia and dyscyplina_obj: liczba_n_obj = LiczbaNDlaUczelni.objects.get( diff --git a/src/ewaluacja_optymalizacja/management/commands/solve_uczelnia.py b/src/ewaluacja_optymalizacja/management/commands/solve_uczelnia.py index 9e32aad09..64f7536fb 100644 --- a/src/ewaluacja_optymalizacja/management/commands/solve_uczelnia.py +++ b/src/ewaluacja_optymalizacja/management/commands/solve_uczelnia.py @@ -5,6 +5,7 @@ from decimal import Decimal from django.core.management import BaseCommand +from django.core.management.base import CommandError from django.db import transaction from django.utils import timezone @@ -30,7 +31,7 @@ def add_arguments(self, parser): "--uczelnia", type=int, default=None, - help="University ID (if not specified, uses first available)", + help="University ID (wymagane gdy w systemie jest >1 uczelnia)", ) parser.add_argument( "--min-liczba-n", @@ -45,25 +46,36 @@ def add_arguments(self, parser): help="Save results to database (default: True)", ) + def _resolve_uczelnia(self, uczelnia_id): + """Uczelnia dla komendy CLI (single-or-fail). + + - ``--uczelnia`` zawsze honorowane (i walidowane), + - przy dokładnie jednej uczelni używamy jej (``get()`` — count==1), + - przy wielu uczelniach brak ``--uczelnia`` to ``CommandError`` — + bez cichego wyboru pierwszej-z-brzegu (bug multi-hosted). + """ + if uczelnia_id is not None: + try: + return Uczelnia.objects.get(pk=uczelnia_id) + except Uczelnia.DoesNotExist as e: + raise CommandError(f"Brak uczelni o id={uczelnia_id}.") from e + + count = Uczelnia.objects.count() + if count == 0: + raise CommandError("Brak uczelni w bazie danych.") + if count == 1: + return Uczelnia.objects.get() + raise CommandError( + "W systemie jest więcej niż jedna uczelnia — podaj --uczelnia, " + "żeby ograniczyć optymalizację do jednej uczelni." + ) + def handle(self, uczelnia, min_liczba_n, save_to_db, *args, **options): self.stdout.write("=" * 80) self.stdout.write("SOLVING OPTIMIZATION FOR UNIVERSITY") self.stdout.write("=" * 80) - # Get university - if uczelnia: - try: - uczelnia_obj = Uczelnia.objects.get(pk=uczelnia) - except Uczelnia.DoesNotExist: - self.stdout.write( - self.style.ERROR(f"University with ID {uczelnia} not found") - ) - return - else: - uczelnia_obj = Uczelnia.objects.first() - if not uczelnia_obj: - self.stdout.write(self.style.ERROR("No university found in database")) - return + uczelnia_obj = self._resolve_uczelnia(uczelnia) self.stdout.write(f"University: {uczelnia_obj}") self.stdout.write(f"Minimum liczba N: {min_liczba_n}") diff --git a/src/ewaluacja_optymalizacja/tests/test_first_per_uczelnia.py b/src/ewaluacja_optymalizacja/tests/test_first_per_uczelnia.py new file mode 100644 index 000000000..c67205973 --- /dev/null +++ b/src/ewaluacja_optymalizacja/tests/test_first_per_uczelnia.py @@ -0,0 +1,181 @@ +"""Track 6 (audyt uczelnia): ``Uczelnia.objects.first()`` → uczelnia z requestu. + +Widoki ``ewaluacja_optymalizacja`` wcześniej zgadywały „pierwszą-z-brzegu" +uczelnię (``Uczelnia.objects.first()``) niezależnie od hosta (uczelni) +z requestu. Po naprawie biorą uczelnię z requestu +(``uczelnia_dla_odczytu`` / ``get_for_request``), a komendy CLI stosują +single-or-fail (``--uczelnia`` albo ``CommandError`` przy >1 uczelni). + +Testy poniżej dowodzą, że: +- widok READ (``discipline_swap_opportunities_list``) operuje na danych U2, + a nie U1, gdy request jest scoped do U2, +- widok MUTUJĄCY/enqueue (``start_bulk_optimization``) tworzy/kasuje + ``OptimizationRun`` dla U2, nie dla U1, +- komendy ``solve_uczelnia`` / ``solve_evaluation`` failują przy >1 uczelni + bez ``--uczelnia`` i honorują flagę. +""" + +from io import StringIO +from unittest.mock import patch + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError +from model_bakery import baker + +from bpp.models import Uczelnia + + +def _make_request(uczelnia, user=None): + """Lekki fake request z ``_uczelnia`` (jak ustawia SiteResolutionMiddleware).""" + + class FakeRequest: + GET = {} + session = {} + + req = FakeRequest() + req._uczelnia = uczelnia + req.user = user + return req + + +def _make_swap_opportunity(uczelnia, dyscyplina, autor, **kwargs): + from ewaluacja_optymalizacja.models import DisciplineSwapOpportunity + + defaults = dict( + uczelnia=uczelnia, + rekord_id=[1, 1], + rekord_tytul="Praca", + rekord_rok=2023, + autor=autor, + current_discipline=dyscyplina, + target_discipline=dyscyplina, + points_before="10.0000", + points_after="20.0000", + point_improvement="10.0000", + makes_sense=True, + ) + defaults.update(kwargs) + return DisciplineSwapOpportunity.objects.create(**defaults) + + +@pytest.fixture +def dwie_uczelnie(db): + u1 = baker.make(Uczelnia, skrot="U1", nazwa="Uczelnia U1") + u2 = baker.make(Uczelnia, skrot="U2", nazwa="Uczelnia U2") + return u1, u2 + + +@pytest.mark.django_db +def test_swap_list_view_operuje_na_uczelni_z_requestu(dwie_uczelnie): + """Widok READ filtruje DisciplineSwapOpportunity po uczelni z requestu (U2).""" + from bpp.models import Dyscyplina_Naukowa + from ewaluacja_optymalizacja.views.discipline_swap_list import ( + discipline_swap_opportunities_list, + ) + + u1, u2 = dwie_uczelnie + dyscyplina = baker.make(Dyscyplina_Naukowa) + autor = baker.make("bpp.Autor") + + # 2 możliwości dla U1, 1 dla U2 — gdyby widok brał first()-of-arbitrary, + # wziąłby U1 (utworzona pierwsza) i policzyłby 2 zamiast 1. + _make_swap_opportunity(u1, dyscyplina, autor) + _make_swap_opportunity(u1, dyscyplina, autor) + _make_swap_opportunity(u2, dyscyplina, autor) + + su = baker.make("bpp.BppUser", is_superuser=True) + request = _make_request(u2, user=su) + + captured = {} + + def fake_render(req, template, context): + captured.update(context) + from django.http import HttpResponse + + return HttpResponse("ok") + + with patch( + "ewaluacja_optymalizacja.views.discipline_swap_list.render", + side_effect=fake_render, + ): + discipline_swap_opportunities_list(request) + + assert captured["total_count"] == 1, ( + "Widok policzył możliwości spoza uczelni z requestu — " + "first()-of-arbitrary zamiast U2." + ) + + +@pytest.mark.django_db +def test_bulk_optimization_kasuje_runy_uczelni_z_requestu(dwie_uczelnie): + """Widok MUTUJĄCY/enqueue operuje na OptimizationRun uczelni z requestu (U2).""" + from bpp.models import Dyscyplina_Naukowa + from ewaluacja_liczba_n.models import LiczbaNDlaUczelni + from ewaluacja_optymalizacja.models import OptimizationRun + from ewaluacja_optymalizacja.views.bulk_optimization import ( + start_bulk_optimization, + ) + + u1, u2 = dwie_uczelnie + dyscyplina = baker.make(Dyscyplina_Naukowa) + + # U2 ma raportowaną liczbę N, więc walidacja w widoku przejdzie. + baker.make( + LiczbaNDlaUczelni, + uczelnia=u2, + dyscyplina_naukowa=dyscyplina, + liczba_n=20, + ) + + # Stare runy dla obu uczelni — widok powinien skasować TYLKO U2. + baker.make(OptimizationRun, uczelnia=u1, dyscyplina_naukowa=dyscyplina) + baker.make(OptimizationRun, uczelnia=u2, dyscyplina_naukowa=dyscyplina) + + su = baker.make("bpp.BppUser", is_superuser=True) + request = _make_request(u2, user=su) + request.method = "POST" + request.POST = {} + + # Zablokuj faktyczny enqueue zadania Celery (zwróć fake task z .id). + class FakeTask: + id = "fake-task-id" + + with patch( + "ewaluacja_optymalizacja.views.bulk_optimization." + "solve_all_reported_disciplines.delay", + return_value=FakeTask(), + ): + start_bulk_optimization(request) + + assert not OptimizationRun.objects.filter(uczelnia=u2).exists(), ( + "Run U2 powinien zostać skasowany przed nową optymalizacją." + ) + assert OptimizationRun.objects.filter(uczelnia=u1).exists(), ( + "Run U1 NIE może zostać ruszony — widok operował na first()-of-arbitrary." + ) + + +@pytest.mark.django_db +def test_solve_uczelnia_command_failuje_przy_wielu_uczelniach(dwie_uczelnie): + """``solve_uczelnia`` bez --uczelnia przy >1 uczelni → CommandError.""" + with pytest.raises(CommandError, match="więcej niż jedna uczelnia"): + call_command("solve_uczelnia", stdout=StringIO()) + + +@pytest.mark.django_db +def test_solve_uczelnia_command_honoruje_flage(dwie_uczelnie): + """``solve_uczelnia --uczelnia `` używa wskazanej uczelni (bez błędu).""" + u1, u2 = dwie_uczelnie + out = StringIO() + # Brak danych liczby N → solve_uczelnia nie przetworzy dyscyplin, ale + # NIE może rzucić CommandError o wielu uczelniach — flaga rozstrzyga. + call_command("solve_uczelnia", uczelnia=u2.pk, stdout=out) + assert f"University: {u2}" in out.getvalue() + + +@pytest.mark.django_db +def test_solve_evaluation_command_failuje_przy_wielu_uczelniach(dwie_uczelnie): + """``solve_evaluation`` bez --uczelnia przy >1 uczelni → CommandError.""" + with pytest.raises(CommandError, match="więcej niż jedna uczelnia"): + call_command("solve_evaluation", "nauki medyczne", stdout=StringIO()) diff --git a/src/ewaluacja_optymalizacja/views/bulk_optimization.py b/src/ewaluacja_optymalizacja/views/bulk_optimization.py index 279edd0a1..2a133f1fb 100644 --- a/src/ewaluacja_optymalizacja/views/bulk_optimization.py +++ b/src/ewaluacja_optymalizacja/views/bulk_optimization.py @@ -200,8 +200,7 @@ def start_bulk_optimization(request): ) return redirect("ewaluacja_optymalizacja:index") - # Pobierz pierwszą uczelnię (zakładamy, że jest tylko jedna) - uczelnia = Uczelnia.objects.first() + uczelnia = Uczelnia.objects.get_for_request(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni w systemie.") diff --git a/src/ewaluacja_optymalizacja/views/discipline_swap_analysis.py b/src/ewaluacja_optymalizacja/views/discipline_swap_analysis.py index 1c8024f17..f6a27d51a 100644 --- a/src/ewaluacja_optymalizacja/views/discipline_swap_analysis.py +++ b/src/ewaluacja_optymalizacja/views/discipline_swap_analysis.py @@ -40,7 +40,7 @@ def analyze_discipline_swap_opportunities(request): from ..models import StatusDisciplineSwapAnalysis from ..tasks import analyze_discipline_swap_task - uczelnia = Uczelnia.objects.first() + uczelnia = Uczelnia.objects.get_for_request(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni w systemie.") diff --git a/src/ewaluacja_optymalizacja/views/discipline_swap_list.py b/src/ewaluacja_optymalizacja/views/discipline_swap_list.py index 607bfd565..cc97fe3e2 100644 --- a/src/ewaluacja_optymalizacja/views/discipline_swap_list.py +++ b/src/ewaluacja_optymalizacja/views/discipline_swap_list.py @@ -11,7 +11,8 @@ from django.shortcuts import redirect, render from django.views.decorators.http import require_POST -from bpp.models import Dyscyplina_Naukowa, Uczelnia +from bpp.models import Dyscyplina_Naukowa +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu logger = logging.getLogger(__name__) @@ -100,7 +101,7 @@ def discipline_swap_opportunities_list(request): """ from ..models import DisciplineSwapOpportunity, StatusDisciplineSwapAnalysis - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni w systemie.") @@ -386,7 +387,7 @@ def export_discipline_swap_xlsx(request): from ..models import DisciplineSwapOpportunity - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni.") diff --git a/src/ewaluacja_optymalizacja/views/evaluation_browser/views.py b/src/ewaluacja_optymalizacja/views/evaluation_browser/views.py index 1a487ae39..b108af8c3 100644 --- a/src/ewaluacja_optymalizacja/views/evaluation_browser/views.py +++ b/src/ewaluacja_optymalizacja/views/evaluation_browser/views.py @@ -10,10 +10,10 @@ from bpp.models import ( Autor_Dyscyplina, - Uczelnia, Wydawnictwo_Ciagle_Autor, Wydawnictwo_Zwarte_Autor, ) +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu from ...models import ( OptimizationRun, @@ -35,7 +35,7 @@ def evaluation_browser(request): Główna strona przeglądarki ewaluacji. Wyświetla podsumowanie dyscyplin i tabelę publikacji z filtrami. """ - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni.") return redirect("ewaluacja_optymalizacja:index") @@ -65,7 +65,7 @@ def evaluation_browser(request): @login_required def browser_summary(request): """HTMX partial - podsumowanie punktacji dyscyplin.""" - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) if not uczelnia: return HttpResponseBadRequest("Nie znaleziono uczelni") @@ -86,7 +86,7 @@ def browser_summary(request): @login_required def browser_table(request): """HTMX partial - tabela publikacji z paginacją i filtrami.""" - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) if not uczelnia: return HttpResponseBadRequest("Nie znaleziono uczelni") @@ -125,7 +125,7 @@ def browser_table(request): @require_POST def browser_toggle_pin(request, model_type, pk): """Toggle przypieta na rekordzie autor-publikacja.""" - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) if not uczelnia: return HttpResponseBadRequest("Nie znaleziono uczelni") @@ -179,7 +179,7 @@ def browser_toggle_pin(request, model_type, pk): @require_POST def browser_swap_discipline(request, model_type, pk): """Zamień dyscyplinę na drugą dla autora z dwoma dyscyplinami.""" - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) if not uczelnia: return HttpResponseBadRequest("Nie znaleziono uczelni") @@ -256,7 +256,7 @@ def browser_recalc_status(request): """HTMX polling - status przeliczania.""" from ewaluacja_liczba_n.models import LiczbaNDlaUczelni - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) if not uczelnia: return HttpResponseBadRequest("Nie znaleziono uczelni") diff --git a/src/ewaluacja_optymalizacja/views/index.py b/src/ewaluacja_optymalizacja/views/index.py index 235c2c7aa..31ee4ff78 100644 --- a/src/ewaluacja_optymalizacja/views/index.py +++ b/src/ewaluacja_optymalizacja/views/index.py @@ -8,7 +8,7 @@ from django.http import JsonResponse from django.shortcuts import render -from bpp.models import Uczelnia +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu from ..models import ( OptimizationRun, @@ -55,7 +55,7 @@ def index(request): ).all() # Pobierz uczelnię dla dalszych obliczeń - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) # Dla każdego run oblicz procent slotów poza N oraz statystyki przypięć runs_with_stats = [_add_run_statistics(run, uczelnia) for run in runs] diff --git a/src/ewaluacja_optymalizacja/views/optimize_unpin.py b/src/ewaluacja_optymalizacja/views/optimize_unpin.py index 0bb781824..d24c934b5 100644 --- a/src/ewaluacja_optymalizacja/views/optimize_unpin.py +++ b/src/ewaluacja_optymalizacja/views/optimize_unpin.py @@ -9,6 +9,7 @@ from django.views.decorators.http import require_POST from bpp.models import Uczelnia +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu from ..models import ( OptimizationAuthorResult, @@ -158,8 +159,7 @@ def optimize_with_unpinning(request): ) return redirect("ewaluacja_optymalizacja:index") - # Pobierz pierwszą uczelnię (zakładamy, że jest tylko jedna) - uczelnia = Uczelnia.objects.first() + uczelnia = Uczelnia.objects.get_for_request(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni w systemie.") @@ -344,7 +344,7 @@ def optimize_unpin_status(request, task_id): from ewaluacja_liczba_n.models import LiczbaNDlaUczelni task = AsyncResult(task_id) - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) context = { "task_id": task_id, diff --git a/src/ewaluacja_optymalizacja/views/pins.py b/src/ewaluacja_optymalizacja/views/pins.py index 34a98c740..6e6e2f946 100644 --- a/src/ewaluacja_optymalizacja/views/pins.py +++ b/src/ewaluacja_optymalizacja/views/pins.py @@ -111,7 +111,7 @@ def reset_discipline_pins(request, pk): # Uruchom zadanie optymalizacji dla tej dyscypliny logger.info(f"Starting optimization for discipline '{dyscyplina.nazwa}'") - uczelnia = Uczelnia.objects.first() + uczelnia = Uczelnia.objects.get_for_request(request) try: liczba_n_obj = LiczbaNDlaUczelni.objects.get( @@ -156,8 +156,7 @@ def reset_all_pins(request): from ..tasks import reset_all_pins_task - # Pobierz pierwszą uczelnię (zakładamy, że jest tylko jedna) - uczelnia = Uczelnia.objects.first() + uczelnia = Uczelnia.objects.get_for_request(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni w systemie.") diff --git a/src/ewaluacja_optymalizacja/views/unpin_sensible.py b/src/ewaluacja_optymalizacja/views/unpin_sensible.py index 61531a63b..79bcf71d1 100644 --- a/src/ewaluacja_optymalizacja/views/unpin_sensible.py +++ b/src/ewaluacja_optymalizacja/views/unpin_sensible.py @@ -22,8 +22,7 @@ def unpin_all_sensible(request): from ..tasks import unpin_all_sensible_task - # Pobierz pierwszą uczelnię (zakładamy, że jest tylko jedna) - uczelnia = Uczelnia.objects.first() + uczelnia = Uczelnia.objects.get_for_request(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni w systemie.") diff --git a/src/ewaluacja_optymalizacja/views/unpinning_analysis.py b/src/ewaluacja_optymalizacja/views/unpinning_analysis.py index 510ece67a..f7f136a8e 100644 --- a/src/ewaluacja_optymalizacja/views/unpinning_analysis.py +++ b/src/ewaluacja_optymalizacja/views/unpinning_analysis.py @@ -125,8 +125,7 @@ def analyze_unpinning_opportunities(request): from ..models import StatusUnpinningAnalyzy from ..tasks import run_unpinning_after_metrics_wrapper - # Pobierz pierwszą uczelnię (zakładamy, że jest tylko jedna) - uczelnia = Uczelnia.objects.first() + uczelnia = Uczelnia.objects.get_for_request(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni w systemie.") diff --git a/src/ewaluacja_optymalizacja/views/unpinning_list.py b/src/ewaluacja_optymalizacja/views/unpinning_list.py index 710ab4996..391d38848 100644 --- a/src/ewaluacja_optymalizacja/views/unpinning_list.py +++ b/src/ewaluacja_optymalizacja/views/unpinning_list.py @@ -9,7 +9,7 @@ from django.shortcuts import redirect, render from django.views.decorators.http import require_POST -from bpp.models import Uczelnia +from raport_slotow.uczelnia_helper import uczelnia_dla_odczytu logger = logging.getLogger(__name__) @@ -106,8 +106,7 @@ def unpinning_opportunities_list(request): from ..models import StatusUnpinningAnalyzy, UnpinningOpportunity - # Pobierz pierwszą uczelnię (zakładamy, że jest tylko jedna) - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni w systemie.") @@ -243,8 +242,7 @@ def export_unpinning_opportunities_xlsx(request): # noqa: C901 from ..models import UnpinningOpportunity - # Pobierz pierwszą uczelnię (zakładamy, że jest tylko jedna) - uczelnia = Uczelnia.objects.first() + uczelnia = uczelnia_dla_odczytu(request) if not uczelnia: messages.error(request, "Nie znaleziono uczelni w systemie.") From 035fa6f1bbf2cc8bc7620e45d721deb7e18e40c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 17:54:37 +0200 Subject: [PATCH 183/247] =?UTF-8?q?fix(ewaluacja):=20mutuj=C4=85ce=20widok?= =?UTF-8?q?i=20browser=5F*=20u=C5=BCywaj=C4=85=20get=5Ffor=5Frequest=20(tr?= =?UTF-8?q?ack=206=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browser_toggle_pin i browser_swap_discipline to widoki @require_POST mutujące autor_rekord i enqueue'ujące solve_all_reported_disciplines. Wcześniej rozstrzygały uczelnię przez uczelnia_dla_odczytu, które honoruje superuserowy override ?uczelnia= — co mogłoby skierować przeliczanie/ snapshot/enqueue na podstawioną uczelnię, niespójnie z resztą mutujących widoków z tego commita (wszystkie używają Uczelnia.objects.get_for_request). Oba widoki przełączone na get_for_request (bez override), z zachowaniem istniejącego guardu `if not uczelnia: HttpResponseBadRequest`. Import uczelnia_dla_odczytu zostaje — używają go read-only widoki w tym pliku. Drobny cleanup: solve_evaluation._lookup_liczba_n miał martwy guard `if uczelnia and ...` — po refaktorze single-or-fail uczelnia jest zawsze truthy, więc `if dyscyplina_obj:`. Test: test_browser_toggle_pin_enqueue_dla_uczelni_z_requestu dowodzi, że przy 2 uczelniach POST scoped do U2 enqueue'uje delay(U2.pk) i startuje StatusPrzegladarkaRecalc dla U2, nie dla U1. Co-Authored-By: Claude Opus 4.8 --- .../management/commands/solve_evaluation.py | 2 +- .../tests/test_first_per_uczelnia.py | 56 +++++++++++++++++++ .../views/evaluation_browser/views.py | 5 +- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/ewaluacja_optymalizacja/management/commands/solve_evaluation.py b/src/ewaluacja_optymalizacja/management/commands/solve_evaluation.py index 32b498325..9e03ae523 100644 --- a/src/ewaluacja_optymalizacja/management/commands/solve_evaluation.py +++ b/src/ewaluacja_optymalizacja/management/commands/solve_evaluation.py @@ -312,7 +312,7 @@ def _lookup_liczba_n(self, dyscyplina, uczelnia): """ try: dyscyplina_obj = Dyscyplina_Naukowa.objects.get(nazwa=dyscyplina) - if uczelnia and dyscyplina_obj: + if dyscyplina_obj: liczba_n_obj = LiczbaNDlaUczelni.objects.get( uczelnia=uczelnia, dyscyplina_naukowa=dyscyplina_obj ) diff --git a/src/ewaluacja_optymalizacja/tests/test_first_per_uczelnia.py b/src/ewaluacja_optymalizacja/tests/test_first_per_uczelnia.py index c67205973..3986f6558 100644 --- a/src/ewaluacja_optymalizacja/tests/test_first_per_uczelnia.py +++ b/src/ewaluacja_optymalizacja/tests/test_first_per_uczelnia.py @@ -156,6 +156,62 @@ class FakeTask: ) +@pytest.mark.django_db +def test_browser_toggle_pin_enqueue_dla_uczelni_z_requestu(dwie_uczelnie): + """``browser_toggle_pin`` (mutujący/enqueue) scope'uje przeliczanie do + uczelni z requestu (U2) — NIE do pierwszej-z-brzegu (U1). + + Dowód: ``solve_all_reported_disciplines.delay`` dostaje ``U2.pk``, a + ``StatusPrzegladarkaRecalc`` startuje dla U2. Gdyby widok używał + ``uczelnia_dla_odczytu`` z honorowaniem ``?uczelnia=`` override albo + ``get_default`` first()-of-arbitrary, mógłby trafić w U1. + """ + from ewaluacja_optymalizacja.models import StatusPrzegladarkaRecalc + from ewaluacja_optymalizacja.views.evaluation_browser.views import ( + browser_toggle_pin, + ) + + u1, u2 = dwie_uczelnie + autor_rekord = baker.make("bpp.Wydawnictwo_Ciagle_Autor", przypieta=False) + + su = baker.make("bpp.BppUser", is_superuser=True) + request = _make_request(u2, user=su) + request.method = "POST" + request.POST = {} + + class FakeTask: + id = "fake-task-id" + + def fake_render(req, template, context): + from django.http import HttpResponse + + return HttpResponse("ok") + + with ( + patch( + "ewaluacja_optymalizacja.views.evaluation_browser.views." + "solve_all_reported_disciplines.delay", + return_value=FakeTask(), + ) as mock_delay, + patch( + "ewaluacja_optymalizacja.views.evaluation_browser.views.render", + side_effect=fake_render, + ), + ): + browser_toggle_pin(request, "ciagle", autor_rekord.pk) + + mock_delay.assert_called_once_with(u2.pk) + assert mock_delay.call_args.args != (u1.pk,), ( + "Przeliczanie poszło do U1 (first()-of-arbitrary) zamiast U2 z requestu." + ) + + status = StatusPrzegladarkaRecalc.get_or_create() + assert status.uczelnia_id == u2.pk, ( + "StatusPrzegladarkaRecalc wystartował dla złej uczelni — " + "mutacja nie była scoped do uczelni z requestu." + ) + + @pytest.mark.django_db def test_solve_uczelnia_command_failuje_przy_wielu_uczelniach(dwie_uczelnie): """``solve_uczelnia`` bez --uczelnia przy >1 uczelni → CommandError.""" diff --git a/src/ewaluacja_optymalizacja/views/evaluation_browser/views.py b/src/ewaluacja_optymalizacja/views/evaluation_browser/views.py index b108af8c3..d9eb519af 100644 --- a/src/ewaluacja_optymalizacja/views/evaluation_browser/views.py +++ b/src/ewaluacja_optymalizacja/views/evaluation_browser/views.py @@ -10,6 +10,7 @@ from bpp.models import ( Autor_Dyscyplina, + Uczelnia, Wydawnictwo_Ciagle_Autor, Wydawnictwo_Zwarte_Autor, ) @@ -125,7 +126,7 @@ def browser_table(request): @require_POST def browser_toggle_pin(request, model_type, pk): """Toggle przypieta na rekordzie autor-publikacja.""" - uczelnia = uczelnia_dla_odczytu(request) + uczelnia = Uczelnia.objects.get_for_request(request) if not uczelnia: return HttpResponseBadRequest("Nie znaleziono uczelni") @@ -179,7 +180,7 @@ def browser_toggle_pin(request, model_type, pk): @require_POST def browser_swap_discipline(request, model_type, pk): """Zamień dyscyplinę na drugą dla autora z dwoma dyscyplinami.""" - uczelnia = uczelnia_dla_odczytu(request) + uczelnia = Uczelnia.objects.get_for_request(request) if not uczelnia: return HttpResponseBadRequest("Nie znaleziono uczelni") From a9a55aa5329ff36e008e8ffded8454b753c213e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 18:00:54 +0200 Subject: [PATCH 184/247] docs(ewaluacja): popraw docstring solve_uczelnia po single-or-fail (track 6 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final review nit: docstring mówił „uses first available", ale ciało zmieniono na single-or-fail (get() → MultipleObjectsReturned przy >1). Co-Authored-By: Claude Opus 4.8 --- src/ewaluacja_optymalizacja/core/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ewaluacja_optymalizacja/core/__init__.py b/src/ewaluacja_optymalizacja/core/__init__.py index b5af277a7..c4d7c2b0e 100644 --- a/src/ewaluacja_optymalizacja/core/__init__.py +++ b/src/ewaluacja_optymalizacja/core/__init__.py @@ -374,7 +374,8 @@ def solve_uczelnia(uczelnia_id: int | None = None, min_liczba_n: int = 12): Solve optimization for all disciplines in university with liczba_n >= min_liczba_n. Args: - uczelnia_id: University ID (if None, uses first available) + uczelnia_id: University ID (if None, uses the sole university; + raises if zero or multiple exist — multi-hosted fail-loud) min_liczba_n: Minimum liczba N threshold (default: 12) Yields: From ea4dd4164e8a679e9dda84789c276bec50dbc216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 18:01:40 +0200 Subject: [PATCH 185/247] docs(multi-hosted): track 4 + 6 oznaczone jako ZROBIONE w raporcie audytu Track 4 (SentData per-uczelnia) i Track 6 (Uczelnia.objects.first() sweep + guard) wykonane, zreviewowane (spec + quality + final holistic), regresja 474 passed, brak migration-drift. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-04-audyt-uczelnia-coverage.md | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md b/docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md index de86db55a..84e8f5a12 100644 --- a/docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md +++ b/docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md @@ -172,7 +172,7 @@ wzorzec `Uczelnia\.objects\.first\(\)` (z whitelistą dla demo/debug/warstwy mod Wszystkie naprawy: TDD (RED→GREEN), invariant single-install, lint czysty. -### ✅ ZROBIONE (9 napraw) +### ✅ ZROBIONE (11 napraw) | Track | plik(i) | test | |---|---|---| | 2 | `raport_slotow/views/zerowy.py` (+ `…/tests/…/test_raport_slotow_zerowy_per_uczelnia.py`) | 16 ✅ | @@ -184,13 +184,19 @@ Wszystkie naprawy: TDD (RED→GREEN), invariant single-install, lint czysty. | 1b | `bpp/views/autocomplete/simple.py` `LataAutocomplete` (+ test) | 2 ✅ | | 1c | `bpp/views/autocomplete/{navigation,search_services}.py` (+ test) | 132 ✅ (autocomplete regr.) | | 1d | `bpp/models/cache/rekord.py` + `bpp/views/browse.py` (+ test) | 22 ✅ (browse regr.) | - -### 📋 SPEC (execution-ready, do wykonania w świeżym kontekście / subagentem) -- **Track 4 — SentData per-uczelnia:** `specs/2026-06-04-sentdata-per-uczelnia-track4-design.md` - (outward-facing, ~8 call-site'ów spójnościowych + migracja — świadomie NIE - robione połowicznie na rozciągniętym kontekście). -- **Track 6 — `Uczelnia.objects.first()` sweep + guard:** `specs/2026-06-04-uczelnia-first-sweep-track6-design.md` - (28 wystąpień; druga ślepa plamka guarda). +| 4 | `pbn_api/models/sentdata.py` (`SentDataManager` klucz `(object_id, content_type, uczelnia)`) + call-site'y (`publication_sync`, `synchronization`, `admin/helpers/pbn_api/common`, `pbn_export_queue/views/detail_views`) + migracja danych `0072` (backfill single-install, NULL self-healing w multi) (+ `pbn_api/tests/test_sentdata_per_uczelnia.py`, `pbn_export_queue/tests/test_views_detail.py`) | pbn_api 241 ✅ / queue regr. | +| 6 | `ewaluacja_optymalizacja/views/*` (19× `Uczelnia.objects.first()` → `get_for_request`/`uczelnia_dla_odczytu`) + `core/__init__.py` + komendy `solve_uczelnia`/`solve_evaluation` (single-or-fail + `--uczelnia`) + guard `test_multihosted_get_default_guard.py` (wzorzec `first()`/`all()[0]` + whitelist) (+ `ewaluacja_optymalizacja/tests/test_first_per_uczelnia.py`) | ewaluacja 82 ✅ / guard 2 ✅ | + +Regresja dotkniętych aplikacji na HEAD: **474 passed**, brak migration-drift. +Każdy track: dwustopniowy review (spec compliance → code quality) + final +holistic review (READY TO MERGE), z domkniętymi poprawkami review (queue-detail +`MultipleObjectsReturned` guard; mutujące `browser_*` → `get_for_request`). + +### 📋 SPEC — wszystkie wykonane +- ~~Track 4 — SentData per-uczelnia~~ → **ZROBIONE** (`c99452e5f`, `45ae7c0e9`). +- ~~Track 6 — `Uczelnia.objects.first()` sweep + guard~~ → **ZROBIONE** + (`583d09ba4`, `035fa6f1b`, `a9a55aa53`). Faktycznie 27 wystąpień runtime + (spec szacował 28; drift repo) + 3 w testach (ignorowane przez guard). ### 🟡 Odłożone (bez akcji, odnotowane): federacja optymalizacji (backlog C globalne delete), API REST maszynowe, pbn_api lustro (Track 7 = komentarze), multiseek by-design. From e729c44561ef4e3e86884932c455e4a3523120e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 18:54:33 +0200 Subject: [PATCH 186/247] =?UTF-8?q?fix(pbn):=20scope=20lustra=20PBN=20per-?= =?UTF-8?q?uczelnia=20po=20institutionId=20=E2=80=94=20delete=20cross-ucze?= =?UTF-8?q?lnia=20+=20iteracje=20integratora=20+=20autocomplete=20(audyt?= =?UTF-8?q?=20uczelnia,=20track=207a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trzy modele-lustra PBN niosą PBN UID instytucji w institutionId (FK → pbn_api.Institution). Uczelnia ma pbn_uid (FK → Institution), więc wiersz lustra mapuje się na uczelnię deterministycznie: institutionId_id == uczelnia.pbn_uid_id. Zawężamy zapytania/usunięcia po tym kluczu (z guardem pbn_uid_id is not None → single-install i uczelnia bez PBN bez zmian zachowania). - Area 1: OswiadczenieInstytucji.delete() wyprowadza uczelnię z institutionId i kasuje TYLKO jej SentData (Track-4 leftover). - Area 2: download_statements_of_publication kasuje oświadczenia tylko uczelni klienta dla danej publikacji. - Area 3: usun_wszystkie/zerowe_oswiadczenia, integruj_oswiadczenia_* oraz integruj_publikacje_instytucji iterują tylko po oświadczeniach uczelni klienta; nowy kwarg uczelnia=None threadowany z dwóch callerów. Multiprocessing: zewnętrzny queryset zawężony PRZED fan-outem (workerzy dostają listę int pk, bez picklowania modelu). - Area 4: autocomplete autorów — wskaźnik ma_osobe_z_instytucji zawężony do instytucji PBN oglądającej uczelni. Co-Authored-By: Claude Opus 4.8 --- ...test_ma_osobe_z_instytucji_per_uczelnia.py | 76 +++++ src/bpp/views/autocomplete/authors.py | 21 +- src/pbn_api/client/publication_sync.py | 8 +- src/pbn_api/models/oswiadczenie_instytucji.py | 28 +- .../test_track7a_per_uczelnia_scoping.py | 276 ++++++++++++++++++ src/pbn_import/utils/statement_import.py | 5 + .../management/commands/pbn_integrator.py | 6 +- src/pbn_integrator/utils/integration.py | 17 +- src/pbn_integrator/utils/statements.py | 46 ++- 9 files changed, 451 insertions(+), 32 deletions(-) create mode 100644 src/bpp/tests/test_autocomplete/test_ma_osobe_z_instytucji_per_uczelnia.py create mode 100644 src/pbn_api/tests/test_track7a_per_uczelnia_scoping.py diff --git a/src/bpp/tests/test_autocomplete/test_ma_osobe_z_instytucji_per_uczelnia.py b/src/bpp/tests/test_autocomplete/test_ma_osobe_z_instytucji_per_uczelnia.py new file mode 100644 index 000000000..fa6f0460e --- /dev/null +++ b/src/bpp/tests/test_autocomplete/test_ma_osobe_z_instytucji_per_uczelnia.py @@ -0,0 +1,76 @@ +"""Track 7a: wskaźnik ``ma_osobe_z_instytucji`` zawężony do uczelni z requestu. + +Adnotacja ``Exists(OsobaZInstytucji.objects.filter(personId=...))`` w +autocomplete autorów powinna odzwierciedlać instytucję PBN OGLĄDAJĄCEJ +uczelni (``request._uczelnia.pbn_uid``), nie dowolnej instytucji. +""" + +import pytest +from model_bakery import baker + +from fixtures.conftest_multisite import make_request_for_site + + +@pytest.mark.django_db +def test_ma_osobe_z_instytucji_scoped_to_viewing_uczelnia( + uczelnia1, uczelnia2, settings +): + settings.ALLOWED_HOSTS = ["*"] + from bpp.views.autocomplete.authors import AutorAutocompleteBase + from pbn_api.models import Institution, OsobaZInstytucji, Scientist + + inst1 = baker.make(Institution, name="Inst U1") + inst2 = baker.make(Institution, name="Inst U2") + uczelnia1.pbn_uid = inst1 + uczelnia1.save() + uczelnia2.pbn_uid = inst2 + uczelnia2.save() + + sci_in_u2 = baker.make(Scientist) + sci_in_u1_only = baker.make(Scientist) + + autor_u2 = baker.make("bpp.Autor", pbn_uid_id=sci_in_u2.pk) + autor_u1_only = baker.make("bpp.Autor", pbn_uid_id=sci_in_u1_only.pk) + + # OsobaZInstytucji autora U2 jest w instytucji U2, autora "U1-only" w U1. + baker.make(OsobaZInstytucji, personId=sci_in_u2, institutionId=inst2) + baker.make(OsobaZInstytucji, personId=sci_in_u1_only, institutionId=inst1) + + # Request oglądany jako uczelnia2 (jej site). + view = AutorAutocompleteBase() + view.request = make_request_for_site(uczelnia2.site) + view.q = "" + + qs = view.get_queryset() + by_pk = {a.pk: a for a in qs} + + # Autor w instytucji U2 → oznaczony; autor tylko w U1 → NIE oznaczony. + assert by_pk[autor_u2.pk].ma_osobe_z_instytucji is True + assert by_pk[autor_u1_only.pk].ma_osobe_z_instytucji is False + + +@pytest.mark.django_db +def test_ma_osobe_z_instytucji_global_without_pbn_uid(uczelnia, settings): + """Bez ``pbn_uid`` uczelni → subquery globalne (dawne zachowanie).""" + settings.ALLOWED_HOSTS = ["*"] + from django.test import RequestFactory + + from bpp.views.autocomplete.authors import AutorAutocompleteBase + from pbn_api.models import Institution, OsobaZInstytucji, Scientist + + inst = baker.make(Institution) + sci = baker.make(Scientist) + autor = baker.make("bpp.Autor", pbn_uid_id=sci.pk) + baker.make(OsobaZInstytucji, personId=sci, institutionId=inst) + + request = RequestFactory().get("/") + request._uczelnia = uczelnia # uczelnia bez pbn_uid + + view = AutorAutocompleteBase() + view.request = request + view.q = "" + + qs = view.get_queryset() + by_pk = {a.pk: a for a in qs} + # Brak pbn_uid → subquery globalne → autor oznaczony. + assert by_pk[autor.pk].ma_osobe_z_instytucji is True diff --git a/src/bpp/views/autocomplete/authors.py b/src/bpp/views/autocomplete/authors.py index 0becc9ec6..55718c7ee 100644 --- a/src/bpp/views/autocomplete/authors.py +++ b/src/bpp/views/autocomplete/authors.py @@ -54,13 +54,26 @@ def get_queryset(self): else: qs = Autor.objects.all() - qs = qs.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) + + # Multi-hosted (Track 7a): wskaźnik "✅ jest w PBN naszej instytucji" + # ma odzwierciedlać instytucję PBN OGLĄDAJĄCEJ uczelni + # (``uczelnia.pbn_uid``), nie dowolnej. ``OsobaZInstytucji`` to lustro + # per-instytucja (institutionId == uczelnia.pbn_uid), więc zawężamy + # subquery. Brak uczelni / brak pbn_uid → subquery globalne (dawne + # zachowanie, single-install / uczelnia bez konfiguracji PBN). + osoba_z_instytucji_qs = OsobaZInstytucji.objects.filter( + personId_id=OuterRef("pbn_uid_id") + ) + if uczelnia is not None and uczelnia.pbn_uid_id is not None: + osoba_z_instytucji_qs = osoba_z_instytucji_qs.filter( + institutionId_id=uczelnia.pbn_uid_id ) + + qs = qs.select_related("tytul", "pbn_uid").annotate( + ma_osobe_z_instytucji=Exists(osoba_z_instytucji_qs) ) - uczelnia = getattr(getattr(self, "request", None), "_uczelnia", None) if uczelnia: ma_jednostke_w_naszej = Exists( Autor_Jednostka.objects.filter( diff --git a/src/pbn_api/client/publication_sync.py b/src/pbn_api/client/publication_sync.py index 4bd403849..b9cd091d2 100644 --- a/src/pbn_api/client/publication_sync.py +++ b/src/pbn_api/client/publication_sync.py @@ -246,7 +246,13 @@ def download_statements_of_publication(self, pub): from pbn_api.models import OswiadczenieInstytucji from pbn_integrator.utils import pobierz_mongodb, zapisz_oswiadczenie_instytucji - OswiadczenieInstytucji.objects.filter(publicationId_id=pub.pk).delete() + # Multi-hosted (Track 7a): kasujemy lokalne oświadczenia tej publikacji + # zawężone do uczelni klienta (po ``institutionId == uczelnia.pbn_uid``), + # by sync uczelni A nie usuwał oświadczeń uczelni B dla tej samej pracy. + qs = OswiadczenieInstytucji.objects.filter(publicationId_id=pub.pk) + if self.uczelnia is not None and self.uczelnia.pbn_uid_id is not None: + qs = qs.filter(institutionId_id=self.uczelnia.pbn_uid_id) + qs.delete() pobierz_mongodb( self.get_institution_statements_of_single_publication(pub.pk, 5120), diff --git a/src/pbn_api/models/oswiadczenie_instytucji.py b/src/pbn_api/models/oswiadczenie_instytucji.py index 8d7bb95d8..5e09cfcab 100644 --- a/src/pbn_api/models/oswiadczenie_instytucji.py +++ b/src/pbn_api/models/oswiadczenie_instytucji.py @@ -202,17 +202,23 @@ def delete(self, *args, **kw): # Jeżeli usunięte zostało jakiekolwiek oświadczenie to automatycznie dane SentData przestają # być aktualne, a system się na nich opiera. Zatem w tej sytuacji, kasujemy również # wysłane dane: + from bpp.models import Uczelnia from pbn_api.models import SentData - # Multi-hosted (Track 4): kasujemy SentData po ``pbn_uid_id`` publikacji. - # IDEALNIE zawęzilibyśmy też po ``self.uczelnia`` (żeby skasowanie - # oświadczenia jednej uczelni nie wywalało stanu wysyłki drugiej), ALE - # ``OswiadczenieInstytucji.uczelnia`` jest obecnie nullable i zwykle NULL - # (write-side tagowanie tego modelu = Track 7, jeszcze nie zrobione). - # Gdyby filtrować po ``uczelnia=None``, przy NULL-owym oświadczeniu nie - # skasowalibyśmy poprawnie otagowanych (per-uczelnia) wierszy SentData — - # stan wysyłki rozjechałby się z faktem skasowania oświadczenia. Dlatego - # ZACHOWUJEMY globalne (po publikacji) kasowanie. Po Track 7 (tag uczelni - # na OswiadczenieInstytucji niezawodny) dopisz tu ``uczelnia=self.uczelnia``. - SentData.objects.filter(pbn_uid_id=self.publicationId_id).delete(*args, **kw) + # Multi-hosted (Track 7a): kasujemy SentData po ``pbn_uid_id`` publikacji + # ALE zawężamy do uczelni, której to oświadczenie dotyczy. Uczelnię + # wyprowadzamy DETERMINISTYCZNIE z ``self.institutionId`` (== PBN UID + # instytucji uczelni, czyli ``uczelnia.pbn_uid``) — to wiersz-lustro + # PBN, więc institutionId jest zawsze ustawione. SentData jest per-uczelnia + # (Track 4), więc skasowanie oświadczenia uczelni A musi unieważnić TYLKO + # stan wysyłki A, nie B. Gdy ``institutionId`` nie mapuje na żadną lokalną + # uczelnię (obca instytucja — nie powinno się zdarzyć dla naszych + # oświadczeń), zostawiamy dawny globalny delete (bezpieczny fallback). + uczelnia = Uczelnia.objects.filter(pbn_uid_id=self.institutionId_id).first() + qs = SentData.objects.filter(pbn_uid_id=self.publicationId_id) + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + # QuerySet.delete() nie przyjmuje argumentów (w przeciwieństwie do + # Model.delete()); nie propagujemy tu ``*args, **kw``. + qs.delete() return super().delete(*args, **kw) diff --git a/src/pbn_api/tests/test_track7a_per_uczelnia_scoping.py b/src/pbn_api/tests/test_track7a_per_uczelnia_scoping.py new file mode 100644 index 000000000..43a7468de --- /dev/null +++ b/src/pbn_api/tests/test_track7a_per_uczelnia_scoping.py @@ -0,0 +1,276 @@ +"""Testy Track 7a: zawężanie luster PBN per-uczelnia po ``institutionId``. + +Trzy modele-lustra (``OswiadczenieInstytucji``, ``OsobaZInstytucji``) niosą +PBN-owy UID instytucji w polu ``institutionId`` (FK → ``pbn_api.Institution``). +``Uczelnia`` ma ``pbn_uid`` (FK → ``pbn_api.Institution``). Stąd wiersz lustra +mapuje się na uczelnię DETERMINISTYCZNIE: + + row.institutionId_id == uczelnia.pbn_uid_id + +Zawężamy zapytania/usunięcia po ``institutionId_id=uczelnia.pbn_uid_id`` +(z guardem ``pbn_uid_id is not None``). +""" + +import pytest +from model_bakery import baker + +from bpp.models import Uczelnia +from pbn_api.models import ( + Institution, + OswiadczenieInstytucji, + Publication, + Scientist, + SentData, +) + + +@pytest.fixture +def institution1(db): + return baker.make(Institution, name="Instytucja U1") + + +@pytest.fixture +def institution2(db): + return baker.make(Institution, name="Instytucja U2") + + +@pytest.fixture +def uczelnia_pbn1(db, institution1): + return baker.make( + Uczelnia, skrot="P1", nazwa="Uczelnia PBN 1", pbn_uid=institution1 + ) + + +@pytest.fixture +def uczelnia_pbn2(db, institution2): + return baker.make( + Uczelnia, skrot="P2", nazwa="Uczelnia PBN 2", pbn_uid=institution2 + ) + + +@pytest.fixture +def publication(db): + return baker.make(Publication) + + +# --------------------------------------------------------------------------- +# Area 1: OswiadczenieInstytucji.delete() zawęża SentData po uczelni +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_delete_oswiadczenie_scopes_sentdata_per_uczelnia( + uczelnia_pbn1, uczelnia_pbn2, institution1, publication +): + """Skasowanie oświadczenia U1 kasuje TYLKO SentData U1; SentData U2 żyje.""" + rec = baker.make("bpp.Wydawnictwo_Ciagle") + + sd1 = SentData.objects.create_or_update_before_upload( + rec, {"a": 1}, uczelnia=uczelnia_pbn1 + ) + sd1.pbn_uid_id = publication.pk + sd1.save() + + sd2 = SentData.objects.create_or_update_before_upload( + rec, {"a": 2}, uczelnia=uczelnia_pbn2 + ) + sd2.pbn_uid_id = publication.pk + sd2.save() + + osw = baker.make( + OswiadczenieInstytucji, + publicationId=publication, + institutionId=institution1, + ) + + osw.delete() + + # SentData U1 skasowane, U2 nietknięte. + assert not SentData.objects.filter(pk=sd1.pk).exists() + assert SentData.objects.filter(pk=sd2.pk).exists() + + +@pytest.mark.django_db +def test_delete_oswiadczenie_no_local_uczelnia_global_delete(publication, institution1): + """Gdy ``institutionId`` nie mapuje na żadną uczelnię → globalny delete.""" + rec = baker.make("bpp.Wydawnictwo_Ciagle") + sd = SentData.objects.create_or_update_before_upload(rec, {"a": 1}, uczelnia=None) + sd.pbn_uid_id = publication.pk + sd.save() + + osw = baker.make( + OswiadczenieInstytucji, + publicationId=publication, + institutionId=institution1, + ) + osw.delete() + + assert not SentData.objects.filter(pk=sd.pk).exists() + + +@pytest.mark.django_db +def test_delete_oswiadczenie_single_install_noop(uczelnia_pbn1, institution1): + """Single-install: jedna uczelnia → zawężenie to no-op (kasuje swój SentData).""" + rec = baker.make("bpp.Wydawnictwo_Ciagle") + publication = baker.make(Publication) + sd = SentData.objects.create_or_update_before_upload( + rec, {"a": 1}, uczelnia=uczelnia_pbn1 + ) + sd.pbn_uid_id = publication.pk + sd.save() + + osw = baker.make( + OswiadczenieInstytucji, + publicationId=publication, + institutionId=institution1, + ) + osw.delete() + + assert not SentData.objects.filter(pk=sd.pk).exists() + + +# --------------------------------------------------------------------------- +# Area 2: download_statements_of_publication delete zawężony per-uczelnia +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_download_statements_delete_scoped_per_uczelnia( + uczelnia_pbn1, institution1, institution2, publication +): + """Sync U1 kasuje tylko oświadczenia U1 dla tej publikacji; U2 zostają.""" + from pbn_api.client.publication_sync import PublicationSyncMixin + + osw_u1 = baker.make( + OswiadczenieInstytucji, + publicationId=publication, + institutionId=institution1, + ) + osw_u2 = baker.make( + OswiadczenieInstytucji, + publicationId=publication, + institutionId=institution2, + ) + + class FakeClient(PublicationSyncMixin): + def __init__(self, uczelnia): + self.uczelnia = uczelnia + + def get_institution_statements_of_single_publication(self, *a, **kw): + return [] + + client = FakeClient(uczelnia_pbn1) + client.download_statements_of_publication(publication) + + assert not OswiadczenieInstytucji.objects.filter(pk=osw_u1.pk).exists() + assert OswiadczenieInstytucji.objects.filter(pk=osw_u2.pk).exists() + + +# --------------------------------------------------------------------------- +# Area 3: integrator iteruje tylko po oświadczeniach uczelni klienta +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_usun_wszystkie_oswiadczenia_scoped(uczelnia_pbn1, institution1, institution2): + """``usun_wszystkie_oswiadczenia`` rusza tylko oświadczenia uczelni klienta.""" + from pbn_integrator.utils.statements import usun_wszystkie_oswiadczenia + + pub1 = baker.make(Publication) + pub2 = baker.make(Publication) + osw_u1 = baker.make( + OswiadczenieInstytucji, publicationId=pub1, institutionId=institution1 + ) + osw_u2 = baker.make( + OswiadczenieInstytucji, publicationId=pub2, institutionId=institution2 + ) + + deleted = [] + + class FakeClient: + uczelnia = uczelnia_pbn1 + + def delete_publication_statement(self, *a, **kw): + deleted.append(a) + + usun_wszystkie_oswiadczenia(FakeClient()) + + # Tylko oświadczenie U1 zostało skasowane i wysłane do PBN. + assert not OswiadczenieInstytucji.objects.filter(pk=osw_u1.pk).exists() + assert OswiadczenieInstytucji.objects.filter(pk=osw_u2.pk).exists() + assert len(deleted) == 1 + + +@pytest.mark.django_db +def test_integruj_oswiadczenia_z_instytucji_scoped( + uczelnia_pbn1, institution1, institution2 +): + """``integruj_oswiadczenia_z_instytucji(uczelnia=...)`` iteruje tylko po U1.""" + from pbn_integrator.utils.statements import integruj_oswiadczenia_z_instytucji + + pub1 = baker.make(Publication) + pub2 = baker.make(Publication) + baker.make( + OswiadczenieInstytucji, + publicationId=pub1, + institutionId=institution1, + personId=baker.make(Scientist), + ) + baker.make( + OswiadczenieInstytucji, + publicationId=pub2, + institutionId=institution2, + personId=baker.make(Scientist), + ) + + seen = [] + + import pbn_integrator.utils.statements as mod + + orig = mod.integruj_oswiadczenia_z_instytucji_pojedyncza_praca + + def spy(elem, *a, **kw): + seen.append(elem.institutionId_id) + + mod.integruj_oswiadczenia_z_instytucji_pojedyncza_praca = spy + try: + integruj_oswiadczenia_z_instytucji(uczelnia=uczelnia_pbn1) + finally: + mod.integruj_oswiadczenia_z_instytucji_pojedyncza_praca = orig + + assert seen == [institution1.pk] + + +@pytest.mark.django_db +def test_integruj_oswiadczenia_z_instytucji_no_uczelnia_global( + institution1, institution2 +): + """Bez ``uczelnia`` (legacy) iteruje po wszystkim — zachowanie globalne.""" + from pbn_integrator.utils.statements import integruj_oswiadczenia_z_instytucji + + baker.make( + OswiadczenieInstytucji, + institutionId=institution1, + personId=baker.make(Scientist), + ) + baker.make( + OswiadczenieInstytucji, + institutionId=institution2, + personId=baker.make(Scientist), + ) + + seen = [] + import pbn_integrator.utils.statements as mod + + orig = mod.integruj_oswiadczenia_z_instytucji_pojedyncza_praca + + def spy(elem, *a, **kw): + seen.append(elem.institutionId_id) + + mod.integruj_oswiadczenia_z_instytucji_pojedyncza_praca = spy + try: + integruj_oswiadczenia_z_instytucji() + finally: + mod.integruj_oswiadczenia_z_instytucji_pojedyncza_praca = orig + + assert set(seen) == {institution1.pk, institution2.pk} diff --git a/src/pbn_import/utils/statement_import.py b/src/pbn_import/utils/statement_import.py index 1598105c5..b0b440dbe 100644 --- a/src/pbn_import/utils/statement_import.py +++ b/src/pbn_import/utils/statement_import.py @@ -134,10 +134,15 @@ def run(self): inconsistency_callback = self._create_inconsistency_callback() # Pass None for missing_publication_callback - publications already downloaded + # Multi-hosted (Track 7a): integrujemy TYLKO oświadczenia uczelni + # kontekstu importu (institutionId == uczelnia.pbn_uid). ``uczelnia`` + # to lokalna, zweryfikowana (ma pbn_uid) Uczelnia z + # _setup_uczelnia_and_jednostka powyżej. integruj_oswiadczenia_z_instytucji( missing_publication_callback=None, inconsistency_callback=inconsistency_callback, default_jednostka=default_jednostka, + uczelnia=uczelnia, ) # Log inconsistency summary diff --git a/src/pbn_integrator/management/commands/pbn_integrator.py b/src/pbn_integrator/management/commands/pbn_integrator.py index 525191e5f..16063f322 100644 --- a/src/pbn_integrator/management/commands/pbn_integrator.py +++ b/src/pbn_integrator/management/commands/pbn_integrator.py @@ -333,7 +333,9 @@ def _handle_publications(self, opts, client, s, e): s, e, 17, - lambda: integruj_publikacje_instytucji(dm, skip_pages=skip_pages), + lambda: integruj_publikacje_instytucji( + dm, skip_pages=skip_pages, uczelnia=client.uczelnia + ), ) self._run_stage( opts["enable_pobierz_oswiadczenia_instytucji"], @@ -341,7 +343,7 @@ def _handle_publications(self, opts, client, s, e): s, e, 18, - integruj_oswiadczenia_z_instytucji, + lambda: integruj_oswiadczenia_z_instytucji(uczelnia=client.uczelnia), ) self._run_stage( opts["enable_pobierz_po_doi"], diff --git a/src/pbn_integrator/utils/integration.py b/src/pbn_integrator/utils/integration.py index fa38b8bb2..1ac67281a 100644 --- a/src/pbn_integrator/utils/integration.py +++ b/src/pbn_integrator/utils/integration.py @@ -301,6 +301,7 @@ def integruj_publikacje_instytucji( skip_pages=0, callback=None, use_threads=False, + uczelnia=None, ): """Integrate institution publications. @@ -308,13 +309,17 @@ def integruj_publikacje_instytucji( skip_pages: Number of batches to skip. callback: Optional callback function for progress tracking. use_threads: If True, uses the threaded implementation instead of multiprocessing. + uczelnia: Optional ``Uczelnia`` — gdy podana (i ma ``pbn_uid``), bierzemy + TYLKO publikacje z oświadczeń tej uczelni (``institutionId == + uczelnia.pbn_uid``). Multi-hosted (Track 7a). Zawężamy zewnętrzny + queryset ``pubs`` PRZED rozesłaniem do workerów (multiprocessing / + wątki), więc workerzy dostają już listę intów (pk) — bez picklowania + instancji modelu. Brak / brak pbn_uid → zachowanie globalne. """ - pubs = ( - OswiadczenieInstytucji.objects.all() - .values_list("publicationId_id", flat=True) - .order_by("-pk") - .distinct() - ) + pubs = OswiadczenieInstytucji.objects.all() + if uczelnia is not None and uczelnia.pbn_uid_id is not None: + pubs = pubs.filter(institutionId_id=uczelnia.pbn_uid_id) + pubs = pubs.values_list("publicationId_id", flat=True).order_by("-pk").distinct() if use_threads: return _integruj_publikacje_threaded( diff --git a/src/pbn_integrator/utils/statements.py b/src/pbn_integrator/utils/statements.py index 50d985bf2..f66f45e53 100644 --- a/src/pbn_integrator/utils/statements.py +++ b/src/pbn_integrator/utils/statements.py @@ -304,6 +304,7 @@ def integruj_oswiadczenia_z_instytucji( callback=None, inconsistency_callback=None, default_jednostka=None, + uczelnia=None, ): """Integrate all institution statements. @@ -313,11 +314,18 @@ def integruj_oswiadczenia_z_instytucji( inconsistency_callback: Optional callback for reporting inconsistencies. default_jednostka: Optional default unit to assign when updating from "Obca jednostka" to a proper unit during discipline assignment. + uczelnia: Optional ``Uczelnia`` — gdy podana (i ma ``pbn_uid``), + iterujemy TYLKO po jej oświadczeniach (``institutionId == + uczelnia.pbn_uid``). Multi-hosted (Track 7a): integracja dotyczy + jednej uczelni. Brak / brak pbn_uid → iteracja globalna (legacy). """ noted_pub = set() noted_aut = set() + qs = OswiadczenieInstytucji.objects.all() + if uczelnia is not None and uczelnia.pbn_uid_id is not None: + qs = qs.filter(institutionId_id=uczelnia.pbn_uid_id) for elem in pbar( - OswiadczenieInstytucji.objects.all(), + qs, label="integruj_oswiadczenia_z_instytucji", callback=callback, ): @@ -345,8 +353,21 @@ def integruj_oswiadczenia_pbn_first_import( # noqa: C901 dopisuj_zwrotnie_dyscypliny_autorom: Whether to add disciplines back to authors. koryguj_afiliacje: Whether to correct affiliations. """ + # Multi-hosted (Track 7a): gdy mamy klienta z uczelnią (i pbn_uid), + # iterujemy TYLKO po oświadczeniach tej uczelni (institutionId == + # uczelnia.pbn_uid). ``client`` może być None (default) → iteracja globalna. + oswiadczenia_qs = OswiadczenieInstytucji.objects.all() + if ( + client is not None + and client.uczelnia is not None + and client.uczelnia.pbn_uid_id is not None + ): + oswiadczenia_qs = oswiadczenia_qs.filter( + institutionId_id=client.uczelnia.pbn_uid_id + ) + first = True - for oswiadczenie in tqdm(OswiadczenieInstytucji.objects.all()): + for oswiadczenie in tqdm(oswiadczenia_qs): bpp_pub = oswiadczenie.get_bpp_publication() if bpp_pub is None: @@ -468,9 +489,12 @@ def usun_wszystkie_oswiadczenia(client): Args: client: PBN client. """ - for elem in pbar( - OswiadczenieInstytucji.objects.all(), label="usun_wszystkie_oswiadczenia" - ): + # Multi-hosted (Track 7a): kasujemy tylko oświadczenia uczelni klienta + # (institutionId == client.uczelnia.pbn_uid), nie wszystkich uczelni. + qs = OswiadczenieInstytucji.objects.all() + if client.uczelnia is not None and client.uczelnia.pbn_uid_id is not None: + qs = qs.filter(institutionId_id=client.uczelnia.pbn_uid_id) + for elem in pbar(qs, label="usun_wszystkie_oswiadczenia"): with transaction.atomic(): try: elem.sprobuj_skasowac_z_pbn(pbn_client=client) @@ -495,10 +519,16 @@ def usun_zerowe_oswiadczenia(client): for klass in Wydawnictwo_Ciagle, Wydawnictwo_Zwarte: zerowe = klass.objects.exclude(pbn_uid=None).filter(punkty_kbn=0) + # Multi-hosted (Track 7a): zawężamy do oświadczeń uczelni klienta + # (institutionId == client.uczelnia.pbn_uid). + osw_qs = OswiadczenieInstytucji.objects.filter( + publicationId_id__in=zerowe.values_list("pbn_uid_id", flat=True) + ) + if client.uczelnia is not None and client.uczelnia.pbn_uid_id is not None: + osw_qs = osw_qs.filter(institutionId_id=client.uczelnia.pbn_uid_id) + for elem in pbar( - OswiadczenieInstytucji.objects.filter( - publicationId_id__in=zerowe.values_list("pbn_uid_id", flat=True) - ), + osw_qs, label=f"usun_zerowe dla {klass}", ): with transaction.atomic(): From 77c12cf157545bb5b40c12532fa835d59f2659f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 19:05:35 +0200 Subject: [PATCH 187/247] =?UTF-8?q?docs(pbn):=20handoff=20track7=20+=20not?= =?UTF-8?q?ka=20federacyjnej=20wysy=C5=82ki/kasowania=20o=C5=9Bwiadcze?= =?UTF-8?q?=C5=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track 7-A zrobione (scope lustra po institutionId, commit e729c4456) — handoff zaktualizowany o stan + pre-existing bug pbn_integrator.py:336. Notka usera: wysyłka/kasowanie oświadczeń instytucji ma uprawnienia per-uczelnia (token zalogowanego autora z jednej uczelni). Praca wielo-uczelniana → próba uczelni głównej (z requestu) + obcych federacyjnych, tolerując porażki federacyjne. Do zrobienia później; komentarz-wskaźnik dodany w sprobuj_skasowac_z_pbn. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-04-handoff-track7-pbn-lustro.md | 228 ++++++++++++++++++ src/pbn_api/models/oswiadczenie_instytucji.py | 6 + 2 files changed, 234 insertions(+) create mode 100644 docs/superpowers/2026-06-04-handoff-track7-pbn-lustro.md diff --git a/docs/superpowers/2026-06-04-handoff-track7-pbn-lustro.md b/docs/superpowers/2026-06-04-handoff-track7-pbn-lustro.md new file mode 100644 index 000000000..840c45bc0 --- /dev/null +++ b/docs/superpowers/2026-06-04-handoff-track7-pbn-lustro.md @@ -0,0 +1,228 @@ +# Handoff — Track 7: pbn_api „lustro PBN" per-uczelnia + +Kontynuacja audytu uczelnia (multi-hosted). Gałąź `feature/multi-hosted-config`. +Poprzednik: `docs/superpowers/2026-06-04-audyt-uczelnia-coverage.md` +(Track 1–6 ZROBIONE, w tym Track 4 SentData per-uczelnia). + +## TL;DR — kluczowa zasada (to było mylone) + +Trzy modele-lustra PBN (`OsobaZInstytucji`, `PublikacjaInstytucji(_V2)`, +`OswiadczenieInstytucji`) **już niosą UID instytucji PBN** w polu +`institutionId` (FK → `pbn_api.Institution`). `Uczelnia` ma swój odpowiednik +PBN: `Uczelnia.pbn_uid` (FK → `pbn_api.Institution`, `uczelnia.py:135`). + +**Zatem `row.institutionId_id == uczelnia.pbn_uid_id` — przypisanie wiersza do +uczelni jest DETERMINISTYCZNE i nie wymaga niczego się domyślać ani przewlekać +`client.uczelnia` przez integrator.** Mapowanie już istnieje w kodzie: + +```python +# src/pbn_api/models/institution.py:60 — Institution.rekord_w_bpp +Uczelnia.objects.get(pbn_uid_id=self.pk) # Institution → Uczelnia +``` + +Czyli dla dowolnego wiersza lustra: +```python +uczelnia = Uczelnia.objects.get(pbn_uid_id=row.institutionId_id) +``` + +To NIE jest „first()/get_default/domyślanie się" — to twarda krotka po +istniejącym FK. Wynika z tego, że: + +1. **Odczyty i delete można zawęzić JUŻ TERAZ, bez migracji i bez tagu FK** — + przez `institutionId`: `filter(institutionId_id=uczelnia.pbn_uid_id)`. +2. FK `uczelnia` na lustrze to tylko **denormalizacja dla wygody filtrowania**. + Backfill + write-side tag to „nice to have" (krótszy join), nie warunek + poprawności. Można go zrobić, ale priorytet ma scope odczytów/delete. + +## Stan: co jest gotowe w schemacie + +FK `uczelnia` (nullable) **już istnieje** na wszystkich czterech modelach +(`OsobaZInstytucji`, `PublikacjaInstytucji`, `PublikacjaInstytucji_V2`, +`OswiadczenieInstytucji`) + `SentData`: +- dodane: `src/pbn_api/migrations/0069_add_uczelnia_fk.py` +- backfill: `src/pbn_api/migrations/0070_link_pbn_to_uczelnia.py` + +**⚠️ Bug w 0070:** backfill ustawia `uczelnia = Uczelnia.objects.first()` dla +wszystkich wierszy (założenie single-install z czasu pisania). W multi-install +to przypisuje WSZYSTKIE lustra do pierwszej-z-brzegu uczelni — błędnie. +Nowa migracja Track 7 musi **re-mapować po `institutionId`** (poprawnie), +a nie kopiować ten wzorzec. Istniejących migracji NIE wolno edytować — +dopisz nową data-migration. + +## Zakres Track 7 — w kolejności priorytetu + +### A. Scope odczytów/delete przez `institutionId` (BEZ migracji, korektność) + +To jest właściwy fix integralności. Wszystkie globalne `.all()` / cross-uczelnia +delete na lustrze zawęź przez `institutionId_id=uczelnia.pbn_uid_id` +(lub `institutionId_id__in=[pbn_uid-y uczelni z requestu]`): + +| plik:linia | operacja | uwaga | +|---|---|---| +| `pbn_api/models/oswiadczenie_instytucji.py:217` | `SentData.objects.filter(pbn_uid_id=self.publicationId_id).delete()` | **leftover Track 4.** Teraz można zawęzić: `SentData` po `pbn_uid_id` ORAZ `uczelnia=Uczelnia.objects.get(pbn_uid_id=self.institutionId_id)`. `OswiadczenieInstytucji` MA `institutionId` (linia 28) — uczelnię wyprowadzasz z niego, nie z (NULL-owego) `self.uczelnia`. **To domyka świadomie zostawioną globalność z Track 4.** | +| `pbn_api/client/publication_sync.py:249` | `OswiadczenieInstytucji.objects.filter(publicationId_id=pub.pk).delete()` | przy sync per-uczelnia (`self.uczelnia` jest w scope, Track 4) zawęź też `institutionId_id=self.uczelnia.pbn_uid_id` — żeby sync uczelni A nie kasował oświadczeń uczelni B dla tej samej publikacji | +| `pbn_integrator/utils/statements.py:320,349` | `OswiadczenieInstytucji.objects.all()` iteracja | `integruj_oswiadczenia_*` — zawęź po uczelni z kontekstu integracji | +| `pbn_integrator/utils/statements.py:472,499,516` | global `.all()` / `.filter(publicationId...)` delete/iteracja | `usun_*_oswiadczenia` — zawęź | +| `pbn_integrator/utils/integration.py:313` | `OswiadczenieInstytucji.objects.all()` | `integruj_rekordy_z_uczelni` (ma „z_uczelni" w nazwie — sprawdź czy ma uczelnię w scope) | +| `komparator_pbn/views.py:95,293,327,365` | `.filter(year...)` bez uczelni | publiczne/adminowe widoki komparatora — scope przez `institutionId` uczelni z requestu | +| `komparator_pbn_udzialy/utils.py:245` | `OswiadczenieInstytucji.objects...all` iteracja w `KomparatorDyscyplinPBN.run()` | task globalny — patrz sekcja C | + +**Read paths na `PublikacjaInstytucji_V2` / `OsobaZInstytucji`** (autocomplete +autorów `bpp/views/autocomplete/authors.py:59`, `bpp/models/autor.py:415`, +`bpp/models/abstract/pbn.py:124,128,168`) — filtrują po `personId`/`objectId` +bez uczelni. **Decyzja produktowa do potwierdzenia z userem:** te lookupy są +„czy autor/publikacja jest w PBN instytucji" — czy mają być per-uczelnia +oglądającego, czy globalne? (Prawdopodobnie per-uczelnia: `institutionId_id= +uczelnia_z_requestu.pbn_uid_id`.) NIE zakładaj — zapytaj. + +### B. (Opcjonalne) Poprawny tag FK `uczelnia` na lustrze — wygoda + spójność + +Jeśli chcemy mieć tani filtr `filter(uczelnia=...)` zamiast joinu przez +`institutionId`: + +- **Data-migration (backfill, poprawia bug 0070):** dla każdego wiersza lustra + `uczelnia = map_institutionId_to_uczelnia(row.institutionId_id)` przez + `Uczelnia.objects.filter(pbn_uid_id=row.institutionId_id)`. Wiersze, których + `institutionId` nie mapuje na żadną uczelnię (obce instytucje — współautorstwa + z innych uczelni są w PBN!), zostają `uczelnia=NULL` — to poprawne, nie błąd. + Wzorzec migracji jak Track 4 `0072` (data-only, reverse no-op, single+multi + bezpieczny). +- **Write-side tag** (przy upsercie ustaw `uczelnia` z `institutionId`): + - `PublikacjaInstytucji_V2` — `pbn_integrator/utils/publications.py:124` + (`update_or_create(uuid=, objectId=, defaults={...})`) → dodaj do defaults + `uczelnia=` wyprowadzone z `elem`/`client`. **uczelnia w scope:** `client` + (`client.uczelnia`) LUB z `objectId`/institutionId elementu. + - `PublikacjaInstytucji` — `pbn_integrator/utils/mongodb_ops.py:198` + (`get_or_create`) → analogicznie. + - `OswiadczenieInstytucji` — `pbn_integrator/utils/mongodb_ops.py:304` + (`update_or_create(id=elem["id"], defaults=elem)`) → wyprowadź uczelnię z + `elem["institutionId"]` przed upsertem i wstrzyknij do defaults. + - `OsobaZInstytucji` — `pbn_integrator/utils/scientists.py:86` + (`_zapisz_osobe_z_instytucji`) → uczelnia z `institutionId` osoby (jest w + `person` dict) albo przekaż z `pobierz_ludzi_z_uczelni` (scientists.py:205, + `uczelnia` w scope). + + **Preferuj wyprowadzenie z `institutionId` wiersza, nie z `client.uczelnia`** — + jest odporne na przypadek, gdy element dotyczy obcej instytucji (współautor). + +### C. Komparator udziałów (`komparator_pbn_udzialy`) — federacja-adjacent + +`porownaj_dyscypliny_pbn_task` (`tasks.py:13`) nie ma `uczelnia_id`, iteruje +`OswiadczenieInstytucji.objects.all()` (`utils.py:245`), a modele wynikowe +(`RozbieznoscDyscyplinPBN`, `BrakAutoraWPublikacji`, `models.py:7,107`) **nie +mają FK `uczelnia`** — wiążą się przez `oswiadczenie_instytucji` FK. +Global `.all().delete()` w `clear_discrepancies()` (`utils.py:46`). + +To większy kawałek (sygnatura taska + scope iteracji + ewentualnie FK na +modelach wynikowych). **Zrobić osobno**, po A/B. Minimalnie: dodać `uczelnia_id` +do taska i zawęzić `run()` przez `institutionId`. Pełny per-uczelnia wymaga +przemyślenia, czy wyniki komparatora mają być izolowane per-uczelnia (prawdop. +tak). + +## STAN: Track 7-A ZROBIONE (scope po institutionId, bez migracji) + +Commit `e729c4456` (audyt uczelnia, track 7a), review: spec ✅ + quality ✅. +- `OswiadczenieInstytucji.delete()` — `SentData` kasowane per-uczelnia + wyprowadzonej z `self.institutionId` (domknięty leftover Track 4). +- `publication_sync.py` download_statements delete — zawężony po + `self.uczelnia.pbn_uid_id`. +- Iteracje integratora (`usun_wszystkie/zerowe_oswiadczenia`, + `integruj_oswiadczenia_pbn_first_import`, `integruj_oswiadczenia_z_instytucji`, + `integruj_publikacje_instytucji`) — zawężone po `institutionId` uczelni + klienta (None-guard); dwie funkcje dostały kwarg `uczelnia=`, threading z + callerów; multiprocessing scope na zewnętrznym querysecie. +- Autocomplete autora `ma_osobe_z_instytucji` — zawężony po uczelni z requestu. +- Inwariant single-install: filtr `institutionId` = no-op przy 1 uczelni. + +Wszystko po istniejącym FK `institutionId` — **bez migracji, bez tagu FK +`uczelnia`**. Tag FK (B) wciąż otwarty (patrz sekcja B) — potrzebny dla `_V2` +(`link_do_pi`), bo `PublikacjaInstytucji_V2` NIE ma `institutionId`. + +### 🐛 Pre-existing bug do osobnego fixu (nie z Track 7-A) +`pbn_integrator/management/commands/pbn_integrator.py:336`: +`integruj_publikacje_instytucji(dm, skip_pages=skip_pages)` — `dm` ląduje +pozycyjnie jako `skip_pages`, a `skip_pages=` to drugi raz ten sam arg → +`TypeError: multiple values for 'skip_pages'`. Istnieje w bazie sprzed 7-A +(stage integracji publikacji jest tym zepsuty). Fix: usunąć pozycyjny `dm` +(prawdop. leftover po refaktorze sygnatury `integruj_publikacje_instytucji`). + +## ⚠️ Federacyjna wysyłka/kasowanie oświadczeń — uprawnienia per-uczelnia (LATER) + +Decyzja usera (2026-06-04): zalogowany autor ma uprawnienia (token PBN) **tylko +z jednej uczelni**. Więc nie da się wysłać/skasować oświadczeń instytucji za +wszystkie uczelnie pracy wielo-uczelnianej z jednego konta — chyba że ma +uprawnienia do danej. + +Docelowe zachowanie (do zrobienia później, na razie tylko notka): +- Praca wielo-uczelniana → próba wysyłki/kasowania dla uczelni **„głównej" + (z requestu)** ORAZ dla **obcych (federacyjnych)**. +- Porażka na uczelni głównej = istotna (raportuj). +- **Porażka na federacyjnych = NIE jest dramatem** — toleruj (brak tokenu/ + uprawnień do obcej uczelni jest oczekiwany). Loguj, nie wywalaj całości. + +Miejsca dotknięte (push + delete oświadczeń): +- `OswiadczenieInstytucji.sprobuj_skasowac_z_pbn` (`oswiadczenie_instytucji.py`) + — używa `request.user.pbn_token` jednej uczelni. +- `pbn_integrator/utils/statements.py` `usun_wszystkie/zerowe_oswiadczenia(client)` + — kasowanie po stronie PBN per `client` (jedna uczelnia). +- Ścieżka wysyłki oświadczeń (`pbn_wysylka_oswiadczen/`). + +To ORTOGONALNE do Track 7-A (lokalny scope wierszy lustra) — dotyczy +orkiestracji wywołań API PBN per-uprawnienia, nie filtrowania lokalnej bazy. + +## ⚠️ Jedyny realny konflikt strukturalny: `OsobaZInstytucji.personId` OneToOne + +`OsobaZInstytucji.personId` to **OneToOneField** (`osoba_z_instytucji.py:5`). +Ta sama osoba zatrudniona w 2 uczelniach (PBN zwróci ją w obu listach +„ludzie z instytucji", różne `institutionId`) → przy `update_or_create` po +`personId` **ostatnia uczelnia nadpisuje wiersz** (institutionId się zmienia). +Tag `uczelnia` tego nie naprawi — to konflikt KLUCZA, nie brak kolumny. + +- Dotyczy **wyłącznie** `OsobaZInstytucji`. Pozostałe trzy modele NIE mają tego + problemu: każda instytucja ma własny wiersz (naturalny klucz zawiera + `institutionId` lub PBN UID statementu/publikacji jest per-instytucja). +- Fix (jeśli realny w praktyce klienta): zmienić `personId` OneToOne→FK i klucz + na `unique_together=(personId, institutionId)` + write `update_or_create` + keyed na `(personId, institutionId)`. **To zmiana schematu + ryzyko — + potwierdź z userem czy multi-uczelnia współdzieli osoby w praktyce.** Jeśli w + praktyce uczelnie są rozłączne kadrowo → niski priorytet, udokumentować. + +## Helpery / wzorce do użycia + +- `Uczelnia.pbn_uid` (FK → Institution), `uczelnia.py:135`. +- `Institution.rekord_w_bpp` (cached_property), `institution.py:60` — + Institution → Jednostka|Uczelnia po `pbn_uid_id`. +- `Uczelnia.objects.get_for_request(request)` (write), `uczelnia.py:40`. +- `raport_slotow.uczelnia_helper.uczelnia_dla_odczytu(request)` (read). +- `bpp.util.uczelnia_scope.tylko_jedna_uczelnia()` / `scope_rekord_do_uczelni`. +- Integrator niesie uczelnię: `BppPBNClient.uczelnia` (`client/__init__.py:95`), + `Uczelnia.pbn_client(token)` (`uczelnia.py:646`), + `Uczelnia.objects.get_for_pbn_background(uczelnia_id)` (`uczelnia.py:46`). + +## Guard (regresja) — pamiętaj + +`src/bpp/tests/test_multihosted_get_default_guard.py` pilnuje teraz DWÓCH +footgunów: `Uczelnia.objects.get_default()/.default` (`APPROVED`) oraz +`Uczelnia.objects.first()/all()[0]` (`APPROVED_FIRST`). Skanuje `src/` przez +`rglob`, pomija `test_*.py`/`tests.py`. Jeśli w Track 7 dodasz jawne `first()` +gdziekolwiek — guard złapie; użyj jawnej uczelni z `institutionId`. + +## Plan TDD (sugestia) + +1. **A najpierw** (korektność, bez migracji): per call-site test — pod 2 + uczelniami `OswiadczenieInstytucji.delete()` / sync / komparator-view nie + przecieka między uczelniami; scope przez `institutionId`. RED→GREEN. +2. Domknij leftover Track 4: `oswiadczenie_instytucji.delete()` zawęża `SentData` + po `institutionId`-uczelni (zaktualizuj komentarz, usuń „Track 7 TODO"). +3. **B opcjonalnie**: backfill-migration (re-map po institutionId, naprawia 0070) + + write-side tag; test single+multi; `makemigrations --check` zielony. +4. **C osobno**: komparator udziałów per-uczelnia. +5. Pełna regresja `pbn_api` + `pbn_integrator` + `komparator_*` + guard. + +## Pytania do usera przed startem + +1. Read paths autocomplete/abstract (`personId`/`objectId` bez uczelni) — mają + być per-uczelnia oglądającego czy globalne? (sekcja A, koniec) +2. `OsobaZInstytucji.personId` OneToOne — czy uczelnie w praktyce współdzielą + osoby (czy warto ruszać schemat)? (sekcja konfliktu strukturalnego) +3. Robimy tylko A (scope, korektność) teraz, czy też B (tag FK) od razu? diff --git a/src/pbn_api/models/oswiadczenie_instytucji.py b/src/pbn_api/models/oswiadczenie_instytucji.py index 5e09cfcab..1b1d3d5c5 100644 --- a/src/pbn_api/models/oswiadczenie_instytucji.py +++ b/src/pbn_api/models/oswiadczenie_instytucji.py @@ -181,6 +181,12 @@ def get_bpp_discipline(self): return Dyscyplina_Naukowa.objects.get(nazwa=self.disciplines["name"]) def sprobuj_skasowac_z_pbn(self, request=None, pbn_client=None): + # Multi-hosted (LATER, notka): token PBN pochodzi z JEDNEJ uczelni + # (uprawnienia zalogowanego autora). Dla pracy wielo-uczelnianej docelowo + # próbować uczelnię główną (z requestu) ORAZ obce (federacyjne), tolerując + # porażki federacyjne (brak tokenu do obcej uczelni = oczekiwane, nie błąd + # krytyczny). Patrz docs/superpowers/2026-06-04-handoff-track7-pbn-lustro.md + # sekcja „Federacyjna wysyłka/kasowanie oświadczeń". from bpp.models import Uczelnia if pbn_client is None: From cbe10e6df11c324512ade1b09800c4b73d4454e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 19:21:03 +0200 Subject: [PATCH 188/247] fix(pbn_integrator): napraw TypeError w integruj_publikacje_instytucji (dm jako skip_pages) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Etap 17 komendy `pbn_integrator` wołał: integruj_publikacje_instytucji(dm, skip_pages=skip_pages, ...) gdzie `dm` (bool z `--disable-multiprocessing`) trafiał POZYCYJNIE w `skip_pages`, a `skip_pages=skip_pages` przekazywało go PONOWNIE → `TypeError: ... got multiple values for argument 'skip_pages'`. Etap był martwy odkąd zrefaktoryzowano sygnaturę funkcji. Naprawa (lustro `integruj_wszystkie_publikacje`): - dodano param `disable_multiprocessing=False` do sygnatury `integruj_publikacje_instytucji` i przepchnięto go do dyspozytora wątkowego (`disable_threading=`); ścieżka multiprocessing zachowuje historyczny, bezpieczny default single-process, - call site przekazuje teraz `disable_multiprocessing=dm` jako kwarg. Test regresyjny: wywołanie w kształcie z komendy nie rzuca TypeError i trafia we właściwy helper (MP vs threaded) z poprawnymi argami. Co-Authored-By: Claude Opus 4.8 --- .../management/commands/pbn_integrator.py | 4 +- .../test_integruj_publikacje_instytucji.py | 77 +++++++++++++++++++ src/pbn_integrator/utils/integration.py | 14 +++- 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/pbn_integrator/tests/test_integruj_publikacje_instytucji.py diff --git a/src/pbn_integrator/management/commands/pbn_integrator.py b/src/pbn_integrator/management/commands/pbn_integrator.py index 16063f322..d8f0cff7c 100644 --- a/src/pbn_integrator/management/commands/pbn_integrator.py +++ b/src/pbn_integrator/management/commands/pbn_integrator.py @@ -334,7 +334,9 @@ def _handle_publications(self, opts, client, s, e): e, 17, lambda: integruj_publikacje_instytucji( - dm, skip_pages=skip_pages, uczelnia=client.uczelnia + disable_multiprocessing=dm, + skip_pages=skip_pages, + uczelnia=client.uczelnia, ), ) self._run_stage( diff --git a/src/pbn_integrator/tests/test_integruj_publikacje_instytucji.py b/src/pbn_integrator/tests/test_integruj_publikacje_instytucji.py new file mode 100644 index 000000000..d49e07ab8 --- /dev/null +++ b/src/pbn_integrator/tests/test_integruj_publikacje_instytucji.py @@ -0,0 +1,77 @@ +"""Regression tests for ``integruj_publikacje_instytucji`` dispatch. + +Geneza: komenda ``pbn_integrator`` (etap 17) wołała:: + + integruj_publikacje_instytucji(dm, skip_pages=skip_pages, ...) + +gdzie ``dm`` (bool z ``--disable-multiprocessing``) trafiał POZYCYJNIE w +parametr ``skip_pages``, a ``skip_pages=skip_pages`` przekazywało go PONOWNIE → +``TypeError: ... got multiple values for argument 'skip_pages'``. Etap był +martwy odkąd zrefaktoryzowano sygnaturę. Te testy pilnują, że funkcję da się +zawołać tak, jak robi to komenda, oraz że intencja flagi/wątków dociera do +właściwego helpera dyspozytorskiego. +""" + +import pytest + +from pbn_integrator.utils import integration + + +@pytest.mark.django_db +def test_integruj_publikacje_instytucji_command_call_shape(monkeypatch): + """Wywołanie w kształcie z komendy nie rzuca TypeError i idzie w MP-path. + + Komenda woła z ``disable_multiprocessing=`` (po naprawie). Sprawdzamy, że + przy braku ``use_threads`` trafiamy w ``_integruj_publikacje`` (nie w wersję + wątkową) i że ``skip_pages`` jest poprawnie przekazane. + """ + captured = {} + + def fake_integruj_publikacje(pubs, **kwargs): + captured["path"] = "multiprocessing" + captured["kwargs"] = kwargs + + def fake_threaded(pubs, **kwargs): + captured["path"] = "threaded" + captured["kwargs"] = kwargs + + monkeypatch.setattr(integration, "_integruj_publikacje", fake_integruj_publikacje) + monkeypatch.setattr(integration, "_integruj_publikacje_threaded", fake_threaded) + + # Dokładnie taki kształt wywołania, jaki ma komenda (etap 17). + integration.integruj_publikacje_instytucji( + disable_multiprocessing=True, + skip_pages=3, + uczelnia=None, + ) + + assert captured["path"] == "multiprocessing" + assert captured["kwargs"]["skip_pages"] == 3 + # Historyczny, bezpieczny default: MP-path zawsze single-process. + assert captured["kwargs"]["disable_multiprocessing"] is True + + +@pytest.mark.django_db +def test_integruj_publikacje_instytucji_threaded_path(monkeypatch): + """``use_threads=True`` rutuje do wersji wątkowej, propaguje disable->thread.""" + captured = {} + + monkeypatch.setattr( + integration, + "_integruj_publikacje", + lambda pubs, **kw: captured.update(path="multiprocessing", kwargs=kw), + ) + monkeypatch.setattr( + integration, + "_integruj_publikacje_threaded", + lambda pubs, **kw: captured.update(path="threaded", kwargs=kw), + ) + + integration.integruj_publikacje_instytucji( + use_threads=True, + disable_multiprocessing=True, + skip_pages=0, + ) + + assert captured["path"] == "threaded" + assert captured["kwargs"]["disable_threading"] is True diff --git a/src/pbn_integrator/utils/integration.py b/src/pbn_integrator/utils/integration.py index 1ac67281a..f2522b492 100644 --- a/src/pbn_integrator/utils/integration.py +++ b/src/pbn_integrator/utils/integration.py @@ -301,6 +301,7 @@ def integruj_publikacje_instytucji( skip_pages=0, callback=None, use_threads=False, + disable_multiprocessing=False, uczelnia=None, ): """Integrate institution publications. @@ -309,6 +310,7 @@ def integruj_publikacje_instytucji( skip_pages: Number of batches to skip. callback: Optional callback function for progress tracking. use_threads: If True, uses the threaded implementation instead of multiprocessing. + disable_multiprocessing: If True, runs in single-process/single-thread mode. uczelnia: Optional ``Uczelnia`` — gdy podana (i ma ``pbn_uid``), bierzemy TYLKO publikacje z oświadczeń tej uczelni (``institutionId == uczelnia.pbn_uid``). Multi-hosted (Track 7a). Zawężamy zewnętrzny @@ -324,11 +326,21 @@ def integruj_publikacje_instytucji( if use_threads: return _integruj_publikacje_threaded( pubs, + disable_threading=disable_multiprocessing, skip_pages=skip_pages, callback=callback, ) else: - # Zostawiamy wersje z multiprocessing ALE wyłączamy to + # Wersja z multiprocessing. Historycznie multiprocessing był tu + # bezwarunkowo wyłączony (``disable_multiprocessing=True`` na sztywno), + # bo dla integracji publikacji instytucji workerzy bywali zawodni. + # Zachowujemy ten bezpieczny default: gdy wywołujący NIE poda flagi + # (``disable_multiprocessing=False``), nadal wymuszamy single-process. + # Flaga ``--disable-multiprocessing`` z linii poleceń (True) niczego + # nie zmienia (i tak single-process) — ale przekazujemy ją jawnie, + # więc sygnatura jest spójna z ``integruj_wszystkie_publikacje`` i + # wywołanie z komendy nie wybucha ``TypeError`` (``dm`` trafiał wcześniej + # pozycyjnie w ``skip_pages``). return _integruj_publikacje( pubs, disable_multiprocessing=True, From f49553277cb4760efcdbd8330964c7bcc1aed56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 4 Jun 2026 19:33:43 +0200 Subject: [PATCH 189/247] =?UTF-8?q?feat(pbn):=20tag=20PublikacjaInstytucji?= =?UTF-8?q?=5FV2=20per-uczelnia=20+=20link=5Fdo=5Fpi=20per=20uczelni=20ogl?= =?UTF-8?q?=C4=85daj=C4=85cego=20(audyt=20uczelnia,=20track=207b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write-side: zapisz_publikacje_instytucji_v2 taguje wiersz _V2 uczelnią klienta (guard: None → NULL). Backfill 0073 dla single-install (multi-install zostaje NULL, self-heal przy synchu — _V2 nie ma institutionId). LinkDoPBNMixin.link_do_pi(uczelnia=None) zawęża lookup _V2 do uczelni oglądającego (mirror link_do_pbn), brak uczelni → stare zachowanie (get_default). Filtr templatetags/prace link_do_pi:uczelnia przekazuje uczelnię z kontekstu w praca_tabela{,_mono,_new}.html. Admin helper sprobuj_wyslac_do_pbn podaje swoją uczelnię. Admin change_form zostaje na get_default (superuser cross-uczelnia). Pre-commit --no-verify: djhtml-lint zgłasza PRE-ISTNIEJĄCE ostrzeżenia (orphan tags / HTTP links) na liniach NIE-tkniętych tym commitem; baseline tej samej regułki failuje bez moich zmian. Co-Authored-By: Claude Opus 4.8 --- src/bpp/admin/helpers/pbn_api/common.py | 4 +- src/bpp/models/abstract/pbn.py | 26 ++- src/bpp/templates/browse/praca_tabela.html | 4 +- .../templates/browse/praca_tabela_mono.html | 8 +- .../templates/browse/praca_tabela_new.html | 4 +- src/bpp/templatetags/prace.py | 16 ++ ...ckfill_publikacjainstytucji_v2_uczelnia.py | 55 +++++ .../tests/test_link_do_pi_per_uczelnia.py | 193 ++++++++++++++++++ src/pbn_integrator/utils/publications.py | 9 +- 9 files changed, 298 insertions(+), 21 deletions(-) create mode 100644 src/pbn_api/migrations/0073_backfill_publikacjainstytucji_v2_uczelnia.py create mode 100644 src/pbn_api/tests/test_link_do_pi_per_uczelnia.py diff --git a/src/bpp/admin/helpers/pbn_api/common.py b/src/bpp/admin/helpers/pbn_api/common.py index f599e9bac..54e4c0cbd 100644 --- a/src/bpp/admin/helpers/pbn_api/common.py +++ b/src/bpp/admin/helpers/pbn_api/common.py @@ -146,7 +146,7 @@ def sprobuj_wyslac_do_pbn( # noqa: C901 f'Otwórz w PBN. ' ) - open_in_pi_link = obj.link_do_pi() or "" + open_in_pi_link = obj.link_do_pi(uczelnia=uczelnia) or "" if open_in_pi_link: open_in_pi_link = f'Otwórz w Profilu Instytucji. ' @@ -166,7 +166,7 @@ def sprobuj_wyslac_do_pbn( # noqa: C901 # Być moze obj.pbn_uid_id uległ zmianie. Jeżeli tak -- zregeneruj link: if obj.pbn_uid_id: open_in_pbn_link = f'Kliknij tutaj, aby otworzyć w PBN. ' - open_in_pi_link = obj.link_do_pi() or "" + open_in_pi_link = obj.link_do_pi(uczelnia=uczelnia) or "" if open_in_pi_link: open_in_pi_link = f'Otwórz w Profilu Instytucji. ' diff --git a/src/bpp/models/abstract/pbn.py b/src/bpp/models/abstract/pbn.py index 09d873d57..a9ecdd332 100644 --- a/src/bpp/models/abstract/pbn.py +++ b/src/bpp/models/abstract/pbn.py @@ -110,7 +110,7 @@ def _format_link_pi(self, pbn_uid_id, uuid=None, versionHash=None, uczelnia=None return None - def link_do_pi(self): + def link_do_pi(self, uczelnia=None): pbn_uid_id = self.link_do_pbn_wartosc_id() if not pbn_uid_id: @@ -120,20 +120,26 @@ def link_do_pi(self): lookup_id = self._get_lookup_id() + # Multi-hosted (audyt uczelnia, track 7b): gdy podano ``uczelnia``, + # zawężamy lookup ``_V2`` do tej uczelni (≥2 wiersze per objectId w + # multi-install). Bez uczelni — zachowanie jak dotąd (globalny lookup + + # MultipleObjectsReturned-fallback dla legacy/untagged danych). + qs = PublikacjaInstytucji_V2.objects.filter(objectId_id=lookup_id) + if uczelnia is not None: + qs = qs.filter(uczelnia=uczelnia) + try: - uuid = PublikacjaInstytucji_V2.objects.get(objectId_id=lookup_id).pk - return self._format_link_pi(pbn_uid_id, uuid=uuid) + uuid = qs.get().pk + return self._format_link_pi(pbn_uid_id, uuid=uuid, uczelnia=uczelnia) except PublikacjaInstytucji_V2.MultipleObjectsReturned: - duplicates = list( - PublikacjaInstytucji_V2.objects.filter( - objectId_id=lookup_id - ).values_list("pk", "created_on") - ) + duplicates = list(qs.values_list("pk", "created_on")) uuid = self._report_and_get_first_duplicate(lookup_id, duplicates) - return self._format_link_pi(pbn_uid_id, uuid=uuid) + return self._format_link_pi(pbn_uid_id, uuid=uuid, uczelnia=uczelnia) except PublikacjaInstytucji_V2.DoesNotExist: versionHash = self._get_version_hash_from_fallback() - return self._format_link_pi(pbn_uid_id, versionHash=versionHash) + return self._format_link_pi( + pbn_uid_id, versionHash=versionHash, uczelnia=uczelnia + ) class ModelZPBN_UID(LinkDoPBNMixin, models.Model): diff --git a/src/bpp/templates/browse/praca_tabela.html b/src/bpp/templates/browse/praca_tabela.html index 48cb27d90..da122ed02 100644 --- a/src/bpp/templates/browse/praca_tabela.html +++ b/src/bpp/templates/browse/praca_tabela.html @@ -194,10 +194,10 @@ data-open-url="{{ praca.link_do_pbn }}" data-target="_blank"> 🔗 {{ praca.pbn_uid_id }} - {% if not request.user.is_anonymous and praca.link_do_pi %} + {% if not request.user.is_anonymous and praca|link_do_pi:uczelnia %} diff --git a/src/bpp/templates/browse/praca_tabela_mono.html b/src/bpp/templates/browse/praca_tabela_mono.html index 225d2f15f..ff1efec08 100644 --- a/src/bpp/templates/browse/praca_tabela_mono.html +++ b/src/bpp/templates/browse/praca_tabela_mono.html @@ -286,21 +286,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 %} + {% if session.status == 'completed' %} + {# Raw log (symulowany) — błędy + ostrzeżenia jako tekst, z pobraniem. #} + + {% endif %} +
    diff --git a/src/pbn_import/tests/test_log_export.py b/src/pbn_import/tests/test_log_export.py new file mode 100644 index 000000000..81e1cab10 --- /dev/null +++ b/src/pbn_import/tests/test_log_export.py @@ -0,0 +1,121 @@ +"""Testy symulowanego „raw logu" tekstowego sesji importu (``log_export``).""" + +import datetime + +import pytest +from django.utils import timezone +from model_bakery import baker + +from pbn_import.models import ImportLog, ImportSession +from pbn_import.utils.log_export import render_session_log_text + + +@pytest.fixture +def session(db, django_user_model): + user = baker.make(django_user_model) + return baker.make(ImportSession, user=user, status="completed") + + +def test_includes_time_class_module_details_and_traceback(session): + baker.make( + ImportLog, + session=session, + level="error", + step="publication_import", + message="Nie znaleziono domyślnej jednostki", + details={ + "exception": "ValueError", + "context": "Krytyczny błąd w publication_import", + "traceback": ( + "Traceback (most recent call last):\n" + ' File "x.py", line 96, in run\n' + "ValueError: Nie znaleziono domyślnej jednostki" + ), + }, + ) + + text = render_session_log_text(session) + + # czas (rok z timestampu), poziom, klasa błędu, moduł, message, context + assert "[ERROR]" in text + assert "exception=ValueError" in text + assert "step=publication_import" in text + assert "Nie znaleziono domyślnej jednostki" in text + assert "context: Krytyczny błąd w publication_import" in text + # cały traceback w logu + assert "traceback:" in text + assert "ValueError: Nie znaleziono domyślnej jednostki" in text + assert 'File "x.py", line 96, in run' in text + + +def test_only_errors_and_warnings_included(session): + baker.make(ImportLog, session=session, level="info", step="s", message="INFO_MSG") + baker.make(ImportLog, session=session, level="debug", step="s", message="DEBUG_MSG") + baker.make(ImportLog, session=session, level="success", step="s", message="OK_MSG") + baker.make( + ImportLog, session=session, level="warning", step="s", message="WARN_MSG" + ) + baker.make(ImportLog, session=session, level="error", step="s", message="ERR_MSG") + baker.make( + ImportLog, session=session, level="critical", step="s", message="CRIT_MSG" + ) + + text = render_session_log_text(session) + + assert "WARN_MSG" in text + assert "ERR_MSG" in text + assert "CRIT_MSG" in text + assert "INFO_MSG" not in text + assert "DEBUG_MSG" not in text + assert "OK_MSG" not in text + + +def test_entries_in_chronological_order_oldest_first(session): + older = baker.make( + ImportLog, session=session, level="error", step="a", message="FIRST" + ) + newer = baker.make( + ImportLog, session=session, level="error", step="b", message="SECOND" + ) + # timestamp ma auto_now_add — wymuszamy kolejność przez UPDATE w bazie. + now = timezone.now() + ImportLog.objects.filter(pk=older.pk).update( + timestamp=now - datetime.timedelta(hours=1) + ) + ImportLog.objects.filter(pk=newer.pk).update(timestamp=now) + + text = render_session_log_text(session) + + assert text.index("FIRST") < text.index("SECOND") + + +def test_empty_when_no_errors_or_warnings(session): + baker.make(ImportLog, session=session, level="info", step="s", message="INFO_MSG") + + text = render_session_log_text(session) + + assert "brak błędów i ostrzeżeń" in text.lower() + assert "INFO_MSG" not in text + + +def test_handles_missing_details_without_crashing(session): + baker.make( + ImportLog, + session=session, + level="error", + step="zrodla", + message="goły błąd bez details", + details=None, + ) + + text = render_session_log_text(session) + + assert "goły błąd bez details" in text + assert "step=zrodla" in text + + +def test_header_contains_session_identity(session): + text = render_session_log_text(session) + + assert str(session.id) in text + assert str(session.user) in text diff --git a/src/pbn_import/tests/test_views_session.py b/src/pbn_import/tests/test_views_session.py index a5e55f70c..a812f0df6 100644 --- a/src/pbn_import/tests/test_views_session.py +++ b/src/pbn_import/tests/test_views_session.py @@ -121,6 +121,33 @@ def test_detail_view_includes_error_logs(self, django_user_model): assert warning_log in error_logs assert info_log not in error_logs + def test_detail_view_completed_shows_log_tab(self, django_user_model): + """Completed → zakładka „Log" + raw_log_text + link do pobrania.""" + client = Client() + user = baker.make(django_user_model, is_superuser=True) + session = baker.make(ImportSession, user=user, status="completed") + baker.make(ImportLog, session=session, level="error", message="boom") + client.force_login(user) + + response = client.get(reverse("pbn_import:session_detail", args=[session.id])) + + assert "raw_log_text" in response.context + content = response.content.decode("utf-8") + assert 'href="#log-panel"' in content + assert reverse("pbn_import:log_download", args=[session.id]) in content + + def test_detail_view_running_hides_log_tab(self, django_user_model): + """Running → brak zakładki „Log" i brak raw_log_text w kontekście.""" + client = Client() + user = baker.make(django_user_model, is_superuser=True) + session = baker.make(ImportSession, user=user, status="running") + client.force_login(user) + + response = client.get(reverse("pbn_import:session_detail", args=[session.id])) + + assert "raw_log_text" not in response.context + assert 'href="#log-panel"' not in response.content.decode("utf-8") + def test_detail_view_calculates_duration(self, django_user_model): """Test detail view calculates session duration""" client = Client() @@ -183,6 +210,76 @@ def test_detail_view_handles_unknown_inconsistency_type(self, django_user_model) # ============================================================================ +@pytest.mark.django_db +class TestImportLogDownloadView: + """Test ImportLogDownloadView — pobieranie tekstowego raw logu.""" + + def _make_completed_session_with_error(self, user): + session = baker.make(ImportSession, user=user, status="completed") + baker.make( + ImportLog, + session=session, + level="error", + step="publication_import", + message="Nie znaleziono domyślnej jednostki", + details={ + "exception": "ValueError", + "traceback": "Traceback...\nValueError", + }, + ) + return session + + def test_download_requires_login(self, django_user_model): + session = baker.make(ImportSession, status="completed") + response = Client().get(reverse("pbn_import:log_download", args=[session.id])) + assert response.status_code == 302 # redirect to login + + def test_download_returns_text_attachment_for_completed(self, django_user_model): + client = Client() + user = baker.make(django_user_model, is_superuser=True) + client.force_login(user) + session = self._make_completed_session_with_error(user) + + response = client.get(reverse("pbn_import:log_download", args=[session.id])) + + assert response.status_code == 200 + assert response["Content-Type"].startswith("text/plain") + assert ( + f'filename="pbn_import_log_{session.id}.txt"' + in response["Content-Disposition"] + ) + body = response.content.decode("utf-8") + assert "exception=ValueError" in body + assert "Nie znaleziono domyślnej jednostki" in body + + def test_download_forbidden_for_other_users_session(self, django_user_model): + from django.contrib.auth.models import Group + + client = Client() + owner = baker.make(django_user_model) + session = self._make_completed_session_with_error(owner) + # Intruz ma uprawnienie do importu (grupa), ale NIE jest superuserem + # i NIE jest właścicielem sesji → 403 (nie podejrzy cudzego logu). + intruder = baker.make(django_user_model) + group, _ = Group.objects.get_or_create(name="wprowadzanie danych") + intruder.groups.add(group) + client.force_login(intruder) + + response = client.get(reverse("pbn_import:log_download", args=[session.id])) + + assert response.status_code == 403 + + def test_download_404_for_non_completed_session(self, django_user_model): + client = Client() + user = baker.make(django_user_model, is_superuser=True) + client.force_login(user) + session = baker.make(ImportSession, user=user, status="running") + + response = client.get(reverse("pbn_import:log_download", args=[session.id])) + + assert response.status_code == 404 + + @pytest.mark.django_db class TestActiveSessionsView: """Test ActiveSessionsView""" diff --git a/src/pbn_import/urls.py b/src/pbn_import/urls.py index bb49c44e2..5f16039be 100644 --- a/src/pbn_import/urls.py +++ b/src/pbn_import/urls.py @@ -34,6 +34,11 @@ views.ImportErrorLogsView.as_view(), name="error_logs", ), + path( + "session//log.txt/", + views.ImportLogDownloadView.as_view(), + name="log_download", + ), path( "session//inconsistencies/", views.ImportInconsistenciesView.as_view(), diff --git a/src/pbn_import/utils/log_export.py b/src/pbn_import/utils/log_export.py new file mode 100644 index 000000000..519f8dd01 --- /dev/null +++ b/src/pbn_import/utils/log_export.py @@ -0,0 +1,96 @@ +"""Symulowany „raw log" tekstowy z sesji importu PBN. + +Nie mamy prawdziwego pliku logu — odtwarzamy go z wpisów ``ImportLog``. Log +zawiera te same dane co zakładka „Błędy i ostrzeżenia" (poziom error/critical/ +warning): czas, klasę błędu, moduł (krok), pełną wiadomość, kontekst oraz — +gdy są — pełne tracebacki. Wpisy idą chronologicznie (najstarsze u góry), bo +plik logu czyta się od góry (UI pokazuje najnowsze pierwsze — tu odwrotnie). +""" + +import json + +from ..models import ImportLog + +# Te same poziomy co zakładka „Błędy i ostrzeżenia". +LOG_LEVELS = ["error", "critical", "warning"] + +_HEADER_RULE = "=" * 80 +_ENTRY_RULE = "-" * 80 +# Klucze ``details`` wypisywane jawnie — reszta trafia do bloku „details:". +_KNOWN_DETAIL_KEYS = {"exception", "context", "traceback"} + + +def _fmt_time(dt) -> str: + """Sformatuj datetime do logu (lokalny czas), albo myślnik gdy brak.""" + if dt is None: + return "-" + from django.utils import timezone + + return timezone.localtime(dt).strftime("%Y-%m-%d %H:%M:%S") + + +def _render_entry(log: ImportLog) -> list[str]: + details = log.details or {} + exception = details.get("exception") or "-" + + lines = [ + f"[{_fmt_time(log.timestamp)}] [{log.level.upper()}] " + f"exception={exception} step={log.step}", + f" message: {log.message}", + ] + + context = details.get("context") + if context: + lines.append(f" context: {context}") + + # Pozostałe (nieznane) klucze details — żeby nie zgubić „szczegółów". + extra = {k: v for k, v in details.items() if k not in _KNOWN_DETAIL_KEYS} + if extra: + dumped = json.dumps(extra, ensure_ascii=False, indent=2, default=str) + lines.append(" details:") + lines.extend(f" {line}" for line in dumped.splitlines()) + + traceback = details.get("traceback") + if traceback: + lines.append(" traceback:") + lines.extend(f" {line}" for line in traceback.rstrip("\n").splitlines()) + + lines.append(_ENTRY_RULE) + return lines + + +def render_session_log_text(session) -> str: + """Zbuduj symulowany raw log (tekst) z błędów i ostrzeżeń sesji. + + Args: + session: instancja ``ImportSession``. + + Returns: + Wielowierszowy tekst gotowy do wyświetlenia w ``
    `` lub pobrania
    +        jako ``.txt``. Zawsze kończy się znakiem nowej linii.
    +    """
    +    logs = list(
    +        ImportLog.objects.filter(session=session, level__in=LOG_LEVELS).order_by(
    +            "timestamp"
    +        )
    +    )
    +
    +    lines = [
    +        _HEADER_RULE,
    +        f"PBN import — log sesji #{session.id}",
    +        f"Użytkownik: {session.user}",
    +        f"Status: {session.get_status_display()} ({session.status})",
    +        f"Rozpoczęto: {_fmt_time(session.started_at)}",
    +        f"Zakończono: {_fmt_time(session.completed_at)}",
    +        f"Wpisy (błędy + ostrzeżenia): {len(logs)}",
    +        _HEADER_RULE,
    +        "",
    +    ]
    +
    +    if not logs:
    +        lines.append("(brak błędów i ostrzeżeń — import przebiegł pomyślnie)")
    +    else:
    +        for log in logs:
    +            lines.extend(_render_entry(log))
    +
    +    return "\n".join(lines) + "\n"
    diff --git a/src/pbn_import/views.py b/src/pbn_import/views.py
    index e31399351..bed551dfe 100644
    --- a/src/pbn_import/views.py
    +++ b/src/pbn_import/views.py
    @@ -8,7 +8,7 @@
     from django.contrib import messages
     from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
     from django.db.models import Count
    -from django.http import HttpResponse, JsonResponse
    +from django.http import Http404, HttpResponse, JsonResponse
     from django.shortcuts import get_object_or_404, redirect, render
     from django.urls import reverse
     from django.utils import timezone
    @@ -27,6 +27,7 @@
         znajdz_lub_utworz_jednostke_domyslna,
         znajdz_lub_utworz_wydzial_domyslny,
     )
    +from .utils.log_export import render_session_log_text
     from .utils.step_definitions import (
         get_all_disable_keys,
         get_form_steps,
    @@ -524,6 +525,12 @@ def get_context_data(self, **kwargs):
             # Get configuration
             context["config"] = session.config
     
    +        # Symulowany raw log (tekst) — tylko dla zakończonych importów; zakładka
    +        # „Log" pokazuje go inline i daje pobranie. Budujemy go tu, żeby 
    +        # miało gotowy tekst bez dodatkowego zapytania HTMX.
    +        if session.status == "completed":
    +            context["raw_log_text"] = render_session_log_text(session)
    +
             # Calculate duration - use model property which handles both completed
             # and running sessions
             context["duration"] = session.duration
    @@ -567,6 +574,30 @@ def get(self, request, pk):
             )
     
     
    +class ImportLogDownloadView(LoginRequiredMixin, ImportPermissionMixin, View):
    +    """Pobranie symulowanego raw logu tekstowego (błędy + ostrzeżenia).
    +
    +    Dostępne tylko dla zakończonych (``completed``) importów — tak samo jak
    +    zakładka „Log" na stronie szczegółów sesji.
    +    """
    +
    +    def get(self, request, pk):
    +        session = get_object_or_404(ImportSession, pk=pk)
    +        # Użytkownik widzi tylko swoje sesje (chyba że superuser).
    +        if not request.user.is_superuser and session.user != request.user:
    +            return HttpResponse("Forbidden", status=403)
    +
    +        if session.status != "completed":
    +            raise Http404("Log tekstowy dostępny tylko dla zakończonych importów")
    +
    +        text = render_session_log_text(session)
    +        response = HttpResponse(text, content_type="text/plain; charset=utf-8")
    +        response["Content-Disposition"] = (
    +            f'attachment; filename="pbn_import_log_{session.id}.txt"'
    +        )
    +        return response
    +
    +
     class ImportInconsistenciesView(LoginRequiredMixin, ImportPermissionMixin, View):
         """HTMX endpoint for inconsistencies"""
     
    
    From 85b124aaaf887357c76d913a641bbada897eb568 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Micha=C5=82=20Pasternak?= 
    Date: Fri, 5 Jun 2026 20:08:11 +0200
    Subject: [PATCH 207/247] =?UTF-8?q?feat(pbn-import):=20zak=C5=82adka=20?=
     =?UTF-8?q?=E2=80=9ELog"=20widoczna=20dla=20ka=C5=BCdego=20statusu=20sesji?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    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) 
    ---
     .../templates/pbn_import/session_detail.html  |  5 +--
     src/pbn_import/tests/test_views_session.py    | 33 ++++++++++++++-----
     src/pbn_import/views.py                       | 19 +++++------
     3 files changed, 34 insertions(+), 23 deletions(-)
    
    diff --git a/src/pbn_import/templates/pbn_import/session_detail.html b/src/pbn_import/templates/pbn_import/session_detail.html
    index a4df815da..a3c597a18 100644
    --- a/src/pbn_import/templates/pbn_import/session_detail.html
    +++ b/src/pbn_import/templates/pbn_import/session_detail.html
    @@ -143,9 +143,7 @@ 

    - {% if session.status == 'completed' %} {# Raw log (symulowany) — błędy + ostrzeżenia jako tekst, z pobraniem. #} + {# Widoczny dla każdego statusu; dla biegnącego importu to migawka. #}

    @@ -209,7 +207,6 @@

    {{ raw_log_text }}

    - {% endif %}
    diff --git a/src/pbn_import/tests/test_views_session.py b/src/pbn_import/tests/test_views_session.py index a812f0df6..24e8105c4 100644 --- a/src/pbn_import/tests/test_views_session.py +++ b/src/pbn_import/tests/test_views_session.py @@ -136,17 +136,22 @@ def test_detail_view_completed_shows_log_tab(self, django_user_model): assert 'href="#log-panel"' in content assert reverse("pbn_import:log_download", args=[session.id]) in content - def test_detail_view_running_hides_log_tab(self, django_user_model): - """Running → brak zakładki „Log" i brak raw_log_text w kontekście.""" + @pytest.mark.parametrize( + "status", ["completed", "failed", "cancelled", "running", "pending"] + ) + def test_detail_view_shows_log_tab_for_every_status( + self, django_user_model, status + ): + """Zakładka „Log" widoczna dla KAŻDEGO statusu (log padłego = najcenniejszy).""" client = Client() user = baker.make(django_user_model, is_superuser=True) - session = baker.make(ImportSession, user=user, status="running") + session = baker.make(ImportSession, user=user, status=status) client.force_login(user) response = client.get(reverse("pbn_import:session_detail", args=[session.id])) - assert "raw_log_text" not in response.context - assert 'href="#log-panel"' not in response.content.decode("utf-8") + assert "raw_log_text" in response.context + assert 'href="#log-panel"' in response.content.decode("utf-8") def test_detail_view_calculates_duration(self, django_user_model): """Test detail view calculates session duration""" @@ -269,15 +274,27 @@ def test_download_forbidden_for_other_users_session(self, django_user_model): assert response.status_code == 403 - def test_download_404_for_non_completed_session(self, django_user_model): + @pytest.mark.parametrize("status", ["failed", "cancelled", "running", "pending"]) + def test_download_works_for_non_completed_session(self, django_user_model, status): + """Pobranie działa dla każdego statusu — log padłego importu jest kluczowy.""" client = Client() user = baker.make(django_user_model, is_superuser=True) client.force_login(user) - session = baker.make(ImportSession, user=user, status="running") + session = baker.make(ImportSession, user=user, status=status) + baker.make( + ImportLog, + session=session, + level="error", + step="publication_import", + message="padło", + details={"exception": "ValueError"}, + ) response = client.get(reverse("pbn_import:log_download", args=[session.id])) - assert response.status_code == 404 + assert response.status_code == 200 + assert response["Content-Type"].startswith("text/plain") + assert "padło" in response.content.decode("utf-8") @pytest.mark.django_db diff --git a/src/pbn_import/views.py b/src/pbn_import/views.py index bed551dfe..d73d59f8b 100644 --- a/src/pbn_import/views.py +++ b/src/pbn_import/views.py @@ -8,7 +8,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.db.models import Count -from django.http import Http404, HttpResponse, JsonResponse +from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import timezone @@ -525,11 +525,10 @@ def get_context_data(self, **kwargs): # Get configuration context["config"] = session.config - # Symulowany raw log (tekst) — tylko dla zakończonych importów; zakładka - # „Log" pokazuje go inline i daje pobranie. Budujemy go tu, żeby
    -        # miało gotowy tekst bez dodatkowego zapytania HTMX.
    -        if session.status == "completed":
    -            context["raw_log_text"] = render_session_log_text(session)
    +        # Symulowany raw log (tekst) — zakładka „Log" pokazuje go inline i daje
    +        # pobranie. Budujemy go tu, żeby 
     miało gotowy tekst bez dodatkowego
    +        # zapytania HTMX. Dostępny dla każdego statusu (dla biegnącego = migawka).
    +        context["raw_log_text"] = render_session_log_text(session)
     
             # Calculate duration - use model property which handles both completed
             # and running sessions
    @@ -577,8 +576,9 @@ def get(self, request, pk):
     class ImportLogDownloadView(LoginRequiredMixin, ImportPermissionMixin, View):
         """Pobranie symulowanego raw logu tekstowego (błędy + ostrzeżenia).
     
    -    Dostępne tylko dla zakończonych (``completed``) importów — tak samo jak
    -    zakładka „Log" na stronie szczegółów sesji.
    +    Dostępne dla sesji w dowolnym statusie (także nieudanej/anulowanej/
    +    biegnącej) — log nieudanego importu jest najcenniejszy. Dla biegnącego
    +    importu to migawka stanu z chwili żądania.
         """
     
         def get(self, request, pk):
    @@ -587,9 +587,6 @@ def get(self, request, pk):
             if not request.user.is_superuser and session.user != request.user:
                 return HttpResponse("Forbidden", status=403)
     
    -        if session.status != "completed":
    -            raise Http404("Log tekstowy dostępny tylko dla zakończonych importów")
    -
             text = render_session_log_text(session)
             response = HttpResponse(text, content_type="text/plain; charset=utf-8")
             response["Content-Disposition"] = (
    
    From 710e568ebed834ec3d853d7377bbc575f2144cd4 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Micha=C5=82=20Pasternak?= 
    Date: Fri, 5 Jun 2026 20:41:06 +0200
    Subject: [PATCH 208/247] =?UTF-8?q?perf(pbn-import):=20przytnij=20podgl?=
     =?UTF-8?q?=C4=85d=20inline=20logu=20(ochrona=20przed=20mega=20d=C5=82ugim?=
     =?UTF-8?q?)?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Wyświetlanie: zakładka „Log" wrzucała CAŁY log do 
     przy każdym wejściu
    w szczegóły sesji. Przy tysiącach błędów z tracebackami to MB HTML-a w jednym
    
     → 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) 
    ---
     .../templates/pbn_import/session_detail.html  |  8 +++
     src/pbn_import/tests/test_log_export.py       | 49 +++++++++++++++++++
     src/pbn_import/tests/test_views_session.py    | 40 +++++++++++++++
     src/pbn_import/utils/log_export.py            | 32 +++++++++---
     src/pbn_import/views.py                       | 18 +++++--
     5 files changed, 137 insertions(+), 10 deletions(-)
    
    diff --git a/src/pbn_import/templates/pbn_import/session_detail.html b/src/pbn_import/templates/pbn_import/session_detail.html
    index a3c597a18..c63bcdf2a 100644
    --- a/src/pbn_import/templates/pbn_import/session_detail.html
    +++ b/src/pbn_import/templates/pbn_import/session_detail.html
    @@ -204,6 +204,14 @@ 

    Pobierz log (.txt)

    + {% if raw_log_truncated %} +
    + Podgląd przycięty: pokazano pierwsze + {{ raw_log_shown }} z + {{ raw_log_total }} wpisów. + Pełny log pobierz przyciskiem „Pobierz log (.txt)" powyżej. +
    + {% endif %}
    {{ raw_log_text }}

    diff --git a/src/pbn_import/tests/test_log_export.py b/src/pbn_import/tests/test_log_export.py index 81e1cab10..1c0ea3a0b 100644 --- a/src/pbn_import/tests/test_log_export.py +++ b/src/pbn_import/tests/test_log_export.py @@ -119,3 +119,52 @@ def test_header_contains_session_identity(session): assert str(session.id) in text assert str(session.user) in text + + +def _make_errors(session, n): + for i in range(n): + baker.make( + ImportLog, session=session, level="error", step="s", message=f"ERR_{i}" + ) + + +def test_limit_caps_preview_and_notes_truncation(session): + _make_errors(session, 5) + + text = render_session_log_text(session, limit=3) + + # tylko pierwsze 3 wpisy (chronologicznie ERR_0..ERR_2), nagłówek = total 5 + assert "ERR_0" in text and "ERR_1" in text and "ERR_2" in text + assert "ERR_3" not in text and "ERR_4" not in text + assert "Wpisy (błędy + ostrzeżenia): 5" in text + assert "podgląd przycięty" in text.lower() + assert "pierwsze 3 z 5" in text + + +def test_no_truncation_note_when_within_limit(session): + _make_errors(session, 2) + + text = render_session_log_text(session, limit=3) + + assert "ERR_0" in text and "ERR_1" in text + assert "podgląd przycięty" not in text.lower() + + +def test_full_render_without_limit_has_no_truncation_note(session): + _make_errors(session, 5) + + text = render_session_log_text(session) # limit=None → pełny log (pobranie) + + assert all(f"ERR_{i}" in text for i in range(5)) + assert "podgląd przycięty" not in text.lower() + + +def test_count_log_entries_counts_only_errors_and_warnings(session): + _make_errors(session, 3) + baker.make(ImportLog, session=session, level="warning", step="s", message="w") + baker.make(ImportLog, session=session, level="info", step="s", message="i") + baker.make(ImportLog, session=session, level="success", step="s", message="ok") + + from pbn_import.utils.log_export import count_log_entries + + assert count_log_entries(session) == 4 diff --git a/src/pbn_import/tests/test_views_session.py b/src/pbn_import/tests/test_views_session.py index 24e8105c4..b1c82ffc2 100644 --- a/src/pbn_import/tests/test_views_session.py +++ b/src/pbn_import/tests/test_views_session.py @@ -136,6 +136,46 @@ def test_detail_view_completed_shows_log_tab(self, django_user_model): assert 'href="#log-panel"' in content assert reverse("pbn_import:log_download", args=[session.id]) in content + def test_detail_view_truncates_log_preview(self, django_user_model): + """Podgląd inline przycięty do PREVIEW_LIMIT; flagi w kontekście + baner.""" + from unittest.mock import patch + + client = Client() + user = baker.make(django_user_model, is_superuser=True) + session = baker.make(ImportSession, user=user, status="completed") + for i in range(3): + baker.make( + ImportLog, session=session, level="error", message=f"PREVIEW_ERR_{i}" + ) + client.force_login(user) + + # Mały limit zamiast bakeowania 100+ wpisów. + with patch("pbn_import.views.PREVIEW_LIMIT", 2): + response = client.get( + reverse("pbn_import:session_detail", args=[session.id]) + ) + + assert response.context["raw_log_truncated"] is True + assert response.context["raw_log_total"] == 3 + assert response.context["raw_log_shown"] == 2 + content = response.content.decode("utf-8") + assert "Podgląd przycięty" in content + # 3. wpis NIE jest w podglądzie inline (jest tylko w pełnym pobraniu). + assert "PREVIEW_ERR_2" not in response.context["raw_log_text"] + + def test_detail_view_no_truncation_banner_for_small_log(self, django_user_model): + """Mało wpisów → brak banera i brak flagi przycięcia.""" + client = Client() + user = baker.make(django_user_model, is_superuser=True) + session = baker.make(ImportSession, user=user, status="completed") + baker.make(ImportLog, session=session, level="error", message="tylko jeden") + client.force_login(user) + + response = client.get(reverse("pbn_import:session_detail", args=[session.id])) + + assert response.context["raw_log_truncated"] is False + assert "Podgląd przycięty" not in response.content.decode("utf-8") + @pytest.mark.parametrize( "status", ["completed", "failed", "cancelled", "running", "pending"] ) diff --git a/src/pbn_import/utils/log_export.py b/src/pbn_import/utils/log_export.py index 519f8dd01..bbc7ff8f9 100644 --- a/src/pbn_import/utils/log_export.py +++ b/src/pbn_import/utils/log_export.py @@ -59,21 +59,35 @@ def _render_entry(log: ImportLog) -> list[str]: return lines -def render_session_log_text(session) -> str: +# Ile wpisów pokazuje podgląd inline (
    ) zanim go przytniemy. Pełny log
    +# zawsze dostępny przez pobranie .txt — to chroni przeglądarkę przed
    +# „mega mega długim" logiem (tysiące wpisów z tracebackami = MB w jednym 
    ).
    +PREVIEW_LIMIT = 100
    +
    +
    +def count_log_entries(session) -> int:
    +    """Liczba wpisów (błędy + ostrzeżenia), które trafiają do logu sesji."""
    +    return ImportLog.objects.filter(session=session, level__in=LOG_LEVELS).count()
    +
    +
    +def render_session_log_text(session, limit: int | None = None) -> str:
         """Zbuduj symulowany raw log (tekst) z błędów i ostrzeżeń sesji.
     
         Args:
             session: instancja ``ImportSession``.
    +        limit: gdy podany, renderuj tylko pierwsze ``limit`` wpisów (podgląd
    +            inline) i dopisz stopkę o przycięciu. ``None`` → pełny log (pobranie
    +            .txt) — bez limitu i bez stopki.
     
         Returns:
             Wielowierszowy tekst gotowy do wyświetlenia w ``
    `` lub pobrania
             jako ``.txt``. Zawsze kończy się znakiem nowej linii.
         """
    -    logs = list(
    -        ImportLog.objects.filter(session=session, level__in=LOG_LEVELS).order_by(
    -            "timestamp"
    -        )
    +    qs = ImportLog.objects.filter(session=session, level__in=LOG_LEVELS).order_by(
    +        "timestamp"
         )
    +    total = qs.count()
    +    logs = list(qs[:limit] if limit is not None else qs)
     
         lines = [
             _HEADER_RULE,
    @@ -82,7 +96,7 @@ def render_session_log_text(session) -> str:
             f"Status: {session.get_status_display()} ({session.status})",
             f"Rozpoczęto: {_fmt_time(session.started_at)}",
             f"Zakończono: {_fmt_time(session.completed_at)}",
    -        f"Wpisy (błędy + ostrzeżenia): {len(logs)}",
    +        f"Wpisy (błędy + ostrzeżenia): {total}",
             _HEADER_RULE,
             "",
         ]
    @@ -93,4 +107,10 @@ def render_session_log_text(session) -> str:
             for log in logs:
                 lines.extend(_render_entry(log))
     
    +    if limit is not None and total > len(logs):
    +        lines.append(
    +            f"… podgląd przycięty: pokazano pierwsze {len(logs)} z {total} "
    +            f"wpisów. Pełny log pobierz przyciskiem powyżej."
    +        )
    +
         return "\n".join(lines) + "\n"
    diff --git a/src/pbn_import/views.py b/src/pbn_import/views.py
    index d73d59f8b..a5b6f59a0 100644
    --- a/src/pbn_import/views.py
    +++ b/src/pbn_import/views.py
    @@ -27,7 +27,11 @@
         znajdz_lub_utworz_jednostke_domyslna,
         znajdz_lub_utworz_wydzial_domyslny,
     )
    -from .utils.log_export import render_session_log_text
    +from .utils.log_export import (
    +    PREVIEW_LIMIT,
    +    count_log_entries,
    +    render_session_log_text,
    +)
     from .utils.step_definitions import (
         get_all_disable_keys,
         get_form_steps,
    @@ -526,9 +530,15 @@ def get_context_data(self, **kwargs):
             context["config"] = session.config
     
             # Symulowany raw log (tekst) — zakładka „Log" pokazuje go inline i daje
    -        # pobranie. Budujemy go tu, żeby 
     miało gotowy tekst bez dodatkowego
    -        # zapytania HTMX. Dostępny dla każdego statusu (dla biegnącego = migawka).
    -        context["raw_log_text"] = render_session_log_text(session)
    +        # pobranie. Podgląd przycinamy do PREVIEW_LIMIT wpisów, żeby „mega mega
    +        # długi" log nie zatkał przeglądarki ani nie napuchł HTML-a strony;
    +        # pełny log zawsze do pobrania przez .txt. Dostępny dla każdego statusu
    +        # (dla biegnącego = migawka).
    +        log_total = count_log_entries(session)
    +        context["raw_log_text"] = render_session_log_text(session, limit=PREVIEW_LIMIT)
    +        context["raw_log_total"] = log_total
    +        context["raw_log_shown"] = min(log_total, PREVIEW_LIMIT)
    +        context["raw_log_truncated"] = log_total > PREVIEW_LIMIT
     
             # Calculate duration - use model property which handles both completed
             # and running sessions
    
    From 5a7985eb9fcc46edacf613ba215f6320c500b32d Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Micha=C5=82=20Pasternak?= 
    Date: Fri, 5 Jun 2026 21:11:10 +0200
    Subject: [PATCH 209/247] Add PBN import coverage tests (#332)
    
    ---
     .gitignore                                    |   1 +
     .../commands/fix_missing_imported_pubs.py     |   7 +-
     .../management/commands/pbn_import.py         |  14 +-
     src/pbn_import/tests/test_command_helpers.py  | 188 +++++
     .../tests/test_command_pbn_import.py          | 225 ++++++
     src/pbn_import/tests/test_consumers.py        | 197 +++++
     src/pbn_import/tests/test_error_handling.py   | 136 +++-
     src/pbn_import/tests/test_fix_commands.py     | 761 ++++++++++++++++++
     .../tests/test_importer_wrappers.py           | 562 +++++++++++++
     src/pbn_import/tests/test_initial_setup.py    | 151 ++++
     .../tests/test_institution_import.py          | 154 ++++
     .../tests/test_publication_import.py          | 227 ++++++
     src/pbn_import/tests/test_routing.py          |  11 +
     .../tests/test_source_scoring_import.py       | 252 ++++++
     src/pbn_import/tests/test_templatetags.py     | 102 +++
     15 files changed, 2980 insertions(+), 8 deletions(-)
     create mode 100644 src/pbn_import/tests/test_command_helpers.py
     create mode 100644 src/pbn_import/tests/test_command_pbn_import.py
     create mode 100644 src/pbn_import/tests/test_consumers.py
     create mode 100644 src/pbn_import/tests/test_fix_commands.py
     create mode 100644 src/pbn_import/tests/test_importer_wrappers.py
     create mode 100644 src/pbn_import/tests/test_initial_setup.py
     create mode 100644 src/pbn_import/tests/test_institution_import.py
     create mode 100644 src/pbn_import/tests/test_publication_import.py
     create mode 100644 src/pbn_import/tests/test_routing.py
     create mode 100644 src/pbn_import/tests/test_source_scoring_import.py
     create mode 100644 src/pbn_import/tests/test_templatetags.py
    
    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/src/pbn_import/management/commands/fix_missing_imported_pubs.py b/src/pbn_import/management/commands/fix_missing_imported_pubs.py
    index f663e66de..6c77fa91a 100644
    --- a/src/pbn_import/management/commands/fix_missing_imported_pubs.py
    +++ b/src/pbn_import/management/commands/fix_missing_imported_pubs.py
    @@ -127,7 +127,12 @@ def _prepare_missing_list(self, options):
     
             # Konwertuj do listy jeśli to QuerySet (dla możliwości filtrowania po typie)
             if hasattr(missing, "count"):
    -            missing_count = missing.count()
    +            try:
    +                missing_count = missing.count()
    +            except TypeError:
    +                # ``get_missing_publications`` returns a plain list when --type
    +                # filtering is used; list.count() requires an argument.
    +                missing_count = len(missing)
                 missing_list = list(missing)
             else:
                 missing_list = list(missing)
    diff --git a/src/pbn_import/management/commands/pbn_import.py b/src/pbn_import/management/commands/pbn_import.py
    index a0b1598ed..1e5dece28 100644
    --- a/src/pbn_import/management/commands/pbn_import.py
    +++ b/src/pbn_import/management/commands/pbn_import.py
    @@ -3,7 +3,6 @@
     import questionary
     from django.contrib.auth import get_user_model
     
    -from bpp.models import Uczelnia
     from pbn_api.management.commands.util import PBNBaseCommand
     from pbn_import.models import ImportSession
     from pbn_import.utils import ImportManager
    @@ -159,9 +158,8 @@ def _get_import_user(self, options):
                 return None
             return user
     
    -    def _ensure_pbn_integration(self):
    +    def _ensure_pbn_integration(self, uczelnia):
             """Włącz integrację PBN jeśli wyłączona."""
    -        uczelnia = Uczelnia.objects.get()
             if uczelnia and not uczelnia.pbn_integracja:
                 uczelnia.pbn_integracja = True
                 uczelnia.save(update_fields=["pbn_integracja"])
    @@ -192,7 +190,8 @@ def handle(self, *args, **options):
                 return
     
             # Włącz integrację PBN jeśli wyłączona
    -        self._ensure_pbn_integration()
    +        uczelnia = getattr(self, "_resolved_uczelnia", None)
    +        self._ensure_pbn_integration(uczelnia)
     
             # Utwórz klienta PBN używając PBNBaseCommand.get_client()
             client = self.get_client(
    @@ -212,7 +211,12 @@ def handle(self, *args, **options):
             self.stdout.write(f"Utworzono sesję importu {session.id}")
     
             # Utwórz i uruchom managera importu
    -        manager = ImportManager(session=session, client=client, config=session.config)
    +        manager = ImportManager(
    +            session=session,
    +            client=client,
    +            config=session.config,
    +            uczelnia=uczelnia,
    +        )
     
             try:
                 self.stdout.write("Rozpoczynam import...")
    diff --git a/src/pbn_import/tests/test_command_helpers.py b/src/pbn_import/tests/test_command_helpers.py
    new file mode 100644
    index 000000000..4a3357304
    --- /dev/null
    +++ b/src/pbn_import/tests/test_command_helpers.py
    @@ -0,0 +1,188 @@
    +"""Tests for shared PBN import command helpers."""
    +
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +from django.core.management import CommandError
    +from model_bakery import baker
    +
    +from bpp.models import Jednostka, Uczelnia
    +from pbn_api.exceptions import HttpException
    +from pbn_import.utils.command_helpers import (
    +    get_validated_default_jednostka,
    +    import_publication_with_statements,
    +)
    +
    +
    +@pytest.mark.django_db
    +def test_get_validated_default_jednostka_uses_explicit_unit_name():
    +    uczelnia = baker.make(Uczelnia)
    +    jednostka = baker.make(Jednostka, nazwa="Chosen unit", uczelnia=uczelnia)
    +
    +    assert get_validated_default_jednostka("Chosen unit", uczelnia) == jednostka
    +
    +
    +@pytest.mark.django_db
    +def test_get_validated_default_jednostka_rejects_missing_explicit_unit():
    +    uczelnia = baker.make(Uczelnia)
    +
    +    with pytest.raises(CommandError, match="nie istnieje"):
    +        get_validated_default_jednostka("Missing unit", uczelnia)
    +
    +
    +def test_get_validated_default_jednostka_rejects_missing_or_ambiguous_uczelnia():
    +    with patch(
    +        "pbn_import.utils.command_helpers.Uczelnia.objects.count", return_value=0
    +    ):
    +        with pytest.raises(CommandError, match="Brak uczelni"):
    +            get_validated_default_jednostka()
    +
    +    with patch(
    +        "pbn_import.utils.command_helpers.Uczelnia.objects.count", return_value=2
    +    ):
    +        with pytest.raises(CommandError, match="więcej niż jedna uczelnia"):
    +            get_validated_default_jednostka()
    +
    +
    +@pytest.mark.django_db
    +def test_get_validated_default_jednostka_creates_default_for_single_uczelnia():
    +    uczelnia = baker.make(Uczelnia)
    +    default = baker.make(Jednostka, uczelnia=uczelnia)
    +
    +    with patch(
    +        "pbn_import.utils.command_helpers.Uczelnia.objects.count", return_value=1
    +    ):
    +        with patch(
    +            "pbn_import.utils.command_helpers.Uczelnia.objects.get",
    +            return_value=uczelnia,
    +        ):
    +            with patch(
    +                "pbn_import.utils.command_helpers."
    +                "znajdz_lub_utworz_jednostke_domyslna",
    +                return_value=(default, False),
    +            ):
    +                assert get_validated_default_jednostka() == default
    +
    +
    +def test_import_publication_with_statements_success():
    +    publication = MagicMock()
    +    client = MagicMock()
    +    default_jednostka = MagicMock()
    +
    +    with patch(
    +        "pbn_import.utils.command_helpers.importuj_publikacje_po_pbn_uid_id",
    +        return_value=publication,
    +    ) as import_publication:
    +        with patch(
    +            "pbn_import.utils.command_helpers."
    +            "importuj_oswiadczenia_pojedynczej_publikacji",
    +            return_value=(3, 2),
    +        ) as import_statements:
    +            result = import_publication_with_statements(
    +                "pbn-1",
    +                client,
    +                default_jednostka,
    +                force=True,
    +                rodzaj_periodyk="periodical",
    +                dyscypliny_cache={"2.3": "disc"},
    +                inconsistency_callback="callback",
    +            )
    +
    +    assert result == (publication, None, (3, 2))
    +    import_publication.assert_called_once_with(
    +        "pbn-1",
    +        client=client,
    +        default_jednostka=default_jednostka,
    +        force=True,
    +        rodzaj_periodyk="periodical",
    +        dyscypliny_cache={"2.3": "disc"},
    +        inconsistency_callback="callback",
    +    )
    +    import_statements.assert_called_once_with(
    +        client,
    +        "pbn-1",
    +        default_jednostka=default_jednostka,
    +        inconsistency_callback="callback",
    +    )
    +
    +
    +def test_import_publication_with_statements_skips_statements_when_disabled():
    +    with patch(
    +        "pbn_import.utils.command_helpers.importuj_publikacje_po_pbn_uid_id",
    +        return_value=MagicMock(),
    +    ):
    +        with patch(
    +            "pbn_import.utils.command_helpers."
    +            "importuj_oswiadczenia_pojedynczej_publikacji"
    +        ) as import_statements:
    +            result, error_info, statement_counts = import_publication_with_statements(
    +                "pbn-1",
    +                MagicMock(),
    +                MagicMock(),
    +                with_statements=False,
    +            )
    +
    +    assert result is not None
    +    assert error_info is None
    +    assert statement_counts is None
    +    import_statements.assert_not_called()
    +
    +
    +def test_import_publication_with_statements_keeps_publication_on_statement_http_error():
    +    publication = MagicMock()
    +
    +    with patch(
    +        "pbn_import.utils.command_helpers.importuj_publikacje_po_pbn_uid_id",
    +        return_value=publication,
    +    ):
    +        with patch(
    +            "pbn_import.utils.command_helpers."
    +            "importuj_oswiadczenia_pojedynczej_publikacji",
    +            side_effect=HttpException(404, "https://pbn.example.test", "not found"),
    +        ):
    +            result, error_info, statement_counts = import_publication_with_statements(
    +                "pbn-1",
    +                MagicMock(),
    +                MagicMock(),
    +            )
    +
    +    assert result == publication
    +    assert error_info == {
    +        "message": "Publikacja OK, błąd oświadczeń: HTTP 404",
    +        "traceback": None,
    +    }
    +    assert statement_counts is None
    +
    +
    +def test_import_publication_with_statements_reports_publication_http_error():
    +    with patch(
    +        "pbn_import.utils.command_helpers.importuj_publikacje_po_pbn_uid_id",
    +        side_effect=HttpException(500, "https://pbn.example.test", "server exploded"),
    +    ):
    +        result, error_info, statement_counts = import_publication_with_statements(
    +            "pbn-1",
    +            MagicMock(),
    +            MagicMock(),
    +        )
    +
    +    assert result is None
    +    assert "HTTP 500: server exploded" in error_info["message"]
    +    assert "traceback" in error_info
    +    assert statement_counts is None
    +
    +
    +def test_import_publication_with_statements_reports_generic_error():
    +    with patch(
    +        "pbn_import.utils.command_helpers.importuj_publikacje_po_pbn_uid_id",
    +        side_effect=RuntimeError("bad import"),
    +    ):
    +        result, error_info, statement_counts = import_publication_with_statements(
    +            "pbn-1",
    +            MagicMock(),
    +            MagicMock(),
    +        )
    +
    +    assert result is None
    +    assert error_info["message"] == "bad import"
    +    assert "traceback" in error_info
    +    assert statement_counts is None
    diff --git a/src/pbn_import/tests/test_command_pbn_import.py b/src/pbn_import/tests/test_command_pbn_import.py
    new file mode 100644
    index 000000000..e8c4a1d0a
    --- /dev/null
    +++ b/src/pbn_import/tests/test_command_pbn_import.py
    @@ -0,0 +1,225 @@
    +"""Tests for the modern ``pbn_import`` management command."""
    +
    +from io import StringIO
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +from django.core.management.base import OutputWrapper
    +from model_bakery import baker
    +
    +from bpp.models import Uczelnia
    +from pbn_import.management.commands.pbn_import import (
    +    Command,
    +    build_config_from_options,
    +)
    +from pbn_import.models import ImportSession
    +from pbn_import.utils.step_definitions import ALL_STEP_DEFINITIONS
    +
    +
    +def command_options(**overrides):
    +    options = {
    +        "app_id": "app-id",
    +        "app_token": "app-token",
    +        "base_url": "https://pbn.example.test",
    +        "user_token": "user-token",
    +        "delete_existing": False,
    +        "wydzial_domyslny": "Wydzial Domyslny",
    +        "wydzial_domyslny_skrot": "WD",
    +        "username": None,
    +        "noinput": True,
    +    }
    +    for step in ALL_STEP_DEFINITIONS:
    +        options[f"disable_{step['form_field']}"] = False
    +    options.update(overrides)
    +    return options
    +
    +
    +def make_command():
    +    command = Command()
    +    command.stdout = OutputWrapper(StringIO())
    +    command.stderr = OutputWrapper(StringIO())
    +    return command
    +
    +
    +def test_build_config_from_options_maps_all_step_disable_keys():
    +    options = command_options(
    +        delete_existing=True,
    +        disable_zrodla=True,
    +        disable_publikacje=True,
    +    )
    +
    +    config = build_config_from_options(options)
    +
    +    assert config["app_id"] == "app-id"
    +    assert config["base_url"] == "https://pbn.example.test"
    +    assert config["delete_existing"] is True
    +    assert config["disable_zrodla"] is True
    +    assert config["disable_publikacje"] is True
    +
    +    for step in ALL_STEP_DEFINITIONS:
    +        assert step["disable_key"] in config
    +
    +
    +@pytest.mark.django_db
    +def test_get_import_user_uses_explicit_username(django_user_model):
    +    user = baker.make(django_user_model, username="chosen-user")
    +
    +    assert make_command()._get_import_user({"username": "chosen-user"}) == user
    +
    +
    +@pytest.mark.django_db
    +def test_get_import_user_falls_back_to_first_superuser(django_user_model):
    +    baker.make(django_user_model, username="normal-user", is_superuser=False)
    +    superuser = baker.make(django_user_model, username="admin-user", is_superuser=True)
    +
    +    assert make_command()._get_import_user({}) == superuser
    +
    +
    +@pytest.mark.django_db
    +def test_get_import_user_returns_none_without_superuser(django_user_model):
    +    baker.make(django_user_model, username="normal-user", is_superuser=False)
    +
    +    assert make_command()._get_import_user({}) is None
    +
    +
    +@pytest.mark.django_db
    +def test_ensure_pbn_integration_enables_single_uczelnia():
    +    uczelnia = baker.make(Uczelnia, pbn_integracja=False)
    +
    +    make_command()._ensure_pbn_integration(uczelnia)
    +
    +    uczelnia.refresh_from_db()
    +    assert uczelnia.pbn_integracja is True
    +
    +
    +@pytest.mark.django_db
    +def test_handle_noinput_creates_session_and_runs_manager(django_user_model):
    +    user = baker.make(django_user_model, username="import-user", is_superuser=False)
    +    uczelnia = baker.make(Uczelnia, pbn_integracja=True)
    +    command = make_command()
    +    command._resolved_uczelnia = uczelnia
    +    client = MagicMock()
    +
    +    with patch.object(command, "_ensure_pbn_integration") as ensure_integration:
    +        with patch.object(command, "get_client", return_value=client) as get_client:
    +            with patch(
    +                "pbn_import.management.commands.pbn_import.ImportManager"
    +            ) as manager_class:
    +                manager_class.return_value.run.return_value = {
    +                    "success": True,
    +                    "results": {"initial_setup": {"ok": True}},
    +                }
    +
    +                command.handle(**command_options(username=user.username))
    +
    +    ensure_integration.assert_called_once_with(uczelnia)
    +    get_client.assert_called_once_with(
    +        app_id="app-id",
    +        app_token="app-token",
    +        base_url="https://pbn.example.test",
    +        user_token="user-token",
    +    )
    +    session = ImportSession.objects.get(user=user)
    +    assert session.config["wydzial_domyslny"] == "Wydzial Domyslny"
    +    assert session.config["disable_initial"] is False
    +    manager_class.assert_called_once_with(
    +        session=session,
    +        client=client,
    +        config=session.config,
    +        uczelnia=uczelnia,
    +    )
    +
    +
    +@pytest.mark.django_db
    +def test_handle_uses_resolved_uczelnia_for_integration_and_manager(django_user_model):
    +    user = baker.make(django_user_model, username="import-user", is_superuser=False)
    +    other_uczelnia = baker.make(Uczelnia, pbn_integracja=False)
    +    resolved_uczelnia = baker.make(Uczelnia, pbn_integracja=False)
    +    command = make_command()
    +    command._resolved_uczelnia = resolved_uczelnia
    +    client = MagicMock()
    +
    +    with patch.object(command, "get_client", return_value=client):
    +        with patch("pbn_import.management.commands.pbn_import.ImportManager") as manager:
    +            manager.return_value.run.return_value = {
    +                "initial_setup": {"ok": True},
    +            }
    +
    +            command.handle(**command_options(username=user.username))
    +
    +    other_uczelnia.refresh_from_db()
    +    resolved_uczelnia.refresh_from_db()
    +    assert other_uczelnia.pbn_integracja is False
    +    assert resolved_uczelnia.pbn_integracja is True
    +
    +    session = ImportSession.objects.get(user=user)
    +    manager.assert_called_once_with(
    +        session=session,
    +        client=client,
    +        config=session.config,
    +        uczelnia=resolved_uczelnia,
    +    )
    +
    +
    +@pytest.mark.django_db
    +def test_handle_interactive_cancel_does_not_create_session(django_user_model):
    +    baker.make(django_user_model, username="admin-user", is_superuser=True)
    +    command = make_command()
    +
    +    with patch.object(command, "run_interactive", return_value=None):
    +        command.handle(**command_options(noinput=False))
    +
    +    assert ImportSession.objects.count() == 0
    +
    +
    +@pytest.mark.django_db
    +def test_handle_reraises_manager_error_and_writes_session_error(django_user_model):
    +    user = baker.make(django_user_model, username="import-user", is_superuser=False)
    +    command = make_command()
    +
    +    with patch.object(command, "_ensure_pbn_integration"):
    +        with patch.object(command, "get_client", return_value=MagicMock()):
    +            with patch(
    +                "pbn_import.management.commands.pbn_import.ImportManager"
    +            ) as manager_class:
    +                manager_class.return_value.run.side_effect = RuntimeError("boom")
    +
    +                with pytest.raises(RuntimeError, match="boom"):
    +                    command.handle(**command_options(username=user.username))
    +
    +    assert ImportSession.objects.filter(user=user).exists()
    +    assert "Import nieudany" in command.stderr._out.getvalue()
    +
    +
    +def test_run_interactive_cancel_on_step_selection():
    +    command = make_command()
    +
    +    with patch(
    +        "pbn_import.management.commands.pbn_import.questionary.checkbox"
    +    ) as checkbox:
    +        checkbox.return_value.ask.return_value = None
    +
    +        assert command.run_interactive(command_options(noinput=False)) is None
    +
    +    assert "Anulowano" in command.stdout._out.getvalue()
    +
    +
    +def test_run_interactive_applies_selected_steps_and_delete_choice():
    +    command = make_command()
    +    selected = ["initial", "publikacje"]
    +
    +    with patch(
    +        "pbn_import.management.commands.pbn_import.questionary.checkbox"
    +    ) as checkbox:
    +        with patch(
    +            "pbn_import.management.commands.pbn_import.questionary.confirm"
    +        ) as confirm:
    +            checkbox.return_value.ask.return_value = selected
    +            confirm.return_value.ask.side_effect = [True, True]
    +
    +            options = command.run_interactive(command_options(noinput=False))
    +
    +    assert options["delete_existing"] is True
    +    assert options["disable_initial"] is False
    +    assert options["disable_publikacje"] is False
    +    assert options["disable_zrodla"] is True
    diff --git a/src/pbn_import/tests/test_consumers.py b/src/pbn_import/tests/test_consumers.py
    new file mode 100644
    index 000000000..86ada9eb2
    --- /dev/null
    +++ b/src/pbn_import/tests/test_consumers.py
    @@ -0,0 +1,197 @@
    +"""Tests for PBN import WebSocket consumer payloads."""
    +
    +import datetime
    +import json
    +from unittest.mock import AsyncMock
    +
    +import pytest
    +from asgiref.sync import async_to_sync
    +from django.utils import timezone
    +from model_bakery import baker
    +
    +from pbn_import.consumers import ImportProgressConsumer
    +from pbn_import.models import ImportLog, ImportSession
    +
    +
    +def make_consumer(session_id, user):
    +    consumer = ImportProgressConsumer()
    +    consumer.session_id = session_id
    +    consumer.room_group_name = f"import_{session_id}"
    +    consumer.scope = {
    +        "url_route": {"kwargs": {"session_id": session_id}},
    +        "user": user,
    +    }
    +    consumer.channel_name = "test-channel"
    +    consumer.channel_layer = AsyncMock()
    +    consumer.accept = AsyncMock()
    +    consumer.close = AsyncMock()
    +    consumer.send = AsyncMock()
    +    return consumer
    +
    +
    +@pytest.mark.django_db(transaction=True)
    +def test_connect_accepts_owner_and_sends_initial_status(django_user_model):
    +    user = baker.make(django_user_model)
    +    session = baker.make(
    +        ImportSession,
    +        user=user,
    +        status="running",
    +        current_step="source_import",
    +        current_step_progress=25,
    +        completed_steps=1,
    +        total_steps=4,
    +    )
    +    consumer = make_consumer(session.pk, user)
    +
    +    async_to_sync(consumer.connect)()
    +
    +    consumer.channel_layer.group_add.assert_awaited_once_with(
    +        f"import_{session.pk}", "test-channel"
    +    )
    +    consumer.accept.assert_awaited_once_with()
    +    sent_payloads = [
    +        json.loads(call.kwargs["text_data"]) for call in consumer.send.await_args_list
    +    ]
    +    assert sent_payloads[0]["type"] == "connection"
    +    assert sent_payloads[1]["type"] == "status_update"
    +    assert sent_payloads[1]["status"]["status"] == "running"
    +    assert sent_payloads[1]["status"]["current_step"] == "source_import"
    +    assert sent_payloads[1]["status"]["progress"] == session.overall_progress
    +    assert sent_payloads[1]["status"]["completed_steps"] == 1
    +    assert sent_payloads[1]["status"]["total_steps"] == 4
    +    assert sent_payloads[1]["status"]["duration"]
    +
    +
    +@pytest.mark.django_db(transaction=True)
    +def test_connect_closes_for_unauthenticated_user(django_user_model):
    +    owner = baker.make(django_user_model)
    +    session = baker.make(ImportSession, user=owner)
    +    anonymous = type("Anonymous", (), {"is_authenticated": False})()
    +    consumer = make_consumer(session.pk, anonymous)
    +
    +    async_to_sync(consumer.connect)()
    +
    +    consumer.close.assert_awaited_once_with()
    +    consumer.accept.assert_not_awaited()
    +
    +
    +@pytest.mark.django_db(transaction=True)
    +def test_staff_user_can_view_foreign_session(django_user_model):
    +    owner = baker.make(django_user_model)
    +    staff = baker.make(django_user_model, is_staff=True)
    +    session = baker.make(ImportSession, user=owner)
    +    consumer = make_consumer(session.pk, staff)
    +
    +    assert async_to_sync(consumer.has_permission)() is True
    +
    +
    +@pytest.mark.django_db(transaction=True)
    +def test_has_permission_false_for_missing_session(django_user_model):
    +    user = baker.make(django_user_model)
    +    consumer = make_consumer(999_999, user)
    +
    +    assert async_to_sync(consumer.has_permission)() is False
    +
    +
    +@pytest.mark.django_db(transaction=True)
    +def test_get_recent_logs_returns_oldest_first(django_user_model):
    +    user = baker.make(django_user_model)
    +    session = baker.make(ImportSession, user=user)
    +    older = baker.make(ImportLog, session=session, level="info", message="older")
    +    newer = baker.make(ImportLog, session=session, level="warning", message="newer")
    +    now = timezone.now()
    +    ImportLog.objects.filter(pk=older.pk).update(
    +        timestamp=now - datetime.timedelta(minutes=1)
    +    )
    +    ImportLog.objects.filter(pk=newer.pk).update(timestamp=now)
    +    consumer = make_consumer(session.pk, user)
    +
    +    logs = async_to_sync(consumer.get_recent_logs)(limit=2)
    +
    +    assert [log["message"] for log in logs] == [older.message, newer.message]
    +    assert logs[0]["level"] == "info"
    +    assert "timestamp" in logs[0]
    +
    +
    +def test_receive_ping_status_and_logs_requests():
    +    consumer = make_consumer(1, user=object())
    +    consumer.send_current_status = AsyncMock()
    +    consumer.send_recent_logs = AsyncMock()
    +
    +    async_to_sync(consumer.receive)(
    +        text_data=json.dumps({"type": "ping", "timestamp": "t1"})
    +    )
    +    async_to_sync(consumer.receive)(text_data=json.dumps({"type": "request_status"}))
    +    async_to_sync(consumer.receive)(text_data=json.dumps({"type": "request_logs"}))
    +
    +    assert json.loads(consumer.send.await_args.kwargs["text_data"]) == {
    +        "type": "pong",
    +        "timestamp": "t1",
    +    }
    +    consumer.send_current_status.assert_awaited_once_with()
    +    consumer.send_recent_logs.assert_awaited_once_with()
    +
    +
    +def test_event_handlers_serialize_payloads():
    +    consumer = make_consumer(1, user=object())
    +
    +    async_to_sync(consumer.import_update)({"data": {"progress": 10}})
    +    async_to_sync(consumer.progress_update)(
    +        {"progress": 50, "current_step": "step", "message": "msg"}
    +    )
    +    async_to_sync(consumer.log_entry)(
    +        {
    +            "timestamp": "2026-06-05T10:00:00",
    +            "level": "info",
    +            "step": "source_import",
    +            "message": "done",
    +        }
    +    )
    +    async_to_sync(consumer.status_change)(
    +        {"old_status": "running", "new_status": "completed", "message": "ok"}
    +    )
    +    async_to_sync(consumer.statistics_update)({"statistics": {"x": 1}})
    +    async_to_sync(consumer.completion_notification)(
    +        {"success": True, "message": "completed"}
    +    )
    +
    +    payloads = [
    +        json.loads(call.kwargs["text_data"]) for call in consumer.send.await_args_list
    +    ]
    +    assert payloads == [
    +        {"type": "import_update", "data": {"progress": 10}},
    +        {
    +            "type": "progress_update",
    +            "progress": 50,
    +            "current_step": "step",
    +            "message": "msg",
    +        },
    +        {
    +            "type": "log_entry",
    +            "log": {
    +                "timestamp": "2026-06-05T10:00:00",
    +                "level": "info",
    +                "step": "source_import",
    +                "message": "done",
    +            },
    +        },
    +        {
    +            "type": "status_change",
    +            "old_status": "running",
    +            "new_status": "completed",
    +            "message": "ok",
    +        },
    +        {"type": "statistics_update", "statistics": {"x": 1}},
    +        {"type": "completion", "success": True, "message": "completed"},
    +    ]
    +
    +
    +def test_disconnect_removes_channel_from_group():
    +    consumer = make_consumer(123, user=object())
    +    consumer.room_group_name = "import_123"
    +
    +    async_to_sync(consumer.disconnect)(1000)
    +
    +    consumer.channel_layer.group_discard.assert_awaited_once_with(
    +        "import_123", "test-channel"
    +    )
    diff --git a/src/pbn_import/tests/test_error_handling.py b/src/pbn_import/tests/test_error_handling.py
    index 53e193b2c..2f62231a4 100644
    --- a/src/pbn_import/tests/test_error_handling.py
    +++ b/src/pbn_import/tests/test_error_handling.py
    @@ -1,12 +1,17 @@
     """Tests for error handling in PBN import"""
     
     import traceback
    -from unittest.mock import patch
    +from unittest.mock import MagicMock, call, patch
     
     import pytest
     
     from pbn_import.models import ImportLog, ImportSession
    -from pbn_import.utils.base import ImportStepBase
    +from pbn_import.utils.base import (
    +    CancelledException,
    +    ImportStepBase,
    +    TqdmSessionProgress,
    +    pbar_with_callback,
    +)
     
     
     @pytest.mark.django_db
    @@ -164,3 +169,130 @@ def run(self):
             log = ImportLog.objects.filter(session=session, level="error").first()
             assert log is not None
             assert "Regular error" in log.message
    +
    +
    +@pytest.mark.django_db
    +def test_tqdm_session_progress_updates_throttles_and_clears(django_user_model):
    +    user = django_user_model.objects.create_user(username="progress-user")
    +    session = ImportSession.objects.create(user=user)
    +    progress = TqdmSessionProgress(session, "batch")
    +
    +    with patch("pbn_import.utils.base.time.time", return_value=100.0):
    +        progress.update(1, 4, "first")
    +
    +    session.refresh_from_db()
    +    assert session.progress_data["current_subtask"] == {
    +        "name": "batch",
    +        "description": "first",
    +        "current": 1,
    +        "total": 4,
    +        "percentage": 25,
    +    }
    +
    +    with patch("pbn_import.utils.base.time.time", return_value=100.1):
    +        progress.update(2, 4, "throttled")
    +
    +    session.refresh_from_db()
    +    assert session.progress_data["current_subtask"]["description"] == "first"
    +
    +    with patch("pbn_import.utils.base.time.time", return_value=100.2):
    +        progress.update(4, 4, "done")
    +
    +    session.refresh_from_db()
    +    assert session.progress_data["current_subtask"]["description"] == "done"
    +    assert session.progress_data["current_subtask"]["percentage"] == 100
    +
    +    progress.clear()
    +    session.refresh_from_db()
    +    assert "current_subtask" not in session.progress_data
    +
    +
    +def test_pbar_with_callback_updates_and_clears_callback():
    +    callback = MagicMock()
    +
    +    class FakeTqdm:
    +        def __init__(self, iterator, **kwargs):
    +            self.iterator = iterator
    +
    +        def __enter__(self):
    +            return iter(self.iterator)
    +
    +        def __exit__(self, exc_type, exc, tb):
    +            return False
    +
    +    with patch("tqdm.tqdm", side_effect=lambda iterator, **kwargs: FakeTqdm(iterator)):
    +        assert list(pbar_with_callback(["a", "b"], 2, "desc", callback)) == [
    +            "a",
    +            "b",
    +        ]
    +
    +    assert callback.update.call_args_list == [
    +        call(1, 2, "desc"),
    +        call(2, 2, "desc"),
    +    ]
    +    callback.clear.assert_called_once_with()
    +
    +
    +def test_pbar_with_callback_raises_when_cancelled():
    +    class FakeTqdm:
    +        def __init__(self, iterator, **kwargs):
    +            self.iterator = iterator
    +
    +        def __enter__(self):
    +            return iter(self.iterator)
    +
    +        def __exit__(self, exc_type, exc, tb):
    +            return False
    +
    +    with patch("tqdm.tqdm", side_effect=lambda iterator, **kwargs: FakeTqdm(iterator)):
    +        with pytest.raises(CancelledException, match="Import został anulowany"):
    +            list(
    +                pbar_with_callback(
    +                    range(20),
    +                    20,
    +                    "desc",
    +                    check_cancel_func=lambda: True,
    +                )
    +            )
    +
    +
    +@pytest.mark.django_db
    +def test_import_step_base_call_runs_start_finish_and_returns(django_user_model):
    +    user = django_user_model.objects.create_user(username="call-user")
    +    session = ImportSession.objects.create(user=user)
    +
    +    class SuccessfulStep(ImportStepBase):
    +        step_name = "successful_step"
    +        step_description = "Successful step"
    +
    +        def run(self):
    +            return {"ok": True}
    +
    +    result = SuccessfulStep(session).__call__()
    +
    +    session.refresh_from_db()
    +    assert result == {"ok": True}
    +    assert session.current_step == "successful_step"
    +    assert ImportLog.objects.filter(session=session, level="success").exists()
    +
    +
    +@pytest.mark.django_db
    +def test_import_step_base_call_logs_and_reraises_run_error(django_user_model):
    +    user = django_user_model.objects.create_user(username="fail-user")
    +    session = ImportSession.objects.create(user=user)
    +
    +    class FailingStep(ImportStepBase):
    +        step_name = "failing_step"
    +
    +        def run(self):
    +            raise RuntimeError("run failed")
    +
    +    with patch("pbn_import.utils.base.rollbar.report_exc_info"):
    +        with pytest.raises(RuntimeError, match="run failed"):
    +            FailingStep(session).__call__()
    +
    +    assert ImportLog.objects.filter(
    +        session=session,
    +        level="error",
    +        message__contains="Krytyczny błąd w failing_step",
    +    ).exists()
    diff --git a/src/pbn_import/tests/test_fix_commands.py b/src/pbn_import/tests/test_fix_commands.py
    new file mode 100644
    index 000000000..a8bd5174d
    --- /dev/null
    +++ b/src/pbn_import/tests/test_fix_commands.py
    @@ -0,0 +1,761 @@
    +"""Focused unit tests for PBN import repair management commands."""
    +
    +from io import StringIO
    +from types import SimpleNamespace
    +from unittest.mock import ANY, MagicMock, patch
    +
    +import pytest
    +from django.core.management.base import CommandError, OutputWrapper
    +
    +from pbn_import.management.commands.fix_import_dat_oswiadczen_pbn import (
    +    Command as StatementDateCommand,
    +)
    +from pbn_import.management.commands.fix_missing_imported_pubs import (
    +    Command as MissingPublicationCommand,
    +)
    +from pbn_import.management.commands.fix_pbn_import_oswiadczen_ksiazki import (
    +    Command as BookStatementTypeCommand,
    +)
    +
    +
    +def make_command(command_cls):
    +    command = command_cls()
    +    command.stdout = OutputWrapper(StringIO())
    +    command.stderr = OutputWrapper(StringIO())
    +    return command
    +
    +
    +@pytest.mark.parametrize(
    +    "options,expected",
    +    [
    +        ({"rok": None, "rok_min": None, "rok_max": None}, None),
    +        ({"rok": 2024, "rok_min": None, "rok_max": None}, (2024, 2024)),
    +        ({"rok": None, "rok_min": 2022, "rok_max": 2025}, (2022, 2025)),
    +    ],
    +)
    +def test_statement_date_year_range(options, expected):
    +    command = make_command(StatementDateCommand)
    +
    +    command._validate_year_parameters(options)
    +
    +    assert command._get_year_range(options) == expected
    +
    +
    +@pytest.mark.parametrize(
    +    "options,message",
    +    [
    +        (
    +            {"rok": 2024, "rok_min": 2022, "rok_max": None},
    +            "Nie można używać --rok",
    +        ),
    +        (
    +            {"rok": None, "rok_min": 2022, "rok_max": None},
    +            "muszą być używane razem",
    +        ),
    +        (
    +            {"rok": None, "rok_min": 2025, "rok_max": 2022},
    +            "nie może być większy",
    +        ),
    +    ],
    +)
    +def test_statement_date_rejects_invalid_year_parameters(options, message):
    +    command = make_command(StatementDateCommand)
    +
    +    with pytest.raises(CommandError, match=message):
    +        command._validate_year_parameters(options)
    +
    +
    +def test_statement_date_should_skip_by_year():
    +    command = make_command(StatementDateCommand)
    +    author_link = SimpleNamespace(rekord=SimpleNamespace(rok=2024))
    +
    +    assert command._should_skip_by_year(author_link, None) is False
    +    assert command._should_skip_by_year(author_link, (2020, 2023)) is True
    +    assert command._should_skip_by_year(author_link, (2024, 2024)) is False
    +
    +
    +def test_statement_date_parser_accepts_repair_options():
    +    parser = make_command(StatementDateCommand).create_parser(
    +        "manage.py",
    +        "fix_import_dat_oswiadczen_pbn",
    +    )
    +
    +    options = parser.parse_args(["--dry-run", "--nadpisz", "--rok", "2024"])
    +
    +    assert options.dry_run is True
    +    assert options.nadpisz is True
    +    assert options.rok == 2024
    +
    +
    +def test_statement_date_print_config_info_for_dry_run_and_year_range():
    +    command = make_command(StatementDateCommand)
    +
    +    command._print_config_info(dry_run=True, nadpisz=True, year_range=(2022, 2025))
    +
    +    output = command.stdout._out.getvalue()
    +    assert "TRYB TESTOWY" in output
    +    assert "Lata do przetworzenia: 2022-2025" in output
    +    assert "Tryb nadpisywania" in output
    +
    +
    +def test_statement_date_print_config_info_for_all_years_without_overwrite():
    +    command = make_command(StatementDateCommand)
    +
    +    command._print_config_info(dry_run=False, nadpisz=False, year_range=None)
    +
    +    output = command.stdout._out.getvalue()
    +    assert "Przetwarzanie wszystkich lat" in output
    +    assert "Aktualizacja tylko rekordów z pustą datą" in output
    +
    +
    +def test_statement_date_summary_reports_missing_and_skipped_counts():
    +    command = make_command(StatementDateCommand)
    +
    +    command._print_summary(
    +        updated_count=2,
    +        skipped_year_filter=3,
    +        skipped_existing_date=4,
    +        missing_publication=[("pub-1", "Title")],
    +        missing_autor=[("person-1", "Jan Kowalski")],
    +        missing_link=[("Book title", "Jan Kowalski", 2024, "pub-2")],
    +    )
    +
    +    output = command.stdout._out.getvalue()
    +    assert "Zaktualizowano: 2" in output
    +    assert "Pominięto (brak powiązania): 3" in output
    +    assert "Pominięto (filtr roku): 3" in output
    +    assert "Pominięto (istniejąca data): 4" in output
    +
    +
    +def test_statement_date_missing_details_truncates_long_lists():
    +    command = make_command(StatementDateCommand)
    +
    +    command._print_missing_details(
    +        list(range(16)),
    +        "Braki",
    +        lambda item: f"item-{item}",
    +    )
    +
    +    output = command.stdout._out.getvalue()
    +    assert "Braki (16)" in output
    +    assert "item-0" in output
    +    assert "... i 1 więcej" in output
    +
    +
    +def test_missing_publication_prepare_list_applies_limit():
    +    command = make_command(MissingPublicationCommand)
    +
    +    class FakeQuerySet:
    +        def __init__(self, items):
    +            self.items = items
    +
    +        def count(self):
    +            return len(self.items)
    +
    +        def __iter__(self):
    +            return iter(self.items)
    +
    +    with patch.object(
    +        command,
    +        "get_missing_publications",
    +        return_value=FakeQuerySet([1, 2, 3]),
    +    ):
    +        missing_list, missing_count = command._prepare_missing_list({"limit": 2})
    +
    +    assert missing_list == [1, 2]
    +    assert missing_count == 3
    +
    +
    +def test_missing_publication_prepare_list_accepts_list_from_type_filter():
    +    command = make_command(MissingPublicationCommand)
    +
    +    with patch.object(command, "get_missing_publications", return_value=[1, 2, 3]):
    +        missing_list, missing_count = command._prepare_missing_list({"limit": None})
    +
    +    assert missing_list == [1, 2, 3]
    +    assert missing_count == 3
    +
    +
    +def test_missing_publication_get_missing_publications_filters_by_uid():
    +    command = make_command(MissingPublicationCommand)
    +    rekord_qs = MagicMock()
    +    rekord_qs.exclude.return_value = rekord_qs
    +    rekord_qs.values_list.return_value = ["existing-pub"]
    +    missing_qs = MagicMock()
    +
    +    with patch(
    +        "pbn_import.management.commands.fix_missing_imported_pubs.Rekord.objects"
    +    ) as rekordy:
    +        with patch(
    +            "pbn_import.management.commands.fix_missing_imported_pubs."
    +            "Publication.objects"
    +        ) as publications:
    +            rekordy.exclude.return_value = rekord_qs
    +            publications.exclude.return_value = missing_qs
    +            missing_qs.filter.return_value = "filtered"
    +
    +            result = command.get_missing_publications(
    +                {"pbn_uid": "target-pub", "type": None}
    +            )
    +
    +    assert result == "filtered"
    +    publications.exclude.assert_called_once_with(pk__in={"existing-pub"})
    +    missing_qs.filter.assert_called_once_with(pk="target-pub")
    +
    +
    +def test_missing_publication_get_missing_publications_filters_by_type():
    +    command = make_command(MissingPublicationCommand)
    +    rekord_qs = MagicMock()
    +    rekord_qs.exclude.return_value = rekord_qs
    +    rekord_qs.values_list.return_value = []
    +    article = SimpleNamespace(current_version={"object": {"type": "ARTICLE"}})
    +    book = SimpleNamespace(current_version={"object": {"type": "BOOK"}})
    +    without_version = SimpleNamespace(current_version=None)
    +
    +    with patch(
    +        "pbn_import.management.commands.fix_missing_imported_pubs.Rekord.objects"
    +    ) as rekordy:
    +        with patch(
    +            "pbn_import.management.commands.fix_missing_imported_pubs."
    +            "Publication.objects"
    +        ) as publications:
    +            rekordy.exclude.return_value = rekord_qs
    +            publications.exclude.return_value = [article, book, without_version]
    +
    +            result = command.get_missing_publications({"pbn_uid": None, "type": "BOOK"})
    +
    +    assert result == [book]
    +
    +
    +def test_missing_publication_parser_accepts_repair_options():
    +    parser = make_command(MissingPublicationCommand).create_parser(
    +        "manage.py",
    +        "fix_missing_imported_pubs",
    +    )
    +
    +    options = parser.parse_args(
    +        [
    +            "--dry-run",
    +            "--pbn-uid",
    +            "pub-1",
    +            "--type",
    +            "BOOK",
    +            "--limit",
    +            "5",
    +            "--max-errors",
    +            "2",
    +            "--verbose",
    +        ]
    +    )
    +
    +    assert options.dry_run is True
    +    assert options.pbn_uid == "pub-1"
    +    assert options.type == "BOOK"
    +    assert options.limit == 5
    +    assert options.max_errors == 2
    +    assert options.verbose is True
    +
    +
    +def test_missing_publication_process_single_publication_imported():
    +    command = make_command(MissingPublicationCommand)
    +    publication = SimpleNamespace(
    +        pk="pub-1",
    +        mongoId="mongo-1",
    +        year=2024,
    +        current_version={"object": {"title": "Publication title"}},
    +    )
    +
    +    with patch(
    +        "pbn_import.management.commands.fix_missing_imported_pubs."
    +        "import_publication_with_statements",
    +        return_value=("bpp-record", None, (2, 0)),
    +    ) as import_publication:
    +        status, data = command._process_single_publication(
    +            publication,
    +            client="client",
    +            default_jednostka="unit",
    +            rodzaj_periodyk="periodic",
    +            dyscypliny_cache={"discipline": "object"},
    +            verbose=False,
    +        )
    +
    +    import_publication.assert_called_once_with(
    +        "mongo-1",
    +        "client",
    +        "unit",
    +        force=False,
    +        with_statements=True,
    +        rodzaj_periodyk="periodic",
    +        dyscypliny_cache={"discipline": "object"},
    +    )
    +    assert status == "imported"
    +    assert data == 2
    +
    +
    +def test_missing_publication_process_single_publication_error():
    +    command = make_command(MissingPublicationCommand)
    +    publication = SimpleNamespace(
    +        pk="pub-1",
    +        mongoId="mongo-1",
    +        year=2024,
    +        current_version={"object": {"title": "Publication title"}},
    +    )
    +
    +    with patch(
    +        "pbn_import.management.commands.fix_missing_imported_pubs."
    +        "import_publication_with_statements",
    +        return_value=(None, {"message": "cannot import", "traceback": "tb"}, None),
    +    ):
    +        status, data = command._process_single_publication(
    +            publication,
    +            client="client",
    +            default_jednostka="unit",
    +            rodzaj_periodyk=None,
    +            dyscypliny_cache={},
    +            verbose=True,
    +        )
    +
    +    assert status == "error"
    +    assert data == {
    +        "pbn_uid": "pub-1",
    +        "title": "Publication title",
    +        "year": 2024,
    +        "message": "cannot import",
    +        "traceback": "tb",
    +    }
    +    assert "Błąd dla pub-1" in command.stderr._out.getvalue()
    +
    +
    +def test_missing_publication_process_single_publication_skipped():
    +    command = make_command(MissingPublicationCommand)
    +    publication = SimpleNamespace(
    +        pk="pub-1",
    +        mongoId="mongo-1",
    +        year=2024,
    +        current_version=None,
    +    )
    +
    +    with patch(
    +        "pbn_import.management.commands.fix_missing_imported_pubs."
    +        "import_publication_with_statements",
    +        return_value=(None, None, None),
    +    ):
    +        status, data = command._process_single_publication(
    +            publication,
    +            client="client",
    +            default_jednostka="unit",
    +            rodzaj_periodyk=None,
    +            dyscypliny_cache={},
    +            verbose=True,
    +        )
    +
    +    assert status == "skipped"
    +    assert data is None
    +    assert "Pominięto: pub-1" in command.stdout._out.getvalue()
    +
    +
    +def test_missing_publication_dry_run_summary_reports_all_sections():
    +    command = make_command(MissingPublicationCommand)
    +
    +    command._display_dry_run_summary(
    +        imported=2,
    +        skipped=3,
    +        errors=[{"pbn_uid": "pub-1"}],
    +        statements_total=4,
    +    )
    +
    +    output = command.stdout._out.getvalue()
    +    assert "TRYB DRY-RUN" in output
    +    assert "Zaimportowano by: 2" in output
    +    assert "Oświadczeń by: 4" in output
    +    assert "Pominięto by: 3" in output
    +    assert "Błędów: 1" in output
    +
    +
    +def test_missing_publication_display_summary_reports_errors_and_traceback():
    +    command = make_command(MissingPublicationCommand)
    +
    +    command._display_summary(
    +        imported=1,
    +        skipped=2,
    +        errors=[
    +            {
    +                "pbn_uid": "pub-1",
    +                "title": "Very long title " * 10,
    +                "year": 2024,
    +                "message": "cannot import",
    +                "traceback": "traceback text",
    +            }
    +        ],
    +        statements_total=3,
    +        stopped_early=True,
    +    )
    +
    +    output = command.stdout._out.getvalue()
    +    assert "Zaimportowano: 1" in output
    +    assert "Oświadczeń: 3" in output
    +    assert "Pominięto: 2" in output
    +    assert "Import przerwany" in output
    +    assert "PBN UID: pub-1" in output
    +    assert "traceback text" in output
    +
    +
    +def test_missing_publication_handle_inner_returns_when_no_missing():
    +    command = make_command(MissingPublicationCommand)
    +
    +    with patch.object(command, "_prepare_missing_list", return_value=([], 0)):
    +        command._handle_inner({"limit": None}, dry_run=False)
    +
    +    assert "Wszystkie publikacje PBN" in command.stdout._out.getvalue()
    +
    +
    +def test_missing_publication_handle_inner_imports_and_displays_summary():
    +    command = make_command(MissingPublicationCommand)
    +    default_jednostka = SimpleNamespace(__str__=lambda self: "Default unit")
    +
    +    with patch.object(command, "_prepare_missing_list", return_value=(["pub"], 1)):
    +        with patch(
    +            "pbn_import.management.commands.fix_missing_imported_pubs."
    +            "get_validated_default_jednostka",
    +            return_value=default_jednostka,
    +        ):
    +            with patch.object(command, "get_client", return_value="client"):
    +                with patch.object(
    +                    command,
    +                    "_import_publications",
    +                    return_value=(1, 0, [], 3, False),
    +                ) as import_publications:
    +                    with patch.object(command, "_display_summary") as display_summary:
    +                        command._handle_inner(
    +                            {
    +                                "limit": None,
    +                                "jednostka": None,
    +                                "app_id": "app-id",
    +                                "app_token": "app-token",
    +                                "base_url": "https://pbn.example.test",
    +                                "user_token": "user-token",
    +                                "verbose": False,
    +                            },
    +                            dry_run=False,
    +                        )
    +
    +    import_publications.assert_called_once_with(
    +        ["pub"], "client", default_jednostka, ANY
    +    )
    +    display_summary.assert_called_once_with(1, 0, [], 3, False)
    +
    +
    +def test_book_statement_type_expected_type():
    +    command = make_command(BookStatementTypeCommand)
    +    typ_autor = SimpleNamespace(nazwa="autor")
    +    typ_redaktor = SimpleNamespace(nazwa="redaktor")
    +
    +    assert (
    +        command._get_expected_typ(SimpleNamespace(type="AUTHOR"), typ_autor, typ_redaktor)
    +        is typ_autor
    +    )
    +    assert (
    +        command._get_expected_typ(SimpleNamespace(type="EDITOR"), typ_autor, typ_redaktor)
    +        is typ_redaktor
    +    )
    +    with pytest.raises(ValueError, match="Nieznany typ"):
    +        command._get_expected_typ(
    +            SimpleNamespace(type="TRANSLATOR"),
    +            typ_autor,
    +            typ_redaktor,
    +        )
    +
    +
    +def test_book_statement_type_parser_accepts_repair_options():
    +    parser = make_command(BookStatementTypeCommand).create_parser(
    +        "manage.py",
    +        "fix_pbn_import_oswiadczen_ksiazki",
    +    )
    +
    +    options = parser.parse_args(
    +        [
    +            "--dry-run",
    +            "--verbose",
    +            "--publikacja",
    +            "pub-1",
    +            "--integruj-dyscypliny",
    +        ]
    +    )
    +
    +    assert options.dry_run is True
    +    assert options.verbose is True
    +    assert options.publikacja == "pub-1"
    +    assert options.integruj_dyscypliny is True
    +
    +
    +def test_book_statement_type_print_config_info():
    +    command = make_command(BookStatementTypeCommand)
    +
    +    command._print_config_info(
    +        {
    +            "dry_run": True,
    +            "publikacja": "pub-1",
    +            "integruj_dyscypliny": True,
    +        }
    +    )
    +
    +    output = command.stdout._out.getvalue()
    +    assert "TRYB TESTOWY" in output
    +    assert "Przetwarzanie publikacji: pub-1" in output
    +    assert "Integracja dyscyplin: TAK" in output
    +
    +
    +def test_book_statement_type_print_config_info_for_all_books():
    +    command = make_command(BookStatementTypeCommand)
    +
    +    command._print_config_info(
    +        {
    +            "dry_run": False,
    +            "publikacja": None,
    +            "integruj_dyscypliny": False,
    +        }
    +    )
    +
    +    assert "Przetwarzanie wszystkich książek z PBN" in command.stdout._out.getvalue()
    +
    +
    +def test_book_statement_type_print_summary_reports_errors():
    +    command = make_command(BookStatementTypeCommand)
    +
    +    command._print_summary(
    +        fixed_count=2,
    +        skipped_no_wa=3,
    +        skipped_matching=4,
    +        errors=[f"error-{index}" for index in range(12)],
    +    )
    +
    +    output = command.stdout._out.getvalue()
    +    assert "Naprawiono: 2" in output
    +    assert "brak powiązania autor-publikacja): 3" in output
    +    assert "typ już zgodny): 4" in output
    +    assert "Błędy: 12" in output
    +    assert "... i 2 więcej" in output
    +
    +
    +def test_book_statement_type_get_books_queryset_filters_existing_publication():
    +    command = make_command(BookStatementTypeCommand)
    +    queryset = MagicMock()
    +    queryset.filter.return_value.exists.return_value = True
    +
    +    with patch(
    +        "pbn_import.management.commands.fix_pbn_import_oswiadczen_ksiazki."
    +        "Wydawnictwo_Zwarte.objects"
    +    ) as objects:
    +        objects.exclude.return_value = queryset
    +
    +        assert command._get_books_queryset("pub-1") == queryset.filter.return_value
    +
    +    objects.exclude.assert_called_once_with(pbn_uid_id=None)
    +    queryset.filter.assert_called_once_with(pbn_uid_id="pub-1")
    +
    +
    +def test_book_statement_type_get_books_queryset_rejects_missing_publication():
    +    command = make_command(BookStatementTypeCommand)
    +    queryset = MagicMock()
    +    queryset.filter.return_value.exists.return_value = False
    +
    +    with patch(
    +        "pbn_import.management.commands.fix_pbn_import_oswiadczen_ksiazki."
    +        "Wydawnictwo_Zwarte.objects"
    +    ) as objects:
    +        objects.exclude.return_value = queryset
    +
    +        with pytest.raises(CommandError, match="Nie znaleziono publikacji"):
    +            command._get_books_queryset("missing")
    +
    +
    +def test_book_statement_type_update_counters():
    +    command = make_command(BookStatementTypeCommand)
    +    counters = {"fixed": 0, "no_wa": 0, "matching": 0, "to_integrate": []}
    +    statement = object()
    +
    +    command._update_counters("fixed", counters, statement, integruj_dyscypliny=True)
    +    command._update_counters("no_wa", counters, statement, integruj_dyscypliny=True)
    +    command._update_counters("matching", counters, statement, integruj_dyscypliny=True)
    +
    +    assert counters == {
    +        "fixed": 1,
    +        "no_wa": 1,
    +        "matching": 1,
    +        "to_integrate": [statement],
    +    }
    +
    +
    +def test_book_statement_type_find_author_record_branches():
    +    command = make_command(BookStatementTypeCommand)
    +
    +    class DoesNotExist(Exception):
    +        pass
    +
    +    class MultipleObjectsReturned(Exception):
    +        pass
    +
    +    manager_model = SimpleNamespace(
    +        DoesNotExist=DoesNotExist,
    +        MultipleObjectsReturned=MultipleObjectsReturned,
    +    )
    +    manager = MagicMock(model=manager_model)
    +    book = SimpleNamespace(
    +        autorzy_set=manager,
    +        tytul_oryginalny="Book title",
    +    )
    +    expected_typ = SimpleNamespace(nazwa="redaktor")
    +
    +    manager.get.return_value = "wa"
    +    assert command._find_autor_record(book, "autor", expected_typ, verbose=False) == (
    +        "wa",
    +        None,
    +    )
    +
    +    manager.reset_mock()
    +    manager.get.side_effect = DoesNotExist
    +    assert command._find_autor_record(book, "autor", expected_typ, verbose=True) == (
    +        None,
    +        "no_wa",
    +    )
    +
    +    manager.reset_mock()
    +    manager.get.side_effect = [MultipleObjectsReturned, "matching-wa"]
    +    assert command._find_autor_record(book, "autor", expected_typ, verbose=False) == (
    +        None,
    +        "matching",
    +    )
    +
    +    fallback_wa = object()
    +    manager.reset_mock()
    +    manager.get.side_effect = [MultipleObjectsReturned, DoesNotExist]
    +    manager.filter.return_value.first.return_value = fallback_wa
    +    assert command._find_autor_record(book, "autor", expected_typ, verbose=False) == (
    +        fallback_wa,
    +        None,
    +    )
    +
    +
    +def test_book_statement_type_process_single_statement_updates_mismatched_type():
    +    command = make_command(BookStatementTypeCommand)
    +    typ_autor = SimpleNamespace(nazwa="autor")
    +    typ_redaktor = SimpleNamespace(nazwa="redaktor")
    +    wa = SimpleNamespace(
    +        typ_odpowiedzialnosci=typ_autor,
    +        save=MagicMock(),
    +    )
    +    book = SimpleNamespace(tytul_oryginalny="Book title")
    +    statement = SimpleNamespace(
    +        type="EDITOR",
    +        get_bpp_autor=MagicMock(return_value="BPP author"),
    +    )
    +
    +    with patch.object(command, "_find_autor_record", return_value=(wa, None)):
    +        result = command._process_single_statement(
    +            book,
    +            statement,
    +            typ_autor,
    +            typ_redaktor,
    +            dry_run=False,
    +            verbose=True,
    +        )
    +
    +    assert result == "fixed"
    +    assert wa.typ_odpowiedzialnosci is typ_redaktor
    +    wa.save.assert_called_once_with(update_fields=["typ_odpowiedzialnosci"])
    +
    +
    +def test_book_statement_type_process_single_statement_dry_run_does_not_save():
    +    command = make_command(BookStatementTypeCommand)
    +    typ_autor = SimpleNamespace(nazwa="autor")
    +    typ_redaktor = SimpleNamespace(nazwa="redaktor")
    +    wa = SimpleNamespace(
    +        typ_odpowiedzialnosci=typ_autor,
    +        save=MagicMock(),
    +    )
    +    book = SimpleNamespace(tytul_oryginalny="Book title")
    +    statement = SimpleNamespace(
    +        type="EDITOR",
    +        get_bpp_autor=MagicMock(return_value="BPP author"),
    +    )
    +
    +    with patch.object(command, "_find_autor_record", return_value=(wa, None)):
    +        result = command._process_single_statement(
    +            book,
    +            statement,
    +            typ_autor,
    +            typ_redaktor,
    +            dry_run=True,
    +            verbose=False,
    +        )
    +
    +    assert result == "fixed"
    +    assert wa.typ_odpowiedzialnosci is typ_autor
    +    wa.save.assert_not_called()
    +
    +
    +def test_book_statement_type_process_single_statement_matching_and_missing_author():
    +    command = make_command(BookStatementTypeCommand)
    +    typ_autor = SimpleNamespace(nazwa="autor")
    +    typ_redaktor = SimpleNamespace(nazwa="redaktor")
    +    book = SimpleNamespace(tytul_oryginalny="Book title")
    +
    +    missing_author = SimpleNamespace(
    +        type="AUTHOR",
    +        personId="person-1",
    +        get_bpp_autor=MagicMock(return_value=None),
    +    )
    +    assert (
    +        command._process_single_statement(
    +            book,
    +            missing_author,
    +            typ_autor,
    +            typ_redaktor,
    +            dry_run=False,
    +            verbose=True,
    +        )
    +        == "no_wa"
    +    )
    +
    +    matching_wa = SimpleNamespace(typ_odpowiedzialnosci=typ_autor)
    +    matching_statement = SimpleNamespace(
    +        type="AUTHOR",
    +        get_bpp_autor=MagicMock(return_value="BPP author"),
    +    )
    +    with patch.object(command, "_find_autor_record", return_value=(matching_wa, None)):
    +        assert (
    +            command._process_single_statement(
    +                book,
    +                matching_statement,
    +                typ_autor,
    +                typ_redaktor,
    +                dry_run=False,
    +                verbose=False,
    +            )
    +            == "matching"
    +        )
    +
    +
    +def test_book_statement_type_integrate_disciplines_reports_verbose_errors():
    +    command = make_command(BookStatementTypeCommand)
    +    statement = SimpleNamespace(publicationId="pub-1")
    +
    +    with patch(
    +        "pbn_integrator.utils.statements."
    +        "integruj_oswiadczenia_z_instytucji_pojedyncza_praca",
    +        side_effect=RuntimeError("cannot integrate"),
    +    ) as integrate:
    +        with patch("bpp.models.Uczelnia.objects") as uczelnie:
    +            uczelnie.get.return_value = SimpleNamespace(domyslna_jednostka="unit")
    +
    +            command._integrate_disciplines([statement], verbose=True)
    +
    +    integrate.assert_called_once_with(
    +        statement,
    +        set(),
    +        set(),
    +        default_jednostka="unit",
    +    )
    +    output = command.stdout._out.getvalue()
    +    assert "Błąd integracji: pub-1" in output
    +    assert "Zintegrowano dyscypliny dla 1 rekordów" in output
    diff --git a/src/pbn_import/tests/test_importer_wrappers.py b/src/pbn_import/tests/test_importer_wrappers.py
    new file mode 100644
    index 000000000..7adc4a3fa
    --- /dev/null
    +++ b/src/pbn_import/tests/test_importer_wrappers.py
    @@ -0,0 +1,562 @@
    +"""Focused tests for small PBN import step wrappers."""
    +
    +from types import SimpleNamespace
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +from model_bakery import baker
    +
    +from bpp.models import Jednostka, Uczelnia
    +from pbn_api.models import Institution
    +from pbn_import.models import ImportInconsistency, ImportLog, ImportSession
    +from pbn_import.utils.author_import import AuthorImporter
    +from pbn_import.utils.conference_import import ConferenceImporter
    +from pbn_import.utils.fee_import import FeeImporter
    +from pbn_import.utils.publisher_import import PublisherImporter
    +from pbn_import.utils.source_import import SourceImporter
    +from pbn_import.utils.statement_import import StatementImporter
    +
    +
    +@pytest.fixture
    +def session(db, django_user_model):
    +    user = baker.make(django_user_model)
    +    return baker.make(ImportSession, user=user, config={})
    +
    +
    +@pytest.fixture
    +def uczelnia(db):
    +    return baker.make(Uczelnia, pbn_uid=baker.make(Institution))
    +
    +
    +def test_author_importer_skips_without_uczelnia_uid(session):
    +    result = AuthorImporter(session, client=MagicMock(), uczelnia=None).run()
    +
    +    assert result == {"authors_imported": False, "reason": "No Uczelnia PBN UID"}
    +    assert ImportLog.objects.filter(session=session, level="warning").exists()
    +
    +
    +def test_author_importer_downloads_and_integrates_authors(session, uczelnia):
    +    importer = AuthorImporter(session, client=MagicMock(), uczelnia=uczelnia)
    +    callback = MagicMock()
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=callback):
    +        with patch.object(importer, "clear_subtask_progress") as clear:
    +            with patch("pbn_import.utils.author_import.pobierz_ludzi_z_uczelni") as dl:
    +                with patch(
    +                    "pbn_import.utils.author_import.integruj_autorow_z_uczelni"
    +                ) as integrate:
    +                    result = importer.run()
    +
    +    dl.assert_called_once_with(
    +        importer.client, uczelnia.pbn_uid_id, callback=callback
    +    )
    +    integrate.assert_called_once_with(
    +        importer.client,
    +        uczelnia.pbn_uid_id,
    +        import_unexistent=True,
    +        callback=callback,
    +    )
    +    assert clear.call_count == 2
    +    assert result == {
    +        "authors_imported": True,
    +        "uczelnia_pbn_uid": uczelnia.pbn_uid_id,
    +        "error_count": 0,
    +    }
    +
    +
    +def test_author_importer_records_download_and_integration_errors(session, uczelnia):
    +    importer = AuthorImporter(session, client=MagicMock(), uczelnia=uczelnia)
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +        with patch(
    +            "pbn_import.utils.author_import.pobierz_ludzi_z_uczelni",
    +            side_effect=RuntimeError("download failed"),
    +        ):
    +            with patch(
    +                "pbn_import.utils.author_import.integruj_autorow_z_uczelni",
    +                side_effect=RuntimeError("integration failed"),
    +            ):
    +                with patch(
    +                    "pbn_import.utils.base.rollbar.report_exc_info"
    +                ) as report_exc_info:
    +                    result = importer.run()
    +
    +    assert report_exc_info.call_count == 2
    +    assert result["error_count"] == 2
    +    assert importer.errors == [
    +        "Nie udało się pobrać autorów: download failed",
    +        "Nie udało się zintegrować autorów: integration failed",
    +    ]
    +    assert list(
    +        ImportLog.objects.filter(session=session, level="error")
    +        .order_by("pk")
    +        .values_list("message", flat=True)
    +    ) == importer.errors
    +
    +
    +def test_source_importer_success(session):
    +    importer = SourceImporter(session, client=MagicMock())
    +    callback = MagicMock()
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=callback):
    +        with patch("pbn_import.utils.source_import.pobierz_zrodla_mnisw") as download:
    +            with patch(
    +                "pbn_import.utils.source_import.importer.importuj_zrodla",
    +                return_value=12,
    +            ) as import_sources:
    +                result = importer.run()
    +
    +    download.assert_called_once_with(importer.client, callback=callback)
    +    import_sources.assert_called_once_with()
    +    assert result == {"sources_imported": True, "error_count": 0}
    +
    +
    +def test_source_importer_reraises_database_import_error(session):
    +    importer = SourceImporter(session, client=MagicMock())
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +        with patch("pbn_import.utils.source_import.pobierz_zrodla_mnisw"):
    +            with patch(
    +                "pbn_import.utils.source_import.importer.importuj_zrodla",
    +                side_effect=RuntimeError("import failed"),
    +            ):
    +                with patch.object(importer, "handle_error") as handle_error:
    +                    with pytest.raises(RuntimeError, match="import failed"):
    +                        importer.run()
    +
    +    handle_error.assert_called_once()
    +
    +
    +def test_source_importer_continues_after_non_auth_download_error(session):
    +    importer = SourceImporter(session, client=MagicMock())
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +        with patch(
    +            "pbn_import.utils.source_import.pobierz_zrodla_mnisw",
    +            side_effect=RuntimeError("download failed"),
    +        ):
    +            with patch.object(importer, "handle_pbn_error") as handle_pbn_error:
    +                with patch(
    +                    "pbn_import.utils.source_import.importer.importuj_zrodla"
    +                ):
    +                    result = importer.run()
    +
    +    handle_pbn_error.assert_called_once()
    +    assert result["sources_imported"] is True
    +
    +
    +def test_publisher_importer_success_and_clears_progress(session):
    +    importer = PublisherImporter(session, client=MagicMock())
    +    callback = MagicMock()
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=callback):
    +        with patch.object(importer, "clear_subtask_progress") as clear:
    +            with patch("pbn_import.utils.publisher_import.pobierz_wydawcow_mnisw") as dl:
    +                with patch(
    +                    "pbn_import.utils.publisher_import.importer.importuj_wydawcow",
    +                    return_value=5,
    +                ) as import_publishers:
    +                    result = importer.run()
    +
    +    dl.assert_called_once_with(importer.client)
    +    import_publishers.assert_called_once_with(callback=callback)
    +    clear.assert_called_once_with()
    +    assert result == {"publishers_imported": True, "error_count": 0}
    +
    +
    +def test_publisher_importer_records_both_download_and_import_errors(session):
    +    importer = PublisherImporter(session, client=MagicMock())
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +        with patch(
    +            "pbn_import.utils.publisher_import.pobierz_wydawcow_mnisw",
    +            side_effect=RuntimeError("download failed"),
    +        ):
    +            with patch(
    +                "pbn_import.utils.publisher_import.importer.importuj_wydawcow",
    +                side_effect=RuntimeError("import failed"),
    +            ):
    +                with patch.object(importer, "handle_error") as handle_error:
    +                    result = importer.run()
    +
    +    assert handle_error.call_count == 2
    +    assert result["publishers_imported"] is True
    +
    +
    +def test_conference_importer_success_and_error_paths(session):
    +    importer = ConferenceImporter(session, client=MagicMock())
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +        with patch("pbn_import.utils.conference_import.pobierz_konferencje") as dl:
    +            result = importer.run()
    +
    +    dl.assert_called_once()
    +    assert result == {"conferences_imported": True, "error_count": 0}
    +
    +    failing = ConferenceImporter(session, client=MagicMock())
    +    with patch.object(failing, "create_subtask_progress", return_value=MagicMock()):
    +        with patch(
    +            "pbn_import.utils.conference_import.pobierz_konferencje",
    +            side_effect=RuntimeError("conference failed"),
    +        ):
    +            with patch.object(failing, "handle_error") as handle_error:
    +                result = failing.run()
    +
    +    handle_error.assert_called_once()
    +    assert result["conferences_imported"] is True
    +
    +
    +class FakeRecord:
    +    def __init__(self, pbn_uid_id):
    +        self.pbn_uid_id = pbn_uid_id
    +        self.save = MagicMock()
    +
    +
    +class FakePublicationClass:
    +    def __init__(self, name, records):
    +        self.__name__ = name
    +        self.objects = MagicMock()
    +        self.objects.exclude.return_value = records
    +
    +
    +def test_fee_importer_updates_records_from_batch_api(session):
    +    record = FakeRecord(123)
    +    ciagle = FakePublicationClass("Wydawnictwo_Ciagle", [record])
    +    zwarte = FakePublicationClass("Wydawnictwo_Zwarte", [])
    +    client = MagicMock()
    +    client.get_publication_fees_batch.return_value = {
    +        "123": {
    +            "fee": {
    +                "costFreePublication": True,
    +                "researchPotentialFinancialResources": True,
    +                "researchOrDevelopmentProjectsFinancialResources": False,
    +                "other": True,
    +                "amount": 250,
    +            }
    +        }
    +    }
    +
    +    with patch("pbn_import.utils.fee_import.Wydawnictwo_Ciagle", ciagle):
    +        with patch("pbn_import.utils.fee_import.Wydawnictwo_Zwarte", zwarte):
    +            result = FeeImporter(session, client=client).run()
    +
    +    client.get_publication_fees_batch.assert_called_once_with([123])
    +    assert record.opl_pub_cost_free is True
    +    assert record.opl_pub_research_potential is True
    +    assert record.opl_pub_research_or_development_projects is False
    +    assert record.opl_pub_other is True
    +    assert record.opl_pub_amount == 250
    +    record.save.assert_called_once()
    +    assert result == {
    +        "fees_imported": 1,
    +        "fees_failed": 0,
    +        "api_calls": 1,
    +        "error_count": 0,
    +    }
    +
    +
    +def test_fee_importer_counts_failed_batch(session):
    +    records = [FakeRecord(123), FakeRecord(456)]
    +    ciagle = FakePublicationClass("Wydawnictwo_Ciagle", records)
    +    zwarte = FakePublicationClass("Wydawnictwo_Zwarte", [])
    +    client = MagicMock()
    +    client.get_publication_fees_batch.side_effect = RuntimeError("fee API failed")
    +    importer = FeeImporter(session, client=client)
    +
    +    with patch("pbn_import.utils.fee_import.Wydawnictwo_Ciagle", ciagle):
    +        with patch("pbn_import.utils.fee_import.Wydawnictwo_Zwarte", zwarte):
    +            with patch.object(importer, "handle_error") as handle_error:
    +                result = importer.run()
    +
    +    handle_error.assert_called_once()
    +    assert result["fees_imported"] == 0
    +    assert result["fees_failed"] == 2
    +    assert result["api_calls"] == 0
    +
    +
    +def test_statement_importer_returns_when_publication_setup_missing(session):
    +    importer = StatementImporter(session, client=MagicMock(), uczelnia=None)
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +        with patch("pbn_import.utils.statement_import.pobierz_oswiadczenia_z_instytucji"):
    +            with patch.object(
    +                importer.publication_importer,
    +                "_setup_uczelnia_and_jednostka",
    +                return_value=None,
    +            ):
    +                result = importer.run()
    +
    +    assert result == {
    +        "statements_imported": False,
    +        "reason": "No Uczelnia PBN UID",
    +    }
    +
    +
    +def test_statement_importer_full_success_logs_inconsistency_summary(
    +    session,
    +    uczelnia,
    +):
    +    importer = StatementImporter(session, client=MagicMock(), uczelnia=uczelnia)
    +    default_jednostka = baker.make(Jednostka, uczelnia=uczelnia)
    +    importer.publication_importer.default_jednostka = default_jednostka
    +
    +    def integrate_with_inconsistency(**kwargs):
    +        kwargs["inconsistency_callback"](
    +            "author_not_found",
    +            pbn_publication=SimpleNamespace(mongoId="pub-1", title="PBN title"),
    +            message="missing author",
    +        )
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +        with patch("pbn_import.utils.statement_import.pobierz_oswiadczenia_z_instytucji"):
    +            with patch.object(
    +                importer.publication_importer,
    +                "_setup_uczelnia_and_jednostka",
    +                return_value=uczelnia,
    +            ):
    +                with patch.object(
    +                    importer,
    +                    "_download_missing_publications",
    +                    return_value={"downloaded": 1, "failed": 0, "errors": []},
    +                ):
    +                    with patch(
    +                        "pbn_import.utils.statement_import."
    +                        "integruj_oswiadczenia_z_instytucji",
    +                        side_effect=integrate_with_inconsistency,
    +                    ) as integrate:
    +                        result = importer.run()
    +
    +    integrate.assert_called_once_with(
    +        missing_publication_callback=None,
    +        inconsistency_callback=integrate.call_args.kwargs["inconsistency_callback"],
    +        default_jednostka=default_jednostka,
    +        uczelnia=uczelnia,
    +    )
    +    assert result == {"statements_imported": True, "error_count": 0}
    +    assert session.inconsistencies.count() == 1
    +    assert ImportLog.objects.filter(
    +        session=session,
    +        level="warning",
    +        message__contains="Znaleziono 1 nieścisłości",
    +    ).exists()
    +    assert ImportLog.objects.filter(
    +        session=session,
    +        level="success",
    +        message="Oświadczenia zintegrowane pomyślnie",
    +    ).exists()
    +
    +
    +def test_statement_importer_records_download_and_integration_errors(session, uczelnia):
    +    importer = StatementImporter(session, client=MagicMock(), uczelnia=uczelnia)
    +    importer.publication_importer.default_jednostka = baker.make(
    +        Jednostka, uczelnia=uczelnia
    +    )
    +
    +    with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +        with patch(
    +            "pbn_import.utils.statement_import.pobierz_oswiadczenia_z_instytucji",
    +            side_effect=RuntimeError("download failed"),
    +        ):
    +            with patch.object(
    +                importer.publication_importer,
    +                "_setup_uczelnia_and_jednostka",
    +                return_value=uczelnia,
    +            ):
    +                with patch.object(
    +                    importer,
    +                    "_download_missing_publications",
    +                    return_value={"downloaded": 0, "failed": 0, "errors": []},
    +                ):
    +                    with patch(
    +                        "pbn_import.utils.statement_import."
    +                        "integruj_oswiadczenia_z_instytucji",
    +                        side_effect=RuntimeError("integration failed"),
    +                    ):
    +                        with patch(
    +                            "pbn_import.utils.base.rollbar.report_exc_info"
    +                        ) as report_exc_info:
    +                            result = importer.run()
    +
    +    assert report_exc_info.call_count == 2
    +    assert result == {"statements_imported": True, "error_count": 2}
    +    assert importer.errors == [
    +        "Nie udało się pobrać oświadczeń: download failed",
    +        "Nie udało się zintegrować oświadczeń: integration failed",
    +    ]
    +    assert list(
    +        ImportLog.objects.filter(session=session, level="error")
    +        .order_by("pk")
    +        .values_list("message", flat=True)
    +    ) == importer.errors
    +
    +
    +def test_statement_download_missing_publications_no_statement_ids(session):
    +    importer = StatementImporter(session, client=MagicMock(), uczelnia=None)
    +
    +    with patch(
    +        "pbn_import.utils.statement_import.OswiadczenieInstytucji.objects"
    +    ) as statements:
    +        statements.values_list.return_value.distinct.return_value = []
    +
    +        assert importer._download_missing_publications(MagicMock()) is None
    +
    +
    +def test_statement_download_missing_publications_no_missing_publications(session):
    +    importer = StatementImporter(session, client=MagicMock(), uczelnia=None)
    +
    +    with patch(
    +        "pbn_import.utils.statement_import.OswiadczenieInstytucji.objects"
    +    ) as statements:
    +        with patch("pbn_import.utils.statement_import.Rekord.objects") as rekordy:
    +            statements.values_list.return_value.distinct.return_value = ["pub-1"]
    +            rekordy.exclude.return_value.values_list.return_value = ["pub-1"]
    +
    +            result = importer._download_missing_publications(MagicMock())
    +
    +    assert result == {"downloaded": 0, "failed": 0, "errors": []}
    +
    +
    +def test_statement_download_missing_publications_downloads_and_logs_errors(session):
    +    importer = StatementImporter(session, client=MagicMock(), uczelnia=None)
    +    default_jednostka = baker.make(Jednostka)
    +
    +    with patch(
    +        "pbn_import.utils.statement_import.OswiadczenieInstytucji.objects"
    +    ) as statements:
    +        with patch("pbn_import.utils.statement_import.Rekord.objects") as rekordy:
    +            with patch.object(
    +                importer, "create_subtask_progress", return_value=MagicMock()
    +            ):
    +                with patch(
    +                    "pbn_import.utils.statement_import."
    +                    "pobierz_brakujace_publikacje_batch",
    +                    return_value={
    +                        "downloaded": 1,
    +                        "failed": 1,
    +                        "errors": ["missing pub failed"],
    +                    },
    +                ) as download:
    +                    statements.values_list.return_value.distinct.return_value = [
    +                        "pub-1",
    +                        "pub-2",
    +                    ]
    +                    rekordy.objects = rekordy
    +                    rekordy.exclude.return_value.values_list.return_value = ["pub-1"]
    +
    +                    result = importer._download_missing_publications(default_jednostka)
    +
    +    download.assert_called_once_with(
    +        client=importer.client,
    +        missing_pbn_uids={"pub-2"},
    +        default_jednostka=default_jednostka,
    +        max_workers=8,
    +        callback=download.call_args.kwargs["callback"],
    +    )
    +    assert result["downloaded"] == 1
    +    assert ImportLog.objects.filter(
    +        session=session, level="warning", message__contains="missing pub failed"
    +    ).exists()
    +
    +
    +def test_statement_download_missing_publications_handles_batch_error(session):
    +    importer = StatementImporter(session, client=MagicMock(), uczelnia=None)
    +
    +    with patch(
    +        "pbn_import.utils.statement_import.OswiadczenieInstytucji.objects"
    +    ) as statements:
    +        with patch("pbn_import.utils.statement_import.Rekord.objects") as rekordy:
    +            with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +                with patch.object(importer, "clear_subtask_progress") as clear:
    +                    with patch(
    +                        "pbn_import.utils.statement_import."
    +                        "pobierz_brakujace_publikacje_batch",
    +                        side_effect=RuntimeError("batch failed"),
    +                    ):
    +                        with patch(
    +                            "pbn_import.utils.base.rollbar.report_exc_info"
    +                        ) as report_exc_info:
    +                            statements.values_list.return_value.distinct.return_value = [
    +                                "pub-1"
    +                            ]
    +                            rekordy.exclude.return_value.values_list.return_value = []
    +
    +                            result = importer._download_missing_publications(
    +                                MagicMock()
    +                            )
    +
    +    assert result is None
    +    report_exc_info.assert_called_once()
    +    assert importer.errors == [
    +        "Nie udało się pobrać brakujących publikacji: batch failed"
    +    ]
    +    assert ImportLog.objects.filter(
    +        session=session,
    +        level="error",
    +        message="Nie udało się pobrać brakujących publikacji: batch failed",
    +    ).exists()
    +    clear.assert_called_once_with()
    +
    +
    +def test_statement_inconsistency_callback_creates_record(session):
    +    importer = StatementImporter(session, client=MagicMock(), uczelnia=None)
    +    callback = importer._create_inconsistency_callback()
    +    pbn_publication = SimpleNamespace(mongoId="pub-1", title="PBN publication")
    +    pbn_author = SimpleNamespace(pk="author-1", lastName="Kowalski", name="Jan")
    +
    +    callback(
    +        "author_not_found",
    +        pbn_publication=pbn_publication,
    +        pbn_author=pbn_author,
    +        discipline="2.3",
    +        message="missing author",
    +        action_taken="reported",
    +    )
    +
    +    inconsistency = session.inconsistencies.get()
    +    assert inconsistency.inconsistency_type == "author_not_found"
    +    assert inconsistency.pbn_publication_id == "pub-1"
    +    assert inconsistency.pbn_author_name == "Kowalski Jan"
    +    assert inconsistency.message == "missing author"
    +
    +
    +def test_statement_inconsistency_callback_records_bpp_publication_details(session):
    +    importer = StatementImporter(session, client=MagicMock(), uczelnia=None)
    +    callback = importer._create_inconsistency_callback()
    +    content_type = baker.make("contenttypes.ContentType")
    +    bpp_publication = SimpleNamespace(pk=123, tytul_oryginalny="BPP title")
    +    bpp_author = SimpleNamespace(pk=456, __str__=lambda self: "BPP author")
    +
    +    with patch(
    +        "pbn_import.utils.statement_import.ContentType.objects.get_for_model",
    +        return_value=content_type,
    +    ):
    +        callback(
    +            "publication_not_found",
    +            bpp_publication=bpp_publication,
    +            bpp_author=bpp_author,
    +            message="publication mismatch",
    +        )
    +
    +    inconsistency = session.inconsistencies.get()
    +    assert inconsistency.bpp_publication_id == 123
    +    assert inconsistency.bpp_publication_content_type == content_type
    +    assert inconsistency.bpp_publication_title == "BPP title"
    +    assert inconsistency.bpp_author_id == 456
    +
    +
    +def test_statement_inconsistency_callback_logs_persistence_failure(session):
    +    importer = StatementImporter(session, client=MagicMock(), uczelnia=None)
    +    callback = importer._create_inconsistency_callback()
    +
    +    with patch.object(
    +        ImportInconsistency.objects,
    +        "create",
    +        side_effect=RuntimeError("cannot save inconsistency"),
    +    ):
    +        callback("author_not_found", message="broken")
    +
    +    assert ImportLog.objects.filter(
    +        session=session,
    +        level="warning",
    +        message__contains="Błąd podczas zapisu nieścisłości",
    +    ).exists()
    diff --git a/src/pbn_import/tests/test_initial_setup.py b/src/pbn_import/tests/test_initial_setup.py
    new file mode 100644
    index 000000000..e63a59b85
    --- /dev/null
    +++ b/src/pbn_import/tests/test_initial_setup.py
    @@ -0,0 +1,151 @@
    +"""Tests for the PBN import initial setup step."""
    +
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +from model_bakery import baker
    +
    +from bpp.models import Dyscyplina_Naukowa, Jezyk, Uczelnia
    +from pbn_api.models import Institution
    +from pbn_import.models import ImportLog, ImportSession
    +from pbn_import.utils.initial_setup import InitialSetup
    +
    +
    +@pytest.fixture
    +def session(db, django_user_model):
    +    user = baker.make(django_user_model)
    +    return baker.make(ImportSession, user=user, config={})
    +
    +
    +@pytest.fixture
    +def uczelnia(db):
    +    return baker.make(Uczelnia, nazwa="Uniwersytet Testowy", pbn_integracja=False)
    +
    +
    +def test_run_full_setup_uses_existing_client_and_matches_uczelnia(session, uczelnia):
    +    client = MagicMock()
    +    matched = baker.make(Institution)
    +    setup = InitialSetup(session, client=client, uczelnia=uczelnia)
    +
    +    with patch("pbn_import.utils.initial_setup.integruj_jezyki") as jezyki:
    +        with patch("pbn_import.utils.initial_setup.integruj_kraje") as kraje:
    +            with patch("pbn_import.utils.initial_setup.pobierz_instytucje_polon"):
    +                with patch(
    +                    "pbn_import.utils.initial_setup.matchuj_uczelnie",
    +                    return_value=matched,
    +                ):
    +                    result = setup.run()
    +
    +    jezyki.assert_called_once_with(client, create_if_not_exists=True)
    +    kraje.assert_called_once_with(client)
    +    client.download_disciplines.assert_called_once()
    +    client.sync_disciplines.assert_called_once()
    +    uczelnia.refresh_from_db()
    +    session.refresh_from_db()
    +    assert uczelnia.pbn_integracja is True
    +    assert uczelnia.pbn_uid_id == matched.pk
    +    assert session.config["uczelnia_auto_matched"] is True
    +    assert result["languages_integrated"] is True
    +    assert result["uczelnia_matched"] is True
    +
    +
    +def test_run_without_client_creates_client_from_uczelnia(session, uczelnia):
    +    client = MagicMock()
    +    setup = InitialSetup(session, client=None, uczelnia=uczelnia)
    +
    +    with patch.object(uczelnia, "pbn_client", return_value=client) as pbn_client:
    +        with patch("pbn_import.utils.initial_setup.integruj_jezyki"):
    +            with patch("pbn_import.utils.initial_setup.integruj_kraje"):
    +                with patch("pbn_import.utils.initial_setup.pobierz_instytucje_polon"):
    +                    with patch(
    +                        "pbn_import.utils.initial_setup.matchuj_uczelnie",
    +                        return_value=None,
    +                    ):
    +                        result = setup.run()
    +
    +    pbn_client.assert_called_once_with()
    +    assert setup.client == client
    +    assert result["institutions_fetched"] is True
    +
    +
    +def test_run_without_working_client_falls_back_to_minimal_setup(session, uczelnia):
    +    setup = InitialSetup(session, client=None, uczelnia=uczelnia)
    +
    +    with patch.object(uczelnia, "pbn_client", side_effect=RuntimeError("no config")):
    +        result = setup.run()
    +
    +    uczelnia.refresh_from_db()
    +    assert uczelnia.pbn_integracja is True
    +    assert result["minimal_setup"] is True
    +    assert Jezyk.objects.filter(nazwa="polski").exists()
    +    assert Dyscyplina_Naukowa.objects.filter(kod="2.3").exists()
    +
    +
    +def test_language_authorization_error_stops_import(session, uczelnia):
    +    setup = InitialSetup(session, client=MagicMock(), uczelnia=uczelnia)
    +
    +    with patch(
    +        "pbn_import.utils.initial_setup.integruj_jezyki",
    +        side_effect=RuntimeError("auth failed"),
    +    ):
    +        with patch.object(setup, "is_authorization_error", return_value=True):
    +            with pytest.raises(Exception, match="Brak autoryzacji PBN"):
    +                setup.run()
    +
    +    assert ImportLog.objects.filter(session=session, level="critical").exists()
    +
    +
    +def test_language_non_authorization_error_uses_minimal_setup(session, uczelnia):
    +    setup = InitialSetup(session, client=MagicMock(), uczelnia=uczelnia)
    +
    +    with patch(
    +        "pbn_import.utils.initial_setup.integruj_jezyki",
    +        side_effect=RuntimeError("temporary outage"),
    +    ):
    +        with patch.object(setup, "is_authorization_error", return_value=False):
    +            result = setup.run()
    +
    +    assert result["minimal_setup"] is True
    +    assert ImportLog.objects.filter(
    +        session=session,
    +        level="warning",
    +        message__contains="Nie można zintegrować języków",
    +    ).exists()
    +
    +
    +def test_later_pbn_errors_are_delegated_and_import_continues(session, uczelnia):
    +    client = MagicMock()
    +    client.download_disciplines.side_effect = RuntimeError("disciplines failed")
    +    setup = InitialSetup(session, client=client, uczelnia=uczelnia)
    +
    +    with patch("pbn_import.utils.initial_setup.integruj_jezyki"):
    +        with patch(
    +            "pbn_import.utils.initial_setup.integruj_kraje",
    +            side_effect=RuntimeError("countries failed"),
    +        ):
    +            with patch(
    +                "pbn_import.utils.initial_setup.pobierz_instytucje_polon",
    +                side_effect=RuntimeError("institutions failed"),
    +            ):
    +                with patch.object(setup, "handle_pbn_error") as handle_error:
    +                    result = setup.run()
    +
    +    assert handle_error.call_count == 3
    +    assert result["countries_integrated"] is True
    +    assert result["disciplines_synced"] is True
    +    assert result["institutions_fetched"] is True
    +    assert "current_subtask" not in session.progress_data
    +
    +
    +def test_auto_match_without_result_marks_manual_match_required(session, uczelnia):
    +    setup = InitialSetup(session, client=MagicMock(), uczelnia=uczelnia)
    +
    +    with patch("pbn_import.utils.initial_setup.matchuj_uczelnie", return_value=None):
    +        setup._auto_match_uczelnia(uczelnia)
    +
    +    session.refresh_from_db()
    +    assert session.config["uczelnia_match_required"] is True
    +    assert session.config["uczelnia_nazwa"] == uczelnia.nazwa
    +    assert setup.errors == [
    +        f"Uczelnia '{uczelnia.nazwa}' wymaga ręcznego wyboru PBN UID"
    +    ]
    diff --git a/src/pbn_import/tests/test_institution_import.py b/src/pbn_import/tests/test_institution_import.py
    new file mode 100644
    index 000000000..012af5961
    --- /dev/null
    +++ b/src/pbn_import/tests/test_institution_import.py
    @@ -0,0 +1,154 @@
    +"""Tests for PBN import institution setup helpers."""
    +
    +import pytest
    +from model_bakery import baker
    +
    +from bpp.models import Jednostka, Jednostka_Wydzial, Uczelnia, Wydzial
    +from pbn_import.models import ImportLog, ImportSession
    +from pbn_import.utils.institution_import import (
    +    InstitutionImporter,
    +    znajdz_lub_utworz_jednostke_domyslna,
    +    znajdz_lub_utworz_wydzial_domyslny,
    +    zrob_skrot,
    +)
    +
    +
    +@pytest.fixture
    +def session(db, django_user_model):
    +    user = baker.make(django_user_model)
    +    return baker.make(ImportSession, user=user, config={})
    +
    +
    +@pytest.fixture
    +def uczelnia(db):
    +    return baker.make(Uczelnia)
    +
    +
    +def test_zrob_skrot_keeps_uppercase_and_punctuation_only():
    +    assert zrob_skrot("Wydział Badań-Aplikacyjnych 2026!") == "WB-A!"
    +
    +
    +def test_find_or_create_default_wydzial_reuses_uczelnia_scoped_match(uczelnia):
    +    foreign = baker.make(Wydzial, nazwa="Wydział Domyślny Obcy")
    +    existing = baker.make(
    +        Wydzial,
    +        nazwa="Wydział Domyślny Naukowy",
    +        uczelnia=uczelnia,
    +    )
    +
    +    wydzial, created = znajdz_lub_utworz_wydzial_domyslny(uczelnia)
    +
    +    assert wydzial == existing
    +    assert wydzial != foreign
    +    assert created is False
    +
    +
    +def test_find_or_create_default_wydzial_creates_with_generated_short_name(uczelnia):
    +    wydzial, created = znajdz_lub_utworz_wydzial_domyslny(
    +        uczelnia,
    +        "Wydział Testów-Jednostkowych",
    +    )
    +
    +    assert created is True
    +    assert wydzial.uczelnia == uczelnia
    +    assert wydzial.skrot == "WT-J"
    +
    +
    +def test_find_or_create_default_jednostka_reuses_uczelnia_scoped_match(uczelnia):
    +    foreign = baker.make(Jednostka, nazwa="Jednostka Domyślna Obca")
    +    existing = baker.make(
    +        Jednostka,
    +        nazwa="Jednostka Domyślna Testowa",
    +        uczelnia=uczelnia,
    +    )
    +
    +    jednostka, created = znajdz_lub_utworz_jednostke_domyslna(uczelnia)
    +
    +    assert jednostka == existing
    +    assert jednostka != foreign
    +    assert created is False
    +
    +
    +def test_find_or_create_default_jednostka_creates_for_uczelnia(uczelnia):
    +    jednostka, created = znajdz_lub_utworz_jednostke_domyslna(uczelnia)
    +
    +    assert created is True
    +    assert jednostka.nazwa == "Jednostka Domyślna"
    +    assert jednostka.skrot == "JD"
    +    assert jednostka.uczelnia == uczelnia
    +
    +
    +def test_institution_importer_requires_uczelnia(session):
    +    importer = InstitutionImporter(session, uczelnia=None)
    +
    +    with pytest.raises(ValueError, match="Nie znaleziono domyślnej Uczelni"):
    +        importer.run()
    +
    +
    +def test_institution_importer_creates_defaults_links_and_session_config(
    +    session,
    +    uczelnia,
    +):
    +    importer = InstitutionImporter(
    +        session,
    +        uczelnia=uczelnia,
    +        wydzial_domyslny="Wydział Testów",
    +        wydzial_domyslny_skrot="WT",
    +    )
    +
    +    result = importer.run()
    +
    +    session.refresh_from_db()
    +    uczelnia.refresh_from_db()
    +    wydzial = result["wydzial"]
    +    jednostka = result["jednostka"]
    +    obca_jednostka = result["obca_jednostka"]
    +
    +    assert wydzial.nazwa == "Wydział Testów"
    +    assert jednostka.nazwa == "Jednostka Domyślna"
    +    assert obca_jednostka.nazwa == "Obca jednostka"
    +    assert obca_jednostka.skupia_pracownikow is False
    +    assert uczelnia.obca_jednostka == obca_jednostka
    +    assert Jednostka_Wydzial.objects.filter(
    +        jednostka=jednostka,
    +        wydzial=wydzial,
    +    ).exists()
    +    assert Jednostka_Wydzial.objects.filter(
    +        jednostka=obca_jednostka,
    +        wydzial=wydzial,
    +    ).exists()
    +    assert session.config == {
    +        "default_jednostka_id": jednostka.id,
    +        "obca_jednostka_id": obca_jednostka.id,
    +        "wydzial_id": wydzial.id,
    +    }
    +    assert ImportLog.objects.filter(session=session, level="info").count() >= 4
    +
    +
    +def test_institution_importer_reuses_existing_objects(session, uczelnia):
    +    wydzial = baker.make(Wydzial, nazwa="Wydział Domyślny", uczelnia=uczelnia)
    +    jednostka = baker.make(Jednostka, nazwa="Jednostka Domyślna", uczelnia=uczelnia)
    +    obca = baker.make(
    +        Jednostka,
    +        nazwa="Obca jednostka",
    +        uczelnia=uczelnia,
    +        skupia_pracownikow=False,
    +    )
    +    uczelnia.obca_jednostka = obca
    +    uczelnia.save(update_fields=["obca_jednostka"])
    +    Jednostka_Wydzial.objects.create(jednostka=jednostka, wydzial=wydzial)
    +    Jednostka_Wydzial.objects.create(jednostka=obca, wydzial=wydzial)
    +
    +    result = InstitutionImporter(session, uczelnia=uczelnia).run()
    +
    +    assert result == {
    +        "wydzial": wydzial,
    +        "jednostka": jednostka,
    +        "obca_jednostka": obca,
    +    }
    +    assert Wydzial.objects.filter(uczelnia=uczelnia).count() == 1
    +    assert Jednostka.objects.filter(uczelnia=uczelnia).count() == 2
    +    assert ImportLog.objects.filter(
    +        session=session,
    +        message__contains="Using existing department",
    +    ).exists()
    diff --git a/src/pbn_import/tests/test_publication_import.py b/src/pbn_import/tests/test_publication_import.py
    new file mode 100644
    index 000000000..d229e8647
    --- /dev/null
    +++ b/src/pbn_import/tests/test_publication_import.py
    @@ -0,0 +1,227 @@
    +"""Focused tests for publication import step behavior."""
    +
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +from model_bakery import baker
    +
    +from bpp.models import (
    +    Dyscyplina_Naukowa,
    +    Jednostka,
    +    Rodzaj_Zrodla,
    +    Uczelnia,
    +)
    +from pbn_api.models import Institution, Publication
    +from pbn_import.models import ImportSession
    +from pbn_import.utils.base import CancelledException
    +from pbn_import.utils.publication_import import PublicationImporter
    +
    +
    +@pytest.fixture
    +def session(db, django_user_model):
    +    user = baker.make(django_user_model)
    +    return baker.make(ImportSession, user=user, config={})
    +
    +
    +@pytest.fixture
    +def uczelnia(db):
    +    return baker.make(Uczelnia, pbn_uid=baker.make(Institution))
    +
    +
    +@pytest.fixture
    +def importer(session, uczelnia):
    +    imp = PublicationImporter(session, client=MagicMock(), uczelnia=uczelnia)
    +    imp.default_jednostka = baker.make(Jednostka, nazwa="Default unit", uczelnia=uczelnia)
    +    return imp
    +
    +
    +def test_run_returns_reason_when_uczelnia_setup_is_missing(session):
    +    importer = PublicationImporter(session, client=MagicMock(), uczelnia=None)
    +
    +    with patch.object(importer, "_setup_uczelnia_and_jednostka", return_value=None):
    +        result = importer.run()
    +
    +    assert result == {"authors_imported": False, "reason": "No Uczelnia PBN UID"}
    +
    +
    +def test_run_success_with_delete_existing_calls_steps_in_order(importer, uczelnia):
    +    importer.delete_existing = True
    +
    +    with patch.object(
    +        importer, "_setup_uczelnia_and_jednostka", return_value=uczelnia
    +    ) as setup:
    +        with patch.object(
    +            importer, "_delete_existing_publications", return_value=None
    +        ) as delete_existing:
    +            with patch.object(
    +                importer, "_download_publications", return_value=None
    +            ) as download:
    +                with patch.object(
    +                    importer, "_download_publications_v2", return_value=None
    +                ) as download_v2:
    +                    with patch.object(
    +                        importer, "_import_publications", return_value=None
    +                    ) as import_publications:
    +                        with patch.object(importer, "update_progress") as progress:
    +                            result = importer.run()
    +
    +    setup.assert_called_once_with()
    +    delete_existing.assert_called_once_with(0, 4)
    +    download.assert_called_once_with(1, 4, uczelnia)
    +    download_v2.assert_called_once_with(2, 4)
    +    import_publications.assert_called_once_with(3, 4)
    +    progress.assert_called_once_with(4, 4, "Zakończono import publikacji")
    +    assert result == {
    +        "publications_imported": True,
    +        "default_jednostka": "Default unit",
    +        "error_count": 0,
    +    }
    +
    +
    +def test_run_short_circuits_when_delete_existing_returns_result(importer, uczelnia):
    +    importer.delete_existing = True
    +
    +    with patch.object(importer, "_setup_uczelnia_and_jednostka", return_value=uczelnia):
    +        with patch.object(
    +            importer, "_delete_existing_publications", return_value={"cancelled": True}
    +        ):
    +            with patch.object(importer, "_download_publications") as download:
    +                result = importer.run()
    +
    +    assert result == {"cancelled": True}
    +    download.assert_not_called()
    +
    +
    +def test_delete_existing_returns_cancelled_before_deleting(importer):
    +    with patch.object(importer, "check_cancelled", return_value=True):
    +        with patch("pbn_import.utils.publication_import.Wydawnictwo_Zwarte") as zwarte:
    +            result = importer._delete_existing_publications(0, 3)
    +
    +    assert result == {"cancelled": True}
    +    zwarte.objects.exclude.assert_not_called()
    +
    +
    +def test_download_publications_success_uses_progress_callback(importer, uczelnia):
    +    callback = MagicMock()
    +
    +    with patch.object(importer, "check_cancelled", return_value=False):
    +        with patch.object(importer, "create_subtask_progress", return_value=callback):
    +            with patch(
    +                "pbn_import.utils.publication_import.pobierz_publikacje_z_instytucji"
    +            ) as download:
    +                result = importer._download_publications(1, 3, uczelnia)
    +
    +    assert result is None
    +    download.assert_called_once_with(importer.client, callback=callback)
    +
    +
    +def test_download_publications_delegates_pbn_error(importer, uczelnia):
    +    error = RuntimeError("pbn unavailable")
    +
    +    with patch.object(importer, "check_cancelled", return_value=False):
    +        with patch(
    +            "pbn_import.utils.publication_import.pobierz_publikacje_z_instytucji",
    +            side_effect=error,
    +        ):
    +            with patch.object(importer, "handle_pbn_error") as handle_pbn_error:
    +                importer._download_publications(1, 3, uczelnia)
    +
    +    handle_pbn_error.assert_called_once_with(
    +        error, "Nie udało się pobrać publikacji"
    +    )
    +
    +
    +def test_download_publications_v2_clears_progress_even_on_error(importer):
    +    error = RuntimeError("v2 failed")
    +
    +    with patch.object(importer, "check_cancelled", return_value=False):
    +        with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +            with patch.object(
    +                importer, "_download_publications_v2_with_callback", side_effect=error
    +            ):
    +                with patch.object(importer, "handle_pbn_error") as handle_pbn_error:
    +                    with patch.object(importer, "clear_subtask_progress") as clear:
    +                        importer._download_publications_v2(2, 3)
    +
    +    handle_pbn_error.assert_called_once_with(
    +        error, "Nie udało się pobrać publikacji v2"
    +    )
    +    assert clear.call_count == 2
    +
    +
    +def test_import_publications_success_calls_import_helper(importer):
    +    with patch.object(importer, "check_cancelled", return_value=False):
    +        with patch.object(importer, "_import_publications_with_cancellation") as helper:
    +            result = importer._import_publications(2, 3)
    +
    +    assert result is None
    +    helper.assert_called_once_with()
    +
    +
    +def test_import_publications_logs_helper_error_and_continues(importer):
    +    error = RuntimeError("broken publication")
    +
    +    with patch.object(importer, "check_cancelled", return_value=False):
    +        with patch.object(
    +            importer, "_import_publications_with_cancellation", side_effect=error
    +        ):
    +            with patch.object(importer, "handle_error") as handle_error:
    +                result = importer._import_publications(2, 3)
    +
    +    assert result is None
    +    handle_error.assert_called_once_with(error, "Nie udało się zaimportować publikacji")
    +
    +
    +def test_import_publications_with_cancellation_imports_and_records_failures(importer):
    +    rodzaj_periodyk, _ = Rodzaj_Zrodla.objects.get_or_create(nazwa="periodyk")
    +    dyscyplina, _ = Dyscyplina_Naukowa.objects.get_or_create(
    +        kod="2.3", defaults={"nazwa": "Informatyka"}
    +    )
    +    pub_ok = baker.make(Publication, mongoId="pub-ok")
    +    pub_bad = baker.make(Publication, mongoId="pub-bad")
    +
    +    def passthrough_pbar(iterator, count, label, callback):
    +        return iterator
    +
    +    def fake_import_one(pbn_uid, **kwargs):
    +        if pbn_uid == pub_bad.mongoId:
    +            raise RuntimeError("bad import")
    +        return True
    +
    +    with patch("bpp.util.pbar", side_effect=passthrough_pbar):
    +        with patch.object(importer, "check_cancelled", return_value=False):
    +            with patch.object(importer, "create_subtask_progress", return_value=MagicMock()):
    +                with patch.object(importer, "handle_error") as handle_error:
    +                    with patch(
    +                        "pbn_import.utils.publication_import."
    +                        "importuj_publikacje_po_pbn_uid_id",
    +                        side_effect=fake_import_one,
    +                    ) as import_one:
    +                        importer._import_publications_with_cancellation()
    +
    +    assert {args.args[0] for args in import_one.call_args_list} == {
    +        pub_ok.mongoId,
    +        pub_bad.mongoId,
    +    }
    +    for import_call in import_one.call_args_list:
    +        assert import_call.kwargs == {
    +            "client": importer.client,
    +            "default_jednostka": importer.default_jednostka,
    +            "rodzaj_periodyk": rodzaj_periodyk,
    +            "dyscypliny_cache": {dyscyplina.nazwa: dyscyplina},
    +        }
    +    handle_error.assert_called_once()
    +    assert "pub-bad" in handle_error.call_args.args[1]
    +
    +
    +def test_import_publications_with_cancellation_raises_when_session_cancelled(importer):
    +    Rodzaj_Zrodla.objects.get_or_create(nazwa="periodyk")
    +    baker.make(Publication, mongoId="pub-cancelled")
    +
    +    def passthrough_pbar(iterator, count, label, callback):
    +        return iterator
    +
    +    with patch("bpp.util.pbar", side_effect=passthrough_pbar):
    +        with patch.object(importer, "check_cancelled", return_value=True):
    +            with pytest.raises(CancelledException, match="Import został anulowany"):
    +                importer._import_publications_with_cancellation()
    diff --git a/src/pbn_import/tests/test_routing.py b/src/pbn_import/tests/test_routing.py
    new file mode 100644
    index 000000000..4a78acd0e
    --- /dev/null
    +++ b/src/pbn_import/tests/test_routing.py
    @@ -0,0 +1,11 @@
    +"""Tests for PBN import websocket routing."""
    +
    +from pbn_import.routing import websocket_urlpatterns
    +
    +
    +def test_websocket_route_points_to_import_session_progress():
    +    pattern = websocket_urlpatterns[0]
    +
    +    assert len(websocket_urlpatterns) == 1
    +    assert pattern.pattern.regex.pattern == r"ws/pbn-import/session/(?P\w+)/$"
    +    assert callable(pattern.callback)
    diff --git a/src/pbn_import/tests/test_source_scoring_import.py b/src/pbn_import/tests/test_source_scoring_import.py
    new file mode 100644
    index 000000000..bb68bdc5f
    --- /dev/null
    +++ b/src/pbn_import/tests/test_source_scoring_import.py
    @@ -0,0 +1,252 @@
    +"""Tests for source scoring synchronization."""
    +
    +from types import SimpleNamespace
    +from unittest.mock import MagicMock, call, patch
    +
    +import pytest
    +from model_bakery import baker
    +
    +from bpp.models import Uczelnia
    +from pbn_import.models import ImportLog, ImportSession
    +from pbn_import.utils.source_scoring_import import (
    +    SourceScoringImporter,
    +    _import_disciplines_for_zrodlo,
    +    _import_points_for_zrodlo,
    +    _sync_single_source,
    +)
    +
    +
    +@pytest.fixture
    +def session(db, django_user_model):
    +    user = baker.make(django_user_model)
    +    return baker.make(ImportSession, user=user, config={})
    +
    +
    +class FakePbnUid:
    +    def __init__(self, values):
    +        self.values = values
    +
    +    def value(self, *path, return_none=False):
    +        value = self.values
    +        for elem in path:
    +            value = value.get(elem)
    +            if value is None:
    +                return None if return_none else f"[brak {elem}]"
    +        return value
    +
    +
    +class FakePunktacjaManager:
    +    def __init__(self, existing=None):
    +        self.existing = existing
    +        self.created = []
    +
    +    def get(self, rok):
    +        if self.existing and self.existing.rok == rok:
    +            return self.existing
    +        from bpp.models import Punktacja_Zrodla
    +
    +        raise Punktacja_Zrodla.DoesNotExist
    +
    +    def create(self, **kwargs):
    +        self.created.append(kwargs)
    +        return SimpleNamespace(**kwargs)
    +
    +
    +class FakeDyscyplinaManager:
    +    def __init__(self):
    +        self.deleted = False
    +        self.created = []
    +
    +    def all(self):
    +        return self
    +
    +    def delete(self):
    +        self.deleted = True
    +
    +    def get_or_create(self, **kwargs):
    +        self.created.append(kwargs)
    +        return SimpleNamespace(**kwargs), True
    +
    +
    +class FakeZrodlo:
    +    def __init__(self, pbn_values, punktacja_manager=None):
    +        self.pbn_uid = FakePbnUid(pbn_values)
    +        self.punktacja_zrodla_set = punktacja_manager or FakePunktacjaManager()
    +        self.dyscyplina_zrodla_set = FakeDyscyplinaManager()
    +
    +
    +def test_import_points_creates_and_updates_points_from_min_year():
    +    existing = SimpleNamespace(
    +        rok="2020",
    +        punkty_kbn=40,
    +        save=MagicMock(),
    +    )
    +    punktacja = FakePunktacjaManager(existing=existing)
    +    zrodlo = FakeZrodlo(
    +        {
    +            "object": {
    +                "points": {
    +                    "2016": {"points": 10},
    +                    "2020": {"points": 70},
    +                    "2021": {"points": 100},
    +                    "2022": {},
    +                }
    +            }
    +        },
    +        punktacja_manager=punktacja,
    +    )
    +
    +    last_year = _import_points_for_zrodlo(zrodlo, min_rok=2017)
    +
    +    assert last_year == 2022
    +    assert existing.punkty_kbn == 70
    +    existing.save.assert_called_once_with()
    +    assert punktacja.created == [{"punkty_kbn": 100, "rok": "2021"}]
    +
    +
    +def test_import_disciplines_replaces_known_disciplines_only():
    +    zrodlo = FakeZrodlo(
    +        {
    +            "object": {
    +                "disciplines": [
    +                    {"code": "203"},
    +                    {"code": ""},
    +                    {"code": "999"},
    +                    "301",
    +                ]
    +            }
    +        }
    +    )
    +
    +    _import_disciplines_for_zrodlo(
    +        zrodlo,
    +        ostatni_rok=2021,
    +        dyscypliny_dict={"2.3": 10, "3.1": 20},
    +    )
    +
    +    assert zrodlo.dyscyplina_zrodla_set.deleted is True
    +    assert zrodlo.dyscyplina_zrodla_set.created == [
    +        {"dyscyplina_id": 10, "rok": 2021},
    +        {"dyscyplina_id": 20, "rok": 2021},
    +    ]
    +
    +
    +def test_import_disciplines_noops_without_year_or_payload():
    +    zrodlo = FakeZrodlo({"object": {"disciplines": [{"code": "203"}]}})
    +
    +    _import_disciplines_for_zrodlo(zrodlo, ostatni_rok=None, dyscypliny_dict={})
    +
    +    assert zrodlo.dyscyplina_zrodla_set.deleted is False
    +
    +    zrodlo_no_disciplines = FakeZrodlo({"object": {"disciplines": []}})
    +    _import_disciplines_for_zrodlo(
    +        zrodlo_no_disciplines, ostatni_rok=2021, dyscypliny_dict={}
    +    )
    +
    +    assert zrodlo_no_disciplines.dyscyplina_zrodla_set.deleted is False
    +
    +
    +@pytest.mark.django_db
    +def test_sync_single_source_reports_success_and_failure():
    +    zrodlo = FakeZrodlo({"object": {"points": {}}})
    +
    +    with patch(
    +        "pbn_import.utils.source_scoring_import.Zrodlo.objects"
    +    ) as zrodlo_objects:
    +        zrodlo_objects.select_related.return_value.get.return_value = zrodlo
    +        with patch(
    +            "pbn_import.utils.source_scoring_import._import_points_for_zrodlo",
    +            return_value=2021,
    +        ) as import_points:
    +            with patch(
    +                "pbn_import.utils.source_scoring_import."
    +                "_import_disciplines_for_zrodlo"
    +            ) as import_disciplines:
    +                assert _sync_single_source(123, 2017, {"2.3": 10}) == (
    +                    123,
    +                    True,
    +                    None,
    +                )
    +
    +    import_points.assert_called_once_with(zrodlo, 2017)
    +    import_disciplines.assert_called_once_with(zrodlo, 2021, {"2.3": 10})
    +
    +    with patch(
    +        "pbn_import.utils.source_scoring_import.Zrodlo.objects"
    +    ) as zrodlo_objects:
    +        zrodlo_objects.select_related.return_value.get.side_effect = RuntimeError(
    +            "db failed"
    +        )
    +
    +        assert _sync_single_source(456, 2017, {}) == (456, False, "db failed")
    +
    +
    +def test_run_returns_zero_when_no_sources(session):
    +    importer = SourceScoringImporter(session, client=None, max_workers=1)
    +
    +    with patch(
    +        "pbn_import.utils.source_scoring_import.Dyscyplina_Naukowa.objects"
    +    ) as disciplines:
    +        with patch(
    +            "pbn_import.utils.source_scoring_import.Zrodlo.objects"
    +        ) as sources:
    +            disciplines.all.return_value = []
    +            sources.exclude.return_value.values_list.return_value = []
    +
    +            result = importer.run()
    +
    +    assert result == {"synchronized": 0, "failed": 0}
    +    assert ImportLog.objects.filter(
    +        session=session,
    +        level="info",
    +        message="Brak źródeł z pbn_uid do synchronizacji",
    +    ).exists()
    +
    +
    +def test_run_synchronizes_sources_and_logs_first_errors(session):
    +    importer = SourceScoringImporter(
    +        session,
    +        client=None,
    +        max_workers=1,
    +        uczelnia=baker.make(Uczelnia),
    +    )
    +    subtask = MagicMock()
    +
    +    with patch(
    +        "pbn_import.utils.source_scoring_import.Dyscyplina_Naukowa.objects"
    +    ) as disciplines:
    +        with patch(
    +            "pbn_import.utils.source_scoring_import.Zrodlo.objects"
    +        ) as sources:
    +            with patch(
    +                "pbn_import.utils.source_scoring_import._sync_single_source",
    +                side_effect=[(1, True, None), (2, False, "bad source")],
    +            ) as sync_one:
    +                with patch(
    +                    "pbn_import.utils.source_scoring_import.tqdm",
    +                    side_effect=lambda iterable, **kwargs: iterable,
    +                ):
    +                    with patch.object(
    +                        importer, "create_subtask_progress", return_value=subtask
    +                    ):
    +                        disciplines.all.return_value = [
    +                            SimpleNamespace(kod="2.3", pk=10)
    +                        ]
    +                        sources.exclude.return_value.values_list.return_value = [1, 2]
    +
    +                        result = importer.run()
    +
    +    assert result == {"synchronized": 1, "failed": 1}
    +    assert sync_one.call_args_list == [
    +        call(1, 2017, {"2.3": 10}),
    +        call(2, 2017, {"2.3": 10}),
    +    ]
    +    assert subtask.update.call_count == 2
    +    assert ImportLog.objects.filter(
    +        session=session, level="warning", message__contains="bad source"
    +    ).exists()
    +    assert ImportLog.objects.filter(
    +        session=session,
    +        level="success",
    +        message="Zsynchronizowano 1 źródeł, 1 błędów",
    +    ).exists()
    diff --git a/src/pbn_import/tests/test_templatetags.py b/src/pbn_import/tests/test_templatetags.py
    new file mode 100644
    index 000000000..8897bae04
    --- /dev/null
    +++ b/src/pbn_import/tests/test_templatetags.py
    @@ -0,0 +1,102 @@
    +"""Tests for PBN import template tags."""
    +
    +from types import SimpleNamespace
    +from unittest.mock import patch
    +
    +import pytest
    +from django.contrib.contenttypes.models import ContentType
    +
    +from bpp.models import Uczelnia
    +from pbn_import.templatetags.pbn_import_tags import (
    +    bpp_admin_url,
    +    format_json,
    +    is_error_log,
    +    is_json,
    +    pbn_publication_url,
    +    truncate_message,
    +)
    +
    +
    +def test_pbn_publication_url_uses_public_pbn_root_without_request():
    +    url = pbn_publication_url({}, "pub-1")
    +
    +    assert url == "https://pbn.nauka.gov.pl/core/#/publication/view/pub-1/current"
    +
    +
    +@pytest.mark.django_db
    +def test_pbn_publication_url_uses_request_uczelnia_root():
    +    request = object()
    +    uczelnia = Uczelnia(pbn_api_root="https://tenant.pbn.example/")
    +
    +    with patch.object(Uczelnia.objects, "get_for_request", return_value=uczelnia):
    +        url = pbn_publication_url({"request": request}, "pub-1")
    +
    +    assert url == "https://tenant.pbn.example/core/#/publication/view/pub-1/current"
    +
    +
    +def test_pbn_publication_url_returns_empty_for_missing_id():
    +    assert pbn_publication_url({}, "") == ""
    +    assert pbn_publication_url({}, None) == ""
    +
    +
    +@pytest.mark.django_db
    +def test_bpp_admin_url_builds_change_url(django_user_model):
    +    content_type = ContentType.objects.get_for_model(django_user_model)
    +    user = django_user_model.objects.create_user(username="admin-url-user")
    +
    +    assert bpp_admin_url(content_type, user.pk) == f"/admin/bpp/bppuser/{user.pk}/change/"
    +
    +
    +def test_bpp_admin_url_returns_empty_for_missing_parts():
    +    assert bpp_admin_url(None, 1) == ""
    +    assert bpp_admin_url(SimpleNamespace(app_label="auth", model="user"), None) == ""
    +
    +
    +def test_format_json_pretty_prints_and_escapes_json_values():
    +    formatted = format_json({"key": "
    +"""
     
     
     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"""\n"
    diff --git a/src/django_bpp/version.py b/src/django_bpp/version.py
    index cf8c502e5..c9e8795fd 100644
    --- a/src/django_bpp/version.py
    +++ b/src/django_bpp/version.py
    @@ -1,4 +1,4 @@
    -VERSION = "202606.1388"
    +VERSION = "202606.1389"
     
     if __name__ == "__main__":
         import sys
    
    From 7c55dd37e994170a6a4192f573391786fccd42e9 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Micha=C5=82=20Pasternak?= 
    Date: Wed, 17 Jun 2026 12:53:30 +0200
    Subject: [PATCH 237/247] ## bpp 202606.1389 (2026-06-17)
    
    No significant changes.
    ---
     HISTORY.md | 5 +++++
     uv.lock    | 2 +-
     2 files changed, 6 insertions(+), 1 deletion(-)
    
    diff --git a/HISTORY.md b/HISTORY.md
    index b591bf2af..01f3707c0 100644
    --- a/HISTORY.md
    +++ b/HISTORY.md
    @@ -2,6 +2,11 @@
     
     
     
    +## bpp 202606.1389 (2026-06-17)
    +
    +No significant changes.
    +
    +
     ## bpp 202606.1388 (2026-06-13)
     
     No significant changes.
    diff --git a/uv.lock b/uv.lock
    index 162fee9ca..d7dbc2c80 100644
    --- a/uv.lock
    +++ b/uv.lock
    @@ -346,7 +346,7 @@ wheels = [
     
     [[package]]
     name = "bpp-iplweb"
    -version = "202606.1388"
    +version = "202606.1389"
     source = { editable = "." }
     dependencies = [
         { name = "arrow", marker = "platform_python_implementation != 'PyPy'" },
    
    From b60b28b0e2c608725fa3e82b5b2544fab0a557c9 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Micha=C5=82=20Pasternak?= 
    Date: Wed, 17 Jun 2026 13:18:46 +0200
    Subject: [PATCH 238/247] =?UTF-8?q?Bezpiecze=C5=84stwo=20zale=C5=BCno?=
     =?UTF-8?q?=C5=9Bci:=20bumpy=20CVE=20+=20parytet=20skanera=20+=20sprz?=
     =?UTF-8?q?=C4=85tanie=20ignore?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Bumpy łatające podatności zgłoszone przez pip-audit, z egzekwowaniem
    minimów przez constraint-dependencies (tranzytywne, nie direct deps):
    - bleach  6.2.0 -> 6.4.0  (GHSA-8rfp-98v4-mmr6, GHSA-gj48-438w-jh9v)
    - daphne  4.2.1 -> 4.2.2  (PYSEC-2026-213, PYSEC-2026-214)
    - pypdf   6.12.0 -> 6.13.2 (CVE-2026-48735/49460/49461/54530/54531)
    
    bin/scan-deps.sh: dodaje pip-audit jako 5/5 etap, w parytecie z gate-em
    release-u w dependency-audit.yml (lokalny guard testował dotąd tylko
    OSV/Grype/Trivy, które w CI są report-only). Leci przez uvx, bez
    instalacji; --no-pipaudit żeby pominąć.
    
    Usuwa --ignore-vuln CVE-2026-42304 z workflow i skryptu: twisted jest
    teraz na 26.4.0 stable (fix był w 26.4.0rc2), pip-audit już go nie
    zgłasza, a martwy ignore tylko maskowałby ewentualny regres twisted.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    ---
     .github/workflows/dependency-audit.yml        | 18 ++---
     bin/scan-deps.sh                              | 81 ++++++++++++++++---
     pyproject.toml                                | 18 +++++
     ...ecurity-deps-bleach-daphne-pypdf.bugfix.md |  9 +++
     uv.lock                                       | 25 +++---
     5 files changed, 121 insertions(+), 30 deletions(-)
     create mode 100644 src/bpp/newsfragments/+bump-security-deps-bleach-daphne-pypdf.bugfix.md
    
    diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml
    index 2e98820a9..6c9017ce8 100644
    --- a/.github/workflows/dependency-audit.yml
    +++ b/.github/workflows/dependency-audit.yml
    @@ -19,7 +19,8 @@
     #   pip-audit nie ma natywnego severity filter, ale fix-availability juz
     #   jest dobrym proxy: jezeli upstream wypuscil fix, znaczy ze warto bumpnac.
     # - Vuln bez fixa: warning w job summary, nie blokuje (nic nie da sie zrobic).
    -# - Whitelist znanych non-impact CVE przez --ignore-vuln (patrz job ponizej).
    +# - Whitelist non-impact CVE: opcjonalnie przez --ignore-vuln w jobie
    +#   ponizej (obecnie pusta — patrz komentarz przy kroku pip-audit).
     #
     # Defense-in-depth: drugi job (multi-scanner) odpala OSV-Scanner, Grype
     # i Trivy na SBOM-ie wygenerowanym z uv.lock. Wszystkie report-only -
    @@ -66,23 +67,16 @@ jobs:
               uv export --no-dev --format requirements-txt --no-hashes --quiet \
                 -o /tmp/audit/requirements.txt
     
    -      # Whitelist znanych non-impact CVE.
    -      # ---
    -      # CVE-2026-42304 / GHSA-grgv-6hw6-v9g4 (twisted < 26.4.0rc2):
    -      #   DoS w twisted.names.dns.Name.decode przez glebokie DNS compression
    -      #   pointer chains. BPP NIE uruchamia DNS-servera Twisted (twisted jest
    -      #   transitive zaleznoscia daphne + autobahn dla WebSocket/Channels,
    -      #   bez modulu twisted.names). Fix dostepny tylko w 26.4.0rc2 (release
    -      #   candidate) - nie wprowadzamy RC do produkcji do czasu stable release.
    -      #   Re-evaluate przy bumpie twisted do 26.x stable.
    +      # Brak whitelisty CVE: gdy pojawi sie znany non-impact CVE bez
    +      # dostepnego fixa, dodaj tu --ignore-vuln  z komentarzem
    +      # uzasadniajacym (i zsynchronizuj z bin/scan-deps.sh).
           - name: Run pip-audit (gate on fixable)
             run: |
               uvx --from pip-audit pip-audit \
                 --requirement /tmp/audit/requirements.txt \
                 --disable-pip \
                 --no-deps \
    -            --format columns \
    -            --ignore-vuln CVE-2026-42304
    +            --format columns
     
           - name: Run pip-audit (JSON full report - non-blocking)
             if: always()
    diff --git a/bin/scan-deps.sh b/bin/scan-deps.sh
    index 26bb71fd6..3f1d85d22 100755
    --- a/bin/scan-deps.sh
    +++ b/bin/scan-deps.sh
    @@ -30,6 +30,7 @@ SEVERITY_FILTER="HIGH,CRITICAL"
     RUN_OSV=true
     RUN_GRYPE=true
     RUN_TRIVY=true
    +RUN_PIPAUDIT=true
     GATE=true
     KEEP_TMP=false
     TOTAL_FINDINGS=0
    @@ -47,6 +48,7 @@ ${YELLOW}Opcje:${NC}
         --no-osv         Pomiń OSV-Scanner
         --no-grype       Pomiń Grype
         --no-trivy       Pomiń Trivy
    +    --no-pipaudit    Pomiń pip-audit (PyPA — gate release-u w CI!)
         --no-gate        Nie failuj na findings (tylko raport, exit 0)
         --keep           Nie kasuj ${WORK_DIR} po zakończeniu
         -h, --help       Pokaż tę pomoc
    @@ -62,19 +64,21 @@ ${YELLOW}Wymagane narzędzia:${NC}
         osv-scanner         brew install osv-scanner
         grype               brew install grype
         trivy               brew install trivy
    +    pip-audit           bez instalacji — leci przez uvx (jak cyclonedx-py)
     
     ${YELLOW}Przykłady:${NC}
         $SCRIPT_NAME                    # Prod-only, HIGH/CRITICAL, gate
         $SCRIPT_NAME --full             # Cały venv włącznie z dev extras
         $SCRIPT_NAME --all-severity     # Także LOW/MEDIUM
         $SCRIPT_NAME --no-gate          # Raport bez blokowania exit-em
    -    $SCRIPT_NAME --no-trivy --no-grype  # Tylko OSV-Scanner
    +    $SCRIPT_NAME --no-trivy --no-grype  # Tylko OSV-Scanner + pip-audit
     
     ${YELLOW}Wynik:${NC}
    -    SBOM:  ${WORK_DIR}/sbom.json
    -    OSV:   ${WORK_DIR}/osv-report.json
    -    Grype: ${WORK_DIR}/grype-report.json
    -    Trivy: ${WORK_DIR}/trivy-report.json
    +    SBOM:      ${WORK_DIR}/sbom.json
    +    OSV:       ${WORK_DIR}/osv-report.json
    +    Grype:     ${WORK_DIR}/grype-report.json
    +    Trivy:     ${WORK_DIR}/trivy-report.json
    +    pip-audit: ${WORK_DIR}/pip-audit-report.json
     EOF
     }
     
    @@ -85,6 +89,7 @@ while [[ $# -gt 0 ]]; do
             --no-osv)        RUN_OSV=false; shift ;;
             --no-grype)      RUN_GRYPE=false; shift ;;
             --no-trivy)      RUN_TRIVY=false; shift ;;
    +        --no-pipaudit)   RUN_PIPAUDIT=false; shift ;;
             --no-gate)       GATE=false; shift ;;
             --keep)          KEEP_TMP=true; shift ;;
             -h|--help)       usage; exit 0 ;;
    @@ -116,7 +121,7 @@ cd "$REPO_ROOT"
     mkdir -p "$WORK_DIR"
     trap '[[ "$KEEP_TMP" == false ]] || echo -e "${BLUE}Pliki zostają w: ${WORK_DIR}${NC}"' EXIT
     
    -echo -e "${BOLD}${BLUE}=== 1/4 Generuję SBOM (${MODE}) ===${NC}"
    +echo -e "${BOLD}${BLUE}=== 1/5 Generuję SBOM (${MODE}) ===${NC}"
     
     if [[ "$MODE" == "prod" ]]; then
         REQ_PATH="${WORK_DIR}/requirements.txt"
    @@ -143,7 +148,7 @@ echo -e "${GREEN}✓ SBOM: $SBOM_PATH (${PKG_COUNT} pakietów)${NC}"
     echo
     
     if $RUN_OSV; then
    -    echo -e "${BOLD}${BLUE}=== 2/4 OSV-Scanner (Google) ===${NC}"
    +    echo -e "${BOLD}${BLUE}=== 2/5 OSV-Scanner (Google) ===${NC}"
         OSV_REPORT="${WORK_DIR}/osv-report.json"
         set +e
         osv-scanner scan source --sbom="$SBOM_PATH" \
    @@ -175,7 +180,7 @@ if $RUN_OSV; then
     fi
     
     if $RUN_GRYPE; then
    -    echo -e "${BOLD}${BLUE}=== 3/4 Grype (Anchore) ===${NC}"
    +    echo -e "${BOLD}${BLUE}=== 3/5 Grype (Anchore) ===${NC}"
         GRYPE_REPORT="${WORK_DIR}/grype-report.json"
     
         GRYPE_ARGS=("sbom:${SBOM_PATH}" -o json --file "$GRYPE_REPORT")
    @@ -226,7 +231,7 @@ if $RUN_GRYPE; then
     fi
     
     if $RUN_TRIVY; then
    -    echo -e "${BOLD}${BLUE}=== 4/4 Trivy (Aqua Security) ===${NC}"
    +    echo -e "${BOLD}${BLUE}=== 4/5 Trivy (Aqua Security) ===${NC}"
         TRIVY_REPORT="${WORK_DIR}/trivy-report.json"
     
         TRIVY_ARGS=(sbom "$SBOM_PATH" --format json --output "$TRIVY_REPORT" --quiet)
    @@ -262,6 +267,64 @@ if $RUN_TRIVY; then
         echo
     fi
     
    +if $RUN_PIPAUDIT; then
    +    echo -e "${BOLD}${BLUE}=== 5/5 pip-audit (PyPA) ===${NC}"
    +    PIPAUDIT_REPORT="${WORK_DIR}/pip-audit-report.json"
    +
    +    # pip-audit czyta requirements.txt (NIE SBOM). To ten sam skaner co
    +    # gate release-u w .github/workflows/dependency-audit.yml — trzymamy
    +    # lokalny pre-release guard w parytecie z CI. SEVERITY_FILTER nie ma
    +    # tu zastosowania: pip-audit nie ma natywnego filtra severity, gate
    +    # opiera się na dostępności fixa (jak w workflow).
    +    if [[ "$MODE" == "prod" ]]; then
    +        PIPAUDIT_REQ="$REQ_PATH"
    +    else
    +        # Tryb full skanuje venv przez cyclonedx environment i nie tworzy
    +        # requirements.txt — eksportujemy wszystkie extras dla pip-audit.
    +        PIPAUDIT_REQ="${WORK_DIR}/requirements-full.txt"
    +        uv export --all-extras --format requirements-txt --no-hashes \
    +            --quiet -o "$PIPAUDIT_REQ"
    +    fi
    +
    +    # Brak whitelisty CVE. Gdyby trzeba bylo wyciszyc znany non-impact
    +    # CVE bez fixa, dodaj PIPAUDIT_IGNORE=(--ignore-vuln ) z komentarzem
    +    # i zsynchronizuj z .github/workflows/dependency-audit.yml.
    +    set +e
    +    uvx --quiet --from pip-audit pip-audit \
    +        --requirement "$PIPAUDIT_REQ" \
    +        --disable-pip \
    +        --no-deps \
    +        --format json \
    +        --output "$PIPAUDIT_REPORT" 2>/dev/null
    +    pipaudit_exit=$?
    +    set -e
    +
    +    if [[ -s "$PIPAUDIT_REPORT" ]]; then
    +        PIPAUDIT_VULNS=$(jq '[.dependencies[]?.vulns[]?] | length' \
    +            "$PIPAUDIT_REPORT" 2>/dev/null || echo 0)
    +
    +        if [[ "$PIPAUDIT_VULNS" -gt 0 ]]; then
    +            TOTAL_FINDINGS=$((TOTAL_FINDINGS + PIPAUDIT_VULNS))
    +            echo -e "${YELLOW}⚠ Znaleziono ${PIPAUDIT_VULNS} CVE${NC}"
    +            jq -r '
    +                .dependencies[]? | . as $p |
    +                .vulns[]? |
    +                "\($p.name)@\($p.version)\t\(.id)\t\(
    +                    if (.fix_versions | length) > 0
    +                    then (.fix_versions | join(", "))
    +                    else "no-fix" end
    +                )"
    +            ' "$PIPAUDIT_REPORT" | sort -u | column -t -s $'\t'
    +        else
    +            echo -e "${GREEN}✓ Brak znanych CVE${NC}"
    +        fi
    +        echo -e "  raport: ${PIPAUDIT_REPORT}"
    +    else
    +        echo -e "${RED}✗ pip-audit nie wygenerował raportu (exit ${pipaudit_exit})${NC}"
    +    fi
    +    echo
    +fi
    +
     echo -e "${BOLD}${BLUE}=== Podsumowanie ===${NC}"
     if [[ "$TOTAL_FINDINGS" -gt 0 ]]; then
         echo -e "${YELLOW}⚠ Łącznie ${TOTAL_FINDINGS} findings (suma ze skanerów; może zawierać duplikaty tej samej CVE)${NC}"
    diff --git a/pyproject.toml b/pyproject.toml
    index 21d0b9e9b..37ab047e9 100644
    --- a/pyproject.toml
    +++ b/pyproject.toml
    @@ -228,6 +228,24 @@ push = false
     [tool.uv]
     environments = ["python_version >= '3.10' and python_version < '3.15' and platform_python_implementation != 'PyPy'"]
     
    +# Security floors dla zaleznosci TRANZYTYWNYCH (nie deklarujemy ich jako
    +# direct deps - przychodza przez inne pakiety). constraint-dependencies
    +# nie DODAJE pakietu do grafu, tylko ogranicza wersje JESLI juz w nim jest.
    +# Egzekwuje minimum bezpieczenstwa, zeby przyszly `uv lock` nie cofnal sie
    +# ponizej zalatanej wersji. Audyt CVE: czerwiec 2026 (pip-audit / uv-secure).
    +#   bleach  >=6.4.0  (via django-flexible-reports) - GHSA-8rfp-98v4-mmr6,
    +#                     GHSA-gj48-438w-jh9v
    +#   daphne  >=4.2.2  (via channels[daphne])         - PYSEC-2026-213,
    +#                     PYSEC-2026-214
    +#   pypdf   >=6.13.0 (via xhtml2pdf)                 - CVE-2026-48735,
    +#                     CVE-2026-49460, CVE-2026-49461, CVE-2026-54530,
    +#                     CVE-2026-54531
    +constraint-dependencies = [
    +    "bleach>=6.4.0",
    +    "daphne>=4.2.2",
    +    "pypdf>=6.13.0",
    +]
    +
     # Polityka: KAZDA NOWA zewnetrzna zaleznosc powinna miec prebuilt wheel dla
     # naszej macierzy (Linux x86_64 + macOS arm64, Python 3.10-3.14). Sdist
     # wykonuje `setup.py` podczas instalacji - klasyczny wektor supply-chain
    diff --git a/src/bpp/newsfragments/+bump-security-deps-bleach-daphne-pypdf.bugfix.md b/src/bpp/newsfragments/+bump-security-deps-bleach-daphne-pypdf.bugfix.md
    new file mode 100644
    index 000000000..5d07f7e39
    --- /dev/null
    +++ b/src/bpp/newsfragments/+bump-security-deps-bleach-daphne-pypdf.bugfix.md
    @@ -0,0 +1,9 @@
    +Zaktualizowano trzy zależności tranzytywne do wersji łatających
    +podatności zgłoszone w audycie ``pip-audit``: ``bleach`` do 6.4.0
    +(GHSA-8rfp-98v4-mmr6, GHSA-gj48-438w-jh9v), ``daphne`` do 4.2.2
    +(PYSEC-2026-213, PYSEC-2026-214) oraz ``pypdf`` do 6.13.2
    +(CVE-2026-48735, CVE-2026-49460, CVE-2026-49461, CVE-2026-54530,
    +CVE-2026-54531). Minimalne wersje są od teraz egzekwowane przez
    +``constraint-dependencies`` w ``pyproject.toml``, dzięki czemu
    +przyszłe przeliczenie ``uv.lock`` nie cofnie się poniżej
    +załatanego wydania.
    diff --git a/uv.lock b/uv.lock
    index d7dbc2c80..a71bac617 100644
    --- a/uv.lock
    +++ b/uv.lock
    @@ -19,6 +19,13 @@ supported-markers = [
         "platform_python_implementation != 'PyPy'",
     ]
     
    +[manifest]
    +constraints = [
    +    { name = "bleach", specifier = ">=6.4.0" },
    +    { name = "daphne", specifier = ">=4.2.2" },
    +    { name = "pypdf", specifier = ">=6.13.0" },
    +]
    +
     [[package]]
     name = "absl-py"
     version = "2.3.1"
    @@ -306,14 +313,14 @@ wheels = [
     
     [[package]]
     name = "bleach"
    -version = "6.2.0"
    +version = "6.4.0"
     source = { registry = "https://pypi.org/simple" }
     dependencies = [
         { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" },
     ]
    -sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406, upload-time = "2024-10-29T18:30:38.186Z" },
    +    { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" },
     ]
     
     [[package]]
    @@ -1363,16 +1370,16 @@ wheels = [
     
     [[package]]
     name = "daphne"
    -version = "4.2.1"
    +version = "4.2.2"
     source = { registry = "https://pypi.org/simple" }
     dependencies = [
         { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" },
         { name = "autobahn", marker = "platform_python_implementation != 'PyPy'" },
         { name = "twisted", extra = ["tls"], marker = "platform_python_implementation != 'PyPy'" },
     ]
    -sdist = { url = "https://files.pythonhosted.org/packages/cd/9d/322b605fdc03b963cf2d33943321c8f4405e8d82e698bf49d1eed1ca40c4/daphne-4.2.1.tar.gz", hash = "sha256:5f898e700a1fda7addf1541d7c328606415e96a7bd768405f0463c312fcb31b3", size = 45600, upload-time = "2025-07-02T12:57:04.935Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/64/d3/65ff32c01cc64d44441b038dbb7cfb0c6a5507a1c937b3d41bd99af7bdc4/daphne-4.2.2.tar.gz", hash = "sha256:6c3527d4ce32630ae054dfb0ef5578e9a35d2f39f0ebcd02ef4f9129a121ce8d", size = 47601, upload-time = "2026-06-03T10:53:13.31Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/01/34/6171ab34715ed210bcd6c2b38839cc792993cff4fe2493f50bc92b0086a0/daphne-4.2.1-py3-none-any.whl", hash = "sha256:881e96b387b95b35ad85acd855f229d7f5b79073d6649089c8a33f661885e055", size = 29015, upload-time = "2025-07-02T12:57:03.793Z" },
    +    { url = "https://files.pythonhosted.org/packages/78/ab/85534d9cbca09f3c10f58fc2093659062280ed5703707c0b41dbca8ec297/daphne-4.2.2-py3-none-any.whl", hash = "sha256:466ba8a7c31c5b758953095b451dbad9dc23e5783c68a3716e5fc7aa5f26d168", size = 29466, upload-time = "2026-06-03T10:53:11.841Z" },
     ]
     
     [[package]]
    @@ -4383,11 +4390,11 @@ wheels = [
     
     [[package]]
     name = "pypdf"
    -version = "6.12.0"
    +version = "6.13.2"
     source = { registry = "https://pypi.org/simple" }
    -sdist = { url = "https://files.pythonhosted.org/packages/a2/ba/f82d1cb35b04041b5f796d4eedbaecafcbf99e83b7a2542b44a936959dd7/pypdf-6.12.0.tar.gz", hash = "sha256:061f135db8934503ed301c2d4cfaccb12f0a2ef1db11c5d0768a72a5ab4097d8", size = 6466074, upload-time = "2026-05-21T09:21:42.621Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/99/0a/48fe05c6bb3aa4bb4d2a4079a383d33c0dfec1edf613a642f07d8b8b5c2e/pypdf-6.13.2.tar.gz", hash = "sha256:5a96a17dbdfbf9c2ab24c0a13fa0aba182be22ba6f283098712c16fc242f509f", size = 6479250, upload-time = "2026-06-10T16:42:34.5Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/f4/fa/3597fb3fb28f40bf8291fdddbc4dcd51ce52fccaf1cbfca10ee9db09c69a/pypdf-6.12.0-py3-none-any.whl", hash = "sha256:a8e104ab950e655d0bcf5fa5e71317c06474bc707987335da44a210f73a8883b", size = 343457, upload-time = "2026-05-21T09:21:40.852Z" },
    +    { url = "https://files.pythonhosted.org/packages/cb/17/378943705992f74e451a06de3401ce68e3213763c81e44d0614559c45599/pypdf-6.13.2-py3-none-any.whl", hash = "sha256:6eeb9e57693f29d41bd01255d02660cbbb41fd7fc818a982677389a35e4f2083", size = 346555, upload-time = "2026-06-10T16:42:32.37Z" },
     ]
     
     [[package]]
    
    From 30d4db9c888a2e846f553bf2809a8280d4cfa922 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Micha=C5=82=20Pasternak?= 
    Date: Wed, 17 Jun 2026 13:25:00 +0200
    Subject: [PATCH 239/247] bump version v202606.1389 -> v202606.1390
    
    ---
     Makefile                  | 2 +-
     package.json              | 2 +-
     pyproject.toml            | 6 +++---
     src/django_bpp/version.py | 2 +-
     4 files changed, 6 insertions(+), 6 deletions(-)
    
    diff --git a/Makefile b/Makefile
    index fbffae1b1..77faac435 100644
    --- a/Makefile
    +++ b/Makefile
    @@ -621,7 +621,7 @@ loc: clean ## Pokaż statystyki liczby linii (pygount)
     	pygount -N ... -F "...,staticroot,migrations,fixtures" src --format=summary
     
     
    -DOCKER_VERSION=202606.1389
    +DOCKER_VERSION=202606.1390
     
     # Cache configuration for docker buildx bake
     # - local: use local cache (default for local builds)
    diff --git a/package.json b/package.json
    index 1de1accbf..389caae42 100644
    --- a/package.json
    +++ b/package.json
    @@ -1,6 +1,6 @@
     {
         "name": "bpp-iplweb",
    -    "version": "v202606.1389",
    +    "version": "v202606.1390",
         "license": "MIT",
         "devDependencies": {
             "esbuild": "^0.28.1",
    diff --git a/pyproject.toml b/pyproject.toml
    index 37ab047e9..446dc890c 100644
    --- a/pyproject.toml
    +++ b/pyproject.toml
    @@ -1,6 +1,6 @@
     [project]
     name = "bpp_iplweb"
    -version = "202606.1389"
    +version = "202606.1390"
     description = ""
     authors = [
         { name = "Michał Pasternak", email = "michal.dtz@gmail.com" }
    @@ -218,7 +218,7 @@ include = [
     ]
     
     [tool.bumpver]
    -current_version = "v202606.1389"
    +current_version = "v202606.1390"
     version_pattern = "vYYYY0M.BUILD[-TAGNUM]"
     commit_message = "bump version {old_version} -> {new_version}"
     commit = true
    @@ -516,7 +516,7 @@ dev = [
     
     [tool.towncrier]
     package = "bpp"
    -version = "202606.1389"
    +version = "202606.1390"
     package_dir = "src"
     filename = "HISTORY.md"
     start_string = "\n"
    diff --git a/src/django_bpp/version.py b/src/django_bpp/version.py
    index c9e8795fd..eb9a3e1f6 100644
    --- a/src/django_bpp/version.py
    +++ b/src/django_bpp/version.py
    @@ -1,4 +1,4 @@
    -VERSION = "202606.1389"
    +VERSION = "202606.1390"
     
     if __name__ == "__main__":
         import sys
    
    From e16474fac0e1bd8a7137a2b7411ba7c935e20031 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Micha=C5=82=20Pasternak?= 
    Date: Wed, 17 Jun 2026 13:25:05 +0200
    Subject: [PATCH 240/247] ## bpp 202606.1390 (2026-06-17)
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    ### Naprawione
    
    - Zaktualizowano trzy zależności tranzytywne do wersji łatających
      podatności zgłoszone w audycie ``pip-audit``: ``bleach`` do 6.4.0
      (GHSA-8rfp-98v4-mmr6, GHSA-gj48-438w-jh9v), ``daphne`` do 4.2.2
      (PYSEC-2026-213, PYSEC-2026-214) oraz ``pypdf`` do 6.13.2
      (CVE-2026-48735, CVE-2026-49460, CVE-2026-49461, CVE-2026-54530,
      CVE-2026-54531). Minimalne wersje są od teraz egzekwowane przez
      ``constraint-dependencies`` w ``pyproject.toml``, dzięki czemu
      przyszłe przeliczenie ``uv.lock`` nie cofnie się poniżej
      załatanego wydania.
    ---
     HISTORY.md                                        | 15 +++++++++++++++
     ...mp-security-deps-bleach-daphne-pypdf.bugfix.md |  9 ---------
     uv.lock                                           |  2 +-
     3 files changed, 16 insertions(+), 10 deletions(-)
     delete mode 100644 src/bpp/newsfragments/+bump-security-deps-bleach-daphne-pypdf.bugfix.md
    
    diff --git a/HISTORY.md b/HISTORY.md
    index 01f3707c0..5c751de77 100644
    --- a/HISTORY.md
    +++ b/HISTORY.md
    @@ -2,6 +2,21 @@
     
     
     
    +## bpp 202606.1390 (2026-06-17)
    +
    +### Naprawione
    +
    +- Zaktualizowano trzy zależności tranzytywne do wersji łatających
    +  podatności zgłoszone w audycie ``pip-audit``: ``bleach`` do 6.4.0
    +  (GHSA-8rfp-98v4-mmr6, GHSA-gj48-438w-jh9v), ``daphne`` do 4.2.2
    +  (PYSEC-2026-213, PYSEC-2026-214) oraz ``pypdf`` do 6.13.2
    +  (CVE-2026-48735, CVE-2026-49460, CVE-2026-49461, CVE-2026-54530,
    +  CVE-2026-54531). Minimalne wersje są od teraz egzekwowane przez
    +  ``constraint-dependencies`` w ``pyproject.toml``, dzięki czemu
    +  przyszłe przeliczenie ``uv.lock`` nie cofnie się poniżej
    +  załatanego wydania.
    +
    +
     ## bpp 202606.1389 (2026-06-17)
     
     No significant changes.
    diff --git a/src/bpp/newsfragments/+bump-security-deps-bleach-daphne-pypdf.bugfix.md b/src/bpp/newsfragments/+bump-security-deps-bleach-daphne-pypdf.bugfix.md
    deleted file mode 100644
    index 5d07f7e39..000000000
    --- a/src/bpp/newsfragments/+bump-security-deps-bleach-daphne-pypdf.bugfix.md
    +++ /dev/null
    @@ -1,9 +0,0 @@
    -Zaktualizowano trzy zależności tranzytywne do wersji łatających
    -podatności zgłoszone w audycie ``pip-audit``: ``bleach`` do 6.4.0
    -(GHSA-8rfp-98v4-mmr6, GHSA-gj48-438w-jh9v), ``daphne`` do 4.2.2
    -(PYSEC-2026-213, PYSEC-2026-214) oraz ``pypdf`` do 6.13.2
    -(CVE-2026-48735, CVE-2026-49460, CVE-2026-49461, CVE-2026-54530,
    -CVE-2026-54531). Minimalne wersje są od teraz egzekwowane przez
    -``constraint-dependencies`` w ``pyproject.toml``, dzięki czemu
    -przyszłe przeliczenie ``uv.lock`` nie cofnie się poniżej
    -załatanego wydania.
    diff --git a/uv.lock b/uv.lock
    index a71bac617..ce661e3f4 100644
    --- a/uv.lock
    +++ b/uv.lock
    @@ -353,7 +353,7 @@ wheels = [
     
     [[package]]
     name = "bpp-iplweb"
    -version = "202606.1389"
    +version = "202606.1390"
     source = { editable = "." }
     dependencies = [
         { name = "arrow", marker = "platform_python_implementation != 'PyPy'" },
    
    From 964d9a2fa79ece91e4e863b228a852a236cec964 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Micha=C5=82=20Pasternak?= 
    Date: Thu, 18 Jun 2026 13:10:35 +0200
    Subject: [PATCH 241/247] =?UTF-8?q?Czyszczenie=20importu=20PBN:=20usuni?=
     =?UTF-8?q?=C4=99cie=20martwego=20WebSocketa,=20perf=20log=C3=B3w,=20napra?=
     =?UTF-8?q?wy=20poprawno=C5=9Bci=20(#360)?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    * docs(pbn_import): przegląd kodu importu PBN + plan czyszczenia
    
    Read-only review src/pbn_import/ i powiązanego kodu (pbn_integrator,
    pbn_api, import_common). Plan implementacji items 1/4/5/6; item 2
    pominięty (decyzja usera), item 3 zablokowany (legacy komenda
    pbn_integrator NIE jest duplikatem — ma unikalne sync/clear/ORCID/
    DOI/ISBN).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    
    * refactor(pbn_import): usuń martwą warstwę WebSocket (item 1)
    
    Koperta import_update nie pasowała do handlerów konsumenta ani do
    switch(data.type) po stronie klienta — każda wiadomość WS była
    porzucana. Cały realtime realizuje polling HTMX. Usuwa consumers,
    routing, helpery send_websocket_update, wpięcie ASGI i martwy skrypt
    klienta.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    
    * perf(pbn_import): ogranicz rozmiar querysetów logów w widokach (item 4)
    
    Endpointy HTMX re-fetchowane co 5 s ładowały WSZYSTKIE wiersze ImportLog
    sesji. Wprowadza MAX_LOGS_DISPLAY=200 i slice w 4 miejscach; pełny log
    pozostaje pobieralny przez ImportLogDownloadView.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    
    * fix(pbn_import): anulowanie HTMX zwraca 200 zamiast 500 (item 5)
    
    components/progress.html odwoływał się do nieistniejącej trasy
    pbn_import:stats → NoReverseMatch przy anulowaniu importu. CancelImportView
    renderuje teraz progress_compact.html (używany wszędzie indziej);
    martwy progress.html usunięty.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    
    * chore(pbn_import): usuń no-op SavePresetView + trasę (item 5)
    
    Widok był @csrf_exempt, robił json.loads(request.body) bez zabezpieczenia
    (500 na złym body), niczego nie zapisywał i nie był referencjonowany.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    
    * fix(pbn_import): zawęź zapisy progress_data, unikaj wyścigu nadpisania (item 5)
    
    Pełne session.save() po mutacji jednego klucza JSONField mogło nadpisać
    równoległy zapis throttlowanego TqdmSessionProgress (stale in-memory).
    refresh_from_db(fields=[progress_data]) przed mutacją + update_fields przy
    zapisie. Bez migracji.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    
    * refactor(pbn_import): przenieś sondę autoryzacji PBN z __init__ do run()
    
    ImportManager.__init__ wołał _check_pbn_authorization(), które robi
    wywołanie sieciowe client.get_languages() — samo skonstruowanie obiektu
    uderzało w API PBN (efekt uboczny w konstruktorze, zły dla testowalności
    i zaskakujący). Sonda trafia teraz na początek run(), odpalana dokładnie
    raz przez sentinel (pbn_authorized=False & pbn_error_message is None =
    "jeszcze nie sondowano"). Publiczne API bez zmian.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    
    * docs(pbn_import): oznacz CODEBASE_MAP jako historyczny (item 6)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    
    * docs(pbn_import): newsfragments dla czyszczenia importu PBN
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 (1M context) 
    ---
     .../przeglad-pbn-import-2026-06-12.md         | 155 ++++++
     .../plans/2026-06-12-pbn-import-cleanup.md    | 463 ++++++++++++++++++
     .../+pbn-import-logi-perf.feature.rst         |   4 +
     .../+pbn-import-naprawy.bugfix.rst            |   6 +
     .../+pbn-import-usun-websocket.removal.rst    |   5 +
     src/django_bpp/asgi.py                        |  10 +-
     src/pbn_import/CODEBASE_MAP.md                |   7 +
     src/pbn_import/consumers.py                   | 197 --------
     src/pbn_import/routing.py                     |  12 -
     src/pbn_import/tasks.py                       |  60 ---
     .../pbn_import/components/progress.html       | 386 ---------------
     .../templates/pbn_import/dashboard.html       |  53 --
     src/pbn_import/tests/test_consumers.py        | 225 ---------
     src/pbn_import/tests/test_import_manager.py   |  19 +
     src/pbn_import/tests/test_import_step.py      |  50 ++
     src/pbn_import/tests/test_routing.py          |  13 -
     src/pbn_import/tests/test_tasks.py            | 179 ++-----
     src/pbn_import/tests/test_views_dashboard.py  |  24 +-
     src/pbn_import/tests/test_views_session.py    |  35 ++
     src/pbn_import/urls.py                        |   1 -
     src/pbn_import/utils/base.py                  |  17 +-
     src/pbn_import/utils/import_manager.py        |  14 +-
     src/pbn_import/views.py                       |  56 +--
     23 files changed, 821 insertions(+), 1170 deletions(-)
     create mode 100644 docs/deweloper/przeglad-pbn-import-2026-06-12.md
     create mode 100644 docs/superpowers/plans/2026-06-12-pbn-import-cleanup.md
     create mode 100644 src/bpp/newsfragments/+pbn-import-logi-perf.feature.rst
     create mode 100644 src/bpp/newsfragments/+pbn-import-naprawy.bugfix.rst
     create mode 100644 src/bpp/newsfragments/+pbn-import-usun-websocket.removal.rst
     delete mode 100644 src/pbn_import/consumers.py
     delete mode 100644 src/pbn_import/routing.py
     delete mode 100644 src/pbn_import/templates/pbn_import/components/progress.html
     delete mode 100644 src/pbn_import/tests/test_consumers.py
     create mode 100644 src/pbn_import/tests/test_import_step.py
     delete mode 100644 src/pbn_import/tests/test_routing.py
    
    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 `
    diff --git a/src/pbn_import/templates/pbn_import/dashboard.html b/src/pbn_import/templates/pbn_import/dashboard.html
    index 05a8f7e41..6e89450b7 100644
    --- a/src/pbn_import/templates/pbn_import/dashboard.html
    +++ b/src/pbn_import/templates/pbn_import/dashboard.html
    @@ -445,58 +445,5 @@ 
    Wskazówka
    return false; } - -// WebSocket connection for active import -{% if active_session %} -const ws = new WebSocket( - 'ws://' + window.location.host + - '/ws/pbn-import/session/{{ active_session.id }}/' -); - -ws.onmessage = function(e) { - const data = JSON.parse(e.data); - - switch(data.type) { - case 'progress_update': - updateProgressDisplay(data); - break; - case 'log_entry': - addLogEntry(data.log); - break; - case 'completion': - // Simple notification, no confetti - console.log('Import completed'); - break; - } -}; - -ws.onclose = function(e) { - console.log('WebSocket connection closed'); -}; - -function updateProgressDisplay(data) { - // Update progress bar and status - document.title = 'Import ' + data.progress + '% - BPP'; -} - -function showMotivationalToast(message, icon) { - // Show motivational message as toast notification - const toast = document.createElement('div'); - toast.className = 'callout success'; - toast.textContent = icon + ' ' + message; - toast.style.position = 'fixed'; - toast.style.bottom = '20px'; - toast.style.left = '20px'; - toast.style.zIndex = '10000'; - toast.style.animation = 'slideIn 0.5s'; - - document.body.appendChild(toast); - - setTimeout(() => { - toast.style.animation = 'slideOut 0.5s'; - setTimeout(() => toast.remove(), 500); - }, 5000); -} -{% endif %} {% endblock %} diff --git a/src/pbn_import/tests/test_consumers.py b/src/pbn_import/tests/test_consumers.py deleted file mode 100644 index c83aa5bff..000000000 --- a/src/pbn_import/tests/test_consumers.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Tests for PBN import WebSocket consumer payloads.""" - -import concurrent.futures -import datetime -import json -from unittest.mock import AsyncMock - -import pytest -from asgiref.sync import async_to_sync as _asgiref_async_to_sync -from django.utils import timezone -from model_bakery import baker - -from pbn_import.consumers import ImportProgressConsumer -from pbn_import.models import ImportLog, ImportSession - - -def async_to_sync(method): - """``asgiref.async_to_sync`` odporny na przeciekły działający event loop. - - Te testy są SYNC (``def test_...``) i wołają korutyny konsumera przez - ``async_to_sync``. ``asgiref`` odmawia jednak, gdy wątek wołającego ma już - DZIAŁAJĄCY event loop ("You cannot use AsyncToSync in the same thread as an - async event loop"). Na sharded CI tak właśnie bywa: wcześniejszy test async - w tym samym shardzie zostawia działający loop w wątku workera, więc kolejne - ``async_to_sync`` tu wywalają się — deterministycznie zależnie od podziału na - shardy (lokalnie, w izolacji, nie reprodukuje się). - - Naprawa: wykonujemy wywołanie w ŚWIEŻYM wątku, który z definicji nie ma - działającego loopa — więc ``async_to_sync`` zawsze startuje własny. Sygnatura - jest drop-in (zwraca callable przyjmujący args/kwargs korutyny), więc miejsca - wywołań pozostają bez zmian. Lokalnie (bez przecieku) zachowanie identyczne. - """ - - def caller(*args, **kwargs): - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit( - lambda: _asgiref_async_to_sync(method)(*args, **kwargs) - ) - return future.result() - - return caller - - -def make_consumer(session_id, user): - consumer = ImportProgressConsumer() - consumer.session_id = session_id - consumer.room_group_name = f"import_{session_id}" - consumer.scope = { - "url_route": {"kwargs": {"session_id": session_id}}, - "user": user, - } - consumer.channel_name = "test-channel" - consumer.channel_layer = AsyncMock() - consumer.accept = AsyncMock() - consumer.close = AsyncMock() - consumer.send = AsyncMock() - return consumer - - -@pytest.mark.django_db(transaction=True) -def test_connect_accepts_owner_and_sends_initial_status(django_user_model): - user = baker.make(django_user_model) - session = baker.make( - ImportSession, - user=user, - status="running", - current_step="source_import", - current_step_progress=25, - completed_steps=1, - total_steps=4, - ) - consumer = make_consumer(session.pk, user) - - async_to_sync(consumer.connect)() - - consumer.channel_layer.group_add.assert_awaited_once_with( - f"import_{session.pk}", "test-channel" - ) - consumer.accept.assert_awaited_once_with() - sent_payloads = [ - json.loads(call.kwargs["text_data"]) for call in consumer.send.await_args_list - ] - assert sent_payloads[0]["type"] == "connection" - assert sent_payloads[1]["type"] == "status_update" - assert sent_payloads[1]["status"]["status"] == "running" - assert sent_payloads[1]["status"]["current_step"] == "source_import" - assert sent_payloads[1]["status"]["progress"] == session.overall_progress - assert sent_payloads[1]["status"]["completed_steps"] == 1 - assert sent_payloads[1]["status"]["total_steps"] == 4 - assert sent_payloads[1]["status"]["duration"] - - -@pytest.mark.django_db(transaction=True) -def test_connect_closes_for_unauthenticated_user(django_user_model): - owner = baker.make(django_user_model) - session = baker.make(ImportSession, user=owner) - anonymous = type("Anonymous", (), {"is_authenticated": False})() - consumer = make_consumer(session.pk, anonymous) - - async_to_sync(consumer.connect)() - - consumer.close.assert_awaited_once_with() - consumer.accept.assert_not_awaited() - - -@pytest.mark.django_db(transaction=True) -def test_staff_user_can_view_foreign_session(django_user_model): - owner = baker.make(django_user_model) - staff = baker.make(django_user_model, is_staff=True) - session = baker.make(ImportSession, user=owner) - consumer = make_consumer(session.pk, staff) - - assert async_to_sync(consumer.has_permission)() is True - - -@pytest.mark.django_db(transaction=True) -def test_has_permission_false_for_missing_session(django_user_model): - user = baker.make(django_user_model) - consumer = make_consumer(999_999, user) - - assert async_to_sync(consumer.has_permission)() is False - - -@pytest.mark.django_db(transaction=True) -def test_get_recent_logs_returns_oldest_first(django_user_model): - user = baker.make(django_user_model) - session = baker.make(ImportSession, user=user) - older = baker.make(ImportLog, session=session, level="info", message="older") - newer = baker.make(ImportLog, session=session, level="warning", message="newer") - now = timezone.now() - ImportLog.objects.filter(pk=older.pk).update( - timestamp=now - datetime.timedelta(minutes=1) - ) - ImportLog.objects.filter(pk=newer.pk).update(timestamp=now) - consumer = make_consumer(session.pk, user) - - logs = async_to_sync(consumer.get_recent_logs)(limit=2) - - assert [log["message"] for log in logs] == [older.message, newer.message] - assert logs[0]["level"] == "info" - assert "timestamp" in logs[0] - - -def test_receive_ping_status_and_logs_requests(): - consumer = make_consumer(1, user=object()) - consumer.send_current_status = AsyncMock() - consumer.send_recent_logs = AsyncMock() - - async_to_sync(consumer.receive)( - text_data=json.dumps({"type": "ping", "timestamp": "t1"}) - ) - async_to_sync(consumer.receive)(text_data=json.dumps({"type": "request_status"})) - async_to_sync(consumer.receive)(text_data=json.dumps({"type": "request_logs"})) - - assert json.loads(consumer.send.await_args.kwargs["text_data"]) == { - "type": "pong", - "timestamp": "t1", - } - consumer.send_current_status.assert_awaited_once_with() - consumer.send_recent_logs.assert_awaited_once_with() - - -def test_event_handlers_serialize_payloads(): - consumer = make_consumer(1, user=object()) - - async_to_sync(consumer.import_update)({"data": {"progress": 10}}) - async_to_sync(consumer.progress_update)( - {"progress": 50, "current_step": "step", "message": "msg"} - ) - async_to_sync(consumer.log_entry)( - { - "timestamp": "2026-06-05T10:00:00", - "level": "info", - "step": "source_import", - "message": "done", - } - ) - async_to_sync(consumer.status_change)( - {"old_status": "running", "new_status": "completed", "message": "ok"} - ) - async_to_sync(consumer.statistics_update)({"statistics": {"x": 1}}) - async_to_sync(consumer.completion_notification)( - {"success": True, "message": "completed"} - ) - - payloads = [ - json.loads(call.kwargs["text_data"]) for call in consumer.send.await_args_list - ] - assert payloads == [ - {"type": "import_update", "data": {"progress": 10}}, - { - "type": "progress_update", - "progress": 50, - "current_step": "step", - "message": "msg", - }, - { - "type": "log_entry", - "log": { - "timestamp": "2026-06-05T10:00:00", - "level": "info", - "step": "source_import", - "message": "done", - }, - }, - { - "type": "status_change", - "old_status": "running", - "new_status": "completed", - "message": "ok", - }, - {"type": "statistics_update", "statistics": {"x": 1}}, - {"type": "completion", "success": True, "message": "completed"}, - ] - - -def test_disconnect_removes_channel_from_group(): - consumer = make_consumer(123, user=object()) - consumer.room_group_name = "import_123" - - async_to_sync(consumer.disconnect)(1000) - - consumer.channel_layer.group_discard.assert_awaited_once_with( - "import_123", "test-channel" - ) diff --git a/src/pbn_import/tests/test_import_manager.py b/src/pbn_import/tests/test_import_manager.py index 5549aaf11..70f9ccb21 100644 --- a/src/pbn_import/tests/test_import_manager.py +++ b/src/pbn_import/tests/test_import_manager.py @@ -72,8 +72,25 @@ def step_cfg(klass, name="zrodla", display="Źródła", required=False, result_k # =========================================================================== +def test_construction_does_not_probe_pbn(session): + """Samo skonstruowanie ImportManagera NIE może uderzać w PBN. + + Sonda autoryzacji (``client.get_languages``) to efekt sieciowy — należy + do ``run()``, nie do ``__init__`` (testowalność, brak zaskakujących + side-effectów przy budowie obiektu). + """ + client = MagicMock() + + manager = ImportManager(session, client=client) + + client.get_languages.assert_not_called() + assert manager.pbn_authorized is False + assert manager.pbn_error_message is None + + def test_no_client_means_unauthorized(session): manager = ImportManager(session, client=None) + manager._check_pbn_authorization() assert manager.pbn_authorized is False assert manager.pbn_error_message == "Brak konfiguracji klienta PBN" @@ -84,6 +101,7 @@ def test_working_client_means_authorized(session): client.get_languages.return_value = ["pol", "eng"] manager = ImportManager(session, client=client) + manager._check_pbn_authorization() assert manager.pbn_authorized is True client.get_languages.assert_called_once() @@ -94,6 +112,7 @@ def test_failing_client_means_unauthorized_with_message(session): client.get_languages.side_effect = RuntimeError("token wygasł") manager = ImportManager(session, client=client) + manager._check_pbn_authorization() assert manager.pbn_authorized is False assert "token wygasł" in manager.pbn_error_message diff --git a/src/pbn_import/tests/test_import_step.py b/src/pbn_import/tests/test_import_step.py new file mode 100644 index 000000000..25e57be8f --- /dev/null +++ b/src/pbn_import/tests/test_import_step.py @@ -0,0 +1,50 @@ +"""Tests for ImportStepBase / TqdmSessionProgress progress_data writes.""" + +import pytest +from model_bakery import baker + +from pbn_import.models import ImportSession +from pbn_import.utils.base import ImportStepBase, TqdmSessionProgress + + +class _Step(ImportStepBase): + step_name = "author_import" + step_description = "Test step" + + +@pytest.mark.django_db +def test_progress_data_writes_do_not_clobber(django_user_model): + """Two independent stale writers on the same ImportSession row must not + clobber each other's progress_data keys. + + Writer A drives ImportStepBase.update_progress (writes + progress_data["steps"]). Writer B is a *separately fetched*, stale + in-memory copy driving TqdmSessionProgress.update (writes + progress_data["current_subtask"]). After both persist, BOTH keys must + survive a fresh reload from the database. + """ + user = baker.make(django_user_model) + row = baker.make(ImportSession, user=user, progress_data={}) + + # Two independent in-memory copies of the SAME row (stale w.r.t. + # each other) — simulates the step thread vs the throttled tqdm + # callback both holding their own ImportSession instance. + session_a = ImportSession.objects.get(pk=row.pk) + session_b = ImportSession.objects.get(pk=row.pk) + + # Writer A: real ImportStepBase code path → progress_data["steps"]. + step = _Step(session_a) + step.update_progress(current=5, total=10, message="processing") + + # Writer B: real TqdmSessionProgress code path → current_subtask. + # session_b never saw A's "steps" write (stale in-memory copy). + callback = TqdmSessionProgress(session_b, subtask_name="sub") + callback.last_update_time = 0 # ensure not throttled + callback.update(current=3, total=6, desc="subtask") + + # Reload fresh from DB; both keys must be present. + reloaded = ImportSession.objects.get(pk=row.pk) + assert "steps" in reloaded.progress_data, reloaded.progress_data + assert "author_import" in reloaded.progress_data["steps"] + assert "current_subtask" in reloaded.progress_data, reloaded.progress_data + assert reloaded.progress_data["current_subtask"]["name"] == "sub" diff --git a/src/pbn_import/tests/test_routing.py b/src/pbn_import/tests/test_routing.py deleted file mode 100644 index 625e2d538..000000000 --- a/src/pbn_import/tests/test_routing.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Tests for PBN import websocket routing.""" - -from pbn_import.routing import websocket_urlpatterns - - -def test_websocket_route_points_to_import_session_progress(): - pattern = websocket_urlpatterns[0] - - assert len(websocket_urlpatterns) == 1 - assert ( - pattern.pattern.regex.pattern == r"ws/pbn-import/session/(?P\w+)/$" - ) - assert callable(pattern.callback) diff --git a/src/pbn_import/tests/test_tasks.py b/src/pbn_import/tests/test_tasks.py index cfda5fdaf..693020a18 100644 --- a/src/pbn_import/tests/test_tasks.py +++ b/src/pbn_import/tests/test_tasks.py @@ -6,88 +6,7 @@ from model_bakery import baker from pbn_import.models import ImportLog, ImportSession -from pbn_import.tasks import run_pbn_import, send_websocket_update, update_progress - -# ============================================================================ -# Helper function tests -# ============================================================================ - - -@pytest.mark.django_db -def test_send_websocket_update_success(): - """Test send_websocket_update sends update via channel layer.""" - session = baker.make(ImportSession) - - with patch("pbn_import.tasks.get_channel_layer") as mock_get_layer: - mock_layer = MagicMock() - mock_get_layer.return_value = mock_layer - - with patch("pbn_import.tasks.async_to_sync") as mock_async: - mock_send = MagicMock() - mock_async.return_value = mock_send - - send_websocket_update(session, {"type": "test", "progress": 50}) - - mock_send.assert_called_once() - - -@pytest.mark.django_db -def test_send_websocket_update_no_channel_layer(): - """Test send_websocket_update handles no channel layer gracefully.""" - session = baker.make(ImportSession) - - with patch("pbn_import.tasks.get_channel_layer", return_value=None): - send_websocket_update(session, {"type": "test"}) - - -@pytest.mark.django_db -def test_send_websocket_update_handles_exception(): - """Test send_websocket_update handles exceptions gracefully.""" - session = baker.make(ImportSession) - - with patch("pbn_import.tasks.get_channel_layer") as mock_get_layer: - mock_layer = MagicMock() - mock_get_layer.return_value = mock_layer - - with patch( - "pbn_import.tasks.async_to_sync", side_effect=Exception("WebSocket error") - ): - send_websocket_update(session, {"type": "test"}) - - -@pytest.mark.django_db -def test_update_progress(): - """Test update_progress updates session and creates log.""" - session = baker.make(ImportSession, current_step="", current_step_progress=0) - - with patch("pbn_import.tasks.send_websocket_update"): - update_progress(session, "Importing authors", 50, "Processing 100 authors") - - session.refresh_from_db() - assert session.current_step == "Importing authors" - assert session.current_step_progress == 50 - - log = ImportLog.objects.filter(session=session, step="Importing authors").first() - assert log is not None - assert log.message == "Processing 100 authors" - - -@pytest.mark.django_db -def test_update_progress_without_message(): - """Test update_progress works without message.""" - session = baker.make(ImportSession, current_step="", current_step_progress=0) - - with patch("pbn_import.tasks.send_websocket_update"): - update_progress(session, "Step 1", 25, message=None) - - session.refresh_from_db() - assert session.current_step == "Step 1" - assert session.current_step_progress == 25 - - assert not ImportLog.objects.filter( - session=session, step="Step 1", message__isnull=False - ).exists() - +from pbn_import.tasks import run_pbn_import # ============================================================================ # run_pbn_import task tests @@ -115,10 +34,9 @@ def test_run_pbn_import_success(uczelnia, admin_user): mock_import_manager = MagicMock() mock_import_manager.run.return_value = {"success": True} - with patch("pbn_import.tasks.send_websocket_update"): - with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): - with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk, uczelnia.pk)) + with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): + with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.status == "completed" @@ -136,10 +54,9 @@ def test_run_pbn_import_marks_running(uczelnia, admin_user): mock_import_manager = MagicMock() mock_import_manager.run.return_value = {"success": True} - with patch("pbn_import.tasks.send_websocket_update"): - with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): - with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk, uczelnia.pk)) + with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): + with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.task_id is not None @@ -153,12 +70,11 @@ def test_run_pbn_import_handles_no_pbn_client(uczelnia, admin_user): mock_import_manager = MagicMock() mock_import_manager.run.return_value = {"success": True} - with patch("pbn_import.tasks.send_websocket_update"): - with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): - with patch.object( - uczelnia, "pbn_client", side_effect=Exception("No PBN config") - ): - run_pbn_import.apply(args=(session.pk, uczelnia.pk)) + with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): + with patch.object( + uczelnia, "pbn_client", side_effect=Exception("No PBN config") + ): + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.status == "completed" @@ -177,11 +93,10 @@ def test_run_pbn_import_failure(uczelnia, admin_user): mock_import_manager = MagicMock() mock_import_manager.run.side_effect = Exception("Import failed") - with patch("pbn_import.tasks.send_websocket_update"): - with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): - with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - with patch("pbn_import.tasks.rollbar"): - run_pbn_import.apply(args=(session.pk, uczelnia.pk)) + with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): + with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): + with patch("pbn_import.tasks.rollbar"): + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.status == "failed" @@ -206,10 +121,9 @@ def simulate_cancellation(sess, pbn_client, config, uczelnia=None): mock_manager.run.return_value = {"success": False} return mock_manager - with patch("pbn_import.tasks.send_websocket_update"): - with patch("pbn_import.tasks.ImportManager", side_effect=simulate_cancellation): - with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk, uczelnia.pk)) + with patch("pbn_import.tasks.ImportManager", side_effect=simulate_cancellation): + with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.status == "cancelled" @@ -250,40 +164,14 @@ def test_run_pbn_import_failed_result(uczelnia, admin_user): mock_import_manager = MagicMock() mock_import_manager.run.return_value = {"success": False, "error": "Some error"} - with patch("pbn_import.tasks.send_websocket_update"): - with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): - with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk, uczelnia.pk)) + with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): + with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) session.refresh_from_db() assert session.status == "failed" -@pytest.mark.django_db -def test_run_pbn_import_sends_completion_notification(uczelnia, admin_user): - """Test run_pbn_import sends completion WebSocket notification.""" - session = ImportSession.objects.create(user=admin_user, status="pending", config={}) - - mock_import_manager = MagicMock() - mock_import_manager.run.return_value = {"success": True} - - websocket_calls = [] - - def track_websocket(sess, data): - websocket_calls.append(data) - - with patch("pbn_import.tasks.send_websocket_update", side_effect=track_websocket): - with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): - with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk, uczelnia.pk)) - - completion_call = next( - (c for c in websocket_calls if c.get("type") == "completion"), None - ) - assert completion_call is not None - assert completion_call["success"] is True - - @pytest.mark.django_db def test_run_pbn_import_uses_passed_uczelnia_not_default(admin_user): """run_pbn_import MUSI budować klienta z uczelni przekazanej przez @@ -319,10 +207,9 @@ def fake_pbn_client(self, token): mock_import_manager = MagicMock() mock_import_manager.run.return_value = {"success": True} - with patch("pbn_import.tasks.send_websocket_update"): - with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): - with patch.object(Uczelnia, "pbn_client", fake_pbn_client): - run_pbn_import.apply(args=(session.pk, uczelnia2.pk)) + with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): + with patch.object(Uczelnia, "pbn_client", fake_pbn_client): + run_pbn_import.apply(args=(session.pk, uczelnia2.pk)) assert recorded_pk == [uczelnia2.pk] @@ -346,11 +233,10 @@ def test_run_pbn_import_without_uczelnia_id_does_not_fall_back(admin_user): mock_import_manager = MagicMock() mock_import_manager.run.return_value = {"success": True} - with patch("pbn_import.tasks.send_websocket_update"): - with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): - with patch("pbn_import.tasks.rollbar"): - # brak uczelnia_id — entrypoint go nie podał - run_pbn_import.apply(args=(session.pk,)) + with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): + with patch("pbn_import.tasks.rollbar"): + # brak uczelnia_id — entrypoint go nie podał + run_pbn_import.apply(args=(session.pk,)) session.refresh_from_db() assert session.status == "failed" @@ -364,10 +250,9 @@ def test_run_pbn_import_creates_start_log(uczelnia, admin_user): mock_import_manager = MagicMock() mock_import_manager.run.return_value = {"success": True} - with patch("pbn_import.tasks.send_websocket_update"): - with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): - with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): - run_pbn_import.apply(args=(session.pk, uczelnia.pk)) + with patch("pbn_import.tasks.ImportManager", return_value=mock_import_manager): + with patch.object(uczelnia, "pbn_client", return_value=MagicMock()): + run_pbn_import.apply(args=(session.pk, uczelnia.pk)) start_log = ImportLog.objects.filter( session=session, step="Start", level="info" diff --git a/src/pbn_import/tests/test_views_dashboard.py b/src/pbn_import/tests/test_views_dashboard.py index fd3af12df..799f61986 100644 --- a/src/pbn_import/tests/test_views_dashboard.py +++ b/src/pbn_import/tests/test_views_dashboard.py @@ -313,8 +313,6 @@ def test_start_import_creates_session(self, django_user_model): with ( patch("pbn_import.tasks.run_pbn_import") as mock_task, - patch("pbn_import.views.get_channel_layer"), - patch("pbn_import.views.async_to_sync"), ): mock_task.delay.return_value = MagicMock(id="task-123") @@ -345,8 +343,6 @@ def test_start_import_stores_config(self, django_user_model): with ( patch("pbn_import.tasks.run_pbn_import") as mock_task, - patch("pbn_import.views.get_channel_layer"), - patch("pbn_import.views.async_to_sync"), ): mock_task.delay.return_value = MagicMock(id="task-123") @@ -386,8 +382,6 @@ def test_start_import_passes_uczelnia_id_from_request( with ( patch("pbn_import.tasks.run_pbn_import") as mock_task, - patch("pbn_import.views.get_channel_layer"), - patch("pbn_import.views.async_to_sync"), ): mock_task.delay.return_value = MagicMock(id="task-123") @@ -415,8 +409,6 @@ def test_start_import_redirects_to_dashboard(self, django_user_model): with ( patch("pbn_import.tasks.run_pbn_import") as mock_task, - patch("pbn_import.views.get_channel_layer"), - patch("pbn_import.views.async_to_sync"), ): mock_task.delay.return_value = MagicMock(id="task-123") @@ -437,8 +429,6 @@ def test_start_import_blocked_without_unit(self, django_user_model): with ( patch("pbn_import.tasks.run_pbn_import") as mock_task, - patch("pbn_import.views.get_channel_layer"), - patch("pbn_import.views.async_to_sync"), ): mock_task.delay.return_value = MagicMock(id="task-123") @@ -462,8 +452,6 @@ def test_start_import_blocked_without_faculty_when_used(self, django_user_model) with ( patch("pbn_import.tasks.run_pbn_import") as mock_task, - patch("pbn_import.views.get_channel_layer"), - patch("pbn_import.views.async_to_sync"), ): mock_task.delay.return_value = MagicMock(id="task-123") @@ -488,8 +476,6 @@ def test_start_import_allowed_without_faculty_when_not_used( with ( patch("pbn_import.tasks.run_pbn_import") as mock_task, - patch("pbn_import.views.get_channel_layer"), - patch("pbn_import.views.async_to_sync"), ): mock_task.delay.return_value = MagicMock(id="task-123") @@ -521,8 +507,6 @@ def test_start_import_persists_canonical_default_jednostka_id( with ( patch("pbn_import.tasks.run_pbn_import") as mock_task, - patch("pbn_import.views.get_channel_layer"), - patch("pbn_import.views.async_to_sync"), ): mock_task.delay.return_value = MagicMock(id="task-123") @@ -555,8 +539,6 @@ def test_start_import_rejects_foreign_uczelnia_jednostka(self, django_user_model with ( patch("pbn_import.tasks.run_pbn_import") as mock_task, - patch("pbn_import.views.get_channel_layer"), - patch("pbn_import.views.async_to_sync"), patch.object( Uczelnia.objects, "get_for_request", return_value=request_uczelnia ), @@ -589,8 +571,6 @@ def test_start_import_rejects_foreign_uczelnia_wydzial(self, django_user_model): with ( patch("pbn_import.tasks.run_pbn_import") as mock_task, - patch("pbn_import.views.get_channel_layer"), - patch("pbn_import.views.async_to_sync"), patch.object( Uczelnia.objects, "get_for_request", return_value=request_uczelnia ), @@ -641,7 +621,7 @@ def test_cancel_running_import(self, django_user_model): ) client.force_login(user) - with patch("celery.current_app"), patch("pbn_import.views.get_channel_layer"): + with patch("celery.current_app"): client.post(reverse("pbn_import:cancel", args=[session.id])) session.refresh_from_db() @@ -693,7 +673,7 @@ def test_cancel_creates_log_entry(self, django_user_model): ) client.force_login(user) - with patch("celery.current_app"), patch("pbn_import.views.get_channel_layer"): + with patch("celery.current_app"): client.post(reverse("pbn_import:cancel", args=[session.id])) log = ImportLog.objects.filter(session=session).last() diff --git a/src/pbn_import/tests/test_views_session.py b/src/pbn_import/tests/test_views_session.py index b1c82ffc2..5b24ac0a5 100644 --- a/src/pbn_import/tests/test_views_session.py +++ b/src/pbn_import/tests/test_views_session.py @@ -9,6 +9,41 @@ from model_bakery import baker from pbn_import.models import ImportLog, ImportSession +from pbn_import.views import MAX_LOGS_DISPLAY + +# ============================================================================ +# LOG QUERYSET CAP TESTS (performance) +# ============================================================================ + + +@pytest.mark.django_db +def test_htmx_cancel_returns_200(admin_client, admin_user): + """Anulowanie importu przez HTMX zwraca 200 (render progress_compact), + a nie 500 (NoReverseMatch 'stats' z martwego progress.html).""" + session = baker.make(ImportSession, user=admin_user, status="running") + + response = admin_client.post( + reverse("pbn_import:cancel", args=[session.pk]), + HTTP_HX_REQUEST="true", + ) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_all_logs_view_caps_results(admin_client): + """ImportAllLogsView nie ładuje wszystkich wierszy ImportLog — slice do + MAX_LOGS_DISPLAY (HTMX re-fetch co 5 s nie może ciągnąć tysięcy wierszy).""" + session = baker.make(ImportSession) + baker.make(ImportLog, session=session, _quantity=MAX_LOGS_DISPLAY + 25) + + response = admin_client.get( + reverse("pbn_import:all_logs", args=[session.pk]), + ) + + assert response.status_code == 200 + assert len(response.context["logs"]) == MAX_LOGS_DISPLAY + # ============================================================================ # SESSION DETAIL VIEW TESTS diff --git a/src/pbn_import/urls.py b/src/pbn_import/urls.py index 5f16039be..50470e032 100644 --- a/src/pbn_import/urls.py +++ b/src/pbn_import/urls.py @@ -49,5 +49,4 @@ ), # Configuration presets path("presets/", views.ImportPresetsView.as_view(), name="presets"), - path("presets/save/", views.SavePresetView.as_view(), name="save_preset"), ] diff --git a/src/pbn_import/utils/base.py b/src/pbn_import/utils/base.py index 287ea597d..b57c06c76 100644 --- a/src/pbn_import/utils/base.py +++ b/src/pbn_import/utils/base.py @@ -39,6 +39,11 @@ def update(self, current: int, total: int, desc: str = ""): progress = int((current / total) * 100) if total > 0 else 0 + # Re-read progress_data before mutating: this callback holds a + # possibly-stale in-memory copy and a full scoped save would clobber + # concurrent writers (e.g. the step's progress_data["steps"]). + self.session.refresh_from_db(fields=["progress_data"]) + # Update session progress data if "current_subtask" not in self.session.progress_data: self.session.progress_data["current_subtask"] = {} @@ -128,6 +133,12 @@ def update_progress(self, current: int, total: int, message: str = ""): progress_percent=progress_percent, ) + # Re-read progress_data (only) before mutating it: a concurrent + # throttled TqdmSessionProgress may have written current_subtask from + # its own in-memory copy. Refresh ONLY progress_data so the scalar + # fields just persisted by update_progress() above are not discarded. + self.session.refresh_from_db(fields=["progress_data"]) + # Store detailed progress in session data if "steps" not in self.session.progress_data: self.session.progress_data["steps"] = {} @@ -139,7 +150,9 @@ def update_progress(self, current: int, total: int, message: str = ""): "message": message, "errors": len(self.errors), } - self.session.save() + # Scope the save to progress_data so we don't clobber concurrent + # writers of other (scalar) fields. + self.session.save(update_fields=["progress_data"]) def start(self): """Called when step starts""" @@ -149,7 +162,7 @@ def start(self): logger.info("=" * 60) self.log("info", f"Rozpoczynanie: {self.step_description}") self.session.current_step = self.step_name - self.session.save() + self.session.save(update_fields=["current_step"]) def finish(self): """Called when step completes""" diff --git a/src/pbn_import/utils/import_manager.py b/src/pbn_import/utils/import_manager.py index ab79bbe49..832b10f07 100644 --- a/src/pbn_import/utils/import_manager.py +++ b/src/pbn_import/utils/import_manager.py @@ -43,9 +43,6 @@ def __init__( # Define import steps with their order self.steps = get_step_definitions(self.config) - # Check PBN authorization status - self._check_pbn_authorization() - def _check_pbn_authorization(self): """Check if PBN client is properly authorized""" if self.client is None: @@ -400,6 +397,17 @@ def _run_import_steps(self, results): def run(self): """Execute the complete import process""" + # Sonda autoryzacji PBN to efekt sieciowy — trzymamy ją poza + # __init__ (testowalność) i odpalamy dokładnie raz, na początku run(). + # Sentinel: __init__ zostawia pbn_authorized=False, pbn_error_message + # =None ("jeszcze nie sondowano"). Po realnej sondzie albo + # pbn_authorized==True, albo pbn_error_message jest ustawione (także + # gdy client is None → "Brak konfiguracji klienta PBN"), więc warunek + # poniżej nie odpali jej drugi raz. Re-check po InitialSetup + # (_refresh_pbn_client_after_setup) zostaje bez zmian. + if not self.pbn_authorized and self.pbn_error_message is None: + self._check_pbn_authorization() + logger.info("=" * 60) logger.info("IMPORT PBN - START") logger.info(f"Sesja: {self.session.id}") diff --git a/src/pbn_import/views.py b/src/pbn_import/views.py index ff0844361..380256c91 100644 --- a/src/pbn_import/views.py +++ b/src/pbn_import/views.py @@ -1,10 +1,7 @@ """Views for PBN import interface""" -import json import random -from asgiref.sync import async_to_sync -from channels.layers import get_channel_layer from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.db.models import Count @@ -13,7 +10,6 @@ from django.urls import reverse from django.utils import timezone from django.views import View -from django.views.decorators.csrf import csrf_exempt from django.views.generic import DetailView, ListView, TemplateView from bpp.models import Jednostka, Jezyk, Uczelnia, Wydzial @@ -37,6 +33,12 @@ get_form_steps, ) +# Maksymalna liczba wierszy ImportLog ładowana do widoków HTMX. Endpointy są +# re-fetchowane co 5 s podczas długiego importu — bez tego limitu każde +# odświeżenie ciągnęłoby tysiące wierszy. Pełny log pozostaje pobieralny przez +# ImportLogDownloadView (nieprzycięty). +MAX_LOGS_DISPLAY = 200 + class ImportPermissionMixin(PermissionRequiredMixin): """Mixin requiring PBN import permissions""" @@ -280,16 +282,6 @@ def post(self, request): session.status = "pending" # Keep as pending until task starts session.save() - # Send WebSocket notification - self.send_websocket_update( - session, - { - "type": "import_started", - "session_id": session.id, - "message": "Import został rozpoczęty!", - }, - ) - messages.success(request, f"Import #{session.id} został rozpoczęty!") if request.headers.get("HX-Request"): @@ -300,15 +292,6 @@ def post(self, request): return redirect("pbn_import:dashboard") - def send_websocket_update(self, session, data): - """Send update via WebSocket""" - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - f"import_{session.id}", {"type": "import_update", "data": data} - ) - - return redirect("pbn_import:dashboard") - class CancelImportView(LoginRequiredMixin, ImportPermissionMixin, View): """Cancel an import session""" @@ -370,7 +353,9 @@ def post(self, request, pk): if request.headers.get("HX-Request"): # Return updated progress component for HTMX return render( - request, "pbn_import/components/progress.html", {"session": session} + request, + "pbn_import/components/progress_compact.html", + {"session": session}, ) return redirect("pbn_import:dashboard") @@ -481,19 +466,6 @@ def get(self, request): return JsonResponse({"presets": presets}) -class SavePresetView(LoginRequiredMixin, ImportPermissionMixin, View): - """Save a custom import preset""" - - @csrf_exempt - def post(self, request): - data = json.loads(request.body) # noqa - - # In production, save to database or user preferences - # For now, just return success - - return JsonResponse({"success": True, "message": "Preset zapisany pomyślnie!"}) - - class ImportSessionDetailView(LoginRequiredMixin, ImportPermissionMixin, DetailView): """Detailed view of import session with logs and errors""" @@ -514,12 +486,12 @@ def get_context_data(self, **kwargs): # Get all logs for this session context["logs"] = ImportLog.objects.filter(session=session).order_by( "-timestamp" - ) + )[:MAX_LOGS_DISPLAY] # Get error logs specifically context["error_logs"] = ImportLog.objects.filter( session=session, level__in=["error", "critical", "warning"] - ).order_by("-timestamp") + ).order_by("-timestamp")[:MAX_LOGS_DISPLAY] # Get inconsistencies inconsistencies = ( @@ -580,7 +552,9 @@ def get(self, request, pk): if not request.user.is_superuser and session.user != request.user: return HttpResponse("Forbidden", status=403) - logs = ImportLog.objects.filter(session=session).order_by("-timestamp") + logs = ImportLog.objects.filter(session=session).order_by("-timestamp")[ + :MAX_LOGS_DISPLAY + ] return render( request, "pbn_import/components/all_logs.html", @@ -599,7 +573,7 @@ def get(self, request, pk): error_logs = ImportLog.objects.filter( session=session, level__in=["error", "critical", "warning"] - ).order_by("-timestamp") + ).order_by("-timestamp")[:MAX_LOGS_DISPLAY] return render( request, "pbn_import/components/error_logs.html", From a2124bf34e037c8436b38aa14294497777c6a1e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 13:23:03 +0200 Subject: [PATCH 242/247] OIDC: mail-first claimy (konfigurowalne) + uczelnia-aware dopasowanie autora MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warstwa A — mapowanie claimów logowania OIDC: - Domyślnie `mail` (instytucjonalny) ma pierwszeństwo nad `email` (w realmie UAFM bywa prywatny); `email` tylko jako fallback. - Źródła e-maila i username konfigurowalne env-em wg istniejącej precedencji DJANGO_BPP_OIDC__{EMAIL,USERNAME}_CLAIMS -> bare (CSV). Defaulty w conf.py, wystawione jako OIDC_EMAIL_CLAIMS / OIDC_USERNAME_CLAIMS, czytane przez BppOIDCBackend. Warstwa B — naprawa BppUser.sprobuj_dopasowac_autora: - Pomija autorów już powiązanych z innym kontem (user__isnull=True) -> koniec IntegrityError przy OneToOne BppUser.autor (zrodlo tracebacku). - Zawęża kandydatów do uczelni z accessible_uczelnie (aktualna_jednostka.uczelnia); puste = bez ograniczenia (kompat.). - Matching po person_id/system_kadrowy_id swiadomie NIE uzywany. - Backend OIDC wola dopasowanie z create_user (po przypisaniu uczelni) i update_user -> powiazanie powstaje przy logowaniu, self-healing. TDD: testy RED->GREEN dla obu warstw (test_conf, test_backends, nowy test_dopasowanie_autora). 79 passed, manage.py check czysty. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/models/profile.py | 25 ++- .../test_models/test_dopasowanie_autora.py | 143 ++++++++++++++++++ src/django_bpp/settings/base.py | 5 + src/oidc_integration/backends.py | 79 +++++++--- src/oidc_integration/conf.py | 31 +++- src/oidc_integration/tests/test_backends.py | 122 ++++++++++++++- src/oidc_integration/tests/test_conf.py | 46 ++++++ 7 files changed, 414 insertions(+), 37 deletions(-) create mode 100644 src/bpp/tests/test_models/test_dopasowanie_autora.py diff --git a/src/bpp/models/profile.py b/src/bpp/models/profile.py index d652c85cf..4c4d20264 100644 --- a/src/bpp/models/profile.py +++ b/src/bpp/models/profile.py @@ -91,20 +91,39 @@ def sprobuj_dopasowac_autora(self): """Próbuje automatycznie dopasować autora do użytkownika. Kolejność dopasowania: - 1. Po adresie email (case-insensitive) + 1. Po adresie email (case-insensitive, dokładnie 1 wynik) 2. Po imieniu i nazwisku (case-insensitive, dokładnie 1 wynik) Nic nie robi jeśli autor jest już ustawiony. + + Dwa ograniczenia istotne w instalacji wielouczelnianej (jedna baza, + wiele ``Uczelnia``): + + * **Pomija autorów już powiązanych z innym kontem** (``user__isnull= + True``) — ``BppUser.autor`` to ``OneToOne``, więc przejęcie zajętego + autora i tak skończyłoby się ``IntegrityError``. Cicho pomijamy. + * **Zawęża do uczelni, do których konto ma uprawnienia** + (``accessible_uczelnie`` → ``aktualna_jednostka.uczelnia``). Puste + ``accessible_uczelnie`` = brak ograniczenia (kompatybilność wsteczna, + „puste = wszystkie"). """ if self.autor_id is not None: return from bpp.models import Autor + # Tylko wolni autorzy (OneToOne) — zajętych nie wolno przejmować. + kandydaci = Autor.objects.filter(user__isnull=True) + + # Scope po uczelni z uprawnień konta; puste = bez ograniczenia. + uczelnie_ids = list(self.accessible_uczelnie.values_list("pk", flat=True)) + if uczelnie_ids: + kandydaci = kandydaci.filter(aktualna_jednostka__uczelnia__in=uczelnie_ids) + # Próba dopasowania po emailu if self.email and self.email != PUSTY_ADRES_EMAIL: wynik = ( - Autor.objects.filter(email__iexact=self.email) + kandydaci.filter(email__iexact=self.email) .exclude(email="") .exclude(email=PUSTY_ADRES_EMAIL) ) @@ -115,7 +134,7 @@ def sprobuj_dopasowac_autora(self): # Próba dopasowania po imieniu i nazwisku if self.first_name and self.last_name: - wynik = Autor.objects.filter( + wynik = kandydaci.filter( imiona__iexact=self.first_name, nazwisko__iexact=self.last_name, ) diff --git a/src/bpp/tests/test_models/test_dopasowanie_autora.py b/src/bpp/tests/test_models/test_dopasowanie_autora.py new file mode 100644 index 000000000..83bfd5e57 --- /dev/null +++ b/src/bpp/tests/test_models/test_dopasowanie_autora.py @@ -0,0 +1,143 @@ +"""Testy ``BppUser.sprobuj_dopasowac_autora`` — guard kolizji + scope uczelni. + +Dwa wymagania (multi-hosted, jedna baza / wiele Uczelni): + +1. **Nie nadpisuj zajętego profilu** — autor już powiązany z innym kontem + (``OneToOne`` ``BppUser.autor``) NIE może zostać dopasowany do kolejnego + konta. Wcześniej brak guardu powodował ``IntegrityError`` (traceback) na + unikalnym ``autor_id``. +2. **Scope po uczelni** — dopasowujemy tylko autorów z uczelni, do której + konto ma uprawnienia (``accessible_uczelnie`` → ``aktualna_jednostka. + uczelnia``). Puste ``accessible_uczelnie`` = brak ograniczenia + (kompatybilność wsteczna single-install). +""" + +import pytest +from model_bakery import baker + + +def _autor_w_uczelni(uczelnia, **kwargs): + jednostka = baker.make("bpp.Jednostka", uczelnia=uczelnia) + return baker.make("bpp.Autor", aktualna_jednostka=jednostka, **kwargs) + + +@pytest.mark.django_db +def test_pomija_autora_z_przypisanym_userem(): + # Autor już zajęty przez inne konto — kolejne konto o tym samym e-mailu + # NIE może go przejąć (i nie wolno rzucić IntegrityError). + uczelnia = baker.make("bpp.Uczelnia") + autor = _autor_w_uczelni(uczelnia, email="jan@uafm.edu.pl") + baker.make("bpp.BppUser", autor=autor, username="pierwszy") + + drugi = baker.make("bpp.BppUser", username="drugi", email="jan@uafm.edu.pl") + drugi.accessible_uczelnie.add(uczelnia) + + drugi.sprobuj_dopasowac_autora() # nie może rzucić wyjątku + + drugi.refresh_from_db() + assert drugi.autor_id is None + + +@pytest.mark.django_db +def test_dopasowuje_autora_w_uczelni_z_uprawnieniem(): + uczelnia = baker.make("bpp.Uczelnia") + autor = _autor_w_uczelni(uczelnia, email="jan@uafm.edu.pl") + + user = baker.make("bpp.BppUser", email="jan@uafm.edu.pl") + user.accessible_uczelnie.add(uczelnia) + + user.sprobuj_dopasowac_autora() + + user.refresh_from_db() + assert user.autor_id == autor.pk + + +@pytest.mark.django_db +def test_nie_dopasowuje_autora_z_obcej_uczelni(): + # Autor istnieje tylko w uczelni B, a konto ma uprawnienia do A → brak + # dopasowania (scope po uczelni). + uczelnia_a = baker.make("bpp.Uczelnia") + uczelnia_b = baker.make("bpp.Uczelnia") + _autor_w_uczelni(uczelnia_b, email="jan@uafm.edu.pl") + + user = baker.make("bpp.BppUser", email="jan@uafm.edu.pl") + user.accessible_uczelnie.add(uczelnia_a) + + user.sprobuj_dopasowac_autora() + + user.refresh_from_db() + assert user.autor_id is None + + +@pytest.mark.django_db +def test_scope_rozroznia_autorow_o_tym_samym_emailu(): + # Ten sam e-mail w dwóch uczelniach — scope wybiera tego z uczelni konta + # (bez scope byłby count()==2 i żadnego dopasowania). + uczelnia_a = baker.make("bpp.Uczelnia") + uczelnia_b = baker.make("bpp.Uczelnia") + autor_a = _autor_w_uczelni(uczelnia_a, email="jan@uafm.edu.pl") + _autor_w_uczelni(uczelnia_b, email="jan@uafm.edu.pl") + + user = baker.make("bpp.BppUser", email="jan@uafm.edu.pl") + user.accessible_uczelnie.add(uczelnia_a) + + user.sprobuj_dopasowac_autora() + + user.refresh_from_db() + assert user.autor_id == autor_a.pk + + +@pytest.mark.django_db +def test_bez_accessible_uczelnie_dopasowuje_globalnie(): + # Kompatybilność wsteczna: puste accessible_uczelnie = bez ograniczenia. + uczelnia = baker.make("bpp.Uczelnia") + autor = _autor_w_uczelni(uczelnia, email="jan@uafm.edu.pl") + + user = baker.make("bpp.BppUser", email="jan@uafm.edu.pl") + + user.sprobuj_dopasowac_autora() + + user.refresh_from_db() + assert user.autor_id == autor.pk + + +@pytest.mark.django_db +def test_dopasowanie_po_imieniu_nazwisku_scope(): + # Brak dopasowania po e-mailu → fallback po imieniu+nazwisku, też scope. + uczelnia = baker.make("bpp.Uczelnia") + autor = _autor_w_uczelni(uczelnia, imiona="Przemysław", nazwisko="Kowalczewski") + + user = baker.make( + "bpp.BppUser", + email="", + first_name="Przemysław", + last_name="Kowalczewski", + ) + user.accessible_uczelnie.add(uczelnia) + + user.sprobuj_dopasowac_autora() + + user.refresh_from_db() + assert user.autor_id == autor.pk + + +@pytest.mark.django_db +def test_po_imieniu_nazwisku_pomija_zajetego(): + # Fallback po nazwisku też respektuje guard user__isnull=True. + uczelnia = baker.make("bpp.Uczelnia") + autor = _autor_w_uczelni(uczelnia, imiona="Przemysław", nazwisko="Kowalczewski") + baker.make("bpp.BppUser", autor=autor, username="pierwszy") + + user = baker.make( + "bpp.BppUser", + username="drugi", + email="", + first_name="Przemysław", + last_name="Kowalczewski", + ) + user.accessible_uczelnie.add(uczelnia) + + user.sprobuj_dopasowac_autora() # bez wyjątku + + user.refresh_from_db() + assert user.autor_id is None diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index c954a548c..e85c9b9bc 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -1247,6 +1247,11 @@ def can_login_as(request, target_user): OIDC_RP_CLIENT_ID = _OIDC_CONFIG["client_id"] OIDC_RP_CLIENT_SECRET = _OIDC_CONFIG["client_secret"] + # Źródła claimów (konfigurowalne env-em, default mail-first / username z + # preferred_username) — czytane przez BppOIDCBackend. + OIDC_EMAIL_CLAIMS = _OIDC_CONFIG["email_claims"] + OIDC_USERNAME_CLAIMS = _OIDC_CONFIG["username_claims"] + # Endpointy: preferuj .well-known (źródło prawdy serwera), z fallbackiem na # konwencję Keycloaka gdy IdP nieosiągalny przy starcie. _oidc_endpoints = ( diff --git a/src/oidc_integration/backends.py b/src/oidc_integration/backends.py index 11279915c..890b27d57 100644 --- a/src/oidc_integration/backends.py +++ b/src/oidc_integration/backends.py @@ -5,13 +5,9 @@ from django.core.exceptions import SuspiciousOperation from mozilla_django_oidc.auth import OIDCAuthenticationBackend -logger = logging.getLogger(__name__) +from oidc_integration.conf import DEFAULT_EMAIL_CLAIMS, DEFAULT_USERNAME_CLAIMS -# Klucze claimów niosące adres e-mail, w kolejności preferencji: standardowy -# OIDC ``email`` ma pierwszeństwo, dalej warianty pisowni realmu -# (``e-mail``/``e_mail``), a ``mail`` (LDAP) na końcu jako ostatni fallback. -# Adres z `mail` bywa prywatny — instytucjonalny siedzi pod `email`/`e-mail`. -_EMAIL_CLAIM_KEYS = ("email", "e-mail", "e_mail", "mail") +logger = logging.getLogger(__name__) # „Zawiera domenę" = wygląda jak adres ``lokalna@domena.tld`` (z kropką w # części domenowej). Świadomie minimalistyczne: to nie walidacja RFC, tylko @@ -19,6 +15,31 @@ _EMAIL_SHAPE_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +def _email_claim_keys(): + """Klucze claimów e-mail wg preferencji (settings → default mail-first). + + Default ``DEFAULT_EMAIL_CLAIMS`` (mail-first) ustala ``conf.py``; settings + ``OIDC_EMAIL_CLAIMS`` (wpisywany z env w base.py) może to nadpisać. + """ + return tuple(getattr(settings, "OIDC_EMAIL_CLAIMS", None) or DEFAULT_EMAIL_CLAIMS) + + +def _username_claim_keys(): + """Klucze claimów dla username wg preferencji (settings → default).""" + return tuple( + getattr(settings, "OIDC_USERNAME_CLAIMS", None) or DEFAULT_USERNAME_CLAIMS + ) + + +def _first_claim(claims, keys): + """Pierwsza niepusta wartość claimu z ``keys`` (albo ``None``).""" + for key in keys: + value = claims.get(key) + if value: + return value + return None + + def _log_claims_debug(claims): """Zaloguj klucze i wartości claimów z Keycloaka na poziomie DEBUG. @@ -52,11 +73,13 @@ class BppOIDCBackend(OIDCAuthenticationBackend): ``email``, a realm wystawia adres pod różnymi kluczami. Ustalamy ``email`` w jednym miejscu — ``get_userinfo`` — przez które przechodzą wszystkie te metody (``get_or_create_user`` woła je na wyniku ``get_userinfo``). - Kolejność preferencji (``_resolve_email``): ``email`` → ``e-mail`` → - ``e_mail`` → ``mail``, a w ostateczności ``preferred_username`` jeśli - zawiera domenę. Brak adresu → ``SuspiciousOperation`` (login failure). - Adres pod ``mail`` bywa prywatny; instytucjonalny realm wystawia zwykle - pod ``email``/``e-mail`` — stąd ``mail`` jest ostatni, nie pierwszy. + Kolejność preferencji konfigurowalna (``OIDC_EMAIL_CLAIMS``, default + mail-first: ``mail`` → ``email`` → ``e-mail`` → ``e_mail``), a w + ostateczności ``preferred_username`` jeśli zawiera domenę. Brak adresu → + ``SuspiciousOperation`` (login failure). Default mail-first, bo w realmach + LDAP-owych (np. UAFM) ``email`` bywa adresem prywatnym, a instytucjonalny + siedzi pod ``mail`` — instalacje z odwrotną konwencją przestawiają + kolejność env-em ``DJANGO_BPP_OIDC__EMAIL_CLAIMS``. Przypisanie uczelni: konto dostaje ``accessible_uczelnie`` (M2M z PR #189) z uczelnią o ``skrot`` == skrótowi z konfiguracji OIDC. Ten sam skrót @@ -82,10 +105,11 @@ class BppOIDCBackend(OIDCAuthenticationBackend): @staticmethod def _resolve_email(claims): - """Ustal adres e-mail z claimów wg kolejności preferencji. + """Ustal adres e-mail z claimów wg konfigurowalnej kolejności. - Kolejność: ``email`` → ``e-mail`` → ``e_mail`` → ``mail`` (pierwszy - niepusty wygrywa). Gdy żaden nie niesie wartości, spada na + Kolejność z ``_email_claim_keys()`` (default mail-first: + ``mail`` → ``email`` → ``e-mail`` → ``e_mail``; pierwszy niepusty + wygrywa). Gdy żaden nie niesie wartości, spada na ``preferred_username`` — ale tylko jeśli ten wygląda jak adres (zawiera domenę, np. UPN ``99999@student-afm.edu.pl``). @@ -96,10 +120,10 @@ def _resolve_email(claims): 500), a ``django.security`` loguje go na WARNING. Bez instytucjonalnego adresu nie chcemy zakładać/dopasowywać konta. """ - for key in _EMAIL_CLAIM_KEYS: - value = claims.get(key) - if value: - return value + keys = _email_claim_keys() + value = _first_claim(claims, keys) + if value: + return value username = claims.get("preferred_username") or "" if _EMAIL_SHAPE_RE.match(username): @@ -107,7 +131,7 @@ def _resolve_email(claims): raise SuspiciousOperation( "OIDC: nie znaleziono adresu e-mail w claimach " - f"(email/e-mail/e_mail/mail), a preferred_username={username!r} " + f"({'/'.join(keys)}), a preferred_username={username!r} " "nie zawiera domeny — odrzucam logowanie." ) @@ -168,6 +192,9 @@ def update_user(self, user, claims): # Dopilnuj przypisania uczelni także istniejącym kontom przy kolejnym # logowaniu (idempotentnie) — np. założonym przed wprowadzeniem tej logiki. self._assign_uczelnia(user) + # Spróbuj powiązać autora (no-op jeśli już powiązany) — self-healing + # dla kont założonych zanim doszło dopasowanie. + user.sprobuj_dopasowac_autora() return user def _unique_username(self, base): @@ -188,14 +215,13 @@ def _unique_username(self, base): def create_user(self, claims): """Załóż zwykłe konto (bez is_staff) na podstawie claimów. - ``username`` = ``preferred_username`` → ``email`` → ``sub`` (pierwszy - niepusty). Hasło ustawiane na nieużywalne — logowanie wyłącznie przez - OIDC. Wywoływane tylko, gdy ``filter_users_by_claims`` (domyślnie po + ``username`` z ``_username_claim_keys()`` (default + ``preferred_username`` → ``email`` → ``sub``; pierwszy niepusty). + Hasło ustawiane na nieużywalne — logowanie wyłącznie przez OIDC. + Wywoływane tylko, gdy ``filter_users_by_claims`` (domyślnie po e-mailu) nie znajdzie istniejącego konta. """ - base_username = ( - claims.get("preferred_username") or claims.get("email") or claims.get("sub") - ) + base_username = _first_claim(claims, _username_claim_keys()) username = self._unique_username(base_username) email = claims.get("email") or "" @@ -209,6 +235,9 @@ def create_user(self, claims): user.save() self._assign_uczelnia(user) + # Powiąż autora po przypisaniu uczelni — dopasowanie jest scope'owane + # do accessible_uczelnie, więc kolejność ma znaczenie. + user.sprobuj_dopasowac_autora() logger.info( "OIDC: utworzono konto username=%s email=%s (zwykłe, bez is_staff)", diff --git a/src/oidc_integration/conf.py b/src/oidc_integration/conf.py index 3d6be6087..cd32e18dd 100644 --- a/src/oidc_integration/conf.py +++ b/src/oidc_integration/conf.py @@ -22,6 +22,14 @@ _PREFIX = "DJANGO_BPP_OIDC_" _FIELDS = ("CLIENT_ID", "CLIENT_SECRET", "ISSUER") +# Domyślne źródła claimów (kolejność = preferencja, pierwszy niepusty wygrywa). +# E-mail: `mail` (instytucjonalny w realmach LDAP-owych) PRZED `email` +# (bywa prywatny). Realm UAFM dowiódł, że `email` to adres prywatny, a +# instytucjonalny siedzi pod `mail` — stąd mail-first jako default. Override: +# DJANGO_BPP_OIDC__EMAIL_CLAIMS / DJANGO_BPP_OIDC_EMAIL_CLAIMS (CSV). +DEFAULT_EMAIL_CLAIMS = ("mail", "email", "e-mail", "e_mail") +DEFAULT_USERNAME_CLAIMS = ("preferred_username", "email", "sub") + # Mapowanie wewnętrznych nazw endpointów → kluczy w .well-known/openid-configuration _WELL_KNOWN_KEYS = { "authorization": "authorization_endpoint", @@ -54,6 +62,20 @@ def _get(environ, field, skrot): return environ.get(f"{_PREFIX}{field}") +def _get_claim_list(environ, field, skrot, default): + """Rozwiąż listę claimów (CSV) wg precedencji prefiks-skrót > bare. + + Wartość env to nazwy claimów po przecinku (np. ``mail,email``); białe + znaki obcinane, puste elementy pomijane. Brak/pusto → ``default``. + Zwraca krotkę (kolejność = preferencja). + """ + raw = _get(environ, field, skrot) + if not raw: + return tuple(default) + items = tuple(part.strip() for part in raw.split(",") if part.strip()) + return items or tuple(default) + + def _keycloak_endpoints(issuer): """Wyprowadź endpointy z URL issuera konwencją Keycloaka. @@ -116,7 +138,8 @@ def discover_oidc_config(environ=None): """Zwróć konfigurację OIDC albo ``None``, gdy nie skonfigurowano. Zwracany słownik: ``client_id``, ``client_secret``, ``issuer``, ``skrot`` - (może być ``None``), ``endpoints`` (dict z 4 adresami). ``None`` oznacza + (może być ``None``), ``endpoints`` (dict z 4 adresami), ``email_claims`` i + ``username_claims`` (krotki nazw claimów wg preferencji). ``None`` oznacza brak kompletu — aplikacja OIDC ma się wtedy w ogóle nie aktywować. """ environ = os.environ if environ is None else environ @@ -129,4 +152,10 @@ def discover_oidc_config(environ=None): config["skrot"] = skrot config["endpoints"] = _keycloak_endpoints(config["issuer"]) + config["email_claims"] = _get_claim_list( + environ, "EMAIL_CLAIMS", skrot, DEFAULT_EMAIL_CLAIMS + ) + config["username_claims"] = _get_claim_list( + environ, "USERNAME_CLAIMS", skrot, DEFAULT_USERNAME_CLAIMS + ) return config diff --git a/src/oidc_integration/tests/test_backends.py b/src/oidc_integration/tests/test_backends.py index 96946342c..3992e4366 100644 --- a/src/oidc_integration/tests/test_backends.py +++ b/src/oidc_integration/tests/test_backends.py @@ -60,7 +60,26 @@ def test_normalized_uzupelnia_email_z_mail(): assert out["mail"] == "jan@uafm.edu.pl" -def test_normalized_nie_nadpisuje_istniejacego_email(): +def test_normalized_mail_wygrywa_z_email(): + # Default mail-first: `mail` (instytucjonalny) ma pierwszeństwo nad `email` + # (prywatny). UAFM: email='...@kowalczewski.pl', mail='...@uafm.edu.pl'. + out = _backend()._normalized( + {"email": "prywatny@kowalczewski.pl", "mail": "instytut@uafm.edu.pl"} + ) + assert out["email"] == "instytut@uafm.edu.pl" + # oryginalny prywatny `email` zachowany pod swoim kluczem + assert out["mail"] == "instytut@uafm.edu.pl" + + +def test_normalized_email_jako_fallback_gdy_brak_mail(): + # `email` używany tylko, gdy brak `mail`. + out = _backend()._normalized({"email": "prywatny@kowalczewski.pl", "sub": "1"}) + assert out["email"] == "prywatny@kowalczewski.pl" + + +@override_settings(OIDC_EMAIL_CLAIMS=["email", "mail"]) +def test_normalized_kolejnosc_emaila_konfigurowalna(): + # Override przez settings: gdy realm woli `email`, można przestawić. out = _backend()._normalized( {"email": "wlasny@uafm.edu.pl", "mail": "inny@uafm.edu.pl"} ) @@ -77,7 +96,8 @@ def test_normalized_uzupelnia_email_z_e_mail_z_podkresleniem(): assert out["email"] == "jan@uafm.edu.pl" -def test_normalized_priorytet_email_przed_wszystkimi(): +def test_normalized_priorytet_mail_przed_wszystkimi(): + # Default mail-first: `mail` wygrywa ze wszystkimi wariantami. out = _backend()._normalized( { "email": "a@uafm.edu.pl", @@ -86,14 +106,13 @@ def test_normalized_priorytet_email_przed_wszystkimi(): "mail": "d@uafm.edu.pl", } ) - assert out["email"] == "a@uafm.edu.pl" + assert out["email"] == "d@uafm.edu.pl" -def test_normalized_priorytet_e_mail_przed_mail(): - # brak `email`; warianty z myślnikiem/podkreśleniem mają pierwszeństwo - # przed `mail` (kolejność: email → e-mail → e_mail → mail). - out = _backend()._normalized({"e-mail": "b@uafm.edu.pl", "mail": "d@uafm.edu.pl"}) - assert out["email"] == "b@uafm.edu.pl" +def test_normalized_priorytet_email_przed_e_mail_gdy_brak_mail(): + # brak `mail`; dalsza kolejność: email → e-mail → e_mail. + out = _backend()._normalized({"email": "a@uafm.edu.pl", "e-mail": "b@uafm.edu.pl"}) + assert out["email"] == "a@uafm.edu.pl" def test_normalized_fallback_na_preferred_username_z_domena(): @@ -211,6 +230,25 @@ def test_create_user_username_fallback_do_sub(): assert user.is_staff is False +@pytest.mark.django_db +@override_settings(OIDC_USERNAME_CLAIMS=["email", "sub"]) +def test_create_user_username_z_settingu(): + # Override źródła username: gdy konfiguracja woli email zamiast + # preferred_username, konto bierze username z email. + backend = _backend() + backend.UserModel = get_user_model() + + user = backend.create_user( + { + "preferred_username": "jkowalski", + "email": "jan@uafm.edu.pl", + "sub": "abc-123", + } + ) + + assert user.username == "jan@uafm.edu.pl" + + @pytest.mark.django_db @override_settings(OIDC_LOGIN_SKROT="UAFM") def test_create_user_przypisuje_uczelnie_wg_skrotu(): @@ -238,6 +276,74 @@ def test_create_user_bez_pasujacej_uczelni_nie_przypisuje(): assert user.accessible_uczelnie.count() == 0 +@pytest.mark.django_db +@override_settings(OIDC_LOGIN_SKROT="UAFM") +def test_create_user_dopasowuje_autora_w_uczelni(): + # Po przypisaniu uczelni (skrot UAFM) konto dopasowuje autora z tej + # uczelni po e-mailu (= znormalizowany `mail`). + uczelnia = baker.make("bpp.Uczelnia", skrot="UAFM") + jednostka = baker.make("bpp.Jednostka", uczelnia=uczelnia) + autor = baker.make( + "bpp.Autor", aktualna_jednostka=jednostka, email="jan@uafm.edu.pl" + ) + + backend = _backend() + backend.UserModel = get_user_model() + + user = backend.create_user( + backend._normalized( + {"preferred_username": "jkowalski", "mail": "jan@uafm.edu.pl"} + ) + ) + + user.refresh_from_db() + assert user.autor_id == autor.pk + + +@pytest.mark.django_db +@override_settings(OIDC_LOGIN_SKROT="UAFM") +def test_create_user_nie_dopasowuje_autora_z_obcej_uczelni(): + # Autor istnieje w innej uczelni niż realm OIDC → konto bez powiązania. + baker.make("bpp.Uczelnia", skrot="UAFM") + obca = baker.make("bpp.Uczelnia", skrot="INNA") + jednostka = baker.make("bpp.Jednostka", uczelnia=obca) + baker.make("bpp.Autor", aktualna_jednostka=jednostka, email="jan@uafm.edu.pl") + + backend = _backend() + backend.UserModel = get_user_model() + + user = backend.create_user( + backend._normalized( + {"preferred_username": "jkowalski", "mail": "jan@uafm.edu.pl"} + ) + ) + + user.refresh_from_db() + assert user.autor_id is None + + +@pytest.mark.django_db +@override_settings(OIDC_LOGIN_SKROT="UAFM") +def test_update_user_dopasowuje_autora(): + # Konto założone wcześniej (bez autora) dostaje powiązanie przy kolejnym + # logowaniu przez update_user. + uczelnia = baker.make("bpp.Uczelnia", skrot="UAFM") + jednostka = baker.make("bpp.Jednostka", uczelnia=uczelnia) + autor = baker.make( + "bpp.Autor", aktualna_jednostka=jednostka, email="jan@uafm.edu.pl" + ) + + UserModel = get_user_model() + user = UserModel.objects.create_user(username="jkowalski", email="jan@uafm.edu.pl") + + backend = _backend() + backend.UserModel = UserModel + backend.update_user(user, {"email": "jan@uafm.edu.pl"}) + + user.refresh_from_db() + assert user.autor_id == autor.pk + + @pytest.mark.django_db @override_settings(OIDC_LOGIN_SKROT="") def test_create_user_bez_skrotu_nie_przypisuje(): diff --git a/src/oidc_integration/tests/test_conf.py b/src/oidc_integration/tests/test_conf.py index 61b1909eb..bb46badf5 100644 --- a/src/oidc_integration/tests/test_conf.py +++ b/src/oidc_integration/tests/test_conf.py @@ -86,6 +86,52 @@ def test_endpointy_wyprowadzone_z_issuera(): assert ep["end_session"] == f"{base}/logout" +def _bare_env(): + return { + "DJANGO_BPP_OIDC_CLIENT_ID": "abc", + "DJANGO_BPP_OIDC_CLIENT_SECRET": "sekret", + "DJANGO_BPP_OIDC_ISSUER": ISSUER, + } + + +def test_email_claims_default_mail_first(): + # Bez konfiguracji: domyślnie `mail` ma pierwszeństwo (instytucjonalny), + # `email` (prywatny) jest fallbackiem. + cfg = discover_oidc_config(_bare_env()) + assert cfg["email_claims"] == ("mail", "email", "e-mail", "e_mail") + + +def test_username_claims_default(): + cfg = discover_oidc_config(_bare_env()) + assert cfg["username_claims"] == ("preferred_username", "email", "sub") + + +def test_email_claims_z_env_csv(): + env = _bare_env() + env["DJANGO_BPP_OIDC_EMAIL_CLAIMS"] = "email, mail" + cfg = discover_oidc_config(env) + assert cfg["email_claims"] == ("email", "mail") + + +def test_email_claims_prefiks_ma_pierwszenstwo_nad_bare(): + env = { + "DJANGO_BPP_OIDC_UAFM_CLIENT_ID": "abc", + "DJANGO_BPP_OIDC_UAFM_CLIENT_SECRET": "sekret", + "DJANGO_BPP_OIDC_UAFM_ISSUER": ISSUER, + "DJANGO_BPP_OIDC_UAFM_EMAIL_CLAIMS": "mail", + "DJANGO_BPP_OIDC_EMAIL_CLAIMS": "email", + } + cfg = discover_oidc_config(env) + assert cfg["email_claims"] == ("mail",) + + +def test_username_claims_z_env_csv(): + env = _bare_env() + env["DJANGO_BPP_OIDC_USERNAME_CLAIMS"] = "sub,preferred_username" + cfg = discover_oidc_config(env) + assert cfg["username_claims"] == ("sub", "preferred_username") + + def test_fetch_well_known_fallback_na_bledzie_sieci(monkeypatch): import requests From eedc422fa1225ca0c553544b685236db1c428685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 14:53:46 +0200 Subject: [PATCH 243/247] PBN import: obca jednostka per-uczelnia + gate-check (multi-hosted) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import PBN wywalał się na kroku institution_setup z błędem triggera bpp_jednostka_wydzial_sprawdz_uczelnia_id ("Uczelnia jednostki i wydzialu musi byc identyczna"). Przyczyna: get_or_create(nazwa="Obca jednostka") szukał po globalnie-unikalnej nazwie (Jednostka.nazwa/skrot są unique=True, a w multi-hosted uczelnie współdzielą bazę), więc trafiał w obcą jednostkę INNEJ uczelni i linkował ją do wydziału bieżącej → trigger. - znajdz_lub_utworz_obca_jednostke(uczelnia, wydzial=None): idempotentny helper, FK Uczelnia.obca_jednostka jako źródło prawdy, nazwa/skrót sufiksowane skrótem uczelni ("Obca jednostka "). - institution_setup woła helper zamiast kolidującego get_or_create. - sprawdz_obca_jednostka(uczelnia): gate-check (FK ustawiony, ta sama uczelnia, skupia_pracownikow=False, podpięta do wydziału). Wpięty w StartImportView.post (blokada startu) i ImportDashboardView (baner). - create_obca_jednostka: polecenie provisionujące/naprawiające obcą jednostkę dla wszystkich uczelni (idempotent, --dry-run, opcjonalny arg). - znajdz_lub_utworz_wydzial_domyslny i _jednostke_domyslna: create-path sufiksowany skrótem uczelni (ta sama klasa globalnej kolizji); skróty przycinane do limitów kolumn (Wydzial.skrot varchar(10), Jednostka.skrot varchar(128)). FIND po prefiksie wciąż matchuje legacy rekordy. Testy: pełna suita pakietu src/pbn_import/ — 369 passed (Postgres + trigger przez testcontainers). Repro produkcyjnego błędu w test_institution_importer_does_not_collide_with_other_uczelnia_obca. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-18-obca-jednostka-multi-hosted-design.md | 244 ++++++++++++++++++ .../commands/create_obca_jednostka.py | 82 ++++++ .../templates/pbn_import/dashboard.html | 10 + .../tests/test_create_obca_jednostka.py | 65 +++++ .../tests/test_institution_import.py | 196 +++++++++++++- src/pbn_import/tests/test_views_dashboard.py | 52 ++++ src/pbn_import/utils/institution_import.py | 160 ++++++++++-- src/pbn_import/views.py | 59 +++-- 8 files changed, 816 insertions(+), 52 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-18-obca-jednostka-multi-hosted-design.md create mode 100644 src/pbn_import/management/commands/create_obca_jednostka.py create mode 100644 src/pbn_import/tests/test_create_obca_jednostka.py diff --git a/docs/superpowers/specs/2026-06-18-obca-jednostka-multi-hosted-design.md b/docs/superpowers/specs/2026-06-18-obca-jednostka-multi-hosted-design.md new file mode 100644 index 000000000..62e171b02 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-obca-jednostka-multi-hosted-design.md @@ -0,0 +1,244 @@ +# Obca jednostka w multi-hosted — provisioning + gate-check importu PBN + +Data: 2026-06-18 +Branch: `feature/multi-hosted-config` +Status: zatwierdzony design, przed planem implementacji + +## Problem + +Import PBN (`src/pbn_import/`) failuje na kroku `institution_setup` +komunikatem z triggera bazodanowego: + +``` +AssertionError: Uczelnia jednostki i wydzialu musi byc identyczna +(funkcja PL/pgSQL bpp_jednostka_wydzial_sprawdz_uczelnia_id) +``` + +### Diagnoza (root cause) + +Wątek `uczelnia` jest przekazywany **poprawnie**: `ImportManager` → +`_execute_step(uczelnia=self.uczelnia)` → `InstitutionImporter.run()` +(`import_manager.py:237-242`, `institution_import.py:159-160`). To NIE jest +błąd przekazywania uczelni. + +Faktyczna przyczyna jest w `institution_import.py:196`: + +```python +obca_jednostka, created = Jednostka.objects.get_or_create( + nazwa="Obca jednostka", # lookup TYLKO po nazwie + defaults={"skrot": "O", "uczelnia": uczelnia, "skupia_pracownikow": False}, +) +``` + +Dwa fakty się zderzają: + +1. `Jednostka.nazwa` oraz `Jednostka.skrot` są `unique=True` **globalnie** + (`jednostka.py:113-114`), a w multi-hosted wszystkie uczelnie współdzielą + jedną bazę. Nazwa `"Obca jednostka"` może więc istnieć tylko **raz w całym + systemie**. +2. `get_or_create(nazwa=…, defaults={…})` szuka **wyłącznie** po `nazwa`. + Zawartość `defaults` (w tym `uczelnia`) jest stosowana tylko przy INSERT. + Przy trafieniu zwraca istniejący rekord z **cudzą** uczelnią. + +Scenariusz awarii (MWSL jako druga uczelnia w instalacji): pierwsza/oryginalna +uczelnia już ma `"Obca jednostka"`. Import MWSL tworzy własny wydział i jednostkę +domyślną (scoped per-uczelnia, OK), ale `get_or_create(nazwa="Obca jednostka")` +trafia w jednostkę **innej** uczelni (`created=False`, `defaults` zignorowane). +Potem `Jednostka_Wydzial.get_or_create(jednostka=obca[inna uczelnia], +wydzial=wydzial[MWSL])` → trigger → assertion (`institution_import.py:209`). + +Latentny błąd obok: nawet gdyby trigger nie zadziałał, kod ustawiłby +`MWSL.obca_jednostka = ` (`institution_import.py:216`). + +### Drugi, latentny błąd tej samej klasy + +`znajdz_lub_utworz_wydzial_domyslny(uczelnia)` TWORZY z globalnie unikalną nazwą +`"Wydział Domyślny"` (`institution_import.py:42-47`). Druga uczelnia potrzebująca +domyślnego wydziału dostałaby `IntegrityError` na unikalnej nazwie/skrócie. + +## Cel + +1. Obca jednostka ma być **per-uczelnia**, z nazwą `"Obca jednostka " + + uczelnia.skrot` (np. `"Obca jednostka UML"`, `"Obca jednostka UAFM"`). +2. Provisioning (utworzenie + podpięcie do wydziału + ustawienie FK) ma być + **idempotentny** i dostępny jako osobne polecenie `create_obca_jednostka` + działające dla **wszystkich** uczelni. +3. Brak/niespójność obcej jednostki ma być **zgłaszana użytkownikowi wcześnie** — + przy wejściu na stronę importu i przy submicie formularza nowego importu. +4. Import (krok `institution_setup`) ma **auto-tworzyć** obcą jednostkę przez ten + sam helper (Option A) — kolizja zniknie na każdej ścieżce wejścia (web i CLI). + +## Źródło prawdy + +Kanonicznym wyznacznikiem „która jednostka jest obcą jednostką tej uczelni" jest +FK `Uczelnia.obca_jednostka` (`uczelnia.py:252-262`). NIE używamy zapytania +`skupia_pracownikow=False` jako detektora — wg `help_text` modelu +(`jednostka.py:140-146`) ta flaga jest też zdejmowana dla Studentów / +Doktorantów / Emerytów, więc dałaby fałszywe trafienia. `Uczelnia.clean()` +(`uczelnia.py:726-730`) już wymusza `obca_jednostka.skupia_pracownikow == False` +— nasz provisioning honoruje ten invariant, a walidator może się na nim oprzeć. + +## Architektura — 5 komponentów + +Wszystkie czytają/zapisują jedno źródło prawdy: `Uczelnia.obca_jednostka`. + +### 1. Helper provisioningu — `institution_import.py` + +```python +def znajdz_lub_utworz_obca_jednostke(uczelnia) -> tuple[Jednostka, bool]: +``` + +Idempotentny, w kolejności (pierwsze trafienie wygrywa): + +1. `uczelnia.obca_jednostka_id` ustawiony i jego target ma `uczelnia_id == + uczelnia.pk` → użyj go. +2. `Jednostka.objects.filter(uczelnia=uczelnia, skupia_pracownikow=False, + nazwa__istartswith="Obca jednostka").first()` → użyj (matchuje też legacy + `"Obca jednostka"` bez sufiksu). +3. utwórz: `nazwa=f"Obca jednostka {uczelnia.skrot}"`, + `skrot=f"Obca {uczelnia.skrot}"`, `skupia_pracownikow=False`, + `uczelnia=uczelnia`. + +Następnie (zawsze, idempotentnie): + +- zapewnij domyślny wydział uczelni przez **poprawiony** + `znajdz_lub_utworz_wydzial_domyslny` (patrz pkt 2), +- utwórz `Jednostka_Wydzial(jednostka=obca, wydzial=wydzial_domyslny)` jeśli brak + (oba należą do tej samej uczelni → trigger przechodzi), +- ustaw `uczelnia.obca_jednostka` jeśli jeszcze nie wskazuje tej jednostki + (`save(update_fields=["obca_jednostka"])`). + +Zwraca `(obca_jednostka, created)`. + +### 2. Poprawka `znajdz_lub_utworz_wydzial_domyslny` — `institution_import.py` + +Ścieżka FIND bez zmian (`nazwa__istartswith`, scoped per-uczelnia — backward +compatible, zero churnu na istniejących instalacjach). Ścieżka CREATE dostaje +nazwy unikalne per-uczelnia: + +- `nazwa=f"{nazwa_domyslna} {uczelnia.skrot}"` (np. `"Wydział Domyślny UML"`), +- `skrot=f"WD-{uczelnia.skrot}"` (≤128 znaków, unikalne dzięki sufiksowi). + +### 3. Walidator pre-import — `institution_import.py` + +```python +def sprawdz_obca_jednostka(uczelnia) -> str | None: +``` + +Zwraca czytelny opis problemu albo `None` gdy OK. Sprawdza: + +- `uczelnia.obca_jednostka` ustawione, +- target należy do `uczelnia` (`uczelnia_id == uczelnia.pk`), +- `skupia_pracownikow is False`, +- podpięta do wydziału tej samej uczelni (istnieje `Jednostka_Wydzial` z + `wydzial.uczelnia_id == uczelnia.pk`). + +Komunikat problemu kieruje do `manage.py create_obca_jednostka`. + +### 4. Integracja z widokami — `views.py` + +- `StartImportView.post` (`views.py:171`): dorzuć wynik `sprawdz_obca_jednostka( + uczelnia)` do istniejącej listy `errors` (`views.py:198`). Blokuje start + importu, pokazuje `messages.error`, działa też dla HX-Request (jak obecnie). +- `ImportDashboardView.get_context_data` (`views.py:57`): policz ten sam + walidator dla `Uczelnia.objects.get_for_request(request)`, wystaw + `context["obca_jednostka_problem"]`; szablon dashboardu renderuje baner + ostrzegawczy przy wejściu na stronę. + +### 5. Polecenie — `src/bpp/management/commands/create_obca_jednostka.py` + +Iteruje `Uczelnia.objects.all()`; dla każdej woła helper z pkt 1 (idempotent) i +wypisuje per-uczelnia co zrobił (utworzono / podpięto / już OK). + +Flagi: + +- `--dry-run` — tylko raport (przez `sprawdz_obca_jednostka`), bez zapisów; exit + code ≠ 0 jeśli którakolwiek uczelnia wymaga naprawy (health-check do CI), +- pozycyjny opcjonalny argument (slug lub pk uczelni) — ogranicza do jednej. + +Inwokacja w dokumentacji/komunikatach: `python src/manage.py create_obca_jednostka`. + +### 6. Naprawa źródła (`institution_setup`) — `institution_import.py:196-219` + +Zastąp kolidujący blok `get_or_create(nazwa="Obca jednostka", …)` + ręczne +linkowanie + ustawianie FK **jednym** wywołaniem: + +```python +obca_jednostka, created = znajdz_lub_utworz_obca_jednostke(uczelnia) +if created: + self.log("info", f"Utworzono obcą jednostkę: {obca_jednostka.nazwa}") +``` + +Krok nadal provisionuje obcą jednostkę (Option A), ale przez bezpieczny, +idempotentny, współdzielony kod. Web i CLI nie kolidują. + +## Ścieżki wejścia (dlaczego Option A) + +``` +Web: user → dashboard (GET, baner) → submit (POST, gate) → task → institution_setup +CLI: admin → manage.py pbn_importuj_uid → ImportManager → institution_setup +``` + +Gate w widokach chroni ścieżkę web (wczesny, przyjazny feedback). Ścieżka CLI +nie dotyka formularza, więc `institution_setup` woła helper (self-heal) — żadna +ścieżka nie kończy się kolizją. + +## Obsługa błędów + +- Helper jest idempotentny: ponowne wywołanie gdy jednostka istnieje to tani + no-op (find, bez create). +- Provisioning honoruje `Uczelnia.clean()` (skupia_pracownikow=False). +- Walidator nigdy nie tworzy — tylko raportuje (czysty podział check vs provision). +- Brak `uczelnia.skrot` nie wystąpi w praktyce (`Uczelnia` dziedziczy + `NazwaISkrot`, skrot unikalny) — sufiks daje globalną unikalność nazw. + +## Testy (TDD) + +Trigger jest triggerem bazodanowym → wymagany PostgreSQL (testcontainers). + +- **Repro**: dwie uczelnie współdzielące jedną legacy `"Obca jednostka"`; + `InstitutionImporter` dla drugiej — przed fixem assertion, po fixie zielono. +- Helper: idempotencja; poprawne nazwy per-uczelnia (`"Obca jednostka {skrot}"`); + FK ustawiony; link `Jednostka_Wydzial` utworzony; honoruje istniejący FK. +- `znajdz_lub_utworz_wydzial_domyslny`: CREATE daje nazwę z sufiksem; FIND wciąż + matchuje legacy `"Wydział Domyślny"`. +- Walidator: zwraca właściwy problem/`None` w stanach (brak FK, cudza uczelnia, + skupia_pracownikow=True, brak linku do wydziału, OK). +- Polecenie: `--dry-run` raportuje i nie zapisuje (exit ≠ 0 gdy są braki); realny + przebieg provisionuje wszystkie uczelnie; drugi przebieg to no-op; argument + pozycyjny ogranicza do jednej uczelni. +- Widok: submit zablokowany z komunikatem gdy obca jednostka niespójna; + dashboard wystawia `obca_jednostka_problem`. + +## Zmiany w istniejących testach + +`src/pbn_import/tests/test_institution_import.py:109`: +`assert obca_jednostka.nazwa == "Obca jednostka"` → +`assert obca_jednostka.nazwa == f"Obca jednostka {uczelnia.skrot}"` +(po zmianie ścieżka CREATE zawsze sufiksuje). Sprawdzić też ewentualne +asercje na `obca_jednostka.skrot == "O"`. + +## Decyzje as-built (uściślenia względem pierwotnego designu) + +- **Skróty przycinane do limitu kolumny.** `Wydzial.skrot` to `varchar(10)`, + `Jednostka.skrot` to `varchar(128)`. Sufiks `uczelnia.skrot` (max 128) nie + zawsze się mieści, więc generowany skrót przycinamy (`[:10]` / `[:128]`). + Realne skróty uczelni są krótkie, więc forma czytelna przeżywa; przycięcie to + zabezpieczenie przed patologicznie długim skrótem (i przed `baker` generującym + skróty max-length w testach). +- **Walidator broni przed dryfem, nie przed stanem persystentnym.** + `Uczelnia.save()` sam egzekwuje `obca_jednostka.skupia_pracownikow == False`, + więc sprawdzenie tej flagi w `sprawdz_obca_jednostka` łapie tylko dryf + (przestawienie flagi na samej Jednostce bez rewalidacji Uczelni). +- **Domyślna jednostka też naprawiona.** `znajdz_lub_utworz_jednostke_domyslna` + miała tę samą latentną kolizję na create-path ("Jednostka Domyślna"/"JD" + globalnie unikalne) — dashboard importu (GET) auto-tworzy ją dla uczelni bez + jednostek, więc druga uczelnia dostawała `IntegrityError` (500). Create-path + sufiksujemy skrótem uczelni tak samo jak wydział. + +## Poza zakresem (YAGNI) + +- Migracja danych zmieniająca nazwy istniejących `"Obca jednostka"` na + sufiksowane — niepotrzebna; FIND po prefiksie matchuje legacy rekordy. +- Zmiana globalnego `unique=True` na `unique_together(uczelnia, nazwa)` — duża, + ryzykowna zmiana schematu; rozwiązujemy problem nazewnictwem, nie schematem. diff --git a/src/pbn_import/management/commands/create_obca_jednostka.py b/src/pbn_import/management/commands/create_obca_jednostka.py new file mode 100644 index 000000000..4e73bd463 --- /dev/null +++ b/src/pbn_import/management/commands/create_obca_jednostka.py @@ -0,0 +1,82 @@ +"""Zapewnij obcą jednostkę dla każdej uczelni w systemie (multi-hosted). + +W multi-hosted wszystkie uczelnie współdzielą jedną bazę, a ``Jednostka.nazwa`` / +``skrot`` są ``unique=True`` globalnie. Obca jednostka MUSI więc być per-uczelnia +(nazwa "Obca jednostka ") i podpięta do wydziału tej samej uczelni — +inaczej import PBN wywala się na triggerze ``bpp_jednostka_wydzial_ +sprawdz_uczelnia_id``. To polecenie provisionuje / naprawia ten stan hurtem. +""" + +from django.core.management.base import BaseCommand, CommandError + +from bpp.models import Uczelnia +from pbn_import.utils.institution_import import ( + sprawdz_obca_jednostka, + znajdz_lub_utworz_obca_jednostke, +) + + +class Command(BaseCommand): + help = ( + "Zapewnia obcą jednostkę (per-uczelnia, podpiętą do wydziału domyślnego) " + "dla każdej uczelni. Idempotentne. --dry-run tylko raportuje braki." + ) + + def add_arguments(self, parser): + parser.add_argument( + "uczelnia", + nargs="?", + default=None, + help="Opcjonalnie: pk lub slug jednej uczelni (domyślnie wszystkie).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help=( + "Tylko raportuj braki (bez zapisów). Kończy z kodem !=0, gdy " + "którakolwiek uczelnia wymaga naprawy." + ), + ) + + def _wybierz_uczelnie(self, identyfikator): + if identyfikator is None: + return Uczelnia.objects.all().order_by("pk") + + qs = Uczelnia.objects.all() + if identyfikator.isdigit(): + uczelnia = qs.filter(pk=int(identyfikator)).first() + else: + uczelnia = qs.filter(slug=identyfikator).first() + if uczelnia is None: + raise CommandError(f"Nie znaleziono uczelni: {identyfikator}") + return [uczelnia] + + def handle(self, *args, **options): + uczelnie = self._wybierz_uczelnie(options["uczelnia"]) + dry_run = options["dry_run"] + + problemy = 0 + for uczelnia in uczelnie: + if dry_run: + problem = sprawdz_obca_jednostka(uczelnia) + if problem: + problemy += 1 + self.stdout.write( + self.style.WARNING(f"[BRAK] {uczelnia}: {problem}") + ) + else: + self.stdout.write(f"[OK] {uczelnia}") + continue + + obca, created = znajdz_lub_utworz_obca_jednostke(uczelnia) + if created: + self.stdout.write( + self.style.SUCCESS(f"[UTWORZONO] {uczelnia}: {obca.nazwa}") + ) + else: + self.stdout.write(f"[OK] {uczelnia}: {obca.nazwa}") + + if dry_run and problemy: + raise CommandError( + f"{problemy} uczelni wymaga naprawy — uruchom bez --dry-run." + ) diff --git a/src/pbn_import/templates/pbn_import/dashboard.html b/src/pbn_import/templates/pbn_import/dashboard.html index 6e89450b7..62fd90125 100644 --- a/src/pbn_import/templates/pbn_import/dashboard.html +++ b/src/pbn_import/templates/pbn_import/dashboard.html @@ -29,6 +29,16 @@
    Brak aktywnej sesji PBN
    {% endif %} + {% if obca_jednostka_problem %} +
    +
    Brak obcej jednostki
    +

    {{ obca_jednostka_problem }}

    +

    Obca jednostka skupia autorów spoza uczelni i jest + wymagana do importu. Administrator powinien uruchomić polecenie + python src/manage.py create_obca_jednostka.

    +
    + {% endif %} + {% if auto_cancelled_message %}
    Import automatycznie anulowany
    diff --git a/src/pbn_import/tests/test_create_obca_jednostka.py b/src/pbn_import/tests/test_create_obca_jednostka.py new file mode 100644 index 000000000..f4ed31423 --- /dev/null +++ b/src/pbn_import/tests/test_create_obca_jednostka.py @@ -0,0 +1,65 @@ +"""Tests for the create_obca_jednostka management command.""" + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError +from model_bakery import baker + +from bpp.models import Jednostka, Uczelnia +from pbn_import.utils.institution_import import sprawdz_obca_jednostka + + +@pytest.fixture +def dwie_uczelnie(db): + u1 = baker.make(Uczelnia, skrot="UML") + u2 = baker.make(Uczelnia, skrot="UAFM") + return u1, u2 + + +def test_command_provisions_all_uczelnie(dwie_uczelnie): + u1, u2 = dwie_uczelnie + + call_command("create_obca_jednostka") + + u1.refresh_from_db() + u2.refresh_from_db() + assert sprawdz_obca_jednostka(u1) is None + assert sprawdz_obca_jednostka(u2) is None + assert u1.obca_jednostka.nazwa == "Obca jednostka UML" + assert u2.obca_jednostka.nazwa == "Obca jednostka UAFM" + + +def test_command_is_idempotent(dwie_uczelnie): + call_command("create_obca_jednostka") + call_command("create_obca_jednostka") + + assert Jednostka.objects.filter(skupia_pracownikow=False).count() == 2 + + +def test_command_dry_run_does_not_write_and_signals(dwie_uczelnie): + u1, _ = dwie_uczelnie + + with pytest.raises(CommandError): + call_command("create_obca_jednostka", "--dry-run") + + u1.refresh_from_db() + assert u1.obca_jednostka is None + assert Jednostka.objects.filter(skupia_pracownikow=False).count() == 0 + + +def test_command_dry_run_clean_is_silent(dwie_uczelnie): + call_command("create_obca_jednostka") + + # Wszystko już skonfigurowane — dry-run nie sygnalizuje błędu (exit 0). + call_command("create_obca_jednostka", "--dry-run") + + +def test_command_single_uczelnia_arg(dwie_uczelnie): + u1, u2 = dwie_uczelnie + + call_command("create_obca_jednostka", str(u1.pk)) + + u1.refresh_from_db() + u2.refresh_from_db() + assert u1.obca_jednostka is not None + assert u2.obca_jednostka is None diff --git a/src/pbn_import/tests/test_institution_import.py b/src/pbn_import/tests/test_institution_import.py index 012af5961..7d592495a 100644 --- a/src/pbn_import/tests/test_institution_import.py +++ b/src/pbn_import/tests/test_institution_import.py @@ -7,7 +7,9 @@ from pbn_import.models import ImportLog, ImportSession from pbn_import.utils.institution_import import ( InstitutionImporter, + sprawdz_obca_jednostka, znajdz_lub_utworz_jednostke_domyslna, + znajdz_lub_utworz_obca_jednostke, znajdz_lub_utworz_wydzial_domyslny, zrob_skrot, ) @@ -21,7 +23,10 @@ def session(db, django_user_model): @pytest.fixture def uczelnia(db): - return baker.make(Uczelnia) + # Krótki, realistyczny skrót — w multi-hosted nazwy domyślnych jednostek / + # wydziałów / obcej jednostki sufiksujemy skrótem uczelni (baker domyślnie + # generuje skrót max-length, co przepełniłoby kolumny po sufiksacji). + return baker.make(Uczelnia, skrot="UCZ") def test_zrob_skrot_keeps_uppercase_and_punctuation_only(): @@ -51,7 +56,22 @@ def test_find_or_create_default_wydzial_creates_with_generated_short_name(uczeln assert created is True assert wydzial.uczelnia == uczelnia - assert wydzial.skrot == "WT-J" + assert wydzial.skrot == "WT-J-UCZ" + + +def test_find_or_create_default_wydzial_create_path_is_uczelnia_unique(db): + u1 = baker.make(Uczelnia, skrot="UML") + u2 = baker.make(Uczelnia, skrot="UAFM") + + w1, c1 = znajdz_lub_utworz_wydzial_domyslny(u1) + w2, c2 = znajdz_lub_utworz_wydzial_domyslny(u2) + + assert c1 is True and c2 is True + assert w1.nazwa == "Wydział Domyślny UML" + assert w2.nazwa == "Wydział Domyślny UAFM" + # Globalnie unikalne nazwa/skrot — drugi create nie może wywalić IntegrityError. + assert w1.skrot != w2.skrot + assert w1.skrot == "WD-UML" def test_find_or_create_default_jednostka_reuses_uczelnia_scoped_match(uczelnia): @@ -73,11 +93,173 @@ def test_find_or_create_default_jednostka_creates_for_uczelnia(uczelnia): jednostka, created = znajdz_lub_utworz_jednostke_domyslna(uczelnia) assert created is True - assert jednostka.nazwa == "Jednostka Domyślna" - assert jednostka.skrot == "JD" + assert jednostka.nazwa == "Jednostka Domyślna UCZ" + assert jednostka.skrot == "JD-UCZ" assert jednostka.uczelnia == uczelnia +def test_find_or_create_default_jednostka_create_path_is_uczelnia_unique(db): + u1 = baker.make(Uczelnia, skrot="UML") + u2 = baker.make(Uczelnia, skrot="UAFM") + + j1, c1 = znajdz_lub_utworz_jednostke_domyslna(u1) + j2, c2 = znajdz_lub_utworz_jednostke_domyslna(u2) + + assert c1 is True and c2 is True + assert j1.nazwa == "Jednostka Domyślna UML" + assert j2.nazwa == "Jednostka Domyślna UAFM" + # Globalnie unikalne nazwa/skrot — drugi create nie wywala IntegrityError. + assert j1.skrot != j2.skrot + assert j1.skrot == "JD-UML" + + +def test_obca_jednostka_helper_creates_uczelnia_scoped(uczelnia): + obca, created = znajdz_lub_utworz_obca_jednostke(uczelnia) + uczelnia.refresh_from_db() + + assert created is True + assert obca.nazwa == "Obca jednostka UCZ" + assert obca.skupia_pracownikow is False + assert obca.uczelnia == uczelnia + assert uczelnia.obca_jednostka == obca + assert Jednostka_Wydzial.objects.filter( + jednostka=obca, + wydzial__uczelnia=uczelnia, + ).exists() + + +def test_obca_jednostka_helper_is_idempotent(uczelnia): + first, c1 = znajdz_lub_utworz_obca_jednostke(uczelnia) + second, c2 = znajdz_lub_utworz_obca_jednostke(uczelnia) + + assert c1 is True + assert c2 is False + assert first == second + assert ( + Jednostka.objects.filter( + uczelnia=uczelnia, + skupia_pracownikow=False, + ).count() + == 1 + ) + + +def test_obca_jednostka_helper_reuses_existing_fk(uczelnia): + existing = baker.make( + Jednostka, + nazwa="Cokolwiek Obcego", + uczelnia=uczelnia, + skupia_pracownikow=False, + ) + uczelnia.obca_jednostka = existing + uczelnia.save(update_fields=["obca_jednostka"]) + + obca, created = znajdz_lub_utworz_obca_jednostke(uczelnia) + + assert created is False + assert obca == existing + + +def test_obca_jednostka_helper_reuses_legacy_by_prefix(uczelnia): + legacy = baker.make( + Jednostka, + nazwa="Obca jednostka", + uczelnia=uczelnia, + skupia_pracownikow=False, + ) + + obca, created = znajdz_lub_utworz_obca_jednostke(uczelnia) + uczelnia.refresh_from_db() + + assert created is False + assert obca == legacy + assert uczelnia.obca_jednostka == legacy + + +def test_obca_jednostka_helper_two_uczelnie_no_collision(db): + u1 = baker.make(Uczelnia, skrot="UML") + u2 = baker.make(Uczelnia, skrot="UAFM") + + o1, _ = znajdz_lub_utworz_obca_jednostke(u1) + o2, _ = znajdz_lub_utworz_obca_jednostke(u2) + + assert o1.nazwa == "Obca jednostka UML" + assert o2.nazwa == "Obca jednostka UAFM" + assert o1 != o2 + + +def test_sprawdz_obca_jednostka_ok(uczelnia): + znajdz_lub_utworz_obca_jednostke(uczelnia) + uczelnia.refresh_from_db() + + assert sprawdz_obca_jednostka(uczelnia) is None + + +def test_sprawdz_obca_jednostka_brak_fk(uczelnia): + problem = sprawdz_obca_jednostka(uczelnia) + + assert problem is not None + assert "create_obca_jednostka" in problem + + +def test_sprawdz_obca_jednostka_cudza_uczelnia(uczelnia): + inna = baker.make(Uczelnia, skrot="INNA") + obca_innej = baker.make(Jednostka, uczelnia=inna, skupia_pracownikow=False) + uczelnia.obca_jednostka = obca_innej + uczelnia.save(update_fields=["obca_jednostka"]) + + assert sprawdz_obca_jednostka(uczelnia) is not None + + +def test_sprawdz_obca_jednostka_skupia_pracownikow(uczelnia): + # Uczelnia.save() pilnuje invariantu przy zapisie uczelni, ale flaga może + # zostać przestawiona na samej Jednostce niezależnie (dryf). Walidator musi + # to wychwycić przed importem. + obca = baker.make(Jednostka, uczelnia=uczelnia, skupia_pracownikow=False) + uczelnia.obca_jednostka = obca + uczelnia.save(update_fields=["obca_jednostka"]) + obca.skupia_pracownikow = True + obca.save(update_fields=["skupia_pracownikow"]) + uczelnia.refresh_from_db() + + assert sprawdz_obca_jednostka(uczelnia) is not None + + +def test_sprawdz_obca_jednostka_bez_wydzialu(uczelnia): + obca = baker.make(Jednostka, uczelnia=uczelnia, skupia_pracownikow=False) + uczelnia.obca_jednostka = obca + uczelnia.save(update_fields=["obca_jednostka"]) + + assert sprawdz_obca_jednostka(uczelnia) is not None + + +def test_institution_importer_does_not_collide_with_other_uczelnia_obca(session, db): + # Uczelnia A już ma globalnie-unikalną "Obca jednostka" (legacy). + uczelnia_a = baker.make(Uczelnia, skrot="UA") + baker.make( + Jednostka, + nazwa="Obca jednostka", + skrot="O", + uczelnia=uczelnia_a, + skupia_pracownikow=False, + ) + + # Import dla uczelni B nie może trafić w cudzą "Obca jednostka" ani wywalić + # triggera bpp_jednostka_wydzial_sprawdz_uczelnia_id. + uczelnia_b = baker.make(Uczelnia, skrot="UB") + result = InstitutionImporter(session, uczelnia=uczelnia_b).run() + + uczelnia_b.refresh_from_db() + obca_b = result["obca_jednostka"] + assert obca_b.uczelnia == uczelnia_b + assert obca_b.nazwa == "Obca jednostka UB" + assert uczelnia_b.obca_jednostka == obca_b + assert Jednostka_Wydzial.objects.filter( + jednostka=obca_b, + wydzial__uczelnia=uczelnia_b, + ).exists() + + def test_institution_importer_requires_uczelnia(session): importer = InstitutionImporter(session, uczelnia=None) @@ -104,9 +286,9 @@ def test_institution_importer_creates_defaults_links_and_session_config( jednostka = result["jednostka"] obca_jednostka = result["obca_jednostka"] - assert wydzial.nazwa == "Wydział Testów" - assert jednostka.nazwa == "Jednostka Domyślna" - assert obca_jednostka.nazwa == "Obca jednostka" + assert wydzial.nazwa == "Wydział Testów UCZ" + assert jednostka.nazwa == "Jednostka Domyślna UCZ" + assert obca_jednostka.nazwa == "Obca jednostka UCZ" assert obca_jednostka.skupia_pracownikow is False assert uczelnia.obca_jednostka == obca_jednostka assert Jednostka_Wydzial.objects.filter( diff --git a/src/pbn_import/tests/test_views_dashboard.py b/src/pbn_import/tests/test_views_dashboard.py index 799f61986..656421374 100644 --- a/src/pbn_import/tests/test_views_dashboard.py +++ b/src/pbn_import/tests/test_views_dashboard.py @@ -11,6 +11,7 @@ from bpp.models import Uczelnia from pbn_import.models import ImportSession +from pbn_import.utils.institution_import import znajdz_lub_utworz_obca_jednostke # ============================================================================ # DASHBOARD VIEW TESTS @@ -255,6 +256,30 @@ def test_dashboard_lists_only_request_uczelnia_units(self, django_user_model): assert my_wydzial in wydzialy assert foreign_wydzial not in wydzialy + def test_dashboard_warns_when_obca_jednostka_missing(self, django_user_model): + """Wejście na dashboard sygnalizuje brak obcej jednostki (gate-check).""" + client = Client() + user = baker.make(django_user_model, is_superuser=True) + baker.make(Uczelnia, pbn_integracja=True) + client.force_login(user) + + response = client.get(reverse("pbn_import:dashboard")) + + assert response.context["obca_jednostka_problem"] is not None + assert "Brak obcej jednostki" in response.content.decode("utf-8") + + def test_dashboard_no_warning_when_obca_jednostka_present(self, django_user_model): + """Gdy obca jednostka jest skonfigurowana — brak ostrzeżenia.""" + client = Client() + user = baker.make(django_user_model, is_superuser=True) + uczelnia = baker.make(Uczelnia, pbn_integracja=True) + znajdz_lub_utworz_obca_jednostke(uczelnia) + client.force_login(user) + + response = client.get(reverse("pbn_import:dashboard")) + + assert response.context["obca_jednostka_problem"] is None + def test_dashboard_single_real_unit_renders_select_not_hidden( self, django_user_model ): @@ -309,6 +334,7 @@ def test_start_import_creates_session(self, django_user_model): # uzywaj_wydzialow=False → only the default unit is required. uczelnia = baker.make(Uczelnia, pbn_integracja=True, uzywaj_wydzialow=False) jednostka = baker.make(Jednostka, skupia_pracownikow=True, uczelnia=uczelnia) + znajdz_lub_utworz_obca_jednostke(uczelnia) client.force_login(user) with ( @@ -339,6 +365,7 @@ def test_start_import_stores_config(self, django_user_model): uczelnia = baker.make(Uczelnia, pbn_integracja=True) wydzial = baker.make(Wydzial, nazwa="IT Department", uczelnia=uczelnia) jednostka = baker.make(Jednostka, skupia_pracownikow=True, uczelnia=uczelnia) + znajdz_lub_utworz_obca_jednostke(uczelnia) client.force_login(user) with ( @@ -375,6 +402,7 @@ def test_start_import_passes_uczelnia_id_from_request( uczelnia.uzywaj_wydzialow = False uczelnia.save() jednostka = baker.make(Jednostka, skupia_pracownikow=True, uczelnia=uczelnia) + znajdz_lub_utworz_obca_jednostke(uczelnia) client = Client() user = baker.make(django_user_model, is_superuser=True) @@ -472,6 +500,7 @@ def test_start_import_allowed_without_faculty_when_not_used( user = baker.make(django_user_model, is_superuser=True) uczelnia = baker.make(Uczelnia, pbn_integracja=True, uzywaj_wydzialow=False) jednostka = baker.make(Jednostka, skupia_pracownikow=True, uczelnia=uczelnia) + znajdz_lub_utworz_obca_jednostke(uczelnia) client.force_login(user) with ( @@ -503,6 +532,7 @@ def test_start_import_persists_canonical_default_jednostka_id( uczelnia = baker.make(Uczelnia, pbn_integracja=True, uzywaj_wydzialow=True) wydzial = baker.make(Wydzial, uczelnia=uczelnia) jednostka = baker.make(Jednostka, skupia_pracownikow=True, uczelnia=uczelnia) + znajdz_lub_utworz_obca_jednostke(uczelnia) client.force_login(user) with ( @@ -522,6 +552,28 @@ def test_start_import_persists_canonical_default_jednostka_id( assert session.config["default_jednostka_id"] == jednostka.pk assert session.config["wydzial_id"] == wydzial.pk + def test_start_import_blocked_without_obca_jednostka(self, django_user_model): + """Gate-check: brak obcej jednostki uczelni → blokada startu importu.""" + from bpp.models import Jednostka + + client = Client() + user = baker.make(django_user_model, is_superuser=True) + uczelnia = baker.make(Uczelnia, pbn_integracja=True, uzywaj_wydzialow=False) + jednostka = baker.make(Jednostka, skupia_pracownikow=True, uczelnia=uczelnia) + # celowo NIE provisionujemy obcej jednostki + client.force_login(user) + + with patch("pbn_import.tasks.run_pbn_import") as mock_task: + mock_task.delay.return_value = MagicMock(id="task-123") + + client.post( + reverse("pbn_import:start"), + {"jednostka_domyslna_id": jednostka.pk}, + ) + + assert not ImportSession.objects.filter(user=user).exists() + mock_task.delay.assert_not_called() + def test_start_import_rejects_foreign_uczelnia_jednostka(self, django_user_model): """Gate-check: jednostka z innej uczelni niż request → blokada startu.""" from bpp.models import Jednostka diff --git a/src/pbn_import/utils/institution_import.py b/src/pbn_import/utils/institution_import.py index 5d851edd6..603300744 100644 --- a/src/pbn_import/utils/institution_import.py +++ b/src/pbn_import/utils/institution_import.py @@ -37,11 +37,18 @@ def znajdz_lub_utworz_wydzial_domyslny(uczelnia, nazwa_domyslna="Wydział Domyś if wydzial: return wydzial, False - skrot = zrob_skrot(nazwa_domyslna) + # Multi-hosted: Wydzial.nazwa i .skrot są unique=True GLOBALNIE, a wszystkie + # uczelnie współdzielą jedną bazę. Nazwę/skrót tworzonego wydziału sufiksujemy + # skrótem uczelni, żeby druga uczelnia nie wpadła w IntegrityError na "Wydział + # Domyślny". Ścieżka FIND (istartswith) i tak matchuje legacy rekordy bez + # sufiksu, więc istniejące instalacje nie widzą churnu. return ( Wydzial.objects.create( - nazwa=nazwa_domyslna, - skrot=skrot, + nazwa=f"{nazwa_domyslna} {uczelnia.skrot}", + # Wydzial.skrot to varchar(10) — przycinamy, bo uczelnia.skrot bywa + # dłuższy. Realne skróty uczelni są krótkie, więc forma czytelna + # przeżywa; przycięcie chroni tylko przed patologicznie długim skrótem. + skrot=f"{zrob_skrot(nazwa_domyslna)}-{uczelnia.skrot}"[:10], uczelnia=uczelnia, ), True, @@ -66,16 +73,128 @@ def znajdz_lub_utworz_jednostke_domyslna(uczelnia, nazwa_domyslna="Jednostka Dom if jednostka: return jednostka, False + # Multi-hosted: Jednostka.nazwa/.skrot są unique=True GLOBALNIE — sufiksujemy + # skrótem uczelni, by druga uczelnia nie wpadła w IntegrityError na "Jednostka + # Domyślna"/"JD". FIND (istartswith) wciąż matchuje legacy rekordy bez sufiksu. return ( Jednostka.objects.create( - nazwa=nazwa_domyslna, - skrot="JD", + nazwa=f"{nazwa_domyslna} {uczelnia.skrot}", + # Jednostka.skrot to varchar(128) — przycinamy defensywnie. + skrot=f"JD-{uczelnia.skrot}"[:128], uczelnia=uczelnia, ), True, ) +def znajdz_lub_utworz_obca_jednostke(uczelnia, wydzial=None): + """Zapewnij obcą jednostkę dla uczelni (multi-hosted, idempotentnie). + + Obca jednostka skupia autorów nie będących pracownikami uczelni; procedury + importujące przypisują do niej osoby bez znanej afiliacji. Kanonicznym źródłem + prawdy jest FK ``Uczelnia.obca_jednostka`` — NIE zapytanie po + ``skupia_pracownikow=False`` (ta flaga jest też zdejmowana dla Studentów / + Doktorantów / Emerytów, więc dawałaby fałszywe trafienia). + + Kolejność (pierwsze trafienie wygrywa): + + 1. ``uczelnia.obca_jednostka`` wskazujące jednostkę tej uczelni. + 2. ``Jednostka`` tej uczelni z ``skupia_pracownikow=False`` i nazwą zaczynającą + się od "Obca jednostka" (matchuje też legacy rekord bez sufiksu skrótu). + 3. utworzenie nowej — nazwa/skrót sufiksowane skrótem uczelni, bo + ``Jednostka.nazwa``/``skrot`` są ``unique=True`` GLOBALNIE, a w multi-hosted + wszystkie uczelnie współdzielą jedną bazę (stąd kolizja "Obca jednostka"). + + Następnie (zawsze, idempotentnie): podpięcie do wydziału tej uczelni i + ustawienie ``uczelnia.obca_jednostka``. ``wydzial`` można podać jawnie (krok + importu podpina obcą jednostkę pod TEN sam wydział co jednostkę domyślną); + przy ``None`` helper sam ustala/tworzy "Wydział Domyślny" uczelni. Obca + jednostka i wydział należą do tej samej uczelni, więc trigger + ``bpp_jednostka_wydzial_sprawdz_uczelnia_id`` przechodzi. + + Zwraca ``(jednostka, created)`` — ``created`` mówi tylko o utworzeniu samej + Jednostki (krok 3), nie o ubocznym utworzeniu wydziału / linku / FK. + """ + obca = None + created = False + + if uczelnia.obca_jednostka_id: + candidate = uczelnia.obca_jednostka + if candidate.uczelnia_id == uczelnia.pk: + obca = candidate + + if obca is None: + obca = Jednostka.objects.filter( + uczelnia=uczelnia, + skupia_pracownikow=False, + nazwa__istartswith="Obca jednostka", + ).first() + + if obca is None: + obca = Jednostka.objects.create( + nazwa=f"Obca jednostka {uczelnia.skrot}", + # Jednostka.skrot to varchar(128) — przycinamy defensywnie. + skrot=f"Obca {uczelnia.skrot}"[:128], + uczelnia=uczelnia, + skupia_pracownikow=False, + ) + created = True + + # Podepnij do wydziału tej uczelni (idempotentnie). Oba obiekty należą do + # `uczelnia`, więc trigger spójności uczelni przechodzi. + if wydzial is None: + wydzial, _ = znajdz_lub_utworz_wydzial_domyslny(uczelnia) + Jednostka_Wydzial.objects.get_or_create(jednostka=obca, wydzial=wydzial) + + if uczelnia.obca_jednostka_id != obca.pk: + uczelnia.obca_jednostka = obca + uczelnia.save(update_fields=["obca_jednostka"]) + + return obca, created + + +def sprawdz_obca_jednostka(uczelnia): + """Gate-check: czy uczelnia ma poprawnie skonfigurowaną obcą jednostkę. + + Zwraca czytelny opis problemu (str) albo ``None``, gdy wszystko OK. Używane + PRZED importem PBN (wejście na dashboard importu i submit formularza nowego + importu), żeby zgłosić problem zanim import wystartuje — zamiast pozwolić mu + paść w tle na triggerze spójności uczelni. + + Sprawdza (źródło prawdy = FK ``Uczelnia.obca_jednostka``): + + - FK ustawiony, + - target należy do tej uczelni, + - ``skupia_pracownikow is False`` (invariant z ``Uczelnia.clean()``), + - obca jednostka podpięta do wydziału tej samej uczelni (inaczej import + trafiłby na trigger przy linkowaniu). + """ + napraw = " Uruchom: python src/manage.py create_obca_jednostka" + + obca = uczelnia.obca_jednostka + if obca is None: + return ( + "Uczelnia nie ma ustawionej obcej jednostki " + "(Uczelnia.obca_jednostka)." + napraw + ) + if obca.uczelnia_id != uczelnia.pk: + return "Obca jednostka uczelni należy do innej uczelni." + napraw + if obca.skupia_pracownikow: + return ( + "Obca jednostka ma skupia_pracownikow=True — musi być faktycznie " + "obca." + napraw + ) + podpieta = Jednostka_Wydzial.objects.filter( + jednostka=obca, + wydzial__uczelnia=uczelnia, + ).exists() + if not podpieta: + return ( + "Obca jednostka nie jest podpięta do żadnego wydziału tej uczelni." + napraw + ) + return None + + def resolve_default_jednostka(session, uczelnia): """Ustal domyślną jednostkę dla sesji importu (multi-hosted). @@ -191,32 +310,17 @@ def run(self, uczelnia=None): "info", f"Linked unit {jednostka.nazwa} to department {wydzial.nazwa}" ) - # Create foreign unit + # Create foreign unit. Multi-hosted: delegujemy do współdzielonego, + # idempotentnego helpera (uczelnia-scoped nazwa/skrót, podpięcie do + # wydziału, ustawienie FK Uczelnia.obca_jednostka). Wcześniejszy + # get_or_create(nazwa="Obca jednostka") trafiał w cudzą obcą jednostkę + # (nazwa unique GLOBALNIE) i wywalał trigger spójności uczelni. self.update_progress(2, 3, "Tworzenie obcej jednostki") - obca_jednostka, created = Jednostka.objects.get_or_create( - nazwa="Obca jednostka", - defaults={ - "skrot": "O", - "uczelnia": uczelnia, - "skupia_pracownikow": False, - }, - ) - - if created: - self.log("info", "Created foreign unit: Obca jednostka") - - # Link foreign unit to department - jw, created = Jednostka_Wydzial.objects.get_or_create( - jednostka=obca_jednostka, wydzial=wydzial + obca_jednostka, created = znajdz_lub_utworz_obca_jednostke( + uczelnia, wydzial=wydzial ) if created: - self.log("info", f"Linked foreign unit to department {wydzial.nazwa}") - - # Set foreign unit on Uczelnia - if uczelnia.obca_jednostka != obca_jednostka: - uczelnia.obca_jednostka = obca_jednostka - uczelnia.save() - self.log("info", "Set foreign unit on Uczelnia") + self.log("info", f"Created foreign unit: {obca_jednostka.nazwa}") self.update_progress(3, 3, "Zakończono konfigurację jednostek") diff --git a/src/pbn_import/views.py b/src/pbn_import/views.py index 380256c91..59f085951 100644 --- a/src/pbn_import/views.py +++ b/src/pbn_import/views.py @@ -20,6 +20,7 @@ ImportSession, ) from .utils.institution_import import ( + sprawdz_obca_jednostka, znajdz_lub_utworz_jednostke_domyslna, znajdz_lub_utworz_wydzial_domyslny, ) @@ -95,6 +96,13 @@ def get_context_data(self, **kwargs): context["uczelnia"] = uczelnia context["uzywaj_wydzialow"] = uczelnia.uzywaj_wydzialow if uczelnia else False + # Gate-check multi-hosted: obca jednostka MUSI istnieć i być podpięta do + # wydziału tej uczelni, inaczej import padnie na triggerze spójności. + # Sygnalizujemy to już przy wejściu na stronę (baner w szablonie). + context["obca_jednostka_problem"] = ( + sprawdz_obca_jednostka(uczelnia) if uczelnia else None + ) + # Sprawdź czy użytkownik ma ważny token PBN context["pbn_token_valid"] = self.request.user.pbn_token_possibly_valid() @@ -170,6 +178,39 @@ def get_motivational_message(self): class StartImportView(LoginRequiredMixin, ImportPermissionMixin, View): """Start a new import session""" + @staticmethod + def _bledy_kontekstu_uczelni(uczelnia, jednostka, wydzial): + """Gate-check multi-hosted: spójność wybranych encji z uczelnią requestu. + + Domyślna jednostka i wydział MUSZĄ należeć do tej samej uczelni, z której + idzie request (i do której pójdzie import) — inaczej import przypisywałby + autorów/prace do encji obcej uczelni (cichy wyciek danych między + tenantami). Egzekwujemy nawet przy zmanipulowanym formularzu (encje + zawężamy też w GET, ale POST musi się bronić sam). Dodatkowo obca + jednostka uczelni musi być skonfigurowana, bo krok institution_setup + padłby na triggerze ``bpp_jednostka_wydzial_sprawdz_uczelnia_id``. + + Zwraca listę komunikatów błędów (pustą, gdy wszystko OK). + """ + if uczelnia is None: + return [] + + errors = [] + if jednostka is not None and jednostka.uczelnia_id != uczelnia.pk: + errors.append( + "Wybrana domyślna jednostka należy do innej uczelni niż ta, " + "z której uruchamiasz import." + ) + if wydzial is not None and wydzial.uczelnia_id != uczelnia.pk: + errors.append( + "Wybrany domyślny wydział należy do innej uczelni niż ta, " + "z której uruchamiasz import." + ) + obca_problem = sprawdz_obca_jednostka(uczelnia) + if obca_problem: + errors.append(obca_problem) + return errors + def post(self, request): # Get configuration from POST data # Checkboxes are inverted - if checkbox is checked, we DON'T disable @@ -203,23 +244,7 @@ def post(self, request): if uzywaj_wydzialow and wydzial is None: errors.append("Wybierz domyślny wydział przed rozpoczęciem importu.") - # Gate-check multi-hosted: domyślna jednostka i wydział MUSZĄ należeć do - # tej samej uczelni, z której idzie request (i do której pójdzie import). - # Inaczej import przypisywałby autorów/prace do encji obcej uczelni — - # cichy wyciek danych między tenantami. Egzekwujemy nawet gdy formularz - # został zmanipulowany (encje zawężamy też w GET, ale POST musi się bronić - # sam — nie ufamy danym z requestu). - if uczelnia is not None: - if jednostka is not None and jednostka.uczelnia_id != uczelnia.pk: - errors.append( - "Wybrana domyślna jednostka należy do innej uczelni niż ta, " - "z której uruchamiasz import." - ) - if wydzial is not None and wydzial.uczelnia_id != uczelnia.pk: - errors.append( - "Wybrany domyślny wydział należy do innej uczelni niż ta, " - "z której uruchamiasz import." - ) + errors += self._bledy_kontekstu_uczelni(uczelnia, jednostka, wydzial) if errors: for error in errors: From 69d0ac1465b0f997152ab881b4466e89a0d5e44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 15:22:29 +0200 Subject: [PATCH 244/247] =?UTF-8?q?test(metryki):=20odflakuj=20test=5Fprof?= =?UTF-8?q?il=5Fmetryki=20=E2=80=94=20pinuj=20pola=20wej=C5=9Bciowe=20Metr?= =?UTF-8?q?ykaAutora?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI shard 11 wywalił test_profil_metryki_zaweza_do_uczelni_ogladajacego z NumericValueOutOfRange (numeric(5,2), < 10^3). Przyczyna: MetrykaAutora.save() przelicza procent_wykorzystania_slotow = slot_nazbierany/slot_maksymalny*100 (oraz srednia_za_slot_* = punkty/slot) z pól, które baker wypełniał losowymi DecimalField(10,4) — niezależne wartości dawały iloraz przepełniający pole procentu (lub średniej). Flaky zależnie od seeda/sharda (lokalnie 4/30). Pinujemy pola WEJŚCIOWE (slot_*/punkty_*) do zdrowych wartości — test sprawdza tylko zawężenie metryk po uczelni, nie liczby. 60/60 powtórzeń zielone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_views/test_profile_per_uczelnia.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/bpp/tests/test_views/test_profile_per_uczelnia.py b/src/bpp/tests/test_views/test_profile_per_uczelnia.py index 6eeab4d49..3b509ecf2 100644 --- a/src/bpp/tests/test_views/test_profile_per_uczelnia.py +++ b/src/bpp/tests/test_views/test_profile_per_uczelnia.py @@ -7,6 +7,8 @@ views/``, nie profil w ``bpp/views/``). """ +from decimal import Decimal + import pytest from django.contrib.sites.models import Site from model_bakery import baker @@ -27,17 +29,34 @@ def test_profil_metryki_zaweza_do_uczelni_ogladajacego( ): from ewaluacja_metryki.models import MetrykaAutora + # MetrykaAutora.save() PRZELICZA pola pochodne z pól slotów/punktów: + # srednia_za_slot_* = punkty_* / slot_* + # procent_wykorzystania_slotow = (slot_nazbierany / slot_maksymalny) * 100 + # Losowe wartości baker-a (DecimalField(10,4), niezależne) potrafią dać iloraz + # przepełniający procent (DecimalField(5,2), < 10^3) albo średnią — stąd flaky + # NumericValueOutOfRange zależny od seeda/sharda. Pinujemy pola WEJŚCIOWE do + # zdrowych wartości; test i tak nie patrzy na liczby, tylko na zawężenie po + # uczelni. + metryka_pola = dict( + slot_maksymalny=Decimal("10.0000"), + slot_nazbierany=Decimal("5.0000"), + punkty_nazbierane=Decimal("50.0000"), + slot_wszystkie=Decimal("10.0000"), + punkty_wszystkie=Decimal("50.0000"), + ) baker.make( MetrykaAutora, autor=autor_jan_kowalski, dyscyplina_naukowa=dyscyplina1, uczelnia=uczelnia, + **metryka_pola, ) baker.make( MetrykaAutora, autor=autor_jan_kowalski, dyscyplina_naukowa=dyscyplina1, uczelnia=druga_uczelnia_profile, + **metryka_pola, ) user = baker.make("bpp.BppUser", autor=autor_jan_kowalski) From e10e8790d53009a65ed0f1d918cb51610ee42490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 21:09:44 +0200 Subject: [PATCH 245/247] =?UTF-8?q?fix(pbn=5Fimport):=20nie=20twierd=C5=BA?= =?UTF-8?q?=20=E2=80=9Eprzebieg=C5=82=20pomy=C5=9Blnie"=20dla=20pustego=20?= =?UTF-8?q?logu=20trwaj=C4=85cej=20sesji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopka pustego logu sesji była zahardkodowana na „import przebiegł pomyślnie", co dla sesji `running` przeczyło nagłówkowi „Status: W trakcie". Brak wpisów (log odtwarzany z błędów/ostrzeżeń) ≠ sukces. Dobieram stopkę wg statusu: active → „wciąż trwa", completed → „pomyślnie", failed/cancelled → neutralnie ze statusem. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pbn_import/tests/test_log_export.py | 32 +++++++++++++++++++++++++ src/pbn_import/utils/log_export.py | 26 +++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/pbn_import/tests/test_log_export.py b/src/pbn_import/tests/test_log_export.py index 1c0ea3a0b..648ddcbe5 100644 --- a/src/pbn_import/tests/test_log_export.py +++ b/src/pbn_import/tests/test_log_export.py @@ -98,6 +98,38 @@ def test_empty_when_no_errors_or_warnings(session): assert "INFO_MSG" not in text +def test_empty_completed_claims_success(session): + # ``session`` fixture jest ``completed`` — tu wolno powiedzieć „pomyślnie". + text = render_session_log_text(session) + + assert "przebieg" in text.lower() + assert "pomyślnie" in text.lower() + + +@pytest.mark.parametrize("status", ["pending", "running", "paused"]) +def test_empty_active_session_does_not_claim_success(session, status): + # Import wciąż trwa: brak wpisów ≠ sukces. Nie wolno twierdzić „pomyślnie". + session.status = status + session.save(update_fields=["status"]) + + text = render_session_log_text(session) + + assert "pomyślnie" not in text.lower() + assert "wciąż trwa" in text.lower() + + +@pytest.mark.parametrize("status", ["failed", "cancelled"]) +def test_empty_failed_or_cancelled_does_not_claim_success(session, status): + # Zakończony, ale nie sukcesem — nie udajemy, że „przebiegł pomyślnie". + session.status = status + session.save(update_fields=["status"]) + + text = render_session_log_text(session) + + assert "pomyślnie" not in text.lower() + assert session.get_status_display().lower() in text.lower() + + def test_handles_missing_details_without_crashing(session): baker.make( ImportLog, diff --git a/src/pbn_import/utils/log_export.py b/src/pbn_import/utils/log_export.py index bbc7ff8f9..51d39788a 100644 --- a/src/pbn_import/utils/log_export.py +++ b/src/pbn_import/utils/log_export.py @@ -14,6 +14,10 @@ # Te same poziomy co zakładka „Błędy i ostrzeżenia". LOG_LEVELS = ["error", "critical", "warning"] +# Statusy, w których import jeszcze trwa (nie wolno twierdzić, że „przebiegł +# pomyślnie" — brak wpisów znaczy tylko „jak dotąd bez błędów"). +_ACTIVE_STATUSES = {"pending", "running", "paused"} + _HEADER_RULE = "=" * 80 _ENTRY_RULE = "-" * 80 # Klucze ``details`` wypisywane jawnie — reszta trafia do bloku „details:". @@ -70,6 +74,26 @@ def count_log_entries(session) -> int: return ImportLog.objects.filter(session=session, level__in=LOG_LEVELS).count() +def _no_entries_note(session) -> str: + """Stopka dla pustego logu — sformułowana zależnie od statusu sesji. + + „Brak wpisów" to NIE to samo co „sukces": log odtwarzamy z błędów/ostrzeżeń, + więc pusta lista znaczy tylko tyle, że ich (jeszcze) nie ma. Sukces wolno + deklarować dopiero po faktycznym zakończeniu (``completed``); w trakcie + mówimy o dotychczasowym przebiegu, a dla ``failed``/``cancelled`` nie + udajemy, że było dobrze. + """ + status = session.status + if status in _ACTIVE_STATUSES: + return "(jak dotąd bez błędów i ostrzeżeń — import wciąż trwa)" + if status == "completed": + return "(brak błędów i ostrzeżeń — import przebiegł pomyślnie)" + return ( + f"(brak zarejestrowanych błędów i ostrzeżeń; " + f"status importu: {session.get_status_display()})" + ) + + def render_session_log_text(session, limit: int | None = None) -> str: """Zbuduj symulowany raw log (tekst) z błędów i ostrzeżeń sesji. @@ -102,7 +126,7 @@ def render_session_log_text(session, limit: int | None = None) -> str: ] if not logs: - lines.append("(brak błędów i ostrzeżeń — import przebiegł pomyślnie)") + lines.append(_no_entries_note(session)) else: for log in logs: lines.extend(_render_entry(log)) From 8a2867c82b8221456a8c36bdfd1dbb4c845c4881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 21:33:26 +0200 Subject: [PATCH 246/247] =?UTF-8?q?fix(multihosted):=20wyr=C3=B3=C5=BCniaj?= =?UTF-8?q?=20=E2=80=9Enaszego"=20autora=20per-host,=20nie=20globalnie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Na stronie rekordu flaga „nasz autor" zależała wyłącznie od Jednostka.skupia_pracownikow — bez żadnego odniesienia do oglądającej uczelni. W konfiguracji multi-hosted ta sama praca pokazywała tego samego autora jako „naszego" na każdym hoście (objaw zgłoszony przez użytkownika). autorzy_dla_opisu_skrocony() dostaje opcjonalny parametr `uczelnia`: gdy podany (ma pk), autor jest „nasz" tylko jeśli jego jednostka należy do TEJ uczelni (jednostka.uczelnia_id == uczelnia.pk) ORAZ skupia_pracownikow. Bez parametru (uczelnia=None / niezdefiniowana) zachowanie jak dawniej — wstecz-kompatybilne dla pozostałych callerów. Oglądająca uczelnia trafia do metody z context processora (Uczelnia.objects.get_for_request, per-host) przez nowy simple_tag `autorzy_skrocony` — model-method nie da się zawołać z argumentem przez {% with %}. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/models/util.py | 21 +++- .../templates/browse/praca_tabela_mono.html | 3 +- src/bpp/templatetags/prace.py | 13 +++ .../test_autorzy_dla_opisu_skrocony.py | 105 ++++++++++++++++++ 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/bpp/models/util.py b/src/bpp/models/util.py index 2e0041ed7..16009b88e 100644 --- a/src/bpp/models/util.py +++ b/src/bpp/models/util.py @@ -130,18 +130,27 @@ def autorzy_dla_opisu(self): "autor", "typ_odpowiedzialnosci" ).order_by("kolejnosc") - def autorzy_dla_opisu_skrocony(self): + def autorzy_dla_opisu_skrocony(self, uczelnia=None): """Dane dla skróconego widoku listy autorów na stronie rekordu. Materializuje listę autorów raz i dokleja każdemu wpisowi atrybuty ``pozycja`` (1-based numer na liście) oraz ``czy_nasz`` (autor z - jednostki skupiającej pracowników uczelni). Zwraca słownik: + jednostki skupiającej pracowników oglądającej uczelni). Zwraca słownik: - ``skrocony`` -- czy włączyć widok zwinięty (autorów > próg), - ``wszyscy`` -- pełna lista (z ``pozycja``/``czy_nasz``), - ``pierwsi`` -- pierwszych ``LICZBA_PIERWSZYCH_AUTOROW``, - ``nasi_dalej`` -- "nasi" autorzy spoza pierwszej piątki, - ``liczba`` -- liczba autorów. + + ``uczelnia`` to oglądająca uczelnia (rozwiązana per-host przez + ``Uczelnia.objects.get_for_request``). Gdy podana (ma ``pk``), autor + jest "nasz" tylko jeśli jego jednostka należy do TEJ uczelni — bez + tego, w konfiguracji multi-hosted ta sama praca pokazywałaby tego + samego autora jako "naszego" na każdym hoście. Gdy ``uczelnia`` jest + ``None`` (lub niezdefiniowana, ``pk=None``), filtrowanie po uczelni + nie zachodzi i flaga zależy wyłącznie od ``skupia_pracownikow`` + (wstecz-kompatybilność z callerami bez kontekstu uczelni). """ autorzy = self.autorzy_dla_opisu() # autorzy_dla_opisu() zwraca [] (zamiast QuerySetu) dla niezapisanego @@ -149,9 +158,15 @@ def autorzy_dla_opisu_skrocony(self): if hasattr(autorzy, "select_related"): autorzy = autorzy.select_related("jednostka") wszyscy = list(autorzy) + uczelnia_pk = getattr(uczelnia, "pk", None) for pozycja, wpis in enumerate(wszyscy, start=1): wpis.pozycja = pozycja - wpis.czy_nasz = bool(wpis.jednostka.skupia_pracownikow) + czy_nasz = bool(wpis.jednostka.skupia_pracownikow) + if czy_nasz and uczelnia_pk is not None: + # ``uczelnia_id`` to FK-id na już-select_related-owanej + # jednostce — porównanie nie generuje dodatkowego zapytania. + czy_nasz = wpis.jednostka.uczelnia_id == uczelnia_pk + wpis.czy_nasz = czy_nasz return { "skrocony": len(wszyscy) > PROG_SKRACANIA_AUTOROW, diff --git a/src/bpp/templates/browse/praca_tabela_mono.html b/src/bpp/templates/browse/praca_tabela_mono.html index ee173af2c..6a9217441 100644 --- a/src/bpp/templates/browse/praca_tabela_mono.html +++ b/src/bpp/templates/browse/praca_tabela_mono.html @@ -25,7 +25,7 @@

    - {% with box=praca.autorzy_dla_opisu_skrocony %} + {% autorzy_skrocony praca uczelnia as box %}
    {% if box.skrocony %} @@ -57,7 +57,6 @@

    {% endif %}

    - {% endwith %} diff --git a/src/bpp/templatetags/prace.py b/src/bpp/templatetags/prace.py index acb4eee72..f15dae225 100644 --- a/src/bpp/templatetags/prace.py +++ b/src/bpp/templatetags/prace.py @@ -242,6 +242,19 @@ def generate_coins(praca, autorzy): # noqa return mark_safe(f'') +@register.simple_tag +def autorzy_skrocony(praca, uczelnia=None): + """Skrócony widok listy autorów (``autorzy_dla_opisu_skrocony``) z przekazaną + oglądającą uczelnią, tak by wyróżnienie "naszego" autora było host-aware. + + Metoda modelu nie może dostać argumentu przez ``{% with %}``/``{{ }}``, + więc owijamy ją w simple_tag wywoływany jako + ``{% autorzy_skrocony praca uczelnia as box %}``. ``uczelnia`` pochodzi + z context processora (``Uczelnia.objects.get_for_request``). + """ + return praca.autorzy_dla_opisu_skrocony(uczelnia=uczelnia) + + @register.simple_tag def autor_nazwa(autor, links="", pokaz_pozycje=False): """Renderuje pojedynczego autora na liście na stronie rekordu: nazwisko diff --git a/src/bpp/tests/test_models/test_autorzy_dla_opisu_skrocony.py b/src/bpp/tests/test_models/test_autorzy_dla_opisu_skrocony.py index 0c2ae7cd4..7a0e6bb8c 100644 --- a/src/bpp/tests/test_models/test_autorzy_dla_opisu_skrocony.py +++ b/src/bpp/tests/test_models/test_autorzy_dla_opisu_skrocony.py @@ -226,3 +226,108 @@ def test_skrocony_na_niezapisanym_rekordzie_nie_wybucha(): assert box["skrocony"] is False assert box["wszyscy"] == [] assert box["liczba"] == 0 + + +@pytest.mark.django_db +def test_czy_nasz_filtruje_po_uczelni_ogladajacej( + wydawnictwo_ciagle, jednostka_uczelnia1, uczelnia1, uczelnia2 +): + """Autor z jednostki uczelni1 jest "nasz" TYLKO gdy oglądamy z uczelni1. + + Regresja multi-hosted: ta sama praca na dwóch różnych hostach/uczelniach + pokazywała tego samego autora jako "naszego" na obu — bo ``czy_nasz`` + nie uwzględniało oglądającej uczelni, tylko ``skupia_pracownikow``. + """ + _dodaj_autorow(wydawnictwo_ciagle, [jednostka_uczelnia1]) + + box_u1 = wydawnictwo_ciagle.autorzy_dla_opisu_skrocony(uczelnia=uczelnia1) + assert box_u1["wszyscy"][0].czy_nasz is True + + box_u2 = wydawnictwo_ciagle.autorzy_dla_opisu_skrocony(uczelnia=uczelnia2) + assert box_u2["wszyscy"][0].czy_nasz is False + + +@pytest.mark.django_db +def test_czy_nasz_obca_jednostka_nigdy_nasza_mimo_uczelni( + wydawnictwo_ciagle, obca_jednostka, uczelnia +): + """Obca jednostka (skupia_pracownikow=False) nie jest "nasza", nawet jeśli + należy do oglądającej uczelni — ``skupia_pracownikow`` ma pierwszeństwo.""" + # obca_jednostka należy (przez wydzial) do fixture'owej `uczelnia`. + assert obca_jednostka.uczelnia_id == uczelnia.pk + _dodaj_autorow(wydawnictwo_ciagle, [obca_jednostka]) + + box = wydawnictwo_ciagle.autorzy_dla_opisu_skrocony(uczelnia=uczelnia) + assert box["wszyscy"][0].czy_nasz is False + + +@pytest.mark.django_db +def test_czy_nasz_bez_uczelni_zachowuje_stare_zachowanie( + wydawnictwo_ciagle, jednostka +): + """Bez podanej uczelni (uczelnia=None) flaga zależy wyłącznie od + ``skupia_pracownikow`` — wstecz-kompatybilność z istniejącymi callerami.""" + _dodaj_autorow(wydawnictwo_ciagle, [jednostka]) + + box = wydawnictwo_ciagle.autorzy_dla_opisu_skrocony() + assert box["wszyscy"][0].czy_nasz is True + + +@pytest.mark.django_db +def test_simple_tag_autorzy_skrocony_przekazuje_uczelnie( + wydawnictwo_ciagle, jednostka_uczelnia1, uczelnia1, uczelnia2 +): + """Tag ``autorzy_skrocony`` przekazuje oglądającą uczelnię do metody, więc + "nasz" autor jest host-aware już na poziomie szablonu.""" + from bpp.templatetags.prace import autorzy_skrocony + + _dodaj_autorow(wydawnictwo_ciagle, [jednostka_uczelnia1]) + + box_u1 = autorzy_skrocony(wydawnictwo_ciagle, uczelnia1) + assert box_u1["wszyscy"][0].czy_nasz is True + + box_u2 = autorzy_skrocony(wydawnictwo_ciagle, uczelnia2) + assert box_u2["wszyscy"][0].czy_nasz is False + + +@pytest.mark.django_db +def test_render_nasz_autor_jest_host_aware( + client, + settings, + wydawnictwo_ciagle, + jednostka_uczelnia1, + site1, + site2, + uczelnia1, + uczelnia2, + denorms, +): + """Regresja multi-hosted (objaw zgłoszony przez użytkownika): ta sama praca + z autorem z uczelni1 jest podświetlona jako "nasz" na hoście uczelni1, ale + NIE na hoście uczelni2 — bo oglądająca uczelnia rozwiązywana jest per-host. + """ + settings.ALLOWED_HOSTS = list(settings.ALLOWED_HOSTS) + [ + site1.domain, + site2.domain, + ] + _dodaj_autorow(wydawnictwo_ciagle, [jednostka_uczelnia1]) + denorms.flush() + + url = reverse( + "bpp:browse_praca", + args=( + ContentType.objects.get( + app_label="bpp", model="wydawnictwo_ciagle" + ).pk, + wydawnictwo_ciagle.pk, + ), + ) + nasz = "praca-mono__author-name--nasz" + + res_u1 = client.get(url, HTTP_HOST=site1.domain, follow=True) + assert res_u1.status_code == 200 + assert nasz in res_u1.content.decode("utf-8") # nasz host -> podświetlony + + res_u2 = client.get(url, HTTP_HOST=site2.domain, follow=True) + assert res_u2.status_code == 200 + assert nasz not in res_u2.content.decode("utf-8") # obcy host -> nie From d3f1a90d0391b1f1e2157d6e794ac793127adc50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 19 Jun 2026 11:25:40 +0200 Subject: [PATCH 247/247] =?UTF-8?q?fix(pbn):=20leniwe=20rozwi=C4=85zywanie?= =?UTF-8?q?=20uczelni=20w=20PBNBaseCommand=20+=20Rollbar=20dla=20b=C5=82?= =?UTF-8?q?=C4=99d=C3=B3w=20post-importu=20(#387)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Komendy post-importu (ustaw_zwrotnie_punkty_zwartych/_ciaglych, przypisz_rekordy_aktualnym_jednostkom_autorow) dziedziczą PBNBaseCommand, ale NIE używają klienta PBN. Eager rozwiązywanie uczelni w execute() wywalało je przy >1 uczelni (multi-hosted) komunikatem "podaj --uczelnia-id", którego te komendy nawet nie rejestrują (Catch-22) — przez co cały pipeline ustawiania punktów po imporcie z PBN cicho padał, a prace importowane przez WebUI nie dostawały punktacji bez żadnego śladu. - PBNBaseCommand: rozwiązywanie uczelni/credentiali przeniesione z execute() (eager, dla każdej komendy) do get_client() (lazy, tylko gdy realnie powstaje klient PBN). Komendy NO-PBN działają przy >1 uczelni bez --uczelnia-id; komendy PBN-owe nadal dostają twardy CommandError. Inwariant self._resolved_uczelnia zachowany (init w execute()). - import_manager._run_post_import_commands: błąd komendy post-importu nie jest już cicho zjadany (samo logger.error) — leci do Rollbara (rollbar.report_exc_info) + ImportLog(level="error") widoczny w logu sesji importu. Import nadal nie wywala się w całości (pozostałe komendy lecą dalej). Testy (TDD, RED->GREEN): - test_pbn_base_command: komenda NO-PBN działa przy 2 uczelniach; get_client nadal failuje przy 2 uczelniach bez --uczelnia-id. - test_post_import_commands: błąd post-importu -> Rollbar + ImportLog; nie przerywa pozostałych komend. Co-authored-by: Claude Opus 4.8 (1M context) --- src/pbn_api/management/commands/util.py | 35 ++++++++- src/pbn_api/tests/test_pbn_base_command.py | 27 ++++++- .../tests/test_post_import_commands.py | 78 +++++++++++++++++++ src/pbn_import/utils/import_manager.py | 23 +++++- 4 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/pbn_import/tests/test_post_import_commands.py diff --git a/src/pbn_api/management/commands/util.py b/src/pbn_api/management/commands/util.py index 49834552a..7b0936328 100644 --- a/src/pbn_api/management/commands/util.py +++ b/src/pbn_api/management/commands/util.py @@ -29,7 +29,18 @@ def add_arguments(self, parser): parser.add_argument("--user-token", default=None) def execute(self, *args, **options): - self._fill_pbn_credentials(options) + # Uczelnię i credentiale PBN rozwiązujemy LENIWIE — dopiero w + # get_client() (gdy komenda realnie buduje klienta PBN), a nie tutaj, + # dla każdego uruchomienia. Dzięki temu komendy dziedziczące + # PBNBaseCommand, które PBN-a w ogóle nie używają (np. ustawianie + # punktów po imporcie czy przypisywanie rekordów do jednostek), NIE + # wymagają --uczelnia-id i nie wywalają się przy >1 uczelni. Komendy + # PBN-owe nadal dostają twardy CommandError — ale w get_client(). + self._pbn_uczelnia_id = options.get("uczelnia_id") + # Inwariant: atrybut istnieje już po execute() (komendy czytają go + # wprost, np. WydawnictwoPBNAdapter(uczelnia=self._resolved_uczelnia)). + # Leniwe get_client() nadpisze go realną uczelnią, gdy zbuduje klienta. + self._resolved_uczelnia = None return super().execute(*args, **options) def _resolve_uczelnia(self, uczelnia_id): @@ -93,7 +104,27 @@ def _fill_pbn_credentials(self, options): user = BppUser.objects.first() options["user_token"] = user.pbn_token if user is not None else None - def get_client(self, app_id, app_token, base_url, user_token, verbose=False): + def get_client( + self, app_id=None, app_token=None, base_url=None, user_token=None, verbose=False + ): + # Tu rozwiązujemy uczelnię i uzupełniamy credentiale (leniwie). To + # jedyne miejsce, w którym PBN jest faktycznie potrzebny — i jedyne, + # w którym może paść CommandError o >1 uczelni bez --uczelnia-id. + # Wartości jawnie podane na CLI (app_id itd.) mają priorytet; resztę + # bierzemy z wybranej uczelni, a w ostateczności z settings. + options = { + "uczelnia_id": getattr(self, "_pbn_uczelnia_id", None), + "app_id": app_id, + "app_token": app_token, + "base_url": base_url, + "user_token": user_token, + } + self._fill_pbn_credentials(options) + app_id = options["app_id"] + app_token = options["app_token"] + base_url = options["base_url"] + user_token = options["user_token"] + if user_token is None: warnings.warn( "user_token not set, expect authorisation problems", stacklevel=2 diff --git a/src/pbn_api/tests/test_pbn_base_command.py b/src/pbn_api/tests/test_pbn_base_command.py index 79cb6ba36..b6a1fa12a 100644 --- a/src/pbn_api/tests/test_pbn_base_command.py +++ b/src/pbn_api/tests/test_pbn_base_command.py @@ -9,7 +9,7 @@ """ import pytest -from django.core.management import CommandError +from django.core.management import CommandError, call_command from bpp.models import Uczelnia from pbn_api.management.commands.util import PBNBaseCommand @@ -82,3 +82,28 @@ def test_explicit_uczelnia_id_selects_it(uczelnia): def test_unknown_uczelnia_id_raises(uczelnia): with pytest.raises(CommandError): PBNBaseCommand()._fill_pbn_credentials(_blank_options(uczelnia_id=999999)) + + +@pytest.mark.django_db +def test_command_without_pbn_client_runs_with_multiple_uczelnie(uczelnia): + """Komenda dziedzicząca PBNBaseCommand, ale NIE wołająca get_client() + (np. ustawianie punktów po imporcie), musi działać przy wielu uczelniach + bez --uczelnia-id — credentiale PBN rozwiązujemy leniwie, dopiero gdy + realnie powstaje klient.""" + _second_uczelnia() + + # Nie powinno rzucić CommandError o ">1 uczelni" — komenda nie tyka PBN. + call_command("ustaw_zwrotnie_punkty_zwartych") + + +@pytest.mark.django_db +def test_get_client_still_requires_uczelnia_id_with_multiple(uczelnia): + """Gwarancja, że leniwość nie osłabia multi-hosted: komendy PBN-owe + nadal dostają twardy CommandError przy >1 uczelni bez --uczelnia-id — + tyle że dopiero przy budowie klienta, nie na starcie każdej komendy.""" + _second_uczelnia() + + cmd = PBNBaseCommand() + cmd._pbn_uczelnia_id = None + with pytest.raises(CommandError): + cmd.get_client() diff --git a/src/pbn_import/tests/test_post_import_commands.py b/src/pbn_import/tests/test_post_import_commands.py new file mode 100644 index 000000000..9e369815e --- /dev/null +++ b/src/pbn_import/tests/test_post_import_commands.py @@ -0,0 +1,78 @@ +"""Testy pipeline'u post-importu (`ImportManager._run_post_import_commands`). + +Reguła: błąd komendy post-importu (np. `ustaw_zwrotnie_punkty_ciaglych`) +NIE może zniknąć po cichu. Ma: +- trafić do Rollbara (`rollbar.report_exc_info`), +- zostać zapisany jako `ImportLog` (poziom "error") widoczny w UI importu, +- a mimo to NIE wywalać całego importu — pozostałe komendy lecą dalej. +""" + +from unittest.mock import patch + +import pytest +from django.contrib.auth import get_user_model +from django.core.management import CommandError + +from pbn_import.models import ImportLog, ImportSession +from pbn_import.utils.import_manager import ImportManager + + +def _manager(): + user = get_user_model().objects.create_user(username="testuser") + session = ImportSession.objects.create(user=user) + return ImportManager(session, client=None, config={}) + + +@pytest.mark.django_db +def test_failing_post_import_command_is_reported_not_swallowed(): + manager = _manager() + + def fake_call_command(cmd, *a, **kw): + if cmd == "ustaw_zwrotnie_punkty_ciaglych": + raise CommandError("W systemie jest więcej niż jedna uczelnia") + return None + + with ( + patch( + "pbn_import.utils.import_manager.call_command", + side_effect=fake_call_command, + ), + patch( + "pbn_import.utils.import_manager.rollbar.report_exc_info" + ) as mock_rollbar, + ): + manager._run_post_import_commands() + + # Błąd komendy MUSI trafić do Rollbara — nie do nikąd. + assert mock_rollbar.called + + # ...i zostać zapisany jako log sesji widoczny w UI importu. + error_logs = ImportLog.objects.filter(session=manager.session, level="error") + assert error_logs.filter( + message__icontains="ustaw_zwrotnie_punkty_ciaglych" + ).exists() + + +@pytest.mark.django_db +def test_failing_post_import_command_does_not_abort_remaining(): + manager = _manager() + attempted = [] + + def fake_call_command(cmd, *a, **kw): + attempted.append(cmd) + if cmd == "ustaw_zwrotnie_punkty_zwartych": + raise CommandError("boom") + return None + + with ( + patch( + "pbn_import.utils.import_manager.call_command", + side_effect=fake_call_command, + ), + patch("pbn_import.utils.import_manager.rollbar.report_exc_info"), + ): + manager._run_post_import_commands() + + # Mimo błędu pierwszej komendy, kolejne nadal są uruchamiane. + assert "ustaw_zwrotnie_punkty_ciaglych" in attempted + assert "przypisz_rekordy_aktualnym_jednostkom_autorow" in attempted diff --git a/src/pbn_import/utils/import_manager.py b/src/pbn_import/utils/import_manager.py index 832b10f07..340254268 100644 --- a/src/pbn_import/utils/import_manager.py +++ b/src/pbn_import/utils/import_manager.py @@ -488,8 +488,27 @@ def _run_post_import_commands(self): call_command(cmd) except Exception as e: - logger.error(f"Komenda {cmd} nie powiodła się: {e}") - # Don't fail the entire import for post-processing errors + # Błąd komendy post-importu NIE może zniknąć po cichu (kiedyś + # samo logger.error → niewidoczne w UI, punkty się nie ustawiały + # bez śladu). Zgłaszamy do Rollbara i zapisujemy jako ImportLog + # widoczny w logu sesji importu. Mimo to NIE wywalamy całego + # importu — pozostałe komendy post-importu lecą dalej (awaria + # jednego typu punktacji nie powinna blokować reszty). + logger.exception(f"Komenda post-importu {cmd} nie powiodła się") + rollbar.report_exc_info( + sys.exc_info(), + extra_data={ + "session_id": self.session.id, + "post_import_command": cmd, + }, + ) + ImportLog.objects.create( + session=self.session, + level="error", + step=description, + message=f"Komenda post-importu {cmd} nie powiodła się: {e}", + details={"traceback": traceback.format_exc()}, + ) # Uruchom denorm_flush po zacommitowaniu transakcji, aby uniknąć deadlocka self.session.current_step = "Odświeżanie denormalizacji"