From 9889eb67dcb9a5369598558eed24f58299446c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 25 Jul 2026 02:02:25 +0200 Subject: [PATCH 1/6] =?UTF-8?q?docs(kompletnosc=5Fpolon):=20projekt=20rapo?= =?UTF-8?q?rtu=20kompletno=C5=9Bci=20danych=20POL-on=202026?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rozporządzenie MNiSW z 16.06.2026 (Dz.U. 2026 poz. 811) określa w § 2 ust. 10 zakres danych o osiągnięciach naukowych w wykazie pracowników. Rozpoznanie wykazało, że BPP ma już prawie wszystkie wymagane pola (pokrywają się z eksportem do PBN) — brakuje widoczności, które rekordy są niekompletne. Spec projektuje raport kompletności: ziarno (autor, dyscyplina, rekord) na konkretnych modelach powiązań, deklaratywny rejestr reguł z odnośnikami do paragrafów, liczenie na żywo bez materializacji. Odnotowane braki modelu do osobnych zgłoszeń: ISMN, flaga przekładu, ocena ekspercka KEN, osiem pól patentowych (por. FD#449), osiągnięcia artystyczne. Refs FD#437 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01McWgJYK4c4GSMdsm6P4oXc --- ...5-raport-kompletnosci-polon-2026-design.md | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md diff --git a/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md b/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md new file mode 100644 index 000000000..7faa94235 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md @@ -0,0 +1,325 @@ +# Raport kompletności danych POL-on 2026 — projekt + +**Zgłoszenie:** FD#437 · **Data:** 2026-07-25 · **Status:** projekt zaakceptowany, do implementacji + +## Problem + +Rozporządzenie Ministra Nauki i Szkolnictwa Wyższego z dnia 16 czerwca 2026 r. +w sprawie danych przetwarzanych w Zintegrowanym Systemie Informacji o Szkolnictwie +Wyższym i Nauce POL-on (Dz. U. 2026 poz. 811, w życie od 30 czerwca 2026 r.) +określa w § 2 ust. 10 zakres danych o osiągnięciach naukowych, jakie podmiot +wprowadza do **wykazu pracowników**. + +Rozpoznanie wykazało, że **BPP w zdecydowanej większości nie wymaga nowych pól** — +zakres z § 2 ust. 10 pokrywa się z tym, czego wymaga eksport do PBN, a te pola +w BPP są. Problemem nie jest brak miejsca na dane, tylko **brak widoczności, +których danych brakuje** w konkretnych rekordach. + +Rozporządzenie wyznacza przy tym twarde terminy (§ 9 ust. 3): + +- osiągnięcia wprowadza się **do 31 grudnia roku następującego po roku zaistnienia zmiany**, +- osiągnięcia z roku poprzedzającego rok ewaluacji — **do 15 stycznia roku ewaluacji**. + +## Cel + +Raport roboczy, który dla bieżącego okna ewaluacyjnego pokazuje **przy którym +pracowniku, w którym rekordzie, jakiej danej brakuje** — z odnośnikiem do +paragrafu rozporządzenia i linkiem prosto do formularza edycji. + +Raport **nie** wysyła niczego do POL-on. BPP nie jest zintegrowane z POL-on; +dane trafiają tam pośrednio (PBN/SEDN, sprawozdawczość uczelni). Raport jest +narzędziem przygotowania danych, nie kanałem sprawozdawczym. + +### Poza zakresem + +- blokowanie zapisu rekordu z brakami (walidacja u źródła) — ewentualny krok drugi, +- eksport do pliku sprawozdawczego POL-on, +- dane spoza § 2 ust. 10 (wykaz studentów, doktorantów, instytucji, dyplomy), +- ujednolicenie rozjechanych definicji okna ewaluacyjnego — patrz „Dług techniczny", +- **osiągnięcia artystyczne (pkt 7), wzory użytkowe (pkt 2), odmiany roślin (pkt 3)** — + BPP nie ma dla nich modelu ani charakteru formalnego. Raport nie może zgłaszać + braków w danych, dla których nie istnieje miejsce zapisu. + +### Zakres ograniczony: patenty + +Model `Patent` pokrywa z § 2 ust. 10 pkt 1 tylko litery a (tytuł), c (numer +patentu), g (data i numer zgłoszenia) oraz przez relacje k (dyscyplina) i m +(współtwórcy, ORCID). + +Dla liter **b, d, e, f, h, i, j, l BPP nie ma pól**: nazwa uprawnionego podmiotu, +nazwa urzędu udzielającego, państwa ochrony, data ogłoszenia w „Wiadomościach +Urzędu Patentowego", uprzednie pierwszeństwo, streszczenie opisu, data złożenia +tłumaczenia patentu europejskiego. + +Raport sprawdza więc dla patentów wyłącznie wymogi możliwe do wypełnienia. +Dołożenie brakujących pól to samodzielna zmiana — zbiega się z FD#449, gdzie +klientka niezależnie zgłosiła braki w modelu patentowym. + +## Decyzje projektowe + +### 1. Ziarno: (autor, dyscyplina, rekord) + +Raport operuje na **parach autor–osiągnięcie**, nie na samych publikacjach. +Odwzorowuje to konstrukcję rozporządzenia: § 2 ust. 10 umieszcza osiągnięcia +w wykazie pracowników, a każde osiągnięcie niesie własną „dyscyplinę, o której +mowa w art. 265 ust. 13 ustawy". Ten sam artykuł u dwóch współautorów to +w POL-onie dwa wpisy, przy dwóch osobach, potencjalnie w dwóch dyscyplinach. + +**Źródłem ziarna są konkretne modele powiązań** — `Wydawnictwo_Ciagle_Autor`, +`Wydawnictwo_Zwarte_Autor`, `Patent_Autor` — a **nie** widok +`Cache_Punktacja_Autora_Query`. + +Powód jest twardy i wynika z kodu: widok `Rekord` (`bpp_rekord_mat`, +`managed = False`) dziedziczy tylko wybrane klasy abstrakcyjne (`RekordBase`, +`src/bpp/models/cache/rekord.py:192-211`). **Nie ma wśród nich** +`ModelZOplataZaPublikacje` ani `ModelZPolamiEwaluacjiPBN`, a `strony`, `tom` +i `nr_zeszytu` są jawnie wygaszone (`= None`, linie 258–260). Przez `Rekord` +**nie da się sprawdzić** wymogów dotyczących opłat APC (pkt 4 lit. m, pkt 5 +lit. n), artykułu recenzyjnego (pkt 4 lit. f), projektów NCN/FNP/NPRH/UE +(pkt 5 lit. k), edycji naukowej (pkt 5 lit. j) ani tomu, zeszytu i stron +(pkt 4 lit. j, k). + +Model konkretny wystawia wszystkie te pola, a jego through-model niesie komplet +danych potrzebnych do przypisania braku do osoby: `autor`, `jednostka`, +`dyscyplina_naukowa`, `przypieta`, `upowaznienie_pbn`, `typ_odpowiedzialnosci` +(`src/bpp/models/abstract/authors.py:19-90`). Dodatkowa korzyść: dane czytane są +na żywo, nie z cache'u, który bywa nieświeży. + +Konsekwencja: widok składa wynik z trzech zapytań (ciągłe, zwarte, patenty). + +### 2. Rejestr reguł jako dane + +Nowa aplikacja `kompletnosc_polon`, moduł `reguly.py`: + +```python +class Waga(models.TextChoices): + WYMAGANE = "W", "Wymagane" # rozporządzenie żąda bezwarunkowo + WARUNKOWE = "C", "Warunkowe" # „jeżeli posiada", „jeżeli są znane" + +@dataclass(frozen=True) +class Regula: + kod: str # stabilny identyfikator, np. "ART_DOI" + dotyczy: Osiagniecie # ARTYKUL | MONOGRAFIA | ROZDZIAL | PATENT + warunek: Q # PRAWDZIWY, gdy danej BRAKUJE + opis: str # komunikat dla użytkownika + paragraf: str # np. "§ 2 ust. 10 pkt 4 lit. a" + waga: Waga = Waga.WYMAGANE +``` + +Warunek jest obiektem `Q`, więc jedna definicja obsługuje trzy zastosowania: +policzenie braków (`annotate`), zawężenie listy (`filter`) i test jednostkowy. +Dopisanie wymogu po nowelizacji to jeden wpis, bez dotykania widoku. + +Pole `paragraf` trafia do interfejsu — użytkownik widzi podstawę prawną wymogu, +a nie samo „czerwone pole". Pole `waga` odwzorowuje warunkowość rozporządzenia: +wymogi opatrzone „jeżeli posiada" / „jeżeli są znane" **nie stają się +bezwarunkowe**; raport pokazuje je osobno i nie wlicza do licznika braków +krytycznych. + +### 3. Rejestr reguł — treść + +Warunki zapisane od strony modelu konkretnego; `a__` oznacza prefiks +through-modelu (`Wydawnictwo_Ciagle_Autor` itd.). + +#### Artykuł naukowy (§ 2 ust. 10 pkt 4) — `Wydawnictwo_Ciagle` + +| Kod | Warunek braku | Lit. | Waga | +|---|---|---|---| +| `ART_DOI` | `doi` puste **i** `public_www` puste **i** `www` puste | a | W | +| `ART_DYSCYPLINA` | `a__dyscyplina_naukowa` NULL **lub** `a__przypieta=False` | c | W | +| `ART_UPOWAZNIENIE` | `a__upowaznienie_pbn=False` | d | W | +| `ART_ORCID` | `a__autor__orcid` puste | e | C | +| `ART_RECENZYJNY` | `pbn_czy_artykul_recenzyjny` NULL | f | W | +| `ART_ZRODLO` | `zrodlo` NULL | h | W | +| `ART_ISSN` | `zrodlo__issn`, `zrodlo__e_issn`, `issn`, `e_issn` — wszystkie puste | h | W | +| `ART_TOM` | `tom` puste **i** `informacje` puste | j | C | +| `ART_STRONY` | `strony` puste **i** `szczegoly` puste | k | C | +| `ART_OA_WERSJA` | `openaccess_tryb_dostepu` ustawione **i** `openaccess_wersja_tekstu` NULL | l | W | +| `ART_OA_LICENCJA` | `openaccess_tryb_dostepu` ustawione **i** `openaccess_licencja` NULL | l | W | +| `ART_OA_DATA` | `openaccess_tryb_dostepu` ustawione **i** `openaccess_data_opublikowania` NULL | l | W | +| `ART_OA_CZAS` | `openaccess_tryb_dostepu` ustawione **i** `openaccess_czas_publikacji` NULL | l | W | +| `ART_OA_MIESIACE` | `openaccess_czas_publikacji` = „po opublikowaniu" **i** `openaccess_ilosc_miesiecy` NULL | l | W | +| `ART_APC` | `opl_pub_cost_free` NULL **i** `opl_pub_amount` NULL **i** trzy flagi źródeł NULL | m | W | +| `ART_APC_ZRODLO` | `opl_pub_amount` > 0 **i** żadna flaga źródła nie jest `True` | m | W | + +Reguły OA są **warunkowe względem trybu dostępu**: jeśli praca nie jest +oznaczona jako Open Access, rozporządzenie nie wymaga danych OA i raport milczy. +`ART_OA_MIESIACE` uruchamia się tylko dla udostępnienia po opublikowaniu — bo +tylko wtedy liczba miesięcy ma sens. + +#### Monografia (§ 2 ust. 10 pkt 5) — `Wydawnictwo_Zwarte`, `charakter_sloty` = książka + +| Kod | Warunek braku | Lit. | Waga | +|---|---|---|---| +| `MON_DOI` | `doi` puste **i** `public_www` puste **i** `www` puste | a | W | +| `MON_ISBN` | `isbn` puste **i** `e_isbn` puste | b | W | +| `MON_WYDAWCA` | `wydawca` NULL **i** `wydawca_opis` puste | e | W | +| `MON_DYSCYPLINA` | `a__dyscyplina_naukowa` NULL **lub** `a__przypieta=False` | g | W | +| `MON_UPOWAZNIENIE` | `a__upowaznienie_pbn=False` | h | W | +| `MON_ORCID` | `a__autor__orcid` puste | d | C | +| `MON_EDYCJA_NAUKOWA` | `pbn_czy_edycja_naukowa` NULL | j | W | +| `MON_PROJEKTY` | wszystkie cztery `pbn_czy_projekt_*` NULL | k | W | +| `MON_OA_*` | jak `ART_OA_*` | m | W | +| `MON_APC`, `MON_APC_ZRODLO` | jak `ART_APC*` | n | W | + +#### Rozdział (§ 2 ust. 10 pkt 6) — `Wydawnictwo_Zwarte`, `charakter_sloty` = rozdział + +| Kod | Warunek braku | Lit. | Waga | +|---|---|---|---| +| `ROZ_NADRZEDNE` | `wydawnictwo_nadrzedne` NULL **i** `wydawnictwo_nadrzedne_w_pbn` NULL | a | W | +| `ROZ_DYSCYPLINA` | jak `MON_DYSCYPLINA` | d | W | +| `ROZ_UPOWAZNIENIE` | jak `MON_UPOWAZNIENIE` | e | W | +| `ROZ_ORCID` | `a__autor__orcid` puste | c | C | + +Rozdział dziedziczy wymogi monografii macierzystej (lit. a odsyła do pkt 5), +ale raport **nie duplikuje** ich na rozdziale — braki monografii pokazuje przy +monografii. Inaczej jedna niekompletna książka generowałaby braki przy każdym +z kilkunastu rozdziałów, zalewając listę. + +#### Patent (§ 2 ust. 10 pkt 1) — `Patent` + +| Kod | Warunek braku | Lit. | Waga | +|---|---|---|---| +| `PAT_NUMER` | `numer_prawa_wylacznego` puste | c | W | +| `PAT_ZGLOSZENIE` | `numer_zgloszenia` puste **lub** `data_zgloszenia` NULL | g | W | +| `PAT_DYSCYPLINA` | jak wyżej | k | W | +| `PAT_UPOWAZNIENIE` | jak wyżej | l | W | +| `PAT_ORCID` | `a__autor__orcid` puste | m | C | + +### 4. Okno ewaluacyjne + +Bieżące okno zaczyna się w **2026 r.** Stała `OKNO_EWALUACJI = (2026, 2029)` +w `ewaluacja_common/const.py` jako jedyne źródło prawdy dla nowej aplikacji. +Istniejących wywołań w `ewaluacja_metryki` nie ruszamy — patrz „Dług techniczny". + +### 5. Liczenie na żywo, bez materializacji + +Braki liczone przy każdym otwarciu widoku, przez `annotate()` warunkami reguł. +Nie powstaje tabela pochodna, nie ma migracji tworzącej model raportu. + +Uzasadnienie: okno 2026+ dopiero się otwiera, więc zbiór jest dziś bliski pustemu +i przez pierwsze lata pozostanie mały. Materializacja (wzorem `MetrykaAutora`) +wprowadzałaby opóźnienie między poprawką a zniknięciem rekordu z listy — czyli +tarcie, które zabija adopcję narzędzia roboczego. Rejestr reguł jest niezależny +od sposobu liczenia, więc materializacja da się dołożyć bez przepisywania logiki. + +## Architektura + +``` +src/kompletnosc_polon/ +├── apps.py # KompletnoscPolonConfig +├── const.py # Osiagniecie, Waga +├── reguly.py # REGULY: tuple[Regula, ...] + pomocnicze selektory +├── selektory.py # budowa querysetów per typ osiągnięcia +├── uczelnia_scope.py # scope_kompletnosc(qs, uczelnia) +├── urls.py # app_name = "kompletnosc_polon" +├── views/ +│ ├── __init__.py # re-eksport + __all__ +│ ├── lista.py # widok zbiorczy: autor → liczba braków +│ └── szczegoly.py # rozwinięcie: rekordy autora + konkretne braki +├── templates/kompletnosc_polon/ +│ ├── lista.html +│ └── szczegoly.html +└── tests/ +``` + +Wzorzec skopiowany z `ewaluacja_metryki` (pakiet `views/` z re-eksportem, +`uczelnia_scope.py`, szablony w `templates//`, dziedziczenie +po globalnym `base.html`). + +**Rejestracja:** `"kompletnosc_polon"` w `INSTALLED_APPS` +(`src/django_bpp/settings/base.py`, blok aplikacji ewaluacyjnych, ok. linii 480); +`path("kompletnosc_polon/", include("kompletnosc_polon.urls"))` +w `src/django_bpp/urls.py`; link w `src/django_bpp/templates/top_bar.html` +w istniejącym podmenu „ewaluacja". + +**Kontrola dostępu:** `EwaluacjaRequiredMixin` z +`ewaluacja_metryki.views.mixins`, ale widok wymaga **pełnych** uprawnień +(`ma_pelne_uprawnienia_ewaluacji`) — to narzędzie redaktorskie, nie widok dla +autora. Zawężenie do uczelni przez `uczelnia_dla_odczytu(request)` +(`raport_slotow.uczelnia_helper`) i `scope_*` z `bpp.util.uczelnia_scope`. + +**Migracje:** brak. Aplikacja nie ma modeli. + +## Przepływ danych + +1. Widok ustala okno (`OKNO_EWALUACJI`) i uczelnię (`uczelnia_dla_odczytu`). +2. `selektory.py` buduje trzy querysety through-modeli, zawężone do: roku + w oknie, przypiętej dyscypliny, autora afiliowanego do jednostki tej uczelni. +3. Dla każdego querysetu `annotate()` dokłada po jednym `BooleanField` na regułę + pasującą do typu osiągnięcia (`Case/When` z `Regula.warunek`). +4. Widok zbiorczy agreguje po autorze: liczba rekordów z brakami, liczba braków + wymaganych i warunkowych. +5. Widok szczegółów listuje rekordy jednego autora wraz z nazwami naruszonych + reguł, opisami i paragrafami, każdy z linkiem do formularza edycji w adminie. + +## Obsługa błędów + +- **Brak przypiętych dyscyplin w całej bazie** (świeża instalacja, nieuruchomiona + ewaluacja) → widok pokazuje komunikat wyjaśniający, że raport wymaga + przypisanych dyscyplin, zamiast pustej tabeli sugerującej „wszystko w porządku". +- **Zero braków** → jawny komunikat „sprawdzono N rekordów, brak zastrzeżeń", + z podaniem zakresu lat. Puste tabele bez kontekstu są mylące. +- **Charakter formalny bez `charakter_sloty`** (fixture instalacyjny zostawia + `null` — ustalone w rozpoznaniu) → taki rekord nie daje się zaklasyfikować jako + monografia ani rozdział. Trafia do osobnej sekcji „nierozpoznany typ + osiągnięcia" zamiast zniknąć po cichu. +- Zgodnie z regułą repo: żadnego `except: pass`. + +## Testy + +- **Test per reguła** — dla każdej z reguł dwa rekordy przez `baker.make`: jeden + spełniający wymóg, jeden z brakiem; asercja, że reguła łapie dokładnie ten drugi. + To właściwa jednostka testowa, bo reguły są danymi. Test parametryzowany po + `REGULY`, więc dopisanie reguły bez testu psuje suitę. +- **Test ziarna** — publikacja dwóch współautorów w dwóch dyscyplinach daje dwa + wiersze, a brak przypisany jest właściwej osobie. +- **Test zakresu** — rekord spoza okna, rekord z odpiętą dyscypliną i rekord + autora z innej uczelni nie pojawiają się w raporcie. +- **Test warunkowości** — praca bez oznaczenia OA nie generuje braków OA; + praca z `openaccess_czas_publikacji` innym niż „po opublikowaniu" nie wymaga + liczby miesięcy. +- **Test widoku** — dostęp odrzucony dla użytkownika bez pełnych uprawnień; + dane zawężone do uczelni. + +Konwencja repo: pytest, funkcje bez klas, `@pytest.mark.django_db`, +`model_bakery.baker`. + +## Dług techniczny ujawniony przy okazji + +Trzy niezgodne definicje okna ewaluacyjnego: + +| Miejsce | Wartość | +|---|---| +| `ewaluacja_common/const.py` | `ROK_MIN = 2022`, `ROK_MAX = 2026` | +| `ewaluacja_metryki/models.py:114-120` | pola `rok_min`/`rok_max`, domyślnie `2022` / `2025` | +| `ewaluacja_metryki/tasks.py`, `utils.py` | `rok_min=2022, rok_max=2025` w ośmiu sygnaturach | + +`ROK_MAX` mówi 2026, metryki liczą do 2025. Rozjazd siedzi w domyślnych +argumentach funkcji, nie we wspólnej stałej, więc jest niewidoczny. Wraz +z otwarciem okna 2026+ każde z tych miejsc jest błędne. + +Świadomie **nie naprawiane w tym PR** — zmiana domyślnych lat zmieniłaby wyniki +liczenia metryk i slotów, czyli zachowanie niezwiązane z tym zgłoszeniem. +Do osobnego zgłoszenia. + +## Braki modelu danych do osobnych zgłoszeń + +Wymogi rozporządzenia bez odpowiednika w BPP: + +| Wymóg | Paragraf | Stan | +|---|---|---| +| ISMN (druki muzyczne) | pkt 5 lit. b | brak pola | +| Czy monografia jest przekładem dzieła istotnego | pkt 5 lit. i | brak pola; kierunek tłumaczenia wyliczany dopiero w adapterze eksportu PBN, nieprzechowywany | +| Zgłoszenie do oceny eksperckiej KEN + wynik | pkt 5 lit. l | brak pola | +| Patenty: uprawniony, urząd, państwa ochrony, data ogłoszenia w WUP, pierwszeństwo, streszczenie, tłumaczenie patentu EP | pkt 1 lit. b, d, e, f, h, i, j | brak pól (por. FD#449) | +| Osiągnięcia artystyczne | pkt 7 | brak modelu | + +## Ryzyka + +| Ryzyko | Reakcja | +|---|---| +| Raport na starcie pusty (okno 2026 dopiero się otwiera) — użytkownik uzna, że nie działa | Widok jawnie komunikuje zakres lat i liczbę sprawdzonych rekordów, także przy zerze braków | +| Fałszywe braki tam, gdzie dana jest nieobowiązkowa | Pole `waga`; wymogi warunkowe liczone osobno i nieoznaczane jako krytyczne | +| Trzy zapytania zamiast jednego | Akceptowane — zbiór mały, a alternatywą jest rozszerzanie widoku `bpp_rekord`, co dotyka całego systemu | +| Instalacje bez ustawionego `charakter_sloty` klasyfikują się nijak | Osobna sekcja „nierozpoznany typ" zamiast cichego pominięcia | From 92f594d76e564206bb2c8487d42042753b192cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 25 Jul 2026 02:30:06 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(kompletnosc=5Fpolon):=20rejestr=20regu?= =?UTF-8?q?=C5=82=20kompletno=C5=9Bci=20danych=20POL-on=202026?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fundament raportu: aplikacja bez modeli, deklaratywny rejestr 40 reguł odwzorowujących § 2 ust. 10 rozporządzenia (Dz.U. 2026 poz. 811) dla artykułów, monografii, rozdziałów i patentów. Reguła to dane: kod, typ osiągnięcia, warunek Q prawdziwy gdy danej brakuje, opis dla użytkownika, paragraf podstawy prawnej i waga (wymagane vs warunkowe — rozporządzenie w wielu miejscach mówi "jeżeli posiada" / "jeżeli są znane" i raport tego nie zaostrza). Warunki zapisane od strony through-modelu powiązania autora z rekordem, nie od strony publikacji. Zapis odwrotny byłby błędny semantycznie: warunek na autorzy_set znaczyłby "istnieje jakiś autor bez upoważnienia" zamiast "ten autor go nie ma", a przy annotate() mnożyłby wiersze przez JOIN. Spec poprawiony w tym miejscu. OKNO_EWALUACJI = (2026, 2029) w ewaluacja_common jako źródło prawdy dla tej aplikacji; istniejące ROK_MIN/ROK_MAX celowo nietknięte. 203 testy: każda reguła sprawdzana parą rekordów (kompletny / z brakiem). Refs FD#437 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01McWgJYK4c4GSMdsm6P4oXc --- ...5-raport-kompletnosci-polon-2026-design.md | 15 +- src/django_bpp/settings/base.py | 1 + src/ewaluacja_common/const.py | 14 + src/kompletnosc_polon/__init__.py | 0 src/kompletnosc_polon/apps.py | 7 + src/kompletnosc_polon/const.py | 53 ++ src/kompletnosc_polon/reguly.py | 517 ++++++++++++++++++ src/kompletnosc_polon/tests/__init__.py | 0 src/kompletnosc_polon/tests/test_reguly.py | 439 +++++++++++++++ 9 files changed, 1044 insertions(+), 2 deletions(-) create mode 100644 src/kompletnosc_polon/__init__.py create mode 100644 src/kompletnosc_polon/apps.py create mode 100644 src/kompletnosc_polon/const.py create mode 100644 src/kompletnosc_polon/reguly.py create mode 100644 src/kompletnosc_polon/tests/__init__.py create mode 100644 src/kompletnosc_polon/tests/test_reguly.py diff --git a/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md b/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md index 7faa94235..f92692e3b 100644 --- a/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md +++ b/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md @@ -118,8 +118,19 @@ krytycznych. ### 3. Rejestr reguł — treść -Warunki zapisane od strony modelu konkretnego; `a__` oznacza prefiks -through-modelu (`Wydawnictwo_Ciagle_Autor` itd.). +**Warunki zapisane są od strony through-modelu** (`Wydawnictwo_Ciagle_Autor`, +`Wydawnictwo_Zwarte_Autor`, `Patent_Autor`): pola powiązania autora bez prefiksu +(`dyscyplina_naukowa`, `przypieta`, `upowaznienie_pbn`, `autor__orcid`), pola +publikacji z prefiksem `rekord__`. + +Pierwsza wersja tego projektu zapisywała warunki odwrotnie — od strony modelu +konkretnego, z `a__` jako skrótem na through-model. Było to **błędne +semantycznie**: `Q(autorzy_set__upowaznienie_pbn=False)` na querysecie +`Wydawnictwo_Ciagle` znaczy „istnieje *jakiś* autor bez upoważnienia", a nie +„*ten* autor go nie ma", a przy `annotate()` zwielokrotniałoby wiersze przez +JOIN. Ziarno raportu to jedna para (autor, rekord) — czyli dokładnie jeden +wiersz through-modelu. W tabelach niżej `a__` czytaj jako „pole powiązania +autora, zapisywane bez prefiksu". #### Artykuł naukowy (§ 2 ust. 10 pkt 4) — `Wydawnictwo_Ciagle` diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index 67d4589ca..5cbb5a27b 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -481,6 +481,7 @@ def _elem_in_sys_argv(possible): "ewaluacja_optymalizacja", "ewaluacja_optymalizuj_publikacje", "ewaluacja_dwudyscyplinowcy", + "kompletnosc_polon", # UWAGA: NIE USUWAĆ aplikacji test_bpp z INSTALLED_APPS! # # Mimo nazwy sugerującej "tylko do testów", test_bpp dostarcza realnych diff --git a/src/ewaluacja_common/const.py b/src/ewaluacja_common/const.py index dcfeaa70b..da4162448 100644 --- a/src/ewaluacja_common/const.py +++ b/src/ewaluacja_common/const.py @@ -7,3 +7,17 @@ # od uśpionej apki ``ewaluacja2021`` (patrz ``ewaluacja2021/README.md``). ROK_MIN = 2022 ROK_MAX = 2026 + +# Okno *bieżącej* ewaluacji, jako domknięty przedział (pierwszy rok, ostatni +# rok). Bieżące okno otwiera się w 2026 r. i zamyka w 2029 r. +# +# To jedyne źródło prawdy dla aplikacji ``kompletnosc_polon`` (raport +# kompletności danych POL-on, FD#437) — raport pyta o braki wyłącznie +# w rekordach z tego przedziału lat. +# +# UWAGA: świadomie NIE podpięte pod ``ROK_MIN``/``ROK_MAX`` powyżej ani pod +# domyślne argumenty w ``ewaluacja_metryki`` (``rok_min=2022, rok_max=2025`` +# w ośmiu sygnaturach). Te trzy definicje okna są dziś wzajemnie niezgodne; +# ich ujednolicenie zmieniłoby wyniki liczenia metryk i slotów, więc idzie +# osobnym zgłoszeniem. Nowy kod ma używać ``OKNO_EWALUACJI``. +OKNO_EWALUACJI = (2026, 2029) diff --git a/src/kompletnosc_polon/__init__.py b/src/kompletnosc_polon/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/kompletnosc_polon/apps.py b/src/kompletnosc_polon/apps.py new file mode 100644 index 000000000..06fb20ea0 --- /dev/null +++ b/src/kompletnosc_polon/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class KompletnoscPolonConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "kompletnosc_polon" + verbose_name = "Kompletność danych POL-on" diff --git a/src/kompletnosc_polon/const.py b/src/kompletnosc_polon/const.py new file mode 100644 index 000000000..aded03984 --- /dev/null +++ b/src/kompletnosc_polon/const.py @@ -0,0 +1,53 @@ +"""Stałe słownikowe raportu kompletności danych POL-on. + +Rozporządzenie MNiSW z dnia 16 czerwca 2026 r. w sprawie danych przetwarzanych +w ZSIoSW POL-on (Dz. U. 2026 poz. 811) określa w § 2 ust. 10 zakres danych +o osiągnięciach naukowych wprowadzanych do wykazu pracowników. +""" + +from django.db import models + + +class Osiagniecie(models.TextChoices): + """Typ osiągnięcia naukowego w rozumieniu § 2 ust. 10 rozporządzenia. + + Każdy wariant odwzorowuje jeden punkt ustępu i jeden model BPP: + + * ``ARTYKUL`` — pkt 4, ``bpp.Wydawnictwo_Ciagle``, + * ``MONOGRAFIA`` — pkt 5, ``bpp.Wydawnictwo_Zwarte`` o charakterze + slotów „książka” (``bpp.const.CHARAKTER_SLOTY_KSIAZKA``), + * ``ROZDZIAL`` — pkt 6, ``bpp.Wydawnictwo_Zwarte`` o charakterze slotów + „rozdział” (``bpp.const.CHARAKTER_SLOTY_ROZDZIAL``), + * ``PATENT`` — pkt 1, ``bpp.Patent``. + + Osiągnięcia artystyczne (pkt 7), wzory użytkowe (pkt 2) i odmiany roślin + (pkt 3) nie mają w BPP modelu ani charakteru formalnego, więc raport ich + nie obejmuje — nie da się zgłosić braku danej, dla której nie istnieje + miejsce zapisu. + """ + + ARTYKUL = "ART", "Artykuł naukowy" + MONOGRAFIA = "MON", "Monografia naukowa" + ROZDZIAL = "ROZ", "Rozdział w monografii naukowej" + PATENT = "PAT", "Patent" + + +class Waga(models.TextChoices): + """Kategoria wymogu — czy rozporządzenie żąda danej bezwarunkowo. + + ``WYMAGANE`` to wymóg bezwarunkowy; brak danej jest błędem, który trzeba + uzupełnić. ``WARUNKOWE`` odwzorowuje zwroty „jeżeli posiada”, „jeżeli są + znane”, „o ile został nadany” — braki tej kategorii raport pokazuje + osobno i nie wlicza do licznika braków krytycznych. + """ + + WYMAGANE = "W", "Wymagane" + WARUNKOWE = "C", "Warunkowe" + + +#: Skrót pozycji słownika ``bpp.Czas_Udostepnienia_OpenAccess`` oznaczającej +#: udostępnienie utworu *po* opublikowaniu. Tylko dla tej wartości ma sens +#: pytanie o liczbę miesięcy karencji (patrz reguły ``*_OA_MIESIACE``). +#: Wartość pochodzi z fixture instalacyjnego ``bpp.fixtures.DANE_OPEN_ACCESS`` +#: i jest tożsama ze słownikiem PBN (``releaseDateMode``). +OA_CZAS_PO_OPUBLIKOWANIU = "AFTER_PUBLICATION" diff --git a/src/kompletnosc_polon/reguly.py b/src/kompletnosc_polon/reguly.py new file mode 100644 index 000000000..04e55196f --- /dev/null +++ b/src/kompletnosc_polon/reguly.py @@ -0,0 +1,517 @@ +"""Rejestr reguł kompletności danych POL-on — reguły jako dane. + +Każdy wymóg § 2 ust. 10 rozporządzenia MNiSW z 16 czerwca 2026 r. (Dz. U. 2026 +poz. 811) zapisany jest jako jedna :class:`Regula` z warunkiem w postaci +``django.db.models.Q``. Dzięki temu jedna definicja obsługuje trzy +zastosowania: policzenie braków (``annotate``), zawężenie listy (``filter``) +oraz test jednostkowy. Dopisanie wymogu po nowelizacji to jeden wpis w +:data:`REGULY`, bez dotykania widoku. + +Konwencja zapisu warunków (WAŻNE — selektory i widoki na niej polegają) +======================================================================= + +**Warunki zapisane są od strony *through-modelu* powiązania autora z +rekordem**, czyli ``bpp.Wydawnictwo_Ciagle_Autor``, +``bpp.Wydawnictwo_Zwarte_Autor`` albo ``bpp.Patent_Autor``. Wynika to wprost +z ziarna raportu: § 2 ust. 10 umieszcza osiągnięcia w *wykazie pracowników*, +a każde osiągnięcie niesie własną dyscyplinę i własne upoważnienie. Jeden +wiersz raportu to jedna para (autor, rekord), czyli dokładnie jeden wiersz +through-modelu. + +Stąd dwie klasy ścieżek pól: + +* pola powiązania autora — **bez prefiksu**: ``dyscyplina_naukowa``, + ``przypieta``, ``upowaznienie_pbn``, ``autor__orcid``; +* pola samej publikacji — z prefiksem :data:`PREFIKS_REKORDU` (``rekord__``): + ``rekord__doi``, ``rekord__zrodlo__issn`` itd. + +Projekt (``docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md``) +notował pola autora skrótem ``a__`` przy warunkach pisanych od strony modelu +konkretnego. Ta konwencja została świadomie odwrócona, bo: + +1. ``a__`` nie jest realnym akcesorem Django — odwrotna relacja z + ``Wydawnictwo_Ciagle`` do through-modelu nazywa się ``autorzy_set``; +2. warunek ``autorzy_set__upowaznienie_pbn=False`` na querysecie modelu + konkretnego znaczy „*istnieje* autor bez upoważnienia”, a nie „*ten* autor + nie ma upoważnienia” — a przy ``annotate()`` dodatkowo zwielokrotniałby + wiersze przez JOIN; +3. sam projekt (sekcja „Przepływ danych”) zakłada, że selektory budują + querysety through-modeli — czyli tę właśnie stronę relacji. + +Warunek jest PRAWDZIWY, gdy danej BRAKUJE +========================================= + +Konsekwentnie w całym rejestrze: reguła „łapie” rekord niekompletny. Pola +tekstowe w BPP są przeważnie ``blank=True, default=""`` (a więc NOT NULL), +ale nie wszystkie — ``doi``, ``autor.orcid``, ``numer_zgloszenia`` oraz +``numer_prawa_wylacznego`` dopuszczają NULL. Dlatego pustkę testujemy dwoma +pomocnikami: :func:`_puste` dla pól NOT NULL i :func:`_puste_lub_null` dla +pól nullowalnych. Warunki muszą dać się w całości wyrazić w SQL — żadnych +metod Pythona. +""" + +from dataclasses import dataclass + +from django.db.models import Q + +from kompletnosc_polon.const import ( + OA_CZAS_PO_OPUBLIKOWANIU, + Osiagniecie, + Waga, +) + +#: Prefiks ścieżki do pól publikacji, gdy zapytanie startuje z through-modelu +#: powiązania autora. Patrz docstring modułu. +PREFIKS_REKORDU = "rekord__" + + +@dataclass(frozen=True) +class Regula: + """Pojedynczy wymóg rozporządzenia, wyrażony jako warunek SQL. + + :param kod: stabilny identyfikator, np. ``"ART_DOI"``; unikalny w całym + rejestrze, używany w szablonach i w adresach URL. + :param dotyczy: typ osiągnięcia, którego reguła dotyczy. + :param warunek: ``Q`` prawdziwe wtedy i tylko wtedy, gdy danej BRAKUJE. + :param opis: komunikat dla użytkownika — co konkretnie uzupełnić. + :param paragraf: podstawa prawna, np. ``"§ 2 ust. 10 pkt 4 lit. a"``. + :param waga: czy rozporządzenie żąda danej bezwarunkowo. + """ + + kod: str + dotyczy: Osiagniecie + warunek: Q + opis: str + paragraf: str + waga: Waga = Waga.WYMAGANE + + +def _puste(pole: str) -> Q: + """Pole tekstowe NOT NULL (``blank=True, default=""``) jest puste.""" + return Q(**{pole: ""}) + + +def _puste_lub_null(pole: str) -> Q: + """Pole tekstowe nullowalne jest puste albo NULL. + + W BPP nullowalne są m.in. ``doi`` (``DOIField(null=True)``), + ``Autor.orcid``, ``Patent.numer_zgloszenia`` i + ``Patent.numer_prawa_wylacznego`` — te dwa ostatnie z powodów + historycznych (``# noqa: DJ001`` w modelu). Pusty ciąg i NULL znaczą + dla użytkownika to samo: danej nie ma. + """ + return Q(**{pole: ""}) | Q(**{f"{pole}__isnull": True}) + + +def _nie_tak(pole: str) -> Q: + """Nullowalne pole logiczne nie jest ustawione na „tak”. + + Zapisane wprost (``False`` albo NULL), zamiast przez ``~Q(pole=True)``, + żeby wygenerowany SQL nie zależał od tego, jak Django obchodzi się + z negacją warunku na kolumnie nullowalnej. + """ + return Q(**{pole: False}) | Q(**{f"{pole}__isnull": True}) + + +# -------------------------------------------------------------------------- +# Fragmenty wspólne dla wielu typów osiągnięć +# -------------------------------------------------------------------------- + +#: Brak identyfikatora cyfrowego: ani DOI, ani żadnego adresu WWW. +#: ``doi`` jest nullowalne, ``www`` i ``public_www`` to ``URLField`` NOT NULL. +BRAK_IDENTYFIKATORA_CYFROWEGO = ( + _puste_lub_null("rekord__doi") + & _puste("rekord__public_www") + & _puste("rekord__www") +) + +#: Dyscyplina nieokreślona albo odpięta od tego powiązania autor–rekord. +BRAK_DYSCYPLINY = Q(dyscyplina_naukowa__isnull=True) | Q(przypieta=False) + +#: Autor nie upoważnił uczelni do sprawozdania tej publikacji. +#: ``upowaznienie_pbn`` to ``BooleanField(default=False)`` — NOT NULL. +BRAK_UPOWAZNIENIA = Q(upowaznienie_pbn=False) + +#: Autor nie ma numeru ORCID. Pole ``Autor.orcid`` jest nullowalne (unique). +BRAK_ORCID = _puste_lub_null("autor__orcid") + +#: Praca jest oznaczona jako Open Access. Wszystkie wymogi OA są warunkowe +#: względem tego oznaczenia: bez trybu dostępu rozporządzenie nie żąda +#: danych OA i raport milczy. +OZNACZONO_OPEN_ACCESS = Q(rekord__openaccess_tryb_dostepu__isnull=False) + +#: Brak kompletu danych o opłacie za publikację (APC): nie wiadomo nawet, +#: czy publikacja była bezkosztowa. +BRAK_DANYCH_O_OPLACIE = ( + Q(rekord__opl_pub_cost_free__isnull=True) + & Q(rekord__opl_pub_amount__isnull=True) + & Q(rekord__opl_pub_research_potential__isnull=True) + & Q(rekord__opl_pub_research_or_development_projects__isnull=True) + & Q(rekord__opl_pub_other__isnull=True) +) + +#: Wpisano kwotę opłaty, ale nie wskazano źródła jej finansowania. +BRAK_ZRODLA_OPLATY = ( + Q(rekord__opl_pub_amount__gt=0) + & _nie_tak("rekord__opl_pub_research_potential") + & _nie_tak("rekord__opl_pub_research_or_development_projects") + & _nie_tak("rekord__opl_pub_other") +) + + +def _reguly_open_access( + prefiks_kodu: str, + dotyczy: Osiagniecie, + paragraf: str, +) -> tuple[Regula, ...]: + """Pięć reguł Open Access, identycznych dla artykułu i monografii. + + Wszystkie poza ``*_OA_MIESIACE`` są warunkowe względem ustawionego trybu + dostępu. ``*_OA_MIESIACE`` uruchamia się węziej — tylko dla udostępnienia + po opublikowaniu, bo tylko wtedy liczba miesięcy karencji ma sens. + """ + return ( + Regula( + kod=f"{prefiks_kodu}_OA_WERSJA", + dotyczy=dotyczy, + warunek=OZNACZONO_OPEN_ACCESS + & Q(rekord__openaccess_wersja_tekstu__isnull=True), + opis="Praca jest oznaczona jako Open Access, ale nie podano " + "wersji udostępnionego tekstu.", + paragraf=paragraf, + ), + Regula( + kod=f"{prefiks_kodu}_OA_LICENCJA", + dotyczy=dotyczy, + warunek=OZNACZONO_OPEN_ACCESS & Q(rekord__openaccess_licencja__isnull=True), + opis="Praca jest oznaczona jako Open Access, ale nie podano " + "licencji, na jakiej ją udostępniono.", + paragraf=paragraf, + ), + Regula( + kod=f"{prefiks_kodu}_OA_DATA", + dotyczy=dotyczy, + warunek=OZNACZONO_OPEN_ACCESS + & Q(rekord__openaccess_data_opublikowania__isnull=True), + opis="Praca jest oznaczona jako Open Access, ale nie podano daty " + "udostępnienia w otwartym dostępie.", + paragraf=paragraf, + ), + Regula( + kod=f"{prefiks_kodu}_OA_CZAS", + dotyczy=dotyczy, + warunek=OZNACZONO_OPEN_ACCESS + & Q(rekord__openaccess_czas_publikacji__isnull=True), + opis="Praca jest oznaczona jako Open Access, ale nie podano " + "czasu udostępnienia (przed, w momencie albo po opublikowaniu).", + paragraf=paragraf, + ), + Regula( + kod=f"{prefiks_kodu}_OA_MIESIACE", + dotyczy=dotyczy, + warunek=Q( + rekord__openaccess_czas_publikacji__skrot=OA_CZAS_PO_OPUBLIKOWANIU + ) + & Q(rekord__openaccess_ilosc_miesiecy__isnull=True), + opis="Pracę udostępniono po opublikowaniu, ale nie podano liczby " + "miesięcy, jakie upłynęły od publikacji do udostępnienia.", + paragraf=paragraf, + ), + ) + + +def _reguly_oplaty( + prefiks_kodu: str, + dotyczy: Osiagniecie, + paragraf: str, +) -> tuple[Regula, ...]: + """Dwie reguły dotyczące opłaty za publikację (APC).""" + return ( + Regula( + kod=f"{prefiks_kodu}_APC", + dotyczy=dotyczy, + warunek=BRAK_DANYCH_O_OPLACIE, + opis="Nie wypełniono danych o opłacie za publikację — nie " + "zaznaczono nawet, czy publikacja była bezkosztowa.", + paragraf=paragraf, + ), + Regula( + kod=f"{prefiks_kodu}_APC_ZRODLO", + dotyczy=dotyczy, + warunek=BRAK_ZRODLA_OPLATY, + opis="Wpisano kwotę opłaty za publikację, ale nie wskazano " + "żadnego źródła jej finansowania.", + paragraf=paragraf, + ), + ) + + +# -------------------------------------------------------------------------- +# Artykuł naukowy — § 2 ust. 10 pkt 4 — bpp.Wydawnictwo_Ciagle_Autor +# -------------------------------------------------------------------------- + +REGULY_ARTYKUL: tuple[Regula, ...] = ( + Regula( + kod="ART_DOI", + dotyczy=Osiagniecie.ARTYKUL, + warunek=BRAK_IDENTYFIKATORA_CYFROWEGO, + opis="Brak numeru DOI oraz adresu strony WWW artykułu.", + paragraf="§ 2 ust. 10 pkt 4 lit. a", + ), + Regula( + kod="ART_DYSCYPLINA", + dotyczy=Osiagniecie.ARTYKUL, + warunek=BRAK_DYSCYPLINY, + opis="Autorowi nie przypisano dyscypliny naukowej dla tej pracy " + "albo dyscyplina została odpięta.", + paragraf="§ 2 ust. 10 pkt 4 lit. c", + ), + Regula( + kod="ART_UPOWAZNIENIE", + dotyczy=Osiagniecie.ARTYKUL, + warunek=BRAK_UPOWAZNIENIA, + opis="Brak upoważnienia autora do wykazania tej pracy w ewaluacji.", + paragraf="§ 2 ust. 10 pkt 4 lit. d", + ), + Regula( + kod="ART_ORCID", + dotyczy=Osiagniecie.ARTYKUL, + warunek=BRAK_ORCID, + opis="Autor nie ma wpisanego identyfikatora ORCID.", + paragraf="§ 2 ust. 10 pkt 4 lit. e", + waga=Waga.WARUNKOWE, + ), + Regula( + kod="ART_RECENZYJNY", + dotyczy=Osiagniecie.ARTYKUL, + warunek=Q(rekord__pbn_czy_artykul_recenzyjny__isnull=True), + opis="Nie określono, czy artykuł jest artykułem recenzyjnym.", + paragraf="§ 2 ust. 10 pkt 4 lit. f", + ), + Regula( + kod="ART_ZRODLO", + dotyczy=Osiagniecie.ARTYKUL, + warunek=Q(rekord__zrodlo__isnull=True), + opis="Nie wskazano czasopisma, w którym artykuł się ukazał.", + paragraf="§ 2 ust. 10 pkt 4 lit. h", + ), + Regula( + kod="ART_ISSN", + dotyczy=Osiagniecie.ARTYKUL, + # Gdy źródło nie jest wskazane, LEFT JOIN daje NULL-e i porównanie + # z pustym ciągiem nie zadziała — stąd jawny człon o braku źródła. + warunek=( + Q(rekord__zrodlo__isnull=True) + | (_puste("rekord__zrodlo__issn") & _puste("rekord__zrodlo__e_issn")) + ) + & _puste("rekord__issn") + & _puste("rekord__e_issn"), + opis="Ani czasopismo, ani sam rekord nie mają numeru ISSN ani e-ISSN.", + paragraf="§ 2 ust. 10 pkt 4 lit. h", + ), + Regula( + kod="ART_TOM", + dotyczy=Osiagniecie.ARTYKUL, + warunek=_puste("rekord__tom") & _puste("rekord__informacje"), + opis="Nie podano tomu (rocznika) czasopisma — pole „Tom” jest puste, " + "a pole „Informacje” nie zawiera danych do wyekstrahowania.", + paragraf="§ 2 ust. 10 pkt 4 lit. j", + waga=Waga.WARUNKOWE, + ), + Regula( + kod="ART_STRONY", + dotyczy=Osiagniecie.ARTYKUL, + warunek=_puste("rekord__strony") & _puste("rekord__szczegoly"), + opis="Nie podano zakresu stron — pole „Strony” jest puste, a pole " + "„Szczegóły” nie zawiera danych do wyekstrahowania.", + paragraf="§ 2 ust. 10 pkt 4 lit. k", + waga=Waga.WARUNKOWE, + ), + *_reguly_open_access("ART", Osiagniecie.ARTYKUL, "§ 2 ust. 10 pkt 4 lit. l"), + *_reguly_oplaty("ART", Osiagniecie.ARTYKUL, "§ 2 ust. 10 pkt 4 lit. m"), +) + + +# -------------------------------------------------------------------------- +# Monografia naukowa — § 2 ust. 10 pkt 5 — bpp.Wydawnictwo_Zwarte_Autor +# -------------------------------------------------------------------------- + +REGULY_MONOGRAFIA: tuple[Regula, ...] = ( + Regula( + kod="MON_DOI", + dotyczy=Osiagniecie.MONOGRAFIA, + warunek=BRAK_IDENTYFIKATORA_CYFROWEGO, + opis="Brak numeru DOI oraz adresu strony WWW monografii.", + paragraf="§ 2 ust. 10 pkt 5 lit. a", + ), + Regula( + kod="MON_ISBN", + dotyczy=Osiagniecie.MONOGRAFIA, + warunek=_puste("rekord__isbn") & _puste("rekord__e_isbn"), + opis="Monografia nie ma numeru ISBN ani e-ISBN.", + paragraf="§ 2 ust. 10 pkt 5 lit. b", + ), + Regula( + kod="MON_ORCID", + dotyczy=Osiagniecie.MONOGRAFIA, + warunek=BRAK_ORCID, + opis="Autor nie ma wpisanego identyfikatora ORCID.", + paragraf="§ 2 ust. 10 pkt 5 lit. d", + waga=Waga.WARUNKOWE, + ), + Regula( + kod="MON_WYDAWCA", + dotyczy=Osiagniecie.MONOGRAFIA, + warunek=Q(rekord__wydawca__isnull=True) & _puste("rekord__wydawca_opis"), + opis="Nie wskazano wydawcy monografii — ani ze słownika wydawców, ani opisowo.", + paragraf="§ 2 ust. 10 pkt 5 lit. e", + ), + Regula( + kod="MON_DYSCYPLINA", + dotyczy=Osiagniecie.MONOGRAFIA, + warunek=BRAK_DYSCYPLINY, + opis="Autorowi nie przypisano dyscypliny naukowej dla tej pracy " + "albo dyscyplina została odpięta.", + paragraf="§ 2 ust. 10 pkt 5 lit. g", + ), + Regula( + kod="MON_UPOWAZNIENIE", + dotyczy=Osiagniecie.MONOGRAFIA, + warunek=BRAK_UPOWAZNIENIA, + opis="Brak upoważnienia autora do wykazania tej pracy w ewaluacji.", + paragraf="§ 2 ust. 10 pkt 5 lit. h", + ), + Regula( + kod="MON_EDYCJA_NAUKOWA", + dotyczy=Osiagniecie.MONOGRAFIA, + warunek=Q(rekord__pbn_czy_edycja_naukowa__isnull=True), + opis="Nie określono, czy monografia jest edycją naukową tekstów źródłowych.", + paragraf="§ 2 ust. 10 pkt 5 lit. j", + ), + Regula( + kod="MON_PROJEKTY", + dotyczy=Osiagniecie.MONOGRAFIA, + warunek=Q(rekord__pbn_czy_projekt_ncn__isnull=True) + & Q(rekord__pbn_czy_projekt_nprh__isnull=True) + & Q(rekord__pbn_czy_projekt_fnp__isnull=True) + & Q(rekord__pbn_czy_projekt_ue__isnull=True), + opis="Nie określono, czy monografia powstała w ramach projektu NCN, " + "NPRH, FNP lub finansowanego przez Unię Europejską.", + paragraf="§ 2 ust. 10 pkt 5 lit. k", + ), + *_reguly_open_access("MON", Osiagniecie.MONOGRAFIA, "§ 2 ust. 10 pkt 5 lit. m"), + *_reguly_oplaty("MON", Osiagniecie.MONOGRAFIA, "§ 2 ust. 10 pkt 5 lit. n"), +) + + +# -------------------------------------------------------------------------- +# Rozdział w monografii — § 2 ust. 10 pkt 6 — bpp.Wydawnictwo_Zwarte_Autor +# +# Rozdział dziedziczy wymogi monografii macierzystej (lit. a odsyła do pkt 5), +# ale raport ich tu NIE duplikuje — braki monografii pokazuje przy monografii. +# Inaczej jedna niekompletna książka generowałaby braki przy każdym +# z kilkunastu rozdziałów, zalewając listę. +# -------------------------------------------------------------------------- + +REGULY_ROZDZIAL: tuple[Regula, ...] = ( + Regula( + kod="ROZ_NADRZEDNE", + dotyczy=Osiagniecie.ROZDZIAL, + warunek=Q(rekord__wydawnictwo_nadrzedne__isnull=True) + & Q(rekord__wydawnictwo_nadrzedne_w_pbn__isnull=True), + opis="Nie wskazano monografii, w której rozdział się ukazał — ani " + "rekordu w BPP, ani publikacji w PBN.", + paragraf="§ 2 ust. 10 pkt 6 lit. a", + ), + Regula( + kod="ROZ_ORCID", + dotyczy=Osiagniecie.ROZDZIAL, + warunek=BRAK_ORCID, + opis="Autor nie ma wpisanego identyfikatora ORCID.", + paragraf="§ 2 ust. 10 pkt 6 lit. c", + waga=Waga.WARUNKOWE, + ), + Regula( + kod="ROZ_DYSCYPLINA", + dotyczy=Osiagniecie.ROZDZIAL, + warunek=BRAK_DYSCYPLINY, + opis="Autorowi nie przypisano dyscypliny naukowej dla tej pracy " + "albo dyscyplina została odpięta.", + paragraf="§ 2 ust. 10 pkt 6 lit. d", + ), + Regula( + kod="ROZ_UPOWAZNIENIE", + dotyczy=Osiagniecie.ROZDZIAL, + warunek=BRAK_UPOWAZNIENIA, + opis="Brak upoważnienia autora do wykazania tej pracy w ewaluacji.", + paragraf="§ 2 ust. 10 pkt 6 lit. e", + ), +) + + +# -------------------------------------------------------------------------- +# Patent — § 2 ust. 10 pkt 1 — bpp.Patent_Autor +# +# Model Patent pokrywa tylko litery a, c, g oraz — przez relacje — k i m. +# Dla liter b, d, e, f, h, i, j, l BPP nie ma pól (nazwa uprawnionego, +# urząd udzielający, państwa ochrony, data ogłoszenia w „Wiadomościach Urzędu +# Patentowego", uprzednie pierwszeństwo, streszczenie opisu, data złożenia +# tłumaczenia patentu europejskiego). Raport nie może zgłaszać braku danej, +# dla której nie istnieje miejsce zapisu — por. FD#449. +# -------------------------------------------------------------------------- + +REGULY_PATENT: tuple[Regula, ...] = ( + Regula( + kod="PAT_NUMER", + dotyczy=Osiagniecie.PATENT, + warunek=_puste_lub_null("rekord__numer_prawa_wylacznego"), + opis="Nie podano numeru prawa wyłącznego (numeru patentu).", + paragraf="§ 2 ust. 10 pkt 1 lit. c", + ), + Regula( + kod="PAT_ZGLOSZENIE", + dotyczy=Osiagniecie.PATENT, + warunek=_puste_lub_null("rekord__numer_zgloszenia") + | Q(rekord__data_zgloszenia__isnull=True), + opis="Niekompletne dane zgłoszenia patentowego — brakuje numeru " + "zgłoszenia albo jego daty.", + paragraf="§ 2 ust. 10 pkt 1 lit. g", + ), + Regula( + kod="PAT_DYSCYPLINA", + dotyczy=Osiagniecie.PATENT, + warunek=BRAK_DYSCYPLINY, + opis="Współtwórcy nie przypisano dyscypliny naukowej dla tego " + "patentu albo dyscyplina została odpięta.", + paragraf="§ 2 ust. 10 pkt 1 lit. k", + ), + Regula( + kod="PAT_UPOWAZNIENIE", + dotyczy=Osiagniecie.PATENT, + warunek=BRAK_UPOWAZNIENIA, + opis="Brak upoważnienia współtwórcy do wykazania tego patentu w ewaluacji.", + paragraf="§ 2 ust. 10 pkt 1 lit. l", + ), + Regula( + kod="PAT_ORCID", + dotyczy=Osiagniecie.PATENT, + warunek=BRAK_ORCID, + opis="Współtwórca nie ma wpisanego identyfikatora ORCID.", + paragraf="§ 2 ust. 10 pkt 1 lit. m", + waga=Waga.WARUNKOWE, + ), +) + + +#: Kompletny rejestr reguł kompletności danych POL-on. +REGULY: tuple[Regula, ...] = ( + *REGULY_ARTYKUL, + *REGULY_MONOGRAFIA, + *REGULY_ROZDZIAL, + *REGULY_PATENT, +) + + +def reguly_dla(osiagniecie: Osiagniecie) -> tuple[Regula, ...]: + """Zwróć reguły dotyczące danego typu osiągnięcia.""" + return tuple(regula for regula in REGULY if regula.dotyczy == osiagniecie) diff --git a/src/kompletnosc_polon/tests/__init__.py b/src/kompletnosc_polon/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/kompletnosc_polon/tests/test_reguly.py b/src/kompletnosc_polon/tests/test_reguly.py new file mode 100644 index 000000000..6fb9ac221 --- /dev/null +++ b/src/kompletnosc_polon/tests/test_reguly.py @@ -0,0 +1,439 @@ +"""Testy rejestru reguł kompletności danych POL-on. + +Reguły są danymi, więc właściwą jednostką testową jest pojedyncza reguła. +Dla każdej z nich budujemy dwa powiązania autor–rekord: jedno spełniające +KOMPLET wymogów swojego typu osiągnięcia i jedno zepsute dokładnie w tym +jednym miejscu, którego reguła dotyczy. Asercja: ``warunek`` łapie wyłącznie +to zepsute. + +Testy są parametryzowane po :data:`kompletnosc_polon.reguly.REGULY`, więc +dopisanie reguły bez dopisania sposobu jej zepsucia (:data:`PSUJ`) psuje +suitę — i o to chodzi. +""" + +import datetime +import itertools +from decimal import Decimal + +import pytest +from model_bakery import baker + +from kompletnosc_polon.const import OA_CZAS_PO_OPUBLIKOWANIU, Osiagniecie, Waga +from kompletnosc_polon.reguly import ( + REGULY, + REGULY_ARTYKUL, + REGULY_MONOGRAFIA, + REGULY_PATENT, + REGULY_ROZDZIAL, + reguly_dla, +) + +#: Rok wewnątrz bieżącego okna ewaluacji — patrz ``OKNO_EWALUACJI``. +ROK = 2026 + +#: Model powiązania autora z rekordem (ziarno raportu) dla każdego typu +#: osiągnięcia. Monografia i rozdział dzielą ten sam model — rozróżnia je +#: dopiero ``charakter_formalny.charakter_sloty``, czym zajmują się selektory, +#: nie reguły. +MODEL_POWIAZANIA = { + Osiagniecie.ARTYKUL: "bpp.Wydawnictwo_Ciagle_Autor", + Osiagniecie.MONOGRAFIA: "bpp.Wydawnictwo_Zwarte_Autor", + Osiagniecie.ROZDZIAL: "bpp.Wydawnictwo_Zwarte_Autor", + Osiagniecie.PATENT: "bpp.Patent_Autor", +} + +MODEL_TRYBU_OA = { + "wydawnictwo_ciagle": "bpp.Tryb_OpenAccess_Wydawnictwo_Ciagle", + "wydawnictwo_zwarte": "bpp.Tryb_OpenAccess_Wydawnictwo_Zwarte", +} + +_licznik_orcid = itertools.count(1) + + +def _kolejny_orcid() -> str: + """Unikalny ORCID w formacie akceptowanym przez walidator ``Autor.orcid``.""" + numer = next(_licznik_orcid) + return f"0000-0001-{numer // 10000 % 10000:04d}-{numer % 10000:04d}" + + +# -------------------------------------------------------------------------- +# Budowa obiektów kompletnych — takich, których ŻADNA reguła nie łapie +# -------------------------------------------------------------------------- + + +def _kompletny_artykul(): + zrodlo = baker.make("bpp.Zrodlo", issn="1234-5678", e_issn="") + return baker.make( + "bpp.Wydawnictwo_Ciagle", + rok=ROK, + zrodlo=zrodlo, + doi="10.1000/kompletny", + www="", + public_www="", + issn="", + e_issn="", + tom="12", + informacje="", + strony="1-10", + szczegoly="", + pbn_czy_artykul_recenzyjny=False, + openaccess_tryb_dostepu=None, + openaccess_wersja_tekstu=None, + openaccess_licencja=None, + openaccess_czas_publikacji=None, + openaccess_data_opublikowania=None, + openaccess_ilosc_miesiecy=None, + opl_pub_cost_free=True, + opl_pub_amount=None, + opl_pub_research_potential=None, + opl_pub_research_or_development_projects=None, + opl_pub_other=None, + ) + + +def _kompletne_zwarte(): + """Wydawnictwo zwarte spełniające wymogi i monografii, i rozdziału.""" + return baker.make( + "bpp.Wydawnictwo_Zwarte", + rok=ROK, + doi="10.1000/kompletny", + www="", + public_www="", + isbn="978-83-01-00000-0", + e_isbn="", + wydawca=baker.make("bpp.Wydawca"), + wydawca_opis="", + wydawnictwo_nadrzedne=baker.make("bpp.Wydawnictwo_Zwarte", rok=ROK), + wydawnictwo_nadrzedne_w_pbn=None, + pbn_czy_edycja_naukowa=False, + pbn_czy_projekt_ncn=False, + pbn_czy_projekt_nprh=False, + pbn_czy_projekt_fnp=False, + pbn_czy_projekt_ue=False, + openaccess_tryb_dostepu=None, + openaccess_wersja_tekstu=None, + openaccess_licencja=None, + openaccess_czas_publikacji=None, + openaccess_data_opublikowania=None, + openaccess_ilosc_miesiecy=None, + opl_pub_cost_free=True, + opl_pub_amount=None, + opl_pub_research_potential=None, + opl_pub_research_or_development_projects=None, + opl_pub_other=None, + ) + + +def _kompletny_patent(): + return baker.make( + "bpp.Patent", + rok=ROK, + numer_prawa_wylacznego="PL 123456", + numer_zgloszenia="P.400000", + data_zgloszenia=datetime.date(ROK, 1, 15), + ) + + +BUDOWNICZY_REKORDU = { + Osiagniecie.ARTYKUL: _kompletny_artykul, + Osiagniecie.MONOGRAFIA: _kompletne_zwarte, + Osiagniecie.ROZDZIAL: _kompletne_zwarte, + Osiagniecie.PATENT: _kompletny_patent, +} + + +def _zbuduj_kompletne_powiazanie(osiagniecie: Osiagniecie): + """Zbuduj parę (autor, rekord), której nie łapie żadna reguła. + + Zwraca instancję through-modelu — to jest ziarno raportu i jednocześnie + punkt, z którego liczone są wszystkie warunki (patrz docstring modułu + ``kompletnosc_polon.reguly``). + """ + rekord = BUDOWNICZY_REKORDU[osiagniecie]() + autor = baker.make("bpp.Autor", orcid=_kolejny_orcid()) + dyscyplina = baker.make("bpp.Dyscyplina_Naukowa") + + # Powiązanie autor–dyscyplina na dany rok jest wymuszone przez + # BazaModeluOdpowiedzialnosciAutorow.clean(), wołane z save(). + baker.make( + "bpp.Autor_Dyscyplina", + autor=autor, + rok=ROK, + dyscyplina_naukowa=dyscyplina, + subdyscyplina_naukowa=None, + ) + + return baker.make( + MODEL_POWIAZANIA[osiagniecie], + rekord=rekord, + autor=autor, + jednostka=baker.make("bpp.Jednostka"), + dyscyplina_naukowa=dyscyplina, + przypieta=True, + upowaznienie_pbn=True, + oswiadczenie_ken=None, + ) + + +# -------------------------------------------------------------------------- +# Psucie — jedna funkcja na regułę, wprowadza dokładnie jeden brak +# -------------------------------------------------------------------------- + + +def _tryb_open_access(rekord): + return baker.make(MODEL_TRYBU_OA[rekord._meta.model_name]) + + +def _psuj_doi(powiazanie): + rekord = powiazanie.rekord + rekord.doi = None + rekord.www = "" + rekord.public_www = "" + rekord.save() + + +def _psuj_dyscypline(powiazanie): + powiazanie.przypieta = False + powiazanie.save() + + +def _psuj_upowaznienie(powiazanie): + powiazanie.upowaznienie_pbn = False + powiazanie.save() + + +def _psuj_orcid(powiazanie): + powiazanie.autor.orcid = None + powiazanie.autor.save() + + +def _psuj_oa(pole): + """Zbuduj funkcję psującą jedno pole Open Access. + + Ustawia tryb dostępu (czyli włącza wymogi OA) i czyści wskazane pole. + """ + + def psuj(powiazanie): + rekord = powiazanie.rekord + rekord.openaccess_tryb_dostepu = _tryb_open_access(rekord) + setattr(rekord, pole, None) + rekord.save() + + return psuj + + +def _psuj_oa_miesiace(powiazanie): + from bpp.models import Czas_Udostepnienia_OpenAccess + + # Słownik czasów udostępnienia jest częścią baseline'u bazy (fixture + # instalacyjny), a `skrot` ma ograniczenie unikalności — stąd + # get_or_create zamiast baker.make. + czas, _ = Czas_Udostepnienia_OpenAccess.objects.get_or_create( + skrot=OA_CZAS_PO_OPUBLIKOWANIU, + defaults={"nazwa": "po opublikowaniu"}, + ) + rekord = powiazanie.rekord + rekord.openaccess_czas_publikacji = czas + rekord.openaccess_ilosc_miesiecy = None + rekord.save() + + +def _psuj_apc(powiazanie): + rekord = powiazanie.rekord + rekord.opl_pub_cost_free = None + rekord.opl_pub_amount = None + rekord.opl_pub_research_potential = None + rekord.opl_pub_research_or_development_projects = None + rekord.opl_pub_other = None + rekord.save() + + +def _psuj_apc_zrodlo(powiazanie): + rekord = powiazanie.rekord + rekord.opl_pub_cost_free = False + rekord.opl_pub_amount = Decimal("1500.00") + rekord.opl_pub_research_potential = None + rekord.opl_pub_research_or_development_projects = False + rekord.opl_pub_other = None + rekord.save() + + +def _psuj_pole_rekordu(**nadpisz): + """Zbuduj funkcję ustawiającą wskazane pola rekordu na podane wartości.""" + + def psuj(powiazanie): + rekord = powiazanie.rekord + for pole, wartosc in nadpisz.items(): + setattr(rekord, pole, wartosc) + rekord.save() + + return psuj + + +def _psuj_issn(powiazanie): + rekord = powiazanie.rekord + rekord.zrodlo.issn = "" + rekord.zrodlo.e_issn = "" + rekord.zrodlo.save() + rekord.issn = "" + rekord.e_issn = "" + rekord.save() + + +#: Sposób wprowadzenia dokładnie tego braku, którego dotyczy dana reguła. +PSUJ = { + # Artykuł naukowy — § 2 ust. 10 pkt 4 + "ART_DOI": _psuj_doi, + "ART_DYSCYPLINA": _psuj_dyscypline, + "ART_UPOWAZNIENIE": _psuj_upowaznienie, + "ART_ORCID": _psuj_orcid, + "ART_RECENZYJNY": _psuj_pole_rekordu(pbn_czy_artykul_recenzyjny=None), + "ART_ZRODLO": _psuj_pole_rekordu(zrodlo=None), + "ART_ISSN": _psuj_issn, + "ART_TOM": _psuj_pole_rekordu(tom="", informacje=""), + "ART_STRONY": _psuj_pole_rekordu(strony="", szczegoly=""), + "ART_OA_WERSJA": _psuj_oa("openaccess_wersja_tekstu"), + "ART_OA_LICENCJA": _psuj_oa("openaccess_licencja"), + "ART_OA_DATA": _psuj_oa("openaccess_data_opublikowania"), + "ART_OA_CZAS": _psuj_oa("openaccess_czas_publikacji"), + "ART_OA_MIESIACE": _psuj_oa_miesiace, + "ART_APC": _psuj_apc, + "ART_APC_ZRODLO": _psuj_apc_zrodlo, + # Monografia naukowa — § 2 ust. 10 pkt 5 + "MON_DOI": _psuj_doi, + "MON_ISBN": _psuj_pole_rekordu(isbn="", e_isbn=""), + "MON_ORCID": _psuj_orcid, + "MON_WYDAWCA": _psuj_pole_rekordu(wydawca=None, wydawca_opis=""), + "MON_DYSCYPLINA": _psuj_dyscypline, + "MON_UPOWAZNIENIE": _psuj_upowaznienie, + "MON_EDYCJA_NAUKOWA": _psuj_pole_rekordu(pbn_czy_edycja_naukowa=None), + "MON_PROJEKTY": _psuj_pole_rekordu( + pbn_czy_projekt_ncn=None, + pbn_czy_projekt_nprh=None, + pbn_czy_projekt_fnp=None, + pbn_czy_projekt_ue=None, + ), + "MON_OA_WERSJA": _psuj_oa("openaccess_wersja_tekstu"), + "MON_OA_LICENCJA": _psuj_oa("openaccess_licencja"), + "MON_OA_DATA": _psuj_oa("openaccess_data_opublikowania"), + "MON_OA_CZAS": _psuj_oa("openaccess_czas_publikacji"), + "MON_OA_MIESIACE": _psuj_oa_miesiace, + "MON_APC": _psuj_apc, + "MON_APC_ZRODLO": _psuj_apc_zrodlo, + # Rozdział w monografii — § 2 ust. 10 pkt 6 + "ROZ_NADRZEDNE": _psuj_pole_rekordu( + wydawnictwo_nadrzedne=None, wydawnictwo_nadrzedne_w_pbn=None + ), + "ROZ_ORCID": _psuj_orcid, + "ROZ_DYSCYPLINA": _psuj_dyscypline, + "ROZ_UPOWAZNIENIE": _psuj_upowaznienie, + # Patent — § 2 ust. 10 pkt 1 + "PAT_NUMER": _psuj_pole_rekordu(numer_prawa_wylacznego=None), + "PAT_ZGLOSZENIE": _psuj_pole_rekordu(data_zgloszenia=None), + "PAT_DYSCYPLINA": _psuj_dyscypline, + "PAT_UPOWAZNIENIE": _psuj_upowaznienie, + "PAT_ORCID": _psuj_orcid, +} + + +def _id_reguly(regula): + return regula.kod + + +# -------------------------------------------------------------------------- +# Testy samego rejestru (bez bazy danych) +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("regula", REGULY, ids=_id_reguly) +def test_regula_ma_komplet_metadanych(regula): + assert regula.kod.strip(), "Reguła bez kodu" + assert regula.opis.strip(), f"Reguła {regula.kod} bez opisu" + assert regula.paragraf.strip(), f"Reguła {regula.kod} bez podstawy prawnej" + assert regula.paragraf.startswith("§ 2 ust. 10 "), ( + f"Reguła {regula.kod} wskazuje paragraf spoza § 2 ust. 10" + ) + assert regula.dotyczy in Osiagniecie + assert regula.waga in Waga + + +def test_kody_regul_sa_unikalne(): + kody = [regula.kod for regula in REGULY] + assert len(kody) == len(set(kody)), "Zduplikowane kody reguł w rejestrze" + + +@pytest.mark.parametrize("regula", REGULY, ids=_id_reguly) +def test_kod_reguly_pasuje_do_typu_osiagniecia(regula): + prefiks = { + Osiagniecie.ARTYKUL: "ART_", + Osiagniecie.MONOGRAFIA: "MON_", + Osiagniecie.ROZDZIAL: "ROZ_", + Osiagniecie.PATENT: "PAT_", + }[regula.dotyczy] + assert regula.kod.startswith(prefiks) + + +@pytest.mark.parametrize("regula", REGULY, ids=_id_reguly) +def test_kazda_regula_ma_zdefiniowany_sposob_zepsucia(regula): + assert regula.kod in PSUJ, ( + f"Dopisano regułę {regula.kod}, ale nie opisano, jak wprowadzić " + f"brak, który ma łapać — uzupełnij słownik PSUJ." + ) + + +def test_reguly_dla_zwraca_rozlaczne_i_zupelne_podzbiory(): + zebrane = [] + for osiagniecie in Osiagniecie: + podzbior = reguly_dla(osiagniecie) + assert podzbior, f"Brak reguł dla {osiagniecie}" + zebrane.extend(podzbior) + + assert len(zebrane) == len(REGULY) + assert set(zebrane) == set(REGULY) + + +def test_rejestr_sklada_sie_z_czterech_grup(): + assert REGULY == ( + *REGULY_ARTYKUL, + *REGULY_MONOGRAFIA, + *REGULY_ROZDZIAL, + *REGULY_PATENT, + ) + + +# -------------------------------------------------------------------------- +# Test właściwy: reguła łapie brak i nie łapie kompletnego +# -------------------------------------------------------------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize("regula", REGULY, ids=_id_reguly) +def test_regula_lapie_dokladnie_rekord_z_brakiem(regula): + kompletne = _zbuduj_kompletne_powiazanie(regula.dotyczy) + niekompletne = _zbuduj_kompletne_powiazanie(regula.dotyczy) + + PSUJ[regula.kod](niekompletne) + + model = type(niekompletne) + znalezione = set(model.objects.filter(regula.warunek).values_list("pk", flat=True)) + + assert znalezione == {niekompletne.pk}, ( + f"Reguła {regula.kod} ({regula.paragraf}) nie łapie wprowadzonego " + f"braku albo łapie także rekord kompletny (pk={kompletne.pk})." + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("regula", REGULY, ids=_id_reguly) +def test_zaden_kompletny_rekord_nie_jest_lapany(regula): + """Obiekt kompletny nie może być łapany przez ŻADNĄ regułę swojego typu. + + Chroni przed budowaniem „kompletnego” obiektu pod jedną regułę kosztem + innej — a więc przed fałszywie zielonym testem powyżej. + """ + kompletne = _zbuduj_kompletne_powiazanie(regula.dotyczy) + model = type(kompletne) + + assert not model.objects.filter(regula.warunek).filter(pk=kompletne.pk).exists(), ( + f"Reguła {regula.kod} ({regula.paragraf}) łapie rekord kompletny." + ) From 29b8765c2cfab5da91d5a551ae679fbf4b05a392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 25 Jul 2026 02:41:41 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(kompletnosc=5Fpolon):=20selektory=20zi?= =?UTF-8?q?arna=20raportu=20i=20anotacja=20regu=C5=82ami?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit powiazania() buduje queryset through-modelu per typ osiągnięcia, zawężony do okna ewaluacji, powiązań przypiętych i uczelni oglądającego (przez istniejący scope_autorzy_do_uczelni, nie własny filtr). z_regulami() dokłada po polu logicznym na regułę plus liczniki braków wymaganych i warunkowych. Zawężenie NIE odsiewa powiązań bez dyscypliny. Spec mówił o "przypiętej dyscyplinie", co dosłownie odsiałoby dyscyplina IS NULL — a wtedy reguły *_DYSCYPLINA nigdy by nie zadziałały i raport przemilczałby brak, o który pyta. Spec poprawiony. Wydawnictwa zwarte bez ustawionego charakter_sloty (fixture instalacyjny zostawia je puste) nie znikają po cichu — powiazania_nierozpoznane() zbiera je do osobnej sekcji, żeby użytkownik nie uznał ich za sprawdzone. 228 testów (203 z kroku 1 + 25 nowych), zweryfikowane mutacyjnie. Refs FD#437 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01McWgJYK4c4GSMdsm6P4oXc --- ...5-raport-kompletnosci-polon-2026-design.md | 12 +- .../fd437-kompletnosc-polon.feature.rst | 6 + src/kompletnosc_polon/selektory.py | 237 ++++++++ src/kompletnosc_polon/tests/test_selektory.py | 513 ++++++++++++++++++ 4 files changed, 765 insertions(+), 3 deletions(-) create mode 100644 src/bpp/newsfragments/fd437-kompletnosc-polon.feature.rst create mode 100644 src/kompletnosc_polon/selektory.py create mode 100644 src/kompletnosc_polon/tests/test_selektory.py diff --git a/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md b/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md index f92692e3b..d27807dba 100644 --- a/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md +++ b/docs/superpowers/specs/2026-07-25-raport-kompletnosci-polon-2026-design.md @@ -222,7 +222,6 @@ src/kompletnosc_polon/ ├── const.py # Osiagniecie, Waga ├── reguly.py # REGULY: tuple[Regula, ...] + pomocnicze selektory ├── selektory.py # budowa querysetów per typ osiągnięcia -├── uczelnia_scope.py # scope_kompletnosc(qs, uczelnia) ├── urls.py # app_name = "kompletnosc_polon" ├── views/ │ ├── __init__.py # re-eksport + __all__ @@ -255,8 +254,15 @@ autora. Zawężenie do uczelni przez `uczelnia_dla_odczytu(request)` ## Przepływ danych 1. Widok ustala okno (`OKNO_EWALUACJI`) i uczelnię (`uczelnia_dla_odczytu`). -2. `selektory.py` buduje trzy querysety through-modeli, zawężone do: roku - w oknie, przypiętej dyscypliny, autora afiliowanego do jednostki tej uczelni. +2. `selektory.py` buduje querysety through-modeli, zawężone do: roku w oknie, + powiązania **przypiętego** (`przypieta=True`) oraz autora afiliowanego do + jednostki tej uczelni (przez `scope_autorzy_do_uczelni`). + + Zawężenie celowo **nie** odsiewa powiązań bez dyscypliny. Pierwotna wersja + projektu mówiła o „przypiętej dyscyplinie", co czytane dosłownie odsiałoby + też `dyscyplina_naukowa IS NULL` — a wtedy reguły `*_DYSCYPLINA` stałyby się + martwe i raport przemilczałby dokładnie ten brak, o który pyta. Powiązanie + przypięte, ale bez dyscypliny, zostaje w raporcie i jest zgłaszane jako brak. 3. Dla każdego querysetu `annotate()` dokłada po jednym `BooleanField` na regułę pasującą do typu osiągnięcia (`Case/When` z `Regula.warunek`). 4. Widok zbiorczy agreguje po autorze: liczba rekordów z brakami, liczba braków diff --git a/src/bpp/newsfragments/fd437-kompletnosc-polon.feature.rst b/src/bpp/newsfragments/fd437-kompletnosc-polon.feature.rst new file mode 100644 index 000000000..3aef8300f --- /dev/null +++ b/src/bpp/newsfragments/fd437-kompletnosc-polon.feature.rst @@ -0,0 +1,6 @@ +Nowy raport „kompletność danych POL-on" w menu ewaluacji pokazuje, przy którym +pracowniku i w którym rekordzie brakuje danych wymaganych rozporządzeniem +Ministra Nauki i Szkolnictwa Wyższego z 16 czerwca 2026 r. (Dz. U. 2026 poz. 811). +Każdy brak opatrzony jest podstawą prawną i odnośnikiem prosto do formularza +edycji. Raport rozróżnia dane wymagane bezwarunkowo od tych, których +rozporządzenie żąda tylko „jeżeli posiada" — te drugie liczone są osobno. diff --git a/src/kompletnosc_polon/selektory.py b/src/kompletnosc_polon/selektory.py new file mode 100644 index 000000000..d1ed07dac --- /dev/null +++ b/src/kompletnosc_polon/selektory.py @@ -0,0 +1,237 @@ +"""Budowa querysetów raportu kompletności danych POL-on. + +Ziarnem raportu jest **para (autor, rekord)**, czyli dokładnie jeden wiersz +*through-modelu* powiązania autora z publikacją (``Wydawnictwo_Ciagle_Autor``, +``Wydawnictwo_Zwarte_Autor``, ``Patent_Autor``). Wynika to z konstrukcji +§ 2 ust. 10 rozporządzenia: osiągnięcia trafiają do *wykazu pracowników*, +a każde niesie własną dyscyplinę i własne upoważnienie. Ten sam artykuł +dwóch współautorów to w POL-onie dwa wpisy. + +Moduł wystawia trzy warstwy, składane przez widok: + +1. :func:`powiazania` — surowe ziarno, zawężone do okna ewaluacji, do + przypiętych powiązań i do uczelni oglądającego; +2. :func:`z_regulami` — anotacja: po jednym ``BooleanField`` na regułę plus + liczniki braków wymaganych i warunkowych; +3. :func:`naruszone_reguly` — odczyt anotacji z pojedynczego wiersza jako + listy obiektów :class:`~kompletnosc_polon.reguly.Regula` (z opisem + i podstawą prawną, do wyświetlenia). + +Rozbieżności wobec projektu (świadome, opisane) +=============================================== + +*Zawężenie „przypięta dyscyplina”* realizujemy jako ``przypieta=True`` +i **nie** dokładamy ``dyscyplina_naukowa__isnull=False``. Projekt w sekcji +„Przepływ danych” wymienia oba warunki, ale drugi z nich unieważniłby reguły +``*_DYSCYPLINA`` (``dyscyplina_naukowa`` NULL **lub** ``przypieta=False``) — +powiązanie bez dyscypliny wypadałoby z raportu, zamiast zostać w nim +zgłoszone jako brak. Zostawiamy więc w raporcie powiązania przypięte, ale +bezdyscyplinowe; człon ``przypieta=False`` reguł ``*_DYSCYPLINA`` jest przy +tym zawężeniu nieosiągalny i pozostaje wyłącznie jako dokumentacja wymogu. +""" + +from functools import reduce + +from django.apps import apps +from django.db.models import ( + BooleanField, + Case, + IntegerField, + Q, + QuerySet, + Value, + When, +) + +from bpp.const import CHARAKTER_SLOTY_KSIAZKA, CHARAKTER_SLOTY_ROZDZIAL +from bpp.util.uczelnia_scope import scope_autorzy_do_uczelni +from ewaluacja_common.const import OKNO_EWALUACJI +from kompletnosc_polon.const import Osiagniecie, Waga +from kompletnosc_polon.reguly import Regula, reguly_dla + +#: Prefiks nazw anotacji z wynikiem reguły: ``brak_ART_DOI`` itd. +PREFIKS_POLA_REGULY = "brak_" + +#: Nazwa anotacji z liczbą naruszonych reguł bezwarunkowych. +POLE_BRAKI_WYMAGANE = "braki_wymagane" + +#: Nazwa anotacji z liczbą naruszonych reguł warunkowych („jeżeli posiada”). +POLE_BRAKI_WARUNKOWE = "braki_warunkowe" + +#: Through-model niosący ziarno raportu dla danego typu osiągnięcia. +#: Monografia i rozdział dzielą jeden model — rozróżnia je dopiero +#: ``charakter_formalny.charakter_sloty`` (patrz :data:`CHARAKTER_SLOTOW`). +MODEL_POWIAZANIA: dict[Osiagniecie, str] = { + Osiagniecie.ARTYKUL: "bpp.Wydawnictwo_Ciagle_Autor", + Osiagniecie.MONOGRAFIA: "bpp.Wydawnictwo_Zwarte_Autor", + Osiagniecie.ROZDZIAL: "bpp.Wydawnictwo_Zwarte_Autor", + Osiagniecie.PATENT: "bpp.Patent_Autor", +} + +#: Wartość ``Charakter_Formalny.charakter_sloty`` klasyfikująca wydawnictwo +#: zwarte do danego typu osiągnięcia. Typy spoza tego słownika (artykuł, +#: patent) nie podlegają klasyfikacji po charakterze formalnym: patent nie ma +#: takiego pola w ogóle, a wydawnictwo ciągłe jest zawsze pkt 4. +CHARAKTER_SLOTOW: dict[Osiagniecie, int] = { + Osiagniecie.MONOGRAFIA: CHARAKTER_SLOTY_KSIAZKA, + Osiagniecie.ROZDZIAL: CHARAKTER_SLOTY_ROZDZIAL, +} + + +def pole_reguly(kod: str) -> str: + """Nazwa anotacji niosącej wynik reguły o podanym kodzie.""" + return f"{PREFIKS_POLA_REGULY}{kod}" + + +def _model(osiagniecie: Osiagniecie): + return apps.get_model(MODEL_POWIAZANIA[osiagniecie]) + + +def _zawez(qs, okno: tuple[int, int], uczelnia) -> QuerySet: + """Wspólne zawężenie ziarna: okno ewaluacji, przypięcie, uczelnia. + + Okno jest przedziałem **domkniętym** — oba lata graniczne wchodzą. + + Uczelnię odsiewamy przez ``scope_autorzy_do_uczelni``: through-modele + niosą ``jednostka``, dokładnie tak jak mat-view autorstw, dla którego ten + pomocnik powstał, więc reguła atrybucji (jednostka zapisana na autorstwie) + i guard single-install są wspólne z resztą systemu — nie powielamy ich + tutaj własnym filtrem. + """ + pierwszy_rok, ostatni_rok = okno + qs = qs.filter( + rekord__rok__gte=pierwszy_rok, + rekord__rok__lte=ostatni_rok, + przypieta=True, + ) + return scope_autorzy_do_uczelni(qs, uczelnia) + + +def powiazania( + osiagniecie: Osiagniecie, + uczelnia=None, + okno: tuple[int, int] = OKNO_EWALUACJI, +) -> QuerySet: + """Ziarno raportu dla jednego typu osiągnięcia. + + :param osiagniecie: typ osiągnięcia w rozumieniu § 2 ust. 10. + :param uczelnia: uczelnia oglądającego; ``None`` (albo instalacja + jednouczelniana) oznacza brak zawężenia. + :param okno: domknięty przedział lat; domyślnie bieżące okno ewaluacji. + """ + qs = _zawez(_model(osiagniecie).objects.all(), okno, uczelnia) + + charakter_slotow = CHARAKTER_SLOTOW.get(osiagniecie) + if charakter_slotow is not None: + qs = qs.filter(rekord__charakter_formalny__charakter_sloty=charakter_slotow) + + return qs + + +def powiazania_nierozpoznane( + uczelnia=None, + okno: tuple[int, int] = OKNO_EWALUACJI, +) -> QuerySet: + """Powiązania z wydawnictwem zwartym, którego nie da się zaklasyfikować. + + Fixture instalacyjny BPP zostawia ``Charakter_Formalny.charakter_sloty`` + puste, a bez tej wartości nie wiadomo, czy rekord jest monografią (pkt 5), + czy rozdziałem (pkt 6) — a więc których wymogów od niego oczekiwać. Takie + powiązania wypadają z obu querysetów :func:`powiazania` i **nie wolno im + zniknąć po cichu**, bo użytkownik uznałby, że raport je sprawdził + i nie znalazł zastrzeżeń. Widok pokazuje je w osobnej sekcji + „nierozpoznany typ osiągnięcia”. + + Zakres celowo ograniczony do ``charakter_sloty IS NULL``, czyli do + *braku klasyfikacji*. Wydawnictwo zwarte oznaczone jako referat + (``CHARAKTER_SLOTY_REFERAT``) jest zaklasyfikowane jawnie i po prostu nie + należy do żadnego z punktów objętych raportem — to nie jest brak danych. + """ + return _zawez( + apps.get_model("bpp.Wydawnictwo_Zwarte_Autor").objects.filter( + rekord__charakter_formalny__charakter_sloty__isnull=True + ), + okno, + uczelnia, + ) + + +def _czy_brakuje(warunek: Q): + """Wyrażenie logiczne: czy warunek reguły zachodzi dla tego wiersza.""" + return Case( + When(warunek, then=Value(True)), + default=Value(False), + output_field=BooleanField(), + ) + + +def _ile_brakow(reguly): + """Suma naruszonych reguł — dodawanie wyrażeń, nie agregat. + + Świadomie NIE używamy ``Sum``: liczymy w obrębie jednego wiersza, więc + agregat wymusiłby ``GROUP BY`` po całym through-modelu. Zwykłe dodawanie + wyrażeń ``CASE ... THEN 1 ELSE 0`` daje ten sam wynik jednym przebiegiem. + """ + skladniki = [ + Case( + When(regula.warunek, then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ) + for regula in reguly + ] + if not skladniki: + return Value(0, output_field=IntegerField()) + return reduce(lambda a, b: a + b, skladniki) + + +def z_regulami(qs: QuerySet, osiagniecie: Osiagniecie) -> QuerySet: + """Dołóż anotacje z wynikiem każdej reguły danego typu osiągnięcia. + + Dokładane pola: + + * ``brak_`` (``BooleanField``) — po jednym na regułę z + :func:`~kompletnosc_polon.reguly.reguly_dla`; ``True`` znaczy „danej + brakuje”; + * :data:`POLE_BRAKI_WYMAGANE` i :data:`POLE_BRAKI_WARUNKOWE` + (``IntegerField``) — liczba naruszonych reguł danej wagi. + + Queryset MUSI startować z through-modelu odpowiadającego typowi + osiągnięcia (patrz :func:`powiazania`) — warunki reguł zapisane są od tej + strony relacji. + """ + reguly = reguly_dla(osiagniecie) + + anotacje = {pole_reguly(r.kod): _czy_brakuje(r.warunek) for r in reguly} + anotacje[POLE_BRAKI_WYMAGANE] = _ile_brakow( + [r for r in reguly if r.waga == Waga.WYMAGANE] + ) + anotacje[POLE_BRAKI_WARUNKOWE] = _ile_brakow( + [r for r in reguly if r.waga == Waga.WARUNKOWE] + ) + + return qs.annotate(**anotacje) + + +def naruszone_reguly(wiersz, osiagniecie: Osiagniecie) -> list[Regula]: + """Reguły naruszone przez pojedynczy — zanotowany — wiersz raportu. + + Zwraca obiekty :class:`~kompletnosc_polon.reguly.Regula`, a nie same kody, + bo widok szczegółów wypisuje z nich opis i podstawę prawną. + + :raises ValueError: gdy wiersz nie przeszedł przez :func:`z_regulami`. + Cicha pusta lista byłaby tu najgorszą możliwą odpowiedzią — wyglądałaby + jak „rekord kompletny”. + """ + naruszone = [] + for regula in reguly_dla(osiagniecie): + pole = pole_reguly(regula.kod) + if not hasattr(wiersz, pole): + raise ValueError( + f"Wiersz {wiersz!r} nie ma anotacji {pole!r} — przepuść " + f"queryset przez z_regulami(qs, {osiagniecie!r}) zanim " + f"zapytasz o naruszone reguły." + ) + if getattr(wiersz, pole): + naruszone.append(regula) + return naruszone diff --git a/src/kompletnosc_polon/tests/test_selektory.py b/src/kompletnosc_polon/tests/test_selektory.py new file mode 100644 index 000000000..020e9d15e --- /dev/null +++ b/src/kompletnosc_polon/tests/test_selektory.py @@ -0,0 +1,513 @@ +"""Testy selektorów raportu kompletności danych POL-on. + +Selektory odpowiadają za trzy rzeczy i każda z nich ma tu własny blok testów: + +1. **zawężenie** — do okna ewaluacji, do przypiętych powiązań i do uczelni + oglądającego (multi-tenant); +2. **klasyfikację** — rozróżnienie monografii od rozdziału po + ``charakter_formalny.charakter_sloty``, wraz z osobną kategorią + „nierozpoznany typ osiągnięcia” dla rekordów, których zaklasyfikować + się nie da; +3. **anotację** — dołożenie po jednym polu logicznym na regułę oraz + liczników braków wymaganych i warunkowych. + +Budowniczych obiektów „kompletnych” (takich, których nie łapie ŻADNA reguła) +importujemy z :mod:`kompletnosc_polon.tests.test_reguly`. Zduplikowanie ich +tutaj oznaczałoby, że przy zmianie reguły trzeba pamiętać o dwóch miejscach — +a to gwarancja cichego rozjazdu. +""" + +import datetime + +import pytest +from model_bakery import baker + +from bpp.const import CHARAKTER_SLOTY_KSIAZKA, CHARAKTER_SLOTY_ROZDZIAL +from ewaluacja_common.const import OKNO_EWALUACJI +from kompletnosc_polon import selektory +from kompletnosc_polon.const import OA_CZAS_PO_OPUBLIKOWANIU, Osiagniecie, Waga +from kompletnosc_polon.reguly import reguly_dla + +from .test_reguly import ( + ROK, + _kolejny_orcid, + _kompletne_zwarte, + _kompletny_artykul, + _kompletny_patent, +) + +PIERWSZY_ROK, OSTATNI_ROK = OKNO_EWALUACJI + + +# -------------------------------------------------------------------------- +# Pomocnicy budujący ziarno raportu: powiązanie (autor, dyscyplina, rekord) +# -------------------------------------------------------------------------- + + +def _jednostka(uczelnia=None): + """Jednostka w podanej uczelni; przy braku uczelni — w nowej.""" + if uczelnia is None: + uczelnia = baker.make("bpp.Uczelnia") + return baker.make("bpp.Jednostka", uczelnia=uczelnia) + + +def _powiazanie( + rekord, + model_powiazania, + *, + uczelnia=None, + jednostka=None, + z_dyscyplina=True, + przypieta=True, + orcid=True, + **nadpisz, +): + """Zbuduj powiązanie autora z rekordem — jeden wiersz raportu. + + Powiązanie autor–dyscyplina na rok rekordu jest wymuszone przez + ``BazaModeluOdpowiedzialnosciAutorow.clean()``, wołane z ``save()``, + więc tworzymy je zawsze, gdy dyscyplina ma być ustawiona. + """ + autor = baker.make("bpp.Autor", orcid=_kolejny_orcid() if orcid else None) + + dyscyplina = None + if z_dyscyplina: + dyscyplina = baker.make("bpp.Dyscyplina_Naukowa") + baker.make( + "bpp.Autor_Dyscyplina", + autor=autor, + rok=rekord.rok, + dyscyplina_naukowa=dyscyplina, + subdyscyplina_naukowa=None, + ) + + return baker.make( + model_powiazania, + rekord=rekord, + autor=autor, + jednostka=jednostka if jednostka is not None else _jednostka(uczelnia), + dyscyplina_naukowa=dyscyplina, + przypieta=przypieta, + upowaznienie_pbn=True, + oswiadczenie_ken=None, + **nadpisz, + ) + + +def _artykul_z_autorem(rok=ROK, **kwargs): + rekord = _kompletny_artykul() + if rok != rekord.rok: + rekord.rok = rok + rekord.save() + return _powiazanie(rekord, "bpp.Wydawnictwo_Ciagle_Autor", **kwargs) + + +def _charakter(charakter_sloty): + """Charakter formalny o zadanym „charakterze dla slotów”. + + ``skrot`` bierzemy losowy (baker), bo słownik charakterów jest częścią + baseline'u bazy i kolizja unikalności psułaby test. + """ + return baker.make("bpp.Charakter_Formalny", charakter_sloty=charakter_sloty) + + +def _zwarte_z_autorem(charakter_sloty, rok=ROK, **kwargs): + rekord = _kompletne_zwarte() + rekord.rok = rok + rekord.charakter_formalny = _charakter(charakter_sloty) + rekord.save() + return _powiazanie(rekord, "bpp.Wydawnictwo_Zwarte_Autor", **kwargs) + + +def _patent_z_autorem(rok=ROK, **kwargs): + rekord = _kompletny_patent() + if rok != rekord.rok: + rekord.rok = rok + rekord.save() + return _powiazanie(rekord, "bpp.Patent_Autor", **kwargs) + + +def _pk(qs): + return set(qs.values_list("pk", flat=True)) + + +# -------------------------------------------------------------------------- +# Zawężenie: okno ewaluacji +# -------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_selektor_bierze_wylacznie_lata_z_okna(): + przed = _artykul_z_autorem(rok=PIERWSZY_ROK - 1) + pierwszy = _artykul_z_autorem(rok=PIERWSZY_ROK) + ostatni = _artykul_z_autorem(rok=OSTATNI_ROK) + po = _artykul_z_autorem(rok=OSTATNI_ROK + 1) + + znalezione = _pk(selektory.powiazania(Osiagniecie.ARTYKUL)) + + assert znalezione == {pierwszy.pk, ostatni.pk}, ( + "Obie granice okna muszą wchodzić do raportu, a lata spoza okna — nie" + ) + assert przed.pk not in znalezione + assert po.pk not in znalezione + + +@pytest.mark.django_db +def test_okno_da_sie_nadpisac_argumentem(): + stary = _artykul_z_autorem(rok=PIERWSZY_ROK - 1) + nowy = _artykul_z_autorem(rok=PIERWSZY_ROK) + + znalezione = _pk( + selektory.powiazania(Osiagniecie.ARTYKUL, okno=(PIERWSZY_ROK - 1, PIERWSZY_ROK)) + ) + + assert znalezione == {stary.pk, nowy.pk} + + +# -------------------------------------------------------------------------- +# Zawężenie: przypięta dyscyplina +# -------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_odpieta_dyscyplina_nie_trafia_do_raportu(): + przypieta = _artykul_z_autorem() + odpieta = _artykul_z_autorem(przypieta=False) + + znalezione = _pk(selektory.powiazania(Osiagniecie.ARTYKUL)) + + assert znalezione == {przypieta.pk} + assert odpieta.pk not in znalezione + + +@pytest.mark.django_db +def test_brak_dyscypliny_trafia_do_raportu_i_jest_zglaszany_jako_brak(): + """Powiązanie przypięte, ale bez dyscypliny, MUSI zostać w raporcie. + + Gdyby selektor odsiewał także ``dyscyplina_naukowa IS NULL``, reguły + ``*_DYSCYPLINA`` nigdy nie mogłyby się uruchomić — a to one są jedynym + sposobem, żeby użytkownik dowiedział się o braku dyscypliny. + """ + bez_dyscypliny = _artykul_z_autorem(z_dyscyplina=False) + + qs = selektory.z_regulami( + selektory.powiazania(Osiagniecie.ARTYKUL), Osiagniecie.ARTYKUL + ) + wiersz = qs.get(pk=bez_dyscypliny.pk) + + assert getattr(wiersz, selektory.pole_reguly("ART_DYSCYPLINA")) is True + + +# -------------------------------------------------------------------------- +# Zawężenie: uczelnia (multi-tenant) +# -------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_powiazanie_autora_z_innej_uczelni_nie_trafia(): + nasza = baker.make("bpp.Uczelnia") + obca = baker.make("bpp.Uczelnia") + + nasze = _artykul_z_autorem(uczelnia=nasza) + obce = _artykul_z_autorem(uczelnia=obca) + + znalezione = _pk(selektory.powiazania(Osiagniecie.ARTYKUL, uczelnia=nasza)) + + assert znalezione == {nasze.pk} + assert obce.pk not in znalezione + + +@pytest.mark.django_db +def test_bez_podanej_uczelni_selektor_nie_zawezia(): + pierwsze = _artykul_z_autorem(uczelnia=baker.make("bpp.Uczelnia")) + drugie = _artykul_z_autorem(uczelnia=baker.make("bpp.Uczelnia")) + + znalezione = _pk(selektory.powiazania(Osiagniecie.ARTYKUL, uczelnia=None)) + + assert znalezione == {pierwsze.pk, drugie.pk} + + +# -------------------------------------------------------------------------- +# Ziarno raportu: (autor, dyscyplina, rekord) +# -------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_dwoch_wspolautorow_w_dwoch_dyscyplinach_daje_dwa_wiersze(): + """Jedna publikacja dwóch współautorów to DWA wpisy w wykazie pracowników. + + Brak (tu: ORCID) musi być przypisany tej osobie, której dotyczy — a nie + „publikacji”. + """ + rekord = _kompletny_artykul() + jednostka = _jednostka() + + z_orcidem = _powiazanie( + rekord, "bpp.Wydawnictwo_Ciagle_Autor", jednostka=jednostka, orcid=True + ) + bez_orcidu = _powiazanie( + rekord, + "bpp.Wydawnictwo_Ciagle_Autor", + jednostka=jednostka, + orcid=False, + kolejnosc=1, + ) + + assert z_orcidem.dyscyplina_naukowa_id != bez_orcidu.dyscyplina_naukowa_id, ( + "Test ma sens tylko przy dwóch RÓŻNYCH dyscyplinach" + ) + + qs = selektory.z_regulami( + selektory.powiazania(Osiagniecie.ARTYKUL), Osiagniecie.ARTYKUL + ) + assert _pk(qs) == {z_orcidem.pk, bez_orcidu.pk} + + pole = selektory.pole_reguly("ART_ORCID") + assert getattr(qs.get(pk=bez_orcidu.pk), pole) is True + assert getattr(qs.get(pk=z_orcidem.pk), pole) is False + + +# -------------------------------------------------------------------------- +# Klasyfikacja: monografia vs rozdział vs „nierozpoznany typ” +# -------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_monografia_i_rozdzial_rozroznia_charakter_slotow(): + monografia = _zwarte_z_autorem(CHARAKTER_SLOTY_KSIAZKA) + rozdzial = _zwarte_z_autorem(CHARAKTER_SLOTY_ROZDZIAL) + + assert _pk(selektory.powiazania(Osiagniecie.MONOGRAFIA)) == {monografia.pk} + assert _pk(selektory.powiazania(Osiagniecie.ROZDZIAL)) == {rozdzial.pk} + + +@pytest.mark.django_db +def test_rekord_bez_charakteru_slotow_nie_znika_tylko_trafia_do_nierozpoznanych(): + """Fixture instalacyjny zostawia ``charakter_sloty`` puste. + + Taki rekord nie daje się zaklasyfikować ani jako monografia, ani jako + rozdział — ale nie wolno mu zniknąć po cichu, bo użytkownik uznałby, że + raport go sprawdził. + """ + nierozpoznany = _zwarte_z_autorem(None) + monografia = _zwarte_z_autorem(CHARAKTER_SLOTY_KSIAZKA) + + assert nierozpoznany.pk not in _pk(selektory.powiazania(Osiagniecie.MONOGRAFIA)) + assert nierozpoznany.pk not in _pk(selektory.powiazania(Osiagniecie.ROZDZIAL)) + + nierozpoznane = _pk(selektory.powiazania_nierozpoznane()) + assert nierozpoznane == {nierozpoznany.pk} + assert monografia.pk not in nierozpoznane + + +@pytest.mark.django_db +def test_nierozpoznane_respektuja_okno_i_uczelnie(): + nasza = baker.make("bpp.Uczelnia") + obca = baker.make("bpp.Uczelnia") + + nasz = _zwarte_z_autorem(None, uczelnia=nasza) + _zwarte_z_autorem(None, uczelnia=obca) + _zwarte_z_autorem(None, uczelnia=nasza, rok=PIERWSZY_ROK - 1) + + assert _pk(selektory.powiazania_nierozpoznane(uczelnia=nasza)) == {nasz.pk} + + +@pytest.mark.django_db +def test_patenty_i_artykuly_nie_podlegaja_klasyfikacji_po_charakterze(): + """Patent nie ma pola ``charakter_formalny``; artykuł zawsze jest pkt 4.""" + patent = _patent_z_autorem() + artykul = _artykul_z_autorem() + + assert _pk(selektory.powiazania(Osiagniecie.PATENT)) == {patent.pk} + assert _pk(selektory.powiazania(Osiagniecie.ARTYKUL)) == {artykul.pk} + + +# -------------------------------------------------------------------------- +# Anotacja regułami +# -------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_anotacja_doklada_pole_logiczne_dla_kazdej_reguly(): + _artykul_z_autorem() + + wiersz = selektory.z_regulami( + selektory.powiazania(Osiagniecie.ARTYKUL), Osiagniecie.ARTYKUL + ).get() + + for regula in reguly_dla(Osiagniecie.ARTYKUL): + pole = selektory.pole_reguly(regula.kod) + assert hasattr(wiersz, pole), f"Brak anotacji {pole}" + assert isinstance(getattr(wiersz, pole), bool) + + +@pytest.mark.django_db +@pytest.mark.parametrize("osiagniecie", list(Osiagniecie), ids=lambda o: o.value) +def test_anotacja_dziala_dla_kazdego_typu_i_nie_zwielokrotnia_wierszy(osiagniecie): + """Anotacja nie może rozmnożyć ziarna przez JOIN-y warunków reguł. + + Wszystkie ścieżki w regułach idą przez relacje „do jednego” (FK w przód), + więc jedno powiązanie musi dać dokładnie jeden wiersz — także wtedy, gdy + dołożymy kilkanaście warunków naraz. + """ + budowniczy = { + Osiagniecie.ARTYKUL: lambda: _artykul_z_autorem(), + Osiagniecie.MONOGRAFIA: lambda: _zwarte_z_autorem(CHARAKTER_SLOTY_KSIAZKA), + Osiagniecie.ROZDZIAL: lambda: _zwarte_z_autorem(CHARAKTER_SLOTY_ROZDZIAL), + Osiagniecie.PATENT: lambda: _patent_z_autorem(), + }[osiagniecie] + powiazanie = budowniczy() + + qs = selektory.z_regulami(selektory.powiazania(osiagniecie), osiagniecie) + + assert list(qs.values_list("pk", flat=True)) == [powiazanie.pk] + assert selektory.naruszone_reguly(qs.get(), osiagniecie) == [] + + +@pytest.mark.django_db +def test_rekord_kompletny_nie_ma_zadnego_braku(): + _artykul_z_autorem() + + wiersz = selektory.z_regulami( + selektory.powiazania(Osiagniecie.ARTYKUL), Osiagniecie.ARTYKUL + ).get() + + assert selektory.naruszone_reguly(wiersz, Osiagniecie.ARTYKUL) == [] + assert getattr(wiersz, selektory.POLE_BRAKI_WYMAGANE) == 0 + assert getattr(wiersz, selektory.POLE_BRAKI_WARUNKOWE) == 0 + + +@pytest.mark.django_db +def test_liczniki_zliczaja_braki_osobno_dla_kazdej_wagi(): + """Jeden brak wymagany (DOI) i jeden warunkowy (ORCID).""" + powiazanie = _artykul_z_autorem(orcid=False) + rekord = powiazanie.rekord + rekord.doi = None + rekord.www = "" + rekord.public_www = "" + rekord.save() + + wiersz = selektory.z_regulami( + selektory.powiazania(Osiagniecie.ARTYKUL), Osiagniecie.ARTYKUL + ).get() + + assert getattr(wiersz, selektory.pole_reguly("ART_DOI")) is True + assert getattr(wiersz, selektory.pole_reguly("ART_ORCID")) is True + assert getattr(wiersz, selektory.POLE_BRAKI_WYMAGANE) == 1 + assert getattr(wiersz, selektory.POLE_BRAKI_WARUNKOWE) == 1 + + naruszone = selektory.naruszone_reguly(wiersz, Osiagniecie.ARTYKUL) + assert {regula.kod for regula in naruszone} == {"ART_DOI", "ART_ORCID"} + + +@pytest.mark.django_db +def test_liczniki_zgadzaja_sie_z_lista_naruszonych_regul(): + _artykul_z_autorem(orcid=False, z_dyscyplina=False) + + wiersz = selektory.z_regulami( + selektory.powiazania(Osiagniecie.ARTYKUL), Osiagniecie.ARTYKUL + ).get() + naruszone = selektory.naruszone_reguly(wiersz, Osiagniecie.ARTYKUL) + + wymagane = [r for r in naruszone if r.waga == Waga.WYMAGANE] + warunkowe = [r for r in naruszone if r.waga == Waga.WARUNKOWE] + + assert getattr(wiersz, selektory.POLE_BRAKI_WYMAGANE) == len(wymagane) + assert getattr(wiersz, selektory.POLE_BRAKI_WARUNKOWE) == len(warunkowe) + assert wymagane and warunkowe, "Test ma sens tylko przy brakach obu wag" + + +@pytest.mark.django_db +def test_naruszone_reguly_zwraca_obiekty_regul_z_opisem_i_paragrafem(): + powiazanie = _artykul_z_autorem() + powiazanie.upowaznienie_pbn = False + powiazanie.save() + + wiersz = selektory.z_regulami( + selektory.powiazania(Osiagniecie.ARTYKUL), Osiagniecie.ARTYKUL + ).get() + (regula,) = selektory.naruszone_reguly(wiersz, Osiagniecie.ARTYKUL) + + assert regula.kod == "ART_UPOWAZNIENIE" + assert regula.paragraf == "§ 2 ust. 10 pkt 4 lit. d" + assert regula.opis + + +@pytest.mark.django_db +def test_naruszone_reguly_wymagaja_zanotowanego_wiersza(): + _artykul_z_autorem() + wiersz = selektory.powiazania(Osiagniecie.ARTYKUL).get() + + with pytest.raises(ValueError): + selektory.naruszone_reguly(wiersz, Osiagniecie.ARTYKUL) + + +@pytest.mark.django_db +def test_anotacja_rozdzialu_uzywa_wylacznie_regul_rozdzialu(): + _zwarte_z_autorem(CHARAKTER_SLOTY_ROZDZIAL) + + wiersz = selektory.z_regulami( + selektory.powiazania(Osiagniecie.ROZDZIAL), Osiagniecie.ROZDZIAL + ).get() + + assert hasattr(wiersz, selektory.pole_reguly("ROZ_NADRZEDNE")) + assert not hasattr(wiersz, selektory.pole_reguly("MON_ISBN")) + + +def test_pole_reguly_wyprowadza_nazwe_z_kodu(): + assert selektory.pole_reguly("ART_DOI") == "brak_ART_DOI" + + +# -------------------------------------------------------------------------- +# Warunkowość Open Access +# -------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_praca_bez_trybu_open_access_nie_generuje_brakow_oa(): + _artykul_z_autorem() + + wiersz = selektory.z_regulami( + selektory.powiazania(Osiagniecie.ARTYKUL), Osiagniecie.ARTYKUL + ).get() + + for kod in ( + "ART_OA_WERSJA", + "ART_OA_LICENCJA", + "ART_OA_DATA", + "ART_OA_CZAS", + "ART_OA_MIESIACE", + ): + assert getattr(wiersz, selektory.pole_reguly(kod)) is False, ( + f"Reguła {kod} zadziałała mimo braku oznaczenia Open Access" + ) + + +@pytest.mark.django_db +def test_udostepnienie_inne_niz_po_opublikowaniu_nie_wymaga_liczby_miesiecy(): + from bpp.models import Czas_Udostepnienia_OpenAccess + + czas, _ = Czas_Udostepnienia_OpenAccess.objects.get_or_create( + skrot="BEFORE_PUBLICATION", + defaults={"nazwa": "przed opublikowaniem"}, + ) + assert czas.skrot != OA_CZAS_PO_OPUBLIKOWANIU + + powiazanie = _artykul_z_autorem() + rekord = powiazanie.rekord + rekord.openaccess_tryb_dostepu = baker.make( + "bpp.Tryb_OpenAccess_Wydawnictwo_Ciagle" + ) + rekord.openaccess_wersja_tekstu = baker.make("bpp.Wersja_Tekstu_OpenAccess") + rekord.openaccess_licencja = baker.make("bpp.Licencja_OpenAccess") + rekord.openaccess_data_opublikowania = datetime.date(ROK, 3, 1) + rekord.openaccess_czas_publikacji = czas + rekord.openaccess_ilosc_miesiecy = None + rekord.save() + + wiersz = selektory.z_regulami( + selektory.powiazania(Osiagniecie.ARTYKUL), Osiagniecie.ARTYKUL + ).get() + + assert getattr(wiersz, selektory.pole_reguly("ART_OA_MIESIACE")) is False + assert selektory.naruszone_reguly(wiersz, Osiagniecie.ARTYKUL) == [] From 71539ec4c1aec92553eef23b79aeab36f825f722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 25 Jul 2026 02:55:36 +0200 Subject: [PATCH 4/6] feat(kompletnosc_polon): widoki raportu, szablony, routing i wpis w menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widok zbiorczy listuje autorów posortowanych malejąco po brakach wymaganych; widok szczegółów wypisuje przy każdym rekordzie naruszone wymogi z podstawą prawną i linkiem do formularza edycji w adminie. Dostęp tylko dla redaktorów (ma_pelne_uprawnienia_ewaluacji) — to narzędzie robocze, nie widok dla autora. Dane zawężone do uczelni oglądającego. Rekordy z nieustawionym charakter_sloty mają własną sekcję w obu widokach. Na liście jest ona zagregowana per autor, bo autor mający wyłącznie takie rekordy nie pojawia się w tabeli głównej i inaczej byłby nieosiągalny. Kontekst widoku celowo nie wystawia klucza "uczelnia" — nadpisywał wartość z context processora, której base.html używa do stopki, i przy uczelnia=None wywracał szablon bazowy. Złapane testem. 249 testów (228 backendu + 21 widoków). Refs FD#437 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01McWgJYK4c4GSMdsm6P4oXc --- .../fd437-kompletnosc-polon.feature.rst | 6 - src/bpp/newsfragments/fd437.feature.md | 9 + src/django_bpp/templates/top_bar.html | 1 + src/django_bpp/urls.py | 6 + .../templates/kompletnosc_polon/lista.html | 195 +++++++++ .../kompletnosc_polon/szczegoly.html | 160 +++++++ src/kompletnosc_polon/tests/test_views.py | 392 ++++++++++++++++++ src/kompletnosc_polon/urls.py | 14 + src/kompletnosc_polon/views/__init__.py | 41 ++ src/kompletnosc_polon/views/lista.py | 155 +++++++ src/kompletnosc_polon/views/mixins.py | 73 ++++ src/kompletnosc_polon/views/szczegoly.py | 111 +++++ 12 files changed, 1157 insertions(+), 6 deletions(-) delete mode 100644 src/bpp/newsfragments/fd437-kompletnosc-polon.feature.rst create mode 100644 src/bpp/newsfragments/fd437.feature.md create mode 100644 src/kompletnosc_polon/templates/kompletnosc_polon/lista.html create mode 100644 src/kompletnosc_polon/templates/kompletnosc_polon/szczegoly.html create mode 100644 src/kompletnosc_polon/tests/test_views.py create mode 100644 src/kompletnosc_polon/urls.py create mode 100644 src/kompletnosc_polon/views/__init__.py create mode 100644 src/kompletnosc_polon/views/lista.py create mode 100644 src/kompletnosc_polon/views/mixins.py create mode 100644 src/kompletnosc_polon/views/szczegoly.py diff --git a/src/bpp/newsfragments/fd437-kompletnosc-polon.feature.rst b/src/bpp/newsfragments/fd437-kompletnosc-polon.feature.rst deleted file mode 100644 index 3aef8300f..000000000 --- a/src/bpp/newsfragments/fd437-kompletnosc-polon.feature.rst +++ /dev/null @@ -1,6 +0,0 @@ -Nowy raport „kompletność danych POL-on" w menu ewaluacji pokazuje, przy którym -pracowniku i w którym rekordzie brakuje danych wymaganych rozporządzeniem -Ministra Nauki i Szkolnictwa Wyższego z 16 czerwca 2026 r. (Dz. U. 2026 poz. 811). -Każdy brak opatrzony jest podstawą prawną i odnośnikiem prosto do formularza -edycji. Raport rozróżnia dane wymagane bezwarunkowo od tych, których -rozporządzenie żąda tylko „jeżeli posiada" — te drugie liczone są osobno. diff --git a/src/bpp/newsfragments/fd437.feature.md b/src/bpp/newsfragments/fd437.feature.md new file mode 100644 index 000000000..ce44a1ac6 --- /dev/null +++ b/src/bpp/newsfragments/fd437.feature.md @@ -0,0 +1,9 @@ +Nowy raport „kompletność danych POL-on" (menu *ewaluacja*, dla redaktorów +danych): wskazuje, przy którym pracowniku i w którym rekordzie brakuje danych +wymaganych przez § 2 ust. 10 rozporządzenia MNiSW z 16 czerwca 2026 r. +(Dz. U. 2026 poz. 811). Zestawienie zbiorcze pokazuje autorów posortowanych +malejąco po brakach wymaganych, a widok szczegółów wypisuje przy każdym +rekordzie naruszone wymogi wraz z podstawą prawną i linkiem prosto do +formularza edycji. Raport odróżnia dane wymagane bezwarunkowo od tych, których +rozporządzenie żąda tylko „jeżeli posiada" — te drugie liczone są osobno i nie +zawyżają pilności. diff --git a/src/django_bpp/templates/top_bar.html b/src/django_bpp/templates/top_bar.html index 0d625301f..eb3b7e349 100644 --- a/src/django_bpp/templates/top_bar.html +++ b/src/django_bpp/templates/top_bar.html @@ -103,6 +103,7 @@