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 01/31] =?UTF-8?q?Phase=200:=20Multi-hosted=20foundation=20?= =?UTF-8?q?=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 02/31] 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 03/31] 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 04/31] 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 05/31] 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 06/31] 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 07/31] 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 08/31] 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 09/31] 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 10/31] =?UTF-8?q?Miniblog:=20M2M=20Article=E2=86=94Uczelni?= =?UTF-8?q?a=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 11/31] =?UTF-8?q?PBN=20Queue=20uczelnia=20FK,=20deduplikat?= =?UTF-8?q?or=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 12/31] =?UTF-8?q?Rozbie=C5=BCno=C5=9Bci=20per-uczelnia=20f?= =?UTF-8?q?iltering=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 13/31] 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 14/31] =?UTF-8?q?Refactor=20accessible=5Fsites=20=E2=86=92?= =?UTF-8?q?=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 15/31] 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 16/31] 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 17/31] 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 18/31] =?UTF-8?q?Uczelnia.site=20obowi=C4=85zkowe=20+=204?= =?UTF-8?q?=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 19/31] =?UTF-8?q?miniblog:=20lazy=20resolution=20=E2=80=94?= =?UTF-8?q?=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 20/31] 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 21/31] 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 22/31] =?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 23/31] 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 24/31] =?UTF-8?q?Site=20URL=20per-request=20=E2=80=94=20fi?= =?UTF-8?q?x=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 25/31] =?UTF-8?q?Import=20wydzia=C5=82=C3=B3w/jednostek=20?= =?UTF-8?q?z=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 26/31] 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 27/31] 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 28/31] 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 29/31] 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 30/31] 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 31/31] 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/