diff --git a/.github/workflows/build-docker-images.yml b/.github/workflows/build-docker-images.yml index df09679e2..5d43c7d90 100644 --- a/.github/workflows/build-docker-images.yml +++ b/.github/workflows/build-docker-images.yml @@ -164,8 +164,19 @@ jobs: echo "::notice::Docker build — znaleziono flage [docker-build] w commit message" else echo "should_build=false" >> "$GITHUB_OUTPUT" + # Dla pull_request event github.ref_name to "/merge" + # (wewnetrzny merge-ref GH, nie branch). `gh workflow run --ref` + # wymaga nazwy brancha — dla PR pobieramy ja z `gh pr view`, + # dla push event'u uzywamy REF_NAME. + if [ "$EVENT_NAME" = "pull_request" ]; then + PR_NUM="${REF_NAME%/merge}" + DISPATCH_REF=$(gh pr view "$PR_NUM" --repo "$REPO" \ + --json headRefName --jq '.headRefName') + else + DISPATCH_REF="$REF_NAME" + fi echo "::notice::Pomijam Docker build — brak flagi [docker-build] w commit message" - echo "::notice::Aby wymusic build, dodaj [docker-build] do commit message lub uruchom: gh workflow run build-docker-images.yml --ref ${REF_NAME}" + echo "::notice::Aby wymusic build, dodaj [docker-build] do commit message lub uruchom recznie: gh workflow run build-docker-images.yml --ref ${DISPATCH_REF}" fi docker: diff --git a/docs/pbn-wysylka-plan.md b/docs/pbn-wysylka-plan.md new file mode 100644 index 000000000..2a5c0fc41 --- /dev/null +++ b/docs/pbn-wysylka-plan.md @@ -0,0 +1,362 @@ +# Plan: bezpieczna wysyłka publikacji i oświadczeń do PBN + +Dokument opisuje plan zmian w mechanizmie wysyłki publikacji z BPP do PBN. +Celem długofalowym jest **rozdzielenie** wysyłki samego dzieła od wysyłki +oświadczeń instytucji, tak aby nieudana wysyłka publikacji nie kasowała +wcześniej istniejących oświadczeń w PBN. + +Plan jest **dwufazowy**. Ta gałąź (`feature/pbn-test-wysylka-interaktywna`) +realizuje wyłącznie **Fazę 1** — interaktywne narzędzie CLI służące do +empirycznego zbadania, jak PBN reaguje na poszczególne kroki, zanim +zdecydujemy o kształcie docelowej refaktoryzacji w Fazie 2. + +## Kontekst problemu + +Gdy w panelu uczelni włączona jest opcja +`uczelnia.pbn_api_kasuj_przed_wysylka=True`, obecny flow w +`PBNClient.sync_publication()` +(`src/pbn_api/client/publication_sync.py:467-539`) wygląda tak: + +1. **DELETE** oświadczeń publikacji w PBN + (`DELETE /api/v1/institutionProfile/publications/{id}` z `all: True`). +2. **POST** publikacji razem z oświadczeniami + (`POST /api/v1/publications`, JSON zawiera klucz `statements`). +3. **DOWNLOAD** publikacji (`GET /api/v1/publications/id/{id}`). +4. **DOWNLOAD** oświadczeń z PBN i synchronizacja lokalnej tabeli + `OswiadczenieInstytucji`. + +Problem: krok 2 bywa zawodny (HTTP 423 Locked, błąd walidacji, status +PBN „LOGED" itp.). Wtedy DELETE z kroku 1 już się wykonał, a POST z kroku +2 nie wszedł — w PBN zostaje publikacja **bez oświadczeń**, a lokalne dane +też już nie wrócą na profil instytucji bez ręcznego ponownego wysyłu +oświadczeń. User zgłasza utratę oświadczeń w tym scenariuszu. + +## Docelowy flow (Faza 2, do zaprojektowania po Fazie 1) + +Wstępny zamysł — do weryfikacji przez Fazę 1: + +1. POST publikacji przez endpoint **repozytoryjny** + `POST /api/v1/repositorium/publications` (JSON bez klucza `statements`, + przepuszczony przez `convert_json_with_statements_to_no_statements()`). + - FAIL ⇒ zwróć błąd, nie ruszamy oświadczeń. Stan w PBN nietknięty. + - OK ⇒ mamy `objectId`. +2. GET oświadczeń publikacji w PBN + (`GET /api/v1/institutionProfile/publications/page/statements?publicationId={objectId}`). +3. Porównanie tego, co jest w PBN, z tym, co wygenerował + `WydawnictwoPBNAdapter.pbn_get_api_statements()`. + - identyczne ⇒ koniec, nic nie robimy z oświadczeniami. + - różne ⇒ DELETE oświadczeń + (`DELETE /api/v1/institutionProfile/publications/{objectId}` z `all: True`) + + POST nowych przez + `POST /api/v2/institution-profile/statements`. +4. DOWNLOAD oświadczeń lokalnie (synchronizacja BPP z PBN, + reużywa `download_statements_of_publication()`). + +Gdy flaga `delete_statements_before_upload=False` — **zachowanie bez zmian**, +stary flow z `/api/v1/publications` pozostaje. + +**Niewiadome, które musimy zbadać zanim wdrożymy Fazę 2:** +- Jak PBN zachowuje się po wysyłce do endpointu repozytoryjnego + w przypadkach, gdy publikacja już istnieje (różne statusy: ACTIVE, + LOGED itp.). +- Czy `POST /api/v2/institution-profile/statements` wymaga uprzedniego + DELETE, czy sam potrafi nadpisać istniejący zestaw oświadczeń. +- Czy kolejność GET → porównanie → DELETE+POST jest wystarczająca, czy + trzeba obsłużyć dodatkowe stany pośrednie. + +## Faza 1 — narzędzie CLI (ta gałąź) + +### Co powstaje + +- `src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py` — + interaktywny REPL, który dla wybranej publikacji prowadzi użytkownika + krok po kroku przez pełen flow wysyłki. Po każdym kroku czeka na + `Enter` (lub `q` żeby przerwać). Dla każdego żądania HTTP pokazuje + metodę, URL, body; dla odpowiedzi — status, body (skrócone lub pełne). +- `src/pbn_api/tests/test_pbn_test_wysylka_interaktywna.py` — testy + jednostkowe z mockiem `input()` i `MockTransport`. +- `src/bpp/newsfragments/+pbn-test-wysylka-interaktywna.feature.rst` — + changelog towncrier. +- `.docker-build` — pusty plik w root repo, włącza build obrazu + Docker w CI (patrz `.github/workflows/build-docker-images.yml`). + +### Zakres narzędzia + +Narzędzie realizuje **tylko operacje zgodne ze specyfikacją PBN API** — +nie podejmuje prób wysyłania JSON-a z kluczem `statements` do endpointu +repozytoryjnego ani innych eksperymentów niezgodnych z API. Zakres +dostępnych kroków: + +1. **Pokaż publikację** — wybrany rekord (pk, tytuł, obecny PBN UID, + liczba oświadczeń lokalnych). +2. **Wygeneruj JSON publikacji** — wywołaj `WydawnictwoPBNAdapter`, + pokaż czy JSON zawiera klucz `statements`. +3. **Wybierz endpoint publikacji** — `/api/v1/publications` (all-in-one, + z oświadczeniami jeśli są w JSON) albo `/api/v1/repositorium/publications` + (wymusza JSON bez oświadczeń przez `convert_json_with_statements_to_no_statements`). +4. **Wyślij POST publikacji** — pokaż URL, body, po wysyłce status i JSON + odpowiedzi; wyciągnij `objectId`. +5. **Pobierz aktualne oświadczenia z PBN** — + `GET /api/v1/institutionProfile/publications/page/statements?publicationId={objectId}`. +6. **Porównaj z lokalnymi** — które identyczne, które w PBN nie ma, które + lokalnie nie ma. Decyzja użytkownika: czy kasować i nadpisywać. +7. **DELETE oświadczeń w PBN** (opcjonalnie) — + `DELETE /api/v1/institutionProfile/publications/{objectId}` z `all: True`. +8. **POST nowych oświadczeń** (opcjonalnie) — + `POST /api/v2/institution-profile/statements` z payloadem z + `WydawnictwoPBNAdapter.pbn_get_api_statements()`. +9. **Podsumowanie** — co poszło, jakie statusy, ile zajęło. + +Tryb `--dry-run` pokazuje wszystkie żądania, ale nic nie wysyła. + +### Wymagania niefunkcjonalne + +- **Nie modyfikuje lokalnej bazy BPP.** Nie tworzy, nie kasuje, nie + aktualizuje żadnych rekordów BPP (w tym `OswiadczenieInstytucji`, + `SentData`, `Publication`). Służy wyłącznie do audytu zachowania PBN. +- Reużywa istniejący `PBNClient` i `WydawnictwoPBNAdapter` — nie duplikuje + logiki budowania JSON ani wysyłki HTTP. +- Obsługa błędów: łapie `HttpException`, `PraceSerwisoweException`, + `NeedsPBNAuthorisationException`; pokazuje czytelny komunikat i + pozwala wrócić do menu wyboru (albo wyjść). +- Identyfikacja użytkownika PBN — wzorzec z + `pbn_wysylka_oswiadczen/tasks.py::get_pbn_client()`. + +## Jak testować narzędzie — krok po kroku + +Ta sekcja to konkretna instrukcja uruchomienia narzędzia +`pbn_test_wysylka_interaktywna` — zarówno lokalnie, jak i w kontenerze +pre-prod zbudowanym przez CI. + +### 1. Testy jednostkowe (bez PBN) + +Szybka weryfikacja że narzędzie działa na poziomie kodu — używa +mockowanego transportu, nie wymaga tokena PBN ani dostępu do sieci. + +```bash +cd /sciezka/do/worktree +UV_NO_SYNC=1 uv run --all-extras pytest \ + src/pbn_api/tests/test_pbn_test_wysylka_interaktywna.py -n auto +``` + +Powinieneś zobaczyć `13 passed`. + +### 2. Smoke test na preprod PBN (tryb dry-run) + +Dry-run pokazuje wszystkie żądania HTTP które *zostałyby* wysłane do +PBN, ale nic nie wysyła. Idealne do weryfikacji że narzędzie widzi +Twoją publikację i poprawnie generuje JSON. + +```bash +# Lokalnie (worktree ma własne .venv i testcontainers): +UV_NO_SYNC=1 uv run --all-extras python src/manage.py \ + pbn_test_wysylka_interaktywna \ + --wydawnictwo-zwarte 12345 \ + --dry-run +``` + +W tym trybie żaden token PBN nie jest wymagany — narzędzie nie wysyła +niczego. Naciskasz Enter między krokami, narzędzie wypisze każde +żądanie (METODA, URL, body). + +### 3. Rzeczywisty test na preprod PBN + +Gdy upewniłeś się że dry-run wygląda OK, puść bez `--dry-run`. Wymaga +tokena PBN — można go podać przez `--user-token` albo skonfigurować +uczelnię (`Uczelnia.pbn_api_user`) w adminie. + +```bash +# Wariant A: token z parametru +UV_NO_SYNC=1 uv run --all-extras python src/manage.py \ + pbn_test_wysylka_interaktywna \ + --wydawnictwo-zwarte 12345 \ + --user-token + +# Wariant B: token zaciągnięty z Uczelnia.pbn_api_user (automatycznie, +# bez --user-token, jeśli uczelnia ma skonfigurowany pbn_api_user_id) +UV_NO_SYNC=1 uv run --all-extras python src/manage.py \ + pbn_test_wysylka_interaktywna \ + --wydawnictwo-ciagle 67890 +``` + +### 4. Uruchomienie w obrazie Docker zbudowanym przez CI + +Po tym jak w tej gałęzi jest plik `.docker-build`, CI buduje obrazy +`iplweb/bpp_appserver:feature-pbn-test-wysylka-interaktywna` (tag = +nazwa brancha, tylko małe litery / kreski). Po zakończeniu buildu: + +```bash +# Pobierz obraz: +docker pull iplweb/bpp_appserver:feature-pbn-test-wysylka-interaktywna + +# Uruchom narzędzie w kontenerze (zakładam że bpp-deploy już stoi): +docker exec -it \ + python src/manage.py pbn_test_wysylka_interaktywna \ + --wydawnictwo-zwarte 12345 --dry-run +``` + +### 5. Co robi narzędzie (flow, 8 kroków) + +Między każdym krokiem naciskasz **Enter** aby kontynuować albo **q** +żeby przerwać (narzędzie zawsze wypisze podsumowanie na końcu, nawet +po q). + +1. **KROK 1/8** — info o publikacji (PK, tytuł, rok, aktualny PBN UID, + liczba lokalnych `OswiadczenieInstytucji`). +2. **KROK 2/8** — generowanie JSON przez `WydawnictwoPBNAdapter`. + Narzędzie pokaże czy JSON zawiera klucz `statements` i zapyta czy + pokazać pełną treść (default: `n`, tylko preview do 600 znaków). +3. **KROK 3/8** — wybór endpointa: `[1]` `/api/v1/publications` + (all-in-one, wysyła razem z oświadczeniami jeśli są w JSON) albo + `[2]` `/api/v1/repositorium/publications` (narzędzie usuwa klucz + `statements` i przepuszcza JSON przez + `convert_json_with_statements_to_no_statements` — zgodnie ze + specyfikacją PBN endpoint repozytoryjny nie przyjmuje oświadczeń). +4. **KROK 4/8** — POST publikacji. Narzędzie wypisze URL, body i prosi + o potwierdzenie. Po sukcesie wyciąga `objectId` z odpowiedzi PBN. +5. **KROK 5/8** — GET aktualnych oświadczeń z PBN dla tego objectId + (`/api/v1/institutionProfile/publications/page/statements + ?publicationId={id}`). +6. **KROK 6/8** — porównanie: **intencja BPP na żywo** vs **aktualnie + w PBN**. „Intencja BPP" to wynik + `WydawnictwoPBNAdapter.pbn_get_api_statements()` — generowany z + aktualnych `Wydawnictwo_*_Autor` + dyscyplin, czyli to co BPP + **wysłałby teraz**. **Nie** używamy lokalnego cache'a + `OswiadczenieInstytucji` (to snapshot poprzedniej synchronizacji + *PBN*, nie aktualnej intencji BPP — po skasowaniu autora cache + zostałby nieaktualny). Narzędzie pokazuje: + - ile oświadczeń jest identycznych (intencja ∩ PBN), + - ile jest tylko w intencji (intencja \ PBN, „do dodania"), + - ile jest tylko w PBN (PBN \ intencja, „do usunięcia"). + Następnie przechodzi do pytań o DELETE i POST (punkty 7-8). +7. **KROK 7/8** — DELETE oświadczeń w PBN + (`DELETE /api/v1/institutionProfile/publications/{objectId}` z + `{"all": true, "statementsOfPersons": []}`). Narzędzie **zawsze + pyta** czy wykonać DELETE, nawet gdy porównanie zwróciło + identyczność. Domyślna wartość zależy od wyniku KROK 6/8: + „identyczne" → default `n`, „różnice" → default `t`. Pozwala to + wymusić DELETE dla testowania reakcji PBN. +8. **KROK 8/8** — POST nowych oświadczeń + (`POST /api/v2/institution-profile/statements` z payloadem z + `WydawnictwoPBNAdapter.pbn_get_api_statements()`). Retry ×3 dla + HTTP 500/423 z exponential backoff. **Zawsze pyta** — jak w + KROK 7/8 — z domyślną wartością zależną od identyczności. + +Narzędzie wypisuje **PODSUMOWANIE** z wynikami każdego kroku (OK / +dry-run / pominięty / BŁĄD HTTP XXX). + +### 6. Co sprawdzać ręcznie w preprod (checklist) + +Cel Fazy 1: zebrać empiryczne obserwacje zachowania PBN przed +decyzjami projektowymi Fazy 2. + +- [ ] **Publikacja z PBN UID + oświadczeniami lokalnymi**, wysłana + opcją `[1]` (`/api/v1/publications`) — potwierdzenie że PBN + zaakceptuje JSON z `statements` (znany case, weryfikacja baseline). +- [ ] **Ta sama publikacja** wysłana opcją `[2]` + (`/api/v1/repositorium/publications` bez `statements` w JSON) — + czy PBN zaakceptuje? Jaki jest `status` publikacji po wysyłce? + Czy oświadczenia wcześniej skojarzone z publikacją pozostają + nietknięte? +- [ ] **Sekwencja** opcja `[2]` → GET oświadczeń → jeśli się różnią, + `t` na DELETE → POST `/v2/statements`. Ma to być docelowy flow + Fazy 2 — chcemy wiedzieć że PBN jest z tym OK. +- [ ] **Edge case** — publikacja ze statusem "LOGED" (jeśli + napotkamy): co zwraca GET? Czy DELETE działa? Co zwraca POST + `/v2/statements`? +- [ ] **Publikacja bez PBN UID (nowa)** — opcja `[1]` powinna + zwrócić nowy `objectId`. Opcja `[2]` też (`.../repositorium`). +- [ ] **Publikacja bez oświadczeń lokalnych** — porównanie w KROK 6/8 + ma pokazać że lokalne są puste, a więc nie ma o czym rozmawiać. + +Obserwacje notujemy w osobnym pliku `docs/pbn-wysylka-eksperymenty.md` +(tworzony w osobnym PR), żeby były bazą do decyzji w Fazie 2. + +### 7. Rozwiązywanie problemów + +- **`NeedsPBNAuthorisationException`** — brak tokena PBN. Podaj + `--user-token ` albo skonfiguruj `Uczelnia.pbn_api_user_id` + (Django admin → Redagowanie → Uczelnia). +- **`BrakZdefiniowanegoObiektuUczelniaWSystemieError`** — stwórz + obiekt Uczelnia w adminie (tylko raz na instalację). +- **`DaneLokalneWymagajaAktualizacjiException`** w KROK 8/8 — brak + lokalnego `PublikacjaInstytucji_V2` dla tego PBN UID. Pobierz + ręcznie przez + `python src/manage.py pbn_pobierz_publikacje_z_instytucji_v2 + --user-token ` albo wyślij publikację najpierw opcją `[1]` + (PBN wtedy zwróci PublikacjaInstytucji_V2, a my ją pobierzemy). +- **`HttpException 423 Locked`** przy POST — publikacja lub zasób + zablokowany w PBN. Poczekaj chwilę i spróbuj ponownie (dla POST + `/v2/statements` narzędzie ma wbudowany retry ×3). +- **Pliki migracji** — jeśli podczas pytest widzisz konflikt leaf + nodes w `importer_publikacji`, zrób + `uv run python src/manage.py makemigrations --merge --noinput` + (nie modyfikuje istniejących migracji, dodaje nową merge-ową). + +## Faza 2 — refaktoryzacja `sync_publication` (**zaimplementowana, PR #164**) + +Refaktoryzacja została wykonana na tej samej gałęzi +`feature/pbn-test-wysylka-interaktywna` (8 commitów, cała seria w PR #164). +Narzędzie CLI `pbn_test_wysylka_interaktywna` zostaje jako diagnostyka — +aktualna `sync_publication` ma tą samą logikę wewnętrznie. + +### Flow docelowy (zaimplementowany) + +1. `upload_publication(rec, ...)` → ZAWSZE `POST /api/v1/repositorium/publications` + (`post_publication_no_statements`), niezależnie od obecności oświadczeń + w JSON. Konwersja przez `convert_json_with_statements_to_no_statements()`. +2. `download_publication(objectId)` + update `SentData.pbn_uid`. +3. Obsługa zmiany/konfliktu PBN UID (`_handle_uid_change`/`_conflict`). +4. `_download_statements_with_retry()` — best effort odświeżenie lokalnego + cache `OswiadczenieInstytucji` + pobranie `PublikacjaInstytucji_V2` + (potrzebne dla `pbn_get_api_statements`). Błąd tutaj = warning log, + flow kontynuuje. +5. `_sync_statements_with_pbn(rec, objectId, kasuj_selektywnie)`: + - GET aktualnych oświadczeń z PBN (retry x3 + rollbar + raise + `StatementsResendFailedException` po wyczerpaniu). + - Diff z `pbn_get_json_statements()` — klucz `(person mongoId, dyscyplina numerek)`. + - Selektywny DELETE per-osoba (`delete_publication_statement(pub_id, personId, role)`) + gdy `Uczelnia.pbn_kasuj_dyscypliny_selektywnie=True` (default), + batch `delete_all_publication_statements` gdy `False`. + - POST batch `/api/v2/institution-profile/statements` dla brakujących. + - Każdy krok z retry x3 + rollbar level=warning + `StatementsResendFailedException`. + +### Zmiany modelu + +- Usunięto `Uczelnia.pbn_api_kasuj_przed_wysylka` (migracja 0414). +- Dodano `Uczelnia.pbn_kasuj_dyscypliny_selektywnie` BooleanField default=True. +- Zachowano `Uczelnia.pbn_wysylaj_bez_oswiadczen` (semantyka: odmawia + wysyłki publikacji bez oświadczeń — walidacja w adapterze `pbn_get_json`). + +### Nowy wyjątek + +`pbn_api.exceptions.StatementsResendFailedException(publication_pk, pbn_uid, last_error)` +— podnoszony po wyczerpaniu retry dla GET/DELETE/POST oświadczeń. +Klasyfikowany w `pbn_export_queue._handle_retry_exception` jako +`RETRY_LATER`. + +### Historia commitów (PR #164) + +1. Exception class + model Uczelnia + migracja + admin +2. Dead code removal (`post_publication`, `_should_retry_validation_error`, + `_retry_download_publication`) + bug fix `_delete_statements_with_retry` + (`< 0` → `<= 0`) +3. Helpery: `_diff_statements`, `_get_pbn_statements_with_retry`, + `_delete_statements_selective`, `_delete_statements_batch`, + `_post_statements_with_retry`, `_sync_statements_with_pbn` +4. Refaktoryzacja `sync_publication` (nowy split flow) +5. Aktualizacja callerów (usunięcie `delete_statements_before_upload`) +6. Aktualizacja 5 plików testowych (37 testów, nowe scenariusze) +7. Handling `StatementsResendFailedException` w `pbn_export_queue` + test +8. Changelog + docs + +## Wymagania CI + +- `.docker-build` w root repo ⇒ CI buduje obraz Docker dla tej gałęzi, + tak by user mógł uruchomić narzędzie w środowisku testowym. +- Pełny pipeline (`tests.yml`): lint + testy (non-playwright, serial, + playwright) muszą przejść. +- Build Docker odpala się **raz** na commit (push do master lub + event pull_request) — od zmiany z 2026-04-21 push do + feature/fix/hotfix nie triggeruje już buildu (tylko PR events), + żeby uniknąć duplikatów. diff --git a/src/bpp/admin/helpers/pbn_api/common.py b/src/bpp/admin/helpers/pbn_api/common.py index 0dfa84a16..30ea00d1c 100644 --- a/src/bpp/admin/helpers/pbn_api/common.py +++ b/src/bpp/admin/helpers/pbn_api/common.py @@ -43,7 +43,7 @@ def sprawdz_wysylke_do_pbn_w_parametrach_uczelni(uczelnia): return uczelnia -def sprobuj_wyslac_do_pbn( +def sprobuj_wyslac_do_pbn( # noqa: C901 obj, pbn_client, uczelnia, notificator, force_upload=False, raise_exceptions=False ): # Sprawdź, czy wydawnictwo nadrzędne ma odpowoednik PBN: @@ -156,7 +156,6 @@ def sprobuj_wyslac_do_pbn( obj, notificator=notificator, force_upload=force_upload, - delete_statements_before_upload=uczelnia.pbn_api_kasuj_przed_wysylka, export_pk_zero=not uczelnia.pbn_api_nie_wysylaj_prac_bez_pk, always_affiliate_to_uid=( uczelnia.pbn_uid_id @@ -247,8 +246,8 @@ def sprobuj_wyslac_do_pbn( extra = "" if link_do_wyslanych: extra = ( - 'Kliknij, aby otworzyć widok wysłanych danych.' - % link_do_wyslanych + f'' + f"Kliknij, aby otworzyć widok wysłanych danych." ) notificator.warning( diff --git a/src/bpp/admin/uczelnia.py b/src/bpp/admin/uczelnia.py index f3632c060..bd705c824 100644 --- a/src/bpp/admin/uczelnia.py +++ b/src/bpp/admin/uczelnia.py @@ -106,7 +106,7 @@ class UczelniaAdmin( "pbn_app_name", "pbn_app_token", "pbn_api_user", - "pbn_api_kasuj_przed_wysylka", + "pbn_kasuj_dyscypliny_selektywnie", "pbn_api_nie_wysylaj_prac_bez_pk", "pbn_api_afiliacja_zawsze_na_uczelnie", "pbn_wysylaj_bez_oswiadczen", diff --git a/src/bpp/migrations/0414_uczelnia_pbn_kasuj_dyscypliny_selektywnie.py b/src/bpp/migrations/0414_uczelnia_pbn_kasuj_dyscypliny_selektywnie.py new file mode 100644 index 000000000..4b0e4ddd1 --- /dev/null +++ b/src/bpp/migrations/0414_uczelnia_pbn_kasuj_dyscypliny_selektywnie.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0413_bppuser_autor_onetoone"), + ] + + operations = [ + migrations.RemoveField( + model_name="uczelnia", + name="pbn_api_kasuj_przed_wysylka", + ), + migrations.AddField( + model_name="uczelnia", + name="pbn_kasuj_dyscypliny_selektywnie", + field=models.BooleanField( + default=True, + help_text=( + "Gdy zaznaczone: ``sync_publication`` usuwa oświadczenia " + "selektywnie per-osoba (DELETE ``/publications/{id}`` z " + "``{personId, role}``) i wysyła tylko brakujące. Gdy " + "odznaczone: usuwa wszystkie oświadczenia publikacji jednym " + "DELETE (``{all: True}``), a następnie wysyła wszystkie " + "lokalne jako batch. Wariant selektywny zachowuje metadata " + "PBN (``addedTimestamp`` itd.) dla identycznych rekordów." + ), + verbose_name="Kasuj oświadczenia selektywnie (per osoba)", + ), + ), + ] diff --git a/src/bpp/migrations/0416_merge_20260504_1024.py b/src/bpp/migrations/0416_merge_20260504_1024.py new file mode 100644 index 000000000..2fbe57093 --- /dev/null +++ b/src/bpp/migrations/0416_merge_20260504_1024.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.13 on 2026-05-04 08:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0414_uczelnia_pbn_kasuj_dyscypliny_selektywnie"), + ("bpp", "0415_merge_20260504_0907"), + ] + + operations = [] diff --git a/src/bpp/models/uczelnia.py b/src/bpp/models/uczelnia.py index c25584de3..a817437f2 100644 --- a/src/bpp/models/uczelnia.py +++ b/src/bpp/models/uczelnia.py @@ -381,8 +381,18 @@ class Uczelnia(ModelZAdnotacjami, ModelZPBN_ID, NazwaISkrot, NazwaWDopelniaczu): pbn_app_token = models.CharField( "Token aplikacji w PBN", blank=True, default="", max_length=128 ) - pbn_api_kasuj_przed_wysylka = models.BooleanField( - "Kasuj oświadczenia rekordu przed wysłaniem do PBN", default=False + pbn_kasuj_dyscypliny_selektywnie = models.BooleanField( + "Kasuj oświadczenia selektywnie (per osoba)", + default=True, + help_text=( + "Gdy zaznaczone: ``sync_publication`` usuwa oświadczenia " + "selektywnie per-osoba (DELETE ``/publications/{id}`` z " + "``{personId, role}``) i wysyła tylko brakujące. Gdy " + "odznaczone: usuwa wszystkie oświadczenia publikacji jednym " + "DELETE (``{all: True}``), a następnie wysyła wszystkie " + "lokalne jako batch. Wariant selektywny zachowuje metadata " + "PBN (``addedTimestamp`` itd.) dla identycznych rekordów." + ), ) pbn_api_nie_wysylaj_prac_bez_pk = models.BooleanField( "Nie wysyłaj do PBN prac z PK=0", default=False diff --git a/src/bpp/newsfragments/+pbn-post-statements-subset.bugfix.rst b/src/bpp/newsfragments/+pbn-post-statements-subset.bugfix.rst new file mode 100644 index 000000000..236a6169f --- /dev/null +++ b/src/bpp/newsfragments/+pbn-post-statements-subset.bugfix.rst @@ -0,0 +1,17 @@ +Poprawka w ``sync_publication``: POST oświadczeń publikacji w trybie +selektywnym (``Uczelnia.pbn_kasuj_dyscypliny_selektywnie=True``, default) +wysyła teraz TYLKO oświadczenia brakujące w PBN (``only_in_intended``), +nie pełen zestaw lokalnych. Wcześniej kod wywoływał +``WydawnictwoPBNAdapter.pbn_get_api_statements()`` zwracające wszystkie +lokalne statements i POST-ował kompletny zestaw — także te oświadczenia, +które już były w PBN. Przy założeniu że API PBN może nie być +idempotentne (odrzucić duplikaty, utworzyć zduplikowane rekordy albo +zachowywać się nieprzewidywalnie), wysyłanie tylko brakujących jest +bezpieczniejsze — nie dublujemy żądań dla już istniejących oświadczeń, +co zachowuje ich metadata w PBN (``addedTimestamp`` itp.). + +Krok 3 algorytmu (PBN puste + BPP ma) nadal wysyła wszystkie oświadczenia +publikacji, bo w tym scenariuszu ``only_in_intended`` = wszystkie klucze +lokalne. Krok 5 (tryb batch, ``pbn_kasuj_dyscypliny_selektywnie=False``) +pozostaje bez zmian — po ``delete_all`` PBN jest puste, więc POST wysyła +pełen zestaw BPP (wipe+rewrite). diff --git a/src/bpp/newsfragments/+pbn-sync-publication-split-flow.bugfix.rst b/src/bpp/newsfragments/+pbn-sync-publication-split-flow.bugfix.rst new file mode 100644 index 000000000..e4e3d51e6 --- /dev/null +++ b/src/bpp/newsfragments/+pbn-sync-publication-split-flow.bugfix.rst @@ -0,0 +1,28 @@ +Refaktoryzacja wysyłki publikacji do PBN (``sync_publication``): publikacja +jest zawsze wysyłana przez endpoint repozytoryjny +``POST /api/v1/repositorium/publications`` (bez oświadczeń w body), a +dyscypliny/oświadczenia synchronizowane są w osobnym kroku przez +``/api/v2/institution-profile/statements`` dopiero po potwierdzeniu +wysyłki publikacji. Dzięki temu nieudana wysyłka publikacji (np. HTTP +423 Locked albo inna przejściowa awaria PBN) nie kasuje już istniejących +oświadczeń w profilu instytucji — wcześniej kasowanie działo się przed +POST i tracono dane przy każdym niepowodzeniu. + +Algorytm synchronizacji oświadczeń: GET aktualnego stanu PBN, porównanie +z intencją BPP (``WydawnictwoPBNAdapter.pbn_get_json_statements()``) +przez klucz ``(personId, disciplineId)``, selektywne DELETE (per-osoba +przez ``delete_publication_statement(personId, role)``) brakujących w +BPP + POST dodatkowych. Tryb kasowania sterowany nową flagą +``Uczelnia.pbn_kasuj_dyscypliny_selektywnie`` (domyślnie ``True``; +``False`` używa ``delete_all`` + POST batch). + +Nowy wyjątek ``StatementsResendFailedException`` (w +``pbn_api.exceptions``) jest podnoszony gdy retry x3 z exponential +backoff (2s/4s/8s) na GET/DELETE/POST /v2/statements się wyczerpie. +Klasyfikowany w ``pbn_export_queue`` jako ``RETRY_LATER`` — kolejka +ponowi wysyłkę za kilka minut. + +Usunięto pole ``Uczelnia.pbn_api_kasuj_przed_wysylka`` (obsolete — +stary pre-upload DELETE zastąpiony nowym algorytmem diff po wysyłce). +Flaga ``Uczelnia.pbn_wysylaj_bez_oswiadczen`` pozostaje z dotychczasową +semantyką (odmawia wysyłki publikacji bez oświadczeń). diff --git a/src/bpp/newsfragments/+pbn-test-narzedzie-fix-compare.bugfix.rst b/src/bpp/newsfragments/+pbn-test-narzedzie-fix-compare.bugfix.rst new file mode 100644 index 000000000..36df1ce33 --- /dev/null +++ b/src/bpp/newsfragments/+pbn-test-narzedzie-fix-compare.bugfix.rst @@ -0,0 +1,20 @@ +Poprawka w narzędziu CLI ``pbn_test_wysylka_interaktywna``: + +- krok porównania oświadczeń (KROK 6/8) używał lokalnego cache'a + ``OswiadczenieInstytucji`` (snapshot z poprzedniej synchronizacji + PBN) jako reprezentanta „stanu BPP", co powodowało fałszywą + identyczność po zmianach w rekordzie — skasowaniu autora, + zmianie/wypięciu dyscypliny lub innej edycji ``Wydawnictwo_*_Autor`` + (cache nie był re-synchronizowany, pokazywał stare 3 oświadczenia + nawet po faktycznym zmniejszeniu intencji BPP do 2). Narzędzie + teraz porównuje **intencję BPP na żywo** — to co by wygenerował + ``WydawnictwoPBNAdapter.pbn_get_api_statements()`` gdyby wysyłać + teraz — z aktualnym stanem PBN. Dodatkowo KROK 1/8 pokazuje zarówno + cache jak i intencję żywą, żeby od razu widać było rozjazd. +- narzędzie zawsze pyta osobno o DELETE oświadczeń i osobno o POST + oświadczeń, także gdy porównanie zwróciło identyczność — + użytkownik może wymusić operację np. dla empirycznego sprawdzenia + reakcji PBN (wcześniej flow kończył się wczesnym ``return`` po + identyczności bez opcji kontynuacji). Domyślna wartość pytania + zależy od wyniku porównania: „identyczne" → default ``n``, + „różnice" → default ``t``. diff --git a/src/bpp/newsfragments/+pbn-test-wysylka-interaktywna.feature.rst b/src/bpp/newsfragments/+pbn-test-wysylka-interaktywna.feature.rst new file mode 100644 index 000000000..1b1612f57 --- /dev/null +++ b/src/bpp/newsfragments/+pbn-test-wysylka-interaktywna.feature.rst @@ -0,0 +1,13 @@ +Dodano interaktywne narzędzie CLI +``pbn_test_wysylka_interaktywna`` (Django management command) do +eksperymentalnego testowania flow wysyłki publikacji i oświadczeń do PBN +krok po kroku. Narzędzie prowadzi użytkownika przez kolejne fazy — +generowanie JSON publikacji, wybór endpointa (``/api/v1/publications`` +all-in-one albo ``/api/v1/repositorium/publications`` bez oświadczeń), +POST publikacji, GET i porównanie oświadczeń lokalnych z tym co jest w +PBN, DELETE oświadczeń i POST przez ``/api/v2/institution-profile/statements`` +— pokazując dla każdego kroku metodę HTTP, URL, body i odpowiedź +serwera. Narzędzie nie modyfikuje lokalnej bazy BPP i posiada tryb +``--dry-run``. Służy jako baza do audytu zachowania PBN API i +projektowania bezpieczniejszej kolejności operacji wysyłki (scenariusz: +nieudana wysyłka publikacji kasowała wcześniej istniejące oświadczenia). diff --git a/src/bpp/newsfragments/+pbn-v2-retry.bugfix.rst b/src/bpp/newsfragments/+pbn-v2-retry.bugfix.rst new file mode 100644 index 000000000..9710b8a7f --- /dev/null +++ b/src/bpp/newsfragments/+pbn-v2-retry.bugfix.rst @@ -0,0 +1,13 @@ +Dodano exponential backoff (5 prób, max ~30 sekund) przy pobieraniu +``PublikacjaInstytucji_V2`` (UUID publikacji z PBN API v2). Poprzednio +jedna próba kończyła się warningiem "nie jest błędem", co myliło użytkowników +— brak V2 oznacza brak możliwości generowania linków do PBN Interfejs +i wysyłki oświadczeń (wymagany UUID). Teraz system automatycznie ponawia +z rosnącym czasem oczekiwania (2s, 4s, 8s, 16s, 32s). + +Jeśli po wszystkich próbach nadal nie ma V2 — wyświetlany jest BŁĄD (czerwony) +z sugestią użycia wysyłki w tle (PBN Export Queue) zamiast interaktywnej. + +**Ważne dla deploymentu**: przy interaktywnej wysyłce z admina może być potrzebne +zwiększenie timeoutu nginx/gunicorn dla ścieżek ``/admin/`` do minimum 90-120 sekund +(domyślne 30-60s może być za mało przy 5 próbach z opóźnieniami). diff --git a/src/bpp_setup_wizard/forms.py b/src/bpp_setup_wizard/forms.py index 8f9a5c190..6a11307f1 100644 --- a/src/bpp_setup_wizard/forms.py +++ b/src/bpp_setup_wizard/forms.py @@ -183,8 +183,8 @@ def save(self, commit=True): uczelnia = super().save(commit=False) # Set the fields that should always be True - uczelnia.pbn_api_kasuj_przed_wysylka = ( - True # Kasuj oświadczenia przed wysłaniem do PBN + uczelnia.pbn_kasuj_dyscypliny_selektywnie = ( + True # Selektywny DELETE oświadczeń per-osoba (nowy flow) ) uczelnia.pbn_api_nie_wysylaj_prac_bez_pk = ( True # Nie wysyłaj do PBN prac z PK=0 diff --git a/src/bpp_setup_wizard/tests.py b/src/bpp_setup_wizard/tests.py index 73362ab00..c979cadc9 100644 --- a/src/bpp_setup_wizard/tests.py +++ b/src/bpp_setup_wizard/tests.py @@ -263,7 +263,7 @@ def test_uczelnia_setup_create_uczelnia(admin_user): assert uczelnia.uzywaj_wydzialow is True # Verify the automatically set fields - assert uczelnia.pbn_api_kasuj_przed_wysylka is True + assert uczelnia.pbn_kasuj_dyscypliny_selektywnie is True assert uczelnia.pbn_api_nie_wysylaj_prac_bez_pk is True assert uczelnia.pbn_api_afiliacja_zawsze_na_uczelnie is True assert uczelnia.pbn_wysylaj_bez_oswiadczen is True diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index f32b25584..2626435e3 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -1380,6 +1380,15 @@ def iter_namespace(ns_pkg): "level": "INFO", "propagate": False, }, + # Logger dla pbn_api - WARNING+ na konsolę. W szczególności + # `_check_error_response` w transport.py loguje pełne body + # i headers przy odpowiedziach >= 400, co jest kluczowe przy + # diagnostyce błędów typu „400 Bad Request" bez czytelnego body. + "pbn_api": { + "handlers": ["console"], + "level": "WARNING", + "propagate": False, + }, }, } diff --git a/src/importer_publikacji/migrations/0006_merge_20260420_2212.py b/src/importer_publikacji/migrations/0006_merge_20260420_2212.py new file mode 100644 index 000000000..2515ea0f8 --- /dev/null +++ b/src/importer_publikacji/migrations/0006_merge_20260420_2212.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.13 on 2026-04-20 20:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("importer_publikacji", "0005_alter_importsession_created_by"), + ("importer_publikacji", "0005_importsession_wydawnictwo_nadrzedne"), + ] + + operations = [] diff --git a/src/importer_publikacji/migrations/0007_merge_20260421_1248.py b/src/importer_publikacji/migrations/0007_merge_20260421_1248.py new file mode 100644 index 000000000..3857f0ad4 --- /dev/null +++ b/src/importer_publikacji/migrations/0007_merge_20260421_1248.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.13 on 2026-04-21 10:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("importer_publikacji", "0006_merge_20260420_2212"), + ("importer_publikacji", "0006_merge_20260421_1100"), + ] + + operations = [] diff --git a/src/pbn_api/adapters/wydawnictwo.py b/src/pbn_api/adapters/wydawnictwo.py index f0508dafe..8b54128b7 100644 --- a/src/pbn_api/adapters/wydawnictwo.py +++ b/src/pbn_api/adapters/wydawnictwo.py @@ -237,16 +237,35 @@ def _build_open_access_base_fields(self) -> dict: return oa def _build_open_access_release_date(self, oa: dict) -> None: - """Add release date fields to OpenAccess dict.""" - if self.original.public_dostep_dnia is not None: + """Ustawia pola daty udostępnienia OpenAccess. + + Priorytet źródeł (od najbardziej dedykowanego): + + 1. ``openaccess_data_opublikowania`` — pole dedykowane OpenAccess + (``ModelZOpenAccess``), ustawiane przez importer z PBN i przez + redakcję ręcznie. To powinno być podstawowe źródło. + 2. ``public_dostep_dnia`` — data wolnego dostępu do strony WWW + (``ModelZWWW``). Fallback dla starszych rekordów bez dedykowanej + daty OpenAccess. + 3. ``dostep_dnia`` — data płatnego dostępu. Ostatni fallback. + + Gdy żadna z dat nie jest wypełniona, pola ``releaseDate``, + ``releaseDateMonth``, ``releaseDateYear`` NIE są ustawiane. + Wcześniejsza implementacja wstawiała hardcoded + ``{"releaseDateMonth": "JANUARY", "releaseDateYear": str(rok)}``, + co wysyłało do PBN nieprawdziwe dane (styczeń niezależnie od + faktycznego miesiąca publikacji). Lepiej pominąć pola i zmusić + PBN do zwrócenia validation error (lub zaakceptowania, jeśli + spec pozwala), niż wysyłać kłamliwy miesiąc. + """ + data_oa = getattr(self.original, "openaccess_data_opublikowania", None) + if data_oa is not None: + oa["releaseDate"] = str(data_oa) + elif self.original.public_dostep_dnia is not None: oa["releaseDate"] = str(self.original.public_dostep_dnia) elif self.original.dostep_dnia is not None: oa["releaseDate"] = str(self.original.dostep_dnia) - if oa.get("releaseDate") is None: - oa["releaseDateMonth"] = "JANUARY" - oa["releaseDateYear"] = str(self.original.rok) - def _build_open_access(self, pub_type: str) -> dict | None: """Build OpenAccess data structure if all required fields are present.""" oa = self._build_open_access_base_fields() diff --git a/src/pbn_api/client/publication_sync.py b/src/pbn_api/client/publication_sync.py index d5d4583d7..378479de9 100644 --- a/src/pbn_api/client/publication_sync.py +++ b/src/pbn_api/client/publication_sync.py @@ -22,6 +22,7 @@ from pbn_api.exceptions import ( CannotDeleteStatementsException, CannotUploadPublicationFee, + DaneLokalneWymagajaAktualizacjiException, HttpException, NoFeeDataException, NoPBNUIDException, @@ -30,6 +31,7 @@ PublicationDoesNotExistInInstitutionProfile, PublikacjaInstytucjiV2NieZnalezionaException, SameDataUploadedRecently, + StatementsResendFailedException, ZnalezionoWielePublikacjiInstytucjiV2Exception, ) from pbn_api.models.pbn_odpowiedzi_niepozadane import PBNOdpowiedziNiepozadane @@ -43,9 +45,21 @@ class PublicationSyncMixin: """Mixin providing publication synchronization methods.""" def post_publication(self, json): + """POST publikacji wraz z oświadczeniami do ``/api/v1/publications``. + + Endpoint all-in-one — przyjmuje payload z kluczem ``statements`` + bezpośrednio z ``WydawnictwoPBNAdapter.pbn_get_json()`` (bez + konwersji pól, bez owijania w listę). Zwraca pojedynczy obiekt + z ``objectId`` (a nie listę z ``id`` jak endpoint repo). + """ return self.transport.post(PBN_POST_PUBLICATIONS_URL, body=json) - def convert_js_with_statements_to_no_statements(self, json): + def convert_json_with_statements_to_no_statements(self, json): + # Endpoint repozytoryjny `/api/v1/repositorium/publications` nie + # przyjmuje klucza `statements` w body — oświadczenia synchronizujemy + # osobno przez `/api/v2/institution-profile/statements`. + json.pop("statements", None) + # PBN zmienił givenNames na firstName for elem in json.get("authors", []): elem["firstName"] = elem.pop("givenNames") @@ -67,17 +81,21 @@ def convert_js_with_statements_to_no_statements(self, json): # OpenAccess modeArticle -> mode json = rename_dict_key(json, "modeArticle", "mode") - # OpenAccess releaseDateYear "2022" -> 2022 - if json.get("openAccess", False): - if isinstance(json["openAccess"], dict) and json["openAccess"].get( - "releaseDateYear" - ): + # OpenAccess releaseDateYear "2022" -> 2022 (int) + # Jeśli konwersja na int zawiedzie — zachowujemy oryginalną wartość + # (PBN zwróci validation error z jasnym komunikatem, jeśli format + # jest nieprawidłowy). Wcześniejsza implementacja miała NameError: + # zmienna ``i`` była zdefiniowana tylko wewnątrz ``try``, a + # bezwarunkowy assignment poza blokiem rzucał NameError gdy + # ``int()`` failowało. + if json.get("openAccess", False) and isinstance(json["openAccess"], dict): + value = json["openAccess"].get("releaseDateYear") + if value is not None: try: - i = int(json["openAccess"]["releaseDateYear"]) - except (ValueError, TypeError, AttributeError): + json["openAccess"]["releaseDateYear"] = int(value) + except (ValueError, TypeError): + # Nie ruszamy wartości — PBN wskaże problem w walidacji. pass - - json["openAccess"]["releaseDateYear"] = i return json def post_publication_no_statements(self, json): @@ -151,7 +169,25 @@ def get_publication_fees_batch(self, publication_ids): return fees_map def _prepare_publication_json(self, rec, export_pk_zero, always_affiliate_to_uid): - """Prepare publication JSON data.""" + """Przygotowuje JSON publikacji do wysyłki. + + Decyzja o endpoincie wynika z obecności klucza ``statements`` w + payloadzie z adaptera: + + - Praca ma lokalne statements → zwracamy surowy JSON adaptera, + ``upload_publication`` POST-uje do ``/api/v1/publications`` + (all-in-one). + - Praca nie ma lokalnych statements (uczelnia z flagą + ``pbn_wysylaj_bez_oswiadczen=True``) → konwertujemy przez + ``convert_json_with_statements_to_no_statements`` (renames pól + + brak ``fee``); ``upload_publication`` POST-uje do + ``/api/v1/repositorium/publications``. + + Adapter rzuca ``StatementsMissing`` gdy brak statements + flaga + uczelni ``=False`` — ten przypadek nie dochodzi tu. + + Zwraca: ``(js, bez_oswiadczen)``. + """ js = WydawnictwoPBNAdapter( rec, export_pk_zero=export_pk_zero, @@ -160,7 +196,7 @@ def _prepare_publication_json(self, rec, export_pk_zero, always_affiliate_to_uid bez_oswiadczen = "statements" not in js if bez_oswiadczen: - js = self.convert_js_with_statements_to_no_statements(js) + js = self.convert_json_with_statements_to_no_statements(js) return js, bez_oswiadczen @@ -174,49 +210,96 @@ def _check_upload_needed(self, rec, js, force_upload): ) def _post_publication_data(self, js, bez_oswiadczen): - """Post publication data and extract objectId.""" + """POST publikacji do właściwego endpointu i wyciągnięcie ``objectId``. + + - ``bez_oswiadczen=False`` → ``/v1/publications``, + response: ``{"objectId": ...}`` (single dict). + - ``bez_oswiadczen=True`` → ``/v1/repositorium/publications``, + response: ``[{"id": ...}]`` (lista 1 elementu). + """ if not bez_oswiadczen: ret = self.post_publication(js) - objectId = ret.get("objectId", None) - else: - ret = self.post_publication_no_statements(js) - if len(ret) != 1: - raise Exception( - "Lista zwróconych obiektów przy wysyłce pracy bez oświadczeń " - "różna od jednego. " - "Sytuacja nieobsługiwana, proszę o kontakt z autorem programu. " - ) - try: - objectId = ret[0].get("id", None) - except KeyError as e: - raise Exception( - f"Serwer zwrócił nieoczekiwaną odpowiedź. {ret=}" - ) from e + objectId = ret.get("objectId", None) if isinstance(ret, dict) else None + return ret, objectId + + ret = self.post_publication_no_statements(js) + if len(ret) != 1: + raise Exception( + "Lista zwróconych obiektów przy wysyłce pracy do repozytorium " + "różna od jednego. " + "Sytuacja nieobsługiwana, proszę o kontakt z autorem programu. " + ) + try: + objectId = ret[0].get("id", None) + except (KeyError, IndexError) as e: + raise Exception(f"Serwer zwrócił nieoczekiwaną odpowiedź. {ret=}") from e return ret, objectId - def _should_retry_validation_error(self, e): - """Check if HTTP exception is a retryable validation error.""" - return ( - e.status_code == 400 - and e.url == "/api/v1/publications" - and "Bad Request" in e.content - and "Validation failed." in e.content - ) + def _pre_upload_clear_pbn_statements_if_any(self, rec): + """Wycofaj oświadczenia z PBN PRZED wysyłką pracy bez-oświadczeniowej. + + Sytuacja docelowa: praca lokalnie ma już 0 dyscyplin (np. ostatnia + została skasowana), a PBN nadal trzyma stare oświadczenia. POST + do ``/v1/repositorium/publications`` może odrzucić publikację gdy + ma już oświadczenia po stronie PBN — kasujemy je upfront. + + Algorytm (best-effort): + + - Brak ``pbn_uid_id`` → praca jeszcze nie ma odpowiednika w PBN, + nie ma czego kasować. + - GET ``/page/statements`` z PBN. Gdy zawiedzie — log warning + i kontynuujemy (``upload_publication`` rzuci czytelny błąd + POST jeśli problem rzeczywiście blokuje wysyłkę). + - Gdy PBN puste — nie ma czego kasować, return. + - W przeciwnym razie DELETE selektywnie/batch (wg + ``Uczelnia.pbn_kasuj_dyscypliny_selektywnie``). DELETE failure + rzucamy w górę (``StatementsResendFailedException``) bo nie + chcemy wysłać publikacji do API które za chwilę odrzuci nas + z powodu pozostałych oświadczeń. + """ + from bpp.models import Uczelnia + + pbn_uid = rec.pbn_uid_id + if not pbn_uid: + return - def _retry_download_publication(self, objectId): - """Attempt to download publication data after validation error.""" + publication_pk = rec.pk try: - publication = self.download_publication(objectId=objectId) - self.download_statements_of_publication(publication) - self.pobierz_publikacje_instytucji_v2(objectId=objectId) - except Exception: - rollbar.report_exc_info(sys.exc_info()) - logger.debug( - "Błąd podczas ponownego pobierania publikacji %s", - objectId, - exc_info=True, + pbn_statements = list( + self.get_institution_statements_of_single_publication( + str(pbn_uid), 5120 + ) + ) + except Exception as e: + logger.warning( + "Pre-upload GET oświadczeń PBN dla %s nieudany (%s). " + "Kontynuuję wysyłkę — POST może rzucić błąd jeśli PBN " + "ma stare statements.", + pbn_uid, + e, + ) + return + + if not pbn_statements: + return + + uczelnia = Uczelnia.objects.get_default() + kasuj_selektywnie = ( + uczelnia.pbn_kasuj_dyscypliny_selektywnie if uczelnia else True + ) + + if kasuj_selektywnie: + self._delete_statements_selective( + str(pbn_uid), pbn_statements, publication_pk ) + else: + try: + self._delete_statements_batch(str(pbn_uid), publication_pk) + except CannotDeleteStatementsException: + # PBN mówi, że nie ma oświadczeń — akceptowalne (race + # między naszym GET-em a kasowaniem przez kogoś innego). + pass def upload_publication( self, @@ -224,55 +307,64 @@ def upload_publication( force_upload=False, export_pk_zero=None, always_affiliate_to_uid=None, - max_retries_on_validation_error=3, + max_retries_on_validation_error=3, # DEPRECATED: nieużywany, backward compat ): - """ - Ta funkcja wysyła dane publikacji na serwer, w zależności od obecności oświadczeń - w JSONie (klucz: "statements") używa albo api /v1/ do wysyłki publikacji - "ze wszystkim", albo korzysta z api /v1/ repozytorialnego. - - Zwracane wyniki wyjściowe też różnią się w zależnosci od użytego API stąd też - ta funkcja stara się w miarę rozsądnie to ogarnąć. + """Wysyła publikację do PBN. + + Wybór endpointu zależy od obecności lokalnych oświadczeń: + + - Praca z lokalnymi statements → ``POST /v1/publications`` + (all-in-one, surowy payload z adaptera; statements w body). + - Praca bez lokalnych statements (uczelnia z + ``pbn_wysylaj_bez_oswiadczen=True``) → + ``POST /v1/repositorium/publications`` (po konwersji + ``convert_json_with_statements_to_no_statements`` + body + owinięte w listę). + + Niezależnie od endpointu, ``sync_publication`` PO udanej wysyłce + synchronizuje oświadczenia osobno przez + ``/api/v2/institution-profile/statements`` (GET → diff → + DELETE/POST). Dla ``/v1/publications`` typowo no-op (PBN ma + identyczne statements z body); dla ``/v1/repositorium`` z + lokalnym brakiem statements — kasuje pozostałe stare statements + z PBN, jeśli były. + + Dla ścieżki repo dodatkowo wykonujemy **pre-upload clear** + (``_pre_upload_clear_pbn_statements_if_any``): gdy praca ma + ``pbn_uid_id`` i PBN ma jakieś oświadczenia — kasujemy je PRZED + POST. Powód: endpoint ``/v1/repositorium/publications`` może + odrzucić publikację gdy PBN ma istniejące oświadczenia, a my + chcemy je usunąć (bo BPP nie ma intencji ich wysłania). + + Zwraca ``(objectId, ret, js, bez_oswiadczen)``. """ js, bez_oswiadczen = self._prepare_publication_json( rec, export_pk_zero, always_affiliate_to_uid ) self._check_upload_needed(rec, js, force_upload) - # Create or update SentData record BEFORE API call - sent_data = SentData.objects.create_or_update_before_upload(rec, js) # noqa - - retry_count = max_retries_on_validation_error - ret = None - objectId = None - - while True: - try: - ret, objectId = self._post_publication_data(js, bez_oswiadczen) - SentData.objects.mark_as_successful(rec, api_response_status=str(ret)) - break + if bez_oswiadczen: + self._pre_upload_clear_pbn_statements_if_any(rec) - except HttpException as e: - if self._should_retry_validation_error(e): - retry_count -= 1 - if retry_count <= 0: - SentData.objects.mark_as_failed( - rec, exception=str(e), api_response_status=e.content - ) - raise e - - time.sleep(0.5) - self._retry_download_publication(objectId) - continue - - SentData.objects.mark_as_failed( - rec, exception=str(e), api_response_status=e.content - ) - raise e + endpoint_path = ( + PBN_POST_PUBLICATION_NO_STATEMENTS_URL + if bez_oswiadczen + else PBN_POST_PUBLICATIONS_URL + ) + api_url = self.transport.base_url + endpoint_path + SentData.objects.create_or_update_before_upload(rec, js, api_url=api_url) - except Exception as e: - SentData.objects.mark_as_failed(rec, exception=str(e)) - raise e + try: + ret, objectId = self._post_publication_data(js, bez_oswiadczen) + SentData.objects.mark_as_successful(rec, api_response_status=str(ret)) + except HttpException as e: + SentData.objects.mark_as_failed( + rec, exception=str(e), api_response_status=e.content + ) + raise + except Exception as e: + SentData.objects.mark_as_failed(rec, exception=str(e)) + raise return objectId, ret, js, bez_oswiadczen @@ -317,18 +409,389 @@ def pobierz_publikacje_instytucji_v2(self, objectId): return zapisz_publikacje_instytucji_v2(self, elem[0]) def _delete_statements_with_retry(self, pbn_uid_id, max_tries=5): - """Delete publication statements with retry on failure.""" + """Delete publication statements with retry on failure. + + Używane przez batch flow (``pbn_wysylka_oswiadczen/tasks.py``) oraz + przez nowy ``_delete_statements_batch`` helper w ``sync_publication``. + """ no_tries = max_tries while True: try: self.delete_all_publication_statements(pbn_uid_id) return True except CannotDeleteStatementsException as e: - if no_tries < 0: + # Warunek <= 0 (nie < 0): dla ``max_tries=5`` chcemy dokładnie + # 5 prób (no_tries: 5→4→3→2→1→0), po szóstej iteracji rzucamy. + # Wcześniejsze ``< 0`` pozwalało na 6 prób. + if no_tries <= 0: raise e no_tries -= 1 time.sleep(0.5) + # ----------- Helpery do nowego split-flow sync_publication ----------- + # Mapowanie kluczy porównania: + # - PBN GET /page/statements zwraca: {personId, area, type, institutionId, ...} + # - Adapter pbn_get_json_statements() zwraca: {personObjectId, disciplineId, + # disciplineUuid, type, ...} + # Klucz porównania: (person mongoId, discipline numerek). Oba na string. + # Selektywny DELETE używa (personId, role) — delete_publication_statement. + + _STATEMENT_RETRY_DELAYS = (2, 4, 8) # exponential backoff przy 3 próbach + + @staticmethod + def _statement_key_pbn(stmt): + """Klucz porównania dla oświadczenia z PBN GET response.""" + return ( + str(stmt.get("personId", "")), + str(stmt.get("area", "")), + ) + + @staticmethod + def _statement_key_intended(stmt): + """Klucz porównania dla oświadczenia z ``pbn_get_json_statements``.""" + return ( + str(stmt.get("personObjectId", "")), + str(stmt.get("disciplineId", "")), + ) + + def _diff_statements(self, pbn_statements, intended_statements): + """Porównuje zestaw oświadczeń PBN z intencją BPP. + + Zwraca (only_in_pbn, only_in_intended) jako sety kluczy + ``(person_mongoId, discipline_numerek)``: + + - ``only_in_pbn`` — do usunięcia z PBN (PBN ma, BPP nie chce) + - ``only_in_intended`` — do dodania do PBN (BPP chce, PBN nie ma) + """ + pbn_keys = {self._statement_key_pbn(s) for s in pbn_statements} + intended_keys = {self._statement_key_intended(s) for s in intended_statements} + return pbn_keys - intended_keys, intended_keys - pbn_keys + + def _report_statements_failure_and_raise( + self, publication_pk, objectId, last_error + ): + """Raportuje do Rollbar (level=warning) i rzuca StatementsResendFailedException.""" + try: + raise StatementsResendFailedException(publication_pk, objectId, last_error) + except StatementsResendFailedException: + rollbar.report_exc_info( + sys.exc_info(), + level="warning", + extra_data={ + "publication_pk": publication_pk, + "pbn_uid": str(objectId), + "last_error": str(last_error), + }, + ) + raise + + def _get_pbn_statements_with_retry(self, objectId, publication_pk, max_tries=3): + """Pobiera oświadczenia publikacji z PBN z retry (exponential backoff). + + Po wyczerpaniu prób: rollbar.report_exc_info(level="warning") oraz + raise ``StatementsResendFailedException``. + """ + last_error = None + for attempt in range(max_tries): + try: + return list( + self.get_institution_statements_of_single_publication( + str(objectId), 5120 + ) + ) + except Exception as e: + last_error = e + logger.warning( + "Błąd pobierania oświadczeń PBN dla %s, próba %d/%d: %s", + objectId, + attempt + 1, + max_tries, + e, + exc_info=True, + ) + if attempt < max_tries - 1: + time.sleep(self._STATEMENT_RETRY_DELAYS[attempt]) + + self._report_statements_failure_and_raise(publication_pk, objectId, last_error) + + def _delete_statements_selective( + self, objectId, pbn_statements_to_delete, publication_pk, max_tries=3 + ): + """Selektywne DELETE oświadczeń per-osoba (delete_publication_statement). + + Iteruje po liście oświadczeń PBN do usunięcia i wywołuje DELETE dla + każdego (klucz: personId + type z PBN GET response). Po wyczerpaniu + prób per oświadczenie: rollbar + raise StatementsResendFailedException. + """ + for stmt in pbn_statements_to_delete: + person_id = stmt.get("personId") + role = stmt.get("type") + last_error = None + success = False + for attempt in range(max_tries): + try: + self.delete_publication_statement(str(objectId), person_id, role) + success = True + break + except Exception as e: + last_error = e + logger.warning( + "Błąd DELETE oświadczenia (%s, %s) dla %s, próba %d/%d: %s", + person_id, + role, + objectId, + attempt + 1, + max_tries, + e, + exc_info=True, + ) + if attempt < max_tries - 1: + time.sleep(self._STATEMENT_RETRY_DELAYS[attempt]) + if not success: + self._report_statements_failure_and_raise( + publication_pk, objectId, last_error + ) + + def _delete_statements_batch(self, objectId, publication_pk, max_tries=3): + """Batch DELETE wszystkich oświadczeń publikacji z retry. + + Rzuca ``CannotDeleteStatementsException`` w górę (caller może + zignorować gdy PBN mówi że nie ma oświadczeń). Po wyczerpaniu prób + dla innych błędów: rollbar + raise StatementsResendFailedException. + """ + last_error = None + for attempt in range(max_tries): + try: + self.delete_all_publication_statements(str(objectId)) + return + except CannotDeleteStatementsException: + raise + except Exception as e: + last_error = e + logger.warning( + "Błąd batch DELETE oświadczeń dla %s, próba %d/%d: %s", + objectId, + attempt + 1, + max_tries, + e, + exc_info=True, + ) + if attempt < max_tries - 1: + time.sleep(self._STATEMENT_RETRY_DELAYS[attempt]) + + self._report_statements_failure_and_raise(publication_pk, objectId, last_error) + + @staticmethod + def _convert_stmt_for_api(stmt): + """Konwersja statement z formatu ``pbn_get_json_statements`` do formatu + akceptowanego przez ``POST /api/v2/institution-profile/statements``. + + Skopiowane z ``WydawnictwoPBNAdapter.pbn_get_api_statements._convert_stmt`` + (``src/pbn_api/adapters/wydawnictwo.py:201-210``). Gdy zmieni się tam + format, zmień też tutaj. + """ + stmt = dict(stmt) # shallow copy — nie modyfikujemy oryginalnego + if "disciplineId" in stmt and "disciplineUuid" in stmt: + del stmt["disciplineId"] + if "type" in stmt: + stmt["personRole"] = stmt.pop("type") + stmt.pop("personNaturalId", None) + return stmt + + def _build_post_statements_payload(self, rec, filter_keys=None): + """Buduje payload dla ``POST /api/v2/institution-profile/statements``. + + Gdy ``filter_keys`` jest ``None``, zwraca pełen payload z + ``WydawnictwoPBNAdapter.pbn_get_api_statements()`` (wszystkie + lokalne statements w formacie zgodnym z endpointem). + + Gdy ``filter_keys`` to set tupli + ``(personObjectId_str, disciplineId_str)``: + + - ``publicationUuid`` bierzemy z wynikowego ``pbn_get_api_statements`` + (to również wymusza wywołanie ``get_pbn_uuid`` w adapterze — + jeśli nie ma V2 lokalnie, rzuci ``DaneLokalneWymagajaAktualizacjiException``). + - Statements bierzemy z surowego ``pbn_get_json_statements()`` (format + przed konwersją, zawiera ``disciplineId`` używany jako część klucza + porównania). Filtrujemy po ``_statement_key_intended`` i przepuszczamy + każdy przez ``_convert_stmt_for_api``. + + Zwraca ``None`` gdy zestaw po filtrowaniu jest pusty (brak sensu + POST-ować pustą listę). + """ + adapter = WydawnictwoPBNAdapter(rec) + + # Zawsze wywołujemy pbn_get_api_statements — daje publicationUuid + # i pełen zestaw dla trybu bez-filtra. Może rzucić + # DaneLokalneWymagajaAktualizacjiException — propaguje do callera. + full_payload = adapter.pbn_get_api_statements() + + if filter_keys is None: + # Pełen zestaw (tryb batch — po delete_all POST-ujemy wszystko). + return full_payload + + if not filter_keys: + return None + + # Filtrowanie po kluczu ``(personObjectId, disciplineId)``. + # Klucz wymaga surowego disciplineId (``pbn_get_api_statements`` + # usuwa disciplineId gdy jest disciplineUuid, więc nie da się + # filtrować po full_payload). + filtered = [ + self._convert_stmt_for_api(s) + for s in adapter.pbn_get_json_statements() + if self._statement_key_intended(s) in filter_keys + ] + if not filtered: + return None + return { + "publicationUuid": full_payload["publicationUuid"], + "statements": filtered, + } + + def _post_statements_with_retry( + self, rec, objectId, publication_pk, filter_keys=None, max_tries=3 + ): + """POST oświadczeń publikacji do ``/api/v2/institution-profile/statements``. + + Args: + rec: rekord BPP (Wydawnictwo_Ciagle/Wydawnictwo_Zwarte). + objectId: PBN UID publikacji (do logowania błędów). + publication_pk: PK rekordu BPP (do logowania błędów). + filter_keys: Optional[set] zestaw kluczy + ``(personObjectId_str, disciplineId_str)`` — gdy podany, + POST-ujemy tylko te statements których klucz jest w zestawie + (używane w krokach 3/4b algorytmu — wysyłamy tylko brakujące + w PBN, nie dublujemy istniejących). Gdy ``None`` — POST-ujemy + pełen zestaw lokalnych (używane w trybie batch). + max_tries: liczba prób retry (default 3). + + Wymaga lokalnego ``PublikacjaInstytucji_V2`` (wywołanie + ``get_pbn_uuid`` rzuca ``DaneLokalneWymagajaAktualizacjiException`` + gdy brak) — ``sync_publication`` wywołuje ``pobierz_publikacje_instytucji_v2`` + przed tym helperem, więc V2 powinno istnieć. + + Retry z exponential backoff. Po wyczerpaniu: rollbar + raise + ``StatementsResendFailedException``. + + Gdy ``filter_keys`` jest pustym setem albo po filtrowaniu zestaw + jest pusty — metoda nie wykonuje POST-a (brak czego wysłać). + """ + # _build_post_statements_payload może rzucić + # DaneLokalneWymagajaAktualizacjiException — propaguje do callera + # (sync_publication), który loguje warning zamiast crash. + payload = self._build_post_statements_payload(rec, filter_keys=filter_keys) + if payload is None: + return # nic do wysłania + + body = {"data": [payload]} + + last_error = None + for attempt in range(max_tries): + try: + self.post_discipline_statements(body) + return + except Exception as e: + last_error = e + logger.warning( + "Błąd POST oświadczeń dla %s, próba %d/%d: %s", + objectId, + attempt + 1, + max_tries, + e, + exc_info=True, + ) + if attempt < max_tries - 1: + time.sleep(self._STATEMENT_RETRY_DELAYS[attempt]) + + self._report_statements_failure_and_raise(publication_pk, objectId, last_error) + + def _sync_statements_with_pbn( + self, rec, objectId, kasuj_selektywnie, notificator=None + ): + """Synchronizuje oświadczenia publikacji z PBN po wysyłce publikacji. + + Algorytm: + 1. GET aktualnych oświadczeń z PBN + 2. Intencja BPP z ``WydawnictwoPBNAdapter.pbn_get_json_statements()`` + 3. Diff (klucz: person mongoId + numerek dyscypliny) + 4a. PBN ma + BPP nie chce → DELETE (selektywnie lub batch) + 4b. PBN nie ma + BPP chce → POST /v2/statements + 4c. Różnice (oba) → DELETE brakujących + POST dodatkowych + 4d. Identyczne → nic + + Args: + rec: rekord BPP (Wydawnictwo_Ciagle/Wydawnictwo_Zwarte) + objectId: PBN UID publikacji + kasuj_selektywnie: True=per-osoba DELETE, False=batch delete_all + notificator: opcjonalny logger UI + + Raises: + StatementsResendFailedException: gdy retry operacji się wyczerpie + DaneLokalneWymagajaAktualizacjiException: gdy POST potrzebuje + V2 którego nie ma lokalnie (propagowana — caller loguje + warning zamiast crashować) + """ + publication_pk = rec.pk + + pbn_statements = self._get_pbn_statements_with_retry(objectId, publication_pk) + intended = WydawnictwoPBNAdapter(rec).pbn_get_json_statements() + + only_in_pbn, only_in_intended = self._diff_statements(pbn_statements, intended) + + if not only_in_pbn and not only_in_intended: + if notificator is not None: + notificator.info( + "Oświadczenia w PBN identyczne z intencją BPP — bez zmian." + ) + return + + if only_in_pbn: + if kasuj_selektywnie: + # Zbuduj listę oświadczeń do usunięcia z PBN (pełne dict-y + # zachowują personId + type, potrzebne dla delete_publication_statement). + stmts_to_delete = [ + s + for s in pbn_statements + if self._statement_key_pbn(s) in only_in_pbn + ] + self._delete_statements_selective( + objectId, stmts_to_delete, publication_pk + ) + else: + try: + self._delete_statements_batch(objectId, publication_pk) + except CannotDeleteStatementsException: + # PBN mówi że nie ma oświadczeń — akceptowalne, kontynuuj + pass + + if only_in_intended: + if kasuj_selektywnie: + # Selective (kroki 3 i 4b algorytmu): wyślij TYLKO oświadczenia + # brakujące w PBN (``only_in_intended``). Nie dublujemy już + # istniejących — zakładamy że API PBN może sobie z duplikatami + # nie radzić (idempotentność nie jest gwarantowana). Ten sam + # filter działa dla obu scenariuszy (PBN puste vs PBN+BPP + # różnią się), bo w obu ``only_in_intended`` reprezentuje + # dokładnie "co trzeba dodać do PBN". + self._post_statements_with_retry( + rec, + objectId, + publication_pk, + filter_keys=only_in_intended, + ) + else: + # Batch: po ``delete_all`` PBN jest puste, więc POST-ujemy + # pełen zestaw lokalny (wipe+rewrite). Bez filtra. + self._post_statements_with_retry(rec, objectId, publication_pk) + + if notificator is not None: + notificator.info( + f"Zsynchronizowano oświadczenia: " + f"skasowano z PBN {len(only_in_pbn)}, " + f"dodano do PBN {len(only_in_intended)}." + ) + def _handle_no_objectid(self, notificator, ret, js, pub): """Handle case when server doesn't return object ID.""" msg = ( @@ -361,13 +824,33 @@ def _download_statements_with_retry( no_tries -= 1 time.sleep(0.5) - try: - self.pobierz_publikacje_instytucji_v2(objectId=objectId) - except PublikacjaInstytucjiV2NieZnalezionaException: - notificator.warning( - "Nie znaleziono oświadczeń dla publikacji po stronie PBN w wersji " - "V2 API. Ten komunikat nie jest błędem. " - ) + # Retry z exponential backoff dla V2 API + max_v2_tries = 5 + v2_try = 0 + base_delay = 2 + + while v2_try < max_v2_tries: + try: + self.pobierz_publikacje_instytucji_v2(objectId=objectId) + return + except PublikacjaInstytucjiV2NieZnalezionaException: + v2_try += 1 + if v2_try >= max_v2_tries: + notificator.error( + f"Po {max_v2_tries} próbach nie znaleziono oświadczeń V2 w PBN. " + "Może to oznaczać że: (1) publikacja nie ma jeszcze oświadczeń, " + "(2) PBN jeszcze ich nie wygenerował. " + "Spróbuj ponownie za kilka minut lub użyj wysyłki w tle " + "(PBN Export Queue), która automatycznie poradzi sobie z tym przypadkiem." + ) + return + + delay = base_delay * (2**v2_try) + logger.info( + f"V2 API nie gotowe dla objectId={objectId}, " + f"próba {v2_try}/{max_v2_tries}, czekam {delay}s" + ) + time.sleep(delay) def _get_username_from_notificator(self, notificator): """Extract username from notificator if available.""" @@ -469,59 +952,68 @@ def sync_publication( # noqa: C901 pub, notificator=None, force_upload=False, - delete_statements_before_upload=False, + delete_statements_before_upload=False, # DEPRECATED: ignorowany export_pk_zero=None, always_affiliate_to_uid=None, ): """ - @param delete_statements_before_upload: gdy True, kasuj oświadczenia publikacji - przed wysłaniem (jeżeli posiada PBN UID) + Synchronizuje publikację BPP z PBN w dwóch niezależnych krokach: + + 1. POST publikacji — endpoint zależy od obecności lokalnych + oświadczeń (``upload_publication`` decyduje): + - praca z statements → ``/v1/publications`` (all-in-one); + - praca bez statements (flaga uczelni + ``pbn_wysylaj_bez_oswiadczen=True``) → + ``/v1/repositorium/publications``. + 2. Synchronizacja oświadczeń (po sukcesie kroku 1): GET aktualnego + stanu w PBN, porównanie z intencją BPP, selektywne DELETE/POST. + Działa dla obu endpointów — po ``/v1/publications`` typowo no-op + (PBN ma już identyczne statements z body), po + ``/v1/repositorium`` z lokalnym brakiem statements: kasuje + ewentualne pozostałości z PBN. + + Gdy POST publikacji zawiedzie — oświadczenia w PBN pozostają + nietknięte (ważna gwarancja bezpieczeństwa). Gdy POST publikacji + OK ale synchronizacja oświadczeń nie — rzuca + ``StatementsResendFailedException`` (klasyfikowane w + ``pbn_export_queue`` jako RETRY_LATER). + + :param delete_statements_before_upload: DEPRECATED, ignorowany — + zachowany w sygnaturze dla backward compat. Nowa logika zawsze + synchronizuje oświadczenia po wysyłce publikacji (split flow), + a tryb kasowania sterowany jest przez + ``Uczelnia.pbn_kasuj_dyscypliny_selektywnie``. """ - pub = self.eventually_coerce_to_publication(pub) + from bpp.models import Uczelnia - if ( - delete_statements_before_upload - and hasattr(pub, "pbn_uid_id") - and pub.pbn_uid_id is not None - ): - try: - self._delete_statements_with_retry(pub.pbn_uid_id) - force_upload = True - except CannotDeleteStatementsException: - pass + pub = self.eventually_coerce_to_publication(pub) - objectId, ret, js, bez_oswiadczen = self.upload_publication( + # KROK 1: POST publikacji do endpointu repo (zawsze) + objectId, ret, js, _bez_oswiadczen = self.upload_publication( pub, force_upload=force_upload, export_pk_zero=export_pk_zero, always_affiliate_to_uid=always_affiliate_to_uid, ) - if bez_oswiadczen and notificator is not None: - notificator.info( - "Rekord nie posiada oświadczeń - wysłano wyłącznie do repozytorium PBN." - ) - if not objectId: self._handle_no_objectid(notificator, ret, js, pub) return + # KROK 2: pobierz lokalnie Publication (obiekt mongodb) publication = self.download_publication(objectId=objectId) - # Update SentData with the publication link now that it exists in the database + # Update SentData with the publication link now that it exists try: sent_data = SentData.objects.get_for_rec(pub) if sent_data.pbn_uid_id is None: sent_data.pbn_uid_id = publication.pk sent_data.save() except SentData.DoesNotExist: - # This shouldn't happen if upload_publication was called, - # but handle gracefully + # This shouldn't happen if upload_publication was called pass - if not bez_oswiadczen: - self._download_statements_with_retry(publication, objectId, notificator) - + # KROK 3: obsługa zmiany/konfliktu PBN UID if pub.pbn_uid_id != objectId: if pub.pbn_uid_id is not None: self._handle_uid_change(pub, objectId, notificator, js, ret) @@ -536,6 +1028,40 @@ def sync_publication( # noqa: C901 pub.pbn_uid = publication pub.save() + # KROK 4: pobierz lokalnie PublikacjaInstytucji_V2 (wymagane przez + # ``pbn_get_api_statements`` w ``_post_statements_with_retry``). + # Przy okazji odświeża lokalny cache ``OswiadczenieInstytucji`` + # (best effort — błąd tu nie blokuje synchronizacji oświadczeń, + # bo ``_sync_statements_with_pbn`` zrobi własne GET dla porównania). + try: + self._download_statements_with_retry(publication, objectId, notificator) + except Exception as e: + logger.warning( + "Odświeżenie lokalnego cache oświadczeń dla %s nie powiodło się: %s", + objectId, + e, + ) + + # KROK 5: synchronizacja oświadczeń (split flow, po wysyłce publikacji) + uczelnia = Uczelnia.objects.get_default() + kasuj_selektywnie = ( + uczelnia.pbn_kasuj_dyscypliny_selektywnie if uczelnia else True + ) + try: + self._sync_statements_with_pbn( + pub, objectId, kasuj_selektywnie, notificator + ) + except DaneLokalneWymagajaAktualizacjiException as e: + # Brak lokalnego PublikacjaInstytucji_V2 — nie da się wysłać + # oświadczeń. Logujemy warning i kontynuujemy (publikacja + # została już wysłana w KROK 1, to nie jest fatal error). + logger.warning("Brak V2 dla %s — pomijam sync oświadczeń: %s", objectId, e) + if notificator is not None: + notificator.warning( + f"Nie mogę zsynchronizować oświadczeń (brak lokalnych " + f"danych V2): {e}" + ) + return publication def eventually_coerce_to_publication(self, pub: Model | str) -> Model: diff --git a/src/pbn_api/client/transport.py b/src/pbn_api/client/transport.py index bf3b7fbd9..66ce99881 100644 --- a/src/pbn_api/client/transport.py +++ b/src/pbn_api/client/transport.py @@ -1,11 +1,13 @@ """HTTP transport layer for PBN API client.""" +import logging import random import time import warnings from urllib.parse import quote import requests +import rollbar from requests import ConnectionError from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError from requests.exceptions import SSLError @@ -24,6 +26,8 @@ from .pagination import PageableResource from .utils import smart_content +logger = logging.getLogger(__name__) + class PBNClientTransport: """Base transport class for PBN API communication.""" @@ -161,6 +165,30 @@ def _check_error_response(self, ret, url): raise ResourceLockedException( ret.status_code, url, smart_content(ret.content) ) + # Diagnostyka: logger.error dla widoczności w konsoli/plikach + # logów, rollbar.report_message dla zdalnego trackingu (oba przy + # każdym 4xx/5xx — szczegóły body i headers przydają się przy + # debugowaniu enigmatycznych odpowiedzi typu „400 Bad Request" + # bez body). + logger.error( + "PBN %s on %s: headers=%r body_len=%d body=%r", + ret.status_code, + url, + dict(ret.headers), + len(ret.content), + ret.content[:4000], + ) + rollbar.report_message( + f"PBN {ret.status_code} on {url}", + level="error" if ret.status_code >= 500 else "warning", + extra_data={ + "status_code": ret.status_code, + "url": url, + "headers": dict(ret.headers), + "body_len": len(ret.content), + "body": smart_content(ret.content[:4000]), + }, + ) raise HttpException(ret.status_code, url, smart_content(ret.content)) def post(self, url, headers=None, body=None, delete=False): diff --git a/src/pbn_api/exceptions.py b/src/pbn_api/exceptions.py index e6a36784a..074f7d39b 100644 --- a/src/pbn_api/exceptions.py +++ b/src/pbn_api/exceptions.py @@ -167,3 +167,23 @@ class BPPAutorPublicationLinkNotFound(Exception): ale autor nie jest powiązany z tą publikacją.""" pass + + +class StatementsResendFailedException(Exception): + """Podnoszony gdy synchronizacja oświadczeń z PBN nie powiodła się + po wyczerpaniu prób retry w ``sync_publication`` (GET/DELETE/POST). + + Publikacja została już wysłana do PBN (POST do endpointu repo OK), + ale kolejne kroki synchronizacji oświadczeń zawiodły. Klasyfikowany + w ``pbn_export_queue`` jako RETRY_LATER + TECHNICZNY. + """ + + def __init__(self, publication_pk, pbn_uid, last_error): + self.publication_pk = publication_pk + self.pbn_uid = pbn_uid + self.last_error = last_error + super().__init__( + f"Synchronizacja oświadczeń dla pracy pk={publication_pk} " + f"(PBN UID={pbn_uid}) nie powiodła się po wyczerpaniu prób: " + f"{last_error}" + ) diff --git a/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py b/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py new file mode 100644 index 000000000..a4152faa8 --- /dev/null +++ b/src/pbn_api/management/commands/pbn_test_wysylka_interaktywna.py @@ -0,0 +1,585 @@ +"""Interaktywne narzędzie do testowania wysyłki publikacji i oświadczeń do PBN. + +Narzędzie prowadzi użytkownika krok po kroku przez pełen flow wysyłki +wybranej publikacji do PBN. Dla każdego żądania HTTP pokazuje metodę, +URL, body (wysyłane dane), a dla odpowiedzi — status i treść. Między +krokami czeka na Enter. + +Narzędzie NIE modyfikuje lokalnej bazy BPP — służy wyłącznie do audytu +zachowania API PBN przed docelową refaktoryzacją ``sync_publication``. + +Tryb ``--dry-run`` pokazuje wszystko, ale nie wysyła niczego do PBN. + +Użycie: + uv run python src/manage.py pbn_test_wysylka_interaktywna \\ + --wydawnictwo-zwarte 123 --user-token [--dry-run] + + uv run python src/manage.py pbn_test_wysylka_interaktywna \\ + --wydawnictwo-ciagle 456 [--dry-run] +""" + +import json +import time +from typing import Any + +from django.core.management.base import CommandError + +from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Zwarte +from pbn_api.adapters.wydawnictwo import WydawnictwoPBNAdapter +from pbn_api.const import ( + PBN_DELETE_PUBLICATION_STATEMENT, + PBN_GET_INSTITUTION_STATEMENTS, + PBN_POST_INSTITUTION_STATEMENTS_URL, + PBN_POST_PUBLICATION_NO_STATEMENTS_URL, + PBN_POST_PUBLICATIONS_URL, +) +from pbn_api.exceptions import ( + AccessDeniedException, + DaneLokalneWymagajaAktualizacjiException, + HttpException, + NeedsPBNAuthorisationException, + PraceSerwisoweException, + ResourceLockedException, +) +from pbn_api.management.commands.util import PBNBaseCommand +from pbn_api.models import OswiadczenieInstytucji + + +class UserAbort(Exception): + """Użytkownik przerwał flow (np. wpisał `q` na pytanie o Enter).""" + + +def _json_truncated(obj: Any, max_len: int = 800) -> str: + """Zwraca JSON sformatowany, ewentualnie skrócony do ``max_len`` znaków.""" + text = json.dumps(obj, indent=2, ensure_ascii=False, default=str) + if len(text) <= max_len: + return text + return text[:max_len] + f"\n... (obcięto, pełny JSON ma {len(text)} znaków)" + + +class Command(PBNBaseCommand): + help = ( + "Interaktywny REPL testujący wysyłkę publikacji i oświadczeń do PBN " + "krok po kroku. Nie modyfikuje lokalnej bazy BPP." + ) + + def add_arguments(self, parser): + super().add_arguments(parser) + parser.add_argument( + "--wydawnictwo-zwarte", + type=int, + help="PK rekordu Wydawnictwo_Zwarte do przetestowania", + ) + parser.add_argument( + "--wydawnictwo-ciagle", + type=int, + help="PK rekordu Wydawnictwo_Ciagle do przetestowania", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Pokazuj co byłoby wysyłane, ale nie wysyłaj niczego do PBN", + ) + parser.add_argument( + "--yes-all", + action="store_true", + help=( + "Automatycznie akceptuj wszystkie pytania Enter " + "(bez interakcji — do testów automatycznych)." + ), + ) + + def handle(self, app_id, app_token, base_url, user_token, *args, **options): + self.dry_run = options["dry_run"] + self.yes_all = options["yes_all"] + self.stats: list[tuple[str, str]] = [] + + if self.dry_run: + self._warn("TRYB DRY-RUN — żadne żądania nie będą wysłane do PBN.") + + publication = self._get_publication(options) + + try: + pbn_client = self.get_client(app_id, app_token, base_url, user_token) + except Exception as e: + raise CommandError(f"Nie mogę utworzyć klienta PBN: {e}") from e + + try: + self._run_flow(pbn_client, publication) + except UserAbort: + self._warn("Przerwano przez użytkownika.") + finally: + self._print_summary() + + # ------------------------- wybór publikacji ------------------------- + + def _get_publication(self, options): + pk_zwarte = options.get("wydawnictwo_zwarte") + pk_ciagle = options.get("wydawnictwo_ciagle") + + if bool(pk_zwarte) == bool(pk_ciagle): + raise CommandError( + "Podaj dokładnie jedno z: --wydawnictwo-zwarte " + "lub --wydawnictwo-ciagle ." + ) + + if pk_zwarte: + try: + return Wydawnictwo_Zwarte.objects.get(pk=pk_zwarte) + except Wydawnictwo_Zwarte.DoesNotExist as e: + raise CommandError( + f"Nie znaleziono Wydawnictwo_Zwarte o pk={pk_zwarte}" + ) from e + + try: + return Wydawnictwo_Ciagle.objects.get(pk=pk_ciagle) + except Wydawnictwo_Ciagle.DoesNotExist as e: + raise CommandError( + f"Nie znaleziono Wydawnictwo_Ciagle o pk={pk_ciagle}" + ) from e + + # ------------------------- główny flow ------------------------- + + def _run_flow(self, pbn_client, publication): + self._step_show_publication(publication) + js, bez_oswiadczen = self._step_generate_json(publication) + endpoint_choice = self._step_choose_endpoint(bez_oswiadczen) + object_id = self._step_post_publication( + pbn_client, publication, js, endpoint_choice + ) + if not object_id: + self._warn("Brak objectId z PBN — kończę flow. Sprawdź odpowiedź serwera.") + return + + pbn_statements = self._step_get_pbn_statements(pbn_client, object_id) + identyczne = self._step_compare_statements(publication, pbn_statements) + + # Zawsze pytamy osobno o DELETE i POST — nawet gdy identyczne. + # Default zależy od wyniku porównania (False dla identycznych, + # True dla różnic), ale user ma ostatnie słowo. Pozwala to + # wymusić operację (np. żeby empirycznie sprawdzić jak PBN + # reaguje na „zbędny" DELETE+POST). + ctx = "identyczne — zwykle nie trzeba" if identyczne else "są różnice" + default_act = not identyczne + + if self._prompt_yes_no( + f"Czy skasować oświadczenia w PBN? ({ctx})", default=default_act + ): + self._step_delete_statements(pbn_client, object_id) + else: + self._info("Pominięto DELETE oświadczeń (decyzja użytkownika).") + self.stats.append(("DELETE oświadczeń", "pominięty")) + + if self._prompt_yes_no( + f"Czy wysłać (POST) oświadczenia do PBN? ({ctx})", + default=default_act, + ): + self._step_post_statements(pbn_client, publication) + else: + self._info("Pominięto POST oświadczeń (decyzja użytkownika).") + self.stats.append(("POST oświadczeń", "pominięty")) + + # ------------------------- kroki ------------------------- + + def _step_show_publication(self, publication): + self._header("KROK 1/8 — Wybrana publikacja") + + # Cache ostatniej synchronizacji z PBN (może być nieaktualny). + cache = ( + OswiadczenieInstytucji.objects.filter( + publicationId_id=publication.pbn_uid_id + ).count() + if publication.pbn_uid_id + else 0 + ) + # Intencja BPP — live count tego co by adapter wysłał teraz. + try: + intended_count = len( + WydawnictwoPBNAdapter(publication).pbn_get_json_statements() + ) + intended_label = str(intended_count) + except Exception as e: # noqa: BLE001 + intended_label = f"?? (błąd adaptera: {e})" + + self._info(f"Typ: {type(publication).__name__}") + self._info(f"PK: {publication.pk}") + self._info(f"Tytuł: {publication.tytul_oryginalny[:100]}") + self._info(f"Rok: {publication.rok}") + self._info(f"PBN UID: {publication.pbn_uid_id or '(brak)'}") + self._info(f"Oświadczenia w cache (OswiadczenieInstytucji): {cache}") + self._info(f"Oświadczenia intencji BPP (live, adapter): {intended_label}") + self._prompt_enter() + + def _step_generate_json(self, publication): + self._header("KROK 2/8 — Generowanie JSON publikacji") + adapter = WydawnictwoPBNAdapter(publication) + js = adapter.pbn_get_json() + bez_oswiadczen = "statements" not in js + n_statements = len(js.get("statements", [])) if not bez_oswiadczen else 0 + self._info(f"Adapter: WydawnictwoPBNAdapter({publication!r})") + self._info( + f"JSON: {'BEZ oświadczeń' if bez_oswiadczen else 'Z oświadczeniami'}" + f" (klucz 'statements' {'NIE' if bez_oswiadczen else 'JEST'} w JSON)" + ) + if not bez_oswiadczen: + self._info(f"Oświadczeń w JSON: {n_statements}") + self._info("Preview JSON:") + self.stdout.write(_json_truncated(js, max_len=600)) + if self._prompt_yes_no("Pokazać pełny JSON?", default=False): + self.stdout.write(json.dumps(js, indent=2, ensure_ascii=False, default=str)) + self._prompt_enter() + return js, bez_oswiadczen + + def _step_choose_endpoint(self, bez_oswiadczen): + self._header("KROK 3/8 — Wybór endpointa wysyłki publikacji") + self._info( + f"Opcja [1]: POST {PBN_POST_PUBLICATIONS_URL} (all-in-one, JSON bez zmian)" + ) + self._info( + f"Opcja [2]: POST {PBN_POST_PUBLICATION_NO_STATEMENTS_URL} " + f"(wymusza JSON bez oświadczeń — convert_json_with_statements_to_no_statements)" + ) + prod_endpoint = ( + "[2] /v1/repositorium/publications" + if bez_oswiadczen + else "[1] /v1/publications" + ) + self._info(f"Produkcja wybrałaby: {prod_endpoint}") + if not bez_oswiadczen: + self._warn( + "UWAGA: JSON zawiera klucz 'statements'. Opcja [2] wyrzuci go " + "z JSON przez convert_json_with_statements_to_no_statements. " + "PBN w obu przypadkach zaakceptuje dokument zgodny ze spec." + ) + while True: + choice = self._prompt("Wybór [1/2] (q=wyjście): ") + if choice == "1": + return "publications" + if choice == "2": + return "repositorium" + if choice.lower() in ("q", "quit", "exit"): + raise UserAbort() + self._err("Nieprawidłowa opcja. Wpisz 1, 2 lub q.") + + def _step_post_publication(self, pbn_client, publication, js, endpoint_choice): + self._header("KROK 4/8 — POST publikacji do PBN") + + if endpoint_choice == "publications": + url = PBN_POST_PUBLICATIONS_URL + body = js + label = "post_publication (all-in-one)" + else: + url = PBN_POST_PUBLICATION_NO_STATEMENTS_URL + # `convert_json_with_statements_to_no_statements` usuwa klucz + # `statements` (endpoint repo go nie przyjmuje) oraz konwertuje + # pola (givenNames→firstName, abstracts do roota itd.). `dict(js)` + # — bo `js` jest współdzielone z gałęzią `publications`, mutowanie + # in-place złamałoby pozostałe kroki flow. + body_js = pbn_client.convert_json_with_statements_to_no_statements(dict(js)) + body = [body_js] + label = "post_publication_no_statements (repozytorium)" + + self._print_http_request("POST", url, body, label=label) + + if self.dry_run: + self._info("[dry-run] Pomijam wysyłkę. Zwracam sztuczny objectId='DRY'.") + self.stats.append(("POST publikacji", "dry-run (pominięty)")) + self._prompt_enter() + return "DRY" + + if not self._prompt_yes_no("Wyślij teraz?", default=True): + self._info("Pominięto POST publikacji (decyzja użytkownika).") + self.stats.append(("POST publikacji", "pominięty")) + return None + + try: + response = pbn_client.transport.post(url, body=body) + except HttpException as e: + self._print_http_error(e) + self.stats.append(("POST publikacji", f"BŁĄD HTTP {e.status_code}")) + if self._prompt_yes_no( + "Wysyłka nie powiodła się. Kontynuować flow?", default=False + ): + return None + raise UserAbort() from e + except ( + AccessDeniedException, + NeedsPBNAuthorisationException, + ResourceLockedException, + PraceSerwisoweException, + ) as e: + self._err(f"{type(e).__name__}: {e}") + self.stats.append(("POST publikacji", f"BŁĄD {type(e).__name__}")) + raise UserAbort() from e + + self._print_http_response(response) + + object_id = self._extract_object_id(response, endpoint_choice) + self._info(f"Wyciągnięty objectId = {object_id!r}") + self.stats.append(("POST publikacji", f"OK, objectId={object_id}")) + self._prompt_enter() + return object_id + + def _step_get_pbn_statements(self, pbn_client, object_id): + self._header("KROK 5/8 — Pobranie aktualnych oświadczeń z PBN") + object_id_str = str(object_id) + url = PBN_GET_INSTITUTION_STATEMENTS + f"?publicationId={object_id_str}" + self._print_http_request( + "GET", url, body=None, label="get_institution_statements" + ) + + if self.dry_run or object_id == "DRY": + self._info("[dry-run] Pomijam GET. Zwracam pustą listę.") + self.stats.append(("GET oświadczeń PBN", "dry-run")) + self._prompt_enter() + return [] + + try: + result = list( + pbn_client.get_institution_statements_of_single_publication( + object_id_str, page_size=5120 + ) + ) + except HttpException as e: + self._print_http_error(e) + self.stats.append(("GET oświadczeń PBN", f"BŁĄD HTTP {e.status_code}")) + raise UserAbort() from e + + self._info(f"PBN zwrócił oświadczeń: {len(result)}") + self.stdout.write(_json_truncated(result, max_len=600)) + self.stats.append(("GET oświadczeń PBN", f"OK, {len(result)} oświadczeń")) + self._prompt_enter() + return result + + def _step_compare_statements(self, publication, pbn_statements): + self._header("KROK 6/8 — Porównanie: intencja BPP (live) vs PBN") + + # Intencja BPP: co wygenerowałby adapter GDYBY teraz wysłać — czyli + # aktualny stan autorów/dyscyplin w rekordzie. NIE używamy cache'a + # ``OswiadczenieInstytucji`` (to snapshot *PBN* z poprzedniego + # pobrania, nie BPP); po edycji w rekordzie — skasowaniu autora, + # dyscypliny, wypięciu — cache pozostałby nieaktualny. + # + # Używamy ``pbn_get_json_statements()`` (surowa lista dict-ów + # przed konwersją w ``pbn_get_api_statements``, która usuwa + # ``disciplineId`` gdy jest ``disciplineUuid``). Surowy format + # zachowuje ``disciplineId`` (numerek MNiSW) i ``personObjectId`` + # — oba nam potrzebne do porównania z PBN GET response, gdzie są + # ``area`` i ``personId``. + try: + intended = WydawnictwoPBNAdapter(publication).pbn_get_json_statements() + except Exception as e: # noqa: BLE001 + self._warn(f"Nie mogę wygenerować intencji BPP (adapter): {e}") + self._info("Zwracam 'różnice' — user zdecyduje co robić.") + self.stats.append(("Porównanie", "nieznane (błąd adaptera)")) + self._prompt_enter() + return False # traktujemy jak "różne" + + def _key(stmt): + """Klucz porównania: (person-mongoId, disciplineNumer). + + Mapowanie między formatami: + - PBN GET response (``/page/statements``): ``personId`` (mongoId), + ``area`` (string, numerek dyscypliny MNiSW np. "301"). + - Adapter ``pbn_get_json_statements()`` (przed konwersją): + ``personObjectId`` (mongoId), ``disciplineId`` (int, numerek + dyscypliny MNiSW). + Oba oznaczają to samo. + """ + if not isinstance(stmt, dict): + return (None, None) + person = stmt.get("personId") or stmt.get("personObjectId") + discipline = stmt.get("area") + if discipline is None: + discipline = stmt.get("disciplineId") + return ( + str(person) if person else None, + str(discipline) if discipline is not None else "", + ) + + intended_keys = {_key(x) for x in intended} + pbn_keys = {_key(x) for x in pbn_statements} + + only_intended = intended_keys - pbn_keys + only_pbn = pbn_keys - intended_keys + common = intended_keys & pbn_keys + + self._info(f"Intencja BPP (live): {len(intended_keys)}") + self._info(f"Aktualnie w PBN: {len(pbn_keys)}") + self._info(f"Identycznych: {len(common)}") + self._info(f"Tylko w intencji (do dodania): {len(only_intended)}") + self._info(f"Tylko w PBN (do usunięcia): {len(only_pbn)}") + + if only_intended: + self._info(f" → intencja bez PBN: {list(only_intended)[:5]}") + if only_pbn: + self._info(f" → w PBN bez intencji: {list(only_pbn)[:5]}") + + identyczne = not only_intended and not only_pbn + self.stats.append( + ( + "Porównanie", + "identyczne" if identyczne else "różnice", + ) + ) + self._prompt_enter() + return identyczne + + def _step_delete_statements(self, pbn_client, object_id): + self._header("KROK 7/8 — DELETE oświadczeń w PBN") + url = PBN_DELETE_PUBLICATION_STATEMENT.format(publicationId=object_id) + body = {"all": True, "statementsOfPersons": []} + self._print_http_request( + "DELETE", url, body, label="delete_all_publication_statements" + ) + + if self.dry_run or object_id == "DRY": + self._info("[dry-run] Pomijam DELETE.") + self.stats.append(("DELETE oświadczeń", "dry-run")) + self._prompt_enter() + return + + # Uwaga: zewnętrzny _prompt_yes_no w _run_flow już zapytał czy + # w ogóle robić DELETE — jeśli tu jesteśmy, user się zgodził. + # Nie dodajemy drugiego pytania "Wyślij DELETE?" dla prostoty. + + try: + response = pbn_client.delete_all_publication_statements(object_id) + except HttpException as e: + self._print_http_error(e) + self.stats.append(("DELETE oświadczeń", f"BŁĄD HTTP {e.status_code}")) + raise UserAbort() from e + except ResourceLockedException as e: + self._err(f"ResourceLocked: {e}") + self.stats.append(("DELETE oświadczeń", "Locked")) + raise UserAbort() from e + + self._print_http_response(response) + self.stats.append(("DELETE oświadczeń", "OK")) + self._prompt_enter() + + def _step_post_statements(self, pbn_client, publication): + self._header("KROK 8/8 — POST nowych oświadczeń") + try: + payload = WydawnictwoPBNAdapter(publication).pbn_get_api_statements() + except DaneLokalneWymagajaAktualizacjiException as e: + self._err( + f"Nie mogę przygotować payloadu oświadczeń: {e}. " + "Prawdopodobnie brak lokalnie PublikacjaInstytucji_V2 dla tego PBN UID." + ) + self.stats.append(("POST oświadczeń", f"błąd adaptera: {e}")) + return + + body = {"data": [payload]} + self._print_http_request( + "POST", + PBN_POST_INSTITUTION_STATEMENTS_URL, + body, + label="post_discipline_statements", + ) + + if self.dry_run: + self._info("[dry-run] Pomijam POST oświadczeń.") + self.stats.append(("POST oświadczeń", "dry-run")) + self._prompt_enter() + return + + # Zewnętrzny _prompt_yes_no w _run_flow już zapytał czy w ogóle + # robić POST — jeśli tu jesteśmy, user się zgodził. Pomijamy + # drugie pytanie "Wyślij POST?" dla prostoty. + + max_tries = 3 + attempt = 0 + while True: + attempt += 1 + try: + response = pbn_client.post_discipline_statements(body) + break + except HttpException as e: + self._print_http_error(e) + if e.status_code in (500, 423) and attempt < max_tries: + wait = 2**attempt + self._warn(f"Retry za {wait}s (próba {attempt}/{max_tries})...") + time.sleep(wait) + continue + self.stats.append( + ("POST oświadczeń", f"BŁĄD HTTP {e.status_code} (prób: {attempt})") + ) + raise UserAbort() from e + + self._print_http_response(response) + self.stats.append(("POST oświadczeń", f"OK (prób: {attempt})")) + self._prompt_enter() + + # ------------------------- helpers ------------------------- + + def _extract_object_id(self, response, endpoint_choice): + if endpoint_choice == "publications": + if isinstance(response, dict): + return response.get("objectId") + return None + if isinstance(response, list) and len(response) == 1: + item = response[0] + if isinstance(item, dict): + return item.get("id") or item.get("objectId") + return None + + def _print_http_request(self, method, url, body, label=""): + self._info(f"Wywołanie: {label}" if label else "Żądanie HTTP:") + self.stdout.write(self.style.HTTP_INFO(f" {method} {url}")) + if body is not None: + self.stdout.write(" body:") + self.stdout.write(_json_truncated(body, max_len=800)) + + def _print_http_response(self, response): + self.stdout.write(self.style.SUCCESS(" Odpowiedź:")) + self.stdout.write(_json_truncated(response, max_len=800)) + + def _print_http_error(self, exc: HttpException): + self._err(f"HTTP {exc.status_code} przy {exc.url}") + self.stdout.write(f" content: {(exc.content or '')[:500]}") + + def _header(self, text): + self.stdout.write("") + self.stdout.write(self.style.MIGRATE_HEADING(f"=== {text} ===")) + + def _info(self, text): + self.stdout.write(text) + + def _warn(self, text): + self.stdout.write(self.style.WARNING(text)) + + def _err(self, text): + self.stdout.write(self.style.ERROR(text)) + + def _prompt(self, msg): + # Ogólny prompt (np. wybór 1/2/q) — nigdy nie uwzględnia yes_all. + # yes_all wpływa tylko na proste pytania "[Enter] kontynuuj" oraz + # yes/no z wartością domyślną (patrz: _prompt_enter, _prompt_yes_no). + return input(msg) + + def _prompt_enter(self): + if self.yes_all: + return + ans = input("[Enter] kontynuuj / [q] wyjście: ") + if ans.strip().lower() in ("q", "quit", "exit"): + raise UserAbort() + + def _prompt_yes_no(self, msg, default=True): + if self.yes_all: + return default + hint = "[T/n]" if default else "[t/N]" + ans = input(f"{msg} {hint}: ").strip().lower() + if not ans: + return default + if ans in ("q", "quit", "exit"): + raise UserAbort() + return ans in ("t", "tak", "y", "yes") + + def _print_summary(self): + self._header("PODSUMOWANIE") + if not self.stats: + self._info("Brak wykonanych operacji.") + return + for name, status in self.stats: + self.stdout.write(f" {name:30s} → {status}") diff --git a/src/pbn_api/migrations/0069_sentdata_api_url.py b/src/pbn_api/migrations/0069_sentdata_api_url.py new file mode 100644 index 000000000..0b6ddb75a --- /dev/null +++ b/src/pbn_api/migrations/0069_sentdata_api_url.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.13 on 2026-04-27 16:02 + +from django.db import migrations, models + + +def fill_null_strings(apps, schema_editor): + """Convert NULL → '' on string fields before adding NOT NULL constraint.""" + SentData = apps.get_model("pbn_api", "SentData") + SentData.objects.filter(exception__isnull=True).update(exception="") + SentData.objects.filter(api_response_status__isnull=True).update( + api_response_status="" + ) + SentData.objects.filter(typ_rekordu__isnull=True).update(typ_rekordu="") + + +class Migration(migrations.Migration): + # Migracja musi byc non-atomic: RunPython robi UPDATE na pbn_api_sentdata, + # ktory generuje deferred trigger events (denorm/easyaudit aktywne na + # tabeli). Nastepujacy AlterField (ALTER TABLE) wywala sie wtedy z + # `ObjectInUse: nie mozna ALTER TABLE ... posiada oczekujace zdarzenia + # wyzwalaczy`. Z atomic=False kazda operacja commituje sie osobno — + # triggery odpalaja sie po UPDATE, a ALTER startuje z czystym stanem. + atomic = False + + dependencies = [ + ("pbn_api", "0068_add_cache_models"), + ] + + operations = [ + migrations.AddField( + model_name="sentdata", + name="api_url", + field=models.CharField( + blank=True, + default="", + help_text="Pełny URL (domena + ścieżka), do którego wysłano dane", + max_length=512, + verbose_name="URL endpointu PBN", + ), + ), + migrations.RunPython(fill_null_strings, migrations.RunPython.noop), + migrations.AlterField( + model_name="sentdata", + name="exception", + field=models.TextField( + blank=True, + default="", + max_length=65535, + verbose_name="Kod błędu", + ), + ), + migrations.AlterField( + model_name="sentdata", + name="api_response_status", + field=models.TextField( + blank=True, + default="", + help_text="Odpowiedź z PBN API", + verbose_name="Status odpowiedzi API", + ), + ), + migrations.AlterField( + model_name="sentdata", + name="typ_rekordu", + field=models.CharField(blank=True, default="", max_length=50), + ), + ] diff --git a/src/pbn_api/models/sentdata.py b/src/pbn_api/models/sentdata.py index 3b82543ed..38bd8c37c 100644 --- a/src/pbn_api/models/sentdata.py +++ b/src/pbn_api/models/sentdata.py @@ -42,7 +42,7 @@ def check_if_upload_needed(self, rec, data: dict): pass return True - def create_or_update_before_upload(self, rec, data: dict): + def create_or_update_before_upload(self, rec, data: dict, api_url=""): """Create or update SentData record before API call""" try: sd = self.get_for_rec(rec) @@ -50,9 +50,10 @@ def create_or_update_before_upload(self, rec, data: dict): sd.submitted_successfully = False sd.submitted_at = timezone.now() sd.uploaded_okay = False - sd.api_response_status = None - sd.exception = None + sd.api_response_status = "" + sd.exception = "" sd.data_sent = data # Update data if changed + sd.api_url = api_url sd.save() return sd except SentData.DoesNotExist: @@ -63,29 +64,30 @@ def create_or_update_before_upload(self, rec, data: dict): submitted_successfully=False, submitted_at=timezone.now(), uploaded_okay=False, + api_url=api_url, ) - def mark_as_successful(self, rec, pbn_uid_id=None, api_response_status=None): + def mark_as_successful(self, rec, pbn_uid_id=None, api_response_status=""): """Mark existing record as successful after API call""" sd = self.get_for_rec(rec) sd.submitted_successfully = True sd.uploaded_okay = True sd.pbn_uid_id = pbn_uid_id sd.api_response_status = api_response_status - sd.exception = None + sd.exception = "" sd.save() - def mark_as_failed(self, rec, exception=None, api_response_status=None): + def mark_as_failed(self, rec, exception="", api_response_status=""): """Mark existing record as failed after API call""" sd = self.get_for_rec(rec) sd.submitted_successfully = False sd.uploaded_okay = False - sd.exception = str(exception) if exception else None + sd.exception = str(exception) if exception else "" sd.api_response_status = api_response_status sd.save() def updated( - self, rec, data: dict, pbn_uid_id=None, uploaded_okay=True, exception=None + self, rec, data: dict, pbn_uid_id=None, uploaded_okay=True, exception="" ): """Legacy method - kept for backward compatibility""" try: @@ -138,7 +140,7 @@ class SentData(LinkDoPBNMixin, models.Model): uploaded_okay = models.BooleanField( "Wysłano poprawnie", default=True, db_index=True ) - exception = models.TextField("Kod błędu", max_length=65535, blank=True, null=True) + exception = models.TextField("Kod błędu", max_length=65535, blank=True, default="") # New fields for success tracking submitted_successfully = models.BooleanField( @@ -154,7 +156,17 @@ class SentData(LinkDoPBNMixin, models.Model): help_text="Kiedy dane zostały wysłane do PBN", ) api_response_status = models.TextField( - "Status odpowiedzi API", null=True, blank=True, help_text="Odpowiedź z PBN API" + "Status odpowiedzi API", + blank=True, + default="", + help_text="Odpowiedź z PBN API", + ) + api_url = models.CharField( + "URL endpointu PBN", + max_length=512, + blank=True, + default="", + help_text="Pełny URL (domena + ścieżka), do którego wysłano dane", ) pbn_uid = models.ForeignKey( @@ -165,7 +177,7 @@ class SentData(LinkDoPBNMixin, models.Model): on_delete=models.SET_NULL, ) - typ_rekordu = models.CharField(max_length=50, blank=True, null=True) + typ_rekordu = models.CharField(max_length=50, blank=True, default="") objects = SentDataManager() @@ -181,15 +193,6 @@ def __str__(self): f"z dnia {self.last_updated_on} (status: {'OK' if self.uploaded_okay else 'ERR'})" ) - def link_do_pbn_wartosc_id(self): - return self.pbn_uid_id - - def rekord_w_bpp(self): - try: - return self.object - except ObjectDoesNotExist: - pass - def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): @@ -197,10 +200,19 @@ def save( if self.typ_rekordu != self.data_sent.get("type"): update_fields.append("typ_rekordu") - self.typ_rekordu = self.data_sent.get("type") + self.typ_rekordu = self.data_sent.get("type") or "" return super().save( force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields, ) + + def link_do_pbn_wartosc_id(self): + return self.pbn_uid_id + + def rekord_w_bpp(self): + try: + return self.object + except ObjectDoesNotExist: + pass diff --git a/src/pbn_api/tests/test_bpp_admin_helpers.py b/src/pbn_api/tests/test_bpp_admin_helpers.py index f52b7a4a8..10a28e0ce 100644 --- a/src/pbn_api/tests/test_bpp_admin_helpers.py +++ b/src/pbn_api/tests/test_bpp_admin_helpers.py @@ -10,11 +10,12 @@ from pbn_api.client import ( PBN_GET_INSTITUTION_STATEMENTS, PBN_GET_PUBLICATION_BY_ID_URL, - PBN_POST_PUBLICATIONS_URL, ) from pbn_api.const import ( PBN_GET_INSTITUTION_PUBLICATIONS_V2, + PBN_POST_INSTITUTION_STATEMENTS_URL, PBN_POST_PUBLICATION_NO_STATEMENTS_URL, + PBN_POST_PUBLICATIONS_URL, ) from pbn_api.exceptions import AccessDeniedException from pbn_api.models import Publication, SentData @@ -147,7 +148,9 @@ def test_sprobuj_wyslac_do_pbn_inny_exception( ): req = rf.get("/") - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = ZeroDivisionError + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = ( + ZeroDivisionError + ) with middleware(req): sprobuj_wyslac_do_pbn_gui( @@ -164,7 +167,9 @@ def test_sprobuj_wyslac_do_pbn_inny_blad( ): req = rf.get("/") - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = Exception("test") + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = ( + Exception("test") + ) with middleware(req): sprobuj_wyslac_do_pbn_gui( @@ -181,6 +186,8 @@ def test_sprobuj_wyslac_do_pbn_z_oswiadczeniami( ): req = rf.get("/") + # Praca ma autora z dyscypliną → adapter generuje statements w JSON → + # endpoint /v1/publications (all-in-one). pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = {"objectId": "123"} pbn_client.transport.return_values[ PBN_GET_PUBLICATION_BY_ID_URL.format(id="123") @@ -194,37 +201,58 @@ def test_sprobuj_wyslac_do_pbn_z_oswiadczeniami( pbn_client.transport.return_values[ PBN_GET_INSTITUTION_PUBLICATIONS_V2 + "?publicationId=123&size=10" ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) + # Publikacja ma autorów z dyscyplinami → intencja BPP != puste PBN → + # sync_statements wykona POST /v2/statements (i potrzebuje mocka). + pbn_client.transport.return_values[PBN_POST_INSTITUTION_STATEMENTS_URL] = { + "data": [] + } with middleware(req): sprobuj_wyslac_do_pbn_gui( req, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_client=pbn_client ) - msg = get_messages(req) - assert "zostały zaktualizowane" in list(msg)[0].message + msg = list(get_messages(req)) + # Może być kilka wiadomości (info o sync oświadczeń + success końcowy). + assert any("zostały zaktualizowane" in m.message for m in msg) @pytest.mark.django_db def test_sprobuj_wyslac_do_pbn_bez_oswiadczen_sukces( pbn_wydawnictwo_zwarte_z_charakterem, pbn_client, rf, pbn_uczelnia ): + """Uczelnia z pbn_wysylaj_bez_oswiadczen=True pozwala na wysyłkę prac bez dyscyplin.""" req = rf.get("/") + pbn_uczelnia.pbn_wysylaj_bez_oswiadczen = True + pbn_uczelnia.save() + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = [ {"id": "123"} ] pbn_client.transport.return_values[ PBN_GET_PUBLICATION_BY_ID_URL.format(id="123") ] = MOCK_RETURNED_MONGODB_DATA + pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=456)] = ( + MOCK_RETURNED_MONGODB_DATA + ) + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_PUBLICATIONS_V2 + "?publicationId=123&size=10" + ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=123&size=5120" + ] = pbn_pageable_json([]) with middleware(req): sprobuj_wyslac_do_pbn_gui( req, pbn_wydawnictwo_zwarte_z_charakterem, pbn_client=pbn_client ) - msg = get_messages(req) - assert "nie posiada oświadczeń" in list(msg)[0].message - assert "zostały zaktualizowane" in list(msg)[1].message + msg = list(get_messages(req)) + # Po refaktoryzacji: sync_publication nie rozróżnia "bez oświadczeń" vs + # "z oświadczeniami" (zawsze repo endpoint). Wiadomość o sukcesie + # zawsze pojawia się po udanej wysyłce. + assert any("zaktualizowane" in m.message for m in msg) @pytest.mark.django_db @@ -247,7 +275,9 @@ def test_sprobuj_wyslac_do_pbn_ostrzezenie_brak_dyscypliny_autora( autor.pbn_uid_id = None autor.save() - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = {"objectId": "123"} + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = [ + {"id": "123"} + ] pbn_client.transport.return_values[ PBN_GET_PUBLICATION_BY_ID_URL.format(id="123") ] = MOCK_RETURNED_MONGODB_DATA @@ -286,16 +316,14 @@ def test_sprobuj_wyslac_do_pbn_przychodzi_istniejacy_pbn_uid_dla_nowego_rekordu( pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = None pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save(update_fields=["pbn_uid"]) - # To jest odpowiedź z PBNu gdzie zwrotnie przyjdzie objectId = MOCK_MONGO_ID + # Praca z dyscypliną → /v1/publications (all-in-one). Odpowiedź: + # objectId = MOCK_MONGO_ID (już istnieje lokalnie pod inną pracą). pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { "objectId": MOCK_MONGO_ID } pbn_client.transport.return_values[ PBN_GET_PUBLICATION_BY_ID_URL.format(id=MOCK_MONGO_ID) ] = MOCK_RETURNED_MONGODB_DATA - pbn_client.transport.return_values[ - PBN_GET_PUBLICATION_BY_ID_URL.format(id=MOCK_MONGO_ID) - ] = MOCK_RETURNED_MONGODB_DATA pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=456)] = ( MOCK_RETURNED_MONGODB_DATA ) @@ -338,7 +366,7 @@ def test_sprobuj_wyslac_do_pbn_przychodzi_inny_pbn_uid_dla_starego_rekordu( pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = publikacja pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save(update_fields=["pbn_uid"]) - # To jest odpowiedź z PBNu gdzie zwrotnie przyjdzie objectId = MOCK_MONGO_ID*2 + # Praca z dyscypliną → /v1/publications. Odpowiedź: objectId = MOCK_MONGO_ID*2. pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { "objectId": MOCK_MONGO_ID * 2 } diff --git a/src/pbn_api/tests/test_client_helpers.py b/src/pbn_api/tests/test_client_helpers.py index 0e40a4e81..8550f8e25 100644 --- a/src/pbn_api/tests/test_client_helpers.py +++ b/src/pbn_api/tests/test_client_helpers.py @@ -13,12 +13,16 @@ from bpp.admin.helpers.pbn_api.gui import sprobuj_wyslac_do_pbn_gui from fixtures import MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA from fixtures.pbn_api import MOCK_RETURNED_MONGODB_DATA, pbn_pageable_json +from pbn_api.adapters.wydawnictwo import WydawnictwoPBNAdapter from pbn_api.client import ( PBN_GET_INSTITUTION_STATEMENTS, PBN_GET_PUBLICATION_BY_ID_URL, - PBN_POST_PUBLICATIONS_URL, ) -from pbn_api.const import PBN_GET_INSTITUTION_PUBLICATIONS_V2 +from pbn_api.const import ( + PBN_GET_INSTITUTION_PUBLICATIONS_V2, + PBN_POST_INSTITUTION_STATEMENTS_URL, + PBN_POST_PUBLICATION_NO_STATEMENTS_URL, +) from pbn_api.models import Institution, Publication from pbn_api.tests.utils import middleware @@ -55,6 +59,7 @@ def test_helpers_wysylka_z_uid_uczelni( pbn_uczelnia, admin_user, pbn_client, + monkeypatch, ): odpowiednik = baker.make(Institution, mongoId="PBN_UID_UCZELNI----") @@ -67,9 +72,21 @@ def test_helpers_wysylka_z_uid_uczelni( pbn_uczelnia.pbn_integracja = pbn_uczelnia.pbn_aktualizuj_na_biezaco = True pbn_uczelnia.save() - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { - "objectId": pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk - } + # Ten test bada UID uczelni w body, nie synchronizację statements — + # wymuszamy pustą intencję żeby _sync_statements_with_pbn nie próbował + # POST /v2/statements. Uczelnia musi mieć pbn_wysylaj_bez_oswiadczen + # żeby adapter.pbn_get_json nie rzucił StatementsMissing. + pbn_uczelnia.pbn_wysylaj_bez_oswiadczen = True + pbn_uczelnia.save() + monkeypatch.setattr( + WydawnictwoPBNAdapter, + "pbn_get_json_statements", + lambda self, _lst=None: [], + ) + + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = [ + {"id": pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk} + ] pbn_client.transport.return_values[ PBN_GET_PUBLICATION_BY_ID_URL.format( id=pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk @@ -84,8 +101,12 @@ def test_helpers_wysylka_z_uid_uczelni( ) pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=123&size=5120" + PBN_GET_INSTITUTION_STATEMENTS + + f"?publicationId={pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk}&size=5120" ] = pbn_pageable_json([]) + pbn_client.transport.return_values[PBN_POST_INSTITUTION_STATEMENTS_URL] = { + "data": [] + } req = rf.get("/") req._uczelnia = pbn_uczelnia @@ -95,13 +116,17 @@ def test_helpers_wysylka_z_uid_uczelni( sprobuj_wyslac_do_pbn_gui(req, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) msg = list(get_messages(req)) - assert len(msg) == 1 - # assert str(msg[0]).find("nie posiada oświadczeń") > -1 - assert str(msg[0]).find("y zaktualizowane") > -1 + # Nowy flow może wyemitować dodatkowe info-messages o sync statements + # (np. "Oświadczenia identyczne"); szukamy końcowego success-message. + assert any("y zaktualizowane" in str(m) for m in msg) - iv = pbn_client.transport.input_values["/api/v1/publications"] - assert iv["body"]["authors"][0]["affiliations"][0] == odpowiednik.pk - assert iv["body"]["institutions"][odpowiednik.pk]["objectId"] == odpowiednik.pk + # Po refaktoryzacji: endpoint repo zwraca body jako lista [js]; autorzy + # po ``convert_json_with_statements_to_no_statements`` używają pola + # ``firstName`` zamiast ``givenNames`` (konwersja w adapterze). + iv = pbn_client.transport.input_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] + body = iv["body"][0] + assert body["authors"][0]["affiliations"][0] == odpowiednik.pk + assert body["institutions"][odpowiednik.pk]["objectId"] == odpowiednik.pk @pytest.mark.django_db @@ -113,6 +138,7 @@ def test_helpers_wysylka_bez_uid_uczelni( pbn_jednostka, admin_user, pbn_client, + monkeypatch, ): odpowiednik = baker.make(Institution, mongoId="PBN_UID_UCZELNI----") @@ -125,9 +151,18 @@ def test_helpers_wysylka_bez_uid_uczelni( pbn_uczelnia.pbn_integracja = pbn_uczelnia.pbn_aktualizuj_na_biezaco = True pbn_uczelnia.save() - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { - "objectId": pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk - } + # Ten test bada afiliację jednostki w body, nie sync statements. + pbn_uczelnia.pbn_wysylaj_bez_oswiadczen = True + pbn_uczelnia.save() + monkeypatch.setattr( + WydawnictwoPBNAdapter, + "pbn_get_json_statements", + lambda self, _lst=None: [], + ) + + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = [ + {"id": pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk} + ] pbn_client.transport.return_values[ PBN_GET_PUBLICATION_BY_ID_URL.format( id=pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk @@ -144,8 +179,12 @@ def test_helpers_wysylka_bez_uid_uczelni( ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=123&size=5120" + PBN_GET_INSTITUTION_STATEMENTS + + f"?publicationId={pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk}&size=5120" ] = pbn_pageable_json([]) + pbn_client.transport.return_values[PBN_POST_INSTITUTION_STATEMENTS_URL] = { + "data": [] + } req = rf.get("/") req._uczelnia = pbn_uczelnia @@ -155,11 +194,27 @@ def test_helpers_wysylka_bez_uid_uczelni( sprobuj_wyslac_do_pbn_gui(req, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) msg = list(get_messages(req)) - assert len(msg) == 1 and str(msg[0]).find("y zaktualizowane") > -1 + assert any("y zaktualizowane" in str(m) for m in msg) - iv = pbn_client.transport.input_values["/api/v1/publications"] - assert iv["body"]["authors"][0]["affiliations"][0] == pbn_jednostka.pbn_uid_id + iv = pbn_client.transport.input_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] + body = iv["body"][0] + assert body["authors"][0]["affiliations"][0] == pbn_jednostka.pbn_uid_id assert ( - iv["body"]["institutions"][pbn_jednostka.pbn_uid_id]["objectId"] + body["institutions"][pbn_jednostka.pbn_uid_id]["objectId"] == pbn_jednostka.pbn_uid_id ) + + +def test_convert_json_with_statements_to_no_statements_removes_statements( + pbn_client, +): + js = {"authors": [], "statements": [{"type": "AUTHOR"}]} + out = pbn_client.convert_json_with_statements_to_no_statements(js) + assert "statements" not in out + + +def test_convert_json_with_statements_to_no_statements_no_statements_key( + pbn_client, +): + js = {"authors": []} + pbn_client.convert_json_with_statements_to_no_statements(js) diff --git a/src/pbn_api/tests/test_client_sync.py b/src/pbn_api/tests/test_client_sync.py index 6a42b96b6..e81752c17 100644 --- a/src/pbn_api/tests/test_client_sync.py +++ b/src/pbn_api/tests/test_client_sync.py @@ -1,69 +1,143 @@ """ Tests for PBNClient sync_publication method. +Logika wyboru endpointu w ``upload_publication``: +- praca z lokalnymi statements → ``POST /v1/publications`` (all-in-one, + surowy payload), odpowiedź ``{"objectId": ...}``; +- praca bez lokalnych statements (uczelnia z + ``pbn_wysylaj_bez_oswiadczen=True``) → ``POST /v1/repositorium/publications`` + (po konwersji), odpowiedź ``[{"id": ...}]``. + +``sync_publication`` synchronizuje oświadczenia osobno przez +``_sync_statements_with_pbn`` — GET aktualnego stanu w PBN, diff z +intencją BPP (``pbn_get_json_statements``), selektywne DELETE (lub +batch — sterowane ``Uczelnia.pbn_kasuj_dyscypliny_selektywnie``) + +POST przez ``/api/v2/institution-profile/statements``. Działa +niezależnie od endpointu wysyłki publikacji. + For upload tests, see test_client_upload.py For discipline tests, see test_client_disciplines.py For helper/GUI tests, see test_client_helpers.py """ +import time +from unittest.mock import patch + import pytest -from bpp.decorators import json from fixtures import MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA from fixtures.pbn_api import MOCK_RETURNED_MONGODB_DATA, pbn_pageable_json +from pbn_api.adapters.wydawnictwo import WydawnictwoPBNAdapter from pbn_api.client import ( PBN_DELETE_PUBLICATION_STATEMENT, PBN_GET_INSTITUTION_STATEMENTS, PBN_GET_PUBLICATION_BY_ID_URL, +) +from pbn_api.const import ( + PBN_GET_INSTITUTION_PUBLICATIONS_V2, + PBN_POST_INSTITUTION_STATEMENTS_URL, + PBN_POST_PUBLICATION_NO_STATEMENTS_URL, PBN_POST_PUBLICATIONS_URL, ) -from pbn_api.const import PBN_GET_INSTITUTION_PUBLICATIONS_V2 -from pbn_api.exceptions import HttpException, PKZeroExportDisabled +from pbn_api.exceptions import ( + HttpException, + PKZeroExportDisabled, + StatementsResendFailedException, +) from pbn_api.models import Publication, SentData -@pytest.mark.django_db -def test_sync_publication_to_samo_id( - pbn_client, - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, - pbn_publication, - pbn_autor, - pbn_jednostka, -): - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() +def _patch_intended_statements(monkeypatch, statements): + """Patch adapter żeby zwracał zadaną listę intended statements. - stare_id = pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id + Patchuje OBIE metody adaptera: + - ``pbn_get_json_statements`` — używana w ``_sync_statements_with_pbn`` + (porównanie z PBN); format ``[{personObjectId, disciplineId, type}, ...]`` + - ``pbn_get_api_statements`` — używana w ``_post_statements_with_retry`` + (POST /v2/statements); zwraca ``{publicationUuid, statements}`` + + Ustawia też ``pbn_wysylaj_bez_oswiadczen=True`` na instancji adaptera, + żeby ``StatementsMissing`` nie wywalił ``pbn_get_json`` w KROK 1 + (walidacja w adapterze wymaga klucza ``statements`` w JSON gdy flaga + False, a my tu symulujemy różne stany). + """ + statements_list = list(statements) + monkeypatch.setattr( + WydawnictwoPBNAdapter, + "pbn_get_json_statements", + lambda self, _lst=None: list(statements_list), + ) + monkeypatch.setattr( + WydawnictwoPBNAdapter, + "pbn_get_api_statements", + lambda self: { + "publicationUuid": "00000000-0000-0000-0000-000000000001", + "statements": list(statements_list), + }, + ) + original_init = WydawnictwoPBNAdapter.__init__ + def patched_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + self.pbn_wysylaj_bez_oswiadczen = True + + monkeypatch.setattr(WydawnictwoPBNAdapter, "__init__", patched_init) + + +def _setup_common_mocks(pbn_client, object_id, pbn_statements=None): + """Ustawia standardowe odpowiedzi mockowe dla sync_publication flow. + + Mockuje OBA endpointy POST publikacji (``/v1/publications`` + + ``/v1/repositorium/publications``), download_publication, V2 + institution publications oraz GET statements (pusta lub podana + lista). Dzięki temu testy nie muszą wiedzieć którą drogą poszedł + upload (zależy od tego czy ``_patch_intended_statements`` ustawił + statements puste czy nie). + + Uwaga: ``object_id`` przekazujemy w formacie natywnym (int albo str) + — PBN GET endpointy formatują URL przez ``.format(id=...)``, a POST + body dostaje wartość jak jest (typ zachowany dla porównania z + ``pub.pbn_uid_id`` po sync). + """ + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = [ + {"id": object_id} + ] pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { - "objectId": pbn_publication.pk + "objectId": object_id } pbn_client.transport.return_values[ - PBN_GET_PUBLICATION_BY_ID_URL.format(id=pbn_publication.pk) + PBN_GET_PUBLICATION_BY_ID_URL.format(id=object_id) ] = MOCK_RETURNED_MONGODB_DATA - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_PUBLICATIONS_V2 + "?publicationId=123&size=10" - ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=456)] = ( MOCK_RETURNED_MONGODB_DATA ) - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=123&size=5120" - ] = pbn_pageable_json( - [ - { - "id": "eaec3254-2eb1-44d9-8c3c-e68fc2a48bd9", - "addedTimestamp": "2020.05.06", - "institutionId": pbn_jednostka.pbn_uid_id, - "personId": pbn_autor.pbn_uid_id, - "publicationId": pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id, - "area": "200", - "inOrcid": True, - "type": "FOOBAR", - } - ] - ) + PBN_GET_INSTITUTION_PUBLICATIONS_V2 + f"?publicationId={object_id}&size=10" + ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + f"?publicationId={object_id}&size=5120" + ] = pbn_pageable_json(list(pbn_statements or [])) + + +# ============================================================ +# Podstawowe scenariusze (happy paths) — sync_publication +# ============================================================ + + +@pytest.mark.django_db +def test_sync_publication_to_samo_id( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, +): + """Publikacja ma już pbn_uid, PBN zwraca to samo ID — pbn_uid_id nie zmienia się.""" + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + stare_id = pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id + + _patch_intended_statements(monkeypatch, []) + _setup_common_mocks(pbn_client, pbn_publication.pk, pbn_statements=[]) pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) @@ -74,25 +148,14 @@ def test_sync_publication_to_samo_id( @pytest.mark.django_db def test_sync_publication_tekstowo_podane_id( - pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_publication + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, ): - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { - "objectId": pbn_publication.pk - } - pbn_client.transport.return_values[ - PBN_GET_PUBLICATION_BY_ID_URL.format(id=pbn_publication.pk) - ] = MOCK_RETURNED_MONGODB_DATA - - pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=456)] = ( - MOCK_RETURNED_MONGODB_DATA - ) - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_PUBLICATIONS_V2 + "?publicationId=123&size=10" - ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) - - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=123&size=5120" - ] = pbn_pageable_json([]) + """Argument w formacie 'model:pk' jest konwertowany przez eventually_coerce_to_publication.""" + _patch_intended_statements(monkeypatch, []) + _setup_common_mocks(pbn_client, pbn_publication.pk, pbn_statements=[]) pbn_client.sync_publication( f"wydawnictwo_zwarte:{pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk}" @@ -104,33 +167,25 @@ def test_sync_publication_tekstowo_podane_id( @pytest.mark.django_db def test_sync_publication_nowe_id( - pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_publication + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, ): + """Nowa publikacja bez pbn_uid_id — PBN nadaje ID, ustawiamy lokalnie.""" assert pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id is None - stare_id = pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id - - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { - "objectId": pbn_publication.pk - } - pbn_client.transport.return_values[ - PBN_GET_PUBLICATION_BY_ID_URL.format(id=pbn_publication.pk) - ] = MOCK_RETURNED_MONGODB_DATA - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=123&size=5120" - ] = pbn_pageable_json([]) - pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=456)] = ( - MOCK_RETURNED_MONGODB_DATA - ) - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_PUBLICATIONS_V2 + "?publicationId=123&size=10" - ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) + _patch_intended_statements(monkeypatch, []) + _setup_common_mocks(pbn_client, pbn_publication.pk, pbn_statements=[]) pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) - pbn_publication.refresh_from_db() - assert pbn_publication.versions[0]["baz"] == "quux" - assert stare_id != pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.refresh_from_db() + # Po refresh_from_db pbn_uid_id to string (CharField PK), mock zwraca + # int — porównujemy przez str() dla tolerancji typu. + assert str(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id) == str( + pbn_publication.pk + ) @pytest.mark.django_db @@ -139,39 +194,24 @@ def test_sync_publication_wysylka_z_zerowym_pk( pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_publication, pbn_uczelnia, + monkeypatch, ): + """Flaga ``export_pk_zero`` kontroluje czy prace z PK=0 są wysyłane.""" pbn_uczelnia.pbn_api_nie_wysylaj_prac_bez_pk = True pbn_uczelnia.save() pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.punkty_kbn = 0 pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { - "objectId": pbn_publication.pk - } - pbn_client.transport.return_values[ - PBN_GET_PUBLICATION_BY_ID_URL.format(id=pbn_publication.pk) - ] = MOCK_RETURNED_MONGODB_DATA - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_PUBLICATIONS_V2 + "?publicationId=123&size=10" - ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) - pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=456)] = ( - MOCK_RETURNED_MONGODB_DATA - ) - - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=123&size=5120" - ] = pbn_pageable_json([]) - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_PUBLICATIONS_V2 + "?publicationId=123&size=10" - ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) + _patch_intended_statements(monkeypatch, []) + _setup_common_mocks(pbn_client, pbn_publication.pk, pbn_statements=[]) - # To pójdzie + # export_pk_zero=True — pójdzie pbn_client.sync_publication( pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, export_pk_zero=True ) - # To nie pójdzie + # export_pk_zero=False — rzuci PKZeroExportDisabled with pytest.raises(PKZeroExportDisabled): pbn_client.sync_publication( pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, export_pk_zero=False @@ -179,188 +219,709 @@ def test_sync_publication_wysylka_z_zerowym_pk( @pytest.mark.django_db -def test_sync_publication_kasuj_oswiadczenia_przed_wszystko_dobrze( +def test_upload_and_sync_publication_without_existing_publication( + pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, monkeypatch +): + """Regression: Publication nie istnieje lokalnie przy upload — SentData ma + być poprawnie zaktualizowany linkiem po download_publication.""" + from fixtures.pbn_api import MOCK_MONGO_ID + + new_object_id = MOCK_MONGO_ID + assert not Publication.objects.filter(pk=new_object_id).exists() + + _patch_intended_statements(monkeypatch, []) + _setup_common_mocks(pbn_client, new_object_id, pbn_statements=[]) + + publication = pbn_client.sync_publication( + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina + ) + + assert Publication.objects.filter(pk=new_object_id).exists() + sent_data = SentData.objects.get_for_rec( + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina + ) + assert sent_data.pbn_uid_id == new_object_id + assert sent_data.pbn_uid == publication + assert sent_data.submitted_successfully is True + + +# ============================================================ +# Wybór endpointu na podstawie obecności statements +# ============================================================ + + +@pytest.mark.django_db +def test_sync_publication_bez_statements_idzie_do_repo( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, +): + """Brak lokalnych statements (flaga uczelni allowed) → endpoint repo.""" + _patch_intended_statements(monkeypatch, []) + _setup_common_mocks(pbn_client, pbn_publication.pk, pbn_statements=[]) + + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + assert PBN_POST_PUBLICATION_NO_STATEMENTS_URL in pbn_client.transport.input_values + assert PBN_POST_PUBLICATIONS_URL not in pbn_client.transport.input_values + body = pbn_client.transport.input_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL][ + "body" + ] + assert isinstance(body, list) and len(body) == 1 + assert "statements" not in body[0] + + +@pytest.mark.django_db +def test_sync_publication_z_statements_idzie_do_v1_publications( pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_publication, pbn_autor, pbn_jednostka, + monkeypatch, ): - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + """Lokalne statements obecne → all-in-one ``/v1/publications`` (raw payload).""" + _patch_intended_statements( + monkeypatch, + [ + { + "personObjectId": pbn_autor.pbn_uid_id, + "disciplineId": 301, + "type": "AUTHOR", + } + ], + ) + _setup_common_mocks( + pbn_client, + pbn_publication.pk, + pbn_statements=[ + { + "id": "aaa", + "personId": pbn_autor.pbn_uid_id, + "area": "301", + "type": "AUTHOR", + "institutionId": pbn_jednostka.pbn_uid_id, + } + ], + ) - stare_id = pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { - "objectId": pbn_publication.pk - } - pbn_client.transport.return_values[ - PBN_GET_PUBLICATION_BY_ID_URL.format(id=pbn_publication.pk) - ] = MOCK_RETURNED_MONGODB_DATA - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_PUBLICATIONS_V2 + "?publicationId=123&size=10" - ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) - pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=456)] = ( - MOCK_RETURNED_MONGODB_DATA - ) + assert PBN_POST_PUBLICATIONS_URL in pbn_client.transport.input_values + assert PBN_POST_PUBLICATION_NO_STATEMENTS_URL not in pbn_client.transport.input_values + # Body to surowy dict z adaptera (NIE lista) — z kluczem statements. + body = pbn_client.transport.input_values[PBN_POST_PUBLICATIONS_URL]["body"] + assert isinstance(body, dict) + assert "statements" in body - pbn_client.transport.return_values[ - PBN_DELETE_PUBLICATION_STATEMENT.format(publicationId=pbn_publication.pk) - ] = [] - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=123&size=5120" - ] = pbn_pageable_json( + +# ============================================================ +# Synchronizacja statements: 4 scenariusze (diff + flagi) +# ============================================================ + + +@pytest.mark.django_db +def test_sync_statements_identyczne_nic_nie_wysyla( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_jednostka, + monkeypatch, +): + """PBN i intencja identyczne — brak DELETE, brak POST /v2/statements.""" + _patch_intended_statements( + monkeypatch, [ { - "id": "eaec3254-2eb1-44d9-8c3c-e68fc2a48bd9", - "addedTimestamp": "2020.05.06", - "institutionId": pbn_jednostka.pbn_uid_id, + "personObjectId": pbn_autor.pbn_uid_id, + "disciplineId": 301, + "type": "AUTHOR", + } + ], + ) + _setup_common_mocks( + pbn_client, + pbn_publication.pk, + pbn_statements=[ + { + "id": "aaa", "personId": pbn_autor.pbn_uid_id, - "publicationId": pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id, - "area": "200", - "inOrcid": True, - "type": "FOOBAR", + "area": "301", + "type": "AUTHOR", + "institutionId": pbn_jednostka.pbn_uid_id, } - ] + ], ) - pbn_client.sync_publication( - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, - delete_statements_before_upload=True, + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk ) + assert url_delete not in pbn_client.transport.input_values + assert PBN_POST_INSTITUTION_STATEMENTS_URL not in pbn_client.transport.input_values - pbn_publication.refresh_from_db() - assert pbn_publication.versions[0]["baz"] == "quux" - assert stare_id == pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id + +@pytest.mark.django_db +def test_sync_statements_pbn_puste_bpp_ma_post_only( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + monkeypatch, +): + """PBN puste, BPP ma intencję — POST do /v2/statements, brak DELETE.""" + _patch_intended_statements( + monkeypatch, + [ + { + "personObjectId": pbn_autor.pbn_uid_id, + "disciplineId": 301, + "type": "AUTHOR", + } + ], + ) + _setup_common_mocks(pbn_client, pbn_publication.pk, pbn_statements=[]) + pbn_client.transport.return_values[PBN_POST_INSTITUTION_STATEMENTS_URL] = { + "data": [] + } + + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk + ) + assert url_delete not in pbn_client.transport.input_values + assert PBN_POST_INSTITUTION_STATEMENTS_URL in pbn_client.transport.input_values @pytest.mark.django_db -def test_sync_publication_kasuj_oswiadczenia_przed_blad_400_nie_zaburzy( +def test_sync_statements_pbn_ma_bpp_puste_selektywnie_delete( pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_publication, pbn_autor, pbn_jednostka, + pbn_uczelnia, + monkeypatch, ): - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() - - stare_id = pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id + """Selektywny mode + PBN ma, BPP puste → DELETE per-osoba, brak POST.""" + pbn_uczelnia.pbn_kasuj_dyscypliny_selektywnie = True + pbn_uczelnia.save() - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { - "objectId": pbn_publication.pk - } - pbn_client.transport.return_values[ - PBN_GET_PUBLICATION_BY_ID_URL.format(id=pbn_publication.pk) - ] = MOCK_RETURNED_MONGODB_DATA - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_PUBLICATIONS_V2 - + f"?publicationId={pbn_publication.pk}&size=10" - ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) - pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=456)] = ( - MOCK_RETURNED_MONGODB_DATA + _patch_intended_statements(monkeypatch, []) + _setup_common_mocks( + pbn_client, + pbn_publication.pk, + pbn_statements=[ + { + "id": "aaa", + "personId": pbn_autor.pbn_uid_id, + "area": "301", + "type": "AUTHOR", + "institutionId": pbn_jednostka.pbn_uid_id, + } + ], + ) + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk ) + pbn_client.transport.return_values[url_delete] = [] - url = PBN_DELETE_PUBLICATION_STATEMENT.format(publicationId=pbn_publication.pk) - err_json = { - "code": 400, - "message": "Bad Request", - "description": "Validation failed.", - "details": { - "publicationId": "Nie można usunąć oświadczeń. Nie istnieją oświadczenia " - "dla publikacji (id = {pbn_publication.pk}) i instytucji (id = XXX)." - }, - } + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + # DELETE wykonany (per osoba) z body {statementsOfPersons: [{personId, role}]} + assert url_delete in pbn_client.transport.input_values + body = pbn_client.transport.input_values[url_delete]["body"] + assert "statementsOfPersons" in body + assert body["statementsOfPersons"][0]["personId"] == pbn_autor.pbn_uid_id + assert body["statementsOfPersons"][0]["role"] == "AUTHOR" + # POST nie ma co robić bo BPP puste + assert PBN_POST_INSTITUTION_STATEMENTS_URL not in pbn_client.transport.input_values + + +@pytest.mark.django_db +def test_sync_statements_pbn_ma_bpp_puste_batch_delete( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_jednostka, + pbn_uczelnia, + monkeypatch, +): + """Batch mode + PBN ma, BPP puste → delete_all, brak POST.""" + pbn_uczelnia.pbn_kasuj_dyscypliny_selektywnie = False + pbn_uczelnia.save() - pbn_client.transport.return_values[url] = HttpException( - 400, url, json.dumps(err_json) + _patch_intended_statements(monkeypatch, []) + _setup_common_mocks( + pbn_client, + pbn_publication.pk, + pbn_statements=[ + { + "id": "aaa", + "personId": pbn_autor.pbn_uid_id, + "area": "301", + "type": "AUTHOR", + "institutionId": pbn_jednostka.pbn_uid_id, + } + ], ) + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk + ) + pbn_client.transport.return_values[url_delete] = [] - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=123&size=5120" - ] = pbn_pageable_json( + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + # Batch DELETE ma body {all: True, statementsOfPersons: []} + assert url_delete in pbn_client.transport.input_values + body = pbn_client.transport.input_values[url_delete]["body"] + assert body == {"all": True, "statementsOfPersons": []} + + +@pytest.mark.django_db +def test_sync_statements_roznice_selektywnie( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_jednostka, + pbn_uczelnia, + monkeypatch, +): + """Różnice w selektywnym trybie: DELETE tylko nieistniejących lokalnie + + POST brakujących.""" + pbn_uczelnia.pbn_kasuj_dyscypliny_selektywnie = True + pbn_uczelnia.save() + + # Intencja: autor X z dyscypliną 100 + _patch_intended_statements( + monkeypatch, [ { - "id": "eaec3254-2eb1-44d9-8c3c-e68fc2a48bd9", - "addedTimestamp": "2020.05.06", + "personObjectId": pbn_autor.pbn_uid_id, + "disciplineId": 100, + "type": "AUTHOR", + } + ], + ) + # PBN: ten sam autor X ale z dyscypliną 301 + _setup_common_mocks( + pbn_client, + pbn_publication.pk, + pbn_statements=[ + { + "id": "aaa", + "personId": pbn_autor.pbn_uid_id, + "area": "301", + "type": "AUTHOR", "institutionId": pbn_jednostka.pbn_uid_id, + } + ], + ) + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk + ) + pbn_client.transport.return_values[url_delete] = [] + pbn_client.transport.return_values[PBN_POST_INSTITUTION_STATEMENTS_URL] = { + "data": [] + } + + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + # Klucz (autor, 301) różni się od (autor, 100) → DELETE + POST + assert url_delete in pbn_client.transport.input_values + assert PBN_POST_INSTITUTION_STATEMENTS_URL in pbn_client.transport.input_values + + # W selektywnym trybie POST wysyła TYLKO oświadczenia brakujące w PBN + # (only_in_intended) — nie pełen zestaw BPP. Sprawdzamy że payload + # zawiera dokładnie jeden statement (autor, dyscyplina 100). + post_body = pbn_client.transport.input_values[PBN_POST_INSTITUTION_STATEMENTS_URL][ + "body" + ] + statements_sent = post_body["data"][0]["statements"] + assert len(statements_sent) == 1 + assert statements_sent[0]["personObjectId"] == pbn_autor.pbn_uid_id + + +@pytest.mark.django_db +def test_sync_statements_pbn_subset_bpp_superset_tylko_brakujace( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_jednostka, + pbn_uczelnia, + monkeypatch, +): + """PBN = {(A, 301)}, BPP = {(A, 301), (B, 200)} — selektywny tryb. + + ``only_in_pbn`` = ∅ → brak DELETE. + ``only_in_intended`` = {(B, 200)} → POST tylko (B, 200). + + Weryfikacja że POST nie dubluje (A, 301) który już jest w PBN — + wysyłamy TYLKO brakujący (B, 200) zgodnie z algorytmem kroku 4b. + """ + pbn_uczelnia.pbn_kasuj_dyscypliny_selektywnie = True + pbn_uczelnia.save() + + autor_b_pbn_uid = "autor-B-mongo-id-xxxxxxxxxxxx" + + # Intencja BPP: autor A z dyscypliną 301 + autor B z dyscypliną 200 + _patch_intended_statements( + monkeypatch, + [ + { + "personObjectId": pbn_autor.pbn_uid_id, + "disciplineId": 301, + "disciplineUuid": "uuid-301", + "type": "AUTHOR", + }, + { + "personObjectId": autor_b_pbn_uid, + "disciplineId": 200, + "disciplineUuid": "uuid-200", + "type": "AUTHOR", + }, + ], + ) + # PBN ma tylko autora A z dyscypliną 301 (BPP jest supersetem) + _setup_common_mocks( + pbn_client, + pbn_publication.pk, + pbn_statements=[ + { + "id": "aaa", "personId": pbn_autor.pbn_uid_id, - "publicationId": pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id, - "area": "200", - "inOrcid": True, - "type": "FOOBAR", + "area": "301", + "type": "AUTHOR", + "institutionId": pbn_jednostka.pbn_uid_id, } - ] + ], ) + pbn_client.transport.return_values[PBN_POST_INSTITUTION_STATEMENTS_URL] = { + "data": [] + } - pbn_client.sync_publication( - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, - delete_statements_before_upload=True, + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + # DELETE nie wywołany — (A, 301) jest w obu, only_in_pbn puste + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk ) + assert url_delete not in pbn_client.transport.input_values - pbn_publication.refresh_from_db() - assert pbn_publication.versions[0]["baz"] == "quux" - assert stare_id == pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id + # POST wysłany tylko dla (B, 200) — nie dublujemy (A, 301) + assert PBN_POST_INSTITUTION_STATEMENTS_URL in pbn_client.transport.input_values + post_body = pbn_client.transport.input_values[PBN_POST_INSTITUTION_STATEMENTS_URL][ + "body" + ] + statements_sent = post_body["data"][0]["statements"] + assert len(statements_sent) == 1 + assert statements_sent[0]["personObjectId"] == autor_b_pbn_uid @pytest.mark.django_db -def test_upload_and_sync_publication_without_existing_publication( - pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina +def test_sync_statements_pbn_puste_wysyla_wszystkie_w_selektywnym( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_uczelnia, + monkeypatch, ): - """ - Regression test for foreign key violation issue. + """Krok 3 algorytmu: PBN puste + BPP ma N oświadczeń → POST zawiera N. - Tests that upload_publication() doesn't fail when the Publication record - doesn't exist yet in the local database, and that sync_publication() - properly updates SentData with the publication link after downloading it. + W selektywnym trybie filter_keys = only_in_intended, które w tym + scenariuszu = wszystkie klucze BPP (bo PBN jest pusty), więc POST + wysyła kompletny zestaw BPP — równoważne z "wyślij wszystkie". """ - # Use the same objectId as in MOCK_RETURNED_MONGODB_DATA - from fixtures.pbn_api import MOCK_MONGO_ID + pbn_uczelnia.pbn_kasuj_dyscypliny_selektywnie = True + pbn_uczelnia.save() - new_object_id = MOCK_MONGO_ID + autor_b_pbn_uid = "autor-B-mongo-id-yyyyyyyyyyyy" + _patch_intended_statements( + monkeypatch, + [ + { + "personObjectId": pbn_autor.pbn_uid_id, + "disciplineId": 301, + "disciplineUuid": "uuid-301", + "type": "AUTHOR", + }, + { + "personObjectId": autor_b_pbn_uid, + "disciplineId": 200, + "disciplineUuid": "uuid-200", + "type": "AUTHOR", + }, + ], + ) + _setup_common_mocks(pbn_client, pbn_publication.pk, pbn_statements=[]) + pbn_client.transport.return_values[PBN_POST_INSTITUTION_STATEMENTS_URL] = { + "data": [] + } - # Ensure Publication doesn't exist - assert not Publication.objects.filter(pk=new_object_id).exists() + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) - # Mock API response for upload (called internally by sync_publication) - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { - "objectId": new_object_id + assert PBN_POST_INSTITUTION_STATEMENTS_URL in pbn_client.transport.input_values + post_body = pbn_client.transport.input_values[PBN_POST_INSTITUTION_STATEMENTS_URL][ + "body" + ] + statements_sent = post_body["data"][0]["statements"] + assert len(statements_sent) == 2 + person_ids = {s["personObjectId"] for s in statements_sent} + assert person_ids == {pbn_autor.pbn_uid_id, autor_b_pbn_uid} + + +@pytest.mark.django_db +def test_sync_statements_batch_mode_post_wszystkie( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_jednostka, + pbn_uczelnia, + monkeypatch, +): + """Batch mode + różnice: delete_all kasuje całość, POST wysyła wszystkie BPP. + + ``kasuj_selektywnie=False`` — po ``delete_all`` PBN jest puste, więc + mimo że diff dał ``only_in_intended = {nowy}`` i ``only_in_pbn = {stary}``, + POST musi wysłać PEŁNY zestaw BPP (nie tylko only_in_intended), + inaczej po delete_all stare oświadczenia znikną bez odtworzenia. + """ + pbn_uczelnia.pbn_kasuj_dyscypliny_selektywnie = False + pbn_uczelnia.save() + + _patch_intended_statements( + monkeypatch, + [ + { + "personObjectId": pbn_autor.pbn_uid_id, + "disciplineId": 100, + "disciplineUuid": "uuid-100", + "type": "AUTHOR", + } + ], + ) + _setup_common_mocks( + pbn_client, + pbn_publication.pk, + pbn_statements=[ + { + "id": "aaa", + "personId": pbn_autor.pbn_uid_id, + "area": "301", + "type": "AUTHOR", + "institutionId": pbn_jednostka.pbn_uid_id, + } + ], + ) + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk + ) + pbn_client.transport.return_values[url_delete] = [] + pbn_client.transport.return_values[PBN_POST_INSTITUTION_STATEMENTS_URL] = { + "data": [] } - # Mock API response for download_publication + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + # Batch DELETE wykonany — kasuje wszystko + body_del = pbn_client.transport.input_values[url_delete]["body"] + assert body_del == {"all": True, "statementsOfPersons": []} + + # POST wysłał pełen zestaw BPP (tutaj 1 statement — autor z dyscypliną 100), + # bez filtra ``only_in_intended``. Sprawdzamy że personObjectId i + # disciplineId pasują do intencji BPP (nie do tego co było w PBN). + post_body = pbn_client.transport.input_values[PBN_POST_INSTITUTION_STATEMENTS_URL][ + "body" + ] + statements_sent = post_body["data"][0]["statements"] + assert len(statements_sent) == 1 + assert statements_sent[0]["personObjectId"] == pbn_autor.pbn_uid_id + + +# ============================================================ +# Error handling: retry + rollbar + StatementsResendFailedException +# ============================================================ + + +@pytest.mark.django_db +def test_sync_publication_get_statements_retry_wyczerpane_raises( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, +): + """GET statements zawodzi 3 razy → StatementsResendFailedException + rollbar.""" + _patch_intended_statements(monkeypatch, []) + _setup_common_mocks(pbn_client, pbn_publication.pk, pbn_statements=[]) + # Nadpisujemy GET statements żeby rzucał pbn_client.transport.return_values[ - PBN_GET_PUBLICATION_BY_ID_URL.format(id=new_object_id) - ] = MOCK_RETURNED_MONGODB_DATA + PBN_GET_INSTITUTION_STATEMENTS + + f"?publicationId={pbn_publication.pk}&size=5120" + ] = HttpException(500, "/api/v1/.../page/statements", "Server Error") - # Mock for objectId 456 from MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA - pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=456)] = ( - MOCK_RETURNED_MONGODB_DATA + # Mock sleep i rollbar żeby test nie był powolny i weryfikujemy call + monkeypatch.setattr(time, "sleep", lambda *_: None) + + with patch("pbn_api.client.publication_sync.rollbar.report_exc_info") as mock_rb: + with pytest.raises(StatementsResendFailedException) as exc_info: + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + # Rollbar wywołany raz z level=warning + assert mock_rb.called + call_kwargs = mock_rb.call_args.kwargs + assert call_kwargs.get("level") == "warning" + assert "publication_pk" in call_kwargs.get("extra_data", {}) + assert "pbn_uid" in call_kwargs.get("extra_data", {}) + + # Exception ma publication_pk i pbn_uid + assert exc_info.value.pbn_uid == pbn_publication.pk + + +@pytest.mark.django_db +def test_sync_publication_selektywny_delete_retry_wyczerpane_raises( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_jednostka, + pbn_uczelnia, + monkeypatch, +): + """Selektywny DELETE zawodzi 3 razy → StatementsResendFailedException.""" + pbn_uczelnia.pbn_kasuj_dyscypliny_selektywnie = True + pbn_uczelnia.save() + + _patch_intended_statements(monkeypatch, []) + _setup_common_mocks( + pbn_client, + pbn_publication.pk, + pbn_statements=[ + { + "id": "aaa", + "personId": pbn_autor.pbn_uid_id, + "area": "301", + "type": "AUTHOR", + "institutionId": pbn_jednostka.pbn_uid_id, + } + ], + ) + # DELETE zawsze zawodzi + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk + ) + pbn_client.transport.return_values[url_delete] = HttpException( + 500, url_delete, "Server Error" ) - # Mock empty statements response - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_STATEMENTS + f"?publicationId={new_object_id}&size=5120" - ] = pbn_pageable_json([]) + monkeypatch.setattr(time, "sleep", lambda *_: None) - # Mock institution publications v2 response - pbn_client.transport.return_values[ - PBN_GET_INSTITUTION_PUBLICATIONS_V2 + f"?publicationId={new_object_id}&size=10" - ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) + with patch("pbn_api.client.publication_sync.rollbar.report_exc_info"): + with pytest.raises(StatementsResendFailedException): + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) - # Call sync_publication() - this internally calls upload_publication() - # which should succeed without FK error, then download_publication() - # which should create the Publication and update SentData - publication = pbn_client.sync_publication( - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina + +@pytest.mark.django_db +def test_sync_publication_post_v2_statements_retry_wyczerpane_raises( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + monkeypatch, +): + """POST /v2/statements zawodzi 3 razy → StatementsResendFailedException.""" + _patch_intended_statements( + monkeypatch, + [ + { + "personObjectId": pbn_autor.pbn_uid_id, + "disciplineId": 100, + "type": "AUTHOR", + } + ], + ) + _setup_common_mocks(pbn_client, pbn_publication.pk, pbn_statements=[]) + pbn_client.transport.return_values[PBN_POST_INSTITUTION_STATEMENTS_URL] = ( + HttpException(500, PBN_POST_INSTITUTION_STATEMENTS_URL, "Server Error") ) - # Verify Publication now exists in database - assert Publication.objects.filter(pk=new_object_id).exists() + monkeypatch.setattr(time, "sleep", lambda *_: None) - # Verify SentData was created and updated with the publication link - sent_data = SentData.objects.get_for_rec( - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina + with patch("pbn_api.client.publication_sync.rollbar.report_exc_info"): + with pytest.raises(StatementsResendFailedException): + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + +# ============================================================ +# Edge case: POST publikacji zawodzi — statements nietknięte +# ============================================================ + + +@pytest.mark.django_db +def test_sync_publication_post_repo_fail_statements_nietkniete( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, +): + """POST publikacji zawodzi → statements w PBN nie są ruszane.""" + _patch_intended_statements(monkeypatch, []) + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = ( + HttpException( + 500, PBN_POST_PUBLICATION_NO_STATEMENTS_URL, "Internal Server Error" + ) ) - assert sent_data.pbn_uid_id == new_object_id - assert sent_data.pbn_uid == publication - assert sent_data.submitted_successfully is True + + with pytest.raises(HttpException): + pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + # Nic nie powinno polecieć do PBN poza failed POST + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk + ) + assert url_delete not in pbn_client.transport.input_values + assert PBN_POST_INSTITUTION_STATEMENTS_URL not in pbn_client.transport.input_values + assert ( + PBN_GET_INSTITUTION_STATEMENTS + + f"?publicationId={pbn_publication.pk}&size=5120" + not in pbn_client.transport.input_values + ) + + +# ============================================================ +# Unit test: _diff_statements key mapping +# ============================================================ + + +def test_diff_statements_key_mapping(pbn_client): + """Klucz porównania PBN vs intended używa (person, discipline) jako string.""" + pbn_stmts = [ + {"personId": "abc123", "area": "301", "type": "AUTHOR"}, + {"personId": "def456", "area": "502", "type": "EDITOR"}, + ] + intended = [ + {"personObjectId": "abc123", "disciplineId": 301, "type": "AUTHOR"}, + {"personObjectId": "ghi789", "disciplineId": 200, "type": "AUTHOR"}, + ] + only_pbn, only_intended = pbn_client._diff_statements(pbn_stmts, intended) + + # (abc123, 301) — w obu, więc w żadnym diff + # (def456, 502) — tylko w PBN + # (ghi789, 200) — tylko w intended + assert only_pbn == {("def456", "502")} + assert only_intended == {("ghi789", "200")} + + +def test_diff_statements_empty_sets(pbn_client): + """Puste zestawy → puste diff.""" + only_pbn, only_intended = pbn_client._diff_statements([], []) + assert only_pbn == set() + assert only_intended == set() diff --git a/src/pbn_api/tests/test_client_upload.py b/src/pbn_api/tests/test_client_upload.py index bcc6ddf97..636b63540 100644 --- a/src/pbn_api/tests/test_client_upload.py +++ b/src/pbn_api/tests/test_client_upload.py @@ -10,9 +10,15 @@ from model_bakery import baker from pbn_api.adapters.wydawnictwo import WydawnictwoPBNAdapter -from pbn_api.client import PBN_POST_PUBLICATIONS_URL -from pbn_api.const import PBN_POST_PUBLICATION_NO_STATEMENTS_URL -from pbn_api.exceptions import SameDataUploadedRecently +from pbn_api.client import ( + PBN_DELETE_PUBLICATION_STATEMENT, + PBN_GET_INSTITUTION_STATEMENTS, +) +from pbn_api.const import ( + PBN_POST_PUBLICATION_NO_STATEMENTS_URL, + PBN_POST_PUBLICATIONS_URL, +) +from pbn_api.exceptions import SameDataUploadedRecently, StatementsMissing from pbn_api.models import Publication, SentData @@ -24,19 +30,21 @@ class PBNTestClientException(Exception): def test_PBNClient_test_upload_publication_nie_trzeba( pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina ): - pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = {"objectId": None} + # Praca ma autora z dyscypliną → adapter generuje statements w JSON → + # ścieżka /v1/publications (all-in-one, bez konwersji pól). + pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { + "objectId": "test-123" + } - # Create SentData with submitted_successfully=True to trigger SameDataUploadedRecently - sent_data = SentData.objects.create_or_update_before_upload( # noqa - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, - WydawnictwoPBNAdapter( - pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina - ).pbn_get_json(), + js = WydawnictwoPBNAdapter( + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina + ).pbn_get_json() + SentData.objects.create_or_update_before_upload( + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, js ) baker.make(Publication, pk="test-123") - # Mark as successful to simulate previous successful upload SentData.objects.mark_as_successful( pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_uid_id="test-123" ) @@ -49,6 +57,7 @@ def test_PBNClient_test_upload_publication_nie_trzeba( def test_PBNClient_test_upload_publication_exception( pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina ): + # Praca z dyscypliną idzie do /v1/publications. pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = ( PBNTestClientException("nei") ) @@ -58,9 +67,10 @@ def test_PBNClient_test_upload_publication_exception( @pytest.mark.django_db -def test_PBNClient_test_upload_publication_wszystko_ok( +def test_PBNClient_test_upload_publication_z_statements_idzie_do_v1_publications( pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_publication ): + """Praca z lokalnymi statements → POST /v1/publications (all-in-one).""" pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { "objectId": pbn_publication.pk } @@ -69,14 +79,91 @@ def test_PBNClient_test_upload_publication_wszystko_ok( pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina ) assert objectId == pbn_publication.pk + assert bez_oswiadczen is False + + # Body wysłane do /v1/publications zawiera klucz "statements" + # (surowy payload z adaptera, bez konwersji pól). + sent_body = pbn_client.transport.input_values[PBN_POST_PUBLICATIONS_URL]["body"] + assert isinstance(sent_body, dict) + assert "statements" in sent_body + # Pole "givenNames" zostaje (konwersja /v1/repositorium nie była wywołana). + assert "givenNames" in sent_body["authors"][0] + + sent_data = SentData.objects.get_for_rec( + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina + ) + assert sent_data.api_url is not None + assert sent_data.api_url.endswith(PBN_POST_PUBLICATIONS_URL) @pytest.mark.django_db -def test_PBNClient_post_publication_no_statements( +def test_PBNClient_test_upload_publication_bez_statements_idzie_do_repo( + pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_publication, uczelnia +): + """Praca bez lokalnych statements + flaga uczelni → POST /v1/repositorium/publications.""" + uczelnia.pbn_wysylaj_bez_oswiadczen = True + uczelnia.save() + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.autorzy_set.all().update( + dyscyplina_naukowa=None + ) + + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = [ + {"id": pbn_publication.pk} + ] + + objectId, ret, js, bez_oswiadczen = pbn_client.upload_publication( + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina + ) + assert objectId == pbn_publication.pk + assert bez_oswiadczen is True + + # Body wysłane do /v1/repositorium/publications: lista, BEZ statements, + # po konwersji pól (givenNames → firstName). + sent_body = pbn_client.transport.input_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL][ + "body" + ] + assert isinstance(sent_body, list) and len(sent_body) == 1 + assert "statements" not in sent_body[0] + assert "firstName" in sent_body[0]["authors"][0] + assert "givenNames" not in sent_body[0]["authors"][0] + + sent_data = SentData.objects.get_for_rec( + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina + ) + assert sent_data.api_url is not None + assert sent_data.api_url.endswith(PBN_POST_PUBLICATION_NO_STATEMENTS_URL) + + +@pytest.mark.django_db +def test_PBNClient_test_upload_publication_bez_dyscypliny_bez_flagi_blad( pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, uczelnia ): + """Brak dyscyplin + flaga uczelni False → StatementsMissing z adaptera.""" + uczelnia.pbn_wysylaj_bez_oswiadczen = False + uczelnia.save() + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.autorzy_set.all().update( + dyscyplina_naukowa=None + ) + + with pytest.raises(StatementsMissing): + pbn_client.upload_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + +@pytest.mark.django_db +def test_PBNClient_post_publication_no_statements( + pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, uczelnia, monkeypatch +): + """Smoke test że sync_publication używa endpoint repo dla pracy bez dyscyplin. + + Uczelnia z ``pbn_wysylaj_bez_oswiadczen=True`` pozwala na wysyłkę prac + bez oświadczeń (inaczej adapter rzuca StatementsMissing w pbn_get_json). + """ from fixtures.pbn_api import MOCK_RETURNED_MONGODB_DATA from pbn_api.client import PBN_GET_PUBLICATION_BY_ID_URL + from pbn_api.const import ( + PBN_GET_INSTITUTION_PUBLICATIONS_V2, + PBN_GET_INSTITUTION_STATEMENTS, + ) uczelnia.pbn_wysylaj_bez_oswiadczen = True uczelnia.save() @@ -87,9 +174,169 @@ def test_PBNClient_post_publication_no_statements( pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=123)] = ( MOCK_RETURNED_MONGODB_DATA ) + pbn_client.transport.return_values[PBN_GET_PUBLICATION_BY_ID_URL.format(id=456)] = ( + MOCK_RETURNED_MONGODB_DATA + ) + from fixtures import MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA + from fixtures.pbn_api import pbn_pageable_json + + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_PUBLICATIONS_V2 + "?publicationId=123&size=10" + ] = pbn_pageable_json(MOCK_RETURNED_INSTITUTION_PUBLICATION_V2_DATA) + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=123&size=5120" + ] = pbn_pageable_json([]) pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.autorzy_set.all().update( dyscyplina_naukowa=None ) ret = pbn_client.sync_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) assert ret + + +# ============================================================ +# Pre-upload clear: kasowanie PBN statements PRZED POST do +# /v1/repositorium/publications gdy BPP intent jest pusty +# ============================================================ + + +@pytest.mark.django_db +def test_pre_upload_clear_kasuje_pbn_statements_przed_post_repo( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_jednostka, + uczelnia, +): + """Praca z pbn_uid_id + PBN ma statements + BPP nie ma → DELETE PRZED POST.""" + from fixtures.pbn_api import pbn_pageable_json + + uczelnia.pbn_wysylaj_bez_oswiadczen = True + uczelnia.pbn_kasuj_dyscypliny_selektywnie = True + uczelnia.save() + + # BPP: praca bez dyscyplin lokalnych + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.autorzy_set.all().update( + dyscyplina_naukowa=None + ) + + # PBN: ma 1 oświadczenie do skasowania + pbn_uid = str(pbn_publication.pk) + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + f"?publicationId={pbn_uid}&size=5120" + ] = pbn_pageable_json( + [ + { + "id": "aaa", + "personId": pbn_autor.pbn_uid_id, + "area": "301", + "type": "AUTHOR", + "institutionId": pbn_jednostka.pbn_uid_id, + } + ] + ) + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format(publicationId=pbn_uid) + pbn_client.transport.return_values[url_delete] = [] + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = [ + {"id": pbn_publication.pk} + ] + + pbn_client.upload_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + # DELETE wykonany (selektywny, per autor) PRZED POST publikacji + assert url_delete in pbn_client.transport.input_values + body = pbn_client.transport.input_values[url_delete]["body"] + assert body["statementsOfPersons"][0]["personId"] == pbn_autor.pbn_uid_id + # POST do repo wykonany + assert PBN_POST_PUBLICATION_NO_STATEMENTS_URL in pbn_client.transport.input_values + + +@pytest.mark.django_db +def test_pre_upload_clear_pomija_gdy_pbn_puste( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + uczelnia, +): + """PBN puste → tylko GET, brak DELETE.""" + from fixtures.pbn_api import pbn_pageable_json + + uczelnia.pbn_wysylaj_bez_oswiadczen = True + uczelnia.save() + + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.autorzy_set.all().update( + dyscyplina_naukowa=None + ) + + pbn_uid = str(pbn_publication.pk) + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + f"?publicationId={pbn_uid}&size=5120" + ] = pbn_pageable_json([]) + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = [ + {"id": pbn_publication.pk} + ] + + pbn_client.upload_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format(publicationId=pbn_uid) + assert url_delete not in pbn_client.transport.input_values + assert PBN_POST_PUBLICATION_NO_STATEMENTS_URL in pbn_client.transport.input_values + + +@pytest.mark.django_db +def test_pre_upload_clear_pomija_bez_pbn_uid( + pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, uczelnia +): + """Bez pbn_uid_id → ani GET ani DELETE (PBN nie ma odpowiednika pracy).""" + uczelnia.pbn_wysylaj_bez_oswiadczen = True + uczelnia.save() + + # Praca bez pbn_uid + bez dyscyplin + assert pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid_id is None + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.autorzy_set.all().update( + dyscyplina_naukowa=None + ) + + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = [ + {"id": "new-uid-123"} + ] + + pbn_client.upload_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + # Brak GET statements (URL nie został wywołany) + assert all( + PBN_GET_INSTITUTION_STATEMENTS not in url + for url in pbn_client.transport.input_values + ) + + +@pytest.mark.django_db +def test_pre_upload_clear_pomija_dla_v1_publications_z_statements( + pbn_client, pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, pbn_publication +): + """Ścieżka /v1/publications (z statements w body) → brak pre-clear. + + Statements idą razem z payloadem, więc nie kasujemy ich upfront. + Drift wykrywa post-upload sync. + """ + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + + pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { + "objectId": pbn_publication.pk + } + + pbn_client.upload_publication(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina) + + # Brak GET (pre-clear nie odpalił się dla all-in-one path) + assert all( + PBN_GET_INSTITUTION_STATEMENTS not in url + for url in pbn_client.transport.input_values + ) + # POST do /v1/publications wykonany + assert PBN_POST_PUBLICATIONS_URL in pbn_client.transport.input_values diff --git a/src/pbn_api/tests/test_pbn_test_wysylka_interaktywna.py b/src/pbn_api/tests/test_pbn_test_wysylka_interaktywna.py new file mode 100644 index 000000000..017800c41 --- /dev/null +++ b/src/pbn_api/tests/test_pbn_test_wysylka_interaktywna.py @@ -0,0 +1,629 @@ +"""Testy dla interaktywnego narzędzia CLI ``pbn_test_wysylka_interaktywna``. + +Narzędzie jest interaktywne i używa wejścia przez ``input()``. +W testach mockujemy ``builtins.input`` przez ``monkeypatch``, a transport +HTTP przez ``MockTransport`` dostarczany przez fixturę ``pbn_client``. +""" + +from io import StringIO + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError + +from fixtures.pbn_api import pbn_pageable_json +from pbn_api.const import ( + PBN_DELETE_PUBLICATION_STATEMENT, + PBN_GET_INSTITUTION_STATEMENTS, + PBN_POST_INSTITUTION_STATEMENTS_URL, + PBN_POST_PUBLICATION_NO_STATEMENTS_URL, + PBN_POST_PUBLICATIONS_URL, +) +from pbn_api.exceptions import HttpException +from pbn_api.management.commands import pbn_test_wysylka_interaktywna as cmd_mod + + +def _patch_get_client(monkeypatch, pbn_client): + """Zamienia Command.get_client() żeby zwracał mockowanego klienta.""" + monkeypatch.setattr( + cmd_mod.Command, + "get_client", + lambda self, *args, **kwargs: pbn_client, + ) + + +def _patch_input(monkeypatch, answers): + """Mockuje builtins.input — zwraca kolejno podane odpowiedzi. + + Po wyczerpaniu listy zwraca pusty string (Enter = kontynuuj). + """ + it = iter(answers) + + def _input(prompt=""): + try: + return next(it) + except StopIteration: + return "" + + monkeypatch.setattr("builtins.input", _input) + + +def _patch_intended_statements(monkeypatch, statements): + """Patchuje adapter żeby zwracał zadaną listę intended statements. + + Patch dotyczy: + - ``pbn_get_json_statements`` — używana w KROK 6/8 (porównanie). + Zwraca surową listę dict-ów (każdy może mieć personObjectId, + disciplineId, disciplineUuid, type itd.). + - ``pbn_get_api_statements`` — używana w KROK 8/8 (POST /v2/statements). + Zwraca ``{"publicationUuid": ..., "statements": [...]}``. + - ``__init__`` — dodatkowo ustawia ``pbn_wysylaj_bez_oswiadczen=True`` + na instancji. Potrzebne gdy ``statements=[]``: inaczej ``pbn_get_json`` + w KROK 2/8 wywoła ``StatementsMissing`` (bo artykuł/rozdział bez + statements jest walidowany jako błąd). W testach chcemy móc + symulować każdy stan — flaga wyłącza walidację. + + Fixture testowy ``pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina`` nie + tworzy ``PublikacjaInstytucji_V2``, bez czego ``pbn_get_api_statements`` + rzuciłoby ``DaneLokalneWymagajaAktualizacjiException``. + """ + from pbn_api.adapters.wydawnictwo import WydawnictwoPBNAdapter + + statements_list = list(statements) + monkeypatch.setattr( + WydawnictwoPBNAdapter, + "pbn_get_json_statements", + lambda self, _lst=None: list(statements_list), + ) + monkeypatch.setattr( + WydawnictwoPBNAdapter, + "pbn_get_api_statements", + lambda self: { + "publicationUuid": "00000000-0000-0000-0000-000000000001", + "statements": list(statements_list), + }, + ) + original_init = WydawnictwoPBNAdapter.__init__ + + def patched_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + self.pbn_wysylaj_bez_oswiadczen = True + + monkeypatch.setattr(WydawnictwoPBNAdapter, "__init__", patched_init) + + +@pytest.mark.django_db +def test_wymaga_jednego_argumentu_publikacji(pbn_client, monkeypatch): + _patch_get_client(monkeypatch, pbn_client) + _patch_input(monkeypatch, []) + + with pytest.raises(CommandError, match="dokładnie jedno"): + call_command( + "pbn_test_wysylka_interaktywna", + stdout=StringIO(), + ) + + +@pytest.mark.django_db +def test_oba_argumenty_publikacji_to_blad(pbn_client, monkeypatch): + _patch_get_client(monkeypatch, pbn_client) + _patch_input(monkeypatch, []) + + with pytest.raises(CommandError, match="dokładnie jedno"): + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + "1", + "--wydawnictwo-ciagle", + "2", + stdout=StringIO(), + ) + + +@pytest.mark.django_db +def test_nieistniejaca_publikacja_zwarte(pbn_client, monkeypatch): + _patch_get_client(monkeypatch, pbn_client) + _patch_input(monkeypatch, []) + out = StringIO() + + with pytest.raises(CommandError, match="Nie znaleziono"): + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + "999999", + stdout=out, + ) + + +@pytest.mark.django_db +def test_dry_run_nie_wysyla_niczego_do_pbn( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, +): + """W trybie --dry-run żadne żądanie HTTP nie może wyjść przez transport.""" + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + + _patch_get_client(monkeypatch, pbn_client) + _patch_intended_statements(monkeypatch, []) + # kolejność promptów: preview JSON? n, wybór endpointa=1, [Enter]-y + _patch_input(monkeypatch, ["n", "1"]) + + out = StringIO() + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + str(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk), + "--dry-run", + "--yes-all", + stdout=out, + ) + + assert pbn_client.transport.input_values == {}, ( + "W trybie --dry-run transport nie powinien dostać żadnego żądania." + ) + output = out.getvalue() + assert "DRY-RUN" in output + assert "KROK 1/8" in output + assert "PODSUMOWANIE" in output + + +@pytest.mark.django_db +def test_happy_path_endpoint_publications( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, +): + """Pełen przebieg z endpointem /api/v1/publications (all-in-one).""" + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + + _patch_get_client(monkeypatch, pbn_client) + # Intended statements puste — tak samo jak PBN → identyczne → domyślnie + # nie robimy DELETE ani POST oświadczeń (default_act=False w yes_all). + _patch_intended_statements(monkeypatch, []) + pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { + "objectId": pbn_publication.pk, + } + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + + f"?publicationId={pbn_publication.pk}&size=5120" + ] = pbn_pageable_json([]) + + # --yes-all akceptuje domyślnie (Enter, yes/no z defaultem), a dla + # wyboru endpointu (niedomyślny prompt) musimy dostarczyć "1". + _patch_input(monkeypatch, ["1"]) + + out = StringIO() + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + str(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk), + "--yes-all", + stdout=out, + ) + + # POST publikacji faktycznie poszedł do endpointu publications: + assert PBN_POST_PUBLICATIONS_URL in pbn_client.transport.input_values + # Endpoint repozytoryjny nie powinien być użyty: + assert ( + PBN_POST_PUBLICATION_NO_STATEMENTS_URL not in pbn_client.transport.input_values + ) + # Porównanie oświadczeń - lokalne puste, PBN puste - identyczne, + # więc DELETE i POST /v2/statements NIE powinny się odbyć: + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk + ) + assert url_delete not in pbn_client.transport.input_values + assert PBN_POST_INSTITUTION_STATEMENTS_URL not in pbn_client.transport.input_values + + output = out.getvalue() + assert "PODSUMOWANIE" in output + assert "KROK 4/8" in output + assert "identyczne" in output + + +@pytest.mark.django_db +def test_happy_path_endpoint_repositorium( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, +): + """Pełen przebieg z endpointem /api/v1/repositorium/publications (bez oświadczeń).""" + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + + _patch_get_client(monkeypatch, pbn_client) + _patch_intended_statements(monkeypatch, []) + pbn_client.transport.return_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL] = [ + {"id": pbn_publication.pk}, + ] + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + + f"?publicationId={pbn_publication.pk}&size=5120" + ] = pbn_pageable_json([]) + + _patch_input(monkeypatch, ["2"]) + + out = StringIO() + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + str(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk), + "--yes-all", + stdout=out, + ) + + # POST faktycznie do repozytorium: + assert PBN_POST_PUBLICATION_NO_STATEMENTS_URL in pbn_client.transport.input_values + # Endpoint all-in-one NIE powinien być użyty: + assert PBN_POST_PUBLICATIONS_URL not in pbn_client.transport.input_values + + # Dodatkowo: body wysłane do repozytorium NIE ma klucza "statements" — to + # kluczowa gwarancja bezpieczeństwa narzędzia (user zastrzegł: żadnych + # nie-spec wysyłek z `statements` na endpoint repozytoryjny). + body = pbn_client.transport.input_values[PBN_POST_PUBLICATION_NO_STATEMENTS_URL][ + "body" + ] + assert isinstance(body, list) and len(body) == 1 + assert "statements" not in body[0] + + +@pytest.mark.django_db +def test_nieprawidlowa_opcja_endpointa_potem_prawidlowa( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, +): + """Po błędnej odpowiedzi na wybór endpointa pętla prosi ponownie.""" + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + + _patch_get_client(monkeypatch, pbn_client) + _patch_intended_statements(monkeypatch, []) + pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { + "objectId": pbn_publication.pk, + } + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + + f"?publicationId={pbn_publication.pk}&size=5120" + ] = pbn_pageable_json([]) + + # najpierw bzdura, potem 1 (publications) + _patch_input(monkeypatch, ["foo", "1"]) + + out = StringIO() + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + str(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk), + "--yes-all", + stdout=out, + ) + + output = out.getvalue() + assert "Nieprawidłowa opcja" in output + assert PBN_POST_PUBLICATIONS_URL in pbn_client.transport.input_values + + +@pytest.mark.django_db +def test_quit_przy_wyborze_endpointa_konczy_flow( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, +): + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + + _patch_get_client(monkeypatch, pbn_client) + _patch_input(monkeypatch, ["q"]) + + out = StringIO() + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + str(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk), + "--yes-all", + stdout=out, + ) + + # Nic nie wyszło do transportu: + assert pbn_client.transport.input_values == {} + output = out.getvalue() + assert "Przerwano" in output + assert "PODSUMOWANIE" in output + + +@pytest.mark.django_db +def test_blad_http_na_post_publikacji_nie_crashuje( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + monkeypatch, +): + """Gdy POST publikacji zwróci HttpException 423, narzędzie ma czytelnie + wypisać błąd i wrócić do podsumowania (nie robić traceback-crash).""" + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + + _patch_get_client(monkeypatch, pbn_client) + pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = HttpException( + 423, + PBN_POST_PUBLICATIONS_URL, + '{"message":"Locked","description":"Zablokowane"}', + ) + + _patch_input(monkeypatch, ["1"]) + + out = StringIO() + # Komenda powinna zakończyć się bez wyjątku — UserAbort jest łapany w handle(). + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + str(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk), + "--yes-all", + stdout=out, + ) + + output = out.getvalue() + assert "HTTP 423" in output + assert "PODSUMOWANIE" in output + # DELETE i POST /v2/statements NIE powinny się wykonać po błędzie: + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk + ) + assert url_delete not in pbn_client.transport.input_values + assert PBN_POST_INSTITUTION_STATEMENTS_URL not in pbn_client.transport.input_values + + +@pytest.mark.django_db +def test_rozne_oswiadczenia_triggeruja_delete_post_gdy_user_zgodzi( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_jednostka, + monkeypatch, +): + """Gdy lokalne oświadczenia różnią się od PBN i user zgodzi się, + narzędzie wysyła DELETE a następnie POST /v2/statements.""" + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + + _patch_get_client(monkeypatch, pbn_client) + pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { + "objectId": pbn_publication.pk, + } + # PBN zwraca 1 oświadczenie, które lokalnie nie istnieje → różnica + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + + f"?publicationId={pbn_publication.pk}&size=5120" + ] = pbn_pageable_json( + [ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "addedTimestamp": "2020.05.06", + "institutionId": pbn_jednostka.pbn_uid_id, + "personId": pbn_autor.pbn_uid_id, + "publicationId": pbn_publication.pk, + "area": "999", + "inOrcid": True, + "type": "AUTHOR", + } + ] + ) + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk + ) + pbn_client.transport.return_values[url_delete] = [] + pbn_client.transport.return_values[PBN_POST_INSTITUTION_STATEMENTS_URL] = { + "data": [] + } + + # Podmieniamy pbn_get_api_statements żeby nie wymagał PublikacjaInstytucji_V2 + # (ten fixture go nie tworzy). Zwracamy sztuczny payload. + from pbn_api.adapters.wydawnictwo import WydawnictwoPBNAdapter + + monkeypatch.setattr( + WydawnictwoPBNAdapter, + "pbn_get_api_statements", + lambda self: { + "publicationUuid": "00000000-0000-0000-0000-000000000001", + "statements": [{"personId": "x"}], + }, + ) + + # 1=publications, 't'=tak (skasuj i wyślij) + _patch_input(monkeypatch, ["1", "t"]) + + out = StringIO() + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + str(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk), + "--yes-all", + stdout=out, + ) + + # DELETE i POST /v2/statements poszły: + assert url_delete in pbn_client.transport.input_values + assert PBN_POST_INSTITUTION_STATEMENTS_URL in pbn_client.transport.input_values + assert pbn_client.transport.input_values[url_delete]["delete"] is True + + output = out.getvalue() + assert "KROK 7/8" in output + assert "KROK 8/8" in output + + +@pytest.mark.django_db +def test_odmowa_delete_post_gdy_sa_roznice_ale_user_nie_chce( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_jednostka, + monkeypatch, +): + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + + _patch_get_client(monkeypatch, pbn_client) + pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { + "objectId": pbn_publication.pk, + } + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + + f"?publicationId={pbn_publication.pk}&size=5120" + ] = pbn_pageable_json( + [ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "institutionId": pbn_jednostka.pbn_uid_id, + "personId": pbn_autor.pbn_uid_id, + "publicationId": pbn_publication.pk, + "area": "999", + "inOrcid": True, + "type": "AUTHOR", + } + ] + ) + + # Pełen flow bez --yes-all, bo chcemy dać user-owi odpowiedź "n" na + # dwa pytania (DELETE i POST). yes_all ignoruje decyzje yes/no i + # wraca do defaultów, a default_act=True (są różnice) → DELETE+POST + # by się wykonały. Lista odpowiedzi (po kolei): + # 1) Enter po KROK 1 + # 2) "" (n na preview JSON, default=False) + # 3) Enter po KROK 2 + # 4) "1" — wybór endpointa: all-in-one + # 5) "" (Wyślij teraz? default=True) + # 6) Enter po KROK 4 + # 7) Enter po KROK 5 + # 8) Enter po KROK 6 + # 9) "n" — nie kasuj oświadczeń (DELETE) + # 10) "n" — nie wysyłaj oświadczeń (POST) + _patch_input(monkeypatch, ["", "", "", "1", "", "", "", "", "n", "n"]) + + out = StringIO() + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + str(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk), + stdout=out, + ) + + # DELETE nie powinien się wykonać: + url_delete = PBN_DELETE_PUBLICATION_STATEMENT.format( + publicationId=pbn_publication.pk + ) + assert url_delete not in pbn_client.transport.input_values + assert PBN_POST_INSTITUTION_STATEMENTS_URL not in pbn_client.transport.input_values + + output = out.getvalue() + assert "decyzja użytkownika" in output + + +@pytest.mark.django_db +def test_compare_uses_intended_not_cache_bug1( + pbn_client, + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina, + pbn_publication, + pbn_autor, + pbn_jednostka, + monkeypatch, +): + """Regression BUG 1: narzędzie ma porównywać intended (adapter) z PBN, + NIE cache OswiadczenieInstytucji. + + Scenariusz: cache (OswiadczenieInstytucji) ma 1 rekord (stary stan), + PBN zwraca ten sam 1 rekord. Intended z adaptera zwraca PUSTĄ listę + (user skasował autora lokalnie). Stary kod pokazałby "identyczne" + (cache 1 == PBN 1). Nowy kod musi pokazać "różnice" (intended 0 ≠ PBN 1). + """ + from datetime import date + + from model_bakery import baker + + from pbn_api.models import OswiadczenieInstytucji + + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pbn_uid = pbn_publication + pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.save() + + # Cache (stary stan, niesprzątnięty) — 1 rekord OswiadczenieInstytucji: + baker.make( + OswiadczenieInstytucji, + publicationId=pbn_publication, + personId=pbn_autor.pbn_uid, + institutionId=pbn_jednostka.pbn_uid, + area=100, + addedTimestamp=date(2020, 1, 1), + ) + assert OswiadczenieInstytucji.objects.count() == 1 + + # Intended BPP (live) — PUSTE (jakby user skasował autora z rekordu): + _patch_intended_statements(monkeypatch, []) + + _patch_get_client(monkeypatch, pbn_client) + pbn_client.transport.return_values[PBN_POST_PUBLICATIONS_URL] = { + "objectId": pbn_publication.pk, + } + # PBN zwraca 1 oświadczenie (zgodne z cache): + pbn_client.transport.return_values[ + PBN_GET_INSTITUTION_STATEMENTS + + f"?publicationId={pbn_publication.pk}&size=5120" + ] = pbn_pageable_json( + [ + { + "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "institutionId": pbn_jednostka.pbn_uid_id, + "personId": pbn_autor.pbn_uid_id, + "publicationId": pbn_publication.pk, + "area": "100", + "type": "AUTHOR", + "inOrcid": True, + } + ] + ) + + # Wybieramy endpoint 1, nie kasujemy/nie wysyłamy (chcemy sprawdzić + # tylko KROK 6/8 output). + _patch_input( + monkeypatch, + ["", "", "", "1", "", "", "", "", "n", "n"], + ) + + out = StringIO() + call_command( + "pbn_test_wysylka_interaktywna", + "--wydawnictwo-zwarte", + str(pbn_wydawnictwo_zwarte_z_autorem_z_dyscyplina.pk), + stdout=out, + ) + + output = out.getvalue() + # Musi pokazać intencja 0 vs PBN 1 → różnice (NIE identyczne): + assert "Intencja BPP (live): 0" in output + assert "Aktualnie w PBN: 1" in output + assert "Tylko w PBN (do usunięcia): 1" in output + # A NIE pokazać że są identyczne (to byłby stary bug): + assert "Porównanie → różnice" in output + + +def test_json_truncated_obcina_dlugi_tekst(): + big = {"key": "x" * 5000} + result = cmd_mod._json_truncated(big, max_len=100) + assert len(result) < 500 + assert "obcięto" in result + + +def test_json_truncated_nie_obcina_krotkiego_tekstu(): + small = {"a": 1} + result = cmd_mod._json_truncated(small, max_len=100) + assert "obcięto" not in result + assert '"a": 1' in result diff --git a/src/pbn_api/tests/utils.py b/src/pbn_api/tests/utils.py index 863132c3f..c4f39b20d 100644 --- a/src/pbn_api/tests/utils.py +++ b/src/pbn_api/tests/utils.py @@ -33,6 +33,7 @@ class MockTransport(RequestsTransport): def __init__(self, return_values=None): self.return_values = {} self.input_values = {} + self.base_url = "https://pbn-test.example.org" if return_values: self.return_values.update(return_values) diff --git a/src/pbn_export_queue/models.py b/src/pbn_export_queue/models.py index 725e4ddc5..234fef4aa 100644 --- a/src/pbn_export_queue/models.py +++ b/src/pbn_export_queue/models.py @@ -22,6 +22,7 @@ PKZeroExportDisabled, PraceSerwisoweException, ResourceLockedException, + StatementsResendFailedException, WillNotExportError, ) @@ -241,6 +242,19 @@ def _handle_retry_exception(self, exc): self.save() return SendStatus.RETRY_LATER + if isinstance(exc, StatementsResendFailedException): + # Publikacja została wysłana do PBN (POST /repositorium OK), + # ale synchronizacja oświadczeń (GET/DELETE/POST /v2) wyczerpała + # retry w sync_publication. Ponawiamy po kilku minutach — + # zwykle chwilowa niedostępność PBN. + self.dopisz_komunikat( + f"Synchronizacja oświadczeń nie powiodła się po wyczerpaniu prób " + f"(PBN UID={exc.pbn_uid}): {exc.last_error}. " + f"Ponowię wysyłkę za kilka minut..." + ) + self.save() + return SendStatus.RETRY_LATER + return None def _handle_exclude_exception(self, exc): diff --git a/src/pbn_export_queue/templates/pbn_export_queue/pbn_export_queue_detail.html b/src/pbn_export_queue/templates/pbn_export_queue/pbn_export_queue_detail.html index 0fc7a772a..dbb9015be 100644 --- a/src/pbn_export_queue/templates/pbn_export_queue/pbn_export_queue_detail.html +++ b/src/pbn_export_queue/templates/pbn_export_queue/pbn_export_queue_detail.html @@ -283,6 +283,13 @@

Linki do wyslanego rekordu

Wyslane dane JSON

+ {% if sent_data.api_url %} +

+ Wyslano do: + {{ sent_data.api_url }} +

+ {% endif %} +
Pokaz/ukryj wyslane dane JSON diff --git a/src/pbn_export_queue/tests/test_pbn_queue_send.py b/src/pbn_export_queue/tests/test_pbn_queue_send.py index 2f87db3b0..05a1c0751 100644 --- a/src/pbn_export_queue/tests/test_pbn_queue_send.py +++ b/src/pbn_export_queue/tests/test_pbn_queue_send.py @@ -20,6 +20,7 @@ PKZeroExportDisabled, PraceSerwisoweException, StatementsMissing, + StatementsResendFailedException, ) from pbn_export_queue.models import PBN_Export_Queue, RodzajBledu, SendStatus @@ -64,6 +65,39 @@ def test_send_to_pbn_record_deleted_but_already_finished( result = queue_item.send_to_pbn() assert result == SendStatus.FINISHED_OKAY + def test_send_to_pbn_statements_resend_failed_exception( + self, wydawnictwo_ciagle, admin_user + ): + """Test RETRY_LATER when StatementsResendFailedException is raised. + + Scenariusz: POST publikacji do /repositorium powiódł się, ale + kolejne kroki (GET/DELETE/POST /v2/statements) wyczerpały retry + w sync_publication. Kolejka ma ponowić za kilka minut. + """ + queue_item = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + ) + + with patch( + "bpp.admin.helpers.pbn_api.cli.sprobuj_wyslac_do_pbn_celery" + ) as mock_send: + mock_send.side_effect = StatementsResendFailedException( + publication_pk=wydawnictwo_ciagle.pk, + pbn_uid="abc-123", + last_error="HTTP 500: Server Error", + ) + + result = queue_item.send_to_pbn() + + assert result == SendStatus.RETRY_LATER + queue_item.refresh_from_db() + assert queue_item.ilosc_prob == 1 + assert "Synchronizacja oświadczeń" in queue_item.komunikat + assert "abc-123" in queue_item.komunikat + assert "Ponowię" in queue_item.komunikat + def test_send_to_pbn_prace_serwisowe_exception( self, wydawnictwo_ciagle, admin_user ): @@ -330,9 +364,7 @@ class TestHttpExceptionValidationClassification: where validation errors were incorrectly classified as technical errors. """ - def test_http_400_with_details_isbn_duplicate( - self, wydawnictwo_ciagle, admin_user - ): + def test_http_400_with_details_isbn_duplicate(self, wydawnictwo_ciagle, admin_user): """ISBN duplicate error should be MERYTORYCZNY (regression test for ID 20)""" queue_item = baker.make( PBN_Export_Queue, @@ -349,9 +381,7 @@ def test_http_400_with_details_isbn_duplicate( "message": "Bad Request", "description": "Validation failed.", "details": { - "isbn": ( - "Publikacja o identycznym ISBN lub ISMN już istnieje!" - ) + "isbn": ("Publikacja o identycznym ISBN lub ISMN już istnieje!") }, } ) @@ -364,9 +394,7 @@ def test_http_400_with_details_isbn_duplicate( assert queue_item.rodzaj_bledu == RodzajBledu.MERYTORYCZNY assert "Błąd walidacji po stronie PBN" in queue_item.komunikat - def test_http_400_with_details_doi_duplicate( - self, wydawnictwo_ciagle, admin_user - ): + def test_http_400_with_details_doi_duplicate(self, wydawnictwo_ciagle, admin_user): """DOI duplicate error should be MERYTORYCZNY (regression test for ID 19)""" queue_item = baker.make( PBN_Export_Queue, @@ -395,9 +423,7 @@ def test_http_400_with_details_doi_duplicate( queue_item.refresh_from_db() assert queue_item.rodzaj_bledu == RodzajBledu.MERYTORYCZNY - def test_http_400_with_details_invalid_year( - self, wydawnictwo_ciagle, admin_user - ): + def test_http_400_with_details_invalid_year(self, wydawnictwo_ciagle, admin_user): """Invalid year error should be MERYTORYCZNY (regression test for ID 11)""" queue_item = baker.make( PBN_Export_Queue, @@ -415,8 +441,7 @@ def test_http_400_with_details_invalid_year( "description": "Validation failed.", "details": { "year": ( - "Rok publikacji nie może być późniejszy" - " od roku bieżącego!" + "Rok publikacji nie może być późniejszy od roku bieżącego!" ) }, } @@ -452,8 +477,7 @@ def test_http_400_with_details_missing_book_id( "description": "Validation failed.", "details": { "book.id": ( - "Identyfikator źródła rozdziału (książki)" - " jest wymagany!" + "Identyfikator źródła rozdziału (książki) jest wymagany!" ) }, } @@ -549,9 +573,7 @@ def test_http_500_stays_techniczny(self, wydawnictwo_ciagle, admin_user): with patch( "bpp.admin.helpers.pbn_api.cli.sprobuj_wyslac_do_pbn_celery" ) as mock: - error_json = json.dumps( - {"code": 500, "message": "Internal Server Error"} - ) + error_json = json.dumps({"code": 500, "message": "Internal Server Error"}) mock.side_effect = HttpException(500, "/api/v1/publications", error_json) result = queue_item.send_to_pbn() @@ -560,9 +582,7 @@ def test_http_500_stays_techniczny(self, wydawnictwo_ciagle, admin_user): queue_item.refresh_from_db() assert queue_item.rodzaj_bledu == RodzajBledu.TECHNICZNY - def test_statements_missing_is_merytoryczny( - self, wydawnictwo_ciagle, admin_user - ): + def test_statements_missing_is_merytoryczny(self, wydawnictwo_ciagle, admin_user): """ StatementsMissing error should be MERYTORYCZNY (business error - missing author statements/disciplines) diff --git a/src/pbn_export_queue/tests/test_views_detail.py b/src/pbn_export_queue/tests/test_views_detail.py index 9958d31aa..90d9ce1ce 100644 --- a/src/pbn_export_queue/tests/test_views_detail.py +++ b/src/pbn_export_queue/tests/test_views_detail.py @@ -222,6 +222,7 @@ def test_build_ai_prompt(admin_user, wydawnictwo_ciagle): '\'{"message": "Validation failed", "description": "Invalid data"}\')' ) sent_data.api_response_status = 400 + sent_data.api_url = "https://pbn-test.example.org/api/v1/repositorium/publications" request = RequestFactory().get("/") request.user = admin_user @@ -238,3 +239,39 @@ def test_build_ai_prompt(admin_user, wydawnictwo_ciagle): assert '{"test": "data"}' in result assert "# KONTEKST" in result assert "# ZADANIE" in result + assert "https://pbn-test.example.org/api/v1/repositorium/publications" in result + assert "Endpoint:" in result + + +@pytest.mark.django_db +def test_build_ai_prompt_fallback_endpoint_z_uczelni( + admin_user, wydawnictwo_ciagle, uczelnia +): + """Gdy sent_data.api_url jest puste, prompt korzysta z aktualnej konfiguracji Uczelni.""" + from unittest.mock import Mock + + uczelnia.pbn_api_root = "https://pbn-fallback.example.org" + uczelnia.save() + + queue_item = baker.make( + PBN_Export_Queue, + rekord_do_wysylki=wydawnictwo_ciagle, + zamowil=admin_user, + komunikat="", + ) + + sent_data = Mock() + sent_data.exception = None + sent_data.api_response_status = None + sent_data.api_url = None + + request = RequestFactory().get("/") + request.user = admin_user + + view = PBNExportQueueDetailView() + view.request = request + view.object = queue_item + + result = view._build_ai_prompt(sent_data, "Test Title", "{}") + + assert "https://pbn-fallback.example.org/api/v1/repositorium/publications" in result diff --git a/src/pbn_export_queue/views/constants.py b/src/pbn_export_queue/views/constants.py index 32151dcae..8612e7a59 100644 --- a/src/pbn_export_queue/views/constants.py +++ b/src/pbn_export_queue/views/constants.py @@ -44,6 +44,8 @@ co jest nie tak z wysyłanymi danymi. # DANE WYSŁANE DO PBN API +- Endpoint: {api_endpoint} +- Dane JSON: ```json {json_data} ``` @@ -61,7 +63,9 @@ 3. Jak poprawić dane, aby eksport się powiódł? # DOKUMENTACJA -Dokumentacja API PBN jest dostępna pod adresem: https://pbn.nauka.gov.pl/api/ +Specyfikacja OpenAPI / Swagger dla PBN API v3: +https://pbn.nauka.gov.pl/api/v3/api-docs +(zawiera definicje wszystkich endpointów, schematy JSON, kody błędów) Proszę o szczegółową analizę i konkretne wskazówki naprawcze. """ diff --git a/src/pbn_export_queue/views/detail_views.py b/src/pbn_export_queue/views/detail_views.py index 0e1f4efe6..045c89ea3 100644 --- a/src/pbn_export_queue/views/detail_views.py +++ b/src/pbn_export_queue/views/detail_views.py @@ -141,6 +141,22 @@ def _extract_error_from_komunikat(self): return error_code, error_details + def _resolve_api_endpoint(self, sent_data): + """Return endpoint URL stored in SentData, with fallback to current Uczelnia config.""" + if sent_data.api_url: + return sent_data.api_url + + from bpp.models import Uczelnia + from pbn_api.const import PBN_POST_PUBLICATION_NO_STATEMENTS_URL + + uczelnia = Uczelnia.objects.get_default() + if uczelnia and uczelnia.pbn_api_root: + return ( + uczelnia.pbn_api_root.rstrip("/") + + PBN_POST_PUBLICATION_NO_STATEMENTS_URL + ) + return "Brak informacji o endpoincie" + def _build_ai_prompt( self, sent_data, @@ -173,6 +189,7 @@ def _build_ai_prompt( error_code=ai_error_code, error_details=ai_error_details, record_title=record_title, + api_endpoint=self._resolve_api_endpoint(sent_data), ) def _add_sent_data_context(self, context): diff --git a/src/pbn_integrator/management/commands/pbn_integrator.py b/src/pbn_integrator/management/commands/pbn_integrator.py index 156f2fdc3..7643273c7 100644 --- a/src/pbn_integrator/management/commands/pbn_integrator.py +++ b/src/pbn_integrator/management/commands/pbn_integrator.py @@ -9,10 +9,12 @@ django.setup() -from pbn_api.exceptions import IntegracjaWylaczonaException -from pbn_api.management.commands.util import PBNBaseCommand -from pbn_integrator import utils as integrator -from pbn_integrator.utils import ( +# Importy poniżej muszą być po django.setup() — stąd noqa E402. +from bpp.models import Uczelnia # noqa: E402 +from pbn_api.exceptions import IntegracjaWylaczonaException # noqa: E402 +from pbn_api.management.commands.util import PBNBaseCommand # noqa: E402 +from pbn_integrator import utils as integrator # noqa: E402 +from pbn_integrator.utils import ( # noqa: E402 integruj_autorow_z_uczelni, integruj_instytucje, integruj_jezyki, @@ -42,8 +44,6 @@ wyswietl_niezmatchowane_ze_zblizonymi_tytulami, ) -from bpp.models import Uczelnia - def check_end_before(stage, end_before_stage): if end_before_stage == stage: @@ -54,13 +54,15 @@ class Command(PBNBaseCommand): def add_arguments(self, parser): super().add_arguments(parser) - parser.add_argument( - "--disable-multiprocessing", action="store_true", default=False - ), + ( + parser.add_argument( + "--disable-multiprocessing", action="store_true", default=False + ), + ) parser.add_argument("--start-from-stage", type=int, default=0) parser.add_argument("--end-before-stage", type=int, default=None) - parser.add_argument("--just-one-stage", action="store_true"), + (parser.add_argument("--just-one-stage", action="store_true"),) parser.add_argument("--clear-all", action="store_true", default=False) parser.add_argument("--clear-publications", action="store_true", default=False) @@ -136,9 +138,6 @@ def add_arguments(self, parser): parser.add_argument("--force-upload", action="store_true", default=False) parser.add_argument("--only-bad", action="store_true", default=False) parser.add_argument("--only-new", action="store_true", default=False) - parser.add_argument( - "--delete-statements-before-upload", action="store_true", default=None - ) parser.add_argument( "--export-pk-zero", action="store_true", @@ -148,7 +147,7 @@ def add_arguments(self, parser): "--disable-progress-bar", action="store_true", default=False ) - def handle( + def handle( # noqa: C901 self, app_id, app_token, @@ -190,10 +189,9 @@ def handle( only_bad, only_new, disable_progress_bar, - delete_statements_before_upload, export_pk_zero, *args, - **options + **options, ): if disable_multiprocessing: integrator.CPU_COUNT = "single" @@ -416,14 +414,10 @@ def handle( if export_pk_zero is None: export_pk_zero = not uczelnia.pbn_api_nie_wysylaj_prac_bez_pk - if delete_statements_before_upload is None: - delete_statements_before_upload = uczelnia.pbn_api_kasuj_przed_wysylka - synchronizuj_publikacje( client=client, force_upload=force_upload, only_bad=only_bad, only_new=only_new, - delete_statements_before_upload=delete_statements_before_upload, export_pk_zero=export_pk_zero, ) diff --git a/src/pbn_integrator/utils/synchronization.py b/src/pbn_integrator/utils/synchronization.py index 4dcb2780c..fa41eaf09 100644 --- a/src/pbn_integrator/utils/synchronization.py +++ b/src/pbn_integrator/utils/synchronization.py @@ -78,7 +78,6 @@ def _synchronizuj_pojedyncza_publikacje( # noqa: C901 rec, force_upload=False, export_pk_zero=None, - delete_statements_before_upload=False, ): """Synchronize a single publication to PBN. @@ -87,14 +86,12 @@ def _synchronizuj_pojedyncza_publikacje( # noqa: C901 rec: BPP record. force_upload: Whether to force upload. export_pk_zero: Export pk zero setting. - delete_statements_before_upload: Whether to delete statements before upload. """ try: client.sync_publication( rec, force_upload=force_upload, export_pk_zero=export_pk_zero, - delete_statements_before_upload=delete_statements_before_upload, ) except SameDataUploadedRecently: pass @@ -182,7 +179,6 @@ def synchronizuj_publikacje( only_new=False, skip=0, export_pk_zero=None, - delete_statements_before_upload=False, ): """Synchronize publications to PBN. @@ -196,7 +192,6 @@ def synchronizuj_publikacje( only_new: Export only records that don't have an entry in SentData. skip: Number of records to skip. export_pk_zero: Export pk zero setting. - delete_statements_before_upload: Whether to delete statements before upload. """ assert not (only_bad and only_new), "Te parametry wykluczają się wzajemnie" # @@ -225,7 +220,6 @@ def synchronizuj_publikacje( client, rec, force_upload=force_upload, - delete_statements_before_upload=delete_statements_before_upload, export_pk_zero=export_pk_zero, ) @@ -237,7 +231,6 @@ def synchronizuj_publikacje( client, rec, force_upload=force_upload, - delete_statements_before_upload=delete_statements_before_upload, export_pk_zero=export_pk_zero, ) @@ -267,7 +260,6 @@ def synchronizuj_publikacje( client, rec, force_upload=force_upload, - delete_statements_before_upload=delete_statements_before_upload, export_pk_zero=export_pk_zero, )