From ed0a782409930ecdd9ee622bdac294d2a1d4dee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 09:53:16 +0200 Subject: [PATCH 01/11] docs(eurocris): spec encji Projekt i Finansowanie (#701, #702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projekt techniczny rozbudowy modelu danych o encje CERIF Project i Funding: nowy model Projekt (Grant zostaje etykietą numeru grantu), Projekt_Autor z rolą, Instytucja_Finansujaca jako Funder eksportowany w secie OrgUnits, Finansowanie 1:N z typem ze słownika OpenAIRE. Mapowania skonfrontowane z XSD profilu leżącym w repozytorium (src/cerif_export/tests/xsd/) — w szczególności struktura Project/Funded (By = referencja do OrgUnit findera, As = osadzone ), dedykowane elementy RORID/FundRefID/GrantDOI oraz wymóg atrybutu scheme dla klasyfikacji Status i Subject. Co-Authored-By: Claude Opus 5 (1M context) --- ...05-eurocris-projekt-finansowanie-design.md | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-eurocris-projekt-finansowanie-design.md diff --git a/docs/superpowers/specs/2026-08-05-eurocris-projekt-finansowanie-design.md b/docs/superpowers/specs/2026-08-05-eurocris-projekt-finansowanie-design.md new file mode 100644 index 000000000..6d9900536 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-eurocris-projekt-finansowanie-design.md @@ -0,0 +1,384 @@ +# euroCRIS: encje Projekt i Finansowanie — projekt techniczny + +Data: 2026-08-05 +Issues: [#701](https://github.com/iplweb/bpp/issues/701) (Project), +[#702](https://github.com/iplweb/bpp/issues/702) (Funding), +parasol [#709](https://github.com/iplweb/bpp/issues/709) +Kontekst merytoryczny: `docs/deweloper/eurocris-co-jeszcze.md`, sekcje A1 i A2 + +## Problem + +Eksport CERIF-XML (`/cerif-oai/`, app `src/cerif_export/`) przechodzi +`openaire-cris-validator` 2.1.1 w całości, ale sety `openaire_cris_projects` +i `openaire_cris_funding` są **puste** — obsługuje je `ProviderPusty`. +Profil OpenAIRE wymaga istnienia setów, nie ich zapełnienia, więc formalna +zgodność jest osiągnięta, a merytorycznie brakuje najważniejszego, co CRIS +ma do pokazania: łańcucha **publikacja ↔ projekt ↔ finansowanie**. + +Przyczyna leży w modelu danych, nie w eksporcie. `bpp.models.grant.Grant` +ma cztery pola (`nazwa_projektu`, `zrodlo_finansowania` jako wolny tekst, +`numer_projektu`, `rok`) — to etykieta numeru grantu przypinana do +publikacji, nie encja projektu. Encja finansującego nie istnieje w ogóle. + +## Zakres + +**W zakresie:** modele danych w `bpp`, admin (wprowadzanie danych przez +redakcję), migracje ze seedem grantodawców, providery i serializery CERIF +dla setów Projects i Funding, rozszerzenie setu OrgUnits o instytucje +finansujące. + +**Poza zakresem** (świadomie, do ewentualnej osobnej iteracji): + +- widok publiczny projektów na froncie BPP (lista, podstrona projektu, + projekty na stronie autora/jednostki), +- endpointy `/api/v1/` dla projektów i finansowania, +- instytucje zewnętrzne jako partnerzy/wykonawcy projektu (BPP nie ma dziś + encji instytucji zewnętrznej; `Consortium/Coordinator` obsługujemy + jednostką własną), +- encje `Product` (#703) i `Equipment` (#704) — inne sety, inne issues. + +## Zależność od gałęzi + +Warstwa eksportu siada na `feat/cerif-mapowania` (PR #700), która wnosi +`cerif_export`, walidację ROR w `bpp/util/ror.py` oraz przełącznik +`Uczelnia.eksport_cerif_osoby`. Gałąź robocza: `feat/eurocris-projekty`, +odbita od `feat/cerif-mapowania`. + +## Źródło prawdy o formacie + +**XSD profilu leży w repozytorium**: +`src/cerif_export/tests/xsd/openaire-cerif-profile.xsd` wraz z +`includes/` i `vocabularies/`. Wszystkie mapowania w tym dokumencie są +z nim skonfrontowane — przy wątpliwościach czytamy XSD, nie dokumentację +OpenAIRE z pamięci. + +## Decyzje projektowe + +| Decyzja | Wybór | Uzasadnienie | +|---|---|---| +| Los modelu `Grant` | Nowy model `Projekt`; `Grant` zostaje etykietą numeru grantu z FK do projektu | Nie rusza istniejących danych ani inline'ów w adminach publikacji | +| Zasięg UI | Admin + eksport CERIF | Minimalny zakres domykający #701/#702 | +| Słownik grantodawców | Nowy model + seed polskich instytucji z prawdziwymi identyfikatorami | Bez FundRef ID `Funding` jest w OpenAIRE prawie bezużyteczny | +| Osoby w projekcie | Jedna relacja `Projekt_Autor` z rolą | CERIF wyraża PI jako rolę w `Team/PrincipalInvestigator` vs `Team/Member` | +| Finansowanie | Osobny model 1:N od projektu | Obsługuje projekty współfinansowane; mapuje się 1:1 na `` | +| Kwoty | Zapisywane zawsze, eksportowane warunkowo | Przełącznik `Uczelnia.eksport_cerif_kwoty` (domyślnie wyłączony), wzorem `eksport_cerif_osoby` | +| Instytucje w projekcie | Tylko jednostka wiodąca | YAGNI: partnerzy zewnętrzni wymagaliby nowej encji | +| Tenant projektu | Przez **wymagane** `Projekt.jednostka` | Scoping idzie `jednostka__uczelnia`, wzorem `providers/publikacje.py`; nullable pole po cichu wypychałoby projekty z eksportu | +| Eksport funderów | Tylko ci realnie finansujący projekty uczelni | Seed nie ma wypychać instytucji niezwiązanych z uczelnią | + +## Ścieżka danych publikacja → projekt + +``` +Wydawnictwo_* / Patent → Grant_Rekordu (GenericFK) → Grant → Projekt → Finansowanie → Instytucja_Finansujaca +``` + +`Grant_Rekordu` **nie wymaga zmian** — po dodaniu `Grant.projekt` staje się +wymaganą przez CERIF relacją publikacja↔projekt. Publikacje, których grant +nie ma przypisanego projektu, nie wnoszą powiązania i nie łamią eksportu. + +## Modele danych (`src/bpp/models/projekt.py`) + +Nowy moduł musi zostać dopisany do `src/bpp/models/__init__.py` +(konwencja `from .projekt import * # noqa`). + +Wszystkie nowe modele eksportowane przez OAI-PMH dziedziczą po +`ModelZAdnotacjami` (`src/bpp/models/abstract/metadata.py:11`) — daje +`ostatnio_zmieniony` (auto_now, null=True, db_index) oraz `adnotacje`. +Na `ostatnio_zmieniony` `cerif_export/providers/base.py` buduje keysetowy +kursor przyrostowego harvestu (`z_datestampem()`); model bez tego pola nie +da się harvestować przyrostowo. + +### `Projekt` + +Dziedziczy `ModelZAdnotacjami` oraz `ModelZeSlowamiKluczowymi` +(`src/bpp/models/abstract/keywords.py`; taggit z domyślnym generic-through, +współdzielony już przez `Patent` i oba `Wydawnictwo_*` — nowy model nie +wymaga własnego through-modelu). + +| Pole | Typ | Element CERIF | +|---|---|---| +| `tytul` | `TextField` | `Title` (pl) | +| `tytul_en` | `TextField(blank=True, default="")` | `Title` (en) | +| `akronim` | `CharField(max_length=50, blank=True, default="")` | `Acronym` | +| `data_rozpoczecia` | `DateField(null=True, blank=True)` | `StartDate` | +| `data_zakonczenia` | `DateField(null=True, blank=True)` | `EndDate` | +| `status` | `CharField` z `choices` | `Status` (z własnym `scheme`) | +| `abstrakt` | `TextField(blank=True, default="")` | `Abstract` (pl) | +| `abstrakt_en` | `TextField(blank=True, default="")` | `Abstract` (en) | +| `slowa_kluczowe`, `slowa_kluczowe_eng` | z miksina | `Keyword` | +| `dyscypliny` | `M2M(Dyscyplina_Naukowa, blank=True)` | `Subject` (z własnym `scheme`) | +| `jednostka` | `FK(Jednostka, PROTECT)` — **wymagane** | `Consortium/Coordinator` | +| `strona_www` | `URLField(blank=True, default="")` | — (brak elementu w profilu) | + +Wartości `status`: planowany / w trakcie / zakończony / przerwany. + +`jednostka` jest wymagana, bo jest jedynym nośnikiem przynależności +projektu do uczelni (tenanta). Bez niej provider nie ma po czym filtrować. + +`strona_www` zostaje jako pole wewnętrzne — sekwencja `Project` w XSD +(linie 238–450) nie zawiera ani `URI`, ani `ElectronicAddress`. + +### `Projekt_Autor` + +| Pole | Typ | +|---|---| +| `projekt` | `FK(Projekt, CASCADE)` | +| `autor` | `FK(Autor, PROTECT)` | +| `rola` | `CharField` z `choices`: kierownik / wykonawca / współwykonawca | +| `od`, `do` | `DateField(null=True, blank=True)` | + +`unique_together = [("projekt", "autor", "rola")]`. + +Dodatkowo warunkowy `UniqueConstraint(fields=["projekt"], +condition=Q(rola="kierownik"), name="...")` — wymusza jednego kierownika +na poziomie bazy. Sam `clean()` tego nie złapie, bo przy dwóch nowych +wierszach w inline widzi tylko stan z bazy; walidacja formsetu w adminie +jest dodatkiem dla czytelnego komunikatu, nie zabezpieczeniem. + +Mapowanie: rola `kierownik` → `Team/PrincipalInvestigator`, pozostałe → +`Team/Member`. + +### `Instytucja_Finansujaca` + +Dziedziczy `ModelZAdnotacjami` — jest eksportowana jako OrgUnit, więc +potrzebuje datestampu. + +| Pole | Typ | Uwagi | +|---|---|---| +| `nazwa` | `TextField` | `Name` | +| `nazwa_en` | `TextField(blank=True, default="")` | `Name` (en) | +| `akronim` | `CharField(max_length=50, blank=True, default="")` | `Acronym` | +| `kraj` | `CharField(max_length=2, default="PL")` | pole wewnętrzne — profil nie ma `Country` w OrgUnit | +| `ror_id` | `CharField(blank=True, default="")` | → `RORID` | +| `fundref_id` | `CharField(blank=True, default="")` | → `FundRefID` | +| `strona_www` | `URLField(blank=True, default="")` | → `ElectronicAddress` | + +`RORID` i `FundRefID` to **dedykowane elementy** w +`includes/orgunit-identifiers.xsd`, nie generyczne `Identifier`. +Walidacja `ror_id` — walidatorem z `bpp/util/ror.py`. + +### `Finansowanie` + +Dziedziczy `ModelZAdnotacjami`. + +| Pole | Typ | Element CERIF | +|---|---|---| +| `projekt` | `FK(Projekt, CASCADE)` | patrz niżej | +| `typ` | `CharField` z `choices`, **wymagany** | `Funding/Type` | +| `instytucja` | `FK(Instytucja_Finansujaca, PROTECT)` | `Funding/Funder` → OrgUnit | +| `nazwa_programu` | `CharField(blank=True, default="")` | `Funding/Name` — np. „OPUS 24" | +| `numer_umowy` | `CharField(blank=True, default="")` | `Funding/Identifier` | +| `kwota` | `DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)` | `Funding/Amount` | +| `waluta` | `CharField(max_length=3, default="PLN")` | atrybut `currency` | +| `grant_doi` | `CharField(blank=True, default="")` | `GrantDOI` (dedykowany element) | + +Wartości `typ` — słownik jest **lokalnie w repo**: +`src/cerif_export/tests/xsd/vocabularies/openaire_funding_types.xsd`, +osiem wartości: FundingProgramme, Call, Tender, Gift, InternalFunding, +Contract, Award, Grant. + +### Zmiany w istniejących modelach + +- `Grant.projekt = FK(Projekt, SET_NULL, null=True, blank=True)` — + `SET_NULL`, bo skasowanie projektu nie może kasować historycznych numerów + grantów przypiętych do publikacji. +- `Uczelnia.eksport_cerif_kwoty = BooleanField(default=False)`. + +### Walidacja (`clean()` na modelu) + +Na modelu, bo dane mogą wejść także importem lub skryptem: + +- `data_zakonczenia >= data_rozpoczecia`, +- `kwota` wypełniona ⇒ `waluta` wymagana. + +Jeden kierownik na projekt — przez `UniqueConstraint` (patrz wyżej). + +## Uprawnienia + +Mechanizm jest ustalony: `src/bpp/system.py`, dict pod kluczem +`GR_WPROWADZANIE_DANYCH` (linie 159 i 219), wywoływany przez +`odtworz_grupy()` podpięte pod `post_migrate` w `src/bpp/apps.py:17` +(plus komenda `odtworz_grupy`). `Grant` i `Grant_Rekordu` już tam są — +dopisać `Projekt`, `Projekt_Autor`, `Instytucja_Finansujaca`, +`Finansowanie`. + +## Admin (`src/bpp/admin/projekt.py`) + +Wpięcie przez jawny import w `src/bpp/admin/__init__.py` +(`from .projekt import ProjektAdmin # noqa`). + +- **`ProjektAdmin`** — `list_display`: tytuł, akronim, status, daty, + jednostka, liczba finansowań. `list_filter`: status, jednostka, + dyscypliny. `search_fields`: tytuł, akronim, abstrakt. + `autocomplete_fields = ["jednostka"]`. Inline'y: + - `Projekt_AutorInline` (`TabularInline`, `autocomplete_fields=["autor"]`, + walidacja formsetu na jednego kierownika), + - `FinansowanieInline` (`TabularInline`, `autocomplete_fields=["instytucja"]`). +- **`Instytucja_FinansujacaAdmin`** — słownik. `search_fields` jest + **wymagane**, inaczej autocomplete w `FinansowanieInline` nie zadziała. + Walidacja `ror_id` walidatorem z `bpp/util/ror.py`; walidacja formatu + `fundref_id`. +- **`GrantAdmin`** — dołożyć `projekt` do `list_display`, + `autocomplete_fields` oraz filtr `projekt__isnull` („granty bez projektu" + to robocza lista redakcji przy uzupełnianiu danych historycznych). +- **`UczelniaAdmin`** — `eksport_cerif_kwoty` do fieldsetu, w którym siedzi + `eksport_cerif_osoby` (`src/bpp/admin/uczelnia.py:174`). + +## Warstwa eksportu (`src/cerif_export/`) + +Architektura bez zmian: **provider** (dostęp do danych, nie wie nic o XML) +→ **serializer w `cerif/`** → **`slowniki/`** (mapowania na słowniki +kontrolowane). + +### Nowe pliki + +- `slowniki/typy_finansowania.py` — `Finansowanie.typ` → URI ze słownika + OpenAIRE Funding Types (plik XSD w repo, patrz wyżej). Wzorzec: + `slowniki/coar.py`. +- `cerif/project.py` — builder `` w kolejności z XSD: `Type`, + `Acronym`, `Title`, `Identifier`, `StartDate`, `EndDate`, `Consortium` + (→ `Coordinator` = OrgUnit jednostki), `Team` (→ `PrincipalInvestigator` + / `Member`), `Funded` (→ `By` = OrgUnit findera, `As` = osadzone + ``), `Subject`, `Keyword`, `Abstract`, `Status`. +- `cerif/funding.py` — builder ``: identyfikator, `Type` + (obowiązkowy), `Name`/`Acronym`, `Amount` + `currency` (tylko gdy + `Uczelnia.eksport_cerif_kwoty`), `GrantDOI`, `Funder` → OrgUnit. + Builder musi być **wywoływalny w dwóch kontekstach**: jako samodzielny + rekord setu `openaire_cris_funding` i jako element osadzony w + `Project/Funded/As`. +- `providers/projekty.py`, `providers/finansowanie.py` — realne providery + (`ProviderEncji`) zamiast `ProviderPusty`. + +### Struktura `Funded` — źródło pomyłek + +XSD (linie 348–372) definiuje `Project/Funded` jako parę: + +- `Funded/By` → `cfLinkWithDisplayNameToPersonOrOrgUnit__Type`, czyli + **referencja do OrgUnit-a findera** (rola Funder), +- `Funded/As` → **osadzony pełny element ``** (``), nie referencja. + +Po stronie samej encji `Funding` finder wchodzi elementem **`Funder`** +(linia 482), nie `FundedBy`. + +### Klasyfikacje z własnym schematem + +`Status` i `Subject` mają typ `cfGenericURIClassification__Type` +(`cerif-commons.xsd:270`) — wymagają atrybutu `scheme` (anyURI) i wartości +będącej URI. Profil nie dostarcza słownika ani dla statusu projektu, ani +dla polskich dyscyplin naukowych, więc definiujemy **własne schematy**, +jako stałe w `const.py`: + +- scheme statusu projektu + URI per wartość, +- scheme dyscyplin (klasyfikacja MNiSW) + URI budowane z kodu dyscypliny. + +Agregator nieznanego schematu nie zinterpretuje, ale dane są jawnie +oznaczone swoim pochodzeniem — to lepsze niż ich pominięcie i lepsze niż +podszywanie się pod cudzy słownik. + +### Zmiany w istniejących plikach + +- `identyfikatory._REJESTR` — trzy wpisy z jawnymi slugami: `bpp.Projekt` + → `pj`, `bpp.Finansowanie` → `fn`, `bpp.Instytucja_Finansujaca` → `if` + (typ **OrgUnit**). Slugi wolne i pasują do wzorca `[a-z]{2}`. +- `const.py` — **nie ma** stałych `TYP_PROJECT`/`TYP_FUNDING`, trzeba + dodać; plus stałe schematów klasyfikacji. +- `providers/jednostki.py` — `modele = [Jednostka, Uczelnia, + Instytucja_Finansujaca]` plus gałąź w `queryset()`. Paginacja w + `strona()` obsługuje N modeli bez zmian. Queryset funderów zawężony do + tych, do których prowadzi `Finansowanie` projektu należącego do tej + uczelni (przez `projekt__jednostka__uczelnia`). +- `cerif/orgunit.py` — gałąź serializująca findera: `Name`, `Acronym`, + `RORID`, `FundRefID`, `ElectronicAddress`. +- `providers/puste.py` — zostają tylko `ProviderProduktow` + i `ProviderAparatury`; docstring modułu wymaga poprawienia (dziś twierdzi, + że BPP „nie prowadzi ewidencji projektów ani finansowania"). + +### Integralność referencyjna a przełącznik osób + +`Uczelnia.eksport_cerif_osoby` (default `True`) działa przez +`widoczni_autorzy()` → przy wyłączeniu zwraca `Autor.objects.none()`, co +zeruje set osób oraz `@id`/ORCID/afiliacje osadzone w publikacjach. Jeśli +`` bezwarunkowo wyemituje `Team`, przy wyłączonym przełączniku +powstaną referencje do identyfikatorów nieobecnych w harveście. +**`Team` podlega temu samemu przełącznikowi**: przy +`eksport_cerif_osoby=False` projekt wychodzi bez `PrincipalInvestigator` +i `Member`. + +Analogicznie finder referowany z `Funding/Funder` i `Funded/By` **musi** +być w secie `openaire_cris_orgunits` — stąd rozszerzenie +`ProviderJednostek`, a nie osobny set. + +`Funded` ma `minOccurs="0"` — projekt bez finansowania jest wobec XSD +poprawny i **nie** musi być odsiewany przez provider. + +## Migracje + +1. **Schema:** `Projekt`, `Projekt_Autor`, `Instytucja_Finansujaca`, + `Finansowanie`, `Grant.projekt`, `Uczelnia.eksport_cerif_kwoty`, + `UniqueConstraint` na kierownika. +2. **Dane:** seed instytucji finansujących — idempotentny `get_or_create` + po `fundref_id`, wzorem migracji `0483`. Zakres: NCN, NCBR, MNiSW/MEiN, + FNP, NAWA, ABM, Komisja Europejska. **Identyfikatory FundRef i ROR + weryfikowane w rejestrach Crossref i ROR przy pisaniu migracji** — nie + zgadywane z pamięci. Instytucja bez potwierdzonego identyfikatora + wchodzi bez niego, nie ze zmyślonym. +3. **Baseline:** `make baseline-update` **raz, przy scalaniu**, nie + w trakcie prac w gałęzi. + +Istniejących migracji nie wolno modyfikować. + +## Testy + +Konwencja pytest: funkcje bez klas, `@pytest.mark.django_db`, +`model_bakery.baker.make`. + +**Model:** +- `clean()` odrzuca `data_zakonczenia < data_rozpoczecia`, +- `clean()` odrzuca kwotę bez waluty, +- `UniqueConstraint` odrzuca drugiego kierownika, +- `Grant.projekt` na `SET_NULL`: skasowanie projektu zostawia grant. + +**Provider:** +- scoping do tenanta przez `jednostka__uczelnia` (projekty innej uczelni + nie wychodzą), +- finder bez powiązanego `Finansowanie` **nie** trafia do setu OrgUnits; + finder z powiązaniem — trafia, +- przyrostowy harvest: zmiana projektu podnosi `ostatnio_zmieniony` + i rekord wraca w kolejnym `from`. + +**Serializer:** +- `Funding/Type` obecny zawsze, +- `Amount` obecny wtedy i tylko wtedy, gdy `eksport_cerif_kwoty=True`, +- `Funded/As` zawiera **osadzone** ``, a `Funded/By` referencję do + OrgUnit-a, +- projekt bez finansowania serializuje się poprawnie (brak `Funded`), +- walidacja wyniku wobec XSD z `tests/xsd/` — wzorem istniejących testów + eksportu. + +**Integralność referencyjna** (najważniejszy test tej zmiany): dla +wyeksportowanego projektu każdy referowany identyfikator OrgUnit / Person / +Funding istnieje w swoim secie — sprawdzane w obu stanach +`eksport_cerif_osoby`. + +**Krok akceptacyjny poza CI:** przebieg `openaire-cris-validator` 2.1.1 na +żywym endpoincie z dumpem produkcyjnym, jak przy #699. + +## Podział prac + +Etap 1 (sekwencyjny, fundament): modele + migracja schema + wpięcie do +`models/__init__.py` i `system.py`. + +Etap 2 (równolegle, po etapie 1): +- admin + walidacja formsetów, +- migracja danych ze seedem grantodawców (z weryfikacją identyfikatorów), +- eksport: `const.py`, `identyfikatory.py`, słownik typów finansowania, + buildery `cerif/project.py` i `cerif/funding.py`, providery, + rozszerzenie `providers/jednostki.py` i `cerif/orgunit.py`. + +Etap 3: testy integralności referencyjnej, pełny przebieg testów, +newsfragmenty `src/bpp/newsfragments/701.feature.rst` +i `702.feature.rst`. + +Kolejność odwrotna nie ma sensu: eksport bez modelu nie ma czego +serializować. From 54d577ea1b8a183cd47b0ff4b26f6e64394f5567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 10:00:28 +0200 Subject: [PATCH 02/11] =?UTF-8?q?docs(eurocris):=20plan=20implementacji=20?= =?UTF-8?q?+=20brakuj=C4=85ce=20ogniwo=20OriginatesFrom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan rozbity na 7 zadań: modele (fundament), następnie równolegle admin, seed grantodawców i fundament eksportu, potem buildery Project/Funding, OriginatesFrom i domknięcie testami integralności. Uzupełnienie speca: profil łączy publikację z projektem elementem Publication/OriginatesFrom (XSD l. 698, analogicznie Patent l. 871), osadzającym pełne . Bez niego sety byłyby pełne, walidator zielony, a agregator dostałby dwie rozłączne listy — czyli brak tego, po co powstało #709. Dopisana też sekcja o znanych odstępstwach (Subject bez mapowania na Fields of Science, brak Project/Type). Co-Authored-By: Claude Opus 5 (1M context) --- ...026-08-05-eurocris-projekt-finansowanie.md | 1183 +++++++++++++++++ ...05-eurocris-projekt-finansowanie-design.md | 54 +- 2 files changed, 1233 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-05-eurocris-projekt-finansowanie.md diff --git a/docs/superpowers/plans/2026-08-05-eurocris-projekt-finansowanie.md b/docs/superpowers/plans/2026-08-05-eurocris-projekt-finansowanie.md new file mode 100644 index 000000000..0316a3a5d --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-eurocris-projekt-finansowanie.md @@ -0,0 +1,1183 @@ +# euroCRIS: encje Projekt i Finansowanie — plan implementacji + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Zapełnić sety CERIF `openaire_cris_projects` i `openaire_cris_funding` realnymi danymi, dodając do BPP encje projektu badawczego, jego zespołu, finansowania i instytucji finansującej. + +**Architecture:** Nowe modele w `bpp` (`Projekt`, `Projekt_Autor`, `Instytucja_Finansujaca`, `Finansowanie`); `Grant` zostaje etykietą numeru grantu z FK do projektu, dzięki czemu istniejąca generyczna relacja `Grant_Rekordu` staje się wymaganą przez CERIF relacją publikacja↔projekt. Warstwa eksportu dostaje dwa nowe providery i dwa buildery XML, a set OrgUnits zostaje rozszerzony o instytucje finansujące (wymóg integralności referencyjnej — `Funding/Funder` musi wskazywać na OrgUnit obecny w harveście). + +**Tech Stack:** Django, PostgreSQL, lxml, pytest + model_bakery, taggit, OAI-PMH/CERIF-XML. + +**Spec:** `docs/superpowers/specs/2026-08-05-eurocris-projekt-finansowanie-design.md` +**Gałąź:** `feat/eurocris-projekty` (odbita od `feat/cerif-mapowania`) +**Issues:** #701 (Project), #702 (Funding) + +## Global Constraints + +- Wszystkie polecenia Pythona przez `uv run`. Nigdy gołe `python`/`pytest`. +- Max długość linii: 88 znaków (ruff). +- **Nie wolno modyfikować istniejących migracji** w `src/*/migrations/`. +- Testy: pytest, funkcje bez klas, `@pytest.mark.django_db`, `baker.make`. Nigdy `unittest.TestCase`. +- Żadnych `except: pass` ani `except Exception: pass` — każdy `except` loguje, re-raise'uje albo zwraca sensowny błąd. +- Nazewnictwo modeli po polsku, przez-modele jako `Model_Model` (wzorce: `Patent_Autor`, `Grant_Rekordu`). +- Źródło prawdy o formacie XML: `src/cerif_export/tests/xsd/openaire-cerif-profile.xsd` + `includes/` + `vocabularies/`. Przy wątpliwości — czytaj XSD, nie dokumentację z pamięci. +- Reguła pakietu `cerif_export.cerif`: **serializer nie dotyka bazy**. Wolno czytać pola załadowanego obiektu, pola `*_id` i relacje zadeklarowane przez provider jako prefetch. Widoczność encji sąsiadujących rozstrzyga wyłącznie `KontekstSerializacji.id_dla`. +- **Nie uruchamiaj `make baseline-update`** w trakcie prac — baseline odświeżamy raz, przy scalaniu gałęzi. +- Newsfragmenty trafiają do `src/bpp/newsfragments/` (nie `changes/`). + +--- + +### Task 1: Modele danych i migracja schematu + +Fundament — wszystkie pozostałe zadania z niego korzystają. Wykonywane **sekwencyjnie, jako pierwsze** (jedna migracja schematu, żeby równoległe zadania nie kolidowały numeracją). + +**Files:** +- Create: `src/bpp/models/projekt.py` +- Modify: `src/bpp/models/__init__.py`, `src/bpp/models/grant.py`, `src/bpp/models/uczelnia.py`, `src/bpp/system.py` +- Create: `src/bpp/tests/test_projekt.py` +- Create (przez `makemigrations`): `src/bpp/migrations/XXXX_projekt_finansowanie.py` + +**Interfaces:** +- Produces: modele `Projekt`, `Projekt_Autor`, `Instytucja_Finansujaca`, `Finansowanie` w `bpp.models`; stałe `Projekt.STATUS_*`, `Projekt_Autor.ROLA_KIEROWNIK`, `Finansowanie.TYP_*`; pola `Grant.projekt`, `Uczelnia.eksport_cerif_kwoty`. + +- [ ] **Step 1: Napisz testy modeli (najpierw czerwone)** + +Utwórz `src/bpp/tests/test_projekt.py`: + +```python +import pytest +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from model_bakery import baker + +from bpp.models import ( + Finansowanie, + Grant, + Instytucja_Finansujaca, + Projekt, + Projekt_Autor, +) + + +@pytest.mark.django_db +def test_projekt_data_zakonczenia_przed_rozpoczeciem(jednostka): + projekt = Projekt( + tytul="Testowy", + jednostka=jednostka, + data_rozpoczecia="2026-01-01", + data_zakonczenia="2025-12-31", + ) + with pytest.raises(ValidationError): + projekt.clean() + + +@pytest.mark.django_db +def test_finansowanie_kwota_bez_waluty(jednostka): + projekt = baker.make(Projekt, jednostka=jednostka) + instytucja = baker.make(Instytucja_Finansujaca) + finansowanie = Finansowanie( + projekt=projekt, + instytucja=instytucja, + typ=Finansowanie.TYP_GRANT, + kwota=1000, + waluta="", + ) + with pytest.raises(ValidationError): + finansowanie.clean() + + +@pytest.mark.django_db +def test_tylko_jeden_kierownik_projektu(jednostka, autor_jan_nowak, autor_jan_kowalski): + projekt = baker.make(Projekt, jednostka=jednostka) + Projekt_Autor.objects.create( + projekt=projekt, autor=autor_jan_nowak, rola=Projekt_Autor.ROLA_KIEROWNIK + ) + with pytest.raises(IntegrityError): + Projekt_Autor.objects.create( + projekt=projekt, + autor=autor_jan_kowalski, + rola=Projekt_Autor.ROLA_KIEROWNIK, + ) + + +@pytest.mark.django_db +def test_kasowanie_projektu_zostawia_grant(jednostka): + projekt = baker.make(Projekt, jednostka=jednostka) + grant = baker.make(Grant, numer_projektu="ABC/123", projekt=projekt) + projekt.delete() + grant.refresh_from_db() + assert grant.pk is not None + assert grant.projekt is None +``` + +Fixture'y `jednostka`, `autor_jan_nowak`, `autor_jan_kowalski` istnieją w `src/fixtures/` — sprawdź ich nazwy przez `grep -rn "def jednostka\b" src/fixtures/` i użyj tych, które faktycznie są. + +- [ ] **Step 2: Uruchom testy, potwierdź czerwone** + +Run: `uv run pytest src/bpp/tests/test_projekt.py -v` +Expected: FAIL — `ImportError: cannot import name 'Projekt'`. + +- [ ] **Step 3: Napisz `src/bpp/models/projekt.py`** + +```python +"""Encje projektu badawczego i jego finansowania. + +Modele odpowiadają encjom CERIF ``Project`` i ``Funding`` z profilu +OpenAIRE Guidelines for CRIS Managers 1.2.0. Dziedziczenie po +``ModelZAdnotacjami`` nie jest kosmetyczne: daje ``ostatnio_zmieniony``, +na którym ``cerif_export.providers.base.z_datestampem`` buduje keysetowy +kursor przyrostowego harvestu OAI-PMH. +""" + +from django.core.exceptions import ValidationError +from django.db import models + +from bpp.models.abstract import ModelZAdnotacjami, ModelZeSlowamiKluczowymi + + +class Projekt(ModelZAdnotacjami, ModelZeSlowamiKluczowymi): + STATUS_PLANOWANY = "planowany" + STATUS_W_TRAKCIE = "w-trakcie" + STATUS_ZAKONCZONY = "zakonczony" + STATUS_PRZERWANY = "przerwany" + + STATUSY = [ + (STATUS_PLANOWANY, "planowany"), + (STATUS_W_TRAKCIE, "w trakcie"), + (STATUS_ZAKONCZONY, "zakończony"), + (STATUS_PRZERWANY, "przerwany"), + ] + + tytul = models.TextField(verbose_name="Tytuł") + tytul_en = models.TextField( + verbose_name="Tytuł (angielski)", blank=True, default="" + ) + akronim = models.CharField(max_length=50, blank=True, default="") + data_rozpoczecia = models.DateField( + verbose_name="Data rozpoczęcia", null=True, blank=True + ) + data_zakonczenia = models.DateField( + verbose_name="Data zakończenia", null=True, blank=True + ) + status = models.CharField( + max_length=20, choices=STATUSY, default=STATUS_W_TRAKCIE + ) + abstrakt = models.TextField(blank=True, default="") + abstrakt_en = models.TextField( + verbose_name="Abstrakt (angielski)", blank=True, default="" + ) + dyscypliny = models.ManyToManyField( + "bpp.Dyscyplina_Naukowa", blank=True, verbose_name="Dyscypliny" + ) + # Jednostka jest wymagana, bo jest jedynym nośnikiem przynależności + # projektu do uczelni (tenanta). Provider CERIF filtruje po + # ``jednostka__uczelnia``; pole nullable po cichu wypychałoby projekty + # z eksportu. + jednostka = models.ForeignKey( + "bpp.Jednostka", + models.PROTECT, + verbose_name="Jednostka realizująca", + ) + strona_www = models.URLField(blank=True, default="") + + class Meta: + verbose_name = "projekt" + verbose_name_plural = "projekty" + ordering = ["-data_rozpoczecia", "tytul"] + + def __str__(self): + if self.akronim: + return f"{self.akronim} — {self.tytul}" + return self.tytul + + def clean(self): + if ( + self.data_rozpoczecia + and self.data_zakonczenia + and self.data_zakonczenia < self.data_rozpoczecia + ): + raise ValidationError( + {"data_zakonczenia": "Data zakończenia jest wcześniejsza " + "niż data rozpoczęcia."} + ) + + +class Projekt_Autor(models.Model): + ROLA_KIEROWNIK = "kierownik" + ROLA_WYKONAWCA = "wykonawca" + ROLA_WSPOLWYKONAWCA = "wspolwykonawca" + + ROLE = [ + (ROLA_KIEROWNIK, "kierownik"), + (ROLA_WYKONAWCA, "wykonawca"), + (ROLA_WSPOLWYKONAWCA, "współwykonawca"), + ] + + projekt = models.ForeignKey(Projekt, models.CASCADE) + autor = models.ForeignKey("bpp.Autor", models.PROTECT) + rola = models.CharField(max_length=20, choices=ROLE, default=ROLA_WYKONAWCA) + od = models.DateField(null=True, blank=True) + do = models.DateField(null=True, blank=True) + + class Meta: + verbose_name = "osoba w projekcie" + verbose_name_plural = "osoby w projekcie" + unique_together = [("projekt", "autor", "rola")] + constraints = [ + # Jeden kierownik na projekt — wymuszone w bazie, bo ``clean()`` + # przy dwóch nowych wierszach dodanych naraz w inline widzi + # wyłącznie stan zapisany, więc drugiego kierownika by przepuścił. + models.UniqueConstraint( + fields=["projekt"], + condition=models.Q(rola="kierownik"), + name="projekt_jeden_kierownik", + ) + ] + + def __str__(self): + return f"{self.autor} ({self.get_rola_display()})" + + +class Instytucja_Finansujaca(ModelZAdnotacjami): + """Grantodawca. Eksportowana jako CERIF ``OrgUnit`` (rola Funder).""" + + nazwa = models.TextField() + nazwa_en = models.TextField( + verbose_name="Nazwa (angielska)", blank=True, default="" + ) + akronim = models.CharField(max_length=50, blank=True, default="") + kraj = models.CharField( + max_length=2, + default="PL", + help_text="Kod ISO 3166-1 alpha-2. Pole wewnętrzne — profil " + "OpenAIRE nie ma elementu Country w encji OrgUnit.", + ) + ror_id = models.CharField( + verbose_name="Identyfikator ROR", max_length=200, blank=True, default="" + ) + fundref_id = models.CharField( + verbose_name="Crossref Funder ID", + max_length=50, + blank=True, + default="", + help_text="Identyfikator z Crossref Funder Registry, np. 501100004281.", + ) + strona_www = models.URLField(blank=True, default="") + + class Meta: + verbose_name = "instytucja finansująca" + verbose_name_plural = "instytucje finansujące" + ordering = ["nazwa"] + + def __str__(self): + if self.akronim: + return f"{self.akronim} — {self.nazwa}" + return self.nazwa + + +class Finansowanie(ModelZAdnotacjami): + """Źródło finansowania projektu. Encja CERIF ``Funding``.""" + + TYP_FUNDING_PROGRAMME = "FundingProgramme" + TYP_CALL = "Call" + TYP_TENDER = "Tender" + TYP_GIFT = "Gift" + TYP_INTERNAL_FUNDING = "InternalFunding" + TYP_CONTRACT = "Contract" + TYP_AWARD = "Award" + TYP_GRANT = "Grant" + + TYPY = [ + (TYP_FUNDING_PROGRAMME, "program finansowania"), + (TYP_CALL, "konkurs"), + (TYP_TENDER, "przetarg"), + (TYP_GIFT, "darowizna"), + (TYP_INTERNAL_FUNDING, "finansowanie wewnętrzne"), + (TYP_CONTRACT, "umowa"), + (TYP_AWARD, "nagroda"), + (TYP_GRANT, "grant"), + ] + + projekt = models.ForeignKey(Projekt, models.CASCADE) + typ = models.CharField(max_length=30, choices=TYPY, default=TYP_GRANT) + instytucja = models.ForeignKey(Instytucja_Finansujaca, models.PROTECT) + nazwa_programu = models.CharField( + max_length=200, + blank=True, + default="", + help_text="Nazwa programu lub konkursu, np. „OPUS 24”.", + ) + numer_umowy = models.CharField(max_length=200, blank=True, default="") + kwota = models.DecimalField( + max_digits=14, decimal_places=2, null=True, blank=True + ) + waluta = models.CharField(max_length=3, blank=True, default="PLN") + grant_doi = models.CharField( + verbose_name="DOI grantu", max_length=200, blank=True, default="" + ) + + class Meta: + verbose_name = "finansowanie" + verbose_name_plural = "finansowania" + + def __str__(self): + return f"{self.instytucja} — {self.get_typ_display()}" + + def clean(self): + if self.kwota is not None and not self.waluta: + raise ValidationError( + {"waluta": "Kwota bez waluty jest niejednoznaczna."} + ) +``` + +Sprawdź nazwy importów miksinów: `grep -rn "ModelZAdnotacjami\|ModelZeSlowamiKluczowymi" src/bpp/models/abstract/__init__.py`. Jeśli nie są reeksportowane z `bpp.models.abstract`, importuj z modułów źródłowych (`bpp.models.abstract.metadata`, `bpp.models.abstract.keywords`). + +- [ ] **Step 4: Wepnij moduł do `src/bpp/models/__init__.py`** + +Dopisz w kolejności alfabetycznej, obok istniejącego `from .grant import *`: + +```python +from .projekt import * # noqa +``` + +- [ ] **Step 5: Dodaj `Grant.projekt` i `Uczelnia.eksport_cerif_kwoty`** + +W `src/bpp/models/grant.py`, w klasie `Grant`: + +```python + projekt = models.ForeignKey( + "bpp.Projekt", + models.SET_NULL, + null=True, + blank=True, + verbose_name="Projekt", + help_text="Projekt badawczy, w ramach którego przyznano ten grant.", + ) +``` + +`SET_NULL`, bo skasowanie projektu nie może kasować historycznych numerów grantów przypiętych do publikacji. + +W `src/bpp/models/uczelnia.py`, obok `eksport_cerif_osoby`: + +```python + eksport_cerif_kwoty = models.BooleanField( + verbose_name="Eksport CERIF: kwoty finansowania", + default=False, + help_text="Czy w eksporcie CERIF-XML wystawiać kwoty finansowania " + "projektów. Domyślnie wyłączone — kwoty zostają w bazie " + "do użytku wewnętrznego.", + ) +``` + +- [ ] **Step 6: Dopisz modele do uprawnień grupy „wprowadzanie danych"** + +W `src/bpp/system.py` znajdź listy pod kluczem `GR_WPROWADZANIE_DANYCH` (są dwie — około linii 159 i 219; obejrzyj obie i ustal, która czemu odpowiada, po sąsiadujących wpisach `Grant` i `Grant_Rekordu`). Dodaj `Projekt`, `Projekt_Autor`, `Instytucja_Finansujaca`, `Finansowanie` analogicznie do tego, jak wpisany jest tam `Grant`, wraz z importem modeli na górze pliku. + +- [ ] **Step 7: Wygeneruj migrację** + +Run: `DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py makemigrations bpp --name projekt_finansowanie` +Expected: jeden nowy plik w `src/bpp/migrations/` z czterema `CreateModel`, dwoma `AddField` i `AddConstraint`. + +Obejrzyj wygenerowany plik. Jeśli `makemigrations` zapyta o wartość domyślną dla nienullowalnego pola — to znaczy, że coś jest nie tak z modelem; popraw model, nie migrację. + +- [ ] **Step 8: Uruchom testy** + +Run: `uv run pytest src/bpp/tests/test_projekt.py -v` +Expected: PASS (4 testy). + +- [ ] **Step 9: Sprawdź, że nic nie zepsuło istniejących testów grantu i uczelni** + +Run: `uv run pytest src/bpp/tests/ -k "grant or uczelnia" -q` +Expected: PASS. + +- [ ] **Step 10: Lint i commit** + +```bash +ruff format src/bpp/models/projekt.py src/bpp/tests/test_projekt.py +ruff check src/bpp/models/projekt.py src/bpp/tests/test_projekt.py +git add src/bpp/models/ src/bpp/tests/test_projekt.py src/bpp/system.py src/bpp/migrations/ +git commit -m "feat(projekty): modele Projekt, Projekt_Autor, Instytucja_Finansujaca, Finansowanie (#701, #702)" +``` + +--- + +### Task 2: Admin — wprowadzanie danych przez redakcję + +**Wymaga:** Task 1. **Równoległe z:** Task 3, Task 4. + +**Files:** +- Create: `src/bpp/admin/projekt.py` +- Modify: `src/bpp/admin/__init__.py`, `src/bpp/admin/grant.py`, `src/bpp/admin/uczelnia.py` +- Create: `src/bpp/tests/test_admin_projekt.py` + +**Interfaces:** +- Consumes: modele z Taska 1 (`Projekt`, `Projekt_Autor`, `Instytucja_Finansujaca`, `Finansowanie`, `Projekt_Autor.ROLA_KIEROWNIK`), walidator ROR z `bpp/util/ror.py` (ustal dokładną nazwę funkcji: `grep -rn "^def " src/bpp/util/ror.py`). +- Produces: `ProjektAdmin`, `Instytucja_FinansujacaAdmin`. + +- [ ] **Step 1: Napisz test walidacji formsetu (czerwony)** + +Utwórz `src/bpp/tests/test_admin_projekt.py`: + +```python +import pytest +from django.contrib.admin.sites import AdminSite +from model_bakery import baker + +from bpp.admin.projekt import ProjektAdmin, Projekt_AutorInline +from bpp.models import Projekt, Projekt_Autor + + +@pytest.mark.django_db +def test_inline_odrzuca_dwoch_kierownikow( + jednostka, autor_jan_nowak, autor_jan_kowalski, rf +): + projekt = baker.make(Projekt, jednostka=jednostka) + inline = Projekt_AutorInline(Projekt, AdminSite()) + FormSet = inline.get_formset(rf.get("/"), projekt) + dane = { + "bpp-projekt_autor-projekt-TOTAL_FORMS": "2", + "bpp-projekt_autor-projekt-INITIAL_FORMS": "0", + "bpp-projekt_autor-projekt-0-autor": str(autor_jan_nowak.pk), + "bpp-projekt_autor-projekt-0-rola": Projekt_Autor.ROLA_KIEROWNIK, + "bpp-projekt_autor-projekt-1-autor": str(autor_jan_kowalski.pk), + "bpp-projekt_autor-projekt-1-rola": Projekt_Autor.ROLA_KIEROWNIK, + } + formset = FormSet(dane, instance=projekt) + assert not formset.is_valid() + + +@pytest.mark.django_db +def test_projekt_admin_ma_search_fields(): + # Bez ``search_fields`` autocomplete w innych adminach nie zadziała. + assert ProjektAdmin.search_fields +``` + +Prefiks formsetu (`bpp-projekt_autor-projekt`) zależy od nazw modeli — jeśli test wywala się na kluczach, wypisz `FormSet.get_default_prefix()` i użyj tego, co faktycznie zwraca. + +- [ ] **Step 2: Uruchom test, potwierdź czerwony** + +Run: `uv run pytest src/bpp/tests/test_admin_projekt.py -v` +Expected: FAIL — `ModuleNotFoundError: bpp.admin.projekt`. + +- [ ] **Step 3: Napisz `src/bpp/admin/projekt.py`** + +```python +from django import forms +from django.contrib import admin + +from bpp.models import ( + Finansowanie, + Instytucja_Finansujaca, + Projekt, + Projekt_Autor, +) + + +class Projekt_AutorFormSet(forms.BaseInlineFormSet): + def clean(self): + super().clean() + kierownicy = 0 + for form in self.forms: + if not form.cleaned_data or form.cleaned_data.get("DELETE"): + continue + if form.cleaned_data.get("rola") == Projekt_Autor.ROLA_KIEROWNIK: + kierownicy += 1 + if kierownicy > 1: + raise forms.ValidationError( + "Projekt może mieć tylko jednego kierownika." + ) + + +class Projekt_AutorInline(admin.TabularInline): + model = Projekt_Autor + formset = Projekt_AutorFormSet + extra = 0 + autocomplete_fields = ["autor"] + + +class FinansowanieInline(admin.TabularInline): + model = Finansowanie + extra = 0 + autocomplete_fields = ["instytucja"] + + +@admin.register(Projekt) +class ProjektAdmin(admin.ModelAdmin): + list_display = [ + "tytul", + "akronim", + "status", + "data_rozpoczecia", + "data_zakonczenia", + "jednostka", + ] + list_filter = ["status", "jednostka", "dyscypliny"] + search_fields = ["tytul", "tytul_en", "akronim", "abstrakt"] + autocomplete_fields = ["jednostka"] + filter_horizontal = ["dyscypliny"] + inlines = [Projekt_AutorInline, FinansowanieInline] + + +@admin.register(Instytucja_Finansujaca) +class Instytucja_FinansujacaAdmin(admin.ModelAdmin): + list_display = ["nazwa", "akronim", "kraj", "ror_id", "fundref_id"] + search_fields = ["nazwa", "nazwa_en", "akronim", "ror_id", "fundref_id"] +``` + +Walidacja `ror_id` jest już na poziomie pola modelu, jeśli Task 1 podpiął walidator; jeśli nie — dodaj `validators=[...]` do pola w modelu (nie w adminie), żeby obowiązywała też przy imporcie. Sprawdź, jak zrobiono to dla `Jednostka.ror_id` na tej gałęzi (`grep -rn "ror" src/bpp/models/jednostka.py`) i powiel wzorzec. + +- [ ] **Step 4: Wepnij admin i rozszerz istniejące** + +W `src/bpp/admin/__init__.py`, wśród pozostałych importów: + +```python +from .projekt import Instytucja_FinansujacaAdmin, ProjektAdmin # noqa +``` + +W `src/bpp/admin/grant.py`, w `GrantAdmin`: + +```python + list_display = [ + "nazwa_projektu", + "numer_projektu", + "rok", + "zrodlo_finansowania", + "projekt", + ] + list_filter = ["projekt"] + autocomplete_fields = ["projekt"] +``` + +W `src/bpp/admin/uczelnia.py` (okolice linii 174) dopisz `"eksport_cerif_kwoty"` do tego samego fieldsetu, w którym jest `"eksport_cerif_osoby"`. + +- [ ] **Step 5: Uruchom testy** + +Run: `uv run pytest src/bpp/tests/test_admin_projekt.py -v` +Expected: PASS. + +- [ ] **Step 6: Sprawdź spójność adminów Django** + +Run: `DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py check` +Expected: `System check identified no issues`. Błędy `admin.E040` oznaczają brak `search_fields` w adminie modelu użytego w `autocomplete_fields`. + +- [ ] **Step 7: Lint i commit** + +```bash +ruff format src/bpp/admin/projekt.py src/bpp/tests/test_admin_projekt.py +ruff check src/bpp/admin/ src/bpp/tests/test_admin_projekt.py +git add src/bpp/admin/ src/bpp/tests/test_admin_projekt.py +git commit -m "feat(projekty): admin projektów, finansowania i instytucji finansujących (#701)" +``` + +--- + +### Task 3: Migracja danych — seed instytucji finansujących + +**Wymaga:** Task 1. **Równoległe z:** Task 2, Task 4. + +**Files:** +- Create: `src/bpp/migrations/XXXX_seed_instytucje_finansujace.py` +- Create: `src/bpp/tests/test_seed_instytucji.py` + +**Interfaces:** +- Consumes: model `Instytucja_Finansujaca` z Taska 1. +- Produces: wiersze słownika grantodawców w bazie. + +- [ ] **Step 1: Zweryfikuj identyfikatory w rejestrach** + +**Nie wpisuj identyfikatorów z pamięci.** Dla każdej instytucji sprawdź Crossref Funder Registry (`https://api.crossref.org/funders?query=`) i rejestr ROR (`https://api.ror.org/organizations?query=`). Zakres: Narodowe Centrum Nauki, Narodowe Centrum Badań i Rozwoju, Ministerstwo Nauki i Szkolnictwa Wyższego, Fundacja na rzecz Nauki Polskiej, Narodowa Agencja Wymiany Akademickiej, Agencja Badań Medycznych, Komisja Europejska. + +Zapisz znalezione wartości; instytucja, dla której **nie ma** potwierdzonego identyfikatora, wchodzi do seeda **bez niego** — nigdy ze zmyślonym. + +- [ ] **Step 2: Napisz test seeda (czerwony)** + +Utwórz `src/bpp/tests/test_seed_instytucji.py`: + +```python +import pytest + +from bpp.models import Instytucja_Finansujaca + + +@pytest.mark.django_db +def test_seed_zawiera_ncn(): + ncn = Instytucja_Finansujaca.objects.filter(akronim="NCN").first() + assert ncn is not None + assert ncn.kraj == "PL" + + +@pytest.mark.django_db +def test_seed_bez_zmyslonych_identyfikatorow(): + # FundRef ID Crossrefa to sam ciąg cyfr (bez URL-a i bez prefiksu). + for instytucja in Instytucja_Finansujaca.objects.exclude(fundref_id=""): + assert instytucja.fundref_id.isdigit(), instytucja.nazwa +``` + +- [ ] **Step 3: Uruchom test, potwierdź czerwony** + +Run: `uv run pytest src/bpp/tests/test_seed_instytucji.py -v` +Expected: FAIL — brak wiersza NCN. + +- [ ] **Step 4: Napisz migrację danych** + +Obejrzyj `src/bpp/migrations/0483_*.py` jako wzorzec (migracja danych z komentarzem uzasadniającym). Utwórz analogiczną: + +```python +"""Seed słownika instytucji finansujących. + +Identyfikatory FundRef pochodzą z Crossref Funder Registry, ROR-y +z rejestru ROR — zweryfikowane w rejestrach przy pisaniu migracji, nie +przepisane z pamięci. Instytucja bez potwierdzonego identyfikatora wchodzi +bez niego: pusty identyfikator jest brakiem danych, a zmyślony jest +błędem, który wycieknie do OpenAIRE i sklei nasze publikacje z cudzym +profilem grantodawcy. + +Idempotentna: ``get_or_create`` po ``fundref_id`` tam, gdzie jest, po +``nazwa`` w przeciwnym razie. Odwrotna operacja jest no-opem — kasowanie +grantodawców przy cofaniu migracji mogłoby zerwać FK z ``Finansowanie``. +""" + +from django.db import migrations + +INSTYTUCJE = [ + # (nazwa, nazwa_en, akronim, kraj, fundref_id, ror_id) + # UZUPEŁNIJ zweryfikowanymi wartościami ze Stepu 1. +] + + +def seed(apps, schema_editor): + Instytucja_Finansujaca = apps.get_model("bpp", "Instytucja_Finansujaca") + for nazwa, nazwa_en, akronim, kraj, fundref_id, ror_id in INSTYTUCJE: + klucz = ( + {"fundref_id": fundref_id} if fundref_id else {"nazwa": nazwa} + ) + Instytucja_Finansujaca.objects.get_or_create( + defaults={ + "nazwa": nazwa, + "nazwa_en": nazwa_en, + "akronim": akronim, + "kraj": kraj, + "fundref_id": fundref_id, + "ror_id": ror_id, + }, + **klucz, + ) + + +class Migration(migrations.Migration): + dependencies = [("bpp", "XXXX_projekt_finansowanie")] + + operations = [migrations.RunPython(seed, migrations.RunPython.noop)] +``` + +Podmień `XXXX_projekt_finansowanie` na faktyczną nazwę migracji z Taska 1 i wypełnij `INSTYTUCJE` danymi ze Stepu 1. + +- [ ] **Step 5: Uruchom testy** + +Run: `uv run pytest src/bpp/tests/test_seed_instytucji.py -v` +Expected: PASS. + +- [ ] **Step 6: Sprawdź idempotentność** + +Run: `DJANGO_BPP_SKIP_DOTENV=1 uv run python src/manage.py migrate bpp` +Expected: brak błędów przy ponownym przejściu (migracja jest już zastosowana; test z `--fake` nie jest potrzebny — idempotentność zapewnia `get_or_create`). + +- [ ] **Step 7: Lint i commit** + +```bash +ruff format src/bpp/migrations/ src/bpp/tests/test_seed_instytucji.py +ruff check src/bpp/migrations/ src/bpp/tests/test_seed_instytucji.py +git add src/bpp/migrations/ src/bpp/tests/test_seed_instytucji.py +git commit -m "feat(projekty): seed słownika instytucji finansujących (#702)" +``` + +--- + +### Task 4: Fundament eksportu — stałe, identyfikatory, słownik typów, funder jako OrgUnit + +**Wymaga:** Task 1. **Równoległe z:** Task 2, Task 3. + +**Files:** +- Modify: `src/cerif_export/const.py`, `src/cerif_export/identyfikatory.py`, `src/cerif_export/providers/jednostki.py`, `src/cerif_export/cerif/orgunit.py` +- Create: `src/cerif_export/slowniki/typy_finansowania.py` +- Create: `src/cerif_export/tests/test_funderzy_orgunit.py` + +**Interfaces:** +- Consumes: modele z Taska 1. +- Produces: `const.TYP_PROJECT`, `const.TYP_FUNDING`, `const.SCHEMAT_STATUSU_PROJEKTU`, `const.SCHEMAT_DYSCYPLIN`; wpisy `pj`/`fn`/`if` w `identyfikatory._REJESTR`; `slowniki.typy_finansowania.uri_typu(typ) -> str | None`; `Instytucja_Finansujaca` w secie OrgUnits. + +- [ ] **Step 1: Napisz test funderów w secie OrgUnits (czerwony)** + +Utwórz `src/cerif_export/tests/test_funderzy_orgunit.py`. Najpierw przeczytaj istniejący `src/cerif_export/tests/` i użyj tamtejszych fixture'ów oraz sposobu wołania providera — poniższy szkielet zakłada, że provider ma metodę `strona()`, ale sprawdź faktyczną sygnaturę w `providers/base.py`: + +```python +import pytest +from model_bakery import baker + +from bpp.models import Finansowanie, Instytucja_Finansujaca, Projekt +from cerif_export.providers.jednostki import ProviderJednostek + + +@pytest.mark.django_db +def test_funder_bez_finansowania_nie_wychodzi(uczelnia, jednostka): + baker.make(Instytucja_Finansujaca, nazwa="Niepowiązana") + provider = ProviderJednostek() + qs = provider.queryset(uczelnia, Instytucja_Finansujaca) + assert not qs.exists() + + +@pytest.mark.django_db +def test_funder_z_finansowaniem_wychodzi(uczelnia, jednostka): + instytucja = baker.make(Instytucja_Finansujaca, nazwa="NCN", akronim="NCN") + projekt = baker.make(Projekt, jednostka=jednostka) + baker.make(Finansowanie, projekt=projekt, instytucja=instytucja) + provider = ProviderJednostek() + qs = provider.queryset(uczelnia, Instytucja_Finansujaca) + assert list(qs) == [instytucja] +``` + +Fixture `uczelnia` i `jednostka` — sprawdź, jak nazywają się w `src/fixtures/` i czy `jednostka` jest powiązana z `uczelnia`. + +- [ ] **Step 2: Uruchom test, potwierdź czerwony** + +Run: `uv run pytest src/cerif_export/tests/test_funderzy_orgunit.py -v` +Expected: FAIL — provider odrzuca nieznany model. + +- [ ] **Step 3: Dodaj stałe do `const.py`** + +Obejrzyj plik i dopisz obok istniejących `TYP_*`: + +```python +TYP_PROJECT = "Project" +TYP_FUNDING = "Funding" + +# Schematy klasyfikacji własnych. ``cfGenericURIClassification__Type`` +# wymaga atrybutu ``scheme`` (anyURI) i wartości będącej URI, a profil +# OpenAIRE nie dostarcza słownika ani dla statusu projektu, ani dla +# polskich dyscyplin naukowych. Własny schemat jawnie nazywa pochodzenie +# wartości — lepiej niż pominięcie danych i lepiej niż podszywanie się pod +# cudzy słownik. +SCHEMAT_STATUSU_PROJEKTU = "https://bpp.iplweb.pl/vocab/StatusProjektu" +SCHEMAT_DYSCYPLIN = "https://bpp.iplweb.pl/vocab/DyscyplinaNaukowa" +``` + +Sprawdź, czy dokładne wartości `TYP_PROJECT`/`TYP_FUNDING` zgadzają się z nazwami elementów w XSD (`grep -n 'name="Project"\|name="Funding"' src/cerif_export/tests/xsd/openaire-cerif-profile.xsd`) — te stałe trafiają do identyfikatorów OAI i muszą pasować do reguły walidatora opisanej w docstringu `cerif/wspolne.py`. + +- [ ] **Step 4: Dopisz wpisy do rejestru identyfikatorów** + +W `src/cerif_export/identyfikatory.py`, w `_REJESTR`: + +```python + "bpp.Projekt": (const.TYP_PROJECT, "pj"), + "bpp.Finansowanie": (const.TYP_FUNDING, "fn"), + "bpp.Instytucja_Finansujaca": (const.TYP_ORGUNIT, "if"), +``` + +Instytucja finansująca dostaje typ **OrgUnit**, nie własny — jest eksportowana w secie `openaire_cris_orgunits`. + +- [ ] **Step 5: Napisz słownik typów finansowania** + +Utwórz `src/cerif_export/slowniki/typy_finansowania.py` wzorem `slowniki/coar.py`. Dozwolone wartości i ich URI odczytaj z `src/cerif_export/tests/xsd/vocabularies/openaire_funding_types.xsd` — osiem wartości: FundingProgramme, Call, Tender, Gift, InternalFunding, Contract, Award, Grant. Moduł eksportuje: + +```python +def uri_typu(typ: str) -> str | None: + """Zwróć URI typu finansowania albo ``None`` dla nieznanej wartości.""" +``` + +- [ ] **Step 6: Rozszerz provider OrgUnits o funderów** + +W `src/cerif_export/providers/jednostki.py`: + +```python +from bpp.models import Instytucja_Finansujaca +``` + +w `ProviderJednostek`: + +```python + modele = [Jednostka, Uczelnia, Instytucja_Finansujaca] +``` + +i w `queryset()` dodatkowa gałąź: + +```python + if model is Instytucja_Finansujaca: + # Wychodzą wyłącznie grantodawcy realnie finansujący projekty + # tej uczelni. Seed słownika ma kilkanaście pozycji; wypchnięcie + # ich wszystkich pokazywałoby OpenAIRE instytucje, z którymi + # uczelnia nie ma nic wspólnego. + return Instytucja_Finansujaca.objects.filter( + finansowanie__projekt__jednostka__uczelnia=uczelnia + ).distinct() +``` + +Nazwa odwrotnej relacji (`finansowanie__`) zależy od `related_name` — jeśli go nie ustawiono w Tasku 1, Django użyje `finansowanie`. Zweryfikuj przez `uv run python src/manage.py shell -c "..."` albo po prostu uruchom test. + +- [ ] **Step 7: Rozszerz serializer OrgUnit** + +W `src/cerif_export/cerif/orgunit.py` dodaj gałąź serializującą `Instytucja_Finansujaca`: `Acronym`, `Name` (pl + en jako `cfMLangString__Type`, wzorem tego, jak plik robi wielojęzyczność dla jednostek), `RORID`, `FundRefID`, `ElectronicAddress`. + +`RORID` i `FundRefID` to **dedykowane elementy** z `includes/orgunit-identifiers.xsd`, nie generyczne `Identifier` — sprawdź ich dokładne nazwy i kolejność: `grep -n "name=" src/cerif_export/tests/xsd/includes/orgunit-identifiers.xsd`. + +Kraj **nie wychodzi** — sekwencja OrgUnit w profilu nie ma elementu Country. + +- [ ] **Step 8: Uruchom testy** + +Run: `uv run pytest src/cerif_export/tests/test_funderzy_orgunit.py -v` +Expected: PASS. + +- [ ] **Step 9: Uruchom istniejące testy eksportu (regresja)** + +Run: `uv run pytest src/cerif_export/tests/ -q` +Expected: PASS — rozszerzenie setu OrgUnits nie może zmienić tego, co wychodzi dla jednostek i uczelni. + +- [ ] **Step 10: Lint i commit** + +```bash +ruff format src/cerif_export/ +ruff check src/cerif_export/ +git add src/cerif_export/ +git commit -m "feat(cerif): instytucje finansujące jako OrgUnit + fundament dla Project/Funding (#702)" +``` + +--- + +### Task 5: Buildery `Project` i `Funding` oraz ich providery + +**Wymaga:** Task 4 (stałe, identyfikatory, słownik typów). + +**Files:** +- Create: `src/cerif_export/cerif/funding.py`, `src/cerif_export/cerif/project.py` +- Create: `src/cerif_export/providers/finansowanie.py`, `src/cerif_export/providers/projekty.py` +- Modify: `src/cerif_export/providers/puste.py`, plus miejsce rejestracji providerów (znajdź je: `grep -rn "ProviderProjektow\|ProviderPusty" src/cerif_export/`) +- Create: `src/cerif_export/tests/test_eksport_projektow.py` + +**Interfaces:** +- Consumes: wszystko z Tasków 1 i 4. +- Produces: `cerif.funding.serializuj(finansowanie, ctx) -> etree.Element`, `cerif.project.serializuj(projekt, ctx) -> etree.Element`, `ProviderProjektow`, `ProviderFinansowania`. + +**Zanim zaczniesz — przeczytaj:** +- `src/cerif_export/cerif/event.py` (najprostszy builder) i `cerif/patent.py` (builder z referencjami do osób), +- `src/cerif_export/cerif/wspolne.py` (helpery `element`, `dodaj`, `tekst`, `data_iso`, `ustaw_id_rekordu`; reguła „serializer nie dotyka bazy"), +- `src/cerif_export/providers/konferencje.py` i `providers/patenty.py` (wzorce providera i `zbiory_widocznosci`), +- XSD: sekwencja `Project` (linie 238–450) i `Funding` (od linii ~460). + +- [ ] **Step 1: Napisz testy serializacji (czerwone)** + +Utwórz `src/cerif_export/tests/test_eksport_projektow.py`. Wzoruj się na istniejących testach eksportu co do sposobu budowania kontekstu (`KontekstSerializacji`) — poniżej treść merytoryczna asercji: + +```python +import pytest +from model_bakery import baker + +from bpp.models import Finansowanie, Instytucja_Finansujaca, Projekt + + +@pytest.mark.django_db +def test_funding_ma_typ_zawsze(uczelnia, jednostka): + # Funding/Type jest w profilu obowiązkowy. + ... + assert el.find("Type") is not None + + +@pytest.mark.django_db +def test_kwota_tylko_przy_wlaczonym_przelaczniku(uczelnia, jednostka): + # eksport_cerif_kwoty=False -> brak ; True -> jest, z currency. + ... + + +@pytest.mark.django_db +def test_funded_by_to_referencja_a_as_osadza_funding(uczelnia, jednostka): + # Project/Funded/By -> referencja do OrgUnit-a findera + # Project/Funded/As -> osadzony pełny + ... + + +@pytest.mark.django_db +def test_projekt_bez_finansowania_serializuje_sie(uczelnia, jednostka): + # Funded ma minOccurs="0" — brak finansowania jest poprawny. + ... + + +@pytest.mark.django_db +def test_team_znika_przy_wylaczonym_eksporcie_osob(uczelnia, jednostka): + # eksport_cerif_osoby=False -> brak PrincipalInvestigator i Member, + # bo osoby nie ma w secie i referencja byłaby wisząca. + ... +``` + +Uzupełnij `...` faktycznym budowaniem obiektów i wołaniem serializera — wzorem istniejących testów eksportu w tym katalogu. Testy mają być kompletne, nie szkicowe. + +- [ ] **Step 2: Uruchom testy, potwierdź czerwone** + +Run: `uv run pytest src/cerif_export/tests/test_eksport_projektow.py -v` +Expected: FAIL — brak modułów `cerif.project` / `cerif.funding`. + +- [ ] **Step 3: Napisz `cerif/funding.py`** + +Builder musi być wywoływalny w **dwóch kontekstach**: jako samodzielny rekord setu `openaire_cris_funding` i jako element osadzony w `Project/Funded/As`. Zrób jedną funkcję `serializuj(finansowanie, ctx)` zwracającą element `` — osadzenie polega na doczepieniu tego samego elementu w innym miejscu drzewa. + +Kolejność elementów z XSD. `Amount` z atrybutem `currency` tylko gdy `ctx` niesie zgodę na kwoty (przełącznik `Uczelnia.eksport_cerif_kwoty` — dołóż go do kontekstu tam, gdzie `kontekst.py` trzyma `eksport_cerif_osoby`). `GrantDOI` to dedykowany element z `includes/funding-identifiers.xsd`. `Funder` to referencja do OrgUnit-a — przez `ctx.id_dla(...)`, nigdy przez zapytanie do bazy. + +- [ ] **Step 4: Napisz `cerif/project.py`** + +Kolejność z XSD: `Type`, `Acronym`, `Title`, `Identifier`, `StartDate`, `EndDate`, `Consortium` (→ `Coordinator` = OrgUnit jednostki), `Team` (→ `PrincipalInvestigator` dla roli kierownik, `Member` dla pozostałych), `Funded` (→ `By` = referencja do OrgUnit-a findera, `As` = osadzone ``), `Subject`, `Keyword`, `Abstract`, `Status`. + +`Status` i `Subject` wymagają atrybutu `scheme` — użyj `const.SCHEMAT_STATUSU_PROJEKTU` i `const.SCHEMAT_DYSCYPLIN` z Taska 4, a wartość zbuduj jako URI (schemat + `/` + kod). + +`Team` emituj **tylko wtedy**, gdy osoby są widoczne (`ctx.id_dla` dla autora zwraca identyfikator) — inaczej powstanie referencja do rekordu nieobecnego w harveście. + +- [ ] **Step 5: Napisz providery** + +`providers/projekty.py` i `providers/finansowanie.py` wzorem `providers/konferencje.py`: + +```python +class ProviderProjektow(ProviderEncji): + set_spec = const.SET_PROJECTS + typ_cerif = const.TYP_PROJECT + modele = [Projekt] + + def queryset(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is not Projekt: + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + return ( + Projekt.objects.filter(jednostka__uczelnia=uczelnia) + .select_related("jednostka") + .prefetch_related( + "projekt_autor_set__autor", + "finansowanie_set__instytucja", + "dyscypliny", + "slowa_kluczowe", + ) + ) +``` + +Nazwy odwrotnych relacji zweryfikuj — jeśli test wywali się na `AttributeError`, sprawdź faktyczne `related_name`. `zbiory_widocznosci()` musi prekomputować widoczność autorów, jednostki i instytucji finansujących — inaczej serializer nie miałby czym rozstrzygać referencji bez chodzenia do bazy. + +- [ ] **Step 6: Posprzątaj `puste.py`** + +Usuń `ProviderProjektow` i `ProviderFinansowania`; zostają `ProviderProduktow` i `ProviderAparatury`. **Popraw docstring modułu** — dziś twierdzi, że BPP „nie prowadzi ewidencji produktów badawczych, aparatury, projektów ani finansowania". Po tej zmianie zdanie musi wymieniać tylko produkty i aparaturę. + +Zaktualizuj miejsce rejestracji providerów, żeby wskazywało na nowe klasy. + +- [ ] **Step 7: Uruchom testy** + +Run: `uv run pytest src/cerif_export/tests/test_eksport_projektow.py -v` +Expected: PASS. + +- [ ] **Step 8: Pełny przebieg testów eksportu** + +Run: `uv run pytest src/cerif_export/ -q` +Expected: PASS. Testy walidujące wobec XSD wyłapią złą kolejność elementów — jeśli coś pada na `xs:sequence`, popraw kolejność w builderze, nie test. + +- [ ] **Step 9: Lint i commit** + +```bash +ruff format src/cerif_export/ +ruff check src/cerif_export/ +git add src/cerif_export/ +git commit -m "feat(cerif): eksport setów openaire_cris_projects i openaire_cris_funding (#701, #702)" +``` + +--- + +### Task 6: `OriginatesFrom` — łańcuch publikacja ↔ projekt ↔ finansowanie + +**Wymaga:** Task 5. + +To jest ogniwo, dla którego całe przedsięwzięcie ma sens. Pełne sety +`openaire_cris_projects` i `openaire_cris_funding` bez tego elementu dają +agregatorowi dwie rozłączne listy: osobno publikacje, osobno projekty. +Walidator przejdzie, wartość merytoryczna będzie zerowa. + +**Files:** +- Modify: `src/cerif_export/cerif/publication.py`, `src/cerif_export/cerif/patent.py`, `src/cerif_export/providers/publikacje.py`, `src/cerif_export/providers/patenty.py` +- Create: `src/cerif_export/tests/test_originates_from.py` + +**Interfaces:** +- Consumes: `cerif.project.serializuj` z Taska 5, `Grant_Rekordu` (generyczna relacja publikacja↔grant), `Grant.projekt` z Taska 1. + +- [ ] **Step 1: Przeczytaj definicję elementu w XSD** + +Run: `sed -n '695,715p' src/cerif_export/tests/xsd/openaire-cerif-profile.xsd` + +Zwróć uwagę: `OriginatesFrom` **osadza** element z grupy podstawień +`ProjectFunding__SubstitutionGroupHead` (czyli pełne `` albo +``), a nie samą referencję. `maxOccurs="unbounded"`, +`minOccurs="0"` — publikacja bez projektu jest poprawna. + +- [ ] **Step 2: Napisz test (czerwony)** + +Utwórz `src/cerif_export/tests/test_originates_from.py`: + +```python +import pytest +from model_bakery import baker + +from bpp.models import Grant, Grant_Rekordu, Projekt + + +@pytest.mark.django_db +def test_publikacja_z_grantem_bez_projektu_nie_ma_originates_from( + uczelnia, jednostka, wydawnictwo_ciagle +): + grant = baker.make(Grant, numer_projektu="BEZ/PROJEKTU", projekt=None) + Grant_Rekordu.objects.create(rekord=wydawnictwo_ciagle, grant=grant) + # ... zbuduj kontekst i zserializuj publikację jak w istniejących testach + assert el.find("OriginatesFrom") is None + + +@pytest.mark.django_db +def test_publikacja_z_projektem_osadza_project( + uczelnia, jednostka, wydawnictwo_ciagle +): + projekt = baker.make(Projekt, jednostka=jednostka, tytul="Badany projekt") + grant = baker.make(Grant, numer_projektu="Z/PROJEKTEM", projekt=projekt) + Grant_Rekordu.objects.create(rekord=wydawnictwo_ciagle, grant=grant) + # ... + osadzony = el.find("OriginatesFrom") + assert osadzony is not None + assert osadzony.find("Project") is not None + + +@pytest.mark.django_db +def test_ta_sama_zasada_dla_patentu(uczelnia, jednostka, patent): + # Patent/OriginatesFrom (XSD linia 871) ma identyczną strukturę. + ... +``` + +Uzupełnij `...` wzorem istniejących testów eksportu publikacji — testy +mają być kompletne, nie szkicowe. + +- [ ] **Step 3: Uruchom testy, potwierdź czerwone** + +Run: `uv run pytest src/cerif_export/tests/test_originates_from.py -v` +Expected: FAIL — brak elementu `OriginatesFrom`. + +- [ ] **Step 4: Wciągnij granty do providerów publikacji i patentów** + +W `providers/publikacje.py` i `providers/patenty.py` dołóż prefetch +generycznej relacji `Grant_Rekordu` wraz z `grant__projekt` (i tym, czego +`cerif.project.serializuj` potrzebuje: `grant__projekt__jednostka`, +`grant__projekt__finansowanie_set__instytucja`). + +`Grant_Rekordu` jest `GenericForeignKey`, więc prefetch idzie od strony +publikacji przez `GenericRelation` — jeśli modele publikacji jej nie mają, +dodaj `GenericRelation(Grant_Rekordu)` do miksina publikacji w `bpp` +(to zmiana bez migracji — `GenericRelation` nie tworzy kolumny). + +`zbiory_widocznosci()` musi objąć projekty i instytucje finansujące, bo +serializer nie wolno mu chodzić do bazy. + +- [ ] **Step 5: Dodaj `OriginatesFrom` do builderów** + +W `cerif/publication.py` i `cerif/patent.py`, w miejscu wymuszonym przez +`xs:sequence` (sprawdź w XSD, gdzie dokładnie `OriginatesFrom` wypada +w kolejności), dla każdego grantu publikacji mającego przypisany projekt: + +```python + for projekt in projekty_publikacji: + pochodzenie = dodaj_element(el, "OriginatesFrom") + pochodzenie.append(cerif_project.serializuj(projekt, ctx)) +``` + +Nazwy helperów dobierz do tych, których faktycznie używa dany plik +(`element`, `dodaj` z `cerif/wspolne.py`). Deduplikuj projekty — dwa granty +tego samego projektu przypięte do jednej publikacji nie mogą dać dwóch +identycznych `OriginatesFrom`. + +- [ ] **Step 6: Uruchom testy** + +Run: `uv run pytest src/cerif_export/tests/test_originates_from.py -v` +Expected: PASS. + +- [ ] **Step 7: Regresja całego eksportu + kontrola zapytań** + +Run: `uv run pytest src/cerif_export/ -q` +Expected: PASS. Osadzanie projektu w każdej publikacji to naturalny +kandydat na N+1 — jeśli w katalogu są testy liczące zapytania +(`django_assert_num_queries`), upewnij się, że nadal przechodzą; jeśli +liczba zapytań rośnie z liczbą publikacji na stronie, brakuje prefetcha +z Stepu 4. + +- [ ] **Step 8: Lint i commit** + +```bash +ruff format src/cerif_export/ src/bpp/ +ruff check src/cerif_export/ src/bpp/ +git add src/cerif_export/ src/bpp/ +git commit -m "feat(cerif): OriginatesFrom — powiązanie publikacji i patentów z projektami (#701)" +``` + +--- + +### Task 7: Integralność referencyjna, domknięcie + +**Wymaga:** Tasków 2, 3, 5, 6. + +**Files:** +- Create: `src/cerif_export/tests/test_integralnosc_projektow.py` +- Create: `src/bpp/newsfragments/701.feature.rst`, `src/bpp/newsfragments/702.feature.rst` +- Modify: `docs/deweloper/eurocris-co-jeszcze.md` + +- [ ] **Step 1: Napisz test integralności referencyjnej** + +To najważniejszy test tej zmiany — klasa błędu, która inaczej wychodzi dopiero na żywym endpoincie pod walidatorem OpenAIRE. + +Utwórz `src/cerif_export/tests/test_integralnosc_projektow.py`: zbuduj uczelnię z jednostką, projektem, zespołem (kierownik + wykonawca), finansowaniem, instytucją finansującą oraz publikacją przypiętą do projektu przez `Grant_Rekordu`, po czym zharvestuj **wszystkie** sety i sprawdź, że każdy identyfikator referowany z ``, `` i z osadzonego `Publication/OriginatesFrom` (Coordinator, PrincipalInvestigator, Member, Funded/By, Funder) występuje jako `@id` rekordu w odpowiednim secie. + +Test musi przejść w **obu** stanach `Uczelnia.eksport_cerif_osoby` — przy `False` referencje do osób mają zniknąć, a nie zawisnąć. + +- [ ] **Step 2: Uruchom test** + +Run: `uv run pytest src/cerif_export/tests/test_integralnosc_projektow.py -v` +Expected: PASS. Jeśli FAIL — to prawdziwy błąd w builderach z Taska 5, nie w teście. + +- [ ] **Step 3: Napisz newsfragmenty** + +`src/bpp/newsfragments/701.feature.rst`: + +```rst +Nowa encja "projekt badawczy" wraz z zespołem projektu. Numery grantów +można teraz przypiąć do projektu, a projekty wychodzą w eksporcie +CERIF-XML w zestawie ``openaire_cris_projects``. +``` + +`src/bpp/newsfragments/702.feature.rst`: + +```rst +Nowa encja "finansowanie" wraz ze słownikiem instytucji finansujących +(z identyfikatorami ROR i Crossref Funder ID). Finansowanie projektów +wychodzi w eksporcie CERIF-XML w zestawie ``openaire_cris_funding``. +Kwoty są eksportowane wyłącznie po włączeniu opcji w ustawieniach uczelni. +``` + +- [ ] **Step 4: Zaktualizuj dokument euroCRIS** + +W `docs/deweloper/eurocris-co-jeszcze.md` sekcje A1 i A2 opisują Project i Funding jako niezrobione. Przepisz je na stan faktyczny: co zostało zaimplementowane, co świadomie pominięto (partnerzy zewnętrzni, front, API) i że `Status`/`Subject` używają własnych schematów klasyfikacji. + +- [ ] **Step 5: Pełny przebieg testów bez Playwrighta** + +Run: `make tests-without-playwright` +Expected: PASS. To trwa kilka minut — nie przerywaj. + +- [ ] **Step 6: Commit** + +```bash +git add src/cerif_export/tests/ src/bpp/newsfragments/ docs/ +git commit -m "test(cerif): integralność referencyjna projektów i finansowania (#701, #702)" +``` + +- [ ] **Step 7: Krok akceptacyjny (poza CI, do wykonania przez człowieka)** + +Uruchom `openaire-cris-validator` 2.1.1 na żywym endpoincie z dumpem produkcyjnym, jak przy #699. Automatyczne testy sprawdzają zgodność ze schematem i integralność wewnątrz jednego harvestu; walidator sprawdza całość protokołu. + +--- + +## Po wykonaniu wszystkich zadań + +- `make baseline-update` — **raz**, przy scalaniu gałęzi (nie wcześniej). +- Commit obu plików: `baseline-sql/baseline.sql` i `baseline-sql/baseline.meta.json`. diff --git a/docs/superpowers/specs/2026-08-05-eurocris-projekt-finansowanie-design.md b/docs/superpowers/specs/2026-08-05-eurocris-projekt-finansowanie-design.md index 6d9900536..199dc61f7 100644 --- a/docs/superpowers/specs/2026-08-05-eurocris-projekt-finansowanie-design.md +++ b/docs/superpowers/specs/2026-08-05-eurocris-projekt-finansowanie-design.md @@ -72,9 +72,24 @@ OpenAIRE z pamięci. Wydawnictwo_* / Patent → Grant_Rekordu (GenericFK) → Grant → Projekt → Finansowanie → Instytucja_Finansujaca ``` -`Grant_Rekordu` **nie wymaga zmian** — po dodaniu `Grant.projekt` staje się -wymaganą przez CERIF relacją publikacja↔projekt. Publikacje, których grant -nie ma przypisanego projektu, nie wnoszą powiązania i nie łamią eksportu. +`Grant_Rekordu` **nie wymaga zmian po stronie modelu** — po dodaniu +`Grant.projekt` niesie już relację publikacja↔projekt. Publikacje, których +grant nie ma przypisanego projektu, nie wnoszą powiązania i nie łamią +eksportu. + +Po stronie **eksportu** relacja wymaga jednak osobnej roboty, i to ona +decyduje o wartości całego przedsięwzięcia. Profil łączy publikację +z projektem elementem **`Publication/OriginatesFrom`** (XSD, linia 698); +analogiczny element ma `Patent` (linia 871). `OriginatesFrom` **osadza** +pełny element z grupy podstawień `ProjectFunding__SubstitutionGroupHead` +(czyli całe `` albo ``), nie samą referencję; +`minOccurs="0"`, `maxOccurs="unbounded"`. + +Dziś `cerif/publication.py` i `cerif/patent.py` nie wiedzą nic o grantach. +Bez dopisania `OriginatesFrom` sety projektów i finansowania byłyby pełne, +walidator zielony, a agregator dostałby **dwie rozłączne listy** — osobno +publikacje, osobno projekty. Czyli dokładnie brak tego, po co powstało +issue #709. ## Modele danych (`src/bpp/models/projekt.py`) @@ -293,6 +308,33 @@ podszywanie się pod cudzy słownik. - `providers/puste.py` — zostają tylko `ProviderProduktow` i `ProviderAparatury`; docstring modułu wymaga poprawienia (dziś twierdzi, że BPP „nie prowadzi ewidencji projektów ani finansowania"). +- `cerif/publication.py`, `cerif/patent.py` — element `OriginatesFrom` + z osadzonym `` dla każdego grantu publikacji mającego projekt + (z deduplikacją: dwa granty tego samego projektu na jednej publikacji + dają jeden element). +- `providers/publikacje.py`, `providers/patenty.py` — prefetch generycznej + relacji `Grant_Rekordu` wraz z `grant__projekt` i tym, czego wymaga + serializer projektu. Osadzanie projektu w każdej publikacji to naturalny + kandydat na N+1 — bez prefetcha liczba zapytań rośnie z liczbą publikacji + na stronie harvestu. + +## Znane odstępstwa od intencji euroCRIS + +Świadome, odnotowane, możliwe do poprawienia później: + +1. **`Subject` z własnym schematem zamiast Fields of Science.** OpenAIRE + rozumie taksonomię FOS; dyscypliny MNiSW dałoby się na nią zmapować + i byłoby to bliżej intencji profilu. Mapowanie to jednak osobna robota + słownikowa, więc na razie eksportujemy własny schemat — dane są jawnie + oznaczone pochodzeniem, ale agregator ich nie zinterpretuje. +2. **`Status` projektu bez słownika kontrolowanego.** Profil żadnego nie + dostarcza; własny schemat jest tu jedyną uczciwą opcją poza pominięciem + elementu. +3. **Brak `Partner`/`Contractor`.** Wymagałoby encji instytucji + zewnętrznej, której BPP nie ma. Elementy są w profilu opcjonalne. +4. **`Project/Type` nie jest wypełniany.** `minOccurs="0"`, a BPP nie ma + danych pozwalających odróżnić projekt badawczy od wdrożeniowego czy + infrastrukturalnego. Zgadywanie byłoby wpisywaniem nieprawdy. ### Integralność referencyjna a przełącznik osób @@ -376,7 +418,11 @@ Etap 2 (równolegle, po etapie 1): buildery `cerif/project.py` i `cerif/funding.py`, providery, rozszerzenie `providers/jednostki.py` i `cerif/orgunit.py`. -Etap 3: testy integralności referencyjnej, pełny przebieg testów, +Etap 3 (po etapie 2): `OriginatesFrom` w `cerif/publication.py` +i `cerif/patent.py` wraz z prefetchami w ich providerach — ogniwo, dla +którego całość ma sens. + +Etap 4: testy integralności referencyjnej, pełny przebieg testów, newsfragmenty `src/bpp/newsfragments/701.feature.rst` i `702.feature.rst`. From 2a9e813078a34864a9b358404cf04a67b13f18c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 10:14:16 +0200 Subject: [PATCH 03/11] feat(projekty): modele Projekt, Projekt_Autor, Instytucja_Finansujaca, Finansowanie (#701, #702) Co-Authored-By: Claude Opus 5 (1M context) --- .../migrations/0485_projekt_finansowanie.py | 112 ++++++++ src/bpp/models/__init__.py | 1 + src/bpp/models/grant.py | 10 + src/bpp/models/projekt.py | 243 ++++++++++++++++++ src/bpp/models/uczelnia.py | 7 + src/bpp/system.py | 12 + src/bpp/tests/test_projekt.py | 63 +++++ 7 files changed, 448 insertions(+) create mode 100644 src/bpp/migrations/0485_projekt_finansowanie.py create mode 100644 src/bpp/models/projekt.py create mode 100644 src/bpp/tests/test_projekt.py diff --git a/src/bpp/migrations/0485_projekt_finansowanie.py b/src/bpp/migrations/0485_projekt_finansowanie.py new file mode 100644 index 000000000..42e57b74b --- /dev/null +++ b/src/bpp/migrations/0485_projekt_finansowanie.py @@ -0,0 +1,112 @@ +# Generated by Django 5.2.16 on 2026-08-05 08:08 + +import django.contrib.postgres.fields +import django.db.models.deletion +import taggit.managers +from django.db import migrations, models + +import bpp.util.ror + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0484_cerif_walidacja_ror'), + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ] + + operations = [ + migrations.CreateModel( + name='Instytucja_Finansujaca', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ostatnio_zmieniony', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('adnotacje', models.TextField(blank=True, db_index=True, default='', help_text='Pole do użytku wewnętrznego -\n wpisane tu informacje nie są wyświetlane na stronach WWW dostępnych\n dla użytkowników końcowych.')), + ('nazwa', models.TextField(verbose_name='Nazwa')), + ('nazwa_en', models.TextField(blank=True, default='', verbose_name='Nazwa (angielska)')), + ('akronim', models.CharField(blank=True, default='', max_length=50, verbose_name='Akronim')), + ('kraj', models.CharField(default='PL', help_text='Kod ISO 3166-1 alpha-2. Pole wewnętrzne — profil OpenAIRE nie ma elementu Country w encji OrgUnit.', max_length=2, verbose_name='Kraj')), + ('ror_id', models.CharField(blank=True, default='', help_text='Identyfikator w Research Organization Registry (ROR), np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc literówka zostanie odrzucona. Gdy pusty, nie zostanie wyeksportowany.', max_length=64, validators=[bpp.util.ror.waliduj], verbose_name='Identyfikator ROR')), + ('fundref_id', models.CharField(blank=True, default='', help_text='Identyfikator z Crossref Funder Registry, np. 501100004281.', max_length=50, verbose_name='Crossref Funder ID')), + ('strona_www', models.URLField(blank=True, default='', verbose_name='Strona WWW')), + ], + options={ + 'verbose_name': 'instytucja finansująca', + 'verbose_name_plural': 'instytucje finansujące', + 'ordering': ['nazwa'], + }, + ), + migrations.AddField( + model_name='uczelnia', + name='eksport_cerif_kwoty', + field=models.BooleanField(default=False, help_text='Czy w eksporcie CERIF-XML wystawiać kwoty finansowania projektów. Domyślnie wyłączone — kwoty zostają w bazie do użytku wewnętrznego.', verbose_name='Eksport CERIF: kwoty finansowania'), + ), + migrations.CreateModel( + name='Projekt', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slowa_kluczowe_eng', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=255), blank=True, help_text='Lista słów kluczowych -- język angielski', null=True, size=None, verbose_name='Słowa kluczowe -- język angielski')), + ('ostatnio_zmieniony', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('adnotacje', models.TextField(blank=True, db_index=True, default='', help_text='Pole do użytku wewnętrznego -\n wpisane tu informacje nie są wyświetlane na stronach WWW dostępnych\n dla użytkowników końcowych.')), + ('tytul', models.TextField(verbose_name='Tytuł')), + ('tytul_en', models.TextField(blank=True, default='', verbose_name='Tytuł (angielski)')), + ('akronim', models.CharField(blank=True, default='', max_length=50, verbose_name='Akronim')), + ('data_rozpoczecia', models.DateField(blank=True, null=True, verbose_name='Data rozpoczęcia')), + ('data_zakonczenia', models.DateField(blank=True, null=True, verbose_name='Data zakończenia')), + ('status', models.CharField(choices=[('planowany', 'planowany'), ('w-trakcie', 'w trakcie'), ('zakonczony', 'zakończony'), ('przerwany', 'przerwany')], default='w-trakcie', max_length=20, verbose_name='Status')), + ('abstrakt', models.TextField(blank=True, default='', verbose_name='Abstrakt')), + ('abstrakt_en', models.TextField(blank=True, default='', verbose_name='Abstrakt (angielski)')), + ('strona_www', models.URLField(blank=True, default='', verbose_name='Strona WWW')), + ('dyscypliny', models.ManyToManyField(blank=True, to='bpp.dyscyplina_naukowa', verbose_name='Dyscypliny')), + ('jednostka', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bpp.jednostka', verbose_name='Jednostka realizująca')), + ('slowa_kluczowe', taggit.managers.TaggableManager(blank=True, help_text='Lista słów kluczowych -- język polski.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Słowa kluczowe -- język polski')), + ], + options={ + 'verbose_name': 'projekt', + 'verbose_name_plural': 'projekty', + 'ordering': ['-data_rozpoczecia', 'tytul'], + }, + ), + migrations.CreateModel( + name='Finansowanie', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ostatnio_zmieniony', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('adnotacje', models.TextField(blank=True, db_index=True, default='', help_text='Pole do użytku wewnętrznego -\n wpisane tu informacje nie są wyświetlane na stronach WWW dostępnych\n dla użytkowników końcowych.')), + ('typ', models.CharField(choices=[('FundingProgramme', 'program finansowania'), ('Call', 'konkurs'), ('Tender', 'przetarg'), ('Gift', 'darowizna'), ('InternalFunding', 'finansowanie wewnętrzne'), ('Contract', 'umowa'), ('Award', 'nagroda'), ('Grant', 'grant')], default='Grant', max_length=30, verbose_name='Typ')), + ('nazwa_programu', models.CharField(blank=True, default='', help_text='Nazwa programu lub konkursu, np. „OPUS 24”.', max_length=200, verbose_name='Nazwa programu')), + ('numer_umowy', models.CharField(blank=True, default='', max_length=200, verbose_name='Numer umowy')), + ('kwota', models.DecimalField(blank=True, decimal_places=2, max_digits=14, null=True, verbose_name='Kwota')), + ('waluta', models.CharField(blank=True, default='PLN', max_length=3, verbose_name='Waluta')), + ('grant_doi', models.CharField(blank=True, default='', max_length=200, verbose_name='DOI grantu')), + ('instytucja', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bpp.instytucja_finansujaca', verbose_name='Instytucja finansująca')), + ('projekt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bpp.projekt', verbose_name='Projekt')), + ], + options={ + 'verbose_name': 'finansowanie', + 'verbose_name_plural': 'finansowania', + }, + ), + migrations.AddField( + model_name='grant', + name='projekt', + field=models.ForeignKey(blank=True, help_text='Projekt badawczy, w ramach którego przyznano ten grant.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='bpp.projekt', verbose_name='Projekt'), + ), + migrations.CreateModel( + name='Projekt_Autor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rola', models.CharField(choices=[('kierownik', 'kierownik'), ('wykonawca', 'wykonawca'), ('wspolwykonawca', 'współwykonawca')], default='wykonawca', max_length=20, verbose_name='Rola')), + ('od', models.DateField(blank=True, null=True, verbose_name='Od')), + ('do', models.DateField(blank=True, null=True, verbose_name='Do')), + ('autor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bpp.autor', verbose_name='Autor')), + ('projekt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bpp.projekt', verbose_name='Projekt')), + ], + options={ + 'verbose_name': 'osoba w projekcie', + 'verbose_name_plural': 'osoby w projekcie', + 'constraints': [models.UniqueConstraint(condition=models.Q(('rola', 'kierownik')), fields=('projekt',), name='projekt_jeden_kierownik')], + 'unique_together': {('projekt', 'autor', 'rola')}, + }, + ), + ] diff --git a/src/bpp/models/__init__.py b/src/bpp/models/__init__.py index da9103dc2..2116c2bb0 100644 --- a/src/bpp/models/__init__.py +++ b/src/bpp/models/__init__.py @@ -12,6 +12,7 @@ from .praca_doktorska import * # noqa from .praca_habilitacyjna import * # noqa from .profile import * # noqa +from .projekt import * # noqa from .repozytorium import * # noqa from .rodzaj_jednostki import RodzajJednostki # noqa from .rzeczownik import * # noqa diff --git a/src/bpp/models/grant.py b/src/bpp/models/grant.py index cfb5a8d40..6e84362f8 100644 --- a/src/bpp/models/grant.py +++ b/src/bpp/models/grant.py @@ -8,6 +8,16 @@ class Grant(models.Model): zrodlo_finansowania = models.TextField(blank=True, default="") numer_projektu = models.CharField(max_length=200, unique=True) rok = models.PositiveSmallIntegerField(null=True, blank=True) + # SET_NULL, bo skasowanie projektu nie może kasować historycznych numerów + # grantów przypiętych do publikacji. + projekt = models.ForeignKey( + "bpp.Projekt", + models.SET_NULL, + null=True, + blank=True, + verbose_name="Projekt", + help_text="Projekt badawczy, w ramach którego przyznano ten grant.", + ) class Meta: verbose_name = "grant" diff --git a/src/bpp/models/projekt.py b/src/bpp/models/projekt.py new file mode 100644 index 000000000..37afc2b36 --- /dev/null +++ b/src/bpp/models/projekt.py @@ -0,0 +1,243 @@ +"""Encje projektu badawczego i jego finansowania. + +Modele odpowiadają encjom CERIF ``Project`` i ``Funding`` z profilu +OpenAIRE Guidelines for CRIS Managers 1.2.0. Dziedziczenie po +``ModelZAdnotacjami`` nie jest kosmetyczne: daje ``ostatnio_zmieniony``, +na którym ``cerif_export.providers.base.z_datestampem`` buduje keysetowy +kursor przyrostowego harvestu OAI-PMH. +""" + +from django.core.exceptions import ValidationError +from django.db import models + +from bpp.models.abstract import ModelZAdnotacjami, ModelZeSlowamiKluczowymi +from bpp.util.ror import waliduj as waliduj_ror + +__all__ = [ + "Projekt", + "Projekt_Autor", + "Instytucja_Finansujaca", + "Finansowanie", +] + + +class Projekt(ModelZAdnotacjami, ModelZeSlowamiKluczowymi): + STATUS_PLANOWANY = "planowany" + STATUS_W_TRAKCIE = "w-trakcie" + STATUS_ZAKONCZONY = "zakonczony" + STATUS_PRZERWANY = "przerwany" + + STATUSY = [ + (STATUS_PLANOWANY, "planowany"), + (STATUS_W_TRAKCIE, "w trakcie"), + (STATUS_ZAKONCZONY, "zakończony"), + (STATUS_PRZERWANY, "przerwany"), + ] + + tytul = models.TextField(verbose_name="Tytuł") + tytul_en = models.TextField( + verbose_name="Tytuł (angielski)", blank=True, default="" + ) + akronim = models.CharField( + verbose_name="Akronim", max_length=50, blank=True, default="" + ) + data_rozpoczecia = models.DateField( + verbose_name="Data rozpoczęcia", null=True, blank=True + ) + data_zakonczenia = models.DateField( + verbose_name="Data zakończenia", null=True, blank=True + ) + status = models.CharField( + verbose_name="Status", + max_length=20, + choices=STATUSY, + default=STATUS_W_TRAKCIE, + ) + abstrakt = models.TextField(verbose_name="Abstrakt", blank=True, default="") + abstrakt_en = models.TextField( + verbose_name="Abstrakt (angielski)", blank=True, default="" + ) + dyscypliny = models.ManyToManyField( + "bpp.Dyscyplina_Naukowa", blank=True, verbose_name="Dyscypliny" + ) + # Jednostka jest wymagana, bo jest jedynym nośnikiem przynależności + # projektu do uczelni (tenanta). Provider CERIF filtruje po + # ``jednostka__uczelnia``; pole nullable po cichu wypychałoby projekty + # z eksportu. + jednostka = models.ForeignKey( + "bpp.Jednostka", + models.PROTECT, + verbose_name="Jednostka realizująca", + ) + strona_www = models.URLField(verbose_name="Strona WWW", blank=True, default="") + + class Meta: + verbose_name = "projekt" + verbose_name_plural = "projekty" + ordering = ["-data_rozpoczecia", "tytul"] + + def __str__(self): + if self.akronim: + return f"{self.akronim} — {self.tytul}" + return self.tytul + + def clean(self): + if ( + self.data_rozpoczecia + and self.data_zakonczenia + and self.data_zakonczenia < self.data_rozpoczecia + ): + raise ValidationError( + { + "data_zakonczenia": "Data zakończenia jest wcześniejsza " + "niż data rozpoczęcia." + } + ) + + +class Projekt_Autor(models.Model): + ROLA_KIEROWNIK = "kierownik" + ROLA_WYKONAWCA = "wykonawca" + ROLA_WSPOLWYKONAWCA = "wspolwykonawca" + + ROLE = [ + (ROLA_KIEROWNIK, "kierownik"), + (ROLA_WYKONAWCA, "wykonawca"), + (ROLA_WSPOLWYKONAWCA, "współwykonawca"), + ] + + projekt = models.ForeignKey(Projekt, models.CASCADE, verbose_name="Projekt") + autor = models.ForeignKey("bpp.Autor", models.PROTECT, verbose_name="Autor") + rola = models.CharField( + verbose_name="Rola", max_length=20, choices=ROLE, default=ROLA_WYKONAWCA + ) + od = models.DateField(verbose_name="Od", null=True, blank=True) + do = models.DateField(verbose_name="Do", null=True, blank=True) + + class Meta: + verbose_name = "osoba w projekcie" + verbose_name_plural = "osoby w projekcie" + unique_together = [("projekt", "autor", "rola")] + constraints = [ + # Jeden kierownik na projekt — wymuszone w bazie, bo ``clean()`` + # przy dwóch nowych wierszach dodanych naraz w inline widzi + # wyłącznie stan zapisany, więc drugiego kierownika by przepuścił. + models.UniqueConstraint( + fields=["projekt"], + condition=models.Q(rola="kierownik"), + name="projekt_jeden_kierownik", + ) + ] + + def __str__(self): + return f"{self.autor} ({self.get_rola_display()})" + + +class Instytucja_Finansujaca(ModelZAdnotacjami): + """Grantodawca. Eksportowana jako CERIF ``OrgUnit`` (rola Funder).""" + + nazwa = models.TextField(verbose_name="Nazwa") + nazwa_en = models.TextField( + verbose_name="Nazwa (angielska)", blank=True, default="" + ) + akronim = models.CharField( + verbose_name="Akronim", max_length=50, blank=True, default="" + ) + kraj = models.CharField( + verbose_name="Kraj", + max_length=2, + default="PL", + help_text="Kod ISO 3166-1 alpha-2. Pole wewnętrzne — profil " + "OpenAIRE nie ma elementu Country w encji OrgUnit.", + ) + ror_id = models.CharField( + verbose_name="Identyfikator ROR", + max_length=64, + blank=True, + default="", + validators=[waliduj_ror], + help_text="Identyfikator w Research Organization Registry (ROR), " + "np. https://ror.org/016f61126 — ma wbudowaną sumę kontrolną, więc " + "literówka zostanie odrzucona. Gdy pusty, nie zostanie " + "wyeksportowany.", + ) + fundref_id = models.CharField( + verbose_name="Crossref Funder ID", + max_length=50, + blank=True, + default="", + help_text="Identyfikator z Crossref Funder Registry, np. 501100004281.", + ) + strona_www = models.URLField(verbose_name="Strona WWW", blank=True, default="") + + class Meta: + verbose_name = "instytucja finansująca" + verbose_name_plural = "instytucje finansujące" + ordering = ["nazwa"] + + def __str__(self): + if self.akronim: + return f"{self.akronim} — {self.nazwa}" + return self.nazwa + + +class Finansowanie(ModelZAdnotacjami): + """Źródło finansowania projektu. Encja CERIF ``Funding``.""" + + TYP_FUNDING_PROGRAMME = "FundingProgramme" + TYP_CALL = "Call" + TYP_TENDER = "Tender" + TYP_GIFT = "Gift" + TYP_INTERNAL_FUNDING = "InternalFunding" + TYP_CONTRACT = "Contract" + TYP_AWARD = "Award" + TYP_GRANT = "Grant" + + TYPY = [ + (TYP_FUNDING_PROGRAMME, "program finansowania"), + (TYP_CALL, "konkurs"), + (TYP_TENDER, "przetarg"), + (TYP_GIFT, "darowizna"), + (TYP_INTERNAL_FUNDING, "finansowanie wewnętrzne"), + (TYP_CONTRACT, "umowa"), + (TYP_AWARD, "nagroda"), + (TYP_GRANT, "grant"), + ] + + projekt = models.ForeignKey(Projekt, models.CASCADE, verbose_name="Projekt") + typ = models.CharField( + verbose_name="Typ", max_length=30, choices=TYPY, default=TYP_GRANT + ) + instytucja = models.ForeignKey( + Instytucja_Finansujaca, models.PROTECT, verbose_name="Instytucja finansująca" + ) + nazwa_programu = models.CharField( + verbose_name="Nazwa programu", + max_length=200, + blank=True, + default="", + help_text="Nazwa programu lub konkursu, np. „OPUS 24”.", + ) + numer_umowy = models.CharField( + verbose_name="Numer umowy", max_length=200, blank=True, default="" + ) + kwota = models.DecimalField( + verbose_name="Kwota", max_digits=14, decimal_places=2, null=True, blank=True + ) + waluta = models.CharField( + verbose_name="Waluta", max_length=3, blank=True, default="PLN" + ) + grant_doi = models.CharField( + verbose_name="DOI grantu", max_length=200, blank=True, default="" + ) + + class Meta: + verbose_name = "finansowanie" + verbose_name_plural = "finansowania" + + def __str__(self): + return f"{self.instytucja} — {self.get_typ_display()}" + + def clean(self): + if self.kwota is not None and not self.waluta: + raise ValidationError({"waluta": "Kwota bez waluty jest niejednoznaczna."}) diff --git a/src/bpp/models/uczelnia.py b/src/bpp/models/uczelnia.py index 1006adf6c..593b8c8be 100644 --- a/src/bpp/models/uczelnia.py +++ b/src/bpp/models/uczelnia.py @@ -606,6 +606,13 @@ class Uczelnia(ModelZAdnotacjami, ModelZPBN_ID, NazwaISkrot, NazwaWDopelniaczu): "bez ORCID-ów, bez afiliacji i bez historii zatrudnienia. Zestaw " "musi istnieć nawet pusty — wymagają tego wytyczne OpenAIRE.", ) + eksport_cerif_kwoty = models.BooleanField( + verbose_name="Eksport CERIF: kwoty finansowania", + default=False, + help_text="Czy w eksporcie CERIF-XML wystawiać kwoty finansowania " + "projektów. Domyślnie wyłączone — kwoty zostają w bazie " + "do użytku wewnętrznego.", + ) pbn_kasuj_dyscypliny_selektywnie = models.BooleanField( "Kasuj oświadczenia selektywnie (per osoba)", default=True, diff --git a/src/bpp/system.py b/src/bpp/system.py index 8817cf403..086022a0a 100644 --- a/src/bpp/system.py +++ b/src/bpp/system.py @@ -31,10 +31,12 @@ Dyscyplina_Naukowa, Dyscyplina_Zrodla, Element_Repozytorium, + Finansowanie, Funkcja_Autora, Grant, Grant_Rekordu, Grupa_Pracownicza, + Instytucja_Finansujaca, Jednostka, Jezyk, Kierunek_Studiow, @@ -42,6 +44,8 @@ Patent_Autor, Praca_Doktorska, Praca_Habilitacyjna, + Projekt, + Projekt_Autor, Punktacja_Zrodla, Redakcja_Zrodla, Rodzaj_Prawa_Patentowego, @@ -131,6 +135,9 @@ Dyscyplina_Naukowa, Zewnetrzna_Baza_Danych, Grant, + # Słownik grantodawców — jak ``Grant`` wypełniany raz i rzadko + # zmieniany, nie przy okazji wprowadzania publikacji. + Instytucja_Finansujaca, FormFieldRepresentation, FormRepresentation, Grupa_Pracownicza, @@ -183,6 +190,11 @@ Wydawca, Poziom_Wydawcy, Grant_Rekordu, + # Dane wprowadzane przez redakcję na bieżąco — jak ``Grant_Rekordu``, + # w odróżnieniu od słownikowego ``Grant``/``Instytucja_Finansujaca``. + Projekt, + Projekt_Autor, + Finansowanie, Element_Repozytorium, IloscUdzialowDlaAutoraZaRok, RozbieznosciView, diff --git a/src/bpp/tests/test_projekt.py b/src/bpp/tests/test_projekt.py new file mode 100644 index 000000000..79d2fdc7b --- /dev/null +++ b/src/bpp/tests/test_projekt.py @@ -0,0 +1,63 @@ +import pytest +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from model_bakery import baker + +from bpp.models import ( + Finansowanie, + Grant, + Instytucja_Finansujaca, + Projekt, + Projekt_Autor, +) + + +@pytest.mark.django_db +def test_projekt_data_zakonczenia_przed_rozpoczeciem(jednostka): + projekt = Projekt( + tytul="Testowy", + jednostka=jednostka, + data_rozpoczecia="2026-01-01", + data_zakonczenia="2025-12-31", + ) + with pytest.raises(ValidationError): + projekt.clean() + + +@pytest.mark.django_db +def test_finansowanie_kwota_bez_waluty(jednostka): + projekt = baker.make(Projekt, jednostka=jednostka) + instytucja = baker.make(Instytucja_Finansujaca) + finansowanie = Finansowanie( + projekt=projekt, + instytucja=instytucja, + typ=Finansowanie.TYP_GRANT, + kwota=1000, + waluta="", + ) + with pytest.raises(ValidationError): + finansowanie.clean() + + +@pytest.mark.django_db +def test_tylko_jeden_kierownik_projektu(jednostka, autor_jan_nowak, autor_jan_kowalski): + projekt = baker.make(Projekt, jednostka=jednostka) + Projekt_Autor.objects.create( + projekt=projekt, autor=autor_jan_nowak, rola=Projekt_Autor.ROLA_KIEROWNIK + ) + with pytest.raises(IntegrityError): + Projekt_Autor.objects.create( + projekt=projekt, + autor=autor_jan_kowalski, + rola=Projekt_Autor.ROLA_KIEROWNIK, + ) + + +@pytest.mark.django_db +def test_kasowanie_projektu_zostawia_grant(jednostka): + projekt = baker.make(Projekt, jednostka=jednostka) + grant = baker.make(Grant, numer_projektu="ABC/123", projekt=projekt) + projekt.delete() + grant.refresh_from_db() + assert grant.pk is not None + assert grant.projekt is None From 58bd5253ac24e719b719fb54bbf5eb82a66bd7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 10:27:28 +0200 Subject: [PATCH 04/11] =?UTF-8?q?feat(projekty):=20seed=20s=C5=82ownika=20?= =?UTF-8?q?instytucji=20finansuj=C4=85cych=20(#702)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identyfikatory FundRef i ROR potwierdzone krzyżowo w obu rejestrach (Crossref Funder Registry + external_ids rekordu ROR), nie przepisane z pamięci. Co-Authored-By: Claude Opus 5 (1M context) --- .../0486_seed_instytucje_finansujace.py | 133 ++++++++++++++++++ src/bpp/tests/test_seed_instytucji.py | 84 +++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/bpp/migrations/0486_seed_instytucje_finansujace.py create mode 100644 src/bpp/tests/test_seed_instytucji.py diff --git a/src/bpp/migrations/0486_seed_instytucje_finansujace.py b/src/bpp/migrations/0486_seed_instytucje_finansujace.py new file mode 100644 index 000000000..655d79ad4 --- /dev/null +++ b/src/bpp/migrations/0486_seed_instytucje_finansujace.py @@ -0,0 +1,133 @@ +"""Seed słownika instytucji finansujących. + +Identyfikatory NIE są przepisane z pamięci — każdy został odpytany w rejestrze +przy pisaniu tej migracji i potwierdzony **krzyżowo**: Crossref Funder Registry +(``https://api.crossref.org/funders?query=…``) podaje FundRef ID, a rekord ROR +(``https://api.ror.org/organizations/``) wymienia ten sam FundRef ID +w ``external_ids``. Zgodność obu rejestrów jest tu jedynym dowodem, że +identyfikator należy do TEJ instytucji, a nie do innej o podobnej nazwie +(w rejestrach siedzą obok siebie np. NCN i NCBJ, ABM i AMED). + +Instytucja bez potwierdzonego identyfikatora weszłaby bez niego: pusty +identyfikator jest brakiem danych, a zmyślony jest błędem, który wycieknie do +OpenAIRE i sklei nasze publikacje z cudzym profilem grantodawcy. Dla całego +zakresu tej migracji potwierdzenie udało się uzyskać, więc pustych nie ma. + +Dwie decyzje wymagające komentarza: + +* **MNiSW.** Crossref prowadzi ten rekord pod nazwą „Ministerstwo Edukacji + i Nauki" (nazwa z okresu połączenia resortów), a ROR — pod „Ministry of + Science and Higher Education". To ten sam podmiot: ROR ``05dwvd537`` + wymienia FundRef ``501100004569`` i ma alias „Ministerstwo Nauki + i Szkolnictwa Wyższego". W seedzie zostaje aktualna polska nazwa resortu, + bo to ona pojawia się w umowach grantowych. +* **Komisja Europejska** dostaje ``kraj="BE"`` za rejestrem ROR (siedziba + w Brukseli). Pole jest wewnętrzne i nie wychodzi w CERIF — profil OpenAIRE + nie ma elementu ``Country`` w encji ``OrgUnit`` — więc nie ma powodu + naciągać go do kodu rezerwowego ``EU``. + +Adresy WWW pochodzą z pola ``links`` rekordów ROR; dwa poprawiono, bo +rejestrowy adres nie działa (``nauka.gov.pl`` MNiSW-u nie odpowiada — resort +siedzi dziś na ``gov.pl/web/nauka``) albo prowadzi do wersji angielskiej po +``http`` (NCBR). Oba zastępniki sprawdzone: HTTP 200. + +Idempotentna: ``get_or_create`` po ``fundref_id`` (wszystkie pozycje go mają; +gdyby doszła instytucja bez FundRef-a, kluczem zostaje ``nazwa``). Operacja +odwrotna jest no-opem — kasowanie grantodawców przy cofaniu migracji zerwałoby +``PROTECT`` z ``Finansowanie`` i wywaliłoby migrację wstecz na wierszach, +których ta migracja nie tworzyła. +""" + +from django.db import migrations + +#: (nazwa, nazwa_en, akronim, kraj, fundref_id, ror_id, strona_www) +INSTYTUCJE = [ + ( + "Narodowe Centrum Nauki", + "National Science Centre", + "NCN", + "PL", + "501100004281", + "https://ror.org/03ha2q922", + "https://www.ncn.gov.pl", + ), + ( + "Narodowe Centrum Badań i Rozwoju", + "National Centre for Research and Development", + "NCBR", + "PL", + "501100005632", + "https://ror.org/05pwfyy15", + "https://www.ncbr.gov.pl/", + ), + ( + "Ministerstwo Nauki i Szkolnictwa Wyższego", + "Ministry of Science and Higher Education", + "MNiSW", + "PL", + "501100004569", + "https://ror.org/05dwvd537", + "https://www.gov.pl/web/nauka", + ), + ( + "Fundacja na rzecz Nauki Polskiej", + "Foundation for Polish Science", + "FNP", + "PL", + "501100001870", + "https://ror.org/048zd9m77", + "https://fnp.org.pl", + ), + ( + "Narodowa Agencja Wymiany Akademickiej", + "National Agency for Academic Exchange", + "NAWA", + "PL", + "501100014434", + "https://ror.org/02jf81j23", + "https://nawa.gov.pl", + ), + ( + "Agencja Badań Medycznych", + "Medical Research Agency", + "ABM", + "PL", + "501100023181", + "https://ror.org/026nedj88", + "https://abm.gov.pl", + ), + ( + "Komisja Europejska", + "European Commission", + "EC", + "BE", + "501100000780", + "https://ror.org/00k4n6c32", + "https://commission.europa.eu", + ), +] + + +def seed(apps, schema_editor): + Instytucja_Finansujaca = apps.get_model("bpp", "Instytucja_Finansujaca") + for nazwa, nazwa_en, akronim, kraj, fundref_id, ror_id, www in INSTYTUCJE: + klucz = {"fundref_id": fundref_id} if fundref_id else {"nazwa": nazwa} + Instytucja_Finansujaca.objects.get_or_create( + defaults={ + "nazwa": nazwa, + "nazwa_en": nazwa_en, + "akronim": akronim, + "kraj": kraj, + "fundref_id": fundref_id, + "ror_id": ror_id, + "strona_www": www, + }, + **klucz, + ) + + +class Migration(migrations.Migration): + dependencies = [("bpp", "0485_projekt_finansowanie")] + + # Reverse to jawny no-op — uzasadnienie w docstringu modułu. + operations = [migrations.RunPython(seed, migrations.RunPython.noop)] diff --git a/src/bpp/tests/test_seed_instytucji.py b/src/bpp/tests/test_seed_instytucji.py new file mode 100644 index 000000000..cf770fad4 --- /dev/null +++ b/src/bpp/tests/test_seed_instytucji.py @@ -0,0 +1,84 @@ +"""Testy seeda słownika instytucji finansujących (migracja danych 0486). + +Migracja danych jest częścią stanu bazy testowej — pytest-django zakłada bazę +przez ``migrate``, więc wiersze zasiane przez 0486 widać w każdym teście +z ``django_db``. Te testy nie pilnują tego, że seed „się wykonał", tylko że +nie wjechał do niego identyfikator wpisany z pamięci: zły Crossref Funder ID +albo ROR z literówką sklei publikacje uczelni z cudzym profilem grantodawcy +w OpenAIRE, a rekord wyglądający na uzupełniony jest gorszy niż pusty. +""" + +import importlib + +import pytest +from django.apps import apps as rejestr_aplikacji + +from bpp.models import Instytucja_Finansujaca +from bpp.util.ror import poprawny as ror_poprawny + +MIGRACJA = "bpp.migrations.0486_seed_instytucje_finansujace" + +#: Akronimy, których obecności pilnujemy — zakres uzgodniony w planie. +OCZEKIWANE_AKRONIMY = { + "NCN", + "NCBR", + "MNiSW", + "FNP", + "NAWA", + "ABM", + "EC", +} + + +@pytest.mark.django_db +def test_seed_zawiera_ncn(): + ncn = Instytucja_Finansujaca.objects.filter(akronim="NCN").first() + assert ncn is not None + assert ncn.kraj == "PL" + + +@pytest.mark.django_db +def test_seed_zawiera_wszystkie_instytucje_z_zakresu(): + obecne = set( + Instytucja_Finansujaca.objects.filter( + akronim__in=OCZEKIWANE_AKRONIMY + ).values_list("akronim", flat=True) + ) + assert obecne == OCZEKIWANE_AKRONIMY + + +@pytest.mark.django_db +def test_seed_bez_zmyslonych_identyfikatorow(): + # FundRef ID Crossrefa to sam ciąg cyfr (bez URL-a i bez prefiksu). + for instytucja in Instytucja_Finansujaca.objects.exclude(fundref_id=""): + assert instytucja.fundref_id.isdigit(), instytucja.nazwa + + +@pytest.mark.django_db +def test_seed_ma_poprawne_identyfikatory_ror(): + # ROR ma wbudowaną sumę kontrolną — literówka w przepisanym identyfikatorze + # jest tu wykrywalna bez odpytywania sieci. + for instytucja in Instytucja_Finansujaca.objects.exclude(ror_id=""): + assert ror_poprawny(instytucja.ror_id), instytucja.nazwa + + +@pytest.mark.django_db +def test_seed_ma_identyfikatory_dla_calego_zakresu(): + # Każda instytucja z zakresu została potwierdzona w OBU rejestrach — + # gdyby któraś straciła identyfikator przy edycji seeda, chcemy to + # zobaczyć tutaj, a nie w harveście OpenAIRE. + for instytucja in Instytucja_Finansujaca.objects.filter( + akronim__in=OCZEKIWANE_AKRONIMY + ): + assert instytucja.fundref_id, instytucja.nazwa + assert instytucja.ror_id, instytucja.nazwa + + +@pytest.mark.django_db +def test_seed_jest_idempotentny(): + # ``get_or_create`` po ``fundref_id`` — powtórne przejście migracji + # (np. przy odtwarzaniu bazy z dumpu sprzed baseline'u) nie może + # zdublować słownika. + przed = Instytucja_Finansujaca.objects.count() + importlib.import_module(MIGRACJA).seed(rejestr_aplikacji, None) + assert Instytucja_Finansujaca.objects.count() == przed From 7fae6559f5fdca5e695d59e13bbf8009c602ba35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 10:35:22 +0200 Subject: [PATCH 05/11] =?UTF-8?q?feat(projekty):=20admin=20projekt=C3=B3w,?= =?UTF-8?q?=20finansowania=20i=20instytucji=20finansuj=C4=85cych=20(#701)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wprowadzanie danych o projektach badawczych z poziomu admina: ProjektAdmin z inline'ami zespołu i finansowania, osobny słownik instytucji finansujących. Formset zespołu duplikuje bazowy UniqueConstraint „jeden kierownik na projekt", żeby redaktor dostał komunikat walidacji zamiast pięćsetki z IntegrityError. Admin grantu pokazuje i wyszukuje powiązany projekt, ustawienia uczelni dostają przełącznik eksportu kwot finansowania do CERIF. Co-Authored-By: Claude Opus 5 (1M context) --- src/bpp/admin/__init__.py | 1 + src/bpp/admin/grant.py | 12 ++- src/bpp/admin/projekt.py | 153 ++++++++++++++++++++++++++++ src/bpp/admin/uczelnia.py | 1 + src/bpp/tests/test_admin_projekt.py | 153 ++++++++++++++++++++++++++++ 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 src/bpp/admin/projekt.py create mode 100644 src/bpp/tests/test_admin_projekt.py diff --git a/src/bpp/admin/__init__.py b/src/bpp/admin/__init__.py index 519a9c36d..06a1ddd5d 100644 --- a/src/bpp/admin/__init__.py +++ b/src/bpp/admin/__init__.py @@ -50,6 +50,7 @@ from .patent import Patent_Admin # noqa from .praca_doktorska import Praca_DoktorskaAdmin # noqa from .praca_habilitacyjna import Praca_HabilitacyjnaAdmin # noqa +from .projekt import Instytucja_FinansujacaAdmin, ProjektAdmin # noqa from .rodzaj_jednostki import RodzajJednostkiAdmin # noqa from .seria_wydawnicza import Seria_WydawniczaAdmin # noqa from .szablondlaopisubibliograficznego import SzablonDlaOpisuBibliograficznego # noqa diff --git a/src/bpp/admin/grant.py b/src/bpp/admin/grant.py index ef6de988a..958c5d5da 100644 --- a/src/bpp/admin/grant.py +++ b/src/bpp/admin/grant.py @@ -7,8 +7,18 @@ @admin.register(Grant) class GrantAdmin(admin.ModelAdmin): - list_display = ["nazwa_projektu", "numer_projektu", "rok", "zrodlo_finansowania"] + list_display = [ + "nazwa_projektu", + "numer_projektu", + "rok", + "zrodlo_finansowania", + "projekt", + ] search_fields = ["nazwa_projektu", "numer_projektu", "rok", "zrodlo_finansowania"] + # Filtr po projekcie pozwala zobaczyć wszystkie numery grantów jednego + # projektu, a przez pozycję „puste” — te jeszcze do niego nieprzypięte. + list_filter = ["projekt"] + autocomplete_fields = ["projekt"] class Grant_RekorduForm(forms.ModelForm): diff --git a/src/bpp/admin/projekt.py b/src/bpp/admin/projekt.py new file mode 100644 index 000000000..92fa881cf --- /dev/null +++ b/src/bpp/admin/projekt.py @@ -0,0 +1,153 @@ +"""Admin encji projektu badawczego, jego finansowania i grantodawców. + +Wprowadzanie danych o projektach odbywa się z poziomu projektu: zespół +i źródła finansowania są inline'ami, bo w oderwaniu od projektu nie mają +sensu. Słownik instytucji finansujących jest osobnym adminem — jest +współdzielony między projektami i uzupełniany rzadko. +""" + +from django import forms +from django.contrib import admin + +from bpp.models import ( + Finansowanie, + Instytucja_Finansujaca, + Projekt, + Projekt_Autor, +) + +from .core import BaseBppAdminMixin +from .helpers.fieldsets import ADNOTACJE_FIELDSET +from .helpers.mixins import ZapiszZAdnotacjaMixin + + +class Projekt_AutorFormSet(forms.BaseInlineFormSet): + """Duplikuje bazowy ``UniqueConstraint`` na „jeden kierownik na projekt”. + + Baza i tak nie przepuści dwóch kierowników, ale rzucony przez nią + ``IntegrityError`` kończy się dla redaktora pięćsetką i utratą całego + wypełnionego formularza. Tutaj ten sam warunek daje czytelny komunikat + walidacji. Constraint w bazie zostaje jako ostatnia linia obrony — + formset widzi wyłącznie własne wiersze, a dane wchodzą też importem + i przez ORM. + """ + + def clean(self): + super().clean() + kierownicy = 0 + for form in self.forms: + if not form.cleaned_data or form.cleaned_data.get("DELETE"): + continue + if form.cleaned_data.get("rola") == Projekt_Autor.ROLA_KIEROWNIK: + kierownicy += 1 + if kierownicy > 1: + raise forms.ValidationError("Projekt może mieć tylko jednego kierownika.") + + +class Projekt_AutorInline(admin.TabularInline): + model = Projekt_Autor + formset = Projekt_AutorFormSet + extra = 0 + autocomplete_fields = ["autor"] + + +class FinansowanieInline(admin.TabularInline): + model = Finansowanie + extra = 0 + autocomplete_fields = ["instytucja"] + fields = [ + "typ", + "instytucja", + "nazwa_programu", + "numer_umowy", + "kwota", + "waluta", + "grant_doi", + ] + + +@admin.register(Projekt) +class ProjektAdmin(ZapiszZAdnotacjaMixin, BaseBppAdminMixin, admin.ModelAdmin): + list_display = [ + "tytul", + "akronim", + "status", + "data_rozpoczecia", + "data_zakonczenia", + "jednostka", + ] + list_filter = ["status", "jednostka", "dyscypliny"] + # ``search_fields`` jest tu wymagane nie tylko dla wygody: bez niego + # ``autocomplete_fields = ["projekt"]`` w adminie grantu wywala się + # na admin.E040. + search_fields = ["tytul", "tytul_en", "akronim", "abstrakt"] + autocomplete_fields = ["jednostka"] + filter_horizontal = ["dyscypliny"] + inlines = [Projekt_AutorInline, FinansowanieInline] + + fieldsets = ( + ( + None, + { + "fields": ( + "tytul", + "tytul_en", + "akronim", + "status", + "jednostka", + "data_rozpoczecia", + "data_zakonczenia", + "strona_www", + ) + }, + ), + ( + "Opis", + { + "classes": ("grp-collapse grp-open",), + "fields": ( + "abstrakt", + "abstrakt_en", + "dyscypliny", + "slowa_kluczowe", + "slowa_kluczowe_eng", + ), + }, + ), + ADNOTACJE_FIELDSET, + ) + + +@admin.register(Instytucja_Finansujaca) +class Instytucja_FinansujacaAdmin( + ZapiszZAdnotacjaMixin, BaseBppAdminMixin, admin.ModelAdmin +): + list_display = ["nazwa", "akronim", "kraj", "ror_id", "fundref_id"] + list_filter = ["kraj"] + search_fields = ["nazwa", "nazwa_en", "akronim", "ror_id", "fundref_id"] + + fieldsets = ( + ( + None, + { + "fields": ( + "nazwa", + "nazwa_en", + "akronim", + "kraj", + "strona_www", + ) + }, + ), + ( + "Identyfikatory zewnętrzne 🌐", + { + "classes": ("grp-collapse grp-open",), + "fields": ( + "ror_id", + "fundref_id", + ), + }, + ), + ADNOTACJE_FIELDSET, + ) diff --git a/src/bpp/admin/uczelnia.py b/src/bpp/admin/uczelnia.py index d008da3e5..615a497bf 100644 --- a/src/bpp/admin/uczelnia.py +++ b/src/bpp/admin/uczelnia.py @@ -173,6 +173,7 @@ def get_queryset(self, request): "fields": ( "eksport_cerif_wlaczony", "eksport_cerif_osoby", + "eksport_cerif_kwoty", "ror_id", ), }, diff --git a/src/bpp/tests/test_admin_projekt.py b/src/bpp/tests/test_admin_projekt.py new file mode 100644 index 000000000..dd37e9326 --- /dev/null +++ b/src/bpp/tests/test_admin_projekt.py @@ -0,0 +1,153 @@ +"""Testy adminów projektu badawczego, finansowania i instytucji finansujących.""" + +import pytest +from django.contrib import admin +from model_bakery import baker + +from bpp.admin.grant import GrantAdmin +from bpp.admin.projekt import ( + FinansowanieInline, + Instytucja_FinansujacaAdmin, + Projekt_AutorInline, + ProjektAdmin, +) +from bpp.admin.uczelnia import UczelniaAdmin +from bpp.models import Instytucja_Finansujaca, Projekt, Projekt_Autor + +# Prefiks formsetu wyznaczony empirycznie przez ``get_default_prefix()``: +# ``BaseInlineFormSet`` bierze go z nazwy odwrotnego akcesora FK, a że +# ``Projekt_Autor.projekt`` nie ma ``related_name``, wychodzi +# ``projekt_autor_set``. Test ``test_prefiks_formsetu_sie_nie_zmienil`` +# pilnuje, żeby pozostałe testy nie przechodziły przez przypadek na +# błędzie „ManagementForm data is missing". +PREFIKS = "projekt_autor_set" + + +def _formset_inline(rf, admin_user, projekt): + inline = Projekt_AutorInline(Projekt, admin.site) + request = rf.get("/") + # ``get_formset`` pyta o uprawnienia (``can_delete``), więc request + # bez ``user`` wywala się na AttributeError. + request.user = admin_user + return inline.get_formset(request, projekt) + + +def _dane(*wiersze): + dane = { + f"{PREFIKS}-TOTAL_FORMS": str(len(wiersze)), + f"{PREFIKS}-INITIAL_FORMS": "0", + f"{PREFIKS}-MIN_NUM_FORMS": "0", + f"{PREFIKS}-MAX_NUM_FORMS": "1000", + } + for nr, (autor, rola) in enumerate(wiersze): + dane[f"{PREFIKS}-{nr}-autor"] = str(autor.pk) + dane[f"{PREFIKS}-{nr}-rola"] = rola + return dane + + +@pytest.mark.django_db +def test_prefiks_formsetu_sie_nie_zmienil(rf, admin_user, jednostka): + projekt = baker.make(Projekt, jednostka=jednostka) + assert _formset_inline(rf, admin_user, projekt).get_default_prefix() == PREFIKS + + +@pytest.mark.django_db +def test_inline_odrzuca_dwoch_kierownikow( + rf, admin_user, jednostka, autor_jan_nowak, autor_jan_kowalski +): + projekt = baker.make(Projekt, jednostka=jednostka) + FormSet = _formset_inline(rf, admin_user, projekt) + formset = FormSet( + _dane( + (autor_jan_nowak, Projekt_Autor.ROLA_KIEROWNIK), + (autor_jan_kowalski, Projekt_Autor.ROLA_KIEROWNIK), + ), + instance=projekt, + ) + assert not formset.is_valid() + # Asercja na treść błędu, nie na sam ``False`` -- inaczej test + # przeszedłby również wtedy, gdyby formset odrzucił dane z powodu + # rozjechanego prefiksu (brakujący ManagementForm). + assert any("kierownika" in blad for blad in formset.non_form_errors()) + + +@pytest.mark.django_db +def test_inline_przepuszcza_kierownika_i_wykonawce( + rf, admin_user, jednostka, autor_jan_nowak, autor_jan_kowalski +): + projekt = baker.make(Projekt, jednostka=jednostka) + FormSet = _formset_inline(rf, admin_user, projekt) + formset = FormSet( + _dane( + (autor_jan_nowak, Projekt_Autor.ROLA_KIEROWNIK), + (autor_jan_kowalski, Projekt_Autor.ROLA_WYKONAWCA), + ), + instance=projekt, + ) + assert formset.is_valid(), formset.errors + + +@pytest.mark.django_db +def test_inline_ignoruje_kierownika_skasowanego( + rf, admin_user, jednostka, autor_jan_nowak, autor_jan_kowalski +): + # Wiersz zaznaczony do skasowania nie może blokować nowego kierownika. + projekt = baker.make(Projekt, jednostka=jednostka) + Projekt_Autor.objects.create( + projekt=projekt, autor=autor_jan_nowak, rola=Projekt_Autor.ROLA_KIEROWNIK + ) + istniejacy = Projekt_Autor.objects.get(projekt=projekt) + FormSet = _formset_inline(rf, admin_user, projekt) + dane = { + f"{PREFIKS}-TOTAL_FORMS": "2", + f"{PREFIKS}-INITIAL_FORMS": "1", + f"{PREFIKS}-MIN_NUM_FORMS": "0", + f"{PREFIKS}-MAX_NUM_FORMS": "1000", + f"{PREFIKS}-0-id": str(istniejacy.pk), + f"{PREFIKS}-0-autor": str(autor_jan_nowak.pk), + f"{PREFIKS}-0-rola": Projekt_Autor.ROLA_KIEROWNIK, + f"{PREFIKS}-0-DELETE": "on", + f"{PREFIKS}-1-autor": str(autor_jan_kowalski.pk), + f"{PREFIKS}-1-rola": Projekt_Autor.ROLA_KIEROWNIK, + } + formset = FormSet(dane, instance=projekt) + assert formset.is_valid(), formset.errors + + +@pytest.mark.django_db +def test_projekt_admin_ma_search_fields(): + # Bez ``search_fields`` autocomplete w innych adminach nie zadziała. + assert ProjektAdmin.search_fields + + +def test_instytucja_finansujaca_admin_ma_search_fields(): + assert Instytucja_FinansujacaAdmin.search_fields + + +def test_projekt_i_instytucja_zarejestrowane_w_adminie(): + assert isinstance(admin.site._registry[Projekt], ProjektAdmin) + assert isinstance( + admin.site._registry[Instytucja_Finansujaca], Instytucja_FinansujacaAdmin + ) + + +def test_projekt_admin_ma_oba_inline(): + assert Projekt_AutorInline in ProjektAdmin.inlines + assert FinansowanieInline in ProjektAdmin.inlines + + +def test_grant_admin_pokazuje_i_wyszukuje_projekt(): + assert "projekt" in GrantAdmin.list_display + assert "projekt" in GrantAdmin.autocomplete_fields + + +def test_uczelnia_admin_ma_przelacznik_kwot(): + # Przełącznik musi siedzieć w tym samym fieldsecie, co reszta ustawień + # eksportu CERIF -- inaczej redaktor go nie znajdzie. + for _nazwa, opcje in UczelniaAdmin.fieldsets: + pola = opcje["fields"] + if "eksport_cerif_osoby" in pola: + assert "eksport_cerif_kwoty" in pola + break + else: + pytest.fail("Nie znaleziono fieldsetu z ustawieniami eksportu CERIF") From eb7142ab1f2fc8dd450f9b9fde1ef6333563af04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 10:36:19 +0200 Subject: [PATCH 06/11] docs(eurocris): faktyczny numer migracji fundamentu w planie Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-05-eurocris-projekt-finansowanie.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-08-05-eurocris-projekt-finansowanie.md b/docs/superpowers/plans/2026-08-05-eurocris-projekt-finansowanie.md index 0316a3a5d..0b76df351 100644 --- a/docs/superpowers/plans/2026-08-05-eurocris-projekt-finansowanie.md +++ b/docs/superpowers/plans/2026-08-05-eurocris-projekt-finansowanie.md @@ -35,7 +35,7 @@ Fundament — wszystkie pozostałe zadania z niego korzystają. Wykonywane **sek - Create: `src/bpp/models/projekt.py` - Modify: `src/bpp/models/__init__.py`, `src/bpp/models/grant.py`, `src/bpp/models/uczelnia.py`, `src/bpp/system.py` - Create: `src/bpp/tests/test_projekt.py` -- Create (przez `makemigrations`): `src/bpp/migrations/XXXX_projekt_finansowanie.py` +- Create (przez `makemigrations`): `src/bpp/migrations/0485_projekt_finansowanie.py` **Interfaces:** - Produces: modele `Projekt`, `Projekt_Autor`, `Instytucja_Finansujaca`, `Finansowanie` w `bpp.models`; stałe `Projekt.STATUS_*`, `Projekt_Autor.ROLA_KIEROWNIK`, `Finansowanie.TYP_*`; pola `Grant.projekt`, `Uczelnia.eksport_cerif_kwoty`. @@ -665,12 +665,12 @@ def seed(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [("bpp", "XXXX_projekt_finansowanie")] + dependencies = [("bpp", "0485_projekt_finansowanie")] operations = [migrations.RunPython(seed, migrations.RunPython.noop)] ``` -Podmień `XXXX_projekt_finansowanie` na faktyczną nazwę migracji z Taska 1 i wypełnij `INSTYTUCJE` danymi ze Stepu 1. +Podmień `0485_projekt_finansowanie` na faktyczną nazwę migracji z Taska 1 i wypełnij `INSTYTUCJE` danymi ze Stepu 1. - [ ] **Step 5: Uruchom testy** From d0a8421cfad4d374d076eb8275ba0361f8d769ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 10:39:17 +0200 Subject: [PATCH 07/11] =?UTF-8?q?feat(cerif):=20instytucje=20finansuj?= =?UTF-8?q?=C4=85ce=20jako=20OrgUnit=20+=20fundament=20dla=20Project/Fundi?= =?UTF-8?q?ng=20(#702)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stałe TYP_PROJECT/TYP_FUNDING i schematy klasyfikacji własnych, wpisy pj/fn/if w rejestrze identyfikatorów, słownik typów finansowania wg vocabularies/openaire_funding_types.xsd oraz eksport grantodawcy jako CERIF OrgUnit (RORID, FundRefID, ElectronicAddress). Co-Authored-By: Claude Opus 5 (1M context) --- src/cerif_export/cerif/orgunit.py | 107 +++++- src/cerif_export/const.py | 18 + src/cerif_export/identyfikatory.py | 6 + src/cerif_export/oai/czasowniki.py | 4 + src/cerif_export/providers/jednostki.py | 41 +- .../slowniki/typy_finansowania.py | 59 +++ .../tests/test_funderzy_orgunit.py | 353 ++++++++++++++++++ 7 files changed, 572 insertions(+), 16 deletions(-) create mode 100644 src/cerif_export/slowniki/typy_finansowania.py create mode 100644 src/cerif_export/tests/test_funderzy_orgunit.py diff --git a/src/cerif_export/cerif/orgunit.py b/src/cerif_export/cerif/orgunit.py index 182794bbf..77eab6e0f 100644 --- a/src/cerif_export/cerif/orgunit.py +++ b/src/cerif_export/cerif/orgunit.py @@ -1,41 +1,93 @@ -"""Serializacja ``bpp.Jednostka`` i ``bpp.Uczelnia`` do encji CERIF ``OrgUnit``. +"""Serializacja ``bpp.Jednostka``, ``bpp.Uczelnia`` i +``bpp.Instytucja_Finansujaca`` do encji CERIF ``OrgUnit``. -Oba modele trafiają do setu ``openaire_cris_orgunits`` i mają w profilu tę samą -encję — różnią się tylko tym, czy mają nadrzędną jednostkę. +Wszystkie trzy modele trafiają do setu ``openaire_cris_orgunits`` i mają +w profilu tę samą encję. Jednostka i uczelnia różnią się tylko tym, czy mają +nadrzędną jednostkę; grantodawca jest tu, bo profil nie ma osobnej encji +instytucji finansującej — ``Funding/Funder`` wskazuje na ``OrgUnit``. Kolejność wymuszona przez ``xs:sequence``: ``Type``, ``Acronym``, ``Name``, -grupa identyfikatorów jednostki, ``ElectronicAddress``, ``PartOf``. +grupa ``OrgUnitIdentifiers__Group``, ``ElectronicAddress``, ``PartOf``. +Wewnątrz grupy identyfikatorów kolejność też jest wymuszona: +``RORID``, ``AlternativeRORID``, ``GRID``, ``AlternativeGRID``, ``ISNI``, +``AlternativeISNI``, ``FundRefID``, ``AlternativeFundRefID`` i dopiero na +końcu generyczny ``Identifier`` (``includes/orgunit-identifiers.xsd``). +Dlatego wartości zapasowe, które nie zmieściły się w dedykowanych +elementach, dopisujemy **po** wszystkich dedykowanych, a nie w miejscu, +w którym powstały. + +Profil **nie ma** w sekwencji ``OrgUnit`` elementu ``Country`` — +``Instytucja_Finansujaca.kraj`` zostaje danymi wewnętrznymi. Provider musi dostarczyć: ``select_related("parent", "uczelnia")`` dla ``Jednostka``. """ +import re + +from bpp.models.projekt import Instytucja_Finansujaca from bpp.util import ror from cerif_export.cerif import wspolne from cerif_export.cerif.wspolne import dodaj, dodaj_kontener, element, tekst TYP_ID_ROR = "https://w3id.org/cerif/vocab/IdentifierTypes#RORID" TYP_ID_PBN_INSTYTUCJA = "https://pbn.nauka.gov.pl/core/#/institution" +TYP_ID_FUNDREF = "https://w3id.org/cerif/vocab/IdentifierTypes#FundRefID" +# ``FundRefID__Type`` dopuszcza wyłącznie pełny DOI rejestru Crossrefa +# (``https://doi.org/10.13039/``). W BPP pole trzyma sam numer, bo +# tak podaje go Crossref w wynikach wyszukiwania i tak wpisuje go redakcja — +# prefiks doklejamy tutaj, żeby dane w bazie nie zależały od formatu XML-a. +PREFIKS_FUNDREF = "https://doi.org/10.13039/" +_WZ_FUNDREF_SUROWY = re.compile( + r"^(?:https?://(?:dx\.)?doi\.org/)?(?:10\.13039/)?(?P\d+)/?$" +) -def dodaj_ror(el, jednostka): - """Dopisz ``RORID``; wartość spoza wzorca profilu → ``Identifier``. + +def _rozstrzygnij_ror(obj): + """Zwróć ``(do_RORID, do_Identifier)`` dla surowego pola ``ror_id``. ``RORID__Type`` wymaga pełnego ``https://ror.org/<9 znaków>`` z alfabetem Crockforda. Redakcja bywa wpisuje sam sufiks albo adres z ukośnikiem na końcu — normalizujemy prefiks, a to, co i tak nie pasuje, odkładamy do generycznego ``Identifier``, żeby nie unieważnić rekordu wobec XSD. """ - surowy = tekst(getattr(jednostka, "ror_id", None)) + surowy = tekst(getattr(obj, "ror_id", None)) if surowy is None: - return None + return None, None # Normalizacja z `bpp.util.ror` — ta sama, której używa walidacja pola. # Ręczne sklejanie prefiksu nie łykało `www.` ani wielkich liter, więc # poprawny ROR wpisany w innej postaci lądował w generycznym Identifier. uri = ror.normalizuj(surowy) if wspolne.pasuje(wspolne.WZ_ROR, uri) is not None: + return uri, None + return None, uri + + +def _rozstrzygnij_fundref(obj): + """Zwróć ``(do_FundRefID, do_Identifier)`` dla pola ``fundref_id``. + + Akceptujemy sam numer, numer z prefiksem DOI oraz pełny URL (także + historyczny ``dx.doi.org`` i ``http``) — wszystko sprowadzamy do postaci + wymaganej przez profil. Wartość, z której nie da się wyłuskać numeru, + idzie do generycznego ``Identifier``: to nadal informacja, a nie powód, + żeby unieważnić cały rekord wobec XSD. + """ + surowy = tekst(getattr(obj, "fundref_id", None)) + if surowy is None: + return None, None + dopasowanie = _WZ_FUNDREF_SUROWY.match(surowy) + if dopasowanie is None: + return None, surowy + return PREFIKS_FUNDREF + dopasowanie.group("numer"), None + + +def dodaj_ror(el, jednostka): + """Dopisz ``RORID``; wartość spoza wzorca profilu → ``Identifier``.""" + uri, zapasowy = _rozstrzygnij_ror(jednostka) + if uri is not None: return dodaj(el, "RORID", uri) - return dodaj(el, "Identifier", uri, type=TYP_ID_ROR) + return dodaj(el, "Identifier", zapasowy, type=TYP_ID_ROR) def dodaj_identyfikator_pbn(el, jednostka): @@ -79,8 +131,43 @@ def dodaj_part_of(el, jednostka, ctx): return part_of +def serializuj_grantodawce(instytucja, ctx): + """``bpp.Instytucja_Finansujaca`` → element ``OrgUnit``. + + Nazwa polska idzie **bez** ``xml:lang``, dokładnie tak, jak wychodzi + z :func:`cerif_export.cerif.wspolne.osadz_orgunit`. Kontrola 5b + walidatora wymaga, żeby encja osadzona (``Funding/Funder``, + ``Project/Funded/By``) była podzbiorem pełnego rekordu — a osadzenie + jest generyczne i o językach nic nie wie. Oznaczenie języka wyłącznie + na nazwie angielskiej zachowuje ten podzbiór i nadal jednoznacznie + mówi, która nazwa jest która. + """ + el = element("OrgUnit", nsmap=wspolne.NSMAP_REKORDU) + wspolne.ustaw_id_rekordu(el, instytucja, ctx) + + dodaj(el, "Acronym", tekst(instytucja.akronim)) + dodaj(el, "Name", tekst(instytucja.nazwa)) + dodaj(el, "Name", tekst(instytucja.nazwa_en), jezyk="en") + + # Kolejność wewnątrz ``OrgUnitIdentifiers__Group``: dedykowane elementy + # (RORID przed FundRefID), a generyczny ``Identifier`` dopiero po nich. + ror_uri, ror_zapasowy = _rozstrzygnij_ror(instytucja) + fundref_uri, fundref_zapasowy = _rozstrzygnij_fundref(instytucja) + dodaj(el, "RORID", ror_uri) + dodaj(el, "FundRefID", fundref_uri) + dodaj(el, "Identifier", ror_zapasowy, type=TYP_ID_ROR) + dodaj(el, "Identifier", fundref_zapasowy, type=TYP_ID_FUNDREF) + + dodaj(el, "ElectronicAddress", tekst(instytucja.strona_www)) + + return el + + def serializuj(jednostka, ctx): - """``bpp.Jednostka`` albo ``bpp.Uczelnia`` → element ``OrgUnit``.""" + """``Jednostka``, ``Uczelnia`` albo grantodawca → element ``OrgUnit``.""" + if isinstance(jednostka, Instytucja_Finansujaca): + return serializuj_grantodawce(jednostka, ctx) + el = element("OrgUnit", nsmap=wspolne.NSMAP_REKORDU) wspolne.ustaw_id_rekordu(el, jednostka, ctx) diff --git a/src/cerif_export/const.py b/src/cerif_export/const.py index d3ebb53ac..6c11aeb2c 100644 --- a/src/cerif_export/const.py +++ b/src/cerif_export/const.py @@ -59,12 +59,30 @@ def nazwa_setu(set_spec: str) -> str: # Typy encji CERIF używane jako człon identyfikatora OAI. +# +# Wartością jest nazwa elementu XSD w LICZBIE MNOGIEJ (``Publication`` → +# ``Publications``). Tak wymaga ``openaire-cris-validator``: sprawdza, że +# nagłówkowy ```` równa się ``"oai:" + repoId + ":" + localName +# + "s/" + @id`` — patrz docstring ``cerif/wspolne.py``. Liczba pojedyncza +# przechodzi tylko przez drugą, luźniejszą gałąź tej kontroli; nie ma powodu +# opierać się na niej dla nowych encji. TYP_PUBLICATION = "Publications" TYP_PERSON = "Persons" TYP_ORGUNIT = "OrgUnits" TYP_PATENT = "Patents" TYP_EVENT = "Events" TYP_SERVICE = "Services" +TYP_PROJECT = "Projects" +TYP_FUNDING = "Fundings" + +# Schematy klasyfikacji własnych. ``cfGenericURIClassification__Type`` +# (cerif-commons.xsd) wymaga atrybutu ``scheme`` (anyURI) i wartości będącej +# URI, a profil OpenAIRE nie dostarcza słownika ani dla statusu projektu, ani +# dla polskich dyscyplin naukowych. Własny schemat jawnie nazywa pochodzenie +# wartości — lepiej niż pominięcie danych i lepiej niż podszywanie się pod +# cudzy słownik. +SCHEMAT_STATUSU_PROJEKTU = "https://bpp.iplweb.pl/vocab/StatusProjektu" +SCHEMAT_DYSCYPLIN = "https://bpp.iplweb.pl/vocab/DyscyplinaNaukowa" # Format datestampów OAI-PMH deklarowany w Identify. Wszystkie znaczniki # czasu wychodzą w UTC w tym formacie. diff --git a/src/cerif_export/identyfikatory.py b/src/cerif_export/identyfikatory.py index f769415dd..86d562b84 100644 --- a/src/cerif_export/identyfikatory.py +++ b/src/cerif_export/identyfikatory.py @@ -38,6 +38,12 @@ class BlednyIdentyfikator(ValueError): "bpp.Uczelnia": (const.TYP_ORGUNIT, "uc"), "bpp.Patent": (const.TYP_PATENT, "pt"), "bpp.Konferencja": (const.TYP_EVENT, "kf"), + "bpp.Projekt": (const.TYP_PROJECT, "pj"), + "bpp.Finansowanie": (const.TYP_FUNDING, "fn"), + # Instytucja finansująca dostaje typ OrgUnit, nie własny: profil nie ma + # osobnej encji grantodawcy, a ``Funding/Funder`` wskazuje na OrgUnit-a. + # Wychodzi więc w secie ``openaire_cris_orgunits``. + "bpp.Instytucja_Finansujaca": (const.TYP_ORGUNIT, "if"), } SLUGI_WG_ETYKIETY = {etykieta: slug for etykieta, (_, slug) in _REJESTR.items()} diff --git a/src/cerif_export/oai/czasowniki.py b/src/cerif_export/oai/czasowniki.py index d0b0a704c..7c365ab19 100644 --- a/src/cerif_export/oai/czasowniki.py +++ b/src/cerif_export/oai/czasowniki.py @@ -77,6 +77,10 @@ "au": "person", "je": "orgunit", "uc": "orgunit", + # Instytucja finansująca wychodzi jako OrgUnit — profil nie ma osobnej + # encji grantodawcy. Bez tego wpisu ``ListRecords`` dla setu OrgUnits + # wywalałby się na pierwszym grantodawcy zwróconym przez provider. + "if": "orgunit", "pt": "patent", "kf": "event", } diff --git a/src/cerif_export/providers/jednostki.py b/src/cerif_export/providers/jednostki.py index 27243df06..efd4bdad1 100644 --- a/src/cerif_export/providers/jednostki.py +++ b/src/cerif_export/providers/jednostki.py @@ -1,11 +1,17 @@ """Provider setu ``openaire_cris_orgunits``. -Set łączy dwa modele: :class:`bpp.models.jednostka.Jednostka` (slug ``je``) -oraz :class:`bpp.models.uczelnia.Uczelnia` (slug ``uc``). Uczelnia wychodzi -zawsze — i to dokładnie jedna, ta bieżąca — bo jest właścicielem całego -repozytorium (``Service/Owner`` w ``Identify``) i korzeniem drzewa +Set łączy trzy modele: :class:`bpp.models.jednostka.Jednostka` (slug ``je``), +:class:`bpp.models.uczelnia.Uczelnia` (slug ``uc``) oraz +:class:`bpp.models.projekt.Instytucja_Finansujaca` (slug ``if``). Uczelnia +wychodzi zawsze — i to dokładnie jedna, ta bieżąca — bo jest właścicielem +całego repozytorium (``Service/Owner`` w ``Identify``) i korzeniem drzewa ``PartOf`` jednostek. +Grantodawcy są tu, bo profil nie ma osobnej encji grantodawcy: ``Funding/Funder`` +i ``Project/Funded/By`` wskazują na ``OrgUnit``. Gdyby instytucja +finansująca nie wychodziła w tym secie, referencja byłaby wisząca i +walidator odrzuciłby harvest (kontrola integralności referencyjnej 5a). + ``PartOf`` bierzemy z MPTT-owego ``Jednostka.parent``, nie z ``Jednostka_Rodzic``. Ta druga to **datowana metryczka historyczna** (wiersze ``od``/``do``, wiele na jednostkę), z której denorm wylicza dopiero ``parent`` @@ -14,6 +20,7 @@ """ from bpp.models.jednostka import Jednostka +from bpp.models.projekt import Instytucja_Finansujaca from bpp.models.uczelnia import Uczelnia from cerif_export import const from cerif_export.identyfikatory import BlednyIdentyfikator @@ -48,6 +55,25 @@ def widoczne_jednostki(uczelnia): ) +def widoczni_grantodawcy(uczelnia): + """Instytucje finansujące eksportowane dla tej uczelni — bez prefetchy. + + Wychodzą wyłącznie grantodawcy realnie finansujący projekty tej uczelni. + Słownik instytucji jest współdzielony przez wszystkich tenantów i ma + kilkanaście pozycji z seeda; wypchnięcie go w całości pokazywałoby + OpenAIRE instytucje, z którymi uczelnia nie ma nic wspólnego — i to + w każdym z harvestów tak samo, więc agregator nie miałby jak odróżnić + realnego powiązania od słownikowego balastu. + + Przynależność do tenanta niesie ``Projekt.jednostka`` — to jedyny + nośnik atrybucji projektu do uczelni. + """ + wymagaj_uczelni(uczelnia) + return Instytucja_Finansujaca.objects.filter( + finansowanie__projekt__jednostka__uczelnia=uczelnia + ).distinct() + + def widoczne_pk(queryset, kandydaci) -> frozenset: """Przetnij zbiór kandydatów z querysetem widoczności — jednym zapytaniem. @@ -61,11 +87,11 @@ def widoczne_pk(queryset, kandydaci) -> frozenset: class ProviderJednostek(ProviderEncji): - """Jednostki organizacyjne uczelni + sama uczelnia.""" + """Jednostki organizacyjne uczelni, sama uczelnia i jej grantodawcy.""" set_spec = const.SET_ORGUNITS typ_cerif = const.TYP_ORGUNIT - modele = [Jednostka, Uczelnia] + modele = [Jednostka, Uczelnia, Instytucja_Finansujaca] def queryset(self, uczelnia, model): wymagaj_uczelni(uczelnia) @@ -84,6 +110,9 @@ def queryset(self, uczelnia, model): # instalacji multi-hosted w bazie siedzą też cudze uczelnie. return Uczelnia.objects.filter(pk=uczelnia.pk).select_related("site") + if model is Instytucja_Finansujaca: + return widoczni_grantodawcy(uczelnia) + raise BlednyIdentyfikator(f"Model {model!r} nie należy do setu {self.set_spec}") def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: diff --git a/src/cerif_export/slowniki/typy_finansowania.py b/src/cerif_export/slowniki/typy_finansowania.py new file mode 100644 index 000000000..3e64f115f --- /dev/null +++ b/src/cerif_export/slowniki/typy_finansowania.py @@ -0,0 +1,59 @@ +"""Słownik kontrolowany typów finansowania (``Funding/Type``). + +``Funding/Type`` jest w profilu **obowiązkowy** i ograniczony enumeracją +z pliku schematu ``vocabularies/openaire_funding_types.xsd`` (vendorowany +w ``cerif_export/tests/xsd/``). Osiem wartości, każda jako URI postaci +``#``. + +W przeciwieństwie do słownika COAR **nie ma tu fallbacku**. Tam wartością +zapasową jest korzeń hierarchii — termin ogólny, ale prawdziwy. Tu każda +z ośmiu wartości niesie konkretne twierdzenie o naturze finansowania +(darowizna to nie przetarg), więc zgadywanie byłoby wpisywaniem nieprawdy. +Nieznana wartość daje ``None``, a decyzję co z tym zrobić podejmuje +serializer — który i tak dostaje dane z pola z ``choices``, więc do +``None`` dochodzi tylko przy ręcznej podmianie w bazie albo po zmianie +słownika w modelu bez aktualizacji tego pliku. + +Funkcja jest **czysta** — nie dotyka bazy. +""" + +SCHEMAT = "https://www.openaire.eu/cerif-profile/vocab/OpenAIRE_Funding_Types" + +# Kod → angielski termin ze schematu. Termin nie bierze udziału w walidacji +# (ta idzie po kluczach), służy dokumentacji i czytelności raportu mapowań. +TYPY = { + "FundingProgramme": "Funding Programme", + "Call": "Call", + "Tender": "Tender", + "Gift": "Gift", + "InternalFunding": "Internal Funding", + "Contract": "Contract", + "Award": "Award", + "Grant": "Grant", +} + +WSZYSTKIE = frozenset(f"{SCHEMAT}#{kod}" for kod in TYPY) + + +def uri_typu(typ: str) -> str | None: + """Zwróć URI typu finansowania albo ``None`` dla nieznanej wartości. + + Porównanie jest **wrażliwe na wielkość liter** — kody w enumeracji są + w CamelCase i schemat XSD porównuje je dosłownie, więc ``"grant"`` + dałoby dokument niepoprawny wobec profilu. + """ + if not typ: + return None + + kod = typ.strip() + if kod not in TYPY: + return None + return f"{SCHEMAT}#{kod}" + + +def znany(uri) -> bool: + """Czy URI należy do enumeracji typów finansowania z profilu?""" + if not uri: + return False + + return uri.strip() in WSZYSTKIE diff --git a/src/cerif_export/tests/test_funderzy_orgunit.py b/src/cerif_export/tests/test_funderzy_orgunit.py new file mode 100644 index 000000000..e388099f4 --- /dev/null +++ b/src/cerif_export/tests/test_funderzy_orgunit.py @@ -0,0 +1,353 @@ +"""Instytucje finansujące jako CERIF ``OrgUnit`` + fundament eksportu. + +``Funding/Funder`` i ``Project/Funded/By`` wskazują na OrgUnit-a, więc +grantodawca **musi** wyjść w secie ``openaire_cris_orgunits`` — inaczej +referencja jest wisząca i walidator odrzuca cały harvest (kontrola 5a). + +Zakres modułu to fundament z Taska 4 planu: stałe typów, rejestr +identyfikatorów, słownik typów finansowania i sam eksport grantodawcy. +""" + +import pytest +from django.contrib.sites.models import Site +from lxml import etree +from model_bakery import baker + +from bpp.models import ( + Finansowanie, + Instytucja_Finansujaca, + Jednostka, + Projekt, + Uczelnia, +) +from cerif_export import const, identyfikatory +from cerif_export.cerif import orgunit, wspolne +from cerif_export.kontekst import KontekstSerializacji +from cerif_export.oai import czasowniki +from cerif_export.providers import provider_dla_modelu, provider_dla_setu +from cerif_export.providers.jednostki import ProviderJednostek +from cerif_export.slowniki import typy_finansowania + +NAMESPACE = "cerif.example.org" + + +def q(nazwa): + """Nazwa elementu z przestrzeni nazw profilu.""" + return f"{{{const.NS_CERIF}}}{nazwa}" + + +def kontekst(uczelnia, obiekty): + provider = provider_dla_setu(const.SET_ORGUNITS) + return KontekstSerializacji( + namespace=NAMESPACE, + uczelnia=uczelnia, + widoczne=provider.zbiory_widocznosci(uczelnia, obiekty), + ) + + +def zbuduj_finansowanie(jednostka, instytucja, **kwargs): + projekt = baker.make(Projekt, jednostka=jednostka) + return baker.make(Finansowanie, projekt=projekt, instytucja=instytucja, **kwargs) + + +# -- stałe i rejestr identyfikatorów ------------------------------------- + + +def test_stale_typow_projektu_i_finansowania(): + # Człon typu w identyfikatorze OAI to nazwa elementu XSD w liczbie + # mnogiej — patrz reguła walidatora opisana w ``cerif/wspolne.py``. + assert const.TYP_PROJECT == "Projects" + assert const.TYP_FUNDING == "Fundings" + + +def test_schematy_klasyfikacji_wlasnych_sa_uri(): + for schemat in (const.SCHEMAT_STATUSU_PROJEKTU, const.SCHEMAT_DYSCYPLIN): + assert schemat.startswith("https://") + + +def test_rejestr_zna_nowe_modele(): + assert identyfikatory.slug_dla(Projekt) == "pj" + assert identyfikatory.slug_dla(Finansowanie) == "fn" + assert identyfikatory.slug_dla(Instytucja_Finansujaca) == "if" + + # Grantodawca jest OrgUnit-em, nie osobnym typem encji. + assert identyfikatory.typ_dla(Instytucja_Finansujaca) == const.TYP_ORGUNIT + assert identyfikatory.typ_dla(Projekt) == const.TYP_PROJECT + assert identyfikatory.typ_dla(Finansowanie) == const.TYP_FUNDING + + +def test_identyfikator_grantodawcy_daje_sie_rozebrac(): + oai_id = identyfikatory.zbuduj_z_czesci(NAMESPACE, "if", 7) + assert oai_id == f"oai:{NAMESPACE}:OrgUnits/if-7" + assert identyfikatory.rozbierz(oai_id) == ( + NAMESPACE, + Instytucja_Finansujaca, + 7, + ) + + +def test_slug_grantodawcy_ma_serializer(): + # Bez wpisu w ``MODULY_SERIALIZERA`` ``ListRecords`` dla setu OrgUnits + # wywaliłby się na pierwszym grantodawcy. + assert czasowniki.serializer_dla("if") is orgunit.serializuj + + +# -- słownik typów finansowania ------------------------------------------ + + +def test_slownik_typow_ma_komplet_wartosci(): + # Osiem wartości z ``vocabularies/openaire_funding_types.xsd``. + assert len(typy_finansowania.TYPY) == 8 + + +def test_uri_typu_dla_kazdej_wartosci_modelu(): + for wartosc, _etykieta in Finansowanie.TYPY: + uri = typy_finansowania.uri_typu(wartosc) + assert uri is not None, wartosc + assert uri == f"{typy_finansowania.SCHEMAT}#{wartosc}" + + +@pytest.mark.parametrize("wejscie", [None, "", "Nieznany", "grant"]) +def test_uri_typu_odrzuca_nieznane(wejscie): + assert typy_finansowania.uri_typu(wejscie) is None + + +# -- provider ------------------------------------------------------------ + + +@pytest.mark.django_db +def test_funder_bez_finansowania_nie_wychodzi(uczelnia, jednostka): + baker.make(Instytucja_Finansujaca, nazwa="Niepowiązana") + + provider = ProviderJednostek() + assert not provider.queryset(uczelnia, Instytucja_Finansujaca).exists() + + +@pytest.mark.django_db +def test_funder_z_finansowaniem_wychodzi(uczelnia, jednostka): + instytucja = baker.make(Instytucja_Finansujaca, nazwa="NCN", akronim="NCN") + zbuduj_finansowanie(jednostka, instytucja) + + provider = ProviderJednostek() + assert list(provider.queryset(uczelnia, Instytucja_Finansujaca)) == [instytucja] + + +@pytest.mark.django_db +def test_funder_wychodzi_raz_mimo_wielu_finansowan(uczelnia, jednostka): + instytucja = baker.make(Instytucja_Finansujaca, nazwa="NCN") + zbuduj_finansowanie(jednostka, instytucja) + zbuduj_finansowanie(jednostka, instytucja) + + provider = ProviderJednostek() + assert list(provider.queryset(uczelnia, Instytucja_Finansujaca)) == [instytucja] + + # Także po przepuszczeniu przez stronicowanie keyset — ``DISTINCT`` + # musi przeżyć adnotację ``_cerif_ts`` i ``ORDER BY`` po niej, inaczej + # harvester dostałby ten sam rekord tyle razy, ile jest finansowań. + obiekty, _ = provider.strona(uczelnia, rozmiar=1000) + assert [o for o in obiekty if isinstance(o, Instytucja_Finansujaca)] == [instytucja] + + +@pytest.mark.django_db +def test_funder_cudzej_uczelni_nie_wychodzi(uczelnia, jednostka): + """Grantodawca innego tenanta nie może wyciec do naszego harvestu.""" + obca_uczelnia = Uczelnia.objects.create( + nazwa="Obca uczelnia", + skrot="OBC", + site=Site.objects.create(domain="obca.example.org", name="obca"), + ) + obca_jednostka = Jednostka.objects.create( + nazwa="Obca jednostka", skrot="OJE", uczelnia=obca_uczelnia + ) + instytucja = baker.make(Instytucja_Finansujaca, nazwa="Obcy grantodawca") + zbuduj_finansowanie(obca_jednostka, instytucja) + + provider = ProviderJednostek() + assert not provider.queryset(uczelnia, Instytucja_Finansujaca).exists() + + +@pytest.mark.django_db +def test_funder_jest_w_secie_orgunits(uczelnia, jednostka): + instytucja = baker.make(Instytucja_Finansujaca, nazwa="NCN") + zbuduj_finansowanie(jednostka, instytucja) + + provider = provider_dla_setu(const.SET_ORGUNITS) + obiekty, _ = provider.strona(uczelnia, rozmiar=1000) + assert instytucja in obiekty + + assert provider_dla_modelu(Instytucja_Finansujaca) is provider + + +@pytest.mark.django_db +def test_pojedynczy_grantodawca_po_identyfikatorze(uczelnia, jednostka): + instytucja = baker.make(Instytucja_Finansujaca, nazwa="NCN") + zbuduj_finansowanie(jednostka, instytucja) + + provider = provider_dla_setu(const.SET_ORGUNITS) + assert ( + provider.pojedynczy(uczelnia, Instytucja_Finansujaca, instytucja.pk) + == instytucja + ) + + +# -- serializacja -------------------------------------------------------- + + +@pytest.mark.django_db +def test_orgunit_grantodawcy_ma_nazwy_i_akronim(uczelnia, jednostka): + instytucja = baker.make( + Instytucja_Finansujaca, + nazwa="Narodowe Centrum Nauki", + nazwa_en="National Science Centre", + akronim="NCN", + strona_www="https://ncn.gov.pl/", + ) + zbuduj_finansowanie(jednostka, instytucja) + + el = orgunit.serializuj(instytucja, kontekst(uczelnia, [instytucja])) + + assert el.get("id") == f"OrgUnits/if-{instytucja.pk}" + assert el.find(q("Acronym")).text == "NCN" + nazwy = el.findall(q("Name")) + assert [n.text for n in nazwy] == [ + "Narodowe Centrum Nauki", + "National Science Centre", + ] + # Nazwa polska bez ``xml:lang`` — dokładnie tak, jak wychodzi + # z ``wspolne.osadz_orgunit``, żeby encja osadzona pozostała podzbiorem + # pełnego rekordu (kontrola 5b walidatora). + assert nazwy[0].get(wspolne.XML_LANG) is None + assert nazwy[1].get(wspolne.XML_LANG) == "en" + assert el.find(q("ElectronicAddress")).text == "https://ncn.gov.pl/" + + +@pytest.mark.django_db +def test_orgunit_grantodawcy_nie_ma_kraju(uczelnia, jednostka): + # Sekwencja OrgUnit w profilu nie ma elementu Country — pole ``kraj`` + # zostaje danymi wewnętrznymi. + instytucja = baker.make(Instytucja_Finansujaca, nazwa="NCN", kraj="PL") + zbuduj_finansowanie(jednostka, instytucja) + + el = orgunit.serializuj(instytucja, kontekst(uczelnia, [instytucja])) + assert el.find(q("Country")) is None + assert "PL" not in etree.tostring(el, encoding="unicode") + + +@pytest.mark.django_db +def test_ror_i_fundref_ida_w_dedykowanych_elementach(uczelnia, jednostka): + instytucja = baker.make( + Instytucja_Finansujaca, + nazwa="Narodowe Centrum Nauki", + ror_id="https://ror.org/016f61126", + fundref_id="501100004281", + ) + zbuduj_finansowanie(jednostka, instytucja) + + el = orgunit.serializuj(instytucja, kontekst(uczelnia, [instytucja])) + + assert el.find(q("RORID")).text == "https://ror.org/016f61126" + # ``FundRefID__Type`` wymaga pełnego DOI-a rejestru Crossrefa; w bazie + # trzymamy sam numer. + assert el.find(q("FundRefID")).text == "https://doi.org/10.13039/501100004281" + + # Kolejność wymuszona przez ``OrgUnitIdentifiers__Group``: + # RORID przed FundRefID, oba przed generycznym Identifier. + nazwy = [etree.QName(dziecko).localname for dziecko in el] + assert nazwy.index("RORID") < nazwy.index("FundRefID") + + +@pytest.mark.django_db +def test_bledny_fundref_ladzie_w_generycznym_identifier(uczelnia, jednostka): + instytucja = baker.make( + Instytucja_Finansujaca, nazwa="Grantodawca", fundref_id="nie-numer" + ) + zbuduj_finansowanie(jednostka, instytucja) + + el = orgunit.serializuj(instytucja, kontekst(uczelnia, [instytucja])) + + assert el.find(q("FundRefID")) is None + identyfikator = el.find(q("Identifier")) + assert identyfikator is not None + assert identyfikator.text == "nie-numer" + assert identyfikator.get("type") == orgunit.TYP_ID_FUNDREF + + +@pytest.mark.django_db +def test_pusty_grantodawca_serializuje_sie_bez_identyfikatorow(uczelnia, jednostka): + instytucja = baker.make( + Instytucja_Finansujaca, + nazwa="Bez identyfikatorów", + nazwa_en="", + akronim="", + ror_id="", + fundref_id="", + strona_www="", + ) + zbuduj_finansowanie(jednostka, instytucja) + + el = orgunit.serializuj(instytucja, kontekst(uczelnia, [instytucja])) + + assert [etree.QName(dziecko).localname for dziecko in el] == ["Name"] + + +@pytest.mark.django_db +def test_orgunit_grantodawcy_zgodny_z_xsd(uczelnia, jednostka): + from cerif_export.tests.test_serializery import sprawdz, zbuduj_schemat + + instytucja = baker.make( + Instytucja_Finansujaca, + nazwa="Narodowe Centrum Nauki", + nazwa_en="National Science Centre", + akronim="NCN", + ror_id="https://ror.org/016f61126", + fundref_id="501100004281", + strona_www="https://ncn.gov.pl/", + ) + zbuduj_finansowanie(jednostka, instytucja) + + el = orgunit.serializuj(instytucja, kontekst(uczelnia, [instytucja])) + sprawdz(zbuduj_schemat(), el) + + +@pytest.mark.django_db +def test_grantodawca_wychodzi_z_harvestu_oai(uczelnia, jednostka): + """Pełny ``ListRecords`` musi wydać rekord grantodawcy, nie wywalić się. + + Sam queryset providera to za mało: bez wpisu w ``MODULY_SERIALIZERA`` + warstwa OAI podnosi ``BlednyIdentyfikator`` dopiero przy serializacji, + czyli w produkcji, a nie w teście providera. + """ + instytucja = baker.make(Instytucja_Finansujaca, nazwa="NCN", akronim="NCN") + zbuduj_finansowanie(jednostka, instytucja) + + korzen = czasowniki.odpowiedz( + czasowniki.Zadanie( + uczelnia, + "https://cerif.example.org/cerif-oai/", + { + "verb": "ListRecords", + "metadataPrefix": const.METADATA_PREFIX, + "set": const.SET_ORGUNITS, + }, + ) + ) + + bledy = [el for el in korzen.iter() if el.tag.endswith("}error")] + assert not bledy, [el.get("code") for el in bledy] + + identyfikatory_naglowkow = { + el.text for el in korzen.iter() if el.tag.endswith("}identifier") and el.text + } + oczekiwany = identyfikatory.zbuduj(uczelnia.oai_repository_identifier(), instytucja) + assert oczekiwany in identyfikatory_naglowkow + + +@pytest.mark.django_db +def test_jednostka_serializuje_sie_bez_zmian(uczelnia, jednostka): + """Rozszerzenie setu nie może ruszyć tego, co wychodzi dla jednostek.""" + el = orgunit.serializuj(jednostka, kontekst(uczelnia, [jednostka])) + + nazwa = el.find(q("Name")) + assert nazwa.text == "Jednostka CERIF" + assert nazwa.get(wspolne.XML_LANG) is None + assert el.find(q("Acronym")).text == "JCE" From 7e4d771e9a70ee5d84695ef0e715fd81635ba9f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 11:05:13 +0200 Subject: [PATCH 08/11] =?UTF-8?q?feat(cerif):=20eksport=20set=C3=B3w=20ope?= =?UTF-8?q?naire=5Fcris=5Fprojects=20i=20openaire=5Fcris=5Ffunding=20(#701?= =?UTF-8?q?,=20#702)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buildery ``cerif/project.py`` i ``cerif/funding.py`` oraz providery obu setów. ``Project/Funded/By`` jest referencją do OrgUnit-a grantodawcy, ``Project/Funded/As`` osadza pełne ```` (ta sama funkcja serializująca, więc kontrola 5b walidatora spełniona trywialnie). ``ZbioryWidocznosci`` dostaje zbiór ``grantodawcy``: bez niego ``zawiera()`` zwracało dla ``Instytucja_Finansujaca`` False, więc ``Funding/Funder`` i ``Project/Funded/By`` osadzały OrgUnit-a bez ``@id`` — referencję nie do rozwiązania w harveście. ``Project/Team`` powstaje wyłącznie gdy osoby są widoczne; przy ``Uczelnia.eksport_cerif_osoby=False`` znika razem z kontenerem. ``Funding/Amount`` wychodzi wyłącznie za zgodą ``Uczelnia.eksport_cerif_kwoty`` — przełącznik wystawia ``KontekstSerializacji.kwoty_dozwolone``. Co-Authored-By: Claude Opus 5 (1M context) --- src/cerif_export/cerif/funding.py | 117 +++ src/cerif_export/cerif/project.py | 217 +++++ src/cerif_export/cerif/wspolne.py | 29 +- src/cerif_export/kontekst.py | 26 + src/cerif_export/oai/czasowniki.py | 2 + src/cerif_export/providers/__init__.py | 9 +- src/cerif_export/providers/finansowanie.py | 53 ++ src/cerif_export/providers/projekty.py | 98 +++ src/cerif_export/providers/puste.py | 22 +- .../tests/test_eksport_projektow.py | 754 ++++++++++++++++++ src/cerif_export/tests/test_providery.py | 11 +- 11 files changed, 1313 insertions(+), 25 deletions(-) create mode 100644 src/cerif_export/cerif/funding.py create mode 100644 src/cerif_export/cerif/project.py create mode 100644 src/cerif_export/providers/finansowanie.py create mode 100644 src/cerif_export/providers/projekty.py create mode 100644 src/cerif_export/tests/test_eksport_projektow.py diff --git a/src/cerif_export/cerif/funding.py b/src/cerif_export/cerif/funding.py new file mode 100644 index 000000000..d76f1ae60 --- /dev/null +++ b/src/cerif_export/cerif/funding.py @@ -0,0 +1,117 @@ +"""Serializacja ``bpp.Finansowanie`` do encji CERIF ``Funding``. + +Kolejność wymuszona przez ``xs:sequence``: ``Type``, ``Acronym``, ``Name``, +``Amount``, grupa ``FundingIdentifiers__Group`` (``GrantDOI``, potem +generyczny ``Identifier``), ``Description``, ``Subject``, ``Keyword``, +``Funder``, ``PartOf``, ``Duration``, ``OAMandate``. + +Jedna funkcja, dwa konteksty +---------------------------- + +:func:`serializuj` obsługuje naraz rekord setu ``openaire_cris_funding`` +i element osadzony w ``Project/Funded/As`` — osadzenie polega na doczepieniu +dokładnie tego samego elementu w innym miejscu drzewa. Kontrola 5b +walidatora (encja osadzona musi być podzbiorem pełnego rekordu) jest wtedy +spełniona trywialnie, bo to jest ten sam element. Z tego samego powodu +``@id`` nadajemy zawsze: finansowanie osadzone w projekcie tej uczelni +wychodzi też jako osobny rekord w secie funding — providery obu setów +filtrują po tym samym warunku (``projekt__jednostka__uczelnia``). + +Grantodawca jest **referencją** do ``OrgUnit`` (profil nie ma osobnej encji +instytucji finansującej), a element nazywa się ``Funder`` — nie ``FundedBy``, +jak po stronie projektu. + +Prefetche wymagane od providera: ``select_related("instytucja")``. +""" + +from cerif_export.cerif import wspolne +from cerif_export.cerif.wspolne import dodaj, element, tekst +from cerif_export.slowniki import typy_finansowania + +# Typy generycznego ``Identifier``. Element ``GrantDOI`` przyjmuje wyłącznie +# wartości pasujące do ``DOI__SimpleType``; wszystko inne odkładamy do +# generycznego identyfikatora, żeby nie unieważnić rekordu wobec XSD. +TYP_ID_DOI = "https://w3id.org/cerif/vocab/IdentifierTypes#DOI" + +# Numer umowy grantowej nie ma w CERIF-ie własnego typu identyfikatora, a +# atrybut ``type`` jest ``xs:anyURI`` i obowiązkowy. Podszycie się pod cudzy +# słownik byłoby fałszywym twierdzeniem o pochodzeniu wartości, więc — jak +# przy ``const.SCHEMAT_*`` — nazywamy własny schemat wprost. +TYP_ID_NUMER_UMOWY = "https://bpp.iplweb.pl/vocab/IdentifierTypes#NumerUmowy" + + +class NieznanyTypFinansowania(ValueError): + """``Funding/Type`` nie da się zmapować na słownik profilu. + + ``Type`` jest w sekwencji obowiązkowy, więc rekord bez niego (ale + z resztą elementów) byłby niepoprawny wobec XSD. Warstwa OAI łapie ten + wyjątek, zgłasza go do Rollbara i pomija jeden rekord — to jedyna + reakcja lepsza od wypuszczenia dokumentu, który agregator odrzuci + w całości. + """ + + +def dodaj_typ(el, finansowanie): + """Dopisz obowiązkowy ``Type`` z własnej przestrzeni nazw słownika.""" + uri = typy_finansowania.uri_typu(getattr(finansowanie, "typ", None)) + if uri is None: + raise NieznanyTypFinansowania( + f"Finansowanie {finansowanie.pk}: typ " + f"{getattr(finansowanie, 'typ', None)!r} spoza słownika profilu" + ) + return dodaj(el, "Type", uri, ns=typy_finansowania.SCHEMAT) + + +def dodaj_kwote(el, finansowanie, ctx): + """Dopisz ``Amount`` — tylko za zgodą uczelni i tylko z walutą. + + ``cfAmount__Type`` wymaga atrybutu ``currency``, więc kwota bez waluty + nie ma jak wyjść poprawnie. Model tego pilnuje w ``clean()``, ale dane + wgrane importem tę ścieżkę omijają. + """ + if not ctx.kwoty_dozwolone: + return None + kwota = getattr(finansowanie, "kwota", None) + waluta = tekst(getattr(finansowanie, "waluta", None)) + if kwota is None or not waluta: + return None + return dodaj(el, "Amount", f"{kwota}", currency=waluta) + + +def dodaj_identyfikatory(el, finansowanie): + """Dopisz ``GrantDOI`` i generyczne ``Identifier`` w kolejności z grupy.""" + doi = tekst(getattr(finansowanie, "grant_doi", None)) + poprawny = wspolne.pasuje(wspolne.WZ_DOI, doi) + + dodaj(el, "GrantDOI", poprawny) + if doi is not None and poprawny is None: + dodaj(el, "Identifier", doi, type=TYP_ID_DOI) + dodaj( + el, + "Identifier", + tekst(getattr(finansowanie, "numer_umowy", None)), + type=TYP_ID_NUMER_UMOWY, + ) + + +def dodaj_grantodawce(el, finansowanie, ctx): + """Dopisz ``Funder`` — referencję do ``OrgUnit`` instytucji finansującej.""" + if getattr(finansowanie, "instytucja_id", None) is None: + return None + return wspolne.dodaj_link_do_orgunit(el, "Funder", finansowanie.instytucja, ctx) + + +def serializuj(finansowanie, ctx): + """``bpp.Finansowanie`` → element ``Funding``.""" + el = element("Funding", nsmap=wspolne.NSMAP_REKORDU) + wspolne.ustaw_id_rekordu(el, finansowanie, ctx) + + dodaj_typ(el, finansowanie) + # ``Acronym`` zostaje pusty: BPP nie ma osobnego pola na skrót + # finansowania, a wpisanie tam nazwy programu dublowałoby ``Name``. + dodaj(el, "Name", tekst(getattr(finansowanie, "nazwa_programu", None))) + dodaj_kwote(el, finansowanie, ctx) + dodaj_identyfikatory(el, finansowanie) + dodaj_grantodawce(el, finansowanie, ctx) + + return el diff --git a/src/cerif_export/cerif/project.py b/src/cerif_export/cerif/project.py new file mode 100644 index 000000000..8365572d6 --- /dev/null +++ b/src/cerif_export/cerif/project.py @@ -0,0 +1,217 @@ +"""Serializacja ``bpp.Projekt`` do encji CERIF ``Project``. + +Kolejność wymuszona przez ``xs:sequence``: ``Type``, ``Acronym``, ``Title``, +``Identifier``, ``StartDate``, ``EndDate``, ``Consortium``, ``Team``, +``Funded``, ``Subject``, ``Keyword``, ``Abstract``, ``Status``, ``Uses``, +``OAMandate``. + +Czego świadomie nie wystawiamy +------------------------------ + +``Type`` — profil oczekuje tu ``cfGenericURIClassification__Type`` +z atrybutem ``scheme``, a BPP nie prowadzi typologii projektów +(badawczy/wdrożeniowy/infrastrukturalny). Wymyślenie własnej i wpisanie +wszystkim tej samej wartości byłoby twierdzeniem bez pokrycia w danych. + +``Projekt.strona_www`` — sekwencja ``Project`` nie ma elementu na adres +strony, a wciśnięcie URL-a do generycznego ``Identifier`` mówiłoby, że to +identyfikator projektu, czym adres strony nie jest. + +Afiliacje osób z ``Team`` — ``Projekt_Autor`` nie niesie jednostki, a +podstawienie ``Projekt.jednostka`` twierdziłoby, że każdy członek zespołu +pracuje w jednostce realizującej. Bywa nieprawdą przy projektach +międzyjednostkowych, więc ``Affiliation`` zostaje puste. + +Referencje wychodzące +--------------------- + +``Consortium/Coordinator`` (jednostka realizująca) i ``Funded/By`` +(grantodawca) są **referencjami** do ``OrgUnit``; ``Funded/As`` niesie +**osadzone pełne** ````. ``Team`` powstaje wyłącznie wtedy, gdy +osoby są widoczne w harveście — przy ``Uczelnia.eksport_cerif_osoby=False`` +referencja do ``Person`` byłaby wisząca, a to najboleśniejsza klasa błędu +w kontroli integralności referencyjnej walidatora. + +Prefetche wymagane od providera: ``select_related("jednostka")``, +``prefetch_related("projekt_autor_set__autor", "finansowanie_set__instytucja", +"dyscypliny", "slowa_kluczowe")``. +""" + +from bpp.models.projekt import Projekt_Autor +from cerif_export import const +from cerif_export.cerif import funding as cerif_funding +from cerif_export.cerif import wspolne +from cerif_export.cerif.wspolne import dodaj, dodaj_kontener, element, tekst + + +def dodaj_tytuly(el, projekt): + """Dopisz ``Title`` w obu językach (``cfMLangString__Type``).""" + dodaj(el, "Title", tekst(getattr(projekt, "tytul", None)), jezyk="pl") + dodaj(el, "Title", tekst(getattr(projekt, "tytul_en", None)), jezyk="en") + + +def dodaj_daty(el, projekt): + """Dopisz ``StartDate`` i ``EndDate``.""" + dodaj(el, "StartDate", wspolne.data_iso(getattr(projekt, "data_rozpoczecia", None))) + dodaj(el, "EndDate", wspolne.data_iso(getattr(projekt, "data_zakonczenia", None))) + + +def dodaj_konsorcjum(el, projekt, ctx): + """Dopisz ``Consortium`` z jednostką realizującą jako ``Coordinator``. + + Kontener powstaje dopiero razem z koordynatorem: pusty ```` + nie niesie żadnej informacji, a jednostka spoza zbioru widoczności nie + ma prawa się w nim pojawić nawet samą nazwą. + """ + if getattr(projekt, "jednostka_id", None) is None: + return None + if not wspolne.jednostka_ujawnialna(projekt.jednostka, ctx): + return None + + konsorcjum = dodaj_kontener(el, "Consortium") + wspolne.dodaj_link_do_orgunit(konsorcjum, "Coordinator", projekt.jednostka, ctx) + return konsorcjum + + +def _nazwa_wyswietlana(autor): + return " ".join( + czlon + for czlon in ( + getattr(autor, "nazwisko", None), + getattr(autor, "imiona", None), + ) + if czlon + ) + + +def dodaj_zespol(el, projekt, ctx): + """Dopisz ``Team`` — kierownik jako ``PrincipalInvestigator``, reszta + jako ``Member``. + + Do zespołu wchodzą wyłącznie osoby, które faktycznie wyjdą w secie + ``openaire_cris_persons`` (``ctx.id_dla`` zwraca identyfikator). Osoba + z ``pokazuj=False`` albo cała lista przy wyłączonym + ``eksport_cerif_osoby`` znika bez śladu — razem z kontenerem, żeby nie + zostawiać pustego ````. + """ + powiazania = getattr(projekt, "projekt_autor_set", None) + if powiazania is None: + return None + + kierownicy, czlonkowie = [], [] + for powiazanie in powiazania.all(): + if powiazanie.autor_id is None: + continue + if ctx.id_dla(powiazanie.autor) is None: + continue + if powiazanie.rola == Projekt_Autor.ROLA_KIEROWNIK: + kierownicy.append(powiazanie) + else: + czlonkowie.append(powiazanie) + + if not kierownicy and not czlonkowie: + return None + + # ``xs:sequence`` w ``Team``: PrincipalInvestigator, Contact, Member. + zespol = dodaj_kontener(el, "Team") + for nazwa, lista in ( + ("PrincipalInvestigator", kierownicy), + ("Member", czlonkowie), + ): + for powiazanie in lista: + wspolne.dodaj_wklad_osoby( + zespol, + nazwa, + powiazanie.autor, + ctx, + nazwa_wyswietlana=_nazwa_wyswietlana(powiazanie.autor), + ) + return zespol + + +def dodaj_finansowania(el, projekt, ctx): + """Dopisz po jednym ``Funded`` na każde źródło finansowania. + + ``By`` to referencja do ``OrgUnit`` grantodawcy, ``As`` to osadzone + pełne ```` — dwie różne rzeczy w jednym kontenerze. Kontener + powstaje nawet wtedy, gdy grantodawca jest niewidoczny: samo + ``Funding`` (typ, nazwa programu, numer umowy) nadal niesie treść. + """ + finansowania = getattr(projekt, "finansowanie_set", None) + if finansowania is None: + return + + for finansowanie in finansowania.all(): + kontener = dodaj_kontener(el, "Funded") + if finansowanie.instytucja_id is not None: + wspolne.dodaj_link_do_orgunit(kontener, "By", finansowanie.instytucja, ctx) + osadzenie = dodaj_kontener(kontener, "As") + osadzenie.append(cerif_funding.serializuj(finansowanie, ctx)) + + +def dodaj_dyscypliny(el, projekt): + """Dopisz ``Subject`` dla każdej dyscypliny naukowej. + + ``cfGenericURIClassification__Type`` wymaga URI wartości i atrybutu + ``scheme``. Profil nie zna polskiej klasyfikacji dyscyplin, więc URI + budujemy we własnym schemacie (``const.SCHEMAT_DYSCYPLIN``) — jawnie + nazwane pochodzenie jest lepsze i od pominięcia danych, i od podszycia + się pod cudzy słownik. + """ + dyscypliny = getattr(projekt, "dyscypliny", None) + if dyscypliny is None: + return + for dyscyplina in dyscypliny.all(): + kod = tekst(getattr(dyscyplina, "kod", None)) + if kod is None: + continue + dodaj( + el, + "Subject", + f"{const.SCHEMAT_DYSCYPLIN}/{kod}", + scheme=const.SCHEMAT_DYSCYPLIN, + ) + + +def dodaj_abstrakty(el, projekt): + """Dopisz ``Abstract`` w obu językach. + + ``Project/Abstract`` to ``cfMLangAnyMixed__Type``, w którym ``xml:lang`` + jest **wymagany** — inaczej niż w abstraktach publikacji + (``cfMLangString__Type``). Dlatego oba wywołania podają język na sztywno: + treść pól rozstrzyga o nim jednoznacznie. + """ + dodaj(el, "Abstract", tekst(getattr(projekt, "abstrakt", None)), jezyk="pl") + dodaj(el, "Abstract", tekst(getattr(projekt, "abstrakt_en", None)), jezyk="en") + + +def dodaj_status(el, projekt): + """Dopisz ``Status`` jako URI we własnym schemacie klasyfikacji.""" + status = tekst(getattr(projekt, "status", None)) + if status is None: + return None + return dodaj( + el, + "Status", + f"{const.SCHEMAT_STATUSU_PROJEKTU}/{status}", + scheme=const.SCHEMAT_STATUSU_PROJEKTU, + ) + + +def serializuj(projekt, ctx): + """``bpp.Projekt`` → element ``Project``.""" + el = element("Project", nsmap=wspolne.NSMAP_REKORDU) + wspolne.ustaw_id_rekordu(el, projekt, ctx) + + dodaj(el, "Acronym", tekst(getattr(projekt, "akronim", None))) + dodaj_tytuly(el, projekt) + dodaj_daty(el, projekt) + dodaj_konsorcjum(el, projekt, ctx) + dodaj_zespol(el, projekt, ctx) + dodaj_finansowania(el, projekt, ctx) + dodaj_dyscypliny(el, projekt) + wspolne.dodaj_slowa_kluczowe(el, projekt) + dodaj_abstrakty(el, projekt) + dodaj_status(el, projekt) + + return el diff --git a/src/cerif_export/cerif/wspolne.py b/src/cerif_export/cerif/wspolne.py index 279f42c61..87b304235 100644 --- a/src/cerif_export/cerif/wspolne.py +++ b/src/cerif_export/cerif/wspolne.py @@ -298,6 +298,12 @@ def osadz_orgunit(rodzic, jednostka, ctx): swojego pełnego rekordu, więc oba elementy budujemy dokładnie tak samo jak :func:`cerif_export.cerif.orgunit.serializuj`. + Skrót nazwy nosi w BPP dwa różne pola: ``skrot`` (``Jednostka``, + ``Uczelnia``) i ``akronim`` (``Instytucja_Finansujaca``). Oba wychodzą + w pełnym rekordzie jako ``Acronym``, więc oba muszą być brane też tutaj + — inaczej osadzony grantodawca gubiłby akronim, po którym najłatwiej go + rozpoznać. + Jednostka spoza zbioru widoczności nie jest osadzana wcale — patrz :func:`jednostka_ujawnialna`. """ @@ -305,12 +311,33 @@ def osadz_orgunit(rodzic, jednostka, ctx): return None el = element("OrgUnit") ustaw_id(el, jednostka, ctx) - dodaj(el, "Acronym", tekst(getattr(jednostka, "skrot", None))) + skrot = getattr(jednostka, "skrot", None) or getattr(jednostka, "akronim", None) + dodaj(el, "Acronym", tekst(skrot)) dodaj(el, "Name", tekst(getattr(jednostka, "nazwa", None))) rodzic.append(el) return el +def dodaj_link_do_orgunit(rodzic, nazwa, jednostka, ctx): + """Dopisz element-link niosący osadzony ``OrgUnit``. + + Odpowiada typowi ``cfLinkWithDisplayNameToPersonOrOrgUnit__Type`` — + ``DisplayName?`` plus **wymagany** ``Person`` albo ``OrgUnit``. Używają + go ``Project/Consortium/Coordinator``, ``Project/Funded/By`` + i ``Funding/Funder``. + + Element powstaje **tylko** wtedy, gdy jednostkę wolno pokazać. Pusty + kontener byłby niepoprawny wobec XSD (dziecko jest obowiązkowe), + a stworzenie go „na wszelki wypadek" i zostawienie pustym to dokładnie + ten rodzaj błędu, który wychodzi dopiero pod walidatorem. + """ + if not jednostka_ujawnialna(jednostka, ctx): + return None + el = dodaj_kontener(rodzic, nazwa) + osadz_orgunit(el, jednostka, ctx) + return el + + def osadz_osobe(rodzic, autor, ctx): """Dopisz skrócony ``Person`` (``PersonName`` + ``ORCID``). diff --git a/src/cerif_export/kontekst.py b/src/cerif_export/kontekst.py index 2adb66105..93b8075cf 100644 --- a/src/cerif_export/kontekst.py +++ b/src/cerif_export/kontekst.py @@ -41,6 +41,13 @@ class ZbioryWidocznosci: autorzy: frozenset = field(default_factory=frozenset) jednostki: frozenset = field(default_factory=frozenset) + # Instytucje finansujące wychodzą w secie OrgUnits, ale mają własny + # model i własny slug, więc nie da się ich wrzucić do ``jednostki`` — + # klucze główne obu tabel kolidują. Bez osobnego zbioru ``zawiera()`` + # zwracało dla grantodawcy ``False``, czyli ``Funding/Funder`` + # i ``Project/Funded/By`` osadzały OrgUnit-a BEZ ``@id``: referencję, + # której nie da się rozwiązać na żaden rekord harvestu. + grantodawcy: frozenset = field(default_factory=frozenset) zrodla: frozenset = field(default_factory=frozenset) konferencje: frozenset = field(default_factory=frozenset) # (slug, pk) — publikacje pochodzą z pięciu różnych modeli @@ -49,6 +56,7 @@ class ZbioryWidocznosci: _POLA_WG_SLUGU = { "au": "autorzy", "je": "jednostki", + "if": "grantodawcy", "zr": "zrodla", "kf": "konferencje", } @@ -76,6 +84,24 @@ class KontekstSerializacji: uczelnia: object widoczne: ZbioryWidocznosci + @property + def kwoty_dozwolone(self) -> bool: + """Czy wolno wystawić kwoty finansowania (``Funding/Amount``)? + + Przełącznik ``Uczelnia.eksport_cerif_kwoty`` jest domyślnie + **wyłączony** — kwoty grantów bywają danymi wrażliwymi umownie + i zostają w bazie do użytku wewnętrznego. + + Decyzja siedzi tutaj, a nie w serializerze sięgającym po pole + uczelni na własną rękę, z tego samego powodu, dla którego + widoczność encji siedzi w :class:`ZbioryWidocznosci`: to jest + kontrakt kontekstu, a nie szczegół implementacyjny jednego + buildera. Odczyt pola już załadowanego obiektu ``Uczelnia`` nie + jest zapytaniem do bazy, więc reguła „serializer nie dotyka bazy" + zostaje nienaruszona. + """ + return bool(getattr(self.uczelnia, "eksport_cerif_kwoty", False)) + def id_dla(self, obj): """Identyfikator OAI albo ``None``, gdy encja nie wyjdzie w secie. diff --git a/src/cerif_export/oai/czasowniki.py b/src/cerif_export/oai/czasowniki.py index 7c365ab19..8d5e71eab 100644 --- a/src/cerif_export/oai/czasowniki.py +++ b/src/cerif_export/oai/czasowniki.py @@ -83,6 +83,8 @@ "if": "orgunit", "pt": "patent", "kf": "event", + "pj": "project", + "fn": "funding", } _WZORCE_DATY = ( diff --git a/src/cerif_export/providers/__init__.py b/src/cerif_export/providers/__init__.py index bdd5c685b..8d85ab5bd 100644 --- a/src/cerif_export/providers/__init__.py +++ b/src/cerif_export/providers/__init__.py @@ -11,17 +11,14 @@ """ from cerif_export.identyfikatory import BlednyIdentyfikator, etykieta_modelu +from cerif_export.providers.finansowanie import ProviderFinansowania from cerif_export.providers.jednostki import ProviderJednostek from cerif_export.providers.konferencje import ProviderKonferencji from cerif_export.providers.osoby import ProviderOsob from cerif_export.providers.patenty import ProviderPatentow +from cerif_export.providers.projekty import ProviderProjektow from cerif_export.providers.publikacje import ProviderPublikacji -from cerif_export.providers.puste import ( - ProviderAparatury, - ProviderFinansowania, - ProviderProduktow, - ProviderProjektow, -) +from cerif_export.providers.puste import ProviderAparatury, ProviderProduktow PROVIDERY = ( ProviderPublikacji(), diff --git a/src/cerif_export/providers/finansowanie.py b/src/cerif_export/providers/finansowanie.py new file mode 100644 index 000000000..33fda85c1 --- /dev/null +++ b/src/cerif_export/providers/finansowanie.py @@ -0,0 +1,53 @@ +"""Provider setu ``openaire_cris_funding``. + +Zakres jest ten sam co dla projektów, tylko o jeden przeskok dalej: +finansowanie należy do tenanta przez ``projekt__jednostka__uczelnia``. +Warunek musi być dokładnie taki sam jak w +:func:`cerif_export.providers.projekty.widoczne_projekty`, bo +``Project/Funded/As`` osadza pełne ```` z ``@id``. Rozjazd między +tymi dwoma filtrami dałby referencję do rekordu, którego harvester nigdy +nie zobaczy. +""" + +from bpp.models.projekt import Finansowanie +from cerif_export import const +from cerif_export.identyfikatory import BlednyIdentyfikator +from cerif_export.kontekst import ZbioryWidocznosci +from cerif_export.providers.base import ProviderEncji +from cerif_export.providers.jednostki import ( + widoczne_pk, + widoczni_grantodawcy, + wymagaj_uczelni, +) + + +def widoczne_finansowania(uczelnia): + """Finansowania eksportowane dla tej uczelni — bez prefetchy.""" + wymagaj_uczelni(uczelnia) + return Finansowanie.objects.filter(projekt__jednostka__uczelnia=uczelnia) + + +class ProviderFinansowania(ProviderEncji): + """Źródła finansowania projektów tej uczelni.""" + + set_spec = const.SET_FUNDING + typ_cerif = const.TYP_FUNDING + modele = [Finansowanie] + + def queryset(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is not Finansowanie: + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + + return widoczne_finansowania(uczelnia).select_related("instytucja") + + def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: + """Prekomputuj widoczność grantodawców osadzanych w ``Funder``.""" + wymagaj_uczelni(uczelnia) + + grantodawcy = {finansowanie.instytucja_id for finansowanie in obiekty} + return ZbioryWidocznosci( + grantodawcy=widoczne_pk(widoczni_grantodawcy(uczelnia), grantodawcy) + ) diff --git a/src/cerif_export/providers/projekty.py b/src/cerif_export/providers/projekty.py new file mode 100644 index 000000000..3e1f9e782 --- /dev/null +++ b/src/cerif_export/providers/projekty.py @@ -0,0 +1,98 @@ +"""Provider setu ``openaire_cris_projects``. + +Przynależność projektu do tenanta niesie wyłącznie ``Projekt.jednostka`` — +model nie ma FK do uczelni, a wszystkie pozostałe relacje (zespół, +finansowanie) są słownikami współdzielonymi albo osobami, które też bywają +wieloetatowe. Dlatego filtr idzie po ``jednostka__uczelnia`` i tylko po nim. + +Reguły widoczności **samej jednostki** (``widoczna``, +``nie_eksportuj_przez_api``) świadomie nie odsiewają projektu: opt-out +dotyczy jednostki jako encji organizacyjnej, a nie prowadzonych w niej +badań. Skutkiem jest jedynie brak ``Consortium/Coordinator`` w rekordzie — +serializer nie osadzi jednostki spoza zbioru widoczności. +""" + +from django.db.models import Prefetch + +from bpp.models.projekt import Finansowanie, Projekt, Projekt_Autor +from cerif_export import const +from cerif_export.identyfikatory import BlednyIdentyfikator +from cerif_export.kontekst import ZbioryWidocznosci +from cerif_export.providers.base import ProviderEncji +from cerif_export.providers.jednostki import ( + widoczne_jednostki, + widoczne_pk, + widoczni_grantodawcy, + wymagaj_uczelni, +) +from cerif_export.providers.osoby import widoczni_autorzy + + +def widoczne_projekty(uczelnia): + """Projekty eksportowane dla tej uczelni — bez prefetchy.""" + wymagaj_uczelni(uczelnia) + return Projekt.objects.filter(jednostka__uczelnia=uczelnia) + + +# Zespół projektu. Kolejność jest stabilna (po kluczu głównym), bo model nie +# ma pola porządkującego, a niedeterministyczna kolejność w XML-u zamieniałaby +# każdy harvest w pozorną zmianę rekordu. +PREFETCH_ZESPOLU = Prefetch( + "projekt_autor_set", + queryset=Projekt_Autor.objects.select_related("autor").order_by("pk"), +) + +# Finansowania wraz z grantodawcami — serializer osadza pełne ```` +# w ``Funded/As``, więc potrzebuje obiektu instytucji, nie tylko jej klucza. +PREFETCH_FINANSOWANIA = Prefetch( + "finansowanie_set", + queryset=Finansowanie.objects.select_related("instytucja").order_by("pk"), +) + + +class ProviderProjektow(ProviderEncji): + """Projekty badawcze prowadzone w jednostkach tej uczelni.""" + + set_spec = const.SET_PROJECTS + typ_cerif = const.TYP_PROJECT + modele = [Projekt] + + def queryset(self, uczelnia, model): + wymagaj_uczelni(uczelnia) + if model is not Projekt: + raise BlednyIdentyfikator( + f"Model {model!r} nie należy do setu {self.set_spec}" + ) + + return ( + widoczne_projekty(uczelnia) + .select_related("jednostka") + .prefetch_related( + PREFETCH_ZESPOLU, + PREFETCH_FINANSOWANIA, + "dyscypliny", + "slowa_kluczowe", + ) + ) + + def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: + """Prekomputuj widoczność zespołu, jednostki realizującej i grantodawców. + + Bez zbioru grantodawców ``Funded/By`` osadzałoby ``OrgUnit`` bez + ``@id`` — referencję, której nie da się rozwiązać na żaden rekord. + """ + wymagaj_uczelni(uczelnia) + + autorzy, jednostki, grantodawcy = set(), set(), set() + for projekt in obiekty: + jednostki.add(projekt.jednostka_id) + for powiazanie in projekt.projekt_autor_set.all(): + autorzy.add(powiazanie.autor_id) + for finansowanie in projekt.finansowanie_set.all(): + grantodawcy.add(finansowanie.instytucja_id) + + return ZbioryWidocznosci( + autorzy=widoczne_pk(widoczni_autorzy(uczelnia), autorzy), + jednostki=widoczne_pk(widoczne_jednostki(uczelnia), jednostki), + grantodawcy=widoczne_pk(widoczni_grantodawcy(uczelnia), grantodawcy), + ) diff --git a/src/cerif_export/providers/puste.py b/src/cerif_export/providers/puste.py index 8531db907..9bc97ea30 100644 --- a/src/cerif_export/providers/puste.py +++ b/src/cerif_export/providers/puste.py @@ -2,9 +2,13 @@ Wytyczne OpenAIRE for CRIS Managers wymagają, żeby wszystkie dziewięć setów było zadeklarowane w ``ListSets`` — dosłownie „even if unpopulated". BPP nie -prowadzi ewidencji produktów badawczych, aparatury, projektów ani -finansowania, więc te cztery sety istnieją i odpowiadają pustą listą rekordów -zamiast błędem ``noRecordsMatch`` na poziomie ``ListSets``. +prowadzi ewidencji produktów badawczych ani aparatury, więc te dwa sety +istnieją i odpowiadają pustą listą rekordów zamiast błędem +``noRecordsMatch`` na poziomie ``ListSets``. + +Projekty i finansowanie były tu do czasu wprowadzenia modeli ``Projekt`` +i ``Finansowanie`` — mają dziś własne providery +(``providers/projekty.py``, ``providers/finansowanie.py``). Providery dziedziczą całą logikę z :class:`ProviderPusty` — deklarują wyłącznie ``set_spec``. ``typ_cerif`` zostaje pusty, bo żaden rekord nigdy @@ -21,18 +25,6 @@ class ProviderProduktow(ProviderPusty): set_spec = const.SET_PRODUCTS -class ProviderProjektow(ProviderPusty): - """``openaire_cris_projects`` — projekty badawcze.""" - - set_spec = const.SET_PROJECTS - - -class ProviderFinansowania(ProviderPusty): - """``openaire_cris_funding`` — źródła finansowania.""" - - set_spec = const.SET_FUNDING - - class ProviderAparatury(ProviderPusty): """``openaire_cris_equipments`` — aparatura badawcza.""" diff --git a/src/cerif_export/tests/test_eksport_projektow.py b/src/cerif_export/tests/test_eksport_projektow.py new file mode 100644 index 000000000..def01e504 --- /dev/null +++ b/src/cerif_export/tests/test_eksport_projektow.py @@ -0,0 +1,754 @@ +"""Eksport setów ``openaire_cris_projects`` i ``openaire_cris_funding``. + +Dwie encje profilu, które do tej pory były zadeklarowane, ale puste. +Najwięcej uwagi dostają tu trzy rzeczy, bo to one wychodzą dopiero pod +walidatorem OpenAIRE, a nie przy oglądaniu XML-a gołym okiem: + +1. ``Project/Funded/By`` to **referencja** do OrgUnit-a grantodawcy, a + ``Project/Funded/As`` to **osadzone pełne** ````; +2. każda referencja musi wskazywać na rekord obecny w harveście — + stąd testy znikającego ``Team`` przy ``eksport_cerif_osoby=False``; +3. kolejność elementów wymuszona przez ``xs:sequence`` — sprawdzana + wprost wobec XSD profilu. +""" + +import decimal + +import pytest +from django.contrib.sites.models import Site +from django.db import connection +from django.test.utils import CaptureQueriesContext +from lxml import etree +from model_bakery import baker + +from bpp.models import ( + Dyscyplina_Naukowa, + Finansowanie, + Instytucja_Finansujaca, + Jednostka, + Projekt, + Projekt_Autor, + Uczelnia, +) +from cerif_export import const, identyfikatory +from cerif_export.cerif import funding as cerif_funding +from cerif_export.cerif import project as cerif_project +from cerif_export.kontekst import KontekstSerializacji +from cerif_export.oai import czasowniki +from cerif_export.providers import provider_dla_modelu, provider_dla_setu +from cerif_export.slowniki import typy_finansowania + +NAMESPACE = "cerif.example.org" +BASE_URL = "https://cerif.example.org/cerif-oai/" + + +def q(nazwa): + """Nazwa elementu z przestrzeni nazw profilu CERIF.""" + return f"{{{const.NS_CERIF}}}{nazwa}" + + +def qt(nazwa): + """Nazwa elementu ze słownika typów finansowania (własna przestrzeń).""" + return f"{{{typy_finansowania.SCHEMAT}}}{nazwa}" + + +def nazwy_dzieci(el): + """Lokalne nazwy dzieci elementu — w kolejności wystąpienia.""" + return [etree.QName(dziecko).localname for dziecko in el] + + +# -- budowanie danych ---------------------------------------------------- + + +@pytest.fixture +def grantodawca(db): + return baker.make( + Instytucja_Finansujaca, + nazwa="Narodowe Centrum Nauki", + nazwa_en="National Science Centre", + akronim="NCN", + fundref_id="501100004281", + ) + + +@pytest.fixture +def projekt(jednostka): + return baker.make( + Projekt, + tytul="Badanie czegoś ważnego", + tytul_en="A study of something important", + akronim="BCW", + status=Projekt.STATUS_W_TRAKCIE, + data_rozpoczecia="2024-01-01", + data_zakonczenia="2026-12-31", + abstrakt="Streszczenie projektu.", + abstrakt_en="Project abstract.", + jednostka=jednostka, + ) + + +def dodaj_finansowanie(projekt, grantodawca, **kwargs): + kwargs.setdefault("typ", Finansowanie.TYP_GRANT) + kwargs.setdefault("nazwa_programu", "OPUS 24") + kwargs.setdefault("kwota", None) + return baker.make(Finansowanie, projekt=projekt, instytucja=grantodawca, **kwargs) + + +# -- serializacja przez provider (jak w produkcji) ----------------------- + + +def _serializuj(uczelnia, set_spec, model, obiekt, serializer): + """Zserializuj obiekt dokładnie tak, jak robi to warstwa OAI. + + Obiekt jest **ponownie pobierany** przez queryset providera, żeby miał + komplet ``select_related``/``prefetch_related``. Serializer nie ma + prawa dotykać bazy, więc test na obiekcie z ``baker.make`` sprawdzałby + co innego niż produkcja. + """ + provider = provider_dla_setu(set_spec) + zaladowany = provider.queryset(uczelnia, model).get(pk=obiekt.pk) + kontekst = KontekstSerializacji( + namespace=NAMESPACE, + uczelnia=uczelnia, + widoczne=provider.zbiory_widocznosci(uczelnia, [zaladowany]), + ) + return serializer(zaladowany, kontekst) + + +def serializuj_projekt(uczelnia, projekt): + return _serializuj( + uczelnia, const.SET_PROJECTS, Projekt, projekt, cerif_project.serializuj + ) + + +def serializuj_finansowanie(uczelnia, finansowanie): + return _serializuj( + uczelnia, + const.SET_FUNDING, + Finansowanie, + finansowanie, + cerif_funding.serializuj, + ) + + +# -- rejestr providerów i serializerów ----------------------------------- + + +def test_slugi_projektu_i_finansowania_maja_serializery(): + """Bez wpisu w ``MODULY_SERIALIZERA`` ``ListRecords`` wywala się + dopiero w produkcji, na pierwszym rekordzie zwróconym przez provider.""" + assert czasowniki.serializer_dla("pj") is cerif_project.serializuj + assert czasowniki.serializer_dla("fn") is cerif_funding.serializuj + + +@pytest.mark.django_db +def test_sety_projektow_i_finansowania_maja_wlasne_providery(): + assert provider_dla_setu(const.SET_PROJECTS).modele == [Projekt] + assert provider_dla_setu(const.SET_FUNDING).modele == [Finansowanie] + assert provider_dla_modelu(Projekt) is provider_dla_setu(const.SET_PROJECTS) + assert provider_dla_modelu(Finansowanie) is provider_dla_setu(const.SET_FUNDING) + + +# -- provider: zakres tenanta -------------------------------------------- + + +@pytest.mark.django_db +def test_projekt_wychodzi_w_secie(uczelnia, projekt): + provider = provider_dla_setu(const.SET_PROJECTS) + obiekty, _ = provider.strona(uczelnia, rozmiar=100) + assert list(obiekty) == [projekt] + + +@pytest.mark.django_db +def test_projekt_cudzej_uczelni_nie_wychodzi(uczelnia, projekt): + obca = Uczelnia.objects.create( + nazwa="Obca uczelnia", + skrot="OBC", + site=Site.objects.create(domain="obca.example.org", name="obca"), + ) + obca_jednostka = Jednostka.objects.create( + nazwa="Obca jednostka", skrot="OJE", uczelnia=obca + ) + baker.make(Projekt, tytul="Cudzy projekt", jednostka=obca_jednostka) + + provider = provider_dla_setu(const.SET_PROJECTS) + obiekty, _ = provider.strona(uczelnia, rozmiar=100) + assert list(obiekty) == [projekt] + + +@pytest.mark.django_db +def test_finansowanie_cudzej_uczelni_nie_wychodzi(uczelnia, projekt, grantodawca): + nasze = dodaj_finansowanie(projekt, grantodawca) + + obca = Uczelnia.objects.create( + nazwa="Obca uczelnia", + skrot="OBC", + site=Site.objects.create(domain="obca.example.org", name="obca"), + ) + obca_jednostka = Jednostka.objects.create( + nazwa="Obca jednostka", skrot="OJE", uczelnia=obca + ) + obcy_projekt = baker.make(Projekt, tytul="Cudzy", jednostka=obca_jednostka) + dodaj_finansowanie(obcy_projekt, grantodawca) + + provider = provider_dla_setu(const.SET_FUNDING) + obiekty, _ = provider.strona(uczelnia, rozmiar=100) + assert list(obiekty) == [nasze] + + +@pytest.mark.django_db +def test_provider_odrzuca_obcy_model(uczelnia): + for set_spec in (const.SET_PROJECTS, const.SET_FUNDING): + with pytest.raises(identyfikatory.BlednyIdentyfikator): + provider_dla_setu(set_spec).queryset(uczelnia, Jednostka) + + +@pytest.mark.django_db +def test_provider_bez_uczelni_podnosi_blad(): + """Cicha degradacja do pustej odpowiedzi udawałaby poprawny harvest.""" + for set_spec, model in ( + (const.SET_PROJECTS, Projekt), + (const.SET_FUNDING, Finansowanie), + ): + with pytest.raises(ValueError): + provider_dla_setu(set_spec).queryset(None, model) + + +# -- Funding ------------------------------------------------------------- + + +@pytest.mark.django_db +def test_funding_ma_typ_zawsze(uczelnia, projekt, grantodawca): + """``Funding/Type`` jest w profilu obowiązkowy i pochodzi ze słownika.""" + finansowanie = dodaj_finansowanie(projekt, grantodawca, typ=Finansowanie.TYP_AWARD) + + el = serializuj_finansowanie(uczelnia, finansowanie) + + typ = el.find(qt("Type")) + assert typ is not None + assert typ.text == typy_finansowania.uri_typu(Finansowanie.TYP_AWARD) + assert nazwy_dzieci(el)[0] == "Type" + + +@pytest.mark.django_db +def test_funding_ma_identyfikator_rekordu(uczelnia, projekt, grantodawca): + finansowanie = dodaj_finansowanie(projekt, grantodawca) + el = serializuj_finansowanie(uczelnia, finansowanie) + assert el.get("id") == f"Fundings/fn-{finansowanie.pk}" + + +@pytest.mark.django_db +def test_nieznany_typ_finansowania_podnosi_blad(uczelnia, projekt, grantodawca): + """Rekord bez ``Type`` byłby niepoprawny wobec XSD, więc lepiej głośno + wywalić jeden rekord (Rollbar + pominięcie) niż wypuścić zepsuty.""" + finansowanie = dodaj_finansowanie(projekt, grantodawca) + Finansowanie.objects.filter(pk=finansowanie.pk).update(typ="NieMaTakiego") + + with pytest.raises(ValueError): + serializuj_finansowanie(uczelnia, finansowanie) + + +@pytest.mark.django_db +def test_kwota_tylko_przy_wlaczonym_przelaczniku(uczelnia, projekt, grantodawca): + """``Uczelnia.eksport_cerif_kwoty`` domyślnie wyłączone — kwoty zostają + w bazie do użytku wewnętrznego.""" + finansowanie = dodaj_finansowanie( + projekt, grantodawca, kwota=decimal.Decimal("1234567.89"), waluta="PLN" + ) + + assert uczelnia.eksport_cerif_kwoty is False + el = serializuj_finansowanie(uczelnia, finansowanie) + assert el.find(q("Amount")) is None + + uczelnia.eksport_cerif_kwoty = True + uczelnia.save() + + el = serializuj_finansowanie(uczelnia, finansowanie) + kwota = el.find(q("Amount")) + assert kwota is not None + assert kwota.text == "1234567.89" + assert kwota.get("currency") == "PLN" + + +@pytest.mark.django_db +def test_kwota_bez_waluty_nie_wychodzi(uczelnia, projekt, grantodawca): + """``cfAmount__Type`` wymaga atrybutu ``currency`` — kwota bez waluty + nie ma jak wyjść poprawnie, więc nie wychodzi wcale.""" + uczelnia.eksport_cerif_kwoty = True + uczelnia.save() + finansowanie = dodaj_finansowanie( + projekt, grantodawca, kwota=decimal.Decimal("100.00"), waluta="" + ) + + el = serializuj_finansowanie(uczelnia, finansowanie) + assert el.find(q("Amount")) is None + + +@pytest.mark.django_db +def test_grant_doi_idzie_w_dedykowanym_elemencie(uczelnia, projekt, grantodawca): + finansowanie = dodaj_finansowanie( + projekt, grantodawca, grant_doi="10.13039/501100004281-XYZ" + ) + + el = serializuj_finansowanie(uczelnia, finansowanie) + assert el.find(q("GrantDOI")).text == "10.13039/501100004281-XYZ" + + +@pytest.mark.django_db +def test_bledny_grant_doi_ladzie_w_generycznym_identifier( + uczelnia, projekt, grantodawca +): + """``DOI__SimpleType`` ma wzorzec — wartość spoza niego unieważniłaby + cały rekord, więc odkładamy ją do generycznego ``Identifier``.""" + finansowanie = dodaj_finansowanie(projekt, grantodawca, grant_doi="to-nie-jest-doi") + + el = serializuj_finansowanie(uczelnia, finansowanie) + assert el.find(q("GrantDOI")) is None + identyfikator = el.find(q("Identifier")) + assert identyfikator.text == "to-nie-jest-doi" + assert identyfikator.get("type") == cerif_funding.TYP_ID_DOI + + +@pytest.mark.django_db +def test_numer_umowy_jako_identyfikator(uczelnia, projekt, grantodawca): + finansowanie = dodaj_finansowanie( + projekt, grantodawca, numer_umowy="UMO-2024/45/B/NZ7/01234" + ) + + el = serializuj_finansowanie(uczelnia, finansowanie) + numery = [ + el_.text + for el_ in el.findall(q("Identifier")) + if el_.get("type") == cerif_funding.TYP_ID_NUMER_UMOWY + ] + assert numery == ["UMO-2024/45/B/NZ7/01234"] + + +@pytest.mark.django_db +def test_funder_to_referencja_do_orgunita(uczelnia, projekt, grantodawca): + """Po stronie ``Funding`` grantodawca siedzi w elemencie ``Funder`` + (nie ``FundedBy``) i jest osadzonym ``OrgUnit``-em z ``@id``.""" + finansowanie = dodaj_finansowanie(projekt, grantodawca) + + el = serializuj_finansowanie(uczelnia, finansowanie) + + funder = el.find(q("Funder")) + assert funder is not None + orgunit = funder.find(q("OrgUnit")) + assert orgunit.get("id") == f"OrgUnits/if-{grantodawca.pk}" + assert orgunit.find(q("Acronym")).text == "NCN" + assert orgunit.find(q("Name")).text == "Narodowe Centrum Nauki" + + +@pytest.mark.django_db +def test_kolejnosc_elementow_finansowania(uczelnia, projekt, grantodawca): + """``xs:sequence``: Type, Name, Amount, GrantDOI, Identifier, Funder.""" + uczelnia.eksport_cerif_kwoty = True + uczelnia.save() + finansowanie = dodaj_finansowanie( + projekt, + grantodawca, + kwota=decimal.Decimal("1000.00"), + waluta="PLN", + grant_doi="10.13039/501100004281-XYZ", + numer_umowy="UMO-1", + ) + + el = serializuj_finansowanie(uczelnia, finansowanie) + assert nazwy_dzieci(el) == [ + "Type", + "Name", + "Amount", + "GrantDOI", + "Identifier", + "Funder", + ] + + +# -- Project ------------------------------------------------------------- + + +@pytest.mark.django_db +def test_projekt_ma_tytuly_daty_i_status(uczelnia, projekt): + el = serializuj_projekt(uczelnia, projekt) + + assert el.get("id") == f"Projects/pj-{projekt.pk}" + assert el.find(q("Acronym")).text == "BCW" + + tytuly = el.findall(q("Title")) + assert [t.text for t in tytuly] == [ + "Badanie czegoś ważnego", + "A study of something important", + ] + from cerif_export.cerif.wspolne import XML_LANG + + assert [t.get(XML_LANG) for t in tytuly] == ["pl", "en"] + + assert el.find(q("StartDate")).text == "2024-01-01" + assert el.find(q("EndDate")).text == "2026-12-31" + + status = el.find(q("Status")) + assert status.get("scheme") == const.SCHEMAT_STATUSU_PROJEKTU + assert status.text == ( + f"{const.SCHEMAT_STATUSU_PROJEKTU}/{Projekt.STATUS_W_TRAKCIE}" + ) + + +@pytest.mark.django_db +def test_projekt_ma_abstrakty_z_jezykiem(uczelnia, projekt): + """``Project/Abstract`` to ``cfMLangAnyMixed__Type`` — ``xml:lang`` + jest tam **wymagany**, w odróżnieniu od abstraktów publikacji.""" + from cerif_export.cerif.wspolne import XML_LANG + + el = serializuj_projekt(uczelnia, projekt) + abstrakty = el.findall(q("Abstract")) + assert [a.text for a in abstrakty] == [ + "Streszczenie projektu.", + "Project abstract.", + ] + assert [a.get(XML_LANG) for a in abstrakty] == ["pl", "en"] + + +@pytest.mark.django_db +def test_projekt_ma_koordynatora_z_jednostki(uczelnia, jednostka, projekt): + el = serializuj_projekt(uczelnia, projekt) + + koordynator = el.find(f"{q('Consortium')}/{q('Coordinator')}") + assert koordynator is not None + orgunit = koordynator.find(q("OrgUnit")) + assert orgunit.get("id") == f"OrgUnits/je-{jednostka.pk}" + assert orgunit.find(q("Name")).text == "Jednostka CERIF" + + +@pytest.mark.django_db +def test_ukryta_jednostka_nie_daje_pustego_konsorcjum(uczelnia, jednostka, projekt): + """Pusty ```` jest bezwartościowy, a ujawnienie nazwy + jednostki z opt-outem byłoby wyciekiem — więc nie ma go wcale.""" + jednostka.nie_eksportuj_przez_api = True + jednostka.save() + + el = serializuj_projekt(uczelnia, projekt) + assert el.find(q("Consortium")) is None + + +@pytest.mark.django_db +def test_projekt_ma_dyscypliny_i_slowa_kluczowe(uczelnia, projekt): + dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="nauki medyczne", kod="3.5") + projekt.dyscypliny.add(dyscyplina) + projekt.slowa_kluczowe.add("onkologia") + projekt.slowa_kluczowe_eng = ["oncology"] + projekt.save() + + el = serializuj_projekt(uczelnia, projekt) + + subject = el.find(q("Subject")) + assert subject.get("scheme") == const.SCHEMAT_DYSCYPLIN + assert subject.text == f"{const.SCHEMAT_DYSCYPLIN}/3.5" + + assert {k.text for k in el.findall(q("Keyword"))} == {"onkologia", "oncology"} + + +@pytest.mark.django_db +def test_zespol_projektu_dzieli_sie_na_kierownika_i_czlonkow( + uczelnia, projekt, fabryka_autorow +): + kierownik = fabryka_autorow("Kierowniczka") + wykonawca = fabryka_autorow("Wykonawca") + Projekt_Autor.objects.create( + projekt=projekt, autor=kierownik, rola=Projekt_Autor.ROLA_KIEROWNIK + ) + Projekt_Autor.objects.create( + projekt=projekt, autor=wykonawca, rola=Projekt_Autor.ROLA_WYKONAWCA + ) + + el = serializuj_projekt(uczelnia, projekt) + zespol = el.find(q("Team")) + assert zespol is not None + + # ``xs:sequence`` w ``Team``: PrincipalInvestigator przed Member. + assert nazwy_dzieci(zespol) == ["PrincipalInvestigator", "Member"] + + pi = zespol.find(f"{q('PrincipalInvestigator')}/{q('Person')}") + assert pi.get("id") == f"Persons/au-{kierownik.pk}" + czlonek = zespol.find(f"{q('Member')}/{q('Person')}") + assert czlonek.get("id") == f"Persons/au-{wykonawca.pk}" + + +@pytest.mark.django_db +def test_team_znika_przy_wylaczonym_eksporcie_osob(uczelnia, projekt, fabryka_autorow): + """Przy ``eksport_cerif_osoby=False`` osób nie ma w harveście, więc + referencja z ``Team`` byłaby wisząca — element nie powstaje wcale.""" + kierownik = fabryka_autorow("Kierowniczka") + Projekt_Autor.objects.create( + projekt=projekt, autor=kierownik, rola=Projekt_Autor.ROLA_KIEROWNIK + ) + + assert serializuj_projekt(uczelnia, projekt).find(q("Team")) is not None + + uczelnia.eksport_cerif_osoby = False + uczelnia.save() + + el = serializuj_projekt(uczelnia, projekt) + assert el.find(q("Team")) is None + assert "Kierowniczka" not in etree.tostring(el, encoding="unicode") + + +@pytest.mark.django_db +def test_autor_z_pokazuj_false_nie_trafia_do_zespolu( + uczelnia, projekt, fabryka_autorow +): + ukryty = fabryka_autorow("Ukryta", pokazuj=False) + Projekt_Autor.objects.create( + projekt=projekt, autor=ukryty, rola=Projekt_Autor.ROLA_WYKONAWCA + ) + + el = serializuj_projekt(uczelnia, projekt) + assert el.find(q("Team")) is None + + +@pytest.mark.django_db +def test_funded_by_to_referencja_a_as_osadza_funding(uczelnia, projekt, grantodawca): + """Dwie różne rzeczy w jednym kontenerze ``Funded``: ``By`` wskazuje na + OrgUnit-a grantodawcy, ``As`` niesie pełne ````.""" + finansowanie = dodaj_finansowanie(projekt, grantodawca) + + el = serializuj_projekt(uczelnia, projekt) + funded = el.find(q("Funded")) + assert funded is not None + assert nazwy_dzieci(funded) == ["By", "As"] + + orgunit = funded.find(f"{q('By')}/{q('OrgUnit')}") + assert orgunit.get("id") == f"OrgUnits/if-{grantodawca.pk}" + # Referencja, nie pełny rekord — bez identyfikatorów rejestrowych. + assert orgunit.find(q("FundRefID")) is None + + osadzone = funded.find(f"{q('As')}/{q('Funding')}") + assert osadzone is not None + assert osadzone.get("id") == f"Fundings/fn-{finansowanie.pk}" + assert osadzone.find(qt("Type")) is not None + assert osadzone.find(f"{q('Funder')}/{q('OrgUnit')}") is not None + + +@pytest.mark.django_db +def test_kazde_finansowanie_daje_wlasny_funded(uczelnia, projekt, grantodawca): + """``Funded`` ma ``maxOccurs="unbounded"`` — dwa źródła, dwa kontenery.""" + drugi = baker.make(Instytucja_Finansujaca, nazwa="Agencja Badań Medycznych") + dodaj_finansowanie(projekt, grantodawca) + dodaj_finansowanie(projekt, drugi, typ=Finansowanie.TYP_CONTRACT) + + el = serializuj_projekt(uczelnia, projekt) + assert len(el.findall(q("Funded"))) == 2 + + +@pytest.mark.django_db +def test_projekt_bez_finansowania_serializuje_sie(uczelnia, projekt): + """``Funded`` ma ``minOccurs="0"`` — projekt bez finansowania jest + poprawny i nie wolno go odsiewać z eksportu.""" + el = serializuj_projekt(uczelnia, projekt) + assert el.find(q("Funded")) is None + assert el.find(q("Title")) is not None + + provider = provider_dla_setu(const.SET_PROJECTS) + obiekty, _ = provider.strona(uczelnia, rozmiar=100) + assert projekt in obiekty + + +@pytest.mark.django_db +def test_kolejnosc_elementow_projektu(uczelnia, projekt, grantodawca): + dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="nauki medyczne", kod="3.5") + projekt.dyscypliny.add(dyscyplina) + projekt.slowa_kluczowe.add("onkologia") + dodaj_finansowanie(projekt, grantodawca) + Projekt_Autor.objects.create( + projekt=projekt, + autor=baker.make("bpp.Autor", nazwisko="Nowak", imiona="Jan"), + rola=Projekt_Autor.ROLA_KIEROWNIK, + ) + + el = serializuj_projekt(uczelnia, projekt) + kolejnosc = nazwy_dzieci(el) + assert kolejnosc == [ + "Acronym", + "Title", + "Title", + "StartDate", + "EndDate", + "Consortium", + "Funded", + "Subject", + "Keyword", + "Abstract", + "Abstract", + "Status", + ] + + +@pytest.mark.django_db +def test_serializacja_strony_nie_rosnie_z_liczba_projektow( + uczelnia, jednostka, grantodawca +): + """Reguła „serializer nie dotyka bazy" — mierzona, nie deklarowana. + + Liczba zapytań na stronę harvestu musi być **stała**: cała robota + siedzi w prefetchach providera i w prekomputowanych zbiorach + widoczności. Gdy rośnie z liczbą projektów, znaczy że serializer + dociąga coś sam — i pełny harvest zamienia się w N+1. + """ + provider = provider_dla_setu(const.SET_PROJECTS) + + def zapytania_dla(ile_projektow): + Projekt.objects.all().delete() + for numer in range(ile_projektow): + nowy = baker.make(Projekt, tytul=f"Projekt {numer}", jednostka=jednostka) + dodaj_finansowanie(nowy, grantodawca) + Projekt_Autor.objects.create( + projekt=nowy, + autor=baker.make("bpp.Autor", nazwisko=f"Nowak{numer}"), + rola=Projekt_Autor.ROLA_KIEROWNIK, + ) + + with CaptureQueriesContext(connection) as licznik: + obiekty, _ = provider.strona(uczelnia, rozmiar=100) + obiekty = list(obiekty) + kontekst = KontekstSerializacji( + namespace=NAMESPACE, + uczelnia=uczelnia, + widoczne=provider.zbiory_widocznosci(uczelnia, obiekty), + ) + for obiekt in obiekty: + cerif_project.serializuj(obiekt, kontekst) + return len(licznik.captured_queries) + + assert zapytania_dla(1) == zapytania_dla(5) + + +# -- zgodność z XSD ------------------------------------------------------ + + +@pytest.mark.django_db +def test_projekt_i_finansowanie_zgodne_z_xsd( + uczelnia, projekt, grantodawca, fabryka_autorow +): + from cerif_export.tests.test_serializery import sprawdz, zbuduj_schemat + + uczelnia.eksport_cerif_kwoty = True + uczelnia.save() + + dyscyplina = baker.make(Dyscyplina_Naukowa, nazwa="nauki medyczne", kod="3.5") + projekt.dyscypliny.add(dyscyplina) + projekt.slowa_kluczowe.add("onkologia") + projekt.slowa_kluczowe_eng = ["oncology"] + projekt.save() + + Projekt_Autor.objects.create( + projekt=projekt, + autor=fabryka_autorow("Kierowniczka"), + rola=Projekt_Autor.ROLA_KIEROWNIK, + ) + Projekt_Autor.objects.create( + projekt=projekt, + autor=fabryka_autorow("Wykonawca"), + rola=Projekt_Autor.ROLA_WYKONAWCA, + ) + finansowanie = dodaj_finansowanie( + projekt, + grantodawca, + kwota=decimal.Decimal("1000.00"), + waluta="PLN", + grant_doi="10.13039/501100004281-XYZ", + numer_umowy="UMO-2024/45/B/NZ7/01234", + ) + + schemat = zbuduj_schemat() + sprawdz(schemat, serializuj_projekt(uczelnia, projekt)) + sprawdz(schemat, serializuj_finansowanie(uczelnia, finansowanie)) + + +@pytest.mark.django_db +def test_pusty_projekt_zgodny_z_xsd(uczelnia, jednostka): + """Minimalny projekt — sam tytuł i status; reszta pól pusta.""" + from cerif_export.tests.test_serializery import sprawdz, zbuduj_schemat + + goly = baker.make( + Projekt, + tytul="Bez niczego", + tytul_en="", + akronim="", + abstrakt="", + abstrakt_en="", + data_rozpoczecia=None, + data_zakonczenia=None, + jednostka=jednostka, + ) + sprawdz(zbuduj_schemat(), serializuj_projekt(uczelnia, goly)) + + +# -- pełny harvest OAI --------------------------------------------------- + + +@pytest.mark.django_db +def test_sety_wychodza_z_harvestu_oai(uczelnia, projekt, grantodawca): + """``ListRecords`` bez parametru ``set`` musi wydać oba nowe rekordy. + + Sam queryset providera to za mało — brak wpisu w ``MODULY_SERIALIZERA`` + albo wisząca referencja wychodzą dopiero tutaj. + """ + finansowanie = dodaj_finansowanie(projekt, grantodawca) + + korzen = czasowniki.odpowiedz( + czasowniki.Zadanie( + uczelnia, + BASE_URL, + {"verb": "ListRecords", "metadataPrefix": const.METADATA_PREFIX}, + ) + ) + + bledy = [el for el in korzen.iter() if el.tag.endswith("}error")] + assert not bledy, [el.get("code") for el in bledy] + + wydane = { + el.text for el in korzen.iter() if el.tag.endswith("}identifier") and el.text + } + ns = uczelnia.oai_repository_identifier() + assert identyfikatory.zbuduj(ns, projekt) in wydane + assert identyfikatory.zbuduj(ns, finansowanie) in wydane + assert identyfikatory.zbuduj(ns, grantodawca) in wydane + + +@pytest.mark.django_db +def test_referencje_projektu_sa_rozwiazywalne( + uczelnia, projekt, grantodawca, fabryka_autorow +): + """Każdy ``@id`` osadzony w ładunku musi mieć swój rekord w harveście. + + To dokładnie ta kontrola (5a), na której ``openaire-cris-validator`` + odrzuca całe repozytorium. + """ + Projekt_Autor.objects.create( + projekt=projekt, + autor=fabryka_autorow("Kierowniczka"), + rola=Projekt_Autor.ROLA_KIEROWNIK, + ) + dodaj_finansowanie(projekt, grantodawca) + + korzen = czasowniki.odpowiedz( + czasowniki.Zadanie( + uczelnia, + BASE_URL, + {"verb": "ListRecords", "metadataPrefix": const.METADATA_PREFIX}, + ) + ) + + wydane = { + el.text.split(":", 2)[-1] + for el in korzen.iter() + if el.tag.endswith("}identifier") and el.text + } + osadzone = set() + for metadata in (el for el in korzen.iter() if el.tag.endswith("}metadata")): + for el in metadata.iter(): + if el.get("id"): + osadzone.add(el.get("id")) + + assert osadzone, "harvest nie osadził żadnej referencji" + assert osadzone <= wydane, sorted(osadzone - wydane) diff --git a/src/cerif_export/tests/test_providery.py b/src/cerif_export/tests/test_providery.py index 441bea667..d95af4247 100644 --- a/src/cerif_export/tests/test_providery.py +++ b/src/cerif_export/tests/test_providery.py @@ -549,13 +549,18 @@ def test_zbiory_widocznosci_to_stala_liczba_zapytan( "set_spec", [ const.SET_PRODUCTS, - const.SET_PROJECTS, - const.SET_FUNDING, const.SET_EQUIPMENTS, ], ) def test_provider_pusty_odpowiada_zerem_rekordow(set_spec, uczelnia_cerif): - """Sety muszą istnieć („even if unpopulated"), ale nic nie zwracają.""" + """Sety muszą istnieć („even if unpopulated"), ale nic nie zwracają. + + Zostały już tylko dwa takie sety. ``openaire_cris_projects`` + i ``openaire_cris_funding`` mają — od wprowadzenia modeli ``Projekt`` + i ``Finansowanie`` — własne providery: ich pusta odpowiedź zależy + dziś od zawartości bazy, a nie od klasy providera, i jest sprawdzana + w ``test_eksport_projektow.py``. + """ provider = provider_dla_setu(set_spec) assert provider.strona(uczelnia_cerif) == ([], None) From 5625a85bfc6939c8d4d437cf44cb59ce9ba1a67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 11:40:08 +0200 Subject: [PATCH 09/11] =?UTF-8?q?feat(cerif):=20OriginatesFrom=20=E2=80=94?= =?UTF-8?q?=20powi=C4=85zanie=20publikacji=20i=20patent=C3=B3w=20z=20proje?= =?UTF-8?q?ktami=20(#701)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publikacje i patenty osadzają w ``OriginatesFrom`` pełny element ```` (grupa podstawień ``ProjectFunding__SubstitutionGroupHead``), a ten w ``Funded/As`` — swoje ````. Dzięki temu agregator dostaje łańcuch publikacja → projekt → finansowanie zamiast dwóch rozłącznych list. ``RekordBPPBaza`` dostaje ``GenericRelation`` do ``Grant_Rekordu`` — bez odwrotnej relacji nie dało się prefetchować grantów od strony rekordu. Zmiana nie tworzy kolumny, więc nie wymaga migracji; zmienia natomiast kaskadę kasowania (wiersze ``Grant_Rekordu`` znikają razem z rekordem, zamiast zostawać sierotami). Zbiory widoczności providerów publikacji i patentów obejmują teraz także zespół projektu, jego jednostkę realizującą i grantodawców — inaczej osadzony projekt referowałby encje bez ``@id``. Co-Authored-By: Claude Opus 5 (1M context) --- src/bpp/models/abstract/records.py | 13 + src/cerif_export/cerif/patent.py | 7 +- src/cerif_export/cerif/publication.py | 7 +- src/cerif_export/cerif/wspolne.py | 67 +++ src/cerif_export/providers/patenty.py | 17 +- src/cerif_export/providers/projekty.py | 126 +++- src/cerif_export/providers/publikacje.py | 17 +- .../tests/test_originates_from.py | 544 ++++++++++++++++++ 8 files changed, 774 insertions(+), 24 deletions(-) create mode 100644 src/cerif_export/tests/test_originates_from.py diff --git a/src/bpp/models/abstract/records.py b/src/bpp/models/abstract/records.py index 51fc62277..2ccd7b53b 100644 --- a/src/bpp/models/abstract/records.py +++ b/src/bpp/models/abstract/records.py @@ -2,6 +2,7 @@ Modele bazowe dla rekordów BPP. """ +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from bpp.models.util import ModelZOpisemBibliograficznym @@ -16,6 +17,18 @@ class RekordBPPBaza( """Klasa bazowa wszystkich rekordów (patenty, prace doktorskie, habilitacyjne, wydawnictwa zwarte i ciągłe)""" + # ``Grant_Rekordu`` wiąże rekord z numerem grantu przez + # ``GenericForeignKey``. Bez odwrotnej ``GenericRelation`` nie da się + # tego prefetchować od strony rekordu, a eksport CERIF osadza w każdej + # publikacji projekt grantu (``Publication/OriginatesFrom``) — bez + # prefetcha byłoby to kilka zapytań NA REKORD przy pełnym harveście. + # + # ``GenericRelation`` nie tworzy kolumny ani indeksu, więc nie wymaga + # migracji. Zmienia natomiast kaskadę kasowania: usunięcie rekordu + # usuwa teraz jego wiersze ``Grant_Rekordu`` zamiast zostawiać je + # jako sieroty wskazujące na nieistniejący ``object_id``. + granty_rekordu = GenericRelation("bpp.Grant_Rekordu") + class Meta: abstract = True diff --git a/src/cerif_export/cerif/patent.py b/src/cerif_export/cerif/patent.py index 95b449b5e..7e1c23df5 100644 --- a/src/cerif_export/cerif/patent.py +++ b/src/cerif_export/cerif/patent.py @@ -7,7 +7,8 @@ Provider musi dostarczyć: ``select_related("rodzaj_prawa")`` oraz ``prefetch_related("autorzy_set__autor", "autorzy_set__jednostka", -"slowa_kluczowe")``. +"slowa_kluczowe")``, a dla ``OriginatesFrom`` — komplet prefetchy +projektu (``providers.projekty.prefetche_projektu``). """ from cerif_export import const @@ -62,6 +63,10 @@ def serializuj(patent, ctx): dodaj(el, "URL", tekst(getattr(patent, "www", None))) dodaj_wynalazcow(el, patent, ctx) wspolne.dodaj_slowa_kluczowe(el, patent) + # ``xs:sequence``: ... Keyword, OriginatesFrom, Predecessor, References, + # FileLocations — projekty wypadają PO słowach kluczowych, a PRZED + # lokalizacjami plików. + wspolne.dodaj_pochodzenie(el, patent, ctx) wspolne.dodaj_pliki(el, patent) return el diff --git a/src/cerif_export/cerif/publication.py b/src/cerif_export/cerif/publication.py index 737da83a8..bf7f0b63a 100644 --- a/src/cerif_export/cerif/publication.py +++ b/src/cerif_export/cerif/publication.py @@ -32,7 +32,9 @@ ``prefetch_related``: ``autorzy_set__autor``, ``autorzy_set__jednostka``, ``autorzy_set__typ_odpowiedzialnosci``, ``dodatkowe_tytuly__jezyk``, -``streszczenia__jezyk_streszczenia``, ``slowa_kluczowe``. +``streszczenia__jezyk_streszczenia``, ``slowa_kluczowe``, oraz — dla +``OriginatesFrom``, które osadza pełny ```` — komplet +z ``providers.projekty.prefetche_pochodzenia``. """ import datetime @@ -378,6 +380,9 @@ def serializuj(obj, ctx): dodaj_licencje(el, obj) wspolne.dodaj_slowa_kluczowe(el, obj) dodaj_streszczenia(el, obj) + # ``xs:sequence``: ... Keyword, Abstract, Status, OriginatesFrom, + # PresentedAt ... — projekty muszą wyjść PRZED konferencją. + wspolne.dodaj_pochodzenie(el, obj, ctx) dodaj_konferencje(el, obj, ctx) dodaj_dostep(el, obj) wspolne.dodaj_pliki(el, obj) diff --git a/src/cerif_export/cerif/wspolne.py b/src/cerif_export/cerif/wspolne.py index 87b304235..13e725909 100644 --- a/src/cerif_export/cerif/wspolne.py +++ b/src/cerif_export/cerif/wspolne.py @@ -383,6 +383,73 @@ def dodaj_wklad_osoby( return el +def projekty_rekordu(obj, ctx): + """Projekty, z których pochodzi rekord — bez duplikatów, w stabilnej + kolejności. + + Droga jest dwuetapowa: rekord → ``Grant_Rekordu`` → ``Grant.projekt``. + ``Grant`` bez projektu (zwykła etykieta numeru) nie daje niczego, bo + ``OriginatesFrom`` bez dziecka z grupy podstawień byłby niepoprawny + wobec XSD. + + Odsiew po uczelni **nie jest** nadgorliwością. ``Grant`` jest w BPP + słownikiem współdzielonym między tenantami, więc ``grant.projekt`` + bywa projektem cudzej uczelni; osadzony ```` zawsze dostaje + ``@id`` (``ustaw_id_rekordu``), więc taki projekt dałby referencję do + rekordu, którego harvester nigdy nie zobaczy. Warunek jest dokładnie + tym samym, co filtr providera + (``providers.projekty.widoczne_projekty``): ``Projekt.jednostka`` to + jedyny nośnik przynależności projektu do uczelni. + + Ten sam projekt bywa rozliczany kilkoma numerami grantu — stąd + deduplikacja po kluczu głównym. + + Wymaga od providera prefetchu ``granty_rekordu`` z + ``select_related("grant", "grant__projekt", "grant__projekt__jednostka")`` + — bez tego każdy odczyt byłby zapytaniem, czego serializerowi nie + wolno. + """ + powiazania = getattr(obj, "granty_rekordu", None) + if powiazania is None: + return [] + + uczelnia_pk = getattr(ctx.uczelnia, "pk", None) + projekty, widziane = [], set() + for powiazanie in powiazania.all(): + grant = powiazanie.grant + if grant is None or grant.projekt_id is None: + continue + if grant.projekt_id in widziane: + continue + projekt = grant.projekt + if projekt.jednostka.uczelnia_id != uczelnia_pk: + continue + widziane.add(projekt.pk) + projekty.append(projekt) + return projekty + + +def dodaj_pochodzenie(el, obj, ctx): + """Dopisz ``OriginatesFrom`` z **osadzonym pełnym** ````. + + Profil nie przewiduje tu referencji: element rozszerza + ``cfLink__BaseType`` o obowiązkowe dziecko z grupy podstawień + ``ProjectFunding__SubstitutionGroupHead``, czyli całe ```` + albo całe ````. Osadzamy projekt — niesie on w + ``Funded/As`` również swoje finansowania, więc łańcuch publikacja → + projekt → finansowanie domyka się w jednym poddrzewie. + + Import ``cerif.project`` jest lokalny, bo ``project`` importuje ten + moduł (helpery ``element``/``dodaj``) — na poziomie modułu powstałby + cykl. + """ + from cerif_export.cerif import project as cerif_project + + for projekt in projekty_rekordu(obj, ctx): + kontener = dodaj_kontener(el, "OriginatesFrom") + kontener.append(cerif_project.serializuj(projekt, ctx)) + + def dodaj_slowa_kluczowe(rodzic, obj): """Dopisz ``Keyword`` z obu list słów kluczowych rekordu. diff --git a/src/cerif_export/providers/patenty.py b/src/cerif_export/providers/patenty.py index add387809..7be1925b9 100644 --- a/src/cerif_export/providers/patenty.py +++ b/src/cerif_export/providers/patenty.py @@ -15,9 +15,14 @@ from cerif_export.providers.jednostki import ( widoczne_jednostki, widoczne_pk, + widoczni_grantodawcy, wymagaj_uczelni, ) from cerif_export.providers.osoby import widoczni_autorzy +from cerif_export.providers.projekty import ( + klucze_osadzonych_projektow, + prefetche_pochodzenia, +) def widoczne_patenty(uczelnia): @@ -73,11 +78,14 @@ def queryset(self, uczelnia, model): return ( widoczne_patenty(uczelnia) .select_related("rodzaj_prawa", "status_korekty", "wydzial") - .prefetch_related("slowa_kluczowe", PREFETCH_TWORCOW) + .prefetch_related( + "slowa_kluczowe", PREFETCH_TWORCOW, *prefetche_pochodzenia() + ) ) def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: - """Prekomputuj widoczność twórców (``Inventors``) i ich jednostek.""" + """Prekomputuj widoczność twórców (``Inventors``), ich jednostek + oraz encji osadzanych przez ``OriginatesFrom``.""" wymagaj_uczelni(uczelnia) autorzy, jednostki = set(), set() @@ -87,7 +95,12 @@ def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: autorzy.add(autorstwo.autor_id) jednostki.add(autorstwo.jednostka_id) + z_projektow = klucze_osadzonych_projektow(obiekty) + autorzy |= z_projektow[0] + jednostki |= z_projektow[1] + return ZbioryWidocznosci( autorzy=widoczne_pk(widoczni_autorzy(uczelnia), autorzy), jednostki=widoczne_pk(widoczne_jednostki(uczelnia), jednostki), + grantodawcy=widoczne_pk(widoczni_grantodawcy(uczelnia), z_projektow[2]), ) diff --git a/src/cerif_export/providers/projekty.py b/src/cerif_export/providers/projekty.py index 3e1f9e782..0ed8da56c 100644 --- a/src/cerif_export/providers/projekty.py +++ b/src/cerif_export/providers/projekty.py @@ -10,6 +10,14 @@ dotyczy jednostki jako encji organizacyjnej, a nie prowadzonych w niej badań. Skutkiem jest jedynie brak ``Consortium/Coordinator`` w rekordzie — serializer nie osadzi jednostki spoza zbioru widoczności. + +Moduł obsługuje też **drugie** miejsce, w którym projekt się pojawia: +``Publication/OriginatesFrom`` i ``Patent/OriginatesFrom`` osadzają pełne +````. Prefetche (:func:`prefetche_pochodzenia`) i zbiory +widoczności (:func:`klucze_osadzonych_projektow`) dla tamtej ścieżki +mieszkają tutaj, a nie w providerach publikacji i patentów, bo są +pochodną tego, czego wymaga ``cerif.project.serializuj`` — dwie kopie tej +wiedzy rozjechałyby się przy pierwszym nowym elemencie projektu. """ from django.db.models import Prefetch @@ -34,20 +42,105 @@ def widoczne_projekty(uczelnia): return Projekt.objects.filter(jednostka__uczelnia=uczelnia) -# Zespół projektu. Kolejność jest stabilna (po kluczu głównym), bo model nie -# ma pola porządkującego, a niedeterministyczna kolejność w XML-u zamieniałaby -# każdy harvest w pozorną zmianę rekordu. -PREFETCH_ZESPOLU = Prefetch( - "projekt_autor_set", - queryset=Projekt_Autor.objects.select_related("autor").order_by("pk"), -) +def prefetche_projektu(prefiks=""): + """Komplet prefetchy wymaganych przez ``cerif.project.serializuj``. + + Funkcja, a nie stałe, bo te same relacje trzeba założyć w DWÓCH + miejscach: na rekordzie projektu (prefiks pusty) i o dwa przeskoki + dalej, przy publikacji osadzającej projekt w ``OriginatesFrom`` + (prefiks ``granty_rekordu__grant__projekt__``). Rozjazd między + kopiami tej listy nie wywala testu — daje po cichu N+1 przy pełnym + harveście. + + Kolejność zespołu i finansowań jest stabilna (po kluczu głównym), bo + żaden z tych modeli nie ma pola porządkującego, a niedeterministyczna + kolejność w XML-u zamieniałaby każdy harvest w pozorną zmianę rekordu. + """ + return [ + Prefetch( + f"{prefiks}projekt_autor_set", + queryset=Projekt_Autor.objects.select_related("autor").order_by("pk"), + ), + # Serializer osadza pełne ```` w ``Funded/As``, więc + # potrzebuje obiektu instytucji, nie tylko jej klucza. + Prefetch( + f"{prefiks}finansowanie_set", + queryset=Finansowanie.objects.select_related("instytucja").order_by("pk"), + ), + f"{prefiks}dyscypliny", + f"{prefiks}slowa_kluczowe", + ] + + +# Ścieżka od rekordu bibliograficznego do projektu: ``Grant_Rekordu`` wiąże +# rekord z numerem grantu (``GenericRelation`` na ``RekordBPPBaza``), a +# ``Grant.projekt`` — numer z projektem. +PREFIKS_PROJEKTU_REKORDU = "granty_rekordu__grant__projekt__" + + +def prefetche_pochodzenia(): + """Prefetche, których wymaga ``Publication``/``Patent`` ``OriginatesFrom``. + + ``select_related`` na querysecie ``Grant_Rekordu`` ściąga grant, projekt + i jego jednostkę jednym zapytaniem na całą stronę harvestu; dalsze + pozycje dociągają to, czego potrzebuje sam ``cerif.project.serializuj``. + Bez kompletu każda publikacja kosztowałaby kilka dodatkowych zapytań — + niewidoczne w teście z jednym rekordem, zabójcze przy pełnym harveście. + + Kolejność jest stabilna (po kluczu głównym ``Grant_Rekordu``), bo model + nie ma pola porządkującego. + """ + from bpp.models.grant import Grant_Rekordu + + return [ + Prefetch( + "granty_rekordu", + queryset=Grant_Rekordu.objects.select_related( + "grant", + "grant__projekt", + # ``jednostka`` rozstrzyga przynależność projektu do tenanta + # (serializer odsiewa po ``uczelnia_id``) i jest osadzana + # jako ``Consortium/Coordinator``. + "grant__projekt__jednostka", + ).order_by("pk"), + ), + *prefetche_projektu(PREFIKS_PROJEKTU_REKORDU), + ] + + +def klucze_osadzonych_projektow(obiekty): + """Klucze encji, które osadzi ``OriginatesFrom`` — ``(autorzy, + jednostki, grantodawcy)``. + + Bez tego zbiory widoczności providera publikacji i patentów obejmowałyby + wyłącznie ludzi i jednostki samej publikacji — a osadzony projekt + referuje SWÓJ zespół, SWOJĄ jednostkę realizującą i SWOICH grantodawców. + Kierownik projektu nie musi być autorem publikacji, więc bez tego kroku + ``Team/PrincipalInvestigator`` osadzałby ``Person`` bez ``@id``, + a ``Funded/By`` — ``OrgUnit`` bez ``@id``. + + Kandydatów nie filtrujemy tu po uczelni: przecięcie z querysetami + widoczności robi i tak ``widoczne_pk``, a warunek zdublowany w dwóch + miejscach rozjeżdża się przy pierwszej zmianie. + """ + autorzy, jednostki, grantodawcy = set(), set(), set() + + for obj in obiekty: + powiazania = getattr(obj, "granty_rekordu", None) + if powiazania is None: + continue + for powiazanie in powiazania.all(): + grant = powiazanie.grant + if grant is None or grant.projekt_id is None: + continue + projekt = grant.projekt + jednostki.add(projekt.jednostka_id) + for czlonek in projekt.projekt_autor_set.all(): + autorzy.add(czlonek.autor_id) + for finansowanie in projekt.finansowanie_set.all(): + grantodawcy.add(finansowanie.instytucja_id) -# Finansowania wraz z grantodawcami — serializer osadza pełne ```` -# w ``Funded/As``, więc potrzebuje obiektu instytucji, nie tylko jej klucza. -PREFETCH_FINANSOWANIA = Prefetch( - "finansowanie_set", - queryset=Finansowanie.objects.select_related("instytucja").order_by("pk"), -) + return autorzy, jednostki, grantodawcy class ProviderProjektow(ProviderEncji): @@ -67,12 +160,7 @@ def queryset(self, uczelnia, model): return ( widoczne_projekty(uczelnia) .select_related("jednostka") - .prefetch_related( - PREFETCH_ZESPOLU, - PREFETCH_FINANSOWANIA, - "dyscypliny", - "slowa_kluczowe", - ) + .prefetch_related(*prefetche_projektu()) ) def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: diff --git a/src/cerif_export/providers/publikacje.py b/src/cerif_export/providers/publikacje.py index a6a1ebd86..2f7af2a33 100644 --- a/src/cerif_export/providers/publikacje.py +++ b/src/cerif_export/providers/publikacje.py @@ -44,9 +44,14 @@ from cerif_export.providers.jednostki import ( widoczne_jednostki, widoczne_pk, + widoczni_grantodawcy, wymagaj_uczelni, ) from cerif_export.providers.osoby import widoczni_autorzy +from cerif_export.providers.projekty import ( + klucze_osadzonych_projektow, + prefetche_pochodzenia, +) from cerif_export.slowniki import coar # Wydawnictwa: mają ``nie_eksportuj_przez_api`` i model autorstwa. @@ -208,6 +213,8 @@ def _prefetche_wydawnictwa(model): """ return [ "slowa_kluczowe", + # ``OriginatesFrom`` osadza w publikacji pełny ````. + *prefetche_pochodzenia(), Prefetch( "autorzy_set", queryset=_AUTORSTWA[model] @@ -318,7 +325,7 @@ def queryset(self, uczelnia, model): return ( widoczne_prace(model, uczelnia) .select_related(*_SELECT_PRACY) - .prefetch_related("slowa_kluczowe") + .prefetch_related("slowa_kluczowe", *prefetche_pochodzenia()) .annotate( **{ ADNOTACJA_COAR: Value( @@ -366,9 +373,17 @@ def zbiory_widocznosci(self, uczelnia, obiekty) -> ZbioryWidocznosci: autorzy.add(autorstwo.autor_id) jednostki.add(autorstwo.jednostka_id) + # ``OriginatesFrom`` osadza pełny projekt, który referuje SWÓJ zespół, + # SWOJĄ jednostkę realizującą i SWOICH grantodawców — żadne z nich + # nie musi mieć nic wspólnego z autorami publikacji. + z_projektow = klucze_osadzonych_projektow(obiekty) + autorzy |= z_projektow[0] + jednostki |= z_projektow[1] + return ZbioryWidocznosci( autorzy=widoczne_pk(widoczni_autorzy(uczelnia), autorzy), jednostki=widoczne_pk(widoczne_jednostki(uczelnia), jednostki), + grantodawcy=widoczne_pk(widoczni_grantodawcy(uczelnia), z_projektow[2]), zrodla=widoczne_pk(widoczne_zrodla(uczelnia), zrodla), konferencje=widoczne_pk(widoczne_konferencje(uczelnia), konferencje), publikacje=frozenset( diff --git a/src/cerif_export/tests/test_originates_from.py b/src/cerif_export/tests/test_originates_from.py new file mode 100644 index 000000000..97259d287 --- /dev/null +++ b/src/cerif_export/tests/test_originates_from.py @@ -0,0 +1,544 @@ +"""``OriginatesFrom`` — powiązanie publikacji i patentów z projektami. + +To ogniwo, dla którego sety ``openaire_cris_projects`` i +``openaire_cris_funding`` w ogóle mają sens. Bez niego agregator dostaje +dwie rozłączne listy: osobno publikacje, osobno projekty; walidator +przechodzi, wartość merytoryczna jest zerowa. + +Trzy rzeczy pilnowane tu szczególnie, bo wychodzą dopiero pod walidatorem +OpenAIRE albo na pełnym harveście: + +1. ``OriginatesFrom`` **osadza** pełne ```` (grupa podstawień + ``ProjectFunding__SubstitutionGroupHead``), a nie referencję — i wypada + w ``xs:sequence`` w dokładnie jednym miejscu (Publication: między + ``Status`` a ``PresentedAt``; Patent: między ``Keyword`` + a ``Predecessor``); +2. osadzony projekt ma ``@id``, więc **musi** realnie wyjść w secie + projektów — stąd odsiew projektów cudzej uczelni (``Grant`` jest + współdzielony i jego ``projekt`` bywa spoza tenanta); +3. serializer nie dotyka bazy — mierzone licznikiem zapytań, nie + deklarowane. +""" + +import pytest +from django.contrib.sites.models import Site +from django.db import connection +from django.test.utils import CaptureQueriesContext +from lxml import etree +from model_bakery import baker + +from bpp.models import ( + Grant, + Grant_Rekordu, + Instytucja_Finansujaca, + Jednostka, + Projekt, + Projekt_Autor, + Uczelnia, +) +from bpp.models.konferencja import Konferencja +from bpp.models.patent import Patent +from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle +from cerif_export import const, identyfikatory +from cerif_export.cerif import patent as cerif_patent +from cerif_export.cerif import publication as cerif_publication +from cerif_export.kontekst import KontekstSerializacji +from cerif_export.oai import czasowniki +from cerif_export.providers import provider_dla_setu + +NAMESPACE = "cerif.example.org" +BASE_URL = "https://cerif.example.org/cerif-oai/" + + +def q(nazwa): + """Nazwa elementu z przestrzeni nazw profilu CERIF.""" + return f"{{{const.NS_CERIF}}}{nazwa}" + + +def nazwy_dzieci(el): + """Lokalne nazwy dzieci elementu — w kolejności wystąpienia.""" + return [etree.QName(dziecko).localname for dziecko in el] + + +# -- budowanie danych ---------------------------------------------------- + + +@pytest.fixture +def grantodawca(db): + return baker.make( + Instytucja_Finansujaca, + nazwa="Narodowe Centrum Nauki", + akronim="NCN", + fundref_id="501100004281", + ) + + +@pytest.fixture +def projekt(jednostka): + return baker.make( + Projekt, + tytul="Badany projekt", + tytul_en="Studied project", + akronim="BAP", + status=Projekt.STATUS_W_TRAKCIE, + data_rozpoczecia="2024-01-01", + data_zakonczenia="2026-12-31", + jednostka=jednostka, + ) + + +@pytest.fixture +def obca_jednostka(db): + """Jednostka innej uczelni — ten sam ``Grant`` bywa współdzielony.""" + obca = Uczelnia.objects.create( + nazwa="Obca uczelnia", + skrot="OBC", + site=Site.objects.create(domain="obca.example.org", name="obca"), + ) + return Jednostka.objects.create(nazwa="Obca jednostka", skrot="OJE", uczelnia=obca) + + +def przypnij_grant(rekord, numer, projekt=None): + """Utwórz numer grantu i przypnij go do rekordu.""" + grant = baker.make(Grant, numer_projektu=numer, projekt=projekt) + Grant_Rekordu.objects.create(rekord=rekord, grant=grant) + return grant + + +def dodaj_finansowanie(projekt, grantodawca, **kwargs): + from bpp.models import Finansowanie + + kwargs.setdefault("typ", Finansowanie.TYP_GRANT) + kwargs.setdefault("nazwa_programu", "OPUS 24") + kwargs.setdefault("kwota", None) + return baker.make(Finansowanie, projekt=projekt, instytucja=grantodawca, **kwargs) + + +# -- serializacja przez provider (jak w produkcji) ----------------------- + + +def _serializuj(uczelnia, set_spec, obiekt, serializer): + """Zserializuj rekord dokładnie tak, jak robi to warstwa OAI. + + Obiekt jest **ponownie pobierany** przez queryset providera, żeby miał + komplet prefetchy. Serializer nie ma prawa dotykać bazy, więc test na + obiekcie prosto z ``baker.make`` sprawdzałby co innego niż produkcja. + """ + provider = provider_dla_setu(set_spec) + zaladowany = provider.queryset(uczelnia, type(obiekt)).get(pk=obiekt.pk) + kontekst = KontekstSerializacji( + namespace=NAMESPACE, + uczelnia=uczelnia, + widoczne=provider.zbiory_widocznosci(uczelnia, [zaladowany]), + ) + return serializer(zaladowany, kontekst) + + +def serializuj_publikacje(uczelnia, rekord): + return _serializuj( + uczelnia, const.SET_PUBLICATIONS, rekord, cerif_publication.serializuj + ) + + +def serializuj_patent(uczelnia, rekord): + return _serializuj(uczelnia, const.SET_PATENTS, rekord, cerif_patent.serializuj) + + +@pytest.fixture +def publikacja(fabryka_wydawnictw, fabryka_autorow): + return fabryka_wydawnictw( + Wydawnictwo_Ciagle, + autor=fabryka_autorow("Autorska"), + tytul_oryginalny="Praca z projektu", + ) + + +@pytest.fixture +def wynalazek(jednostka, fabryka_autorow, status_ok): + rekord = baker.make( + Patent, + tytul_oryginalny="Wynalazek z projektu", + status_korekty=status_ok, + rok=2020, + nie_eksportuj_przez_api=False, + numer_prawa_wylacznego="PL 123456", + ) + rekord.dodaj_autora(fabryka_autorow("Wynalazca"), jednostka) + return rekord + + +# -- kiedy elementu NIE ma ----------------------------------------------- + + +@pytest.mark.django_db +def test_publikacja_bez_grantow_nie_ma_originates_from(uczelnia, publikacja): + """``minOccurs="0"`` — publikacja bez projektu jest poprawna.""" + el = serializuj_publikacje(uczelnia, publikacja) + assert el.find(q("OriginatesFrom")) is None + + +@pytest.mark.django_db +def test_publikacja_z_grantem_bez_projektu_nie_ma_originates_from(uczelnia, publikacja): + """Sam numer grantu to za mało. + + ``Grant`` bez ``projekt`` jest w BPP zwykłą etykietą tekstową — nie ma + z czego zbudować encji ``Project``, a pusty ``OriginatesFrom`` byłby + niepoprawny wobec XSD (dziecko z grupy podstawień jest obowiązkowe). + """ + przypnij_grant(publikacja, "BEZ/PROJEKTU", projekt=None) + + el = serializuj_publikacje(uczelnia, publikacja) + assert el.find(q("OriginatesFrom")) is None + + +@pytest.mark.django_db +def test_projekt_cudzej_uczelni_nie_wychodzi_w_originates_from( + uczelnia, publikacja, obca_jednostka +): + """``Grant`` jest współdzielony między uczelniami. + + Osadzony ``Project`` zawsze dostaje ``@id``, więc projekt spoza tenanta + dałby referencję do rekordu, którego harvester nigdy nie zobaczy — + dokładnie ta klasa błędu, na której walidator odrzuca repozytorium + (kontrola integralności referencyjnej 5a). + """ + obcy = baker.make(Projekt, tytul="Cudzy projekt", jednostka=obca_jednostka) + przypnij_grant(publikacja, "CUDZY/1", projekt=obcy) + + el = serializuj_publikacje(uczelnia, publikacja) + assert el.find(q("OriginatesFrom")) is None + assert "Cudzy projekt" not in etree.tostring(el, encoding="unicode") + + +# -- osadzanie projektu --------------------------------------------------- + + +@pytest.mark.django_db +def test_publikacja_z_projektem_osadza_pelny_project(uczelnia, publikacja, projekt): + """``OriginatesFrom`` osadza element z grupy podstawień, nie referencję.""" + przypnij_grant(publikacja, "Z/PROJEKTEM", projekt=projekt) + + el = serializuj_publikacje(uczelnia, publikacja) + pochodzenie = el.find(q("OriginatesFrom")) + assert pochodzenie is not None + + osadzony = pochodzenie.find(q("Project")) + assert osadzony is not None + assert osadzony.get("id") == f"Projects/pj-{projekt.pk}" + assert osadzony.find(q("Acronym")).text == "BAP" + assert [t.text for t in osadzony.findall(q("Title"))] == [ + "Badany projekt", + "Studied project", + ] + assert osadzony.find(q("StartDate")).text == "2024-01-01" + + +@pytest.mark.django_db +def test_osadzony_projekt_niesie_wlasne_finansowanie( + uczelnia, publikacja, projekt, grantodawca +): + """Łańcuch domyka się dopiero na finansowaniu. + + ``Project/Funded/As`` osadza pełne ```` z ``@id``, więc rekord + finansowania też musi być w harveście — ``ProviderFinansowania`` + filtruje identycznie jak ``ProviderProjektow``. + """ + finansowanie = dodaj_finansowanie(projekt, grantodawca) + przypnij_grant(publikacja, "Z/FINANSOWANIEM", projekt=projekt) + + el = serializuj_publikacje(uczelnia, publikacja) + funded = el.find(f"{q('OriginatesFrom')}/{q('Project')}/{q('Funded')}") + assert funded is not None + assert nazwy_dzieci(funded) == ["By", "As"] + + orgunit = funded.find(f"{q('By')}/{q('OrgUnit')}") + assert orgunit.get("id") == f"OrgUnits/if-{grantodawca.pk}" + + osadzone = funded.find(f"{q('As')}/{q('Funding')}") + assert osadzone.get("id") == f"Fundings/fn-{finansowanie.pk}" + + +@pytest.mark.django_db +def test_zespol_osadzonego_projektu_ma_id_osoby( + uczelnia, publikacja, projekt, fabryka_autorow +): + """Zbiór ``autorzy`` providera publikacji musi objąć też zespół projektu. + + Kierownik projektu nie musi być autorem publikacji — bez rozszerzenia + zbioru widoczności ``Team/PrincipalInvestigator`` osadzałby ``Person`` + bez ``@id``. + """ + kierowniczka = fabryka_autorow("Kierowniczka") + Projekt_Autor.objects.create( + projekt=projekt, autor=kierowniczka, rola=Projekt_Autor.ROLA_KIEROWNIK + ) + przypnij_grant(publikacja, "Z/ZESPOLEM", projekt=projekt) + + el = serializuj_publikacje(uczelnia, publikacja) + osoba = el.find( + f"{q('OriginatesFrom')}/{q('Project')}/{q('Team')}" + f"/{q('PrincipalInvestigator')}/{q('Person')}" + ) + assert osoba is not None + assert osoba.get("id") == f"Persons/au-{kierowniczka.pk}" + + +@pytest.mark.django_db +def test_koordynator_osadzonego_projektu_ma_id_jednostki( + uczelnia, publikacja, fabryka_autorow +): + """Jednostka realizująca bywa inna niż jednostka autorów publikacji.""" + inna = Jednostka.objects.create( + nazwa="Katedra Projektowa", skrot="KPR", uczelnia=uczelnia + ) + wlasny = baker.make(Projekt, tytul="Projekt katedry", jednostka=inna) + przypnij_grant(publikacja, "Z/KATEDRY", projekt=wlasny) + + el = serializuj_publikacje(uczelnia, publikacja) + orgunit = el.find( + f"{q('OriginatesFrom')}/{q('Project')}/{q('Consortium')}" + f"/{q('Coordinator')}/{q('OrgUnit')}" + ) + assert orgunit is not None + assert orgunit.get("id") == f"OrgUnits/je-{inna.pk}" + + +@pytest.mark.django_db +def test_dwa_granty_tego_samego_projektu_daja_jeden_originates_from( + uczelnia, publikacja, projekt +): + """Deduplikacja po projekcie. + + Jeden projekt bywa rozliczany kilkoma numerami grantu; dwa identyczne + ``OriginatesFrom`` przy jednej publikacji to czysty szum w harveście. + """ + przypnij_grant(publikacja, "PIERWSZY/1", projekt=projekt) + przypnij_grant(publikacja, "DRUGI/2", projekt=projekt) + + el = serializuj_publikacje(uczelnia, publikacja) + assert len(el.findall(q("OriginatesFrom"))) == 1 + + +@pytest.mark.django_db +def test_dwa_rozne_projekty_daja_dwa_originates_from(uczelnia, publikacja, projekt): + """``maxOccurs="unbounded"`` — publikacja bywa efektem dwóch projektów.""" + drugi = baker.make(Projekt, tytul="Drugi projekt", jednostka=projekt.jednostka) + przypnij_grant(publikacja, "PIERWSZY/1", projekt=projekt) + przypnij_grant(publikacja, "DRUGI/2", projekt=drugi) + + el = serializuj_publikacje(uczelnia, publikacja) + osadzone = { + p.get("id") for p in el.findall(f"{q('OriginatesFrom')}/{q('Project')}") + } + assert osadzone == {f"Projects/pj-{projekt.pk}", f"Projects/pj-{drugi.pk}"} + + +# -- miejsce w xs:sequence ------------------------------------------------ + + +@pytest.mark.django_db +def test_originates_from_wypada_przed_presented_at(uczelnia, publikacja, projekt): + """``xs:sequence`` profilu: ... Keyword, Abstract, Status, + **OriginatesFrom**, PresentedAt, ... — zła kolejność to niepoprawny + dokument, a walidator odrzuca cały rekord.""" + publikacja.konferencja = baker.make( + Konferencja, nazwa="Konferencja Testowa", skrocona_nazwa="KT" + ) + publikacja.save() + publikacja.slowa_kluczowe.add("onkologia") + przypnij_grant(publikacja, "Z/KOLEJNOSCIA", projekt=projekt) + + kolejnosc = nazwy_dzieci(serializuj_publikacje(uczelnia, publikacja)) + assert "OriginatesFrom" in kolejnosc + assert kolejnosc.index("Keyword") < kolejnosc.index("OriginatesFrom") + assert kolejnosc.index("OriginatesFrom") < kolejnosc.index("PresentedAt") + + +@pytest.mark.django_db +def test_patent_osadza_project_po_slowach_kluczowych(uczelnia, wynalazek, projekt): + """``Patent/OriginatesFrom`` ma identyczną strukturę co w publikacji, + ale inne miejsce w sekwencji: ... Keyword, **OriginatesFrom**, + Predecessor, References, FileLocations.""" + wynalazek.slowa_kluczowe.add("chemia") + przypnij_grant(wynalazek, "PATENT/1", projekt=projekt) + + el = serializuj_patent(uczelnia, wynalazek) + osadzony = el.find(f"{q('OriginatesFrom')}/{q('Project')}") + assert osadzony is not None + assert osadzony.get("id") == f"Projects/pj-{projekt.pk}" + + kolejnosc = nazwy_dzieci(el) + assert kolejnosc.index("Keyword") < kolejnosc.index("OriginatesFrom") + + +@pytest.mark.django_db +def test_patent_bez_projektu_nie_ma_originates_from(uczelnia, wynalazek): + przypnij_grant(wynalazek, "PATENT/BEZ", projekt=None) + assert serializuj_patent(uczelnia, wynalazek).find(q("OriginatesFrom")) is None + + +@pytest.mark.django_db +def test_projekt_cudzej_uczelni_nie_wychodzi_w_patencie( + uczelnia, wynalazek, obca_jednostka +): + obcy = baker.make(Projekt, tytul="Cudzy projekt", jednostka=obca_jednostka) + przypnij_grant(wynalazek, "PATENT/CUDZY", projekt=obcy) + assert serializuj_patent(uczelnia, wynalazek).find(q("OriginatesFrom")) is None + + +# -- zgodność z XSD ------------------------------------------------------- + + +@pytest.mark.django_db +def test_publikacja_i_patent_z_projektem_zgodne_z_xsd( + uczelnia, publikacja, wynalazek, projekt, grantodawca, fabryka_autorow +): + from cerif_export.tests.test_serializery import sprawdz, zbuduj_schemat + + Projekt_Autor.objects.create( + projekt=projekt, + autor=fabryka_autorow("Kierowniczka"), + rola=Projekt_Autor.ROLA_KIEROWNIK, + ) + dodaj_finansowanie(projekt, grantodawca) + publikacja.konferencja = baker.make( + Konferencja, nazwa="Konferencja", skrocona_nazwa="K" + ) + publikacja.save() + publikacja.slowa_kluczowe.add("onkologia") + wynalazek.slowa_kluczowe.add("chemia") + przypnij_grant(publikacja, "XSD/PUB", projekt=projekt) + przypnij_grant(wynalazek, "XSD/PAT", projekt=projekt) + + schemat = zbuduj_schemat() + sprawdz(schemat, serializuj_publikacje(uczelnia, publikacja)) + sprawdz(schemat, serializuj_patent(uczelnia, wynalazek)) + + +# -- integralność referencyjna na pełnym harveście ------------------------ + + +@pytest.mark.django_db +def test_referencje_osadzonego_projektu_sa_rozwiazywalne( + uczelnia, publikacja, wynalazek, projekt, grantodawca, fabryka_autorow +): + """Każdy ``@id`` osadzony w ładunku ma swój rekord w harveście. + + To ta sama kontrola (5a), na której ``openaire-cris-validator`` + odrzuca całe repozytorium — tylko że tutaj referencje wychodzą + z publikacji i patentu, a nie z rekordu projektu. + """ + Projekt_Autor.objects.create( + projekt=projekt, + autor=fabryka_autorow("Kierowniczka"), + rola=Projekt_Autor.ROLA_KIEROWNIK, + ) + dodaj_finansowanie(projekt, grantodawca) + przypnij_grant(publikacja, "HARVEST/PUB", projekt=projekt) + przypnij_grant(wynalazek, "HARVEST/PAT", projekt=projekt) + + korzen = czasowniki.odpowiedz( + czasowniki.Zadanie( + uczelnia, + BASE_URL, + {"verb": "ListRecords", "metadataPrefix": const.METADATA_PREFIX}, + ) + ) + + bledy = [el for el in korzen.iter() if el.tag.endswith("}error")] + assert not bledy, [el.get("code") for el in bledy] + + wydane = { + el.text.split(":", 2)[-1] + for el in korzen.iter() + if el.tag.endswith("}identifier") and el.text + } + ns = uczelnia.oai_repository_identifier() + assert identyfikatory.zbuduj(ns, projekt).split(":", 2)[-1] in wydane + + osadzone = set() + for metadata in (el for el in korzen.iter() if el.tag.endswith("}metadata")): + for el in metadata.iter(): + if el.get("id"): + osadzone.add(el.get("id")) + + assert f"Projects/pj-{projekt.pk}" in osadzone + assert osadzone <= wydane, sorted(osadzone - wydane) + + +@pytest.mark.django_db +def test_osoby_wylaczone_nie_wyciekaja_przez_osadzony_projekt( + uczelnia, publikacja, projekt, fabryka_autorow +): + """Przy ``eksport_cerif_osoby=False`` zespół projektu znika razem + z resztą osób — inaczej wyłączenie setu byłoby pozorne, bo dane + osobowe wychodziłyby okrężną drogą przez publikacje.""" + Projekt_Autor.objects.create( + projekt=projekt, + autor=fabryka_autorow("Kierowniczka"), + rola=Projekt_Autor.ROLA_KIEROWNIK, + ) + przypnij_grant(publikacja, "OSOBY/1", projekt=projekt) + + uczelnia.eksport_cerif_osoby = False + uczelnia.save() + + el = serializuj_publikacje(uczelnia, publikacja) + osadzony = el.find(f"{q('OriginatesFrom')}/{q('Project')}") + assert osadzony is not None + assert osadzony.find(q("Team")) is None + assert "Kierowniczka" not in etree.tostring(osadzony, encoding="unicode") + + +# -- koszt: serializer nie dotyka bazy ------------------------------------ + + +@pytest.mark.django_db +def test_serializacja_publikacji_nie_rosnie_z_liczba_projektow( + uczelnia, jednostka, fabryka_autorow, fabryka_wydawnictw, grantodawca +): + """Osadzanie projektu w każdej publikacji to naturalny kandydat na N+1. + + Liczba zapytań na stronę harvestu musi być **stała**: cała robota + siedzi w prefetchach providera i w prekomputowanych zbiorach + widoczności. Gdy rośnie z liczbą publikacji z projektami, znaczy że + serializer dociąga coś sam. + """ + provider = provider_dla_setu(const.SET_PUBLICATIONS) + autor = fabryka_autorow("Autorska") + + def zapytania_dla(ile): + Grant_Rekordu.objects.all().delete() + Grant.objects.all().delete() + Wydawnictwo_Ciagle.objects.all().delete() + Projekt.objects.all().delete() + + for numer in range(ile): + wlasny = baker.make(Projekt, tytul=f"Projekt {numer}", jednostka=jednostka) + dodaj_finansowanie(wlasny, grantodawca) + Projekt_Autor.objects.create( + projekt=wlasny, + autor=baker.make("bpp.Autor", nazwisko=f"Nowak{numer}"), + rola=Projekt_Autor.ROLA_KIEROWNIK, + ) + rekord = fabryka_wydawnictw( + Wydawnictwo_Ciagle, + autor=autor, + tytul_oryginalny=f"Praca {numer}", + ) + przypnij_grant(rekord, f"NR/{numer}", projekt=wlasny) + + with CaptureQueriesContext(connection) as licznik: + obiekty, _ = provider.strona(uczelnia, rozmiar=100) + obiekty = list(obiekty) + kontekst = KontekstSerializacji( + namespace=NAMESPACE, + uczelnia=uczelnia, + widoczne=provider.zbiory_widocznosci(uczelnia, obiekty), + ) + for obiekt in obiekty: + if isinstance(obiekt, Wydawnictwo_Ciagle): + cerif_publication.serializuj(obiekt, kontekst) + return len(licznik.captured_queries) + + assert zapytania_dla(1) == zapytania_dla(5) From d1683194bf37a1b950ca7020021729094de6b95c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 12:00:30 +0200 Subject: [PATCH 10/11] =?UTF-8?q?test(cerif):=20integralno=C5=9B=C4=87=20r?= =?UTF-8?q?eferencyjna=20projekt=C3=B3w=20i=20finansowania=20(#701,=20#702?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nowy `test_integralnosc_projektow.py` harvestuje wszystkie sety przez ListRecords i sprawdza, że każdy `@id` z encji osadzonej ma rekord we właściwym secie — w obu stanach `eksport_cerif_osoby` i `eksport_cerif_kwoty`. Pokrywa Consortium/Coordinator, Team/PrincipalInvestigator, Team/Member, Funded/By, Funded/As i Funding/Funder, także w projekcie osadzonym w `Publication/OriginatesFrom`. Sekcje A1 i A2 dokumentu euroCRIS opisane na stan faktyczny: co zaimplementowano i co świadomie pominięto (Project/Type, strona WWW, afiliacje zespołu, partnerzy zewnętrzni, własne schematy Subject i Status). Co-Authored-By: Claude Opus 5 (1M context) --- docs/deweloper/eurocris-co-jeszcze.md | 128 +++-- src/bpp/newsfragments/701.feature.rst | 3 + src/bpp/newsfragments/702.feature.rst | 4 + .../tests/test_integralnosc_projektow.py | 474 ++++++++++++++++++ 4 files changed, 574 insertions(+), 35 deletions(-) create mode 100644 src/bpp/newsfragments/701.feature.rst create mode 100644 src/bpp/newsfragments/702.feature.rst create mode 100644 src/cerif_export/tests/test_integralnosc_projektow.py diff --git a/docs/deweloper/eurocris-co-jeszcze.md b/docs/deweloper/eurocris-co-jeszcze.md index 616e3afc8..93f638fac 100644 --- a/docs/deweloper/eurocris-co-jeszcze.md +++ b/docs/deweloper/eurocris-co-jeszcze.md @@ -5,7 +5,7 @@ > Poszczególne sekcje mają odpowiadające im issues: A1 → #701, A2 → #702, > A3 → #703, A4 → #704, B → #705 i #706, D → #707, F1 → #708. -Data: 2026-08-04 +Data: 2026-08-04, aktualizacja 2026-08-05 (sekcje A1 i A2 — zrobione). Kontekst: po wdrożeniu aplikacji `cerif_export` (wariant A). ## Stan wyjściowy @@ -16,7 +16,8 @@ CRIS Managers 1.2.0**. `openaire-cris-validator` 2.1.1 przechodzi w całości Zaimplementowane encje: `Publication` (wydawnictwa ciągłe i zwarte, prace doktorskie i habilitacyjne, źródła jako kanały wydawnicze), `Person`, -`OrgUnit`, `Patent`, `Event`, `Service`. +`OrgUnit`, `Patent`, `Event`, `Service`, a od #701/#702 również `Project` +i `Funding` (sekcje A1 i A2). **Zgodność formalna jest osiągnięta.** Wszystko poniżej to różnica między „przechodzi walidator" a „daje uczelni to, po co się w to wchodzi". @@ -25,36 +26,91 @@ doktorskie i habilitacyjne, źródła jako kanały wydawnicze), `Person`, ## A. Encje niezaimplementowane — największa realna wartość -Sety istnieją, ale są puste. Profil na to pozwala (wymaga istnienia setu, nie -jego zapełnienia), więc walidator przechodzi. Natomiast OpenAIRE nie zobaczy -przez to najważniejszego, co CRIS ma do pokazania: **powiązań publikacja ↔ -projekt ↔ finansowanie**. - -### A1. `Project` — set `openaire_cris_projects` - -Model `Grant` (`src/bpp/models/grant.py`) ma cztery pola: `nazwa_projektu`, -`zrodlo_finansowania`, `numer_projektu`, `rok`. To w praktyce etykieta numeru -grantu przypinana do publikacji, nie encja projektu. - -Do dodania: `StartDate`/`EndDate` (dziś tylko `rok`), `Acronym`, -`PrincipalInvestigator`, `Coordinator`/`Partner`/`Contractor`, `Team`/`Member`, -`Abstract`, `Subject`, `Keyword`, `Status`, `Funded/By/As`. - -Zakres: model + admin + UI + migracja danych. **5–7 osobodni.** - -To jest zmiana produktowa, nie tylko eksportowa — dotyka formularzy, którymi -redakcja wprowadza dane. Warto ją brać razem z A2. - -### A2. `Funding` — set `openaire_cris_funding` - -Nie istnieje. `Grant.zrodlo_finansowania` to `TextField`. Brak encji -finansującego (Funder = `OrgUnit` z `FundRefID`), brak kwot, brak `GrantDOI`. - -`Funding/Type` jest w profilu **obowiązkowy**, ze słownika OpenAIRE Funding -Types (Funding Programme / Call / Tender / Gift / Internal Funding / Contract -/ Award / Grant). - -Zakres: nowy model + Funder + powiązanie z `Project`. **4–6 osobodni.** +Sety istnieją, ale bywają puste. Profil na to pozwala (wymaga istnienia setu, +nie jego zapełnienia), więc walidator przechodzi. Natomiast OpenAIRE nie +zobaczy przez to najważniejszego, co CRIS ma do pokazania: **powiązań +publikacja ↔ projekt ↔ finansowanie**. + +Łańcuch publikacja ↔ projekt ↔ finansowanie jest już zamknięty (A1 i A2); +otwarte zostają `Product` i `Equipment`. + +### A1. `Project` — set `openaire_cris_projects` ✅ ZROBIONE (#701) + +Model `Grant` (`src/bpp/models/grant.py`) pozostał tym, czym był: etykietą +numeru grantu przypinaną do publikacji. Obok niego stanął **`Projekt`** +(`src/bpp/models/projekt.py`) — pełna encja projektu badawczego, a `Grant` +dostał do niego FK. Dzięki temu istniejące numery grantów nie wymagają +migracji: redakcja podpina je do projektów stopniowo, a rekordy bez projektu +działają dalej po staremu. + +Zaimplementowane: + +- **model + admin** — `Projekt` (tytuł PL/EN, akronim, daty rozpoczęcia + i zakończenia, status, abstrakt PL/EN, dyscypliny, słowa kluczowe, + jednostka realizująca), `Projekt_Autor` (zespół: kierownik / wykonawca / + współwykonawca); +- **eksport** — `src/cerif_export/cerif/project.py`, + `src/cerif_export/providers/projekty.py`: `Acronym`, `Title` (PL i EN), + `StartDate`, `EndDate`, `Consortium/Coordinator`, `Team/PrincipalInvestigator` + i `Team/Member`, `Funded/By` + `Funded/As`, `Subject`, `Keyword`, `Abstract`, + `Status`; +- **powiązanie z dorobkiem** — `Publication/OriginatesFrom` + i `Patent/OriginatesFrom` osadzają pełny ``. To ogniwo, dla którego + cała sekcja A w ogóle ma sens: bez niego agregator dostaje dwie rozłączne + listy. + +Świadomie pominięte: + +| Element | Dlaczego | +|---|---| +| `Project/Type` | Profil oczekuje `cfGenericURIClassification__Type` ze schematem typologii projektów (badawczy / wdrożeniowy / infrastrukturalny). BPP takiej typologii nie prowadzi, a wpisanie wszystkim tej samej wartości byłoby twierdzeniem bez pokrycia w danych. | +| `Projekt.strona_www` | Sekwencja `Project` nie ma elementu na adres strony. Wciśnięcie URL-a do generycznego `Identifier` mówiłoby, że to identyfikator projektu — czym adres strony nie jest. | +| `Team/*/Affiliation` | `Projekt_Autor` nie niesie jednostki, a podstawienie `Projekt.jednostka` twierdziłoby, że każdy członek zespołu pracuje w jednostce realizującej. Bywa nieprawdą przy projektach międzyjednostkowych. | +| `Consortium/Partner`, `Consortium/Contractor` | Wymagają encji instytucji zewnętrznej (partner spoza uczelni), której BPP nie ma. `Jednostka` opisuje wyłącznie strukturę własnej uczelni. | +| Front publiczny i API dla projektów | Poza zakresem: zmiana miała domknąć eksport CERIF, a nie wprowadzić projekty do serwisu i do `/api/v1/`. | + +Uwaga o schematach klasyfikacji: `Status` i `Subject` używają **własnych** +przestrzeni nazw (`const.SCHEMAT_STATUSU_PROJEKTU`, `const.SCHEMAT_DYSCYPLIN`), +bo profil nie dostarcza słownika ani dla statusu projektu, ani dla polskiej +klasyfikacji dyscyplin naukowych — a mapowanie dyscyplin na Fields of Science +jest osobną decyzją merytoryczną, nie techniczną. Własny schemat jawnie nazywa +pochodzenie wartości; to lepsze i od pominięcia danych, i od podszycia się pod +cudzy słownik. + +### A2. `Funding` — set `openaire_cris_funding` ✅ ZROBIONE (#702) + +Zaimplementowane: + +- **`Instytucja_Finansujaca`** — słownik grantodawców z `ror_id` + i `fundref_id` (Crossref Funder ID), zaseedowany siedmioma pozycjami (NCN, + NCBiR, MNiSW/MEiN, FNP, NAWA, NPRH, ABM); +- **`Finansowanie`** — typ (obowiązkowy, ze słownika OpenAIRE Funding Types), + nazwa programu, numer umowy, `GrantDOI`, kwota + waluta, FK do projektu + i do instytucji; +- **eksport** — `src/cerif_export/cerif/funding.py`, + `src/cerif_export/providers/finansowanie.py`. `Funding/Funder` to + **referencja** do `OrgUnit`; grantodawcy wychodzą w secie + `openaire_cris_orgunits`, bo profil nie ma osobnej encji finansującego. + Ta sama funkcja serializująca obsługuje rekord setu i element osadzony + w `Project/Funded/As` — kontrola 5b walidatora (encja osadzona jest + podzbiorem pełnego rekordu) jest wtedy spełniona trywialnie. + +Świadomie pominięte: + +| Element | Dlaczego | +|---|---| +| `Funding/Amount` domyślnie | Kwoty grantów bywają objęte klauzulą poufności, więc wychodzą dopiero po włączeniu `Uczelnia.eksport_cerif_kwoty` (domyślnie wyłączone). | +| `Funding/Acronym` | BPP nie ma osobnego pola na skrót finansowania, a wpisanie tam nazwy programu dublowałoby `Name`. | +| `Funding/PartOf`, `Duration`, `OAMandate` | Brak odpowiadających danych w modelu. | +| Numer umowy jako typowany identyfikator | Numer umowy grantowej nie ma w CERIF-ie własnego typu, a atrybut `type` jest obowiązkowy — stąd własny URI (`funding.TYP_ID_NUMER_UMOWY`), zamiast podszycia się pod cudzy słownik. | +| Front publiczny i API dla finansowania | Jak wyżej: zmiana domykała eksport, nie serwis. | + +Integralność referencyjna całości (`Consortium/Coordinator`, +`Team/PrincipalInvestigator`, `Team/Member`, `Funded/By`, `Funding/Funder`) +jest pilnowana automatycznie — +`src/cerif_export/tests/test_integralnosc_projektow.py` harvestuje wszystkie +sety przez `ListRecords` i sprawdza, że każdy `@id` z encji osadzonej ma +rekord we właściwym secie, w obu stanach obu przełączników uczelni. ### A3. `Product` — set `openaire_cris_products` @@ -145,7 +201,8 @@ zombie w indeksie. | `Patent/CountryCode`, `Issuer` | BPP nie przechowuje (implicite UPRP) | | `Event/Type`, `Organizer`, `Sponsor` | `Konferencja` nie ma tych pól; `panstwo` jest tekstem, nie ISO 3166 | | `Person/ScopusAuthorID`, `ResearcherID`, `ISNI` | BPP przechowuje tylko ORCID i PBN UID | -| `OrgUnit/ISNI`, `GRID`, `FundRefID` | brak (ROR wystarcza, reszta opcjonalna) | +| `OrgUnit/ISNI`, `GRID` | brak (ROR wystarcza, reszta opcjonalna) | +| `OrgUnit/FundRefID` | wychodzi dla instytucji finansujących (`Instytucja_Finansujaca.fundref_id`); dla jednostek i uczelni nadal brak — nie mają Crossref Funder ID | --- @@ -155,8 +212,9 @@ zombie w indeksie. a bez tego eksport jest formalnie poprawny i merytorycznie ubogi. 2. **D (rejestracja + decyzja RODO)** — odblokowuje jakikolwiek efekt. 3. **C (nagrobki)** — im wcześniej, tym mniej śmieci w indeksie. -4. **A1 + A2 (Project + Funding)** — największa wartość merytoryczna, - ~10–13 osobodni, wymaga decyzji produktowej o rozbudowie modelu grantów. +4. ~~**A1 + A2 (Project + Funding)**~~ — zrobione (#701, #702). Zostaje + robota redakcji: wprowadzenie projektów i podpięcie do nich numerów + grantów, bo bez danych sety są puste tak samo jak przed zmianą. 5. **A3, A4 (Product, Equipment)** — gdy uczelnia zacznie je rejestrować. 6. **E** — przy okazji, pojedynczo. diff --git a/src/bpp/newsfragments/701.feature.rst b/src/bpp/newsfragments/701.feature.rst new file mode 100644 index 000000000..ec53de8a5 --- /dev/null +++ b/src/bpp/newsfragments/701.feature.rst @@ -0,0 +1,3 @@ +Nowa encja "projekt badawczy" wraz z zespołem projektu. Numery grantów +można teraz przypiąć do projektu, a projekty wychodzą w eksporcie +CERIF-XML w zestawie ``openaire_cris_projects``. diff --git a/src/bpp/newsfragments/702.feature.rst b/src/bpp/newsfragments/702.feature.rst new file mode 100644 index 000000000..7f1b08c47 --- /dev/null +++ b/src/bpp/newsfragments/702.feature.rst @@ -0,0 +1,4 @@ +Nowa encja "finansowanie" wraz ze słownikiem instytucji finansujących +(z identyfikatorami ROR i Crossref Funder ID). Finansowanie projektów +wychodzi w eksporcie CERIF-XML w zestawie ``openaire_cris_funding``. +Kwoty są eksportowane wyłącznie po włączeniu opcji w ustawieniach uczelni. diff --git a/src/cerif_export/tests/test_integralnosc_projektow.py b/src/cerif_export/tests/test_integralnosc_projektow.py new file mode 100644 index 000000000..4dcc235bd --- /dev/null +++ b/src/cerif_export/tests/test_integralnosc_projektow.py @@ -0,0 +1,474 @@ +"""Integralność referencyjna projektów i finansowania — pełny harvest. + +Testy pojedynczych builderów sprawdzają, że ``Project`` i ``Funding`` +wyglądają jak trzeba. Nie sprawdzają rzeczy najważniejszej: czy encje, na +które te elementy **wskazują**, faktycznie wychodzą z endpointu. To dwie +niezależne warstwy — provider decyduje o zbiorze rekordów, serializer +o zbiorze referencji — i rozjeżdżają się po cichu. + +Dlatego wszystko tutaj idzie przez ``ListRecords``, a nie przez providery +wołane wprost: brak wpisu w rejestrze serializerów albo pomylony ``setSpec`` +widać wyłącznie na pełnej ścieżce OAI. + +Sprawdzane referencje wychodzące: + +* ``Project/Consortium/Coordinator`` → ``OrgUnit`` (jednostka realizująca), +* ``Project/Team/PrincipalInvestigator``, ``Project/Team/Member`` → + ``Person`` (zespół projektu), +* ``Project/Funded/By`` → ``OrgUnit`` (grantodawca), +* ``Project/Funded/As`` → osadzone ``Funding`` z ``@id``, +* ``Funding/Funder`` → ``OrgUnit`` (grantodawca), + +i to samo raz jeszcze wewnątrz ``Publication/OriginatesFrom``, gdzie cały +```` jest osadzony w rekordzie publikacji. + +Oba przełączniki uczelni (``eksport_cerif_osoby``, ``eksport_cerif_kwoty``) +są przebiegane w obu stanach. Przy wyłączonych osobach referencje do +``Person`` mają **zniknąć**, a nie zawisnąć — to najboleśniejsza klasa +błędu w kontroli integralności walidatora, bo objawia się dopiero na żywym +endpoincie komunikatem o nieznalezionym rekordzie. +""" + +import pytest +from lxml import etree +from model_bakery import baker + +from bpp.models import ( + Finansowanie, + Grant, + Grant_Rekordu, + Instytucja_Finansujaca, + Projekt, + Projekt_Autor, +) +from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle +from cerif_export import const, identyfikatory +from cerif_export.oai import czasowniki +from cerif_export.oai.bledy import NS_PMH + +BASE_URL = "https://cerif.example.org/cerif-oai/" + +# Nazwa elementu encji CERIF → set, w którym musi wyjść jej pełny rekord. +# Encja osadzona Z identyfikatorem to obietnica: „ten rekord znajdziesz +# w harveście". Mapa zamienia obietnicę w sprawdzalny warunek. +SET_DLA_ENCJI = { + "OrgUnit": const.SET_ORGUNITS, + "Person": const.SET_PERSONS, + "Project": const.SET_PROJECTS, + "Funding": const.SET_FUNDING, + "Publication": const.SET_PUBLICATIONS, + "Patent": const.SET_PATENTS, + "Event": const.SET_EVENTS, +} + + +def q(nazwa): + """Nazwa elementu z przestrzeni nazw profilu CERIF.""" + return f"{{{const.NS_CERIF}}}{nazwa}" + + +def p(nazwa): + """Nazwa elementu z przestrzeni nazw koperty OAI-PMH. + + Koperta (``record``, ``header``, ``setSpec``, ``metadata``) i ładunek + (``Project``, ``OrgUnit``, …) żyją w **różnych** przestrzeniach nazw. + Wyszukiwanie ``record`` w przestrzeni CERIF-a nic nie znajduje, a test + milcząco przechodzi na pustym harveście — stąd dwie osobne funkcje. + """ + return f"{{{NS_PMH}}}{nazwa}" + + +def lokalny(uczelnia, obj): + """``@id`` encji tak, jak wystawia go serializer (bez prefiksu ``oai:``).""" + return identyfikatory.zbuduj(uczelnia.oai_repository_identifier(), obj).split( + ":", 2 + )[2] + + +# -- dane ---------------------------------------------------------------- + + +@pytest.fixture +def grantodawca(db): + return baker.make( + Instytucja_Finansujaca, + nazwa="Narodowe Centrum Nauki", + nazwa_en="National Science Centre", + akronim="NCN", + fundref_id="501100004281", + ) + + +@pytest.fixture +def projekt(jednostka): + return baker.make( + Projekt, + tytul="Badanie integralności", + tytul_en="A study of integrity", + akronim="BIN", + status=Projekt.STATUS_W_TRAKCIE, + data_rozpoczecia="2024-01-01", + data_zakonczenia="2026-12-31", + jednostka=jednostka, + ) + + +@pytest.fixture +def kierownik(fabryka_autorow): + return fabryka_autorow("Kierowniczy", orcid="0000-0002-1825-0097") + + +@pytest.fixture +def wykonawca(fabryka_autorow): + return fabryka_autorow("Wykonawczy") + + +@pytest.fixture +def finansowanie(projekt, grantodawca): + return baker.make( + Finansowanie, + projekt=projekt, + instytucja=grantodawca, + typ=Finansowanie.TYP_GRANT, + nazwa_programu="OPUS 24", + numer_umowy="UMO-2024/53/B/NZ7/00001", + kwota="1234567.89", + waluta="PLN", + ) + + +@pytest.fixture +def publikacja(fabryka_wydawnictw, kierownik, projekt): + """Publikacja przypięta do projektu przez ``Grant_Rekordu``. + + Autorem jest kierownik projektu, ale wykonawca już nie — inaczej test + nie odróżniłby zbioru widoczności providera publikacji od zbioru + dołożonego przez ``klucze_osadzonych_projektow``. + """ + rekord = fabryka_wydawnictw(Wydawnictwo_Ciagle, autor=kierownik) + grant = baker.make(Grant, numer_projektu="UMO-2024/53/B/NZ7/00001", projekt=projekt) + Grant_Rekordu.objects.create(rekord=rekord, grant=grant) + return rekord + + +@pytest.fixture +def pelny_projekt( + uczelnia, + jednostka, + projekt, + kierownik, + wykonawca, + grantodawca, + finansowanie, + publikacja, +): + """Uczelnia z projektem, zespołem, finansowaniem i publikacją grantową.""" + Projekt_Autor.objects.create( + projekt=projekt, autor=kierownik, rola=Projekt_Autor.ROLA_KIEROWNIK + ) + Projekt_Autor.objects.create( + projekt=projekt, autor=wykonawca, rola=Projekt_Autor.ROLA_WYKONAWCA + ) + return uczelnia + + +# -- harvest ------------------------------------------------------------- + + +def harvest(uczelnia): + """Zbierz ``[(setSpec, element metadanych)]`` z całego endpointu. + + Przechodzi tokeny wznowienia, bo pełny harvest bywa wielostronicowy, + a rekord projektu i rekord finansowania siedzą w różnych setach — + zatrzymanie się na pierwszej stronie testowałoby połowę problemu. + """ + wyniki = [] + token = None + for _ in range(50): + argumenty = ( + {"verb": "ListRecords", "resumptionToken": token} + if token + else {"verb": "ListRecords", "metadataPrefix": const.METADATA_PREFIX} + ) + korzen = czasowniki.odpowiedz(czasowniki.Zadanie(uczelnia, BASE_URL, argumenty)) + + bledy = [el for el in korzen.iter() if el.tag.endswith("}error")] + assert not bledy, [el.get("code") for el in bledy] + + for rekord in korzen.iter(p("record")): + naglowek = rekord.find(p("header")) + metadane = rekord.find(p("metadata")) + assert metadane is not None and len(metadane), ( + "rekord bez ładunku metadanych" + ) + wyniki.append((naglowek.findtext(p("setSpec")), metadane[0])) + + tokeny = [el for el in korzen.iter() if el.tag.endswith("}resumptionToken")] + token = tokeny[0].text if tokeny else None + if not token: + return wyniki + raise AssertionError("Harvest nie zakończył się po 50 stronach") + + +def wydane_identyfikatory(rekordy): + """``{@id: setSpec}`` dla encji najwyższego poziomu każdego rekordu.""" + wynik = {} + for set_spec, encja in rekordy: + identyfikator = encja.get("id") + assert identyfikator, f"rekord w secie {set_spec} bez @id" + wynik[identyfikator] = set_spec + return wynik + + +def referencje_wychodzace(rekordy): + """``[(nazwa encji, @id, ścieżka)]`` dla każdej encji **osadzonej**. + + Encja osadzona bez ``@id`` jest w profilu dozwolona wprost i niczego nie + obiecuje, więc nie jest referencją i tu nie trafia. + """ + wynik = [] + for _, encja in rekordy: + for potomek in encja.iter(): + if potomek is encja: + continue + identyfikator = potomek.get("id") + if not identyfikator: + continue + wynik.append( + ( + etree.QName(potomek).localname, + identyfikator, + sciezka(encja, potomek), + ) + ) + return wynik + + +def sciezka(korzen, el): + """Czytelna ścieżka elementu w obrębie rekordu — do komunikatu błędu.""" + czlony = [] + biezacy = el + while biezacy is not None and biezacy is not korzen: + czlony.append(etree.QName(biezacy).localname) + biezacy = biezacy.getparent() + czlony.append(f"{etree.QName(korzen).localname}[@id={korzen.get('id')}]") + return "/".join(reversed(czlony)) + + +def encje_setu(rekordy, set_spec): + """Encje najwyższego poziomu z danego setu.""" + return [encja for spec, encja in rekordy if spec == set_spec] + + +def jedyna(elementy, opis): + assert len(elementy) == 1, f"oczekiwano jednego {opis}, jest {len(elementy)}" + return elementy[0] + + +def sciezki_id(encja, *czlony): + """``@id`` wszystkich elementów pod ścieżką złożoną z nazw lokalnych.""" + return [ + el.get("id") for el in encja.findall("/".join(q(nazwa) for nazwa in czlony)) + ] + + +# -- właściwe testy ------------------------------------------------------ + + +@pytest.mark.parametrize("osoby", [True, False]) +@pytest.mark.parametrize("kwoty", [True, False]) +@pytest.mark.django_db +def test_zadna_referencja_projektu_nie_wisi(pelny_projekt, osoby, kwoty): + """Każdy ``@id`` z encji osadzonej ma rekord we właściwym secie. + + Sprawdzenie idzie po **wszystkich** czterech kombinacjach przełączników, + bo każdy z nich zawęża zbiór wydanych rekordów, a serializerów nie — + i to jest dokładnie ten rozjazd, który produkuje wiszącą referencję. + """ + uczelnia = pelny_projekt + uczelnia.eksport_cerif_osoby = osoby + uczelnia.eksport_cerif_kwoty = kwoty + uczelnia.save() + + rekordy = harvest(uczelnia) + assert rekordy, "harvest nie zwrócił żadnego rekordu" + + wydane = wydane_identyfikatory(rekordy) + referencje = referencje_wychodzace(rekordy) + assert referencje, "harvest nie zawiera ani jednej encji osadzonej z @id" + + wiszace = [] + for nazwa, identyfikator, gdzie in referencje: + oczekiwany_set = SET_DLA_ENCJI.get(nazwa) + assert oczekiwany_set, f"encja {nazwa!r} spoza mapy setów ({gdzie})" + + faktyczny_set = wydane.get(identyfikator) + if faktyczny_set != oczekiwany_set: + wiszace.append((gdzie, identyfikator, faktyczny_set or "BRAK REKORDU")) + + assert not wiszace, f"referencje nie do rozwiązania: {sorted(wiszace)}" + + +@pytest.mark.django_db +def test_projekt_referuje_zespol_jednostke_i_grantodawce( + pelny_projekt, jednostka, kierownik, wykonawca, grantodawca, finansowanie +): + """Komplet referencji rekordu ``Project`` — co do identyfikatora. + + Test „nic nie wisi" przechodzi także wtedy, gdy referencji po prostu + nie ma. Ten pilnuje drugiej strony: że są i wskazują na te encje, + na które powinny. + """ + uczelnia = pelny_projekt + rekordy = harvest(uczelnia) + projekt_el = jedyna(encje_setu(rekordy, const.SET_PROJECTS), "rekordu Project") + + assert sciezki_id(projekt_el, "Consortium", "Coordinator", "OrgUnit") == [ + lokalny(uczelnia, jednostka) + ] + assert sciezki_id(projekt_el, "Team", "PrincipalInvestigator", "Person") == [ + lokalny(uczelnia, kierownik) + ] + assert sciezki_id(projekt_el, "Team", "Member", "Person") == [ + lokalny(uczelnia, wykonawca) + ] + assert sciezki_id(projekt_el, "Funded", "By", "OrgUnit") == [ + lokalny(uczelnia, grantodawca) + ] + assert sciezki_id(projekt_el, "Funded", "As", "Funding") == [ + lokalny(uczelnia, finansowanie) + ] + + +@pytest.mark.django_db +def test_finansowanie_referuje_grantodawce(pelny_projekt, grantodawca): + """Rekord ``Funding`` wskazuje grantodawcę przez ``Funder``.""" + uczelnia = pelny_projekt + rekordy = harvest(uczelnia) + funding_el = jedyna(encje_setu(rekordy, const.SET_FUNDING), "rekordu Funding") + + assert sciezki_id(funding_el, "Funder", "OrgUnit") == [ + lokalny(uczelnia, grantodawca) + ] + + +@pytest.mark.django_db +def test_projekt_osadzony_w_publikacji_ma_te_same_referencje( + pelny_projekt, projekt, jednostka, kierownik, wykonawca, grantodawca, finansowanie +): + """``Publication/OriginatesFrom`` osadza pełny projekt — z referencjami. + + Zbiory widoczności providera publikacji budowane są z innego kodu niż + zbiory providera projektów (``klucze_osadzonych_projektow``), więc to + osobne ryzyko, nie powtórka poprzedniego testu. Wykonawca projektu nie + jest autorem publikacji: gdyby zbiór nie obejmował zespołu projektu, + ``Member`` wyszedłby bez ``@id``. + """ + uczelnia = pelny_projekt + rekordy = harvest(uczelnia) + + publikacje = encje_setu(rekordy, const.SET_PUBLICATIONS) + osadzone = [ + el + for encja in publikacje + for el in encja.findall(f"{q('OriginatesFrom')}/{q('Project')}") + ] + osadzony = jedyna(osadzone, "osadzonego elementu Project") + + assert osadzony.get("id") == lokalny(uczelnia, projekt) + assert sciezki_id(osadzony, "Consortium", "Coordinator", "OrgUnit") == [ + lokalny(uczelnia, jednostka) + ] + assert sciezki_id(osadzony, "Team", "PrincipalInvestigator", "Person") == [ + lokalny(uczelnia, kierownik) + ] + assert sciezki_id(osadzony, "Team", "Member", "Person") == [ + lokalny(uczelnia, wykonawca) + ] + assert sciezki_id(osadzony, "Funded", "By", "OrgUnit") == [ + lokalny(uczelnia, grantodawca) + ] + assert sciezki_id(osadzony, "Funded", "As", "Funding") == [ + lokalny(uczelnia, finansowanie) + ] + + +@pytest.mark.django_db +def test_bez_eksportu_osob_zespol_znika_zamiast_zawisnac(pelny_projekt): + """``eksport_cerif_osoby=False`` usuwa ``Team``, nie tylko ``@id``. + + Zostawienie samego kontenera z osobami bez identyfikatora przeszłoby + kontrolę integralności, ale wystawiłoby nazwiska osób, których uczelnia + świadomie nie eksportuje. + """ + uczelnia = pelny_projekt + uczelnia.eksport_cerif_osoby = False + uczelnia.save() + + rekordy = harvest(uczelnia) + assert not encje_setu(rekordy, const.SET_PERSONS), "set osób miał być pusty" + + # ``iter`` zwraca też sam element startowy, więc rekord ``Project`` + # sprawdza się tym samym przebiegiem, co projekt osadzony w publikacji. + for set_spec in (const.SET_PROJECTS, const.SET_PUBLICATIONS): + for encja in encje_setu(rekordy, set_spec): + for projekt_el in encja.iter(q("Project")): + assert projekt_el.find(q("Team")) is None, ( + f"Team przetrwał wyłączenie eksportu osób w {set_spec}" + ) + + +@pytest.mark.django_db +def test_jednostka_poza_eksportem_znika_z_konsorcjum(pelny_projekt, jednostka): + """Opt-out jednostki zabiera ``Consortium``, ale nie sam projekt. + + ``nie_eksportuj_przez_api`` dotyczy jednostki jako encji organizacyjnej, + a nie prowadzonych w niej badań — rekord ``Project`` zostaje. Znika + natomiast referencja do OrgUnit-a, którego harvester już nie zobaczy; + kontener ``Consortium`` musi zniknąć razem z nią, bo dziecko jest w nim + wobec XSD obowiązkowe. + """ + uczelnia = pelny_projekt + jednostka.nie_eksportuj_przez_api = True + jednostka.save() + + rekordy = harvest(uczelnia) + wydane = wydane_identyfikatory(rekordy) + assert lokalny(uczelnia, jednostka) not in wydane + + projekt_el = jedyna(encje_setu(rekordy, const.SET_PROJECTS), "rekordu Project") + assert projekt_el.find(q("Consortium")) is None + + for _, identyfikator, gdzie in referencje_wychodzace(rekordy): + assert identyfikator in wydane, f"wisząca referencja w {gdzie}" + + +@pytest.mark.django_db +def test_kwoty_finansowania_zalezne_od_przelacznika(pelny_projekt, finansowanie): + """``Funding/Amount`` wychodzi wyłącznie przy zgodzie uczelni. + + Sprawdzane na wszystkich wystąpieniach ``Funding`` naraz — rekordzie + setu i elementach osadzonych w ``Project/Funded/As`` — bo to ta sama + funkcja serializująca, ale różne konteksty wywołania. + """ + uczelnia = pelny_projekt + + for wlaczone, oczekiwane in ((True, 1), (False, 0)): + uczelnia.eksport_cerif_kwoty = wlaczone + uczelnia.save() + + rekordy = harvest(uczelnia) + kwoty = [ + el + for _, encja in rekordy + for funding in encja.iter(q("Funding")) + for el in funding.findall(q("Amount")) + ] + # Jedno finansowanie, ale trzy wystąpienia elementu ``Funding``: + # rekord setu, osadzenie w rekordzie projektu i osadzenie + # w projekcie doczepionym do publikacji. + assert len(kwoty) == 3 * oczekiwane, ( + f"eksport_cerif_kwoty={wlaczone}: {len(kwoty)} elementów Amount" + ) + for kwota in kwoty: + assert kwota.get("currency") == "PLN" From c848b1abc446d8b108e0625d19580dbee0df2511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 5 Aug 2026 12:33:49 +0200 Subject: [PATCH 11/11] chore(baseline): odswiezenie po migracjach 0485-0486 (projekty i finansowanie) Co-Authored-By: Claude Opus 5 (1M context) --- baseline-sql/baseline.meta.json | 6 +- baseline-sql/baseline.sql | 643 ++++++++++++++++++++++++++++---- 2 files changed, 578 insertions(+), 71 deletions(-) diff --git a/baseline-sql/baseline.meta.json b/baseline-sql/baseline.meta.json index 523be9c8b..be281fbc6 100644 --- a/baseline-sql/baseline.meta.json +++ b/baseline-sql/baseline.meta.json @@ -1,11 +1,11 @@ { - "git_sha": "eb4a48a411f1fce569d541cfaaa38a4705024fb8", + "git_sha": "d1683194bf37a1b950ca7020021729094de6b95c", "last_migration": { "admin": "0003_logentry_add_action_flag_choices", "admin_dashboard": "0001_initial", "auth": "0012_alter_user_first_name_max_length", "axes": "0010_accessattemptexpiration", - "bpp": "0484_cerif_walidacja_ror", + "bpp": "0486_seed_instytucje_finansujace", "channels_broadcast": "0001_initial", "constance": "0003_drop_pickle", "contenttypes": "0002_remove_content_type_name", @@ -75,5 +75,5 @@ "zglos_publikacje": "0027_zgloszenie_zaimportowane" }, "meta_version": 1, - "postgres_version": "PostgreSQL 16.13 (Debian 16.13-1.pgdg13+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit" + "postgres_version": "PostgreSQL 16.13 (Debian 16.13-1.pgdg13+1) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit" } diff --git a/baseline-sql/baseline.sql b/baseline-sql/baseline.sql index 457f09ca7..1b7a66398 100644 --- a/baseline-sql/baseline.sql +++ b/baseline-sql/baseline.sql @@ -5301,6 +5301,39 @@ CREATE VIEW public.bpp_ewaluacja_upowaznienia_view AS WHERE ((bpp_rekord_mat.punkty_kbn > (0)::numeric) AND (bpp_autorzy_mat.dyscyplina_naukowa_id IS NOT NULL) AND (bpp_autorzy_mat.afiliuje = false)); +-- +-- Name: bpp_finansowanie; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.bpp_finansowanie ( + id integer NOT NULL, + ostatnio_zmieniony timestamp with time zone, + adnotacje text NOT NULL, + typ character varying(30) NOT NULL, + nazwa_programu character varying(200) NOT NULL, + numer_umowy character varying(200) NOT NULL, + kwota numeric(14,2), + waluta character varying(3) NOT NULL, + grant_doi character varying(200) NOT NULL, + instytucja_id integer NOT NULL, + projekt_id integer NOT NULL +); + + +-- +-- Name: bpp_finansowanie_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +ALTER TABLE public.bpp_finansowanie ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.bpp_finansowanie_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + -- -- Name: bpp_funkcja_autora; Type: TABLE; Schema: public; Owner: - -- @@ -5337,6 +5370,7 @@ CREATE TABLE public.bpp_grant ( zrodlo_finansowania text NOT NULL, numer_projektu character varying(200) NOT NULL, rok smallint, + projekt_id integer, CONSTRAINT bpp_grant_rok_check CHECK ((rok >= 0)) ); @@ -5406,6 +5440,38 @@ ALTER TABLE public.bpp_grupa_pracownicza ALTER COLUMN id ADD GENERATED BY DEFAUL ); +-- +-- Name: bpp_instytucja_finansujaca; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.bpp_instytucja_finansujaca ( + id integer NOT NULL, + ostatnio_zmieniony timestamp with time zone, + adnotacje text NOT NULL, + nazwa text NOT NULL, + nazwa_en text NOT NULL, + akronim character varying(50) NOT NULL, + kraj character varying(2) NOT NULL, + ror_id character varying(64) NOT NULL, + fundref_id character varying(50) NOT NULL, + strona_www character varying(200) NOT NULL +); + + +-- +-- Name: bpp_instytucja_finansujaca_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +ALTER TABLE public.bpp_instytucja_finansujaca ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.bpp_instytucja_finansujaca_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + -- -- Name: bpp_jednostka_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- @@ -6752,6 +6818,95 @@ CREATE VIEW public.bpp_praca_habilitacyjna_view AS FROM public.bpp_praca_habilitacyjna; +-- +-- Name: bpp_projekt; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.bpp_projekt ( + id integer NOT NULL, + slowa_kluczowe_eng character varying(255)[], + ostatnio_zmieniony timestamp with time zone, + adnotacje text NOT NULL, + tytul text NOT NULL, + tytul_en text NOT NULL, + akronim character varying(50) NOT NULL, + data_rozpoczecia date, + data_zakonczenia date, + status character varying(20) NOT NULL, + abstrakt text NOT NULL, + abstrakt_en text NOT NULL, + strona_www character varying(200) NOT NULL, + jednostka_id integer NOT NULL +); + + +-- +-- Name: bpp_projekt_autor; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.bpp_projekt_autor ( + id integer NOT NULL, + rola character varying(20) NOT NULL, + od date, + "do" date, + autor_id integer NOT NULL, + projekt_id integer NOT NULL +); + + +-- +-- Name: bpp_projekt_autor_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +ALTER TABLE public.bpp_projekt_autor ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.bpp_projekt_autor_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +-- +-- Name: bpp_projekt_dyscypliny; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.bpp_projekt_dyscypliny ( + id integer NOT NULL, + projekt_id integer NOT NULL, + dyscyplina_naukowa_id integer NOT NULL +); + + +-- +-- Name: bpp_projekt_dyscypliny_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +ALTER TABLE public.bpp_projekt_dyscypliny ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.bpp_projekt_dyscypliny_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +-- +-- Name: bpp_projekt_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +ALTER TABLE public.bpp_projekt ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.bpp_projekt_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + -- -- Name: bpp_publikacja_habilitacyjna; Type: TABLE; Schema: public; Owner: - -- @@ -7677,6 +7832,7 @@ CREATE TABLE public.bpp_uczelnia ( eksport_cerif_wlaczony boolean NOT NULL, ror_id character varying(64) NOT NULL, eksport_cerif_osoby boolean NOT NULL, + eksport_cerif_kwoty boolean NOT NULL, CONSTRAINT bpp_uczelnia_ilosc_jednostek_na_strone_check CHECK ((ilosc_jednostek_na_strone >= 0)), CONSTRAINT bpp_uczelnia_pokazuj_deklaracje_dostepnosci_check CHECK ((pokazuj_deklaracje_dostepnosci >= 0)) ); @@ -13816,6 +13972,22 @@ COPY public.auth_group_permissions (id, group_id, permission_id) FROM stdin; 402 6 696 403 6 697 404 6 698 +405 1 1099 +406 1 1100 +407 1 1101 +408 1 1102 +409 3 1103 +410 3 1104 +411 3 1105 +412 3 1106 +413 3 1107 +414 3 1108 +415 3 1109 +416 3 1110 +417 3 1111 +418 3 1112 +419 3 1113 +420 3 1114 \. @@ -14902,6 +15074,22 @@ COPY public.auth_permission (id, name, content_type_id, codename) FROM stdin; 1096 Can change Status odpinania wszystkich sensownych 273 change_statusodpinaniawszystkich 1097 Can delete Status odpinania wszystkich sensownych 273 delete_statusodpinaniawszystkich 1098 Can view Status odpinania wszystkich sensownych 273 view_statusodpinaniawszystkich +1099 Can add instytucja finansująca 274 add_instytucja_finansujaca +1100 Can change instytucja finansująca 274 change_instytucja_finansujaca +1101 Can delete instytucja finansująca 274 delete_instytucja_finansujaca +1102 Can view instytucja finansująca 274 view_instytucja_finansujaca +1103 Can add projekt 275 add_projekt +1104 Can change projekt 275 change_projekt +1105 Can delete projekt 275 delete_projekt +1106 Can view projekt 275 view_projekt +1107 Can add finansowanie 276 add_finansowanie +1108 Can change finansowanie 276 change_finansowanie +1109 Can delete finansowanie 276 delete_finansowanie +1110 Can view finansowanie 276 view_finansowanie +1111 Can add osoba w projekcie 277 add_projekt_autor +1112 Can change osoba w projekcie 277 change_projekt_autor +1113 Can delete osoba w projekcie 277 delete_projekt_autor +1114 Can view osoba w projekcie 277 view_projekt_autor \. @@ -15178,6 +15366,14 @@ COPY public.bpp_element_repozytorium (id, object_id, rodzaj, nazwa_pliku, tryb_d \. +-- +-- Data for Name: bpp_finansowanie; Type: TABLE DATA; Schema: public; Owner: - +-- + +COPY public.bpp_finansowanie (id, ostatnio_zmieniony, adnotacje, typ, nazwa_programu, numer_umowy, kwota, waluta, grant_doi, instytucja_id, projekt_id) FROM stdin; +\. + + -- -- Data for Name: bpp_funkcja_autora; Type: TABLE DATA; Schema: public; Owner: - -- @@ -15195,7 +15391,7 @@ COPY public.bpp_funkcja_autora (id, nazwa, skrot, pokazuj_za_nazwiskiem) FROM st -- Data for Name: bpp_grant; Type: TABLE DATA; Schema: public; Owner: - -- -COPY public.bpp_grant (id, nazwa_projektu, zrodlo_finansowania, numer_projektu, rok) FROM stdin; +COPY public.bpp_grant (id, nazwa_projektu, zrodlo_finansowania, numer_projektu, rok, projekt_id) FROM stdin; \. @@ -15215,6 +15411,21 @@ COPY public.bpp_grupa_pracownicza (id, nazwa) FROM stdin; \. +-- +-- Data for Name: bpp_instytucja_finansujaca; Type: TABLE DATA; Schema: public; Owner: - +-- + +COPY public.bpp_instytucja_finansujaca (id, ostatnio_zmieniony, adnotacje, nazwa, nazwa_en, akronim, kraj, ror_id, fundref_id, strona_www) FROM stdin; +1 2026-08-05 10:33:37.136795+00 Narodowe Centrum Nauki National Science Centre NCN PL https://ror.org/03ha2q922 501100004281 https://www.ncn.gov.pl +2 2026-08-05 10:33:37.138596+00 Narodowe Centrum Badań i Rozwoju National Centre for Research and Development NCBR PL https://ror.org/05pwfyy15 501100005632 https://www.ncbr.gov.pl/ +3 2026-08-05 10:33:37.13981+00 Ministerstwo Nauki i Szkolnictwa Wyższego Ministry of Science and Higher Education MNiSW PL https://ror.org/05dwvd537 501100004569 https://www.gov.pl/web/nauka +4 2026-08-05 10:33:37.141041+00 Fundacja na rzecz Nauki Polskiej Foundation for Polish Science FNP PL https://ror.org/048zd9m77 501100001870 https://fnp.org.pl +5 2026-08-05 10:33:37.141971+00 Narodowa Agencja Wymiany Akademickiej National Agency for Academic Exchange NAWA PL https://ror.org/02jf81j23 501100014434 https://nawa.gov.pl +6 2026-08-05 10:33:37.142911+00 Agencja Badań Medycznych Medical Research Agency ABM PL https://ror.org/026nedj88 501100023181 https://abm.gov.pl +7 2026-08-05 10:33:37.144032+00 Komisja Europejska European Commission EC BE https://ror.org/00k4n6c32 501100000780 https://commission.europa.eu +\. + + -- -- Data for Name: bpp_jednostka; Type: TABLE DATA; Schema: public; Owner: - -- @@ -15377,6 +15588,30 @@ COPY public.bpp_praca_habilitacyjna (id, opis_bibliograficzny_cache, opis_biblio \. +-- +-- Data for Name: bpp_projekt; Type: TABLE DATA; Schema: public; Owner: - +-- + +COPY public.bpp_projekt (id, slowa_kluczowe_eng, ostatnio_zmieniony, adnotacje, tytul, tytul_en, akronim, data_rozpoczecia, data_zakonczenia, status, abstrakt, abstrakt_en, strona_www, jednostka_id) FROM stdin; +\. + + +-- +-- Data for Name: bpp_projekt_autor; Type: TABLE DATA; Schema: public; Owner: - +-- + +COPY public.bpp_projekt_autor (id, rola, od, "do", autor_id, projekt_id) FROM stdin; +\. + + +-- +-- Data for Name: bpp_projekt_dyscypliny; Type: TABLE DATA; Schema: public; Owner: - +-- + +COPY public.bpp_projekt_dyscypliny (id, projekt_id, dyscyplina_naukowa_id) FROM stdin; +\. + + -- -- Data for Name: bpp_publikacja_habilitacyjna; Type: TABLE DATA; Schema: public; Owner: - -- @@ -15598,7 +15833,7 @@ COPY public.bpp_tytul (id, nazwa, skrot) FROM stdin; -- Data for Name: bpp_uczelnia; Type: TABLE DATA; Schema: public; Owner: - -- -COPY public.bpp_uczelnia (id, ostatnio_zmieniony, adnotacje, nazwa, skrot, nazwa_dopelniacz_field, slug, logo_www, logo_svg, favicon_ico, pbn_id, obca_jednostka_id, pokazuj_index_copernicus, pokazuj_punktacje_wewnetrzna, pokazuj_status_korekty, pokazuj_ranking_autorow, pokazuj_praca_recenzowana, clarivate_password, clarivate_username, domyslnie_afiliuje, pokazuj_liczbe_cytowan_w_rankingu, pokazuj_liczbe_cytowan_na_stronie_autora, wydruk_logo, wydruk_parametry_zapytania, wydruk_logo_szerokosc, wyszukiwanie_rekordy_na_strone_anonim, wyszukiwanie_rekordy_na_strone_zalogowany, pokazuj_punktacja_snip, podpowiadaj_dyscypliny, pokazuj_tabele_slotow_na_stronie_rekordu, pokazuj_raport_slotow_autor, pokazuj_raport_slotow_uczelnia, ranking_autorow_rozbij_domyslnie, pokazuj_raport_slotow_zerowy, sortuj_jednostki_alfabetycznie, metoda_do_roku_formularze, pbn_uid_id, pbn_api_root, pbn_app_name, pbn_app_token, pbn_aktualizuj_na_biezaco, pbn_integracja, pbn_api_user_id, pbn_api_nie_wysylaj_prac_bez_pk, ilosc_jednostek_na_strone, pokazuj_tylko_jednostki_nadrzedne, wymagaj_informacji_o_oplatach, pokazuj_formularz_zglaszania_publikacji, pbn_api_afiliacja_zawsze_na_uczelnie, pbn_wysylaj_bez_oswiadczen, deklaracja_dostepnosci_tekst, deklaracja_dostepnosci_url, pokazuj_deklaracje_dostepnosci, ranking_autorow_bez_kol_naukowych, pokazuj_autorow_obcych_w_przegladaniu_danych, pokazuj_autorow_bez_prac_w_przegladaniu_danych, drukuj_alternatywne_oswiadczenia, drukuj_oswiadczenia, pokazuj_zrodla_bez_prac_w_przegladaniu_danych, pokazuj_jednostki_na_pierwszej_stronie, pokazuj_wydzialy_na_pierwszej_stronie, przydzielaj_1_slot_gdy_udzial_mniejszy, pytaj_o_zgode_na_publikacje_pelnego_tekstu, uzywaj_wydzialow, tytul_strony_glownej, wymagaj_logowania_zglos_publikacje, nowy_autor_z_formularza_pokazuj, orcid_client_id, orcid_client_secret, orcid_sandbox, orcid_tylko_dla_pracownikow, wymagaj_oplatach_artykul, wymagaj_oplatach_inne, wymagaj_oplatach_monografia, wymagaj_oplatach_rozdzial, pbn_kasuj_dyscypliny_selektywnie, pokazuj_siec_powiazan, dspace_aktywny, dspace_api_endpoint, dspace_api_password, dspace_api_username, dspace_domyslny_jezyk_dc, site_id, theme_name, google_analytics_property_id, google_verification_code, pokazuj_oswiadczenie_ken, skrot_wydzialu_w_nazwie_jednostki, wydruk_margines_dol, wydruk_margines_gora, wydruk_margines_lewo, wydruk_margines_prawo, zwijaj_dlugie_listy_autorow, oai_identyfikator_repozytorium, oai_pmh_aktywny, api_v1_wlaczone, eksport_cerif_wlaczony, ror_id, eksport_cerif_osoby) FROM stdin; +COPY public.bpp_uczelnia (id, ostatnio_zmieniony, adnotacje, nazwa, skrot, nazwa_dopelniacz_field, slug, logo_www, logo_svg, favicon_ico, pbn_id, obca_jednostka_id, pokazuj_index_copernicus, pokazuj_punktacje_wewnetrzna, pokazuj_status_korekty, pokazuj_ranking_autorow, pokazuj_praca_recenzowana, clarivate_password, clarivate_username, domyslnie_afiliuje, pokazuj_liczbe_cytowan_w_rankingu, pokazuj_liczbe_cytowan_na_stronie_autora, wydruk_logo, wydruk_parametry_zapytania, wydruk_logo_szerokosc, wyszukiwanie_rekordy_na_strone_anonim, wyszukiwanie_rekordy_na_strone_zalogowany, pokazuj_punktacja_snip, podpowiadaj_dyscypliny, pokazuj_tabele_slotow_na_stronie_rekordu, pokazuj_raport_slotow_autor, pokazuj_raport_slotow_uczelnia, ranking_autorow_rozbij_domyslnie, pokazuj_raport_slotow_zerowy, sortuj_jednostki_alfabetycznie, metoda_do_roku_formularze, pbn_uid_id, pbn_api_root, pbn_app_name, pbn_app_token, pbn_aktualizuj_na_biezaco, pbn_integracja, pbn_api_user_id, pbn_api_nie_wysylaj_prac_bez_pk, ilosc_jednostek_na_strone, pokazuj_tylko_jednostki_nadrzedne, wymagaj_informacji_o_oplatach, pokazuj_formularz_zglaszania_publikacji, pbn_api_afiliacja_zawsze_na_uczelnie, pbn_wysylaj_bez_oswiadczen, deklaracja_dostepnosci_tekst, deklaracja_dostepnosci_url, pokazuj_deklaracje_dostepnosci, ranking_autorow_bez_kol_naukowych, pokazuj_autorow_obcych_w_przegladaniu_danych, pokazuj_autorow_bez_prac_w_przegladaniu_danych, drukuj_alternatywne_oswiadczenia, drukuj_oswiadczenia, pokazuj_zrodla_bez_prac_w_przegladaniu_danych, pokazuj_jednostki_na_pierwszej_stronie, pokazuj_wydzialy_na_pierwszej_stronie, przydzielaj_1_slot_gdy_udzial_mniejszy, pytaj_o_zgode_na_publikacje_pelnego_tekstu, uzywaj_wydzialow, tytul_strony_glownej, wymagaj_logowania_zglos_publikacje, nowy_autor_z_formularza_pokazuj, orcid_client_id, orcid_client_secret, orcid_sandbox, orcid_tylko_dla_pracownikow, wymagaj_oplatach_artykul, wymagaj_oplatach_inne, wymagaj_oplatach_monografia, wymagaj_oplatach_rozdzial, pbn_kasuj_dyscypliny_selektywnie, pokazuj_siec_powiazan, dspace_aktywny, dspace_api_endpoint, dspace_api_password, dspace_api_username, dspace_domyslny_jezyk_dc, site_id, theme_name, google_analytics_property_id, google_verification_code, pokazuj_oswiadczenie_ken, skrot_wydzialu_w_nazwie_jednostki, wydruk_margines_dol, wydruk_margines_gora, wydruk_margines_lewo, wydruk_margines_prawo, zwijaj_dlugie_listy_autorow, oai_identyfikator_repozytorium, oai_pmh_aktywny, api_v1_wlaczone, eksport_cerif_wlaczony, ror_id, eksport_cerif_osoby, eksport_cerif_kwoty) FROM stdin; \. @@ -16189,6 +16424,10 @@ COPY public.django_content_type (id, app_label, model) FROM stdin; 271 orcid_integration orcididentity 272 oidc_integration oidcidentity 273 ewaluacja_optymalizacja statusodpinaniawszystkich +274 bpp instytucja_finansujaca +275 bpp projekt +276 bpp finansowanie +277 bpp projekt_autor \. @@ -16205,6 +16444,52 @@ COPY public.django_countdown_sitecountdown (id, countdown_time, message, long_de -- COPY public.django_migrations (id, app, name, applied) FROM stdin; +593 denorm 0001_initial 2000-01-01 00:00:00+00 +1089 import_pracownikow 0027_profil_uczelnia 2000-01-01 00:00:00+00 +520 bpp 0411_nowy_formularz_zgloszenia 2000-01-01 00:00:00+00 +802 importer_publikacji 0009_importedauthor_candidate 2000-01-01 00:00:00+00 +803 importer_publikacji 0010_importedauthor_zapisany_jako 2000-01-01 00:00:00+00 +804 importer_publikacji 0006_merge_20260420_2212 2000-01-01 00:00:00+00 +805 importer_publikacji 0007_merge_20260421_1248 2000-01-01 00:00:00+00 +806 importer_publikacji 0011_merge_20260601_0632 2000-01-01 00:00:00+00 +807 importer_publikacji 0012_alter_importedauthor_session 2000-01-01 00:00:00+00 +810 integrator2 0003_django110_py3k 2000-01-01 00:00:00+00 +811 integrator2 0004_django32 2000-01-01 00:00:00+00 +812 integrator2 0005_nullbooleanfield 2000-01-01 00:00:00+00 +813 integrator2 0006_move_files_to_protected 2000-01-01 00:00:00+00 +814 komparator_pbn 0001_initial 2000-01-01 00:00:00+00 +815 komparator_pbn 0002_pbndownloadtask_current_step_and_more 2000-01-01 00:00:00+00 +816 komparator_pbn 0003_delete_pbndownloadtask 2000-01-01 00:00:00+00 +817 pbn_api 0069_sentdata_api_url 2000-01-01 00:00:00+00 +818 pbn_api 0070_drop_unused_indexes 2000-01-01 00:00:00+00 +819 pbn_api 0071_alter_institution_addresspostalcode_and_more 2000-01-01 00:00:00+00 +820 pbn_api 0072_pbn_search_gin_indexes 2000-01-01 00:00:00+00 +821 komparator_pbn_udzialy 0001_initial 2000-01-01 00:00:00+00 +822 komparator_pbn_udzialy 0002_add_brakautora_model 2000-01-01 00:00:00+00 +1017 ewaluacja_liczba_n 0010_merge_20260604_1952 2000-01-01 00:00:00+00 +823 komparator_pbn_udzialy 0003_remove_brakautorawpublikacji_komparator__autor_i_e007d7_idx_and_more 2000-01-01 00:00:00+00 +824 menu 0001_initial 2000-01-01 00:00:00+00 +825 messages_extends 0001_initial 2000-01-01 00:00:00+00 +826 siteblog 0001_initial 2000-01-01 00:00:00+00 +827 miniblog 0001_initial 2000-01-01 00:00:00+00 +828 miniblog 0002_auto_20180101_2017 2000-01-01 00:00:00+00 +829 miniblog 0003_alter_article_article_body 2000-01-01 00:00:00+00 +830 miniblog 0004_migrate_to_siteblog_and_delete 2000-01-01 00:00:00+00 +832 oswiadczenia 0001_add_export_task_model 2000-01-01 00:00:00+00 +833 oswiadczenia 0002_add_offset_limit 2000-01-01 00:00:00+00 +834 oswiadczenia 0003_fix_export_format_max_length 2000-01-01 00:00:00+00 +835 oswiadczenia 0004_add_przypieta_filter 2000-01-01 00:00:00+00 +1122 bpp 0480_cerif_mapowania_slownikow 2000-01-01 00:00:00+00 +1123 bpp 0481_uczelnia_eksport_cerif_osoby 2000-01-01 00:00:00+00 +1124 bpp 0482_alter_jednostka_ror_id_alter_uczelnia_ror_id 2000-01-01 00:00:00+00 +1125 bpp 0483_cerif_znak_towarowy 2000-01-01 00:00:00+00 +1126 bpp 0484_cerif_walidacja_ror 2000-01-01 00:00:00+00 +1127 oauth2_provider 0015_refreshtoken_token_checksum 2000-01-01 00:00:00+00 +1128 oauth2_provider 0016_alter_devicegrant_scope 2000-01-01 00:00:00+00 +1129 oauth2_provider 0017_application_dcr_created 2000-01-01 00:00:00+00 +1130 oauth2_provider 0018_resource_indicators 2000-01-01 00:00:00+00 +1131 oauth2_provider 0019_application_registration_source 2000-01-01 00:00:00+00 +1132 oauth2_provider 0020_cimd_application_fields 2000-01-01 00:00:00+00 114 bpp 0037_auto_20160124_1336 2000-01-01 00:00:00+00 234 bpp 0152_merge 2000-01-01 00:00:00+00 836 oswiadczenia 0005_migrate_template_to_dbtemplate 2000-01-01 00:00:00+00 @@ -16302,7 +16587,6 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 930 snapshot_odpiec 0002_alter_snapshotodpiec_owner 2000-01-01 00:00:00+00 931 taggit 0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx 2000-01-01 00:00:00+00 932 test_bpp 0001_initial 2000-01-01 00:00:00+00 -593 denorm 0001_initial 2000-01-01 00:00:00+00 933 test_bpp 0002_testobjectthatdoesnotexist 2000-01-01 00:00:00+00 935 zglos_publikacje 0001_initial 2000-01-01 00:00:00+00 936 zglos_publikacje 0002_auto_20220710_2331 2000-01-01 00:00:00+00 @@ -16483,7 +16767,6 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 713 flexible_reports 0005_column_attrs 2000-01-01 00:00:00+00 998 bpp 0455_faza_b_i2 2000-01-01 00:00:00+00 1088 import_pracownikow 0026_importpracownikow_uczelnia 2000-01-01 00:00:00+00 -1089 import_pracownikow 0027_profil_uczelnia 2000-01-01 00:00:00+00 1090 bpp 0470_indeksy_gin_i_funkcyjne 2000-01-01 00:00:00+00 1093 bpp 0471_deduplikuj_autor_jednostka_bez_daty 2000-01-01 00:00:00+00 1094 bpp 0472_constraint_autor_jednostka_bez_daty 2000-01-01 00:00:00+00 @@ -16586,6 +16869,7 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 68 pbn_api 0034_auto_20211028_0341 2000-01-01 00:00:00+00 70 pbn_api 0036_disciplinegroup_discipline 2000-01-01 00:00:00+00 71 pbn_api 0037_alter_discipline_options_and_more 2000-01-01 00:00:00+00 +423 bpp 0329_auto_20220921_2030 2000-01-01 00:00:00+00 72 pbn_api 0038_oswiadczenieinstytucji_disciplines 2000-01-01 00:00:00+00 73 pbn_api 0039_alter_oswiadczenieinstytucji_area 2000-01-01 00:00:00+00 74 sites 0001_initial 2000-01-01 00:00:00+00 @@ -16923,7 +17207,6 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 420 bpp 0326_auto_20220818_0012 2000-01-01 00:00:00+00 421 bpp 0327_uczelnia_pokazuj_formularz_zglaszania_publikacji 2000-01-01 00:00:00+00 422 bpp 0328_charakter_formalny_charakter_crossref 2000-01-01 00:00:00+00 -423 bpp 0329_auto_20220921_2030 2000-01-01 00:00:00+00 424 bpp 0328_alter_uczelnia_wymagaj_informacji_o_oplatach 2000-01-01 00:00:00+00 425 bpp 0330_merge_20220921_2152 2000-01-01 00:00:00+00 426 bpp 0331_kierunek_studiow 2000-01-01 00:00:00+00 @@ -17016,7 +17299,6 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 517 bpp 0410_set_polish_skrot_crossref 2000-01-01 00:00:00+00 518 bpp 0411_uczelnia_orcid_fields 2000-01-01 00:00:00+00 519 bpp 0412_uczelnia_orcid_staff_only 2000-01-01 00:00:00+00 -520 bpp 0411_nowy_formularz_zgloszenia 2000-01-01 00:00:00+00 521 bpp 0413_merge_20260416_2124 2000-01-01 00:00:00+00 522 bpp 0413_bppuser_autor_onetoone 2000-01-01 00:00:00+00 523 bpp 0414_merge_20260427_1123 2000-01-01 00:00:00+00 @@ -17289,49 +17571,8 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 799 importer_publikacji 0006_merge_20260421_1100 2000-01-01 00:00:00+00 800 importer_publikacji 0007_async_import_state 2000-01-01 00:00:00+00 801 importer_publikacji 0008_identifier_textfield 2000-01-01 00:00:00+00 -802 importer_publikacji 0009_importedauthor_candidate 2000-01-01 00:00:00+00 -803 importer_publikacji 0010_importedauthor_zapisany_jako 2000-01-01 00:00:00+00 -804 importer_publikacji 0006_merge_20260420_2212 2000-01-01 00:00:00+00 -805 importer_publikacji 0007_merge_20260421_1248 2000-01-01 00:00:00+00 -806 importer_publikacji 0011_merge_20260601_0632 2000-01-01 00:00:00+00 -807 importer_publikacji 0012_alter_importedauthor_session 2000-01-01 00:00:00+00 -810 integrator2 0003_django110_py3k 2000-01-01 00:00:00+00 -811 integrator2 0004_django32 2000-01-01 00:00:00+00 -812 integrator2 0005_nullbooleanfield 2000-01-01 00:00:00+00 -813 integrator2 0006_move_files_to_protected 2000-01-01 00:00:00+00 -814 komparator_pbn 0001_initial 2000-01-01 00:00:00+00 -815 komparator_pbn 0002_pbndownloadtask_current_step_and_more 2000-01-01 00:00:00+00 -816 komparator_pbn 0003_delete_pbndownloadtask 2000-01-01 00:00:00+00 -817 pbn_api 0069_sentdata_api_url 2000-01-01 00:00:00+00 -818 pbn_api 0070_drop_unused_indexes 2000-01-01 00:00:00+00 -819 pbn_api 0071_alter_institution_addresspostalcode_and_more 2000-01-01 00:00:00+00 -820 pbn_api 0072_pbn_search_gin_indexes 2000-01-01 00:00:00+00 -821 komparator_pbn_udzialy 0001_initial 2000-01-01 00:00:00+00 -822 komparator_pbn_udzialy 0002_add_brakautora_model 2000-01-01 00:00:00+00 -1017 ewaluacja_liczba_n 0010_merge_20260604_1952 2000-01-01 00:00:00+00 -823 komparator_pbn_udzialy 0003_remove_brakautorawpublikacji_komparator__autor_i_e007d7_idx_and_more 2000-01-01 00:00:00+00 -824 menu 0001_initial 2000-01-01 00:00:00+00 -825 messages_extends 0001_initial 2000-01-01 00:00:00+00 -826 siteblog 0001_initial 2000-01-01 00:00:00+00 -827 miniblog 0001_initial 2000-01-01 00:00:00+00 -828 miniblog 0002_auto_20180101_2017 2000-01-01 00:00:00+00 -829 miniblog 0003_alter_article_article_body 2000-01-01 00:00:00+00 -830 miniblog 0004_migrate_to_siteblog_and_delete 2000-01-01 00:00:00+00 -832 oswiadczenia 0001_add_export_task_model 2000-01-01 00:00:00+00 -833 oswiadczenia 0002_add_offset_limit 2000-01-01 00:00:00+00 -834 oswiadczenia 0003_fix_export_format_max_length 2000-01-01 00:00:00+00 -835 oswiadczenia 0004_add_przypieta_filter 2000-01-01 00:00:00+00 -1122 bpp 0480_cerif_mapowania_slownikow 2000-01-01 00:00:00+00 -1123 bpp 0481_uczelnia_eksport_cerif_osoby 2000-01-01 00:00:00+00 -1124 bpp 0482_alter_jednostka_ror_id_alter_uczelnia_ror_id 2000-01-01 00:00:00+00 -1125 bpp 0483_cerif_znak_towarowy 2000-01-01 00:00:00+00 -1126 bpp 0484_cerif_walidacja_ror 2000-01-01 00:00:00+00 -1127 oauth2_provider 0015_refreshtoken_token_checksum 2000-01-01 00:00:00+00 -1128 oauth2_provider 0016_alter_devicegrant_scope 2000-01-01 00:00:00+00 -1129 oauth2_provider 0017_application_dcr_created 2000-01-01 00:00:00+00 -1130 oauth2_provider 0018_resource_indicators 2000-01-01 00:00:00+00 -1131 oauth2_provider 0019_application_registration_source 2000-01-01 00:00:00+00 -1132 oauth2_provider 0020_cimd_application_fields 2000-01-01 00:00:00+00 +1133 bpp 0485_projekt_finansowanie 2000-01-01 00:00:00+00 +1134 bpp 0486_seed_instytucje_finansujace 2000-01-01 00:00:00+00 \. @@ -17704,21 +17945,6 @@ COPY public.formdefaults_formfieldrepresentation (id, name, label, klass, "order 55 if_do do django.forms.fields.FloatField 7 nowe_raporty.forms_dynamiczne.RaportForm_raport_autorow 56 tylko_punktowane Tylko prace punktowane (pkt MNiSW > 0) django.forms.fields.BooleanField 8 nowe_raporty.forms_dynamiczne.RaportForm_raport_autorow 57 obiekt Autor django.forms.models.ModelChoiceField 9 nowe_raporty.forms_dynamiczne.RaportForm_raport_autorow -223 od_roku Od roku django.forms.fields.IntegerField 1 raport_slotow.forms.autor.AutorRaportSlotowForm -224 do_roku Do roku django.forms.fields.IntegerField 2 raport_slotow.forms.autor.AutorRaportSlotowForm -225 od_roku Od roku django.forms.fields.IntegerField 0 raport_slotow.forms.ewaluacja.ParametryRaportSlotowEwaluacjaForm -226 do_roku Do roku django.forms.fields.IntegerField 1 raport_slotow.forms.ewaluacja.ParametryRaportSlotowEwaluacjaForm -227 od_roku Od roku django.forms.fields.IntegerField 0 raport_slotow.forms.uczelnia.UtworzRaportSlotowUczelniaForm -228 do_roku Do roku django.forms.fields.IntegerField 1 raport_slotow.forms.uczelnia.UtworzRaportSlotowUczelniaForm -229 slot Slot django.forms.fields.DecimalField 3 raport_slotow.forms.uczelnia.UtworzRaportSlotowUczelniaForm -230 od_roku Od roku django.forms.fields.IntegerField 0 nowe_raporty.forms_dynamiczne.RaportForm_raport_uczelni -231 do_roku Do roku django.forms.fields.IntegerField 1 nowe_raporty.forms_dynamiczne.RaportForm_raport_uczelni -232 od_roku Od roku django.forms.fields.IntegerField 0 nowe_raporty.forms_dynamiczne.RaportForm_raport_wydzialow -233 do_roku Do roku django.forms.fields.IntegerField 1 nowe_raporty.forms_dynamiczne.RaportForm_raport_wydzialow -234 od_roku Od roku django.forms.fields.IntegerField 0 nowe_raporty.forms_dynamiczne.RaportForm_raport_jednostek -235 do_roku Do roku django.forms.fields.IntegerField 1 nowe_raporty.forms_dynamiczne.RaportForm_raport_jednostek -236 od_roku Od roku django.forms.fields.IntegerField 0 nowe_raporty.forms_dynamiczne.RaportForm_raport_autorow -237 do_roku Do roku django.forms.fields.IntegerField 1 nowe_raporty.forms_dynamiczne.RaportForm_raport_autorow \. @@ -18593,14 +18819,14 @@ SELECT pg_catalog.setval('public.auth_group_id_seq', 9, true); -- Name: auth_group_permissions_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - -- -SELECT pg_catalog.setval('public.auth_group_permissions_id_seq', 404, true); +SELECT pg_catalog.setval('public.auth_group_permissions_id_seq', 420, true); -- -- Name: auth_permission_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - -- -SELECT pg_catalog.setval('public.auth_permission_id_seq', 1098, true); +SELECT pg_catalog.setval('public.auth_permission_id_seq', 1114, true); -- @@ -18757,6 +18983,13 @@ SELECT pg_catalog.setval('public.bpp_dyscyplina_zrodla_id_seq', 1, false); SELECT pg_catalog.setval('public.bpp_element_repozytorium_id_seq', 1, false); +-- +-- Name: bpp_finansowanie_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.bpp_finansowanie_id_seq', 1, false); + + -- -- Name: bpp_funkcja_autora_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - -- @@ -18785,6 +19018,13 @@ SELECT pg_catalog.setval('public.bpp_grant_rekordu_id_seq', 1, false); SELECT pg_catalog.setval('public.bpp_grupa_pracownicza_id_seq', 1, false); +-- +-- Name: bpp_instytucja_finansujaca_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.bpp_instytucja_finansujaca_id_seq', 7, true); + + -- -- Name: bpp_jednostka_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - -- @@ -18904,6 +19144,27 @@ SELECT pg_catalog.setval('public.bpp_praca_doktorska_id_seq', 1, false); SELECT pg_catalog.setval('public.bpp_praca_habilitacyjna_id_seq', 1, false); +-- +-- Name: bpp_projekt_autor_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.bpp_projekt_autor_id_seq', 1, false); + + +-- +-- Name: bpp_projekt_dyscypliny_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.bpp_projekt_dyscypliny_id_seq', 1, false); + + +-- +-- Name: bpp_projekt_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.bpp_projekt_id_seq', 1, false); + + -- -- Name: bpp_publikacja_habilitacyjna_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - -- @@ -19286,7 +19547,7 @@ SELECT pg_catalog.setval('public.django_celery_results_taskresult_id_seq', 1, fa -- Name: django_content_type_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - -- -SELECT pg_catalog.setval('public.django_content_type_id_seq', 273, true); +SELECT pg_catalog.setval('public.django_content_type_id_seq', 277, true); -- @@ -19300,7 +19561,7 @@ SELECT pg_catalog.setval('public.django_countdown_sitecountdown_id_seq', 1, fals -- Name: django_migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - -- -SELECT pg_catalog.setval('public.django_migrations_id_seq', 1132, true); +SELECT pg_catalog.setval('public.django_migrations_id_seq', 1134, true); -- @@ -20578,6 +20839,14 @@ ALTER TABLE ONLY public.bpp_element_repozytorium ADD CONSTRAINT bpp_element_repozytorium_pkey PRIMARY KEY (id); +-- +-- Name: bpp_finansowanie bpp_finansowanie_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_finansowanie + ADD CONSTRAINT bpp_finansowanie_pkey PRIMARY KEY (id); + + -- -- Name: bpp_funkcja_autora bpp_funkcja_autora_nazwa_key; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -20650,6 +20919,14 @@ ALTER TABLE ONLY public.bpp_grupa_pracownicza ADD CONSTRAINT bpp_grupa_pracownicza_pkey PRIMARY KEY (id); +-- +-- Name: bpp_instytucja_finansujaca bpp_instytucja_finansujaca_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_instytucja_finansujaca + ADD CONSTRAINT bpp_instytucja_finansujaca_pkey PRIMARY KEY (id); + + -- -- Name: bpp_jednostka bpp_jednostka_nazwa_key; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -21034,6 +21311,46 @@ ALTER TABLE ONLY public.bpp_praca_habilitacyjna ADD CONSTRAINT bpp_praca_habilitacyjna_slug_key UNIQUE (slug); +-- +-- Name: bpp_projekt_autor bpp_projekt_autor_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_projekt_autor + ADD CONSTRAINT bpp_projekt_autor_pkey PRIMARY KEY (id); + + +-- +-- Name: bpp_projekt_autor bpp_projekt_autor_projekt_id_autor_id_rola_9c73e3d2_uniq; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_projekt_autor + ADD CONSTRAINT bpp_projekt_autor_projekt_id_autor_id_rola_9c73e3d2_uniq UNIQUE (projekt_id, autor_id, rola); + + +-- +-- Name: bpp_projekt_dyscypliny bpp_projekt_dyscypliny_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_projekt_dyscypliny + ADD CONSTRAINT bpp_projekt_dyscypliny_pkey PRIMARY KEY (id); + + +-- +-- Name: bpp_projekt_dyscypliny bpp_projekt_dyscypliny_projekt_id_dyscyplina_na_0dc40a8b_uniq; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_projekt_dyscypliny + ADD CONSTRAINT bpp_projekt_dyscypliny_projekt_id_dyscyplina_na_0dc40a8b_uniq UNIQUE (projekt_id, dyscyplina_naukowa_id); + + +-- +-- Name: bpp_projekt bpp_projekt_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_projekt + ADD CONSTRAINT bpp_projekt_pkey PRIMARY KEY (id); + + -- -- Name: bpp_publikacja_habilitacyjna bpp_publikacja_habilitac_praca_habilitacyjna_id_c_470a79c0_uniq; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -24538,6 +24855,41 @@ CREATE INDEX bpp_dyscyplina_zrodla_dyscyplina_id_49fdef14 ON public.bpp_dyscypli CREATE INDEX bpp_element_repozytorium_content_type_id_fc6548c3 ON public.bpp_element_repozytorium USING btree (content_type_id); +-- +-- Name: bpp_finansowanie_adnotacje_46ab25a2; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_finansowanie_adnotacje_46ab25a2 ON public.bpp_finansowanie USING btree (adnotacje); + + +-- +-- Name: bpp_finansowanie_adnotacje_46ab25a2_like; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_finansowanie_adnotacje_46ab25a2_like ON public.bpp_finansowanie USING btree (adnotacje text_pattern_ops); + + +-- +-- Name: bpp_finansowanie_instytucja_id_3f853b4e; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_finansowanie_instytucja_id_3f853b4e ON public.bpp_finansowanie USING btree (instytucja_id); + + +-- +-- Name: bpp_finansowanie_ostatnio_zmieniony_93ad2441; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_finansowanie_ostatnio_zmieniony_93ad2441 ON public.bpp_finansowanie USING btree (ostatnio_zmieniony); + + +-- +-- Name: bpp_finansowanie_projekt_id_9a1533de; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_finansowanie_projekt_id_9a1533de ON public.bpp_finansowanie USING btree (projekt_id); + + -- -- Name: bpp_funkcja_autora_nazwa_4e7c83a9_like; Type: INDEX; Schema: public; Owner: - -- @@ -24559,6 +24911,13 @@ CREATE INDEX bpp_funkcja_autora_skrot_a2018010_like ON public.bpp_funkcja_autora CREATE INDEX bpp_grant_numer_projektu_80a75d77_like ON public.bpp_grant USING btree (numer_projektu varchar_pattern_ops); +-- +-- Name: bpp_grant_projekt_id_fa10d91b; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_grant_projekt_id_fa10d91b ON public.bpp_grant USING btree (projekt_id); + + -- -- Name: bpp_grant_rekordu_content_type_id_6044868b; Type: INDEX; Schema: public; Owner: - -- @@ -24573,6 +24932,27 @@ CREATE INDEX bpp_grant_rekordu_content_type_id_6044868b ON public.bpp_grant_reko CREATE INDEX bpp_grupa_pracownicza_nazwa_2846f911_like ON public.bpp_grupa_pracownicza USING btree (nazwa varchar_pattern_ops); +-- +-- Name: bpp_instytucja_finansujaca_adnotacje_3f433335; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_instytucja_finansujaca_adnotacje_3f433335 ON public.bpp_instytucja_finansujaca USING btree (adnotacje); + + +-- +-- Name: bpp_instytucja_finansujaca_adnotacje_3f433335_like; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_instytucja_finansujaca_adnotacje_3f433335_like ON public.bpp_instytucja_finansujaca USING btree (adnotacje text_pattern_ops); + + +-- +-- Name: bpp_instytucja_finansujaca_ostatnio_zmieniony_e4f261b4; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_instytucja_finansujaca_ostatnio_zmieniony_e4f261b4 ON public.bpp_instytucja_finansujaca USING btree (ostatnio_zmieniony); + + -- -- Name: bpp_jednostka_adnotacje_054c3d14; Type: INDEX; Schema: public; Owner: - -- @@ -25714,6 +26094,62 @@ CREATE INDEX bpp_praca_habilitacyjna_uwagi_dc1226e8_like ON public.bpp_praca_hab CREATE INDEX bpp_praca_habilitacyjna_wydawca_rok ON public.bpp_praca_habilitacyjna USING btree (wydawca_id, rok); +-- +-- Name: bpp_projekt_adnotacje_1ff16d81; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_projekt_adnotacje_1ff16d81 ON public.bpp_projekt USING btree (adnotacje); + + +-- +-- Name: bpp_projekt_adnotacje_1ff16d81_like; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_projekt_adnotacje_1ff16d81_like ON public.bpp_projekt USING btree (adnotacje text_pattern_ops); + + +-- +-- Name: bpp_projekt_autor_autor_id_95065772; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_projekt_autor_autor_id_95065772 ON public.bpp_projekt_autor USING btree (autor_id); + + +-- +-- Name: bpp_projekt_autor_projekt_id_e3a3c8e6; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_projekt_autor_projekt_id_e3a3c8e6 ON public.bpp_projekt_autor USING btree (projekt_id); + + +-- +-- Name: bpp_projekt_dyscypliny_dyscyplina_naukowa_id_375f6310; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_projekt_dyscypliny_dyscyplina_naukowa_id_375f6310 ON public.bpp_projekt_dyscypliny USING btree (dyscyplina_naukowa_id); + + +-- +-- Name: bpp_projekt_dyscypliny_projekt_id_eecd14d0; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_projekt_dyscypliny_projekt_id_eecd14d0 ON public.bpp_projekt_dyscypliny USING btree (projekt_id); + + +-- +-- Name: bpp_projekt_jednostka_id_993220d0; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_projekt_jednostka_id_993220d0 ON public.bpp_projekt USING btree (jednostka_id); + + +-- +-- Name: bpp_projekt_ostatnio_zmieniony_19062ac6; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bpp_projekt_ostatnio_zmieniony_19062ac6 ON public.bpp_projekt USING btree (ostatnio_zmieniony); + + -- -- Name: bpp_publikacja_habilitacyjna_content_type_id_ffc66b48; Type: INDEX; Schema: public; Owner: - -- @@ -30747,6 +31183,13 @@ CREATE INDEX powiazania__shared__c5dc1a_idx ON public.powiazania_autorow_authorc CREATE INDEX powiazania_autorow_authorc_secondary_author_id_60d62c07 ON public.powiazania_autorow_authorconnection USING btree (secondary_author_id); +-- +-- Name: projekt_jeden_kierownik; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX projekt_jeden_kierownik ON public.bpp_projekt_autor USING btree (projekt_id) WHERE ((rola)::text = 'kierownik'::text); + + -- -- Name: przemapuj_prace_autora_prz_jednostka_do_id_3a525441; Type: INDEX; Schema: public; Owner: - -- @@ -33135,6 +33578,30 @@ ALTER TABLE ONLY public.bpp_element_repozytorium ADD CONSTRAINT bpp_element_repozyto_content_type_id_fc6548c3_fk_django_co FOREIGN KEY (content_type_id) REFERENCES public.django_content_type(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: bpp_finansowanie bpp_finansowanie_instytucja_id_3f853b4e_fk_bpp_insty; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_finansowanie + ADD CONSTRAINT bpp_finansowanie_instytucja_id_3f853b4e_fk_bpp_insty FOREIGN KEY (instytucja_id) REFERENCES public.bpp_instytucja_finansujaca(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: bpp_finansowanie bpp_finansowanie_projekt_id_9a1533de_fk_bpp_projekt_id; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_finansowanie + ADD CONSTRAINT bpp_finansowanie_projekt_id_9a1533de_fk_bpp_projekt_id FOREIGN KEY (projekt_id) REFERENCES public.bpp_projekt(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: bpp_grant bpp_grant_projekt_id_fa10d91b_fk_bpp_projekt_id; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_grant + ADD CONSTRAINT bpp_grant_projekt_id_fa10d91b_fk_bpp_projekt_id FOREIGN KEY (projekt_id) REFERENCES public.bpp_projekt(id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: bpp_grant_rekordu bpp_grant_rekordu_content_type_id_6044868b_fk_django_co; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -33535,6 +34002,46 @@ ALTER TABLE ONLY public.bpp_praca_habilitacyjna ADD CONSTRAINT bpp_praca_habilitacyjna_wydawca_id_800e89ce_fk_bpp_wydawca_id FOREIGN KEY (wydawca_id) REFERENCES public.bpp_wydawca(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: bpp_projekt_autor bpp_projekt_autor_autor_id_95065772_fk_bpp_autor_id; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_projekt_autor + ADD CONSTRAINT bpp_projekt_autor_autor_id_95065772_fk_bpp_autor_id FOREIGN KEY (autor_id) REFERENCES public.bpp_autor(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: bpp_projekt_autor bpp_projekt_autor_projekt_id_e3a3c8e6_fk_bpp_projekt_id; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_projekt_autor + ADD CONSTRAINT bpp_projekt_autor_projekt_id_e3a3c8e6_fk_bpp_projekt_id FOREIGN KEY (projekt_id) REFERENCES public.bpp_projekt(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: bpp_projekt_dyscypliny bpp_projekt_dyscypli_dyscyplina_naukowa_i_375f6310_fk_bpp_dyscy; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_projekt_dyscypliny + ADD CONSTRAINT bpp_projekt_dyscypli_dyscyplina_naukowa_i_375f6310_fk_bpp_dyscy FOREIGN KEY (dyscyplina_naukowa_id) REFERENCES public.bpp_dyscyplina_naukowa(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: bpp_projekt_dyscypliny bpp_projekt_dyscypliny_projekt_id_eecd14d0_fk_bpp_projekt_id; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_projekt_dyscypliny + ADD CONSTRAINT bpp_projekt_dyscypliny_projekt_id_eecd14d0_fk_bpp_projekt_id FOREIGN KEY (projekt_id) REFERENCES public.bpp_projekt(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: bpp_projekt bpp_projekt_jednostka_id_993220d0_fk_bpp_jednostka_id; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bpp_projekt + ADD CONSTRAINT bpp_projekt_jednostka_id_993220d0_fk_bpp_jednostka_id FOREIGN KEY (jednostka_id) REFERENCES public.bpp_jednostka(id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: bpp_publikacja_habilitacyjna bpp_publikacja_habil_content_type_id_ffc66b48_fk_django_co; Type: FK CONSTRAINT; Schema: public; Owner: - --