From 147329c156817e1bf3688fc1485416a17e71182a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 15 Jul 2026 14:54:32 +0200 Subject: [PATCH] =?UTF-8?q?D3:=20sync=20s=C5=82ownika=20dyscyplin=20przez?= =?UTF-8?q?=20django-pbn-client.sync=5Fdictionary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit download_disciplines/sync_disciplines przestają trzymać otwartą transakcję przez czas remote-calla do PBN. Pobranie idzie teraz przez sync_dictionary(fetch, upsert) (wzorzec materialize-before-atomic z django-pbn-client 0.2.1): fetch poza transakcją, upsert atomowo. Matching do modeli BPP (Dyscyplina_Naukowa/TlumaczDyscyplin) wydzielony do osobnej @transaction.atomic metody — zostaje w BPP (BPP-specific). Usunięto martwy `if discipline.name == "weterynaria": pass`. Test-strażnik (transaction=True) dowodzi, że fetch dzieje się poza transakcją. Zależność django-pbn-client 0.2.1 pinowana git-source (do usunięcia po publikacji na PyPI). Stack: na feat/pbn-p4-reader. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 7 ++- .../pbn-d3-sync-dictionary.bugfix.rst | 5 ++ src/pbn_api/client/disciplines.py | 47 ++++++++++++++--- src/pbn_api/tests/test_client_disciplines.py | 52 +++++++++++++++---- uv.lock | 10 ++-- 5 files changed, 93 insertions(+), 28 deletions(-) create mode 100644 src/bpp/newsfragments/pbn-d3-sync-dictionary.bugfix.rst diff --git a/pyproject.toml b/pyproject.toml index ec30e3da7..c7852b4bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -311,10 +311,13 @@ default = true # WYCOFAĆ gdy upstream wyda release z #2156 → wróć na PyPI `>=4.5`. Tracking: #280. [tool.uv.sources] django-import-export = { git = "https://github.com/mpasternak/django-import-export.git", rev = "d6ee0d39194fee31437affbdc6fee5ce549b4b8f" } -# TYMCZASOWO: pbn-client 0.3.0 (ErrorRecord) nie jest jeszcze na PyPI. Pin do +# TYMCZASOWO: pbn-client 0.2.1 (ErrorRecord) nie jest jeszcze na PyPI. Pin do # commita gałęzi feat/error-record, żeby CI/lokalnie rozwiązać zależność. -# USUŃ po wydaniu pbn-client 0.3.0 na PyPI (wtedy sam pin >=0.3,<0.4 wystarczy). +# USUŃ po wydaniu pbn-client 0.2.1 na PyPI (wtedy sam pin >=0.2.1,<0.3 wystarczy). pbn-client = { git = "https://github.com/iplweb/pbn-client.git", rev = "84503cf41b308bc86edc6f8709b71383a8676cac" } +# TYMCZASOWO: django-pbn-client 0.2.1 (sync_dictionary, D3) nie jest jeszcze na +# PyPI. Pin do commita gałęzi feat/sync-dictionary. USUŃ po wydaniu 0.2.1. +django-pbn-client = { git = "https://github.com/iplweb/django-pbn-client.git", rev = "9b2164f889861db9292b91508debf0732211bb39" } # Konfiguracja `pytest-testcontainers-django` — pluginu pytest, ktory # startuje kontenery PG/Redis przed importem Django settings i wstrzykuje diff --git a/src/bpp/newsfragments/pbn-d3-sync-dictionary.bugfix.rst b/src/bpp/newsfragments/pbn-d3-sync-dictionary.bugfix.rst new file mode 100644 index 000000000..e1ba00cb6 --- /dev/null +++ b/src/bpp/newsfragments/pbn-d3-sync-dictionary.bugfix.rst @@ -0,0 +1,5 @@ +Synchronizacja słownika dyscyplin z PBN nie trzyma już otwartej transakcji +bazodanowej przez cały czas pobierania danych z PBN. Pobranie (remote) wykonuje +się teraz przed otwarciem transakcji (wzorzec ``sync_dictionary`` z pakietu +``django-pbn-client``), a zapis leci atomowo — dłuższa niedostępność PBN nie +blokuje już połączenia bazodanowego. diff --git a/src/pbn_api/client/disciplines.py b/src/pbn_api/client/disciplines.py index 7b195c774..be99f5053 100644 --- a/src/pbn_api/client/disciplines.py +++ b/src/pbn_api/client/disciplines.py @@ -1,6 +1,13 @@ -"""Disciplines synchronization mixin for PBN API client.""" +"""Disciplines synchronization mixin for PBN API client. + +Pobranie słownika z PBN korzysta z ``django_pbn_client.sync_dictionary`` +(materialize-before-atomic): remote-fetch wykonuje się PRZED transakcją, a +upsert do lokalnych modeli — w świeżym bloku atomic. Wcześniej +``@transaction.atomic`` obejmował cały remote-call. +""" from django.db import transaction +from django_pbn_client import sync_dictionary from import_common.core import ( matchuj_aktualna_dyscypline_pbn, @@ -14,11 +21,21 @@ class DisciplinesMixin: """Mixin providing discipline synchronization methods.""" - @transaction.atomic def download_disciplines(self): - """Zapisuje słownik dyscyplin z API PBN do lokalnej bazy""" + """Pobierz słownik dyscyplin z API PBN i zapisz do lokalnej bazy. + + Remote-fetch (``get_disciplines``) wykonuje się poza transakcją; sam + zapis leci atomowo (patrz ``sync_dictionary``). + """ + sync_dictionary(self.get_disciplines, self._upsert_disciplines) + + def _upsert_disciplines(self, elems): + """Upsert pobranego słownika do ``DisciplineGroup``/``Discipline``. - for elem in self.get_disciplines(): + Wołane WEWNĄTRZ transakcji otwartej przez ``sync_dictionary`` — bez + własnego ``@transaction.atomic``. + """ + for elem in elems: validityDateFrom = elem.get("validityDateFrom", None) validityDateTo = elem.get("validityDateTo", None) uuid = elem["uuid"] @@ -43,9 +60,25 @@ def download_disciplines(self): ), ) - @transaction.atomic def sync_disciplines(self): + """Pobierz słownik i zsynchronizuj tłumaczenia dyscyplin BPP. + + Remote-fetch (``download_disciplines``) jest transakcyjnie bezpieczny; + dopasowanie do modeli BPP leci w OSOBNEJ transakcji + (``_sync_discipline_translations``). Remote-call NIE jest już + obejmowany transakcją (wcześniejszy ``@transaction.atomic`` na całej + metodzie trzymał ją otwartą przez czas pobierania z PBN). + """ self.download_disciplines() + self._sync_discipline_translations() + + @transaction.atomic + def _sync_discipline_translations(self): + """Dopasuj aktualny słownik PBN do modeli BPP. + + Matching (``Dyscyplina_Naukowa``/``TlumaczDyscyplin``) jest BPP-specific + i celowo pozostaje w BPP (nie w pakiecie). + """ try: cur_dg = DisciplineGroup.objects.get_current() except DisciplineGroup.DoesNotExist as e: @@ -76,9 +109,7 @@ def sync_disciplines(self): wpis_tlumacza.save() for discipline in cur_dg.discipline_set.all(): - if discipline.name == "weterynaria": - pass - # Każda dyscyplina z aktualnego słownika powinna być wpisana do systemu BPP + # Każda dyscyplina z aktualnego słownika powinna być wpisana do BPP try: TlumaczDyscyplin.objects.get(pbn_2024_now=discipline) except TlumaczDyscyplin.DoesNotExist: diff --git a/src/pbn_api/tests/test_client_disciplines.py b/src/pbn_api/tests/test_client_disciplines.py index e6d186f53..53b64267b 100644 --- a/src/pbn_api/tests/test_client_disciplines.py +++ b/src/pbn_api/tests/test_client_disciplines.py @@ -9,6 +9,7 @@ from pathlib import Path import pytest +from django.db import connection from pbn_client.const import PBN_GET_DISCIPLINES_URL from bpp.decorators import json @@ -17,23 +18,23 @@ from pbn_api.models.discipline import Discipline -def test_get_disciplines(pbn_client): +def _load_disciplines_fixture(pbn_client): fixture_path = Path(__file__).parent / "fixture_test_get_disciplines.json" with open(fixture_path, "rb") as f: pbn_client.transport.return_values[PBN_GET_DISCIPLINES_URL] = json.loads( f.read() ) + + +def test_get_disciplines(pbn_client): + _load_disciplines_fixture(pbn_client) ret = pbn_client.get_disciplines() assert "validityDateFrom" in ret[0] @pytest.mark.django_db def test_download_disciplines(pbn_client): - fixture_path = Path(__file__).parent / "fixture_test_get_disciplines.json" - with open(fixture_path, "rb") as f: - pbn_client.transport.return_values[PBN_GET_DISCIPLINES_URL] = json.loads( - f.read() - ) + _load_disciplines_fixture(pbn_client) assert Discipline.objects.count() == 0 pbn_client.download_disciplines() @@ -42,11 +43,7 @@ def test_download_disciplines(pbn_client): @pytest.mark.django_db def test_sync_disciplines(pbn_client): - fixture_path = Path(__file__).parent / "fixture_test_get_disciplines.json" - with open(fixture_path, "rb") as f: - pbn_client.transport.return_values[PBN_GET_DISCIPLINES_URL] = json.loads( - f.read() - ) + _load_disciplines_fixture(pbn_client) d1 = Dyscyplina_Naukowa.objects.create(kod="5.1", nazwa="ekonomia i finanse") d2 = Dyscyplina_Naukowa.objects.create(kod="1.6", nazwa="nauka o kulturze") @@ -60,3 +57,36 @@ def test_sync_disciplines(pbn_client): assert TlumaczDyscyplin.objects.przetlumacz_dyscypline(d1, 2024) is not None assert TlumaczDyscyplin.objects.przetlumacz_dyscypline(d2, 2024) is not None + + +@pytest.mark.django_db(transaction=True) +def test_download_disciplines_fetches_outside_transaction(pbn_client): + """D3/bugfix: remote-fetch (get_disciplines) NIE może dziać się w otwartej + transakcji (wcześniej @transaction.atomic obejmował cały remote-call). + + ``transaction=True`` sprawia, że sam test nie owija się w atomic, więc + ``connection.in_atomic_block`` w trakcie fetchu wiarygodnie odzwierciedla + brak transakcji; upsert leci już wewnątrz transakcji sync_dictionary. + """ + _load_disciplines_fixture(pbn_client) + + seen = {} + original_get = pbn_client.get_disciplines + + def spying_get_disciplines(): + seen["fetch_in_atomic"] = connection.in_atomic_block + return original_get() + + original_upsert = pbn_client._upsert_disciplines + + def spying_upsert(elems): + seen["upsert_in_atomic"] = connection.in_atomic_block + return original_upsert(elems) + + pbn_client.get_disciplines = spying_get_disciplines + pbn_client._upsert_disciplines = spying_upsert + + pbn_client.download_disciplines() + + assert seen["fetch_in_atomic"] is False # remote POZA transakcją + assert seen["upsert_in_atomic"] is True # zapis WEWNĄTRZ transakcji diff --git a/uv.lock b/uv.lock index 86b7d8af9..8e7c77eea 100644 --- a/uv.lock +++ b/uv.lock @@ -615,7 +615,7 @@ requires-dist = [ { name = "django-multiseek", specifier = "==0.10.2" }, { name = "django-oauth-toolkit", specifier = "==3.3.0" }, { name = "django-password-policies-iplweb", specifier = "==0.9.0" }, - { name = "django-pbn-client", specifier = ">=0.2,<0.3" }, + { name = "django-pbn-client", git = "https://github.com/iplweb/django-pbn-client.git?rev=9b2164f889861db9292b91508debf0732211bb39" }, { name = "django-pg-baseline", specifier = ">=0.3.0" }, { name = "django-polish-inflection", specifier = ">=0.1,<0.2" }, { name = "django-prometheus", specifier = ">=2.5.0" }, @@ -2104,16 +2104,12 @@ wheels = [ [[package]] name = "django-pbn-client" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } +version = "0.2.1" +source = { git = "https://github.com/iplweb/django-pbn-client.git?rev=9b2164f889861db9292b91508debf0732211bb39#9b2164f889861db9292b91508debf0732211bb39" } dependencies = [ { name = "django", marker = "platform_python_implementation != 'PyPy'" }, { name = "pbn-client", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/22/2ef927450e8b20bc27e25010ba2e801c4b5f0b38614266dc897c3fad2ec6/django_pbn_client-0.2.0.tar.gz", hash = "sha256:75b1079475e2b3c23a9e92c95f577ff4935f71df37a9c901e9d9638626acf1f2", size = 13687, upload-time = "2026-07-14T17:30:19.328Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/b5/03b2e79e6de2ca826cd8b912374c5e3e07356d924872c215737e1c4baf17/django_pbn_client-0.2.0-py3-none-any.whl", hash = "sha256:b6d993cbcfef45adcaf62690ddf797f8bf7b3b8fb804f2d557aae2192fd26db6", size = 10524, upload-time = "2026-07-14T17:30:18.406Z" }, -] [[package]] name = "django-pg-baseline"